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
+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);
});
});