test(photos): dimension-overlay PNG stations + results.overlayPng (#63)

Failing TDD for M2 dimension-overlay copy of the calibration inventory PNG:
width stations every 12 in from EXIF bottom (6, 18, … ≤ L−6), axis-right
length graphic, and Calculate storing overlayPng without touching batchApply[cal].
This commit is contained in:
2026-09-15 21:29:59 +00:00
parent 3936c575b2
commit c674931f5c
2 changed files with 471 additions and 0 deletions
+185
View File
@@ -0,0 +1,185 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
overlayWidthStations,
pickBottomTowardTop,
axisRightUnit,
buildOverlayPlan,
type OverlayPlan,
} from './overlay.ts';
import { orientedRect, minAreaRect } from '../geometry/rect.ts';
// --- Station math (#63) ---
test('overlayWidthStations: every 12 in from 6 while ≤ L−6 (#63)', () => {
assert.deepEqual(overlayWidthStations(48), [6, 18, 30, 42]);
assert.deepEqual(overlayWidthStations(40), [6, 18, 30]);
assert.deepEqual(overlayWidthStations(12), [6]);
assert.deepEqual(overlayWidthStations(18), [6]);
assert.deepEqual(overlayWidthStations(19), [6, 18]);
assert.deepEqual(overlayWidthStations(11), []);
assert.deepEqual(overlayWidthStations(10), []);
assert.deepEqual(overlayWidthStations(6), []);
});
test('overlayWidthStations: never tip-inclusive 0 or L (#63)', () => {
for (const L of [24, 36, 48, 60, 72]) {
const stations = overlayWidthStations(L);
assert.ok(!stations.includes(0), `L=${L} must not include tip 0`);
assert.ok(!stations.includes(L), `L=${L} must not include tip L`);
for (const s of stations) {
assert.ok(s >= 6 && s <= L - 6, `station ${s} within [6, L−6] for L=${L}`);
assert.equal((s - 6) % 12, 0, `station ${s} is 6+12k`);
}
}
});
// --- EXIF-oriented bottom along approved axis (#63) ---
test('pickBottomTowardTop: larger y is bottom (EXIF-oriented photo, y↓) (#63)', () => {
// Axis along +y (down the photo): start at top, end at bottom of span
const start: [number, number] = [100, 50]; // top of photo
const end: [number, number] = [100, 450]; // bottom of photo
const { bottom, top, ux, uy } = pickBottomTowardTop(start, end);
assert.deepEqual(bottom, end, 'end nearer photo bottom is bottom');
assert.deepEqual(top, start);
// Unit from bottom → top points up (−y)
assert.ok(Math.abs(ux) < 1e-9);
assert.ok(uy < 0, `uy=${uy} must point toward photo top`);
});
test('pickBottomTowardTop: flipped axis endpoints still pick EXIF bottom (#63)', () => {
const a: [number, number] = [50, 400]; // bottom-ish
const b: [number, number] = [50, 40]; // top-ish
const r1 = pickBottomTowardTop(a, b);
const r2 = pickBottomTowardTop(b, a);
assert.deepEqual(r1.bottom, a);
assert.deepEqual(r2.bottom, a);
assert.deepEqual(r1.top, b);
assert.deepEqual(r2.top, b);
});
test('pickBottomTowardTop: length-axis direction stays on approved span (not photo upright) (#63)', () => {
// Diagonal approved axis — bottom is still the larger-y end, unit follows the diagonal
const start: [number, number] = [10, 10];
const end: [number, number] = [210, 410];
const { bottom, top, ux, uy } = pickBottomTowardTop(start, end);
assert.deepEqual(bottom, end);
assert.deepEqual(top, start);
const len = Math.hypot(top[0] - bottom[0], top[1] - bottom[1]);
assert.ok(Math.abs(Math.hypot(ux, uy) - 1) < 1e-9);
assert.ok(Math.abs(ux - (top[0] - bottom[0]) / len) < 1e-9);
assert.ok(Math.abs(uy - (top[1] - bottom[1]) / len) < 1e-9);
});
test('axisRightUnit: right of bottom→top travel in canvas (y↓) (#63)', () => {
// Travel up (−y): facing up, right is +x
const [rx, ry] = axisRightUnit(0, -1);
assert.ok(rx > 0.99 && Math.abs(ry) < 1e-9, `right of up should be +x, got (${rx},${ry})`);
// Travel +x (right): facing right, right is +y (down)
const [rx2, ry2] = axisRightUnit(1, 0);
assert.ok(Math.abs(rx2) < 1e-9 && ry2 > 0.99, `right of +x should be +y, got (${rx2},${ry2})`);
});
// --- Overlay plan geometry (#63) ---
test('buildOverlayPlan: length line on axis-right outside slab; widths at 12in stations (#63)', () => {
// Uniform 480×100 slab @ 10 px/in → L=48in, axis +x. Photo taller so bottom is clear.
// Place slab in a tall canvas so EXIF bottom (large y) picks the +x? Wait axis is +x (horizontal).
// For horizontal axis both ends have same y — tie-break must be stable.
const W = 480, H = 200;
const bitset = new Uint8Array(W * H);
// Slab occupies y=50..149, x=0..479 (full width strip)
for (let y = 50; y < 150; y++) for (let x = 0; x < W; x++) bitset[y * W + x] = 1;
const box = orientedRect(minAreaRect(bitset, W, H)!, 0); // long side +x
const scale = 10; // px/in
const lengthIn = 48;
const plan = buildOverlayPlan({
bitset,
widthPx: W,
heightPx: H,
bbox: box,
scale,
lengthIn,
lengthLabel: '48"',
// Identity inventory map (source == inventory) for unit geometry
mapToInventory: (x, y) => [x, y],
});
assert.ok(plan.length, 'must include length dimension');
assert.equal(plan.widths.length, 4, 'stations 6,18,30,42');
assert.deepEqual(
plan.widths.map((w) => w.stationIn),
[6, 18, 30, 42],
);
// Length graphic must sit to the axis-right of the wood (outside edge)
const mid = plan.length;
// For axis +x, bottom tie-break: if y equal, pickBottomTowardTop uses a documented rule.
// Right of bottom→top should be outside the slab along the short axis.
assert.ok(
mid.outside,
'length dimension must be flagged outside the slab on axis-right',
);
// Width arrows: tips at left/right slab edges (perp extent), text on line
for (const w of plan.widths) {
assert.ok(w.left && w.right, 'width has left/right tips');
assert.ok(typeof w.label === 'string' && w.label.length > 0, 'width has measurement text');
// Across wood: left and right differ primarily along short axis (±y for +x length)
const dx = w.right[0] - w.left[0];
const dy = w.right[1] - w.left[1];
assert.ok(Math.abs(dy) > Math.abs(dx), 'width span is across (perp to +x length)');
}
// Color contract for draw consumers
assert.equal(plan.stroke, '#000000');
assert.ok(plan.fontPx >= 24, `fontPx ${plan.fontPx} large enough to read`);
});
test('buildOverlayPlan: stations measured from EXIF bottom, not tip-inclusive M1 every-6 (#63)', () => {
// Vertical slab: length along +y (photo upright matches axis). Bottom = larger y.
const W = 100, H = 480;
const bitset = new Uint8Array(W * H).fill(1);
const box = orientedRect(minAreaRect(bitset, W, H)!, 90); // long side +y
const plan = buildOverlayPlan({
bitset,
widthPx: W,
heightPx: H,
bbox: box,
scale: 10,
lengthIn: 48,
lengthLabel: '48"',
mapToInventory: (x, y) => [x, y],
});
assert.deepEqual(
plan.widths.map((w) => w.stationIn),
[6, 18, 30, 42],
'must be 12in overlay stations, not M1 every-6 tip-trimmed list',
);
// First station is 6in up from photo-bottom end of span
const first = plan.widths[0];
// Bottom of photo is y=H-ish; 6in @ 10px/in = 60px toward top → y ≈ H-1 - 60
assert.ok(first.center[1] > H / 2, 'first station nearer the photo bottom half');
});
test('buildOverlayPlan: dishonest upright-photo axes rejected — uses approved bbox axis (#63)', () => {
// Slab long along +x but we approve axis at 0; plan length direction must follow bbox, not image H.
const W = 400, H = 120;
const bitset = new Uint8Array(W * H).fill(1);
const box = orientedRect(minAreaRect(bitset, W, H)!, 0);
const plan: OverlayPlan = buildOverlayPlan({
bitset,
widthPx: W,
heightPx: H,
bbox: box,
scale: 10,
lengthIn: 40,
lengthLabel: '40"',
mapToInventory: (x, y) => [x, y],
});
// Length line endpoints should differ mainly in x (along approved axis), not y
const lx = Math.abs(plan.length.a[0] - plan.length.b[0]);
const ly = Math.abs(plan.length.a[1] - plan.length.b[1]);
assert.ok(lx > ly * 2, `length graphic follows approved +x axis (dx=${lx}, dy=${ly})`);
});