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