Files
SlabUploader/frontend/src/png/overlay.test.ts
T

1135 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
overlayWidthStations,
pickBottomTowardTop,
axisRightUnit,
buildOverlayPlan,
inventorySourceMap,
BODY_WIDTH_FRAC,
type OverlayPlan,
} from './overlay.ts';
import { orientedRect, minAreaRect } from '../geometry/rect.ts';
import { axisEndpoints } from '../measure/measure.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]); // L−6=13 → 18 excluded
assert.deepEqual(overlayWidthStations(11), []);
assert.deepEqual(overlayWidthStations(10), []);
assert.deepEqual(overlayWidthStations(6), []);
assert.deepEqual(overlayWidthStations(24), [6, 18]);
assert.deepEqual(overlayWidthStations(30), [6, 18]);
});
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 >= 12, `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})`);
});
// --- Ty UAT nits (#67) ---
test('buildOverlayPlan: length endpoints snap to mask tips, not oversized bbox corners (#67)', () => {
// Wood occupies x=100..299 only; synthetic bbox long side extends past tips (x=50..350).
const W = 400, H = 160;
const bitset = new Uint8Array(W * H);
for (let y = 40; y < 100; y++) for (let x = 100; x < 300; x++) bitset[y * W + x] = 1;
const bbox = {
corners: [
[350, 100],
[350, 40],
[50, 40],
[50, 100],
] as [number, number][],
longSidePx: 300,
angleDeg: 0,
};
const plan = buildOverlayPlan({
bitset,
widthPx: W,
heightPx: H,
bbox,
scale: 10,
lengthIn: 20,
lengthLabel: '20"',
mapToInventory: (x, y) => [x, y],
});
// Length line sits on axis-right; project a/b onto the length axis through the
// wood centroid — endpoints must match mask span ends, not bbox corners.
const oriented = pickBottomTowardTop([100, 70], [299, 70]);
const strip = (p: [number, number]): [number, number] => {
const cx = 199.5, cy = 70;
const along = (p[0] - cx) * oriented.ux + (p[1] - cy) * oriented.uy;
return [cx + oriented.ux * along, cy + oriented.uy * along];
};
const xs = [strip(plan.length.a)[0], strip(plan.length.b)[0]].sort((a, b) => a - b);
// Mask tips ≈ 100 and 299 (inclusive pixels); bbox corners at 50 and 350
assert.ok(Math.abs(xs[0] - 100) <= 2, `mask min tip ${xs[0]} ≈ 100, not bbox 50`);
assert.ok(Math.abs(xs[1] - 299) <= 2, `mask max tip ${xs[1]} ≈ 299, not bbox 350`);
assert.ok(Math.abs(xs[0] - 50) > 20, 'must not use bbox corner at 50');
assert.ok(Math.abs(xs[1] - 350) > 20, 'must not use bbox corner at 350');
});
test('buildOverlayPlan: width labels sit axis-left outside the slab (#67)', () => {
// Uniform strip: length +x, width along y. Axis-right of bottom→top is −y (up).
const W = 480, H = 200;
const bitset = new Uint8Array(W * H);
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);
const plan = buildOverlayPlan({
bitset,
widthPx: W,
heightPx: H,
bbox: box,
scale: 10,
lengthIn: 48,
lengthLabel: '48"',
mapToInventory: (x, y) => [x, y],
});
assert.ok(plan.widths.length > 0);
for (const w of plan.widths) {
assert.ok(w.labelAt, 'width mark must expose labelAt');
// left/right tips still on wood edges
assert.ok(Math.abs(w.left[1] - 149) <= 2 || Math.abs(w.left[1] - 50) <= 2);
assert.ok(Math.abs(w.right[1] - 149) <= 2 || Math.abs(w.right[1] - 50) <= 2);
// labelAt on axis-left = opposite of axis-right from center
// For this geometry, axis-right is toward smaller y; axis-left toward larger y
// (outside past the bottom edge y≈149).
const cy = w.center[1];
assert.ok(
w.labelAt[1] > Math.max(w.left[1], w.right[1]) + 4,
`width labelAt y=${w.labelAt[1]} must sit past slab edge (left of travel), not on wood center y=${cy}`,
);
assert.ok(
Math.abs(w.labelAt[0] - w.center[0]) < 3,
'width label stays on the station cross-section (same x as center)',
);
}
// Length stays axis-right (outside opposite side)
const lenY = (plan.length.a[1] + plan.length.b[1]) / 2;
assert.ok(lenY < 50 - 4, `length dim stays axis-right outside top edge, got y=${lenY}`);
});
test('buildOverlayPlan: font smaller than prior 28/scale*1.2 and 22/scale*1.0 (#67,#71)', () => {
const W = 480, H = 200;
const bitset = new Uint8Array(W * H);
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);
const scale = 10;
const plan = buildOverlayPlan({
bitset,
widthPx: W,
heightPx: H,
bbox: box,
scale,
lengthIn: 48,
lengthLabel: '48"',
mapToInventory: (x, y) => [x, y],
});
const oldFont67 = Math.max(28, Math.round(scale * 1.2));
const oldFont71 = Math.max(22, Math.round(scale * 1.0));
assert.ok(plan.fontPx < oldFont67, `fontPx ${plan.fontPx} must be < prior ${oldFont67}`);
assert.ok(plan.fontPx < oldFont71, `fontPx ${plan.fontPx} must be < prior ${oldFont71}`);
assert.ok(plan.fontPx >= 12, `fontPx ${plan.fontPx} still legible`);
// Concrete contract (#71): capped by scale when no inventory size
assert.equal(plan.fontPx, Math.max(12, Math.min(16, Math.round(scale * 0.18))));
});
// --- Length dim must not overshoot wood footprint (#69) ---
/** Tilted-axis slab: irregular mask + approved bbox long side ~10° from vertical. */
function tiltedSlabFixture() {
const W = 300, H = 600;
const bitset = new Uint8Array(W * H);
for (let y = 50; y <= 550; y++) {
const t = (y - 50) / 500;
const x0 = Math.round(80 + t * 20);
const x1 = Math.round(180 + t * 40);
for (let x = x0; x <= x1; x++) bitset[y * W + x] = 1;
}
const ang = (10 * Math.PI) / 180;
const ux0 = Math.sin(ang);
const uy0 = Math.cos(ang);
const rx0 = -uy0;
const ry0 = ux0;
const cx = 140, cy = 300, halfL = 280, halfW = 90;
const corners: [number, number][] = [
[cx + ux0 * halfL + rx0 * halfW, cy + uy0 * halfL + ry0 * halfW],
[cx + ux0 * halfL - rx0 * halfW, cy + uy0 * halfL - ry0 * halfW],
[cx - ux0 * halfL - rx0 * halfW, cy - uy0 * halfL - ry0 * halfW],
[cx - ux0 * halfL + rx0 * halfW, cy - uy0 * halfL + ry0 * halfW],
];
const bbox = { corners, longSidePx: halfL * 2, angleDeg: 80 };
return { W, H, bitset, bbox };
}
function maskWoodAabb(bitset: Uint8Array, W: number, H: number) {
let minX = W, maxX = 0, minY = H, maxY = 0;
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
if (!bitset[y * W + x]) continue;
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
return { minX, maxX, minY, maxY };
}
test('buildOverlayPlan: length axial span = robust tip stations; no AABB overshoot along axis (#69)', () => {
const { W, H, bitset, bbox } = tiltedSlabFixture();
const scale = 10;
const wood = maskWoodAabb(bitset, W, H);
const slabBounds = {
x: wood.minX - 8,
y: wood.minY - 8,
w: wood.maxX - wood.minX + 16,
h: wood.maxY - wood.minY + 16,
};
const inv = inventorySourceMap(slabBounds, { widthPx: W, heightPx: H }, 900, 1200);
const plan = buildOverlayPlan({
bitset,
widthPx: W,
heightPx: H,
bbox,
scale,
lengthIn: 50,
lengthLabel: '50"',
mapToInventory: inv.mapToInventory,
});
// Wood AABB in inventory space (for axis-right clearance only)
let mMinX = Infinity, mMaxX = -Infinity;
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
if (!bitset[y * W + x]) continue;
const [mx] = inv.mapToInventory(x, y);
if (mx < mMinX) mMinX = mx;
if (mx > mMaxX) mMaxX = mx;
}
}
// Width-aware tip stations along approved bbox axis (#71; was 0.2%/99.8%)
const { start: aa, end: bb } = axisEndpoints(bbox);
const ex = bb[0] - aa[0], ey = bb[1] - aa[1], elen = Math.hypot(ex, ey) || 1;
const ux = ex / elen, uy = ey / elen;
const cx = bbox.corners.reduce((s, c) => s + c[0], 0) / 4;
const cy = bbox.corners.reduce((s, c) => s + c[1], 0) / 4;
const cProj = cx * ux + cy * uy;
const rx = -uy, ry = ux;
const binMin = new Map<number, number>();
const binMax = new Map<number, number>();
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
if (!bitset[y * W + x]) continue;
const along = x * ux + y * uy;
const across = (x - cx) * rx + (y - cy) * ry;
const bin = Math.round(along);
const prevMin = binMin.get(bin);
if (prevMin === undefined || across < prevMin) binMin.set(bin, across);
const prevMax = binMax.get(bin);
if (prevMax === undefined || across > prevMax) binMax.set(bin, across);
}
}
let maxWidth = 0;
const widths: { bin: number; width: number }[] = [];
for (const [bin, minA] of binMin) {
const maxA = binMax.get(bin)!;
const width = maxA - minA + 1;
widths.push({ bin, width });
if (width > maxWidth) maxWidth = width;
}
const thresh = Math.max(4, maxWidth * BODY_WIDTH_FRAC);
widths.sort((a, b) => a.bin - b.bin);
let tipLo = widths[0].bin, tipHi = widths[widths.length - 1].bin;
for (const { bin, width } of widths) {
if (width >= thresh) { tipLo = bin; break; }
}
for (let i = widths.length - 1; i >= 0; i--) {
if (widths[i].width >= thresh) { tipHi = widths[i].bin; break; }
}
const tipStart: [number, number] = [cx + ux * (tipLo - cProj), cy + uy * (tipLo - cProj)];
const tipEnd: [number, number] = [cx + ux * (tipHi - cProj), cy + uy * (tipHi - cProj)];
const oriented = pickBottomTowardTop(tipStart, tipEnd);
// Inventory map is isotropic scale+translate → directions preserved; axial span scales.
const mapBot = inv.mapToInventory(oriented.bottom[0], oriented.bottom[1]);
const mapTop = inv.mapToInventory(oriented.top[0], oriented.top[1]);
const tipAxial = Math.hypot(mapTop[0] - mapBot[0], mapTop[1] - mapBot[1]);
const lenAxial = Math.hypot(plan.length.b[0] - plan.length.a[0], plan.length.b[1] - plan.length.a[1]);
// Axial edge-inset (#71) shortens span slightly vs tip stations
assert.ok(
lenAxial <= tipAxial + 2 && lenAxial >= tipAxial - 8,
`length span ${lenAxial.toFixed(1)} must match width-aware tip span ${tipAxial.toFixed(1)} (allow small axial inset only; #73)`,
);
// Axial stations of length ends = tip stations (pure axis-right offset does not change along-u).
const mapOrigin = inv.mapToInventory(0, 0);
const mapU = inv.mapToInventory(oriented.ux, oriented.uy);
const iux = mapU[0] - mapOrigin[0];
const iuy = mapU[1] - mapOrigin[1];
const ilen = Math.hypot(iux, iuy) || 1;
const nux = iux / ilen, nuy = iuy / ilen;
const lenProjs = [plan.length.a[0] * nux + plan.length.a[1] * nuy, plan.length.b[0] * nux + plan.length.b[1] * nuy].sort(
(a, b) => a - b,
);
const tipProjs = [mapBot[0] * nux + mapBot[1] * nuy, mapTop[0] * nux + mapTop[1] * nuy].sort((a, b) => a - b);
assert.ok(Math.abs(lenProjs[0] - tipProjs[0]) <= 14, `length min station ${lenProjs[0]} ≈ tip ${tipProjs[0]} (inset)`);
assert.ok(Math.abs(lenProjs[1] - tipProjs[1]) <= 14, `length max station ${lenProjs[1]} ≈ tip ${tipProjs[1]} (inset)`);
// Length stays axis-right of wood (clearance to the right in inventory x for this fixture)
const lenMidX = (plan.length.a[0] + plan.length.b[0]) / 2;
assert.ok(lenMidX > mMaxX + 4, `length dim must sit outside wood on axis-right (x=${lenMidX} vs maxX=${mMaxX})`);
});
test('buildOverlayPlan: width labels fully left of wood AABB after inventory map (#69)', () => {
const { W, H, bitset, bbox } = tiltedSlabFixture();
const scale = 10;
const wood = maskWoodAabb(bitset, W, H);
const slabBounds = {
x: wood.minX - 8,
y: wood.minY - 8,
w: wood.maxX - wood.minX + 16,
h: wood.maxY - wood.minY + 16,
};
const inv = inventorySourceMap(slabBounds, { widthPx: W, heightPx: H }, 900, 1200);
const plan = buildOverlayPlan({
bitset,
widthPx: W,
heightPx: H,
bbox,
scale,
lengthIn: 50,
lengthLabel: '50"',
mapToInventory: inv.mapToInventory,
});
assert.ok(plan.widths.length > 0, 'expected width stations');
let mMinX = Infinity, mMaxX = -Infinity, mMinY = Infinity, mMaxY = -Infinity;
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
if (!bitset[y * W + x]) continue;
const [mx, my] = inv.mapToInventory(x, y);
if (mx < mMinX) mMinX = mx;
if (mx > mMaxX) mMaxX = mx;
if (my < mMinY) mMinY = my;
if (my > mMaxY) mMaxY = my;
}
}
for (const w of plan.widths) {
// Label anchor fully off the wood footprint (axis-left), with margin for glyph half-width
const margin = plan.fontPx * 0.55; // textAlign will grow away from wood; still keep anchor clear
const onWood =
w.labelAt[0] >= mMinX - 1 &&
w.labelAt[0] <= mMaxX + 1 &&
w.labelAt[1] >= mMinY - 1 &&
w.labelAt[1] <= mMaxY + 1;
assert.ok(
!onWood,
`width labelAt (${w.labelAt[0].toFixed(0)},${w.labelAt[1].toFixed(0)}) must be outside wood AABB`,
);
// Axis-left = past the left tip (smaller across); for this nearly-vertical slab, left of wood in x
assert.ok(
w.labelAt[0] < mMinX - 2,
`width label ${w.label} x=${w.labelAt[0].toFixed(0)} must be left of wood minX=${mMinX.toFixed(0)} (margin~${margin.toFixed(0)})`,
);
// Width arrows still touch wood edges (left/right tips inside/on AABB)
assert.ok(w.left[0] >= mMinX - 3 && w.left[0] <= mMaxX + 3);
assert.ok(w.right[0] >= mMinX - 3 && w.right[0] <= mMaxX + 3);
}
});
// --- Length/width dims must follow approved axis, not screen tip hack (#70) ---
// --- Length/width dims must follow approved axis, not screen tip hack (#70) ---
/**
* ~25° approved axis. Irregular tips sit far off the centerline on opposite
* sides so tip-to-tip ≠ (ux,uy). #69 screen-hack follows tip pixels and fails
* the parallel-axis / pure axis-right offset asserts; classic offset passes.
*/
function tiltedAxis25Fixture() {
const W = 420, H = 520;
const ang = (25 * Math.PI) / 180;
const ux0 = Math.sin(ang);
const uy0 = Math.cos(ang);
const rx0 = -uy0;
const ry0 = ux0;
const cx = 210, cy = 260, halfL = 160, halfW = 50;
const bitset = new Uint8Array(W * H);
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const along = (x - cx) * ux0 + (y - cy) * uy0;
const across = (x - cx) * rx0 + (y - cy) * ry0;
const t = (along + halfL) / (2 * halfL);
// Strong opposite bias at the tips (±0.85*halfW) so tip pixels diverge from axis
const centerAcross = (t - 0.5) * 2 * halfW * 0.85;
const halfAt = halfW * 0.35;
if (Math.abs(along) <= halfL && Math.abs(across - centerAcross) <= halfAt) {
bitset[y * W + x] = 1;
}
}
}
const corners: [number, number][] = [
[cx + ux0 * halfL + rx0 * halfW, cy + uy0 * halfL + ry0 * halfW],
[cx + ux0 * halfL - rx0 * halfW, cy + uy0 * halfL - ry0 * halfW],
[cx - ux0 * halfL - rx0 * halfW, cy - uy0 * halfL - ry0 * halfW],
[cx - ux0 * halfL + rx0 * halfW, cy - uy0 * halfL + ry0 * halfW],
];
const bbox = { corners, longSidePx: halfL * 2, angleDeg: 90 - 25 };
return { W, H, bitset, bbox, ux0, uy0, rx0, ry0, halfL, cx, cy };
}
test('buildOverlayPlan: angleDeg=90 vertical long side → width left→right within 2° of horizontal (#72)', () => {
// Upright slab: approved length axis is vertical (angleDeg 90). Width marks must
// run left→right ≈ horizontal (atan2 ≈ 0 or ±π), not slanted with the photo.
const W = 200, H = 400;
const bitset = new Uint8Array(W * H);
for (let y = 40; y <= 360; y++) {
for (let x = 60; x <= 140; x++) bitset[y * W + x] = 1;
}
const prev = minAreaRect(bitset, W, H)!;
const bbox = orientedRect(prev, 90);
assert.equal(bbox.angleDeg, 90);
const scale = 10; // 320px span ≈ 32"
const plan = buildOverlayPlan({
bitset,
widthPx: W,
heightPx: H,
bbox,
scale,
lengthIn: 32,
lengthLabel: '32"',
mapToInventory: (x, y) => [x, y],
});
assert.ok(plan.widths.length > 0, 'expected width stations');
for (const w of plan.widths) {
const ang = Math.atan2(w.right[1] - w.left[1], w.right[0] - w.left[0]);
// Fold to [-90, 90] so left→right and right→left both count as horizontal.
let deg = (ang * 180) / Math.PI;
if (deg > 90) deg -= 180;
if (deg < -90) deg += 180;
assert.ok(
Math.abs(deg) <= 2,
`width station ${w.stationIn} left→right must be within 2° of horizontal (atan2 deg=${deg.toFixed(2)}; left=${w.left} right=${w.right})`,
);
}
});
test('buildOverlayPlan: length a→b || approved (ux,uy); width left→right || (rx,ry) (#70)', () => {
const { W, H, bitset, bbox, cx, cy } = tiltedAxis25Fixture();
const scale = 10;
const plan = buildOverlayPlan({
bitset,
widthPx: W,
heightPx: H,
bbox,
scale,
lengthIn: 32,
lengthLabel: '32"',
mapToInventory: (x, y) => [x, y],
});
const { start: aa, end: bb } = axisEndpoints(bbox);
const ex = bb[0] - aa[0], ey = bb[1] - aa[1], elen = Math.hypot(ex, ey) || 1;
const uxB = ex / elen, uyB = ey / elen;
const cProj = cx * uxB + cy * uyB;
const rxB = -uyB, ryB = uxB;
const binMin = new Map<number, number>();
const binMax = new Map<number, number>();
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
if (!bitset[y * W + x]) continue;
const along = x * uxB + y * uyB;
const across = (x - cx) * rxB + (y - cy) * ryB;
const bin = Math.round(along);
const prevMin = binMin.get(bin);
if (prevMin === undefined || across < prevMin) binMin.set(bin, across);
const prevMax = binMax.get(bin);
if (prevMax === undefined || across > prevMax) binMax.set(bin, across);
}
}
let maxW = 0;
const wbins: { bin: number; width: number }[] = [];
for (const [bin, minA] of binMin) {
const maxA = binMax.get(bin)!;
const width = maxA - minA + 1;
wbins.push({ bin, width });
if (width > maxW) maxW = width;
}
const thresh = Math.max(4, maxW * BODY_WIDTH_FRAC);
wbins.sort((a, b) => a.bin - b.bin);
let tipLo = wbins[0].bin, tipHi = wbins[wbins.length - 1].bin;
for (const { bin, width } of wbins) {
if (width >= thresh) { tipLo = bin; break; }
}
for (let i = wbins.length - 1; i >= 0; i--) {
if (wbins[i].width >= thresh) { tipHi = wbins[i].bin; break; }
}
const tipStart: [number, number] = [cx + uxB * (tipLo - cProj), cy + uyB * (tipLo - cProj)];
const tipEnd: [number, number] = [cx + uxB * (tipHi - cProj), cy + uyB * (tipHi - cProj)];
const oriented = pickBottomTowardTop(tipStart, tipEnd);
const { ux, uy, bottom, top } = oriented;
const [rx, ry] = axisRightUnit(ux, uy);
const ldx = plan.length.b[0] - plan.length.a[0];
const ldy = plan.length.b[1] - plan.length.a[1];
const llen = Math.hypot(ldx, ldy) || 1;
const lux = ldx / llen, luy = ldy / llen;
const lengthDot = lux * ux + luy * uy;
assert.ok(
lengthDot > 0.9995,
`length a→b must be || approved axis (dot=${lengthDot.toFixed(6)}; got (${lux.toFixed(4)},${luy.toFixed(4)}) vs (${ux.toFixed(4)},${uy.toFixed(4)}))`,
);
// Endpoints = centerline tip stations + pure axis-right (no screen-only tip translation)
for (const [endPt, tip] of [
[plan.length.a, bottom],
[plan.length.b, top],
] as const) {
const ox = endPt[0] - tip[0];
const oy = endPt[1] - tip[1];
const along = ox * ux + oy * uy;
const across = ox * rx + oy * ry;
// Pure axis-right plus small axial inset toward center (#71 edgeInset)
assert.ok(
Math.abs(along) <= 4,
`length end offset must be axis-right (+tiny axial inset) (along=${along.toFixed(2)}; tip=(${tip[0].toFixed(1)},${tip[1].toFixed(1)}))`,
);
assert.ok(across > 8, `length end must sit axis-right of centerline tip (across=${across.toFixed(1)})`);
}
assert.ok(plan.widths.length > 0, 'expected width marks');
for (const w of plan.widths) {
const wdx = w.right[0] - w.left[0];
const wdy = w.right[1] - w.left[1];
const wlen = Math.hypot(wdx, wdy) || 1;
const wrx = wdx / wlen, wry = wdy / wlen;
const widthDot = wrx * rx + wry * ry;
assert.ok(
widthDot > 0.999,
`width left→right must be || axis-right (dot=${widthDot.toFixed(6)} at station ${w.stationIn})`,
);
const perp = wrx * ux + wry * uy;
assert.ok(Math.abs(perp) < 0.02, `width must be ⊥ length axis (dot=${perp.toFixed(4)})`);
}
});
// --- Dim tips on footprint; labels in-bounds; smaller font (#71) ---
/**
* Solid body + dense fringe whiskers past the visual tips.
* 0.2%/99.8% axial percentiles still land in the fringe (overshoot);
* width-aware tip finding must snap to the solid body ends.
*/
function fringeTipFixture() {
const W = 280, H = 560;
const bitset = new Uint8Array(W * H);
// Solid body: x=90..190, y=120..440
for (let y = 120; y <= 440; y++) {
for (let x = 90; x <= 190; x++) bitset[y * W + x] = 1;
}
// Dense fringe whiskers (~10% of body width) past tips — denser than 0.2%.
// Must stay below BODY_WIDTH_FRAC (#73 ~0.15) so width-aware tips exclude them.
for (let y = 95; y < 120; y++) {
for (let x = 135; x <= 145; x++) bitset[y * W + x] = 1;
}
for (let y = 441; y <= 465; y++) {
for (let x = 135; x <= 145; x++) bitset[y * W + x] = 1;
}
// Approved axis ~vertical (slight tilt)
const ang = (8 * Math.PI) / 180;
const ux0 = Math.sin(ang);
const uy0 = Math.cos(ang);
const rx0 = -uy0;
const ry0 = ux0;
const cx = 140, cy = 280, halfL = 200, halfW = 70;
const corners: [number, number][] = [
[cx + ux0 * halfL + rx0 * halfW, cy + uy0 * halfL + ry0 * halfW],
[cx + ux0 * halfL - rx0 * halfW, cy + uy0 * halfL - ry0 * halfW],
[cx - ux0 * halfL - rx0 * halfW, cy - uy0 * halfL - ry0 * halfW],
[cx - ux0 * halfL + rx0 * halfW, cy - uy0 * halfL + ry0 * halfW],
];
const bbox = { corners, longSidePx: halfL * 2, angleDeg: 90 - 8 };
return { W, H, bitset, bbox, solidMinY: 120, solidMaxY: 440, ux0, uy0, cx, cy };
}
test('buildOverlayPlan: length span ≤ solid-body axial extent; fringe whiskers excluded (#71)', () => {
const { W, H, bitset, bbox, solidMinY, solidMaxY, ux0, uy0, cx, cy } = fringeTipFixture();
const scale = 10;
const wood = maskWoodAabb(bitset, W, H);
const slabBounds = {
x: wood.minX - 10,
y: wood.minY - 10,
w: wood.maxX - wood.minX + 20,
h: wood.maxY - wood.minY + 20,
};
const outW = 900, outH = 1200;
const inv = inventorySourceMap(slabBounds, { widthPx: W, heightPx: H }, outW, outH);
const plan = buildOverlayPlan({
bitset,
widthPx: W,
heightPx: H,
bbox,
scale,
lengthIn: 32,
lengthLabel: '32"',
mapToInventory: inv.mapToInventory,
inventoryW: outW,
inventoryH: outH,
});
// Solid-body axial projections (exclude fringe by y gate on this fixture)
const solidProjs: number[] = [];
for (let y = solidMinY; y <= solidMaxY; y++) {
for (let x = 0; x < W; x++) {
if (!bitset[y * W + x]) continue;
solidProjs.push(x * ux0 + y * uy0);
}
}
solidProjs.sort((a, b) => a - b);
const solidMin = solidProjs[0];
const solidMax = solidProjs[solidProjs.length - 1];
const cProj = cx * ux0 + cy * uy0;
const solidBot: [number, number] = [cx + ux0 * (solidMin - cProj), cy + uy0 * (solidMin - cProj)];
const solidTop: [number, number] = [cx + ux0 * (solidMax - cProj), cy + uy0 * (solidMax - cProj)];
const oriented = pickBottomTowardTop(solidBot, solidTop);
const mapBot = inv.mapToInventory(oriented.bottom[0], oriented.bottom[1]);
const mapTop = inv.mapToInventory(oriented.top[0], oriented.top[1]);
const solidAxial = Math.hypot(mapTop[0] - mapBot[0], mapTop[1] - mapBot[1]);
const lenAxial = Math.hypot(plan.length.b[0] - plan.length.a[0], plan.length.b[1] - plan.length.a[1]);
// Length must not exceed solid body (percentile fringe would overshoot by ≫2px)
assert.ok(
lenAxial <= solidAxial + 2,
`length span ${lenAxial.toFixed(1)} must be ≤ solid-body span ${solidAxial.toFixed(1)} (fringe excluded)`,
);
// Prove 0.2%/99.8% still overshoots solid body — so this fixture is meaningful
const allProjs: number[] = [];
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
if (!bitset[y * W + x]) continue;
allProjs.push(x * ux0 + y * uy0);
}
}
allProjs.sort((a, b) => a - b);
const lo = Math.min(Math.floor(allProjs.length * 0.002), allProjs.length - 1);
const hi = Math.max(Math.floor(allProjs.length * 0.998), lo);
const pctSpan = allProjs[hi] - allProjs[lo];
const solidSpan = solidMax - solidMin;
assert.ok(
pctSpan > solidSpan + 5,
`fixture sanity: percentile span ${pctSpan.toFixed(1)} must overshoot solid ${solidSpan.toFixed(1)}`,
);
// Still || approved axis (keep #70)
const ldx = plan.length.b[0] - plan.length.a[0];
const ldy = plan.length.b[1] - plan.length.a[1];
const llen = Math.hypot(ldx, ldy) || 1;
const mapOrigin = inv.mapToInventory(0, 0);
const mapU = inv.mapToInventory(oriented.ux, oriented.uy);
const iux = mapU[0] - mapOrigin[0];
const iuy = mapU[1] - mapOrigin[1];
const ilen = Math.hypot(iux, iuy) || 1;
const lengthDot = (ldx / llen) * (iux / ilen) + (ldy / llen) * (iuy / ilen);
assert.ok(lengthDot > 0.999, `length must stay || axis (dot=${lengthDot.toFixed(6)})`);
});
test('buildOverlayPlan: width endpoints on/inside wood edges with stroke inset (#71)', () => {
const { W, H, bitset, bbox } = fringeTipFixture();
const scale = 10;
const plan = buildOverlayPlan({
bitset,
widthPx: W,
heightPx: H,
bbox,
scale,
lengthIn: 32,
lengthLabel: '32"',
mapToInventory: (x, y) => [x, y],
inventoryW: W,
inventoryH: H,
});
assert.ok(plan.widths.length > 0, 'expected width stations');
const wood = maskWoodAabb(bitset, W, H);
// Rebuild oriented axis to re-probe full mask extent at each station
const { start: aa, end: bb } = axisEndpoints(bbox);
const ex = bb[0] - aa[0], ey = bb[1] - aa[1], elen = Math.hypot(ex, ey) || 1;
const uxB = ex / elen, uyB = ey / elen;
const cx = bbox.corners.reduce((s, c) => s + c[0], 0) / 4;
const cy = bbox.corners.reduce((s, c) => s + c[1], 0) / 4;
const cProj = cx * uxB + cy * uyB;
// Width-aware tips from the same solid body y-range as fringeTipFixture
const solidProjs: number[] = [];
for (let y = 120; y <= 440; y++) {
for (let x = 0; x < W; x++) {
if (!bitset[y * W + x]) continue;
solidProjs.push(x * uxB + y * uyB);
}
}
solidProjs.sort((a, b) => a - b);
const tipStart: [number, number] = [cx + uxB * (solidProjs[0] - cProj), cy + uyB * (solidProjs[0] - cProj)];
const tipEnd: [number, number] = [cx + uxB * (solidProjs[solidProjs.length - 1] - cProj), cy + uyB * (solidProjs[solidProjs.length - 1] - cProj)];
const oriented = pickBottomTowardTop(tipStart, tipEnd);
const { ux, uy, bottom } = oriented;
const [rx, ry] = axisRightUnit(ux, uy);
for (const w of plan.widths) {
assert.ok(
w.left[0] >= wood.minX - 1 && w.left[0] <= wood.maxX + 1 &&
w.left[1] >= wood.minY - 1 && w.left[1] <= wood.maxY + 1,
`width left must be on/inside wood AABB`,
);
assert.ok(
w.right[0] >= wood.minX - 1 && w.right[0] <= wood.maxX + 1 &&
w.right[1] >= wood.minY - 1 && w.right[1] <= wood.maxY + 1,
`width right must be on/inside wood AABB`,
);
// Full mask probe at this station (no inset)
const t = w.stationIn * scale;
const ox = bottom[0] + ux * t;
const oy = bottom[1] + uy * t;
const half = Math.max(2, Math.round((W + H) / 4));
let minP = Infinity, maxP = -Infinity;
for (let p = -half; p <= half; p++) {
const x = Math.round(ox + rx * p);
const y = Math.round(oy + ry * p);
if (x < 0 || y < 0 || x >= W || y >= H) continue;
if (bitset[y * W + x]) {
if (p < minP) minP = p;
if (p > maxP) maxP = p;
}
}
const fullSpan = maxP - minP;
const planSpan = Math.hypot(w.right[0] - w.left[0], w.right[1] - w.left[1]);
// Inset ≥ ~0.5px each end so stroke/arrowhead visual tips do not past the edge
assert.ok(
planSpan <= fullSpan - 0.9,
`width span ${planSpan.toFixed(2)} must be inset from full mask ${fullSpan} (stroke/arrowhead)`,
);
// Tips still near the edge (not collapsed)
assert.ok(planSpan >= fullSpan - 20, `inset must be modest, got plan=${planSpan} full=${fullSpan}`);
}
});
test('buildOverlayPlan: labels inside inventory PNG; length off wood; smaller font (#71)', () => {
const { W, H, bitset, bbox } = fringeTipFixture();
const scale = 10;
const wood = maskWoodAabb(bitset, W, H);
// Tight crop so axis-left width labels would clip without clamping
const slabBounds = {
x: wood.minX - 4,
y: wood.minY - 4,
w: wood.maxX - wood.minX + 8,
h: wood.maxY - wood.minY + 8,
};
const outW = 600, outH = 800;
const inv = inventorySourceMap(slabBounds, { widthPx: W, heightPx: H }, outW, outH);
const plan = buildOverlayPlan({
bitset,
widthPx: W,
heightPx: H,
bbox,
scale,
lengthIn: 32,
lengthLabel: '32"',
mapToInventory: inv.mapToInventory,
inventoryW: outW,
inventoryH: outH,
});
// Font: one consistent smaller size than prior max(22, scale*1.0)
const priorFont = Math.max(22, Math.round(scale * 1.0));
assert.ok(plan.fontPx < priorFont, `fontPx ${plan.fontPx} must be < prior ${priorFont}`);
assert.ok(plan.fontPx >= 12, `fontPx ${plan.fontPx} still readable`);
// Wood AABB in inventory
let mMinX = Infinity, mMaxX = -Infinity, mMinY = Infinity, mMaxY = -Infinity;
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
if (!bitset[y * W + x]) continue;
const [mx, my] = inv.mapToInventory(x, y);
if (mx < mMinX) mMinX = mx;
if (mx > mMaxX) mMaxX = mx;
if (my < mMinY) mMinY = my;
if (my > mMaxY) mMaxY = my;
}
}
// Length label axis-right off wood
const lenLabel = plan.length.labelAt;
assert.ok(
lenLabel[0] > mMaxX + 2 || lenLabel[1] < mMinY - 2 || lenLabel[1] > mMaxY + 2,
`length label (${lenLabel[0].toFixed(0)},${lenLabel[1].toFixed(0)}) must be off wood AABB`,
);
// Glyph margin: keep anchors inside canvas
const margin = Math.max(8, plan.fontPx * 0.6);
assert.ok(lenLabel[0] >= margin && lenLabel[0] <= outW - margin, `length label x in canvas`);
assert.ok(lenLabel[1] >= margin && lenLabel[1] <= outH - margin, `length label y in canvas`);
for (const w of plan.widths) {
// Width labels axis-left off wood
assert.ok(
w.labelAt[0] < mMinX - 1 || w.labelAt[1] < mMinY - 1 || w.labelAt[1] > mMaxY + 1,
`width label ${w.label} must be off wood`,
);
// Clamped inside inventory PNG (anchor + estimated glyph extent toward axis-left)
const glyphPad = plan.fontPx * Math.max(2, w.label.length * 0.55);
assert.ok(
w.labelAt[0] >= margin && w.labelAt[0] <= outW - margin,
`width label x=${w.labelAt[0].toFixed(0)} must be inside canvas [0,${outW}]`,
);
assert.ok(
w.labelAt[1] >= margin && w.labelAt[1] <= outH - margin,
`width label y=${w.labelAt[1].toFixed(0)} must be inside canvas [0,${outH}]`,
);
// textAlign right grows left — left extent of glyphs must stay ≥ 0
assert.ok(
w.labelAt[0] - glyphPad >= -1,
`width label glyphs must not clip left edge (anchor=${w.labelAt[0].toFixed(0)}, pad=${glyphPad.toFixed(0)})`,
);
}
});
// --- Tapered live-edge tips must not be dropped (#73) ---
/**
* Live-edge taper: body max width ~100px; tips taper to ~20% of max (~20px).
* Old BODY_WIDTH_FRAC=0.32 dropped tip bins; length dim under-reached wood ends.
*/
function taperedTipFixture() {
const W = 240, H = 500;
const bitset = new Uint8Array(W * H);
const y0 = 40, y1 = 460;
const maxHalf = 50; // body half-width → ~100px
for (let y = y0; y <= y1; y++) {
const t = (y - y0) / (y1 - y0); // 0..1 along length
// Cosine taper: tips at ~20% of max width, mid at full
const tipFrac = 0.20;
const envelope = tipFrac + (1 - tipFrac) * Math.sin(Math.PI * t);
const half = Math.max(2, Math.round(maxHalf * envelope));
const cx = 120;
for (let x = cx - half; x <= cx + half; x++) bitset[y * W + x] = 1;
}
const ang = (5 * Math.PI) / 180;
const ux0 = Math.sin(ang);
const uy0 = Math.cos(ang);
const rx0 = -uy0;
const ry0 = ux0;
const cx = 120, cy = 250, halfL = 220, halfW = 70;
const corners: [number, number][] = [
[cx + ux0 * halfL + rx0 * halfW, cy + uy0 * halfL + ry0 * halfW],
[cx + ux0 * halfL - rx0 * halfW, cy + uy0 * halfL - ry0 * halfW],
[cx - ux0 * halfL - rx0 * halfW, cy - uy0 * halfL - ry0 * halfW],
[cx - ux0 * halfL + rx0 * halfW, cy - uy0 * halfL + ry0 * halfW],
];
const bbox = { corners, longSidePx: halfL * 2, angleDeg: 90 - 5 };
return { W, H, bitset, bbox, y0, y1, tipFrac: 0.20, maxHalf, ux0, uy0, cx, cy };
}
test('buildOverlayPlan: tapered tips (~20% max width) included; length span ≥ tip span − small inset (#73)', () => {
const { W, H, bitset, bbox, y0, y1, tipFrac, maxHalf, ux0, uy0, cx, cy } = taperedTipFixture();
// Sanity: tip bins are ~15–25% of max width (would fail old 0.32 thresh)
const tipHalf = Math.max(2, Math.round(maxHalf * tipFrac));
const tipWidth = tipHalf * 2 + 1;
const bodyWidth = maxHalf * 2 + 1;
const tipRatio = tipWidth / bodyWidth;
assert.ok(tipRatio >= 0.15 && tipRatio <= 0.25, `fixture tip ratio ${tipRatio.toFixed(3)} in [0.15,0.25]`);
assert.ok(tipRatio < 0.32, `fixture tip ratio ${tipRatio.toFixed(3)} must be < old 0.32 thresh`);
const scale = 10;
const wood = maskWoodAabb(bitset, W, H);
const slabBounds = {
x: wood.minX - 8,
y: wood.minY - 8,
w: wood.maxX - wood.minX + 16,
h: wood.maxY - wood.minY + 16,
};
const inv = inventorySourceMap(slabBounds, { widthPx: W, heightPx: H }, 900, 1200);
const plan = buildOverlayPlan({
bitset,
widthPx: W,
heightPx: H,
bbox,
scale,
lengthIn: 42,
lengthLabel: '42"',
mapToInventory: inv.mapToInventory,
});
// Width-aware tip stations with current BODY_WIDTH_FRAC
const { start: aa, end: bb } = axisEndpoints(bbox);
const ex = bb[0] - aa[0], ey = bb[1] - aa[1], elen = Math.hypot(ex, ey) || 1;
const ux = ex / elen, uy = ey / elen;
const cProj = cx * ux + cy * uy;
const rx = -uy, ry = ux;
const binMin = new Map<number, number>();
const binMax = new Map<number, number>();
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
if (!bitset[y * W + x]) continue;
const along = x * ux + y * uy;
const across = (x - cx) * rx + (y - cy) * ry;
const bin = Math.round(along);
const prevMin = binMin.get(bin);
if (prevMin === undefined || across < prevMin) binMin.set(bin, across);
const prevMax = binMax.get(bin);
if (prevMax === undefined || across > prevMax) binMax.set(bin, across);
}
}
let maxWidth = 0;
const widths: { bin: number; width: number }[] = [];
for (const [bin, minA] of binMin) {
const maxA = binMax.get(bin)!;
const width = maxA - minA + 1;
widths.push({ bin, width });
if (width > maxWidth) maxWidth = width;
}
const thresh = Math.max(4, maxWidth * BODY_WIDTH_FRAC);
widths.sort((a, b) => a.bin - b.bin);
let tipLo = widths[0].bin, tipHi = widths[widths.length - 1].bin;
for (const { bin, width } of widths) {
if (width >= thresh) { tipLo = bin; break; }
}
for (let i = widths.length - 1; i >= 0; i--) {
if (widths[i].width >= thresh) { tipHi = widths[i].bin; break; }
}
// Tip bins at the axial extremes must survive the threshold (not dropped)
const firstBin = widths[0];
const lastBin = widths[widths.length - 1];
assert.ok(
firstBin.width >= thresh || tipLo <= firstBin.bin + 3,
`low tip bin width=${firstBin.width} vs thresh=${thresh.toFixed(1)} must be kept (tipLo=${tipLo} first=${firstBin.bin})`,
);
assert.ok(
lastBin.width >= thresh || tipHi >= lastBin.bin - 3,
`high tip bin width=${lastBin.width} vs thresh=${thresh.toFixed(1)} must be kept (tipHi=${tipHi} last=${lastBin.bin})`,
);
// Axial tip span must reach near the physical wood ends (y0/y1)
const tipStart: [number, number] = [cx + ux * (tipLo - cProj), cy + uy * (tipLo - cProj)];
const tipEnd: [number, number] = [cx + ux * (tipHi - cProj), cy + uy * (tipHi - cProj)];
const oriented = pickBottomTowardTop(tipStart, tipEnd);
const mapBot = inv.mapToInventory(oriented.bottom[0], oriented.bottom[1]);
const mapTop = inv.mapToInventory(oriented.top[0], oriented.top[1]);
const tipAxial = Math.hypot(mapTop[0] - mapBot[0], mapTop[1] - mapBot[1]);
const lenAxial = Math.hypot(plan.length.b[0] - plan.length.a[0], plan.length.b[1] - plan.length.a[1]);
// Length must reach tip stations minus only a small axial inset (tighten old −24px)
assert.ok(
lenAxial <= tipAxial + 2 && lenAxial >= tipAxial - 8,
`length span ${lenAxial.toFixed(1)} must match tip span ${tipAxial.toFixed(1)} within small inset (#73)`,
);
// Prove old 0.32 thresh would have under-reached (fixture is meaningful)
const oldThresh = Math.max(4, maxWidth * 0.32);
let oldLo = widths[0].bin, oldHi = widths[widths.length - 1].bin;
for (const { bin, width } of widths) {
if (width >= oldThresh) { oldLo = bin; break; }
}
for (let i = widths.length - 1; i >= 0; i--) {
if (widths[i].width >= oldThresh) { oldHi = widths[i].bin; break; }
}
assert.ok(
(tipHi - tipLo) > (oldHi - oldLo) + 10,
`new tip span ${tipHi - tipLo} must exceed old-0.32 span ${oldHi - oldLo} (taper kept)`,
);
// Still no overshoot past wood AABB along axis (keep #69/#71)
const woodProjs: number[] = [];
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
if (!bitset[y * W + x]) continue;
woodProjs.push(x * ux + y * uy);
}
}
woodProjs.sort((a, b) => a - b);
const woodSpan = woodProjs[woodProjs.length - 1] - woodProjs[0];
const tipSpanSrc = tipHi - tipLo;
assert.ok(tipSpanSrc <= woodSpan + 2, `tips must not overshoot wood (${tipSpanSrc} vs ${woodSpan})`);
});
test('inventory source map rotates dimension graphics with the calibration slab', () => {
const width = 6, height = 4;
const bitset = new Uint8Array(width * height);
for (let y = 1; y <= 2; y++) for (let x = 1; x <= 4; x++) bitset[y * width + x] = 1;
const map = inventorySourceMap(
{ x: 1, y: 1, w: 4, h: 2 },
{ widthPx: width, heightPx: height },
2, 3, 1, bitset, 90,
).mapToInventory;
const a = map(1, 1.5);
const b = map(4, 1.5);
assert.ok(Math.abs(b[0] - a[0]) < 1e-8, 'horizontal source axis maps vertical after clockwise rotation');
assert.ok(b[1] > a[1]);
});