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,94 @@
|
||||
import type { ABox, BBox } from '../capture/capture.types';
|
||||
|
||||
export function slabBounds(bitset: Uint8Array, w: number, h: number): ABox {
|
||||
let minX = w, minY = h, maxX = -1, maxY = -1;
|
||||
for (let y = 0; y < h; y++) {
|
||||
const row = y * w;
|
||||
for (let x = 0; x < w; x++) {
|
||||
if (bitset[row + x]) {
|
||||
if (x < minX) minX = x;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (y > maxY) maxY = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (maxX < 0) return { x: 0, y: 0, w: 0, h: 0 };
|
||||
return { x: minX, y: minY, w: maxX - minX + 1, h: maxY - minY + 1 };
|
||||
}
|
||||
|
||||
// Convex hull (Andrew's monotone chain). Points are [x, y].
|
||||
export function convexHull(pts: [number, number][]): [number, number][] {
|
||||
const p = [...pts].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
||||
if (p.length < 3) return p;
|
||||
const cross = (o: [number, number], a: [number, number], b: [number, number]) =>
|
||||
(a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
|
||||
const lower: [number, number][] = [];
|
||||
for (const pt of p) {
|
||||
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], pt) <= 0) lower.pop();
|
||||
lower.push(pt);
|
||||
}
|
||||
const upper: [number, number][] = [];
|
||||
for (let i = p.length - 1; i >= 0; i--) {
|
||||
const pt = p[i];
|
||||
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], pt) <= 0) upper.pop();
|
||||
upper.push(pt);
|
||||
}
|
||||
lower.pop();
|
||||
upper.pop();
|
||||
return lower.concat(upper);
|
||||
}
|
||||
|
||||
// Rotating calipers: true minimum-area bounding rectangle of the mask.
|
||||
export function minAreaRect(bitset: Uint8Array, w: number, h: number): BBox | null {
|
||||
const pts: [number, number][] = [];
|
||||
for (let y = 0; y < h; y++) {
|
||||
const row = y * w;
|
||||
for (let x = 0; x < w; x++) if (bitset[row + x]) pts.push([x, y]);
|
||||
}
|
||||
if (pts.length < 2) return null;
|
||||
const hull = convexHull(pts);
|
||||
|
||||
let best = { area: Infinity, cx: 0, cy: 0, ux: 1, uy: 0, hw: 0, hh: 0 };
|
||||
const n = hull.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const j = (i + 1) % n;
|
||||
const ex = hull[j][0] - hull[i][0];
|
||||
const ey = hull[j][1] - hull[i][1];
|
||||
const len = Math.hypot(ex, ey);
|
||||
if (len === 0) continue;
|
||||
const ux = ex / len, uy = ey / len;
|
||||
const vx = -uy, vy = ux;
|
||||
let minU = Infinity, maxU = -Infinity, minV = Infinity, maxV = -Infinity;
|
||||
for (let k = 0; k < n; k++) {
|
||||
const dx = hull[k][0] - hull[i][0];
|
||||
const dy = hull[k][1] - hull[i][1];
|
||||
const u = dx * ux + dy * uy;
|
||||
const v = dx * vx + dy * vy;
|
||||
if (u < minU) minU = u;
|
||||
if (u > maxU) maxU = u;
|
||||
if (v < minV) minV = v;
|
||||
if (v > maxV) maxV = v;
|
||||
}
|
||||
const rw = maxU - minU;
|
||||
const rh = maxV - minV;
|
||||
const area = rw * rh;
|
||||
if (area < best.area) {
|
||||
best = { area, cx: hull[i][0] + (minU + maxU) / 2 * ux + (minV + maxV) / 2 * vx, cy: hull[i][1] + (minU + maxU) / 2 * uy + (minV + maxV) / 2 * vy, ux, uy, hw: rw / 2, hh: rh / 2 };
|
||||
}
|
||||
}
|
||||
|
||||
// long-side direction: the edge with the larger extent
|
||||
const lu = best.hw >= best.hh ? best.ux : -best.uy;
|
||||
const lv = best.hw >= best.hh ? best.uy : best.ux;
|
||||
const su = best.hw >= best.hh ? -best.uy : best.ux;
|
||||
const sv = best.hw >= best.hh ? -best.ux : -best.uy;
|
||||
const halfL = best.hw >= best.hh ? best.hw : best.hh;
|
||||
const halfS = best.hw >= best.hh ? best.hh : best.hw;
|
||||
const corners: [number, number][] = [];
|
||||
for (const [a, b] of [[1, 1], [1, -1], [-1, -1], [-1, 1]] as const) {
|
||||
corners.push([best.cx + a * halfL * lu + b * halfS * su, best.cy + a * halfL * lv + b * halfS * sv]);
|
||||
}
|
||||
const angleDeg = ((Math.atan2(lv, lu) * 180) / Math.PI + 180) % 180;
|
||||
return { corners, longSidePx: halfL * 2, angleDeg };
|
||||
}
|
||||
Reference in New Issue
Block a user