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

602 lines
24 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,
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 >= 18, `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 one step smaller than prior 28 / scale*1.2 (#67)', () => {
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 oldFont = Math.max(28, Math.round(scale * 1.2));
assert.ok(plan.fontPx < oldFont, `fontPx ${plan.fontPx} must be < prior ${oldFont}`);
assert.ok(plan.fontPx >= 18, `fontPx ${plan.fontPx} still legible`);
// Concrete contract: max(22, round(scale*1.0))
assert.equal(plan.fontPx, Math.max(22, Math.round(scale * 1.0)));
});
// --- 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;
}
}
// Robust tip stations along approved bbox axis (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 projs: number[] = [];
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
if (!bitset[y * W + x]) continue;
projs.push(x * ux + y * uy);
}
}
projs.sort((p, q) => p - q);
const lo = Math.min(Math.floor(projs.length * 0.002), projs.length - 1);
const hi = Math.max(Math.floor(projs.length * 0.998), lo);
const tipMin = projs[lo];
const tipMax = projs[hi];
// Centerline tip positions (same as maskAxisSegment start/end)
const tipStart: [number, number] = [cx + ux * (tipMin - cProj), cy + uy * (tipMin - cProj)];
const tipEnd: [number, number] = [cx + ux * (tipMax - cProj), cy + uy * (tipMax - 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]);
assert.ok(
Math.abs(lenAxial - tipAxial) <= 2,
`length span ${lenAxial.toFixed(1)} must equal centerline tip span ${tipAxial.toFixed(1)} (no speck overshoot)`,
);
// Axial stations of length ends = robust tip stations (pure axis-right offset does not change along-u).
// Inventory map is isotropic scale+translate, so axis direction is preserved.
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]) <= 2, `length min station ${lenProjs[0]} ≈ tip ${tipProjs[0]}`);
assert.ok(Math.abs(lenProjs[1] - tipProjs[1]) <= 2, `length max station ${lenProjs[1]} ≈ tip ${tipProjs[1]}`);
// 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: 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 projs: number[] = [];
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
if (!bitset[y * W + x]) continue;
projs.push(x * uxB + y * uyB);
}
}
projs.sort((p, q) => p - q);
const lo = Math.min(Math.floor(projs.length * 0.002), projs.length - 1);
const hi = Math.max(Math.floor(projs.length * 0.998), lo);
const tipStart: [number, number] = [cx + uxB * (projs[lo] - cProj), cy + uyB * (projs[lo] - cProj)];
const tipEnd: [number, number] = [cx + uxB * (projs[hi] - cProj), cy + uyB * (projs[hi] - 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;
assert.ok(
Math.abs(along) <= 1.5,
`length end offset must be pure axis-right (along=${along.toFixed(2)} must be ~0; 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)})`);
}
});