import { detectPrepared, disposePrepared, prepareImage, preparedThreshold, type PreparedImage } 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; const MIN_HOLE_MM = 3; 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); /** Lighting-corrected grayscale of `image`, computed once per photo; owns WASM memory. */ prepared = $state.raw(null); loadError = $state(null); tool = $state('none'); scalePoints = $state([]); lengthMm = $state(null); threshold = $state(DEFAULT_THRESHOLD); 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, prepared } = 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); } if (!prepared) return null; return detectPrepared(cv, prepared, { threshold: this.threshold, seed: this.seed, marginPx: this.marginPx, minHolePx: this.pxPerMm ? MIN_HOLE_MM * this.pxPerMm : 0, }); }); exportShape = $derived(this.shapePx && this.pxPerMm ? prepareForExport(this.shapePx, this.pxPerMm) : null); setCv(cv: CV) { this.cv = cv; this.prepare(); } setImage(image: RasterImage) { this.image = image; this.tool = 'none'; this.scalePoints = []; this.seed = null; this.clearManual(); this.prepare(); } private prepare() { if (this.prepared) disposePrepared(this.prepared); const { cv, image } = this; this.prepared = cv && image ? prepareImage(cv, image) : null; this.threshold = cv && this.prepared ? preparedThreshold(cv, this.prepared) : 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.prepared) this.threshold = preparedThreshold(this.cv, this.prepared); } 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'; } }