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)})`); } });