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:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user