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