feat(capture): ship Milestone 1 browser capture app

Why
Phone and PC need a single capture flow that ends at inventory PNG and
board-foot numbers with no backend, inference, or Woo create.

Scope
Add frontend/ Vite+Svelte capture UI with Resume, Add photo, Mask,
Length axis, and Measure. Mask runs in a worker with Auto/Green/Black
and a 2.0s sensitivity debounce. Measure computes scale, widths, sqft,
bdft, and a 3:4 alpha inventory PNG. Persist one device draft in
IndexedDB. Serve the static build from frontend/Dockerfile via nginx
(deploy/nginx-m1.conf). No FastAPI.

Verification
sudo docker build -f frontend/Dockerfile -t slabuploader-m1 .
sudo docker run -d --name slabuploader-m1 -p 0.0.0.0:18080:80 slabuploader-m1
curl http://127.0.0.1:18080/ returns 200
Playwright: synthetic green-sheet photo through Calculate shows sqft/bdft
This commit is contained in:
2026-09-14 07:53:39 +00:00
parent 3bae76cea3
commit d86fc93ed6
24 changed files with 2969 additions and 0 deletions
+75
View File
@@ -0,0 +1,75 @@
import type { BBox, Eighths, Measures, Widths } from '../capture/capture.types';
// Long-side start point per heading: heading 1 starts at corners[1] so that
// the axis reads corners[1] -> corners[0] (see rect.ts corner order).
export function axisEndpoints(bbox: BBox, heading: 1 | -1): { start: [number, number]; end: [number, number] } {
return heading === 1 ? { start: bbox.corners[1], end: bbox.corners[0] } : { start: bbox.corners[0], end: bbox.corners[1] };
}
// Sampled every 6 in along the axis; each sample is a line probe perpendicular
// to the axis, width = max run of slab px across the probe.
function sampleWidths(
bitset: Uint8Array, w: number, h: number,
start: [number, number], end: [number, number],
scale: number, lengthIn: number,
): Eighths[] {
const dx = end[0] - start[0];
const dy = end[1] - start[1];
const len = Math.hypot(dx, dy) || 1;
const ax = dx / len, ay = dy / len;
const px = -ay, py = ax; // perpendicular
const stepPx = 6 * scale;
const count = Math.max(1, Math.floor(lengthIn / 6) + 1);
const out: Eighths[] = [];
const half = Math.max(2, Math.round((w + h) / 4));
for (let i = 0; i < count; i++) {
const t = (i / (count - 1 || 1)) * len;
const ox = start[0] + ax * t;
const oy = start[1] + ay * t;
let minP = Infinity, maxP = -Infinity;
for (let p = -half; p <= half; p++) {
const x = Math.round(ox + px * p);
const y = Math.round(oy + py * p);
if (x < 0 || y < 0 || x >= w || y >= h) continue;
if (bitset[y * w + x]) {
if (p < minP) minP = p;
if (p > maxP) maxP = p;
}
}
if (maxP < minP) continue; // no slab px on this probe
out.push(Math.max(0, Math.round(((maxP - minP + 1) / scale) * 8)));
}
return out;
}
export function measure(
bitset: Uint8Array, w: number, h: number,
bbox: BBox, heading: 1 | -1,
lengthEighths: Eighths, thicknessEighths: Eighths,
slabPx: number,
): Omit<Measures, 'inventoryPng'> & { sourceBelowFloor: false } {
const lengthIn = lengthEighths / 8;
const scale = bbox.longSidePx / lengthIn;
const { start, end } = axisEndpoints(bbox, heading);
const every6In = sampleWidths(bitset, w, h, start, end, scale, lengthIn);
let widths: Widths;
if (every6In.length > 0) {
const min = Math.min(...every6In);
const max = Math.max(...every6In);
const avg = Math.round(every6In.reduce((a, b) => a + b, 0) / every6In.length);
widths = { min, max, avg, every6In };
} else {
widths = { min: 0, max: 0, avg: 0, every6In: [] };
}
const sqft = Math.round((slabPx / (scale * scale) / 144) * 100) / 100;
const bdft = Math.round(sqft * (thicknessEighths / 8) * 100) / 100;
return {
lengthEighths,
thicknessEighths,
scale,
widths,
sqft,
bdft,
sourceBelowFloor: false,
};
}