diff --git a/src/lib/app-state.svelte.ts b/src/lib/app-state.svelte.ts new file mode 100644 index 0000000..5f7bbd1 --- /dev/null +++ b/src/lib/app-state.svelte.ts @@ -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(null); + cvError = $state(null); + image = $state.raw(null); + loadError = $state(null); + + tool = $state('none'); + scalePoints = $state([]); + lengthMm = $state(null); + threshold = $state(DEFAULT_THRESHOLD); + invert = $state(false); + seed = $state(null); + marginMm = $state(-2); + mode = $state<'auto' | 'manual'>('auto'); + manualOuter = $state([]); + manualHoles = $state([]); + drawing = $state([]); + + 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'; + } +} diff --git a/tests/app-state.test.ts b/tests/app-state.test.ts new file mode 100644 index 0000000..ed65ec1 --- /dev/null +++ b/tests/app-state.test.ts @@ -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); + }); +});