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
This commit is contained in:
Ty
2026-09-14 02:11:28 -07:00
parent 507865e30b
commit b124450d0c
5 changed files with 852 additions and 4 deletions
+64
View File
@@ -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,
};
}
+69
View File
@@ -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;
}
+610 -4
View File
@@ -1,12 +1,618 @@
<script>
/** M1 placeholder home — the 5-state capture flow is built in later cards. */
/**
* M1-5: Full capture flow — Add photo → Mask → Length axis → Measure.
* Drives the pure core modules (mask.js, maskState.js, axis.js, measure.js,
* math.js, inventory.js) with canvas overlays.
*/
import { keySheet, maskStats } from '$lib/core/mask.js';
import {
createMaskState,
reduceMaskState,
computeMask,
RECOMPUTE_DELAY_MS
} from '$lib/core/maskState.js';
import {
minAreaRect,
axisFromRect,
createAxisState,
reduceAxisState,
rotateAxis
} from '$lib/core/axis.js';
import { sampleWidthsPx } from '$lib/core/measure.js';
import { scale as calcScale, sqft, bdft, sampledWidthsFrom } from '$lib/core/math.js';
import { prepareInventoryMetadata } from '$lib/core/inventory.js';
// --- state ----------------------------------------------------------
let state = $state('add_photo'); // add_photo | mask | axis | measure
let photo = $state(null); // ImageBitmap
let photoData = $state(null); // Uint8ClampedArray RGBA
let photoW = $state(0);
let photoH = $state(0);
let maskState = $state(null);
let maskResult = $state(null); // {mask, weights, stats}
let axisState = $state(null);
let rect = $state(null);
let heicNotice = $state(false);
let busy = $state(false);
let debounceTimer = $state(null);
// measure
let lengthIn = $state(48);
let thicknessIn = $state(1);
let result = $state(null);
let pngUrl = $state(null);
// canvas refs
let overlayCanvas = $state(null);
let photoCanvas = $state(null);
// --- photo input ----------------------------------------------------
async function onFileChange(e) {
const file = e.target.files?.[0];
if (!file) return;
heicNotice = false;
const isHeic =
file.type === 'image/heic' || file.type === 'image/heif' ||
file.name.toLowerCase().match(/\.(heic|heif)$/);
let bitmap;
try {
bitmap = await createImageBitmap(file);
} catch {
// Browser cannot decode (HEIC on Chrome/FF)
if (isHeic) {
heicNotice = true;
} else {
heicNotice = true; // undecodable file of any type
}
return;
}
photo = bitmap;
photoW = bitmap.width;
photoH = bitmap.height;
// Draw to canvas to get RGBA data
const c = document.createElement('canvas');
c.width = photoW;
c.height = photoH;
const ctx = c.getContext('2d');
ctx.drawImage(bitmap, 0, 0);
photoData = ctx.getImageData(0, 0, photoW, photoH).data;
// Initialize mask state
maskState = createMaskState({ width: photoW, height: photoH });
maskResult = null;
state = 'mask';
// Trigger initial recompute
setTimeout(() => doRecompute(), 100);
}
function dismissHeic() {
heicNotice = false;
}
// --- mask recompute --------------------------------------------------
function doRecompute() {
if (!photoData || !maskState) return;
busy = true;
// Run in a microtask to let UI paint
setTimeout(() => {
const { mask, weights, stats } = computeMask(photoData, {
width: photoW,
height: photoH,
sheet: maskState.sheet,
sensitivity: maskState.sensitivity,
edgeOffset: maskState.edgeOffset,
feather: maskState.feather
});
maskResult = { mask, weights, stats };
maskState = reduceMaskState(maskState, { type: 'recompute', stats, at: Date.now() });
busy = false;
drawOverlay();
}, 30);
}
function scheduleRecompute() {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => doRecompute(), RECOMPUTE_DELAY_MS);
}
function onSheetChange(sheet) {
maskState = reduceMaskState(maskState, { type: 'set_sheet', sheet });
// Sheet change may recompute immediately
doRecompute();
}
function onSensitivityChange(val) {
maskState = reduceMaskState(maskState, { type: 'set_sensitivity', value: Number(val) });
scheduleRecompute();
}
function nudgeSensitivity(delta) {
maskState = reduceMaskState(maskState, { type: 'nudge_sensitivity', delta });
scheduleRecompute();
}
function onEdgeOffsetChange(val) {
maskState = reduceMaskState(maskState, { type: 'set_edge_offset', value: Number(val) });
scheduleRecompute();
}
function onFeatherChange(val) {
maskState = reduceMaskState(maskState, { type: 'set_feather', value: Number(val) });
scheduleRecompute();
}
function approveMask() {
if (!maskState || maskState.stats.empty) return;
maskState = reduceMaskState(maskState, { type: 'approve' });
// Compute bounding rect + axis
rect = minAreaRect(maskResult.mask, photoW, photoH);
if (rect) {
axisState = createAxisState(rect);
}
state = 'axis';
}
// --- axis ------------------------------------------------------------
function rotateAxisBy(deg) {
if (!axisState) return;
const rad = (deg * Math.PI) / 180;
axisState = reduceAxisState(axisState, { type: 'rotate', angle: rad });
}
function approveAxis() {
if (!axisState) return;
axisState = reduceAxisState(axisState, { type: 'approve' });
state = 'measure';
}
// --- measure ---------------------------------------------------------
function calculate() {
if (!rect || !axisState || !maskResult) return;
const axisLenPx = rect.width >= rect.height ? rect.width : rect.height;
const pxPerIn = calcScale(axisLenPx, lengthIn);
const axis = axisState.axis;
const origin = { x: rect.center.x - axis.x * axisLenPx / 2, y: rect.center.y - axis.y * axisLenPx / 2 };
const axisObj = { origin, dir: axis, lengthPx: axisLenPx };
const widthsPx = sampleWidthsPx(maskResult.mask, photoW, photoH, axisObj, pxPerIn);
const { min, max, avg } = sampledWidthsFrom(widthsPx, pxPerIn);
const sqftVal = sqft(maskResult.stats.kept, pxPerIn);
const bdftVal = bdft(sqftVal, thicknessIn);
const meta = prepareInventoryMetadata(
maskResult.stats.kept, pxPerIn, lengthIn, thicknessIn, widthsPx
);
result = { ...meta, sqft: sqftVal, bdft: bdftVal, min, max, avg };
// Build inventory PNG: slab centered on transparent bg, 3:4 portrait, 80% fill
generatePng();
}
function generatePng() {
const c = document.createElement('canvas');
// 3:4 portrait, shorter side >= 1600 if source allows
const srcMin = Math.min(photoW, photoH);
const shorter = Math.max(1600, Math.min(srcMin, 1600));
const longer = Math.round((shorter * 4) / 3);
c.width = shorter;
c.height = longer;
const ctx = c.getContext('2d');
ctx.clearRect(0, 0, c.width, c.height);
// Scale slab to 80% fill
const slabW = photoW;
const slabH = photoH;
const scaleW = (c.width * 0.8) / slabW;
const scaleH = (c.height * 0.8) / slabH;
const s = Math.min(scaleW, scaleH);
const dw = slabW * s;
const dh = slabH * s;
const dx = (c.width - dw) / 2;
const dy = (c.height - dh) / 2;
// Draw photo, then apply mask as alpha
ctx.drawImage(photo, dx, dy, dw, dh);
const imgData = ctx.getImageData(dx, dy, dw, dh);
// Scale mask to dw x dh
const maskData = maskResult.mask;
for (let y = 0; y < dh; y++) {
for (let x = 0; x < dw; x++) {
const sx = Math.floor((x / dw) * photoW);
const sy = Math.floor((y / dh) * photoH);
const mIdx = sy * photoW + sx;
const a = maskData[mIdx] ? 255 : 0;
const i = (y * dw + x) * 4;
imgData.data[i + 3] = a;
}
}
ctx.putImageData(imgData, dx, dy);
c.toBlob((blob) => {
if (pngUrl) URL.revokeObjectURL(pngUrl);
pngUrl = URL.createObjectURL(blob);
}, 'image/png');
}
// --- overlay drawing -------------------------------------------------
function drawOverlay() {
if (!overlayCanvas || !photo) return;
const canvas = overlayCanvas;
const w = canvas.width;
const h = canvas.height;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, w, h);
// Draw photo
ctx.drawImage(photo, 0, 0, w, h);
if (state === 'mask' && maskResult) {
// Checkerboard on removed pixels
const weights = maskResult.weights;
const mask = maskResult.mask;
const sx = w / photoW;
const sy = h / photoH;
// Draw checkerboard pattern for removed areas
const checker = 8;
for (let y = 0; y < h; y += checker) {
for (let x = 0; x < w; x += checker) {
const px = Math.floor((x / w) * photoW);
const py = Math.floor((y / h) * photoH);
const idx = py * photoW + px;
if (!mask[idx]) {
const shade = ((Math.floor(x / checker) + Math.floor(y / checker)) % 2 === 0)
? 'rgba(200,200,200,0.6)'
: 'rgba(220,220,220,0.6)';
ctx.fillStyle = shade;
ctx.fillRect(x, y, checker, checker);
}
}
}
}
if ((state === 'axis' || state === 'measure') && rect) {
// Draw bounding rectangle
const cx = (rect.center.x / photoW) * w;
const cy = (rect.center.y / photoH) * h;
const rw = (rect.width / photoW) * w;
const rh = (rect.height / photoH) * h;
const angle = rect.angle;
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(angle);
ctx.strokeStyle = '#00ff88';
ctx.lineWidth = 2;
ctx.strokeRect(-rw / 2, -rh / 2, rw, rh);
// Draw axis line on long side
const axis = axisState?.axis;
if (axis) {
const axLen = (Math.max(rect.width, rect.height) / photoW) * w;
const axScreenX = axis.x * axLen;
const axScreenY = axis.y * axLen;
ctx.strokeStyle = '#ff4444';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(-axScreenX / 2, -axScreenY / 2);
ctx.lineTo(axScreenX / 2, axScreenY / 2);
ctx.stroke();
// Arrowhead
const tipX = axScreenX / 2;
const tipY = axScreenY / 2;
const aAng = Math.atan2(axScreenY, axScreenX);
ctx.beginPath();
ctx.moveTo(tipX, tipY);
ctx.lineTo(tipX - 12 * Math.cos(aAng - 0.4), tipY - 12 * Math.sin(aAng - 0.4));
ctx.moveTo(tipX, tipY);
ctx.lineTo(tipX - 12 * Math.cos(aAng + 0.4), tipY - 12 * Math.sin(aAng + 0.4));
ctx.stroke();
}
ctx.restore();
}
}
// --- back navigation -------------------------------------------------
function goBack() {
if (state === 'measure') state = 'axis';
else if (state === 'axis') state = 'mask';
else if (state === 'mask') state = 'add_photo';
}
function reset() {
if (photo) photo.close();
photo = null;
photoData = null;
maskState = null;
maskResult = null;
axisState = null;
rect = null;
result = null;
if (pngUrl) { URL.revokeObjectURL(pngUrl); pngUrl = null; }
state = 'add_photo';
}
// Resize overlay canvas when photo changes
$effect(() => {
if (photoW && photoH && overlayCanvas) {
overlayCanvas.width = photoW;
overlayCanvas.height = photoH;
drawOverlay();
}
});
// Redraw when state or mask changes
$effect(() => {
drawOverlay();
});
</script>
<div class="capture">
<h2>Capture</h2>
<p>The M1 capture flow (Add photo → Mask → Length axis → Measure → PNG) lands in cards M1-3…M1-5.</p>
<h1>SlabUploader</h1>
{#if heicNotice}
<div class="notice" role="alert">
<p>Cannot decode this photo. Please retake as JPEG.</p>
<button onclick={dismissHeic}>OK</button>
</div>
{/if}
{#if state === 'add_photo'}
<section class="step add-photo">
<h2>Add Photo</h2>
<p class="setup-rules">
Photograph the slab on a green or black sheet.<br>
Sheet fills the frame. Slab fully on sheet.<br>
Top-down, lens parallel to the face.<br>
Length aligned with the long side of the picture.
</p>
<input type="file" accept="image/jpeg,image/png" onchange={onFileChange} />
</section>
{:else}
<div class="preview-wrap">
<canvas bind:this={overlayCanvas} class="preview"></canvas>
{#if busy}<div class="busy">Processing…</div>{/if}
</div>
{#if state === 'mask' && maskState}
<section class="step mask-step">
<h2>Mask</h2>
<div class="controls">
<div class="control-row">
<label>Sheet</label>
<div class="seg">
<button class:active={maskState.sheet === 'auto'} onclick={() => onSheetChange('auto')}>Auto</button>
<button class:active={maskState.sheet === 'green'} onclick={() => onSheetChange('green')}>Green</button>
<button class:active={maskState.sheet === 'black'} onclick={() => onSheetChange('black')}>Black</button>
</div>
</div>
<div class="control-row">
<label>Sensitivity</label>
<button class="nudge" onclick={() => nudgeSensitivity(-5)}>−</button>
<input type="range" min="0" max="100" step="1" value={maskState.sensitivity} oninput={onSensitivityChange} />
<span class="val">{maskState.sensitivity}</span>
<button class="nudge" onclick={() => nudgeSensitivity(5)}>+</button>
</div>
<div class="control-row">
<label>Edge offset</label>
<button class="nudge" onclick={() => onEdgeOffsetChange(maskState.edgeOffset - 1)}>−</button>
<span class="val">{maskState.edgeOffset}px</span>
<button class="nudge" onclick={() => onEdgeOffsetChange(maskState.edgeOffset + 1)}>+</button>
</div>
<div class="control-row">
<label>Feather</label>
<button class="nudge" onclick={() => onFeatherChange(Math.max(0, maskState.feather - 1))}>−</button>
<span class="val">{maskState.feather}px</span>
<button class="nudge" onclick={() => onFeatherChange(maskState.feather + 1)}>+</button>
</div>
</div>
<div class="actions">
<button class="back" onclick={goBack}>Back</button>
<button class="approve" disabled={maskState.stats.empty} onclick={approveMask}>
Approve Mask
</button>
</div>
{#if maskState.stats.empty}
<p class="warn">No slab detected — adjust sensitivity or retake.</p>
{/if}
</section>
{/if}
{#if state === 'axis' && axisState}
<section class="step axis-step">
<h2>Length Axis</h2>
<p>Rotate the axis to follow the slab length, then approve.</p>
<div class="controls">
<div class="control-row">
<label>Rotate</label>
<button class="nudge" onclick={() => rotateAxisBy(-5)}>−5°</button>
<button class="nudge" onclick={() => rotateAxisBy(5)}>+5°</button>
<button class="nudge" onclick={() => rotateAxisBy(90)}>90°</button>
</div>
</div>
<div class="actions">
<button class="back" onclick={goBack}>Back</button>
<button class="approve" onclick={approveAxis}>Approve Axis</button>
</div>
</section>
{/if}
{#if state === 'measure'}
<section class="step measure-step">
<h2>Measure</h2>
<div class="controls">
<div class="control-row">
<label>Length (in)</label>
<input type="number" min="1" step="0.125" value={lengthIn} oninput={(e) => lengthIn = parseFloat(e.target.value) || 0} />
</div>
<div class="control-row">
<label>Thickness (in)</label>
<input type="number" min="0.125" step="0.125" value={thicknessIn} oninput={(e) => thicknessIn = parseFloat(e.target.value) || 0} />
</div>
</div>
<div class="actions">
<button class="back" onclick={goBack}>Back</button>
<button class="approve" onclick={calculate}>Calculate</button>
</div>
{#if result}
<div class="results">
<h3>Results</h3>
<dl>
<dt>Width (min)</dt><dd>{result.min?.toFixed(2)} in</dd>
<dt>Width (max)</dt><dd>{result.max?.toFixed(2)} in</dd>
<dt>Width (avg)</dt><dd>{result.avg?.toFixed(2)} in</dd>
<dt>Length</dt><dd>{result.lengthIn} in</dd>
<dt>Thickness</dt><dd>{result.thicknessIn} in</dd>
<dt>Scale</dt><dd>{result.scale?.toFixed(2)} px/in</dd>
<dt>Sq Ft</dt><dd>{result.sqft} ft²</dd>
<dt>Board Feet</dt><dd>{result.bdft} bdft</dd>
</dl>
{#if pngUrl}
<h3>Inventory PNG</h3>
<img src={pngUrl} alt="Inventory PNG" class="png-preview" />
{/if}
<button class="reset" onclick={reset}>Start New Slab</button>
</div>
{/if}
</section>
{/if}
{/if}
</div>
<style>
.capture { max-width: 720px; margin: 2rem auto; padding: 0 1rem; }
.capture {
max-width: 720px;
margin: 0 auto;
padding: 1rem;
font-family: system-ui, sans-serif;
}
h1 { text-align: center; font-size: 1.4rem; margin-bottom: 1rem; }
h2 { font-size: 1.1rem; margin-bottom: 0.5rem; }
.preview-wrap {
position: relative;
width: 100%;
max-height: 50vh;
overflow: hidden;
border-radius: 8px;
border: 1px solid #ddd;
background: #f5f5f5;
}
.preview {
width: 100%;
height: auto;
display: block;
}
.busy {
position: absolute;
top: 8px;
right: 8px;
background: rgba(0,0,0,0.6);
color: #fff;
padding: 4px 10px;
border-radius: 4px;
font-size: 0.8rem;
}
.step { margin-top: 1rem; }
.setup-rules { font-size: 0.9rem; color: #555; margin-bottom: 1rem; line-height: 1.5; }
.controls { display: flex; flex-direction: column; gap: 0.75rem; margin: 0.5rem 0; }
.control-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.control-row label { min-width: 100px; font-weight: 600; font-size: 0.9rem; }
.control-row input[type="range"] { flex: 1; min-width: 120px; }
.control-row input[type="number"] { width: 80px; padding: 6px; }
.val { min-width: 40px; text-align: center; font-size: 0.9rem; }
.seg { display: flex; gap: 0; }
.seg button {
padding: 8px 16px;
border: 1px solid #ccc;
background: #fff;
cursor: pointer;
font-size: 0.9rem;
}
.seg button:first-child { border-radius: 6px 0 0 6px; }
.seg button:last-child { border-radius: 0 6px 6px 0; }
.seg button.active { background: #333; color: #fff; border-color: #333; }
.nudge {
width: 44px;
height: 44px;
border-radius: 50%;
border: 1px solid #ccc;
background: #fff;
font-size: 1rem;
cursor: pointer;
}
.nudge:active { background: #eee; }
.actions {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
}
.actions button {
padding: 12px 24px;
border-radius: 8px;
border: none;
font-size: 1rem;
cursor: pointer;
}
.back { background: #eee; }
.approve { background: #22c55e; color: #fff; }
.approve:disabled { background: #aaa; cursor: not-allowed; }
.warn { color: #dc2626; font-size: 0.9rem; margin-top: 0.5rem; }
.notice {
background: #fef3cd;
border: 1px solid #ffc107;
padding: 1rem;
border-radius: 8px;
margin: 1rem 0;
}
.notice button {
margin-top: 0.5rem;
padding: 8px 16px;
border: none;
border-radius: 6px;
background: #333;
color: #fff;
}
.results { margin-top: 1.5rem; }
.results h3 { font-size: 1rem; margin-top: 1rem; }
.results dl {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.25rem 1rem;
}
.results dt { font-weight: 600; font-size: 0.9rem; }
.results dd { font-size: 0.9rem; color: #333; }
.png-preview {
max-width: 100%;
max-height: 400px;
border: 1px solid #ddd;
border-radius: 8px;
margin-top: 0.5rem;
background: repeating-conic-gradient(#f0f0f0 0% 25%, #fff 0% 50%) 0 0 / 20px 20px;
}
.reset {
margin-top: 1rem;
padding: 12px 24px;
border-radius: 8px;
border: none;
background: #6366f1;
color: #fff;
font-size: 1rem;
cursor: pointer;
}
</style>
+35
View File
@@ -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);
});
});
+74
View File
@@ -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);
});
});