diff --git a/frontend/src/lib/core/mask.js b/frontend/src/lib/core/mask.js new file mode 100644 index 0000000..b4e3531 --- /dev/null +++ b/frontend/src/lib/core/mask.js @@ -0,0 +1,121 @@ +// M1-3 mask key geometry — pure functions, no canvas/DOM. +// +// keySheet(data, { sheet, sensitivity }) -> Uint8Array of length n where +// 1 = keep slab pixel, 0 = remove. maskStats(mask) -> { kept, total, empty }. +// +// Green key (chroma distance to the sheet green): +// dist = Euclidean RGB distance to the reference sheet green (0,255,0) +// keep when dist > threshold(sensitivity); threshold SHRINKS as +// sensitivity rises (0 = tightest, 100 = loosest) so higher sensitivity +// keeps a superset of lower sensitivity. +// +// Black key (brightness above a cutoff): +// keep when brightness > cutoff. +// +// Sensitivity 0–100 scales tightness — higher sensitivity keeps more (looser +// thresholds): green threshold grows with sensitivity, black cutoff shrinks. +// Auto picks whichever sheet yields more kept pixels. + +/** + * Green key: keep = chroma distance to the sheet green exceeds the threshold. + * Distance is Euclidean in RGB to the reference sheet green (0,255,0): + * sheet green (0,200,0) -> ~55 (removed at low sensitivity) + * brown wood (140,90,40) -> ~220 (kept) + * Sensitivity scales the threshold down: higher = looser = keeps more. + */ +// Reference sheet green: pure keying green. Sheet green (0,200,0) sits ~55 +// from it; brown wood (140,90,40) ~220 — a clean separation. +const SHEET_GREEN = { r: 0, g: 255, b: 0 }; +function greenDistance(r, g, b) { + const dr = r - SHEET_GREEN.r; + const dg = g - SHEET_GREEN.g; + const db = b - SHEET_GREEN.b; + return Math.sqrt(dr * dr + dg * dg + db * db); +} + +/** + * 0–100 sensitivity -> green distance threshold. + * A pixel is KEPT when its distance EXCEEDS the threshold, so a LOWER + * threshold keeps MORE pixels. Sensitivity 0 is the tightest key (highest + * threshold, removes even slab-green pixels); sensitivity 100 is the loosest + * (lowest threshold, keeps even the sheet). Monotone: higher sensitivity + * keeps a superset of lower sensitivity. + * s=0 -> 150: brown (~220) kept, sheet green (~55) removed + * s=100 -> 50: brown kept, sheet green kept (loosest) + */ +function greenThreshold(s) { + return 150 - s; +} + +// --- black key --------------------------------------------------------------- +// A black key removes a dark OR saturated background (e.g. a bright green +// chroma-key sheet) and keeps the bright, low-saturation slab. Brightness +// alone is not enough: a bright green sheet is "bright" but it's a background, +// so a saturation gate drops it. + +/** Brightness cutoff for the black key (higher sensitivity = looser = more kept). */ +function blackCutoff(s) { + // 40 at s=0, 140 at s=100: at s=50 (90) a near-black sheet (≈10) is + // removed and a bright slab (~210) is kept. + return 40 + (s / 100) * 100; +} + +/** Saturation gate for the black key: pixels more saturated than this are + * treated as background (e.g. a green keying sheet, saturation ≈ 200) and + * dropped, even if bright. The slab is low-saturation. */ +const BLACK_SAT_CAP = 120; + +/** + * Key the sheet out of a pixel buffer. + * @param {Uint8ClampedArray|{r,g,b,a}} data RGBA pixels, length n*4. + * @param {{sheet?:'auto'|'green'|'black', sensitivity?:number}} opts + * sensitivity is 0–100 (0 = tightest key, 100 = loosest). + * @returns {Uint8Array} length n, 1=keep slab pixel, 0=remove. + */ +export function keySheet(data, opts = {}) { + const sheet = opts.sheet ?? 'auto'; + const sensitivity = Math.max(0, Math.min(100, opts.sensitivity ?? 50)); + const n = data.length / 4; + const mask = new Uint8Array(n); + let sheetMode; + if (sheet === 'green' || sheet === 'black') sheetMode = sheet; + else { + // auto: run both keys, keep the one with more kept pixels. + const g = keySheet(data, { sheet: 'green', sensitivity }); + const b = keySheet(data, { sheet: 'black', sensitivity }); + let gk = 0, + bk = 0; + for (let i = 0; i < n; i++) { + gk += g[i]; + bk += b[i]; + } + // Tie goes to green: on a green field both keys keep the slab, and the + // spec expects auto to resolve to the green key when counts match. + return gk >= bk ? g : b; + } + for (let i = 0; i < n; i++) { + const j = i * 4; + const r = data[j], + g = data[j + 1], + b = data[j + 2]; + if (sheetMode === 'green') { + mask[i] = greenDistance(r, g, b) > greenThreshold(sensitivity) ? 1 : 0; + } else { + const bright = 0.2126 * r + 0.7152 * g + 0.0722 * b; + const sat = Math.max(r, g, b) - Math.min(r, g, b); + mask[i] = + bright > blackCutoff(sensitivity) && sat < BLACK_SAT_CAP ? 1 : 0; + } + } + return mask; +} + +/** + * @param {Uint8Array} mask + * @returns {{kept:number, total:number, empty:boolean}} empty=true blocks approve. + */ +export function maskStats(mask) { + let kept = 0; + for (let i = 0; i < mask.length; i++) kept += mask[i]; + return { kept, total: mask.length, empty: kept === 0 }; +} diff --git a/frontend/src/lib/core/math.js b/frontend/src/lib/core/math.js new file mode 100644 index 0000000..9148c43 --- /dev/null +++ b/frontend/src/lib/core/math.js @@ -0,0 +1,73 @@ +// M1 deterministic measurement math — pure functions, no DOM. +// +// Ground truth: docs/CAPTURE.md §Measure +// scale = longest_axis_px / L +// sqft = (mask_pixels / scale^2) / 144 (2 decimals, half-up) +// bdft = sqft * thickness_in (2 decimals, half-up) +// Widths are sampled every `everyInches` along the approved length axis. + +/** px-per-inch scale: longest axis in pixels divided by its length in inches. */ +export function scale(longestAxisPx, lengthIn) { + return longestAxisPx / lengthIn; +} + +/** + * Round half-up to `decimals` places (NOT banker's rounding, not toFixed's + * binary-float quirks). 30.625 -> 30.63, 1.005 -> 1.01. + */ +export function roundHalfUp(x, decimals) { + const f = Math.pow(10, decimals); + return Math.round((x + Number.EPSILON) * f) / f; +} + +/** + * Square feet from mask pixel area and the px-per-inch scale. + * (mask_px / scale^2) gives square inches; /144 gives square feet. + * @returns number rounded half-up to 2 decimals. + */ +export function sqft(maskPx, scalePxPerIn) { + if (maskPx <= 0 || scalePxPerIn <= 0) return 0; + const sqIn = maskPx / (scalePxPerIn * scalePxPerIn); + return roundHalfUp(sqIn / 144, 2); +} + +/** Board feet: sqft * thickness_in. 1 sqft at 1in thick = 1 bdft. */ +export function bdft(sqftVal, thicknessIn) { + return roundHalfUp(sqftVal * thicknessIn, 2); +} + +/** + * Sampled perpendicular widths, in inches, every `everyInches` along the axis + * from 0 up to (but not past) axisLengthPx. + * @param widthPx constant width in px sampled at each point + * @param pxPerIn scale + * @param axisLengthPx total axis length in px + * @param everyInches sampling interval in inches + * @returns number[] widths in inches + */ +export function sampledWidths(widthPx, pxPerIn, axisLengthPx, everyInches) { + const out = []; + for (let inch = 0; inch * pxPerIn <= axisLengthPx; inch += everyInches) { + out.push(widthPx / pxPerIn); + } + return out; +} + +/** + * min/max/avg (inches) over a list of sampled widths given in px. + * @param {number[]} widthsPx + * @param {number} pxPerIn + * @returns {{min:number, max:number, avg:number}} + */ +export function sampledWidthsFrom(widthsPx, pxPerIn) { + const inches = widthsPx.map((w) => w / pxPerIn); + const min = Math.min(...inches); + const max = Math.max(...inches); + const avg = inches.reduce((a, b) => a + b, 0) / inches.length; + return { min, max, avg }; +} + +/** Snap an inch value to the nearest 1/8 inch. */ +export function snapToEighth(inches) { + return Math.round(inches * 8) / 8; +} diff --git a/frontend/tests/e2e/mask.spec.js b/frontend/tests/e2e/mask.spec.js new file mode 100644 index 0000000..d61872f --- /dev/null +++ b/frontend/tests/e2e/mask.spec.js @@ -0,0 +1,42 @@ +import { test, expect } from '@playwright/test'; + +// M1-3: Playwright skeleton for the Mask state. +// +// The live Mask UI lands in card M1-5, so these tests are written but .skip()ped +// now — the suite must collect clean while the geometry is already unit-covered +// in tests/unit/mask.test.js (keySheet / maskStats are pure and DOM-free). +// +// Spec ground truth (docs/CAPTURE.md): +// - "If the file is HEIC and the browser cannot decode it, show a one-tap +// notice to retake the photo as JPEG." +// - "Approve is blocked when the mask has no slab pixels. User retakes." + +test.describe('Mask state (M1-5 live UI)', () => { + test.skip('HEIC input that the browser cannot decode shows a one-tap retake notice', async ({ page }) => { + // 1. Land in the Mask state with a photo loaded (M1-5 provides this). + await page.goto('/'); + // 2. Provide an HEIC file the browser cannot decode. + // (Test fixture path to be added with the M1-5 capture UI.) + // const input = page.locator('input[type="file"]'); + // await input.setInputFiles('tests/fixtures/slab.heic'); + // 3. The app must surface a one-tap 'retake as JPEG' notice — never a + // silent crash, never a server-side decode. + // const notice = page.getByRole('alert', { name: /retake/i }); + // await expect(notice).toBeVisible(); + // await expect(notice).toHaveText(/retake as JPEG/i); + // 4. Tapping it returns to Add photo. + // await notice.tap(); + // await expect(page.getByRole('button', { name: /add photo/i })).toBeVisible(); + }); + + test.skip('Approve is blocked while the mask has no slab pixels', async ({ page }) => { + // 1. Load a Mask whose keying removes every pixel (e.g. photo of sheet only). + await page.goto('/'); + // 2. Approve must be disabled — the mask has no slab pixels. + // const approve = page.getByRole('button', { name: /approve/i }); + // await expect(approve).toBeDisabled(); + // 3. User retakes: after a photo with a real slab is keyed, approve enables. + // await page.getByRole('button', { name: /retake/i }).tap(); + // await expect(approve).toBeEnabled(); + }); +}); diff --git a/frontend/tests/unit/mask.test.js b/frontend/tests/unit/mask.test.js new file mode 100644 index 0000000..b736da6 --- /dev/null +++ b/frontend/tests/unit/mask.test.js @@ -0,0 +1,174 @@ +import { describe, it, expect } from 'vitest'; +import { keySheet, maskStats } from '$lib/core/mask.js'; + +// M1-3 mask key geometry — unit tests (RED first, per strict TDD). +// Pure functions, no canvas/DOM: keySheet(data, opts) -> Uint8Array (1=keep, +// 0=remove), maskStats(mask) -> {kept, total, empty}. +// +// Ground truth is docs/CAPTURE.md §Mask: sheet Auto/Green/Black, sensitivity +// 0–100 (higher = looser, keeps more), approve blocked when the mask has no +// slab pixels. + +// --- synthetic helpers (typed arrays, node-safe, no DOM) ------------------- + +/** w x h RGBA buffer, every pixel set to (r,g,b,255). */ +function solid(w, h, r, g, b) { + const data = new Uint8ClampedArray(w * h * 4); + for (let i = 0; i < w * h; i++) { + data[i * 4 + 0] = r; + data[i * 4 + 1] = g; + data[i * 4 + 2] = b; + data[i * 4 + 3] = 255; + } + return data; +} + +/** Solid canvas with a centered filled rect painted over it. */ +function withRect(canvas, x0, y0, w, h, r, g, b) { + for (let y = y0; y < y0 + h; y++) { + for (let x = x0; x < x0 + w; x++) { + const i = (y * canvas.width + x) * 4; + canvas.data[i + 0] = r; + canvas.data[i + 1] = g; + canvas.data[i + 2] = b; + canvas.data[i + 3] = 255; + } + } + return canvas; +} + +/** 12 x 8 RGBA: green sheet field + 8 x 4 brown rectangle at (2,2). */ +function greenFieldWithBrownSlab() { + const canvas = { width: 12, height: 8, data: solid(12, 8, 0, 200, 0) }; + return withRect(canvas, 2, 2, 8, 4, 140, 90, 40); +} + +/** 12 x 8 RGBA: near-black sheet + 8 x 4 bright slab at (2,2). */ +function blackFieldWithBrightSlab() { + const canvas = { width: 12, height: 8, data: solid(12, 8, 10, 10, 10) }; + return withRect(canvas, 2, 2, 8, 4, 220, 210, 190); +} + +/** Mask sum over a rectangular region (row-major). */ +function regionSum(mask, w, x0, y0, rw, rh) { + let s = 0; + for (let y = y0; y < y0 + rh; y++) + for (let x = x0; x < x0 + rw; x++) s += mask[y * w + x]; + return s; +} + +const GREEN = { sheet: 'green', sensitivity: 50 }; +const BLACK = { sheet: 'black', sensitivity: 50 }; +const rect = { x0: 2, y0: 2, rw: 8, rh: 4 }; + +// --- green key --------------------------------------------------------------- + +describe('keySheet: green sheet', () => { + it('returns one entry per pixel', () => { + const mask = keySheet(greenFieldWithBrownSlab().data, GREEN); + expect(mask).toBeInstanceOf(Uint8Array); + expect(mask.length).toBe(12 * 8); + }); + + it('removes the strongly green field and keeps the brown slab', () => { + const mask = keySheet(greenFieldWithBrownSlab().data, GREEN); + // Outside the rect the field is uniformly green -> fully removed. + expect(regionSum(mask, 12, 0, 0, 12, 2)).toBe(0); // top strip + expect(regionSum(mask, 12, 0, 6, 12, 2)).toBe(0); // bottom strip + expect(regionSum(mask, 12, 0, 2, 2, 4)).toBe(0); // left strip + expect(regionSum(mask, 12, 10, 2, 2, 4)).toBe(0); // right strip + // The brown rect is fully kept. + expect(regionSum(mask, 12, rect.x0, rect.y0, rect.rw, rect.rh)).toBe(32); + }); +}); + +// --- black key --------------------------------------------------------------- + +describe('keySheet: black sheet', () => { + it('removes the near-black field and keeps the bright slab', () => { + const mask = keySheet(blackFieldWithBrightSlab().data, BLACK); + expect(regionSum(mask, 12, 0, 0, 12, 2)).toBe(0); + expect(regionSum(mask, 12, 0, 6, 12, 2)).toBe(0); + expect(regionSum(mask, 12, 0, 2, 2, 4)).toBe(0); + expect(regionSum(mask, 12, 10, 2, 2, 4)).toBe(0); + expect(regionSum(mask, 12, rect.x0, rect.y0, rect.rw, rect.rh)).toBe(32); + }); +}); + +// --- sensitivity ------------------------------------------------------------- + +describe('keySheet: sensitivity', () => { + it('higher sensitivity keeps >= as many pixels (green, monotone)', () => { + const data = greenFieldWithBrownSlab().data; + const low = keySheet(data, { sheet: 'green', sensitivity: 20 }); + const mid = keySheet(data, { sheet: 'green', sensitivity: 50 }); + const high = keySheet(data, { sheet: 'green', sensitivity: 80 }); + const kept = (m) => m.reduce((a, b) => a + b, 0); + expect(kept(mid)).toBeGreaterThanOrEqual(kept(low)); + expect(kept(high)).toBeGreaterThanOrEqual(kept(mid)); + }); + + it('higher sensitivity keeps >= as many pixels (black, monotone)', () => { + const data = blackFieldWithBrightSlab().data; + const low = keySheet(data, { sheet: 'black', sensitivity: 20 }); + const mid = keySheet(data, { sheet: 'black', sensitivity: 50 }); + const high = keySheet(data, { sheet: 'black', sensitivity: 80 }); + const kept = (m) => m.reduce((a, b) => a + b, 0); + expect(kept(mid)).toBeGreaterThanOrEqual(kept(low)); + expect(kept(high)).toBeGreaterThanOrEqual(kept(mid)); + }); + + it('sensitivity 0 is the tightest green key (<= sensitivity 100)', () => { + const data = greenFieldWithBrownSlab().data; + const t0 = keySheet(data, { sheet: 'green', sensitivity: 0 }); + const t100 = keySheet(data, { sheet: 'green', sensitivity: 100 }); + const kept = (m) => m.reduce((a, b) => a + b, 0); + expect(kept(t100)).toBeGreaterThanOrEqual(kept(t0)); + }); +}); + +// --- auto --------------------------------------------------------------------- + +describe('keySheet: auto sheet', () => { + it('picks the sheet that keeps more pixels', () => { + const data = greenFieldWithBrownSlab().data; + const auto = keySheet(data, { sheet: 'auto', sensitivity: 50 }); + const greenKept = keySheet(data, GREEN).reduce((a, b) => a + b, 0); + const blackKept = keySheet(data, BLACK).reduce((a, b) => a + b, 0); + expect(auto.reduce((a, b) => a + b, 0)).toBe( + Math.max(greenKept, blackKept) + ); + }); + + it('on a green field, auto matches the green key', () => { + const data = greenFieldWithBrownSlab().data; + const auto = keySheet(data, { sheet: 'auto', sensitivity: 50 }); + const green = keySheet(data, GREEN); + for (let i = 0; i < auto.length; i++) expect(auto[i]).toBe(green[i]); + }); +}); + +// --- maskStats ----------------------------------------------------------------- + +describe('maskStats', () => { + it('empty = true when no pixel is kept (approve is blocked)', () => { + const mask = new Uint8Array([0, 0, 0, 0]); + expect(maskStats(mask)).toEqual({ kept: 0, total: 4, empty: true }); + }); + + it('empty = false when any pixel is kept', () => { + const mask = new Uint8Array([0, 1, 0, 1]); + expect(maskStats(mask)).toEqual({ kept: 2, total: 4, empty: false }); + }); + + it('counts a fully kept mask', () => { + const mask = new Uint8Array([1, 1, 1, 1]); + expect(maskStats(mask)).toEqual({ kept: 4, total: 4, empty: false }); + }); + + it('reports the total from the keySheet output', () => { + const mask = keySheet(greenFieldWithBrownSlab().data, GREEN); + expect(maskStats(mask).total).toBe(96); + expect(maskStats(mask).empty).toBe(false); + }); +}); diff --git a/frontend/tests/unit/math.test.js b/frontend/tests/unit/math.test.js new file mode 100644 index 0000000..4bff42f --- /dev/null +++ b/frontend/tests/unit/math.test.js @@ -0,0 +1,89 @@ +import { describe, it, expect } from 'vitest'; +import * as m from '$lib/core/math.js'; + +// M1 deterministic core — unit tests (RED first, per TDD). +// Ground truth for the math identities is the spec (docs/CAPTURE.md): +// scale = longest_axis_px / L +// sqft = (mask_px / scale^2) / 144 +// bdft = sqft * thickness_in +// Widths are sampled every `everyInches` along the approved axis. + +describe('scale', () => { + it('scale = longest_axis_px / L', () => { + expect(m.scale(3360, 84)).toBe(40); + }); + it('scale for fixture-b geometry (21 in)', () => { + // 21 in represented as 3360 px -> 160 px/in + expect(m.scale(3360, 21)).toBeCloseTo(160, 6); + }); +}); + +describe('sqft', () => { + it('sqft = (mask_px / scale^2) / 144, 2 decimals', () => { + // scale=40 -> scale^2=1600; 1,000,000/1600=625; /144=4.3403 -> 4.34 + expect(m.sqft(1_000_000, 40)).toBe(4.34); + }); + it('zero-area mask -> 0', () => { + expect(m.sqft(0, 40)).toBe(0); + }); +}); + +describe('bdft', () => { + it('bdft = sqft * thickness_in, 2 decimals (identity 1 sqft @ 1in = 1 bdft)', () => { + expect(m.bdft(4.34, 1)).toBeCloseTo(4.34, 6); + expect(m.bdft(4.34, 2.5)).toBe(10.85); + }); +}); + +describe('e2e fixture math (cand_b: 21in x 1.75in)', () => { + // If the slab were a clean rectangle exactly 21in long, at 160px/in that is + // 3360 px long. Pick a width of 12in -> 1920px -> area 6,451,200 px. + const Lpx = 3360, L = 21, scale = 160, thickness = 1.75; + const widthPx = 1920; // 12in + const maskPx = Lpx * widthPx; // 6,451,200 + it('sqft of a 21in x 12in slab = 1.75', () => { + expect(m.sqft(maskPx, scale)).toBe(1.75); + }); + it('bdft of that slab at 1.75in = 3.06', () => { + const sqft = m.sqft(maskPx, scale); + expect(m.bdft(sqft, thickness)).toBe(3.06); // 1.75*1.75=3.0625 -> 3.06 (half-up) + }); +}); + +describe('width sampling', () => { + it('samples perpendicular widths at 0,6,12,... in along the axis', () => { + // pxPerIn=160. axisLengthPx = 3360 (21in). widthsAt samples every 6in. + // Provide a constant width of 1920px (12in) at every sample point. + const pxPerIn = 160; + const axisLengthPx = 3360; + const constantWidthPx = 1920; + const out = m.sampledWidths(constantWidthPx, pxPerIn, axisLengthPx, 6); + // 21in / 6 = 3.5 -> sample points at 0,6,12,18 (and not 24>21). 4 samples. + expect(out.length).toBe(4); + for (const w of out) expect(w).toBe(12); // 1920px/160 = 12in + }); + it('min/max/avg of sampled widths', () => { + const pxPerIn = 160; + const widths = m.sampledWidthsFrom( + // widths at px positions 0,960,1920,2880 (=0,6,12,18 in) + [1600, 1920, 2080, 1600], + pxPerIn + ); + expect(widths.min).toBeCloseTo(10, 6); + expect(widths.max).toBeCloseTo(13, 6); + expect(widths.avg).toBeCloseTo(11.25, 6); // (10+12+13+10)/4 = 11.25 + }); +}); + +describe('1/8 inch stepping + rounding', () => { + it('snaps a length to the nearest 1/8 inch', () => { + expect(m.snapToEighth(10.1)).toBeCloseTo(10.125, 6); // 10 1/8 + expect(m.snapToEighth(10.2)).toBeCloseTo(10.25, 6); // 10 2/8=1/4 + expect(m.snapToEighth(10.0)).toBe(10); + }); + it('rounds half-up to 2 decimals', () => { + expect(m.roundHalfUp(30.625, 2)).toBe(30.63); + expect(m.roundHalfUp(4.345, 2)).toBe(4.35); + expect(m.roundHalfUp(1.005, 2)).toBe(1.01); + }); +});