feat: add AppState with click routing and derived detection/export
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
99cafc0b55
commit
4b19c7ea4e
2 files changed
+236
No files matched your search
@@ -0,0 +1,116 @@
|
|||||||
|
import { detect, otsuThreshold } from './detect';
|
||||||
|
import { prepareForExport } from './geometry';
|
||||||
|
import { loadPhoto } from './load-image';
|
||||||
|
import { shapeFromPolygons } from './margin';
|
||||||
|
import type { CV } from './opencv-loader';
|
||||||
|
import { scaleFromLine } from './scale';
|
||||||
|
import type { Point, RasterImage, Shape } from './types';
|
||||||
|
|
||||||
|
export type Tool = 'none' | 'scale' | 'seed' | 'draw';
|
||||||
|
|
||||||
|
const DEFAULT_THRESHOLD = 128;
|
||||||
|
|
||||||
|
export class AppState {
|
||||||
|
// Raw: the cv module and pixel buffers must never be wrapped in reactive proxies.
|
||||||
|
cv = $state.raw<CV | null>(null);
|
||||||
|
cvError = $state<string | null>(null);
|
||||||
|
image = $state.raw<RasterImage | null>(null);
|
||||||
|
loadError = $state<string | null>(null);
|
||||||
|
|
||||||
|
tool = $state<Tool>('none');
|
||||||
|
scalePoints = $state<Point[]>([]);
|
||||||
|
lengthMm = $state<number | null>(null);
|
||||||
|
threshold = $state(DEFAULT_THRESHOLD);
|
||||||
|
invert = $state(false);
|
||||||
|
seed = $state<Point | null>(null);
|
||||||
|
marginMm = $state(-2);
|
||||||
|
mode = $state<'auto' | 'manual'>('auto');
|
||||||
|
manualOuter = $state<Point[]>([]);
|
||||||
|
manualHoles = $state<Point[][]>([]);
|
||||||
|
drawing = $state<Point[]>([]);
|
||||||
|
|
||||||
|
pxPerMm = $derived(
|
||||||
|
this.scalePoints.length === 2 && this.lengthMm !== null
|
||||||
|
? scaleFromLine(this.scalePoints[0], this.scalePoints[1], this.lengthMm)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
scaleInvalid = $derived(this.scalePoints.length === 2 && this.lengthMm !== null && this.pxPerMm === null);
|
||||||
|
marginPx = $derived(this.pxPerMm ? this.marginMm * this.pxPerMm : 0);
|
||||||
|
|
||||||
|
shapePx = $derived.by((): Shape | null => {
|
||||||
|
const { cv, image } = this;
|
||||||
|
if (!cv || !image) return null;
|
||||||
|
if (this.mode === 'manual') {
|
||||||
|
return shapeFromPolygons(cv, image.width, image.height, { outer: this.manualOuter, holes: this.manualHoles }, this.marginPx);
|
||||||
|
}
|
||||||
|
return detect(cv, image, { threshold: this.threshold, invert: this.invert, seed: this.seed, marginPx: this.marginPx });
|
||||||
|
});
|
||||||
|
|
||||||
|
exportShape = $derived(this.shapePx && this.pxPerMm ? prepareForExport(this.shapePx, this.pxPerMm) : null);
|
||||||
|
|
||||||
|
setCv(cv: CV) {
|
||||||
|
this.cv = cv;
|
||||||
|
if (this.image) this.threshold = otsuThreshold(cv, this.image);
|
||||||
|
}
|
||||||
|
|
||||||
|
setImage(image: RasterImage) {
|
||||||
|
this.image = image;
|
||||||
|
this.tool = 'none';
|
||||||
|
this.scalePoints = [];
|
||||||
|
this.seed = null;
|
||||||
|
this.clearManual();
|
||||||
|
this.threshold = this.cv ? otsuThreshold(this.cv, image) : DEFAULT_THRESHOLD;
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadFile(file: Blob) {
|
||||||
|
try {
|
||||||
|
this.loadError = null;
|
||||||
|
this.setImage(await loadPhoto(file));
|
||||||
|
} catch {
|
||||||
|
this.loadError = 'Could not read this image file.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
autoThreshold() {
|
||||||
|
if (this.cv && this.image) this.threshold = otsuThreshold(this.cv, this.image);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleImageClick(p: Point) {
|
||||||
|
const image = this.image;
|
||||||
|
if (!image || p.x < 0 || p.y < 0 || p.x >= image.width || p.y >= image.height) return;
|
||||||
|
switch (this.tool) {
|
||||||
|
case 'scale':
|
||||||
|
if (this.scalePoints.length === 1) {
|
||||||
|
this.scalePoints.push(p);
|
||||||
|
this.tool = 'none';
|
||||||
|
} else {
|
||||||
|
this.scalePoints = [p];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'seed':
|
||||||
|
this.seed = p;
|
||||||
|
this.tool = 'none';
|
||||||
|
break;
|
||||||
|
case 'draw':
|
||||||
|
this.drawing.push(p);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
finishPolygon() {
|
||||||
|
if (this.drawing.length >= 3) {
|
||||||
|
if (this.manualOuter.length === 0) this.manualOuter = this.drawing;
|
||||||
|
else this.manualHoles.push(this.drawing);
|
||||||
|
this.mode = 'manual';
|
||||||
|
}
|
||||||
|
this.drawing = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
clearManual() {
|
||||||
|
this.manualOuter = [];
|
||||||
|
this.manualHoles = [];
|
||||||
|
this.drawing = [];
|
||||||
|
this.mode = 'auto';
|
||||||
|
if (this.tool === 'draw') this.tool = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { AppState } from '../src/lib/app-state.svelte';
|
||||||
|
|
||||||
|
const image = { width: 100, height: 80, data: new Uint8ClampedArray(100 * 80 * 4) };
|
||||||
|
|
||||||
|
function withImage() {
|
||||||
|
const app = new AppState();
|
||||||
|
app.setImage(image);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AppState', () => {
|
||||||
|
it('starts with sensible defaults', () => {
|
||||||
|
const app = new AppState();
|
||||||
|
expect(app.marginMm).toBe(-2);
|
||||||
|
expect(app.mode).toBe('auto');
|
||||||
|
expect(app.tool).toBe('none');
|
||||||
|
expect(app.shapePx).toBeNull();
|
||||||
|
expect(app.exportShape).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets the scale from two clicks, then leaves the scale tool', () => {
|
||||||
|
const app = withImage();
|
||||||
|
app.tool = 'scale';
|
||||||
|
app.handleImageClick({ x: 10, y: 10 });
|
||||||
|
expect(app.tool).toBe('scale');
|
||||||
|
app.handleImageClick({ x: 70, y: 10 });
|
||||||
|
expect(app.tool).toBe('none');
|
||||||
|
app.lengthMm = 30;
|
||||||
|
expect(app.pxPerMm).toBeCloseTo(2);
|
||||||
|
expect(app.marginPx).toBeCloseTo(-4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restarts the scale line on a third click', () => {
|
||||||
|
const app = withImage();
|
||||||
|
app.tool = 'scale';
|
||||||
|
app.handleImageClick({ x: 10, y: 10 });
|
||||||
|
app.handleImageClick({ x: 70, y: 10 });
|
||||||
|
app.tool = 'scale';
|
||||||
|
app.handleImageClick({ x: 5, y: 5 });
|
||||||
|
expect(app.scalePoints).toEqual([{ x: 5, y: 5 }]);
|
||||||
|
expect(app.pxPerMm).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags an invalid scale and keeps the margin at 0 until the scale is valid', () => {
|
||||||
|
const app = withImage();
|
||||||
|
app.tool = 'scale';
|
||||||
|
app.handleImageClick({ x: 10, y: 10 });
|
||||||
|
app.handleImageClick({ x: 10, y: 10 });
|
||||||
|
app.lengthMm = 50;
|
||||||
|
expect(app.scaleInvalid).toBe(true);
|
||||||
|
expect(app.marginPx).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores clicks outside the image', () => {
|
||||||
|
const app = withImage();
|
||||||
|
app.tool = 'seed';
|
||||||
|
app.handleImageClick({ x: -1, y: 10 });
|
||||||
|
app.handleImageClick({ x: 50, y: 80 });
|
||||||
|
expect(app.seed).toBeNull();
|
||||||
|
expect(app.tool).toBe('seed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets the seed and leaves the seed tool', () => {
|
||||||
|
const app = withImage();
|
||||||
|
app.tool = 'seed';
|
||||||
|
app.handleImageClick({ x: 20, y: 30 });
|
||||||
|
expect(app.seed).toEqual({ x: 20, y: 30 });
|
||||||
|
expect(app.tool).toBe('none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws an outline then holes, switching to manual mode', () => {
|
||||||
|
const app = withImage();
|
||||||
|
app.tool = 'draw';
|
||||||
|
[{ x: 1, y: 1 }, { x: 90, y: 1 }, { x: 90, y: 70 }].forEach((p) => app.handleImageClick(p));
|
||||||
|
app.finishPolygon();
|
||||||
|
expect(app.manualOuter).toHaveLength(3);
|
||||||
|
expect(app.mode).toBe('manual');
|
||||||
|
[{ x: 20, y: 20 }, { x: 30, y: 20 }, { x: 30, y: 30 }].forEach((p) => app.handleImageClick(p));
|
||||||
|
app.finishPolygon();
|
||||||
|
expect(app.manualHoles).toHaveLength(1);
|
||||||
|
expect(app.drawing).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('discards a polygon with fewer than 3 points', () => {
|
||||||
|
const app = withImage();
|
||||||
|
app.tool = 'draw';
|
||||||
|
app.handleImageClick({ x: 1, y: 1 });
|
||||||
|
app.finishPolygon();
|
||||||
|
expect(app.manualOuter).toEqual([]);
|
||||||
|
expect(app.mode).toBe('auto');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clearManual returns to auto mode', () => {
|
||||||
|
const app = withImage();
|
||||||
|
app.tool = 'draw';
|
||||||
|
[{ x: 1, y: 1 }, { x: 90, y: 1 }, { x: 90, y: 70 }].forEach((p) => app.handleImageClick(p));
|
||||||
|
app.finishPolygon();
|
||||||
|
app.clearManual();
|
||||||
|
expect(app.mode).toBe('auto');
|
||||||
|
expect(app.manualOuter).toEqual([]);
|
||||||
|
expect(app.tool).toBe('none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a new image resets scale, seed and manual work', () => {
|
||||||
|
const app = withImage();
|
||||||
|
app.tool = 'scale';
|
||||||
|
app.handleImageClick({ x: 10, y: 10 });
|
||||||
|
app.seed = { x: 5, y: 5 };
|
||||||
|
app.manualOuter = [{ x: 1, y: 1 }, { x: 2, y: 2 }, { x: 3, y: 1 }];
|
||||||
|
app.mode = 'manual';
|
||||||
|
app.setImage(image);
|
||||||
|
expect(app.scalePoints).toEqual([]);
|
||||||
|
expect(app.seed).toBeNull();
|
||||||
|
expect(app.manualOuter).toEqual([]);
|
||||||
|
expect(app.mode).toBe('auto');
|
||||||
|
expect(app.tool).toBe('none');
|
||||||
|
expect(app.threshold).toBe(128);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user