Files
lasercut-scrap-modeller/src/lib/app-state.svelte.ts
T
immortaly007andClaude Opus 5.5 55a25ba978 feat: correct uneven lighting and pick polarity automatically
Fit the background brightness from the photo border and subtract it, so
side-lit photos threshold cleanly. The clicked pixel (or, without a click,
the border) decides whether the piece is darker or lighter, replacing the
invert checkbox. Adds the MDF-on-wood-floor photo as a regression fixture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:12:16 +02:00

120 lines
3.6 KiB
TypeScript

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