From b124450d0c8adc408c6af8d7c4f1117e0b99cf63 Mon Sep 17 00:00:00 2001 From: Ty Date: Mon, 14 Sep 2026 02:11:28 -0700 Subject: [PATCH] M1-5: Measure + PNG + full capture UI (TDD) - measure.js: sampleWidthsPx scanline sampler - inventory.js: prepareInventoryMetadata - +page.svelte: full 4-state capture flow (Add photo, Mask, Axis, Measure) - Canvas overlay: checkerboard mask, bounding rect, axis line - Inventory PNG: 3:4 portrait, slab centered 80% fill, alpha - 77/77 unit tests green --- frontend/src/lib/core/inventory.js | 64 +++ frontend/src/lib/core/measure.js | 69 +++ frontend/src/routes/+page.svelte | 614 +++++++++++++++++++++++++- frontend/tests/unit/inventory.test.js | 35 ++ frontend/tests/unit/measure.test.js | 74 ++++ 5 files changed, 852 insertions(+), 4 deletions(-) create mode 100644 frontend/src/lib/core/inventory.js create mode 100644 frontend/src/lib/core/measure.js create mode 100644 frontend/tests/unit/inventory.test.js create mode 100644 frontend/tests/unit/measure.test.js diff --git a/frontend/src/lib/core/inventory.js b/frontend/src/lib/core/inventory.js new file mode 100644 index 0000000..880f246 --- /dev/null +++ b/frontend/src/lib/core/inventory.js @@ -0,0 +1,64 @@ +// M1 deterministic inventory generation — pure functions. +// +// Ground truth: docs/CAPTURE.md §Inventory PNG +// Aspect | 3:4 portrait +// Layout | Slab centered, 80% fill at widest and tallest points +// Size | After crop, shorter side >= 1600px +// +// This module handles the logic for calculating the inventory metadata +// and preparing the instructions for the browser-side PNG encoding. +// +// Note: The actual PNG encoding (canvas/OffscreenCanvas) is handled +// by the frontend component/worker. This file provides the data. +// +/** + * @typedef {Object} InventoryMetadata + * @property {number} sqft + * @property {number} bdft + * @property {number[]} widths + * @property {number} minWidth + * @property {number} maxWidth + * @property {number} avgWidth + * @property {number} lengthIn + * @property {number} thicknessIn + * @property {number} scale + */ +// +/** + * Prepares the metadata for the inventory PNG. + * @param {number} maskPx - Total area of the mask in pixels. + * @param {number} scale - The px-per-inch scale. + * @param {number} lengthIn - User-entered length in inches. + * @param {number} thicknessIn - User-entered thickness in inches. + * @param {number[]} sampledWidthsPx - The raw pixel widths sampled along the axis. + * @returns {InventoryMetadata} + */ +export function prepareInventoryMetadata( + maskPx, + scale, + lengthIn, + thicknessIn, + sampledWidthsPx +) { + // Re-use logic from math.js to ensure consistency + const sqft = (maskPx / (scale * scale)) / 144; + const bdft = sqft * thicknessIn; + + // Widths are converted to inches + const widths = sampledWidthsPx.map((w) => w / scale); + const minWidth = Math.min(...widths); + const maxWidth = Math.max(...widths); + const avgWidth = widths.reduce((a, b) => a + b, 0) / widths.length; + + return { + sqft: Math.round(sqft * 100) / 100, + bdft: Math.round(bdft * 100) / 100, + widths, + minWidth, + maxWidth, + avgWidth, + lengthIn, + thicknessIn, + scale, + }; +} diff --git a/frontend/src/lib/core/measure.js b/frontend/src/lib/core/measure.js new file mode 100644 index 0000000..bf52a3d --- /dev/null +++ b/frontend/src/lib/core/measure.js @@ -0,0 +1,69 @@ +// M1-5 Measure — mask width sampling along the approved length axis. +// +// Ground truth: docs/CAPTURE.md §Measure +// - scale = longest_axis_px / L +// - Widths: min, max, avg, sampled every 6 in along the approved length +// axis, perpendicular to it +// +// sampleWidthsPx is the real scanline sampler the UI runs on the approved +// mask: at each sample point t = 0, 6, 12, ... in (px) along the axis it +// scans the perpendicular direction across the whole photo and measures the +// contiguous run of kept pixels that covers the sample point. + +/** + * Sample perpendicular mask widths along a length axis. + * + * @param {Uint8Array} mask binary mask (1 = kept), row-major + * @param {number} width photo width in px + * @param {number} height photo height in px + * @param {{origin:{x:number,y:number}, dir:{x:number,y:number}, lengthPx:number}} axis + * origin = a point on the axis (e.g. the rectangle corner the long side + * starts at), dir = unit vector along the slab length, lengthPx = its + * pixel length. + * @param {number} pxPerIn scale + * @param {number} [everyIn=6] sampling interval in inches + * @returns {number[]} widths in px, one per sample (t = 0, everyIn, ... in px + * at or under lengthPx) + */ +export function sampleWidthsPx(mask, width, height, axis, pxPerIn, everyIn = 6) { + const stepPx = everyIn * pxPerIn; + const ux = axis.dir.x; + const uy = axis.dir.y; + // Perpendicular unit vector (left normal): (-uy, ux). + const perpX = -uy; + const perpY = ux; + const out = []; + + for (let t = 0; t <= axis.lengthPx; t += stepPx) { + // Sample point. + const sx = axis.origin.x + ux * t; + const sy = axis.origin.y + uy * t; + // Find the contiguous kept run along the perpendicular line that + // covers (sx, sy), walking out from the sample point in both + // perpendicular directions. + const rx = Math.round(sx); + const ry = Math.round(sy); + if (rx < 0 || ry < 0 || rx >= width || ry >= height || !mask[ry * width + rx]) { + out.push(0); + continue; + } + let a = 0; // extent in the negative perpendicular direction + let b = 0; // extent in the positive perpendicular direction + for (;;) { + const nx = rx - Math.round(perpX * (a + 1)); + const ny = ry - Math.round(perpY * (a + 1)); + if (nx < 0 || ny < 0 || nx >= width || ny >= height) break; + if (!mask[ny * width + nx]) break; + a += 1; + } + for (;;) { + const nx = rx + Math.round(perpX * (b + 1)); + const ny = ry + Math.round(perpY * (b + 1)); + if (nx < 0 || ny < 0 || nx >= width || ny >= height) break; + if (!mask[ny * width + nx]) break; + b += 1; + } + out.push(a + b + 1); + } + return out; +} diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index f7fea10..b60a5e7 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -1,12 +1,618 @@
-

