1135 lines
45 KiB
TypeScript
1135 lines
45 KiB
TypeScript
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]);
|
||
});
|