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';
}
}