Capture

-

The M1 capture flow (Add photo → Mask → Length axis → Measure → PNG) lands in cards M1-3…M1-5.

+

SlabUploader

+ + {#if heicNotice} + + {/if} + + {#if state === 'add_photo'} +
+

Add Photo

+

+ Photograph the slab on a green or black sheet.
+ Sheet fills the frame. Slab fully on sheet.
+ Top-down, lens parallel to the face.
+ Length aligned with the long side of the picture. +

+ +
+ {:else} +
+ + {#if busy}
Processing…
{/if} +
+ + {#if state === 'mask' && maskState} +
+

Mask

+
+
+ +
+ + + +
+
+
+ + + + {maskState.sensitivity} + +
+
+ + + {maskState.edgeOffset}px + +
+
+ + + {maskState.feather}px + +
+
+
+ + +
+ {#if maskState.stats.empty} +

No slab detected — adjust sensitivity or retake.

+ {/if} +
+ {/if} + + {#if state === 'axis' && axisState} +
+

Length Axis

+

Rotate the axis to follow the slab length, then approve.

+
+
+ + + + +
+
+
+ + +
+
+ {/if} + + {#if state === 'measure'} +
+

Measure

+
+
+ + lengthIn = parseFloat(e.target.value) || 0} /> +
+
+ + thicknessIn = parseFloat(e.target.value) || 0} /> +
+
+
+ + +
+ + {#if result} +
+

Results

+
+
Width (min)
{result.min?.toFixed(2)} in
+
Width (max)
{result.max?.toFixed(2)} in
+
Width (avg)
{result.avg?.toFixed(2)} in
+
Length
{result.lengthIn} in
+
Thickness
{result.thicknessIn} in
+
Scale
{result.scale?.toFixed(2)} px/in
+
Sq Ft
{result.sqft} ft²
+
Board Feet
{result.bdft} bdft
+
+ {#if pngUrl} +

Inventory PNG

+ Inventory PNG + {/if} + +
+ {/if} +
+ {/if} + {/if}
diff --git a/frontend/tests/unit/inventory.test.js b/frontend/tests/unit/inventory.test.js new file mode 100644 index 0000000..4e233ba --- /dev/null +++ b/frontend/tests/unit/inventory.test.js @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; +import * as m from '../../src/lib/core/math.js'; +import { prepareInventoryMetadata } from '../../src/lib/core/inventory.js'; + +describe('prepareInventoryMetadata', () => { + it('calculates correct metadata for a 21in x 12in slab at 1.75in thick', () => { + const Lpx = 3360, L = 21, scale = 160, thickness = 1.75; + const widthPx = 1920; // 12in + const maskPx = Lpx * widthPx; // 6,451,200 + + const result = prepareInventoryMetadata( + maskPx, + scale, + L, + thickness, + [1600, 1920, 2080, 1600] + ); + + // sqft = (6,451,200 / 160^2) / 144 = 1.75 + expect(result.sqft).toBe(1.75); + // bdft = 1.75 * 1.75 = 3.0625 -> 3.06 + expect(result.bdft).toBe(3.06); + expect(result.lengthIn).toBe(21); + expect(result.thicknessIn).toBe(1.75); + expect(result.minWidth).toBe(10); + expect(result.maxWidth).toBe(13); + expect(result.avgWidth).toBe(11.25); + }); + + it('handles zero area', () => { + const result = prepareInventoryMetadata(0, 160, 21, 1.75, []); + expect(result.sqft).toBe(0); + expect(result.bdft).toBe(0); + }); +}); diff --git a/frontend/tests/unit/measure.test.js b/frontend/tests/unit/measure.test.js new file mode 100644 index 0000000..ffe82a5 --- /dev/null +++ b/frontend/tests/unit/measure.test.js @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest'; +import { sampleWidthsPx } from '../../src/lib/core/measure.js'; + +// M1-5 Measure — pure geometry tests. +// +// Ground truth: docs/CAPTURE.md §Measure +// - Widths: min, max, avg, sampled every 6 in along the approved length +// axis, perpendicular to it +// - scale = longest_axis_px / L +// +// sampleWidthsPx runs the real scanline mask sampling that the UI uses on +// the approved mask, so these tests pin the exact pixel widths the e2e +// fixture math is computed from. + +/** + * Build a binary mask directly (no keySheet dependency): 1 inside the slab + * rectangle, 0 elsewhere. This keeps the test self-contained and focused on + * sampleWidthsPx geometry, which is what the test suite is for. + */ +function buildMask(width, height, slabW, slabH, ox, oy) { + const mask = new Uint8Array(width * height); + for (let y = 0; y < height; y++) + for (let x = 0; x < width; x++) + mask[y * width + x] = + x >= ox && x < ox + slabW && y >= oy && y < oy + slabH ? 1 : 0; + return { mask, width, height }; +} + +describe('sampleWidthsPx (M1-5 Measure geometry)', () => { + it('samples perpendicular widths every 6in along a horizontal axis', () => { + // 16 px/in. Photo 400x300. Slab 288x96 px (18in x 6in) at (0,0). + // Axis along x at slab center y=48, from x=0, length 288 px. + // stepPx = 6*16 = 96. Samples at t=0,96,192,288. + // t=288 lands on the slab boundary (x=288, slab is x<288) -> width 0. + const { mask, width, height } = buildMask(400, 300, 288, 96, 0, 0); + const axis = { origin: { x: 0, y: 48 }, dir: { x: 1, y: 0 }, lengthPx: 288 }; + const widths = sampleWidthsPx(mask, width, height, axis, 16); + expect(widths.length).toBe(4); + expect(widths[0]).toBeCloseTo(96, 0); + expect(widths[1]).toBeCloseTo(96, 0); + expect(widths[2]).toBeCloseTo(96, 0); + expect(widths[3]).toBe(0); + }); + + it('returns widths perpendicular to a vertical axis', () => { + // Slab 96x288 px (6in x 18in) at (0,0). Axis along y at x=48, length 288. + const { mask, width, height } = buildMask(400, 300, 96, 288, 0, 0); + const axis = { origin: { x: 48, y: 0 }, dir: { x: 0, y: 1 }, lengthPx: 288 }; + const widths = sampleWidthsPx(mask, width, height, axis, 16); + expect(widths.length).toBe(4); + expect(widths[0]).toBeCloseTo(96, 0); + expect(widths[1]).toBeCloseTo(96, 0); + expect(widths[2]).toBeCloseTo(96, 0); + expect(widths[3]).toBe(0); + }); + + it('samples only up to the axis length, not beyond', () => { + // Slab 80 px (5in) wide: 2nd sample at 96 px > lengthPx=80. + const { mask, width, height } = buildMask(200, 150, 80, 80, 0, 0); + const axis = { origin: { x: 0, y: 0 }, dir: { x: 1, y: 0 }, lengthPx: 80 }; + const widths = sampleWidthsPx(mask, width, height, axis, 16); + expect(widths.length).toBe(1); + }); + + it('reports the real mask extent, not the photo extent', () => { + // Slab 80 px tall at y=35: the perpendicular sample must be 80, + // never the 150 px photo height. + const { mask, width, height } = buildMask(200, 150, 80, 80, 0, 35); + const axis = { origin: { x: 0, y: 35 }, dir: { x: 1, y: 0 }, lengthPx: 80 }; + const widths = sampleWidthsPx(mask, width, height, axis, 16); + expect(widths.length).toBe(1); + expect(widths[0]).toBeCloseTo(80, 0); + }); +});