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:
immortaly007andClaude Opus 5.5 committed 2026-10-15 14:46:28 +02:00
1 parent 99cafc0b55
commit 4b19c7ea4e
2 files changed
+236

No files matched your search

+116
View File
@@ -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';
}
}
+120
View File
@@ -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);
});
});