diff --git a/frontend/src/capture/session.ts b/frontend/src/capture/session.ts index cb26783..9c83e96 100644 --- a/frontend/src/capture/session.ts +++ b/frontend/src/capture/session.ts @@ -10,7 +10,7 @@ import { measure } from '../measure/measure'; import { orientedRect } from '../geometry/rect'; import { encodeInventoryPng, type InventoryInput } from '../png/inventory'; import { encodeOverlayPng, type EncodeOverlayInput } from '../png/overlay'; -import { rotateCalibration } from '../png/rotateCal'; +import { bakeTiltTransform, rotateCalibration } from '../png/rotateCal'; import { decodeOrientedBitmap } from '../decodeOrientedBitmap'; import { clampTiltDeg, defaultTape, tapeEndpoints, type TapeTransform } from './tapeGeometry'; import { getCropFill } from '../settings/cropFill'; @@ -636,19 +636,37 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { // #63: second copy of the calibration inventory PNG with dimension overlays. // Keep clean inventoryPng untouched; never stash overlay into batchApply[cal]. // Overlay uses the same baked orientation as the clean cal PNG (#99 scope). + // Length / width directions follow the tape (not wood MAR): map tick A→B + // through the same bake as rotateCal, then orient the overlay bbox to that. let overlayBlob: Blob; try { + const preEp = axis.tape.spanPx > 0 + ? tapeEndpoints(axis.tape, source.widthPx, source.heightPx) + : null; + const bakeXform = bakeTiltTransform(source.widthPx, source.heightPx, axis.tape.tiltDeg); + let overlayBbox = calMask.bbox; + let overlayAxis: { start: [number, number]; end: [number, number] } | undefined; + if (preEp && preEp.spanPx > 0) { + const start = bakeXform.mapPoint(preEp.start[0], preEp.start[1]); + const end = bakeXform.mapPoint(preEp.end[0], preEp.end[1]); + const dx = end[0] - start[0]; + const dy = end[1] - start[1]; + const angleDeg = ((Math.atan2(dy, dx) * 180) / Math.PI + 180) % 180; + overlayBbox = orientedRect(calMask.bbox, angleDeg); + overlayAxis = { start, end }; + } overlayBlob = await encodeOverlay({ inventoryPng: pngBlob, bitset: calMask.bitset, widthPx: calSource.widthPx, heightPx: calSource.heightPx, - bbox: calMask.bbox, + bbox: overlayBbox, scale: base.scale, lengthIn: eighthsToIn(lengthEighths), lengthLabel: fmtEighths(lengthEighths), slabBounds: calMask.slabBounds, source: calSource, + axis: overlayAxis, }); } catch { // Deterministic tests may inject a non-decodable inventory blob; still diff --git a/frontend/src/png/overlay.test.ts b/frontend/src/png/overlay.test.ts index d3940d9..74519c4 100644 --- a/frontend/src/png/overlay.test.ts +++ b/frontend/src/png/overlay.test.ts @@ -1117,3 +1117,110 @@ test('buildOverlayPlan: tapered tips (~20% max width) included; length span ≥ const tipSpanSrc = tipHi - tipLo; assert.ok(tipSpanSrc <= woodSpan + 2, `tips must not overshoot wood (${tipSpanSrc} vs ${woodSpan})`); }); + +test('buildOverlayPlan: when MAR angle ≠ tape angle, length || tape and width ⊥ tape (~2°)', () => { + // Wood MAR is horizontal (angleDeg 0); tape / approved axis is vertical (90°). + // Without optional axis (or orientedRect to tape), widths would be vertical. + // With tape-driven axis, length is vertical and width left→right ≈ horizontal. + const W = 240, H = 400; + const bitset = new Uint8Array(W * H); + // Slightly elongated horizontally so MAR long side ≈ 0°, but still a tall slab. + for (let y = 40; y <= 360; y++) { + for (let x = 40; x <= 200; x++) bitset[y * W + x] = 1; + } + const mar = minAreaRect(bitset, W, H)!; + // Force wood-long-axis bbox to 0° — intentionally NOT the tape (vertical). + const woodBbox = orientedRect(mar, 0); + assert.equal(woodBbox.angleDeg, 0); + + // Tape vertical: tick A near bottom, B near top (image y↓). + const tapeStart: [number, number] = [120, 350]; + const tapeEnd: [number, number] = [120, 50]; + const tapeAngleDeg = ((Math.atan2(tapeEnd[1] - tapeStart[1], tapeEnd[0] - tapeStart[0]) * 180) / Math.PI + 180) % 180; + // Overlay bbox keeps MAR extents but direction from tape (session.calculate path). + const tapeBbox = orientedRect(woodBbox, tapeAngleDeg); + const scale = 10; + const plan = buildOverlayPlan({ + bitset, + widthPx: W, + heightPx: H, + bbox: tapeBbox, + scale, + lengthIn: 32, + lengthLabel: '32"', + mapToInventory: (x, y) => [x, y], + axis: { start: tapeStart, end: tapeEnd }, + }); + + // Length a→b must be parallel to tape (vertical → |dx| small vs |dy|). + 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 tdx = tapeEnd[0] - tapeStart[0], tdy = tapeEnd[1] - tapeStart[1]; + const tlen = Math.hypot(tdx, tdy) || 1; + const tux = tdx / tlen, tuy = tdy / tlen; + const lengthDot = Math.abs(lux * tux + luy * tuy); + assert.ok(lengthDot > 0.999, `length must || tape (dot=${lengthDot})`); + + assert.ok(plan.widths.length > 0, 'expected width stations'); + // Tape-perpendicular = horizontal for a vertical tape. + for (const w of plan.widths) { + const ang = Math.atan2(w.right[1] - w.left[1], w.right[0] - w.left[0]); + 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 tape-perpendicular/horizontal (deg=${deg.toFixed(2)})`, + ); + } + + // Contrast: same wood bbox WITHOUT tape axis would follow MAR (horizontal length). + const woodPlan = buildOverlayPlan({ + bitset, + widthPx: W, + heightPx: H, + bbox: woodBbox, + scale, + lengthIn: 32, + lengthLabel: '32"', + mapToInventory: (x, y) => [x, y], + }); + const wdx = woodPlan.length.b[0] - woodPlan.length.a[0]; + const wdy = woodPlan.length.b[1] - woodPlan.length.a[1]; + const wlen = Math.hypot(wdx, wdy) || 1; + // Wood MAR length should NOT be parallel to vertical tape. + const woodDot = Math.abs((wdx / wlen) * tux + (wdy / wlen) * tuy); + assert.ok(woodDot < 0.5, `wood-MAR overlay must disagree with tape (dot=${woodDot})`); +}); + +test('buildOverlayPlan: optional axis alone drives direction even if bbox angle wrong', () => { + const W = 200, H = 400; + const bitset = new Uint8Array(W * H); + for (let y = 50; y <= 350; y++) { + for (let x = 70; x <= 130; x++) bitset[y * W + x] = 1; + } + const mar = minAreaRect(bitset, W, H)!; + // Wrong bbox angle on purpose (horizontal) while axis is vertical. + const wrongBbox = orientedRect(mar, 0); + const plan = buildOverlayPlan({ + bitset, + widthPx: W, + heightPx: H, + bbox: wrongBbox, + scale: 10, + lengthIn: 30, + lengthLabel: '30"', + mapToInventory: (x, y) => [x, y], + axis: { start: [100, 340], end: [100, 60] }, + }); + assert.ok(plan.widths.length > 0); + for (const w of plan.widths) { + const ang = Math.atan2(w.right[1] - w.left[1], w.right[0] - w.left[0]); + let deg = (ang * 180) / Math.PI; + if (deg > 90) deg -= 180; + if (deg < -90) deg += 180; + assert.ok(Math.abs(deg) <= 2, `axis override: width deg=${deg.toFixed(2)}`); + } +}); diff --git a/frontend/src/png/overlay.ts b/frontend/src/png/overlay.ts index a328861..b1cf9ad 100644 --- a/frontend/src/png/overlay.ts +++ b/frontend/src/png/overlay.ts @@ -81,6 +81,12 @@ export interface OverlayPlanInput { /** Inventory PNG size — used to clamp labels inside the canvas (#71). */ inventoryW?: number; inventoryH?: number; + /** + * Optional explicit length axis (e.g. baked tape tick A→B). When set, (ux,uy) + * come from this segment instead of bbox corners — keeps width ⊥ tape even + * when MAR wood long side differs from the tape. + */ + axis?: { start: [number, number]; end: [number, number] }; } /** @@ -154,6 +160,7 @@ function maskAxisSegment( w: number, h: number, bbox: BBox, + axis?: { start: [number, number]; end: [number, number] }, ): { start: [number, number]; end: [number, number]; @@ -164,7 +171,7 @@ function maskAxisSegment( uy: number; span: number; } { - const { start: a, end: b } = axisEndpoints(bbox); + const { start: a, end: b } = axis ?? axisEndpoints(bbox); const ex = b[0] - a[0]; const ey = b[1] - a[1]; const elen = Math.hypot(ex, ey) || 1; @@ -270,9 +277,9 @@ function fmtInches(inches: number): string { export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan { const { bitset, widthPx, heightPx, bbox, scale, lengthIn, lengthLabel, mapToInventory, - inventoryW, inventoryH, + inventoryW, inventoryH, axis, } = input; - const seg = maskAxisSegment(bitset, widthPx, heightPx, bbox); + const seg = maskAxisSegment(bitset, widthPx, heightPx, bbox, axis); // Re-orient span ends so EXIF bottom establishes station origin; axis still // lies on the approved M1 direction (seg ux/uy flipped only when needed). const oriented = pickBottomTowardTop(seg.start, seg.end); @@ -522,6 +529,8 @@ export interface EncodeOverlayInput { source: { widthPx: number; heightPx: number }; /** Optional override; default reads live settings.crop_fill (#99). */ cropFill?: number; + /** Optional explicit length axis in source px (baked tape A→B). */ + axis?: { start: [number, number]; end: [number, number] }; } /** @@ -549,6 +558,7 @@ export async function encodeOverlayPng(input: EncodeOverlayInput): Promise mapToInventory, inventoryW: outW, inventoryH: outH, + axis: input.axis, }); drawOverlayPlan(ctx, plan); return await canvas.convertToBlob({ type: 'image/png' }); diff --git a/frontend/src/png/rotateCal.test.ts b/frontend/src/png/rotateCal.test.ts new file mode 100644 index 0000000..5d03bb5 --- /dev/null +++ b/frontend/src/png/rotateCal.test.ts @@ -0,0 +1,56 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { bakeTiltTransform } from './rotateCal.ts'; + +test('bakeTiltTransform: identity ≈0° leaves points and size unchanged', () => { + const x = bakeTiltTransform(800, 600, 0); + assert.equal(x.identity, true); + assert.equal(x.outW, 800); + assert.equal(x.outH, 600); + assert.deepEqual(x.mapPoint(100, 200), [100, 200]); + assert.deepEqual(x.mapPoint(0, 0), [0, 0]); +}); + +test('bakeTiltTransform: 360° folds to identity', () => { + const x = bakeTiltTransform(400, 300, 360); + assert.equal(x.identity, true); + assert.deepEqual(x.mapPoint(10, 20), [10, 20]); +}); + +test('bakeTiltTransform: 90° CW maps centre→centre; corner into AABB canvas', () => { + const srcW = 200, srcH = 100; + const x = bakeTiltTransform(srcW, srcH, 90); + assert.equal(x.identity, false); + // Rotated AABB of 200×100 about centre ≈ 100×200 (ceil may add 1px from float). + assert.ok(x.outW >= 100 && x.outW <= 101, `outW=${x.outW}`); + assert.ok(x.outH >= 200 && x.outH <= 201, `outH=${x.outH}`); + const [cx, cy] = x.mapPoint(srcW / 2, srcH / 2); + assert.ok(Math.abs(cx - x.outW / 2) < 1e-9); + assert.ok(Math.abs(cy - x.outH / 2) < 1e-9); + // Top-left (0,0) relative (−100,−50) → CW90 → (~50,~−100) → +out/2 + const [tlx, tly] = x.mapPoint(0, 0); + assert.ok(Math.abs(tlx - (50 + x.outW / 2)) < 1e-4, `tlx=${tlx}`); + assert.ok(Math.abs(tly - (-100 + x.outH / 2)) < 1e-4, `tly=${tly}`); +}); + +test('bakeTiltTransform: maps a tape-like vertical segment; direction rotates with tilt', () => { + const srcW = 400, srcH = 600; + const start: [number, number] = [200, 100]; + const end: [number, number] = [200, 500]; + const preUx = 0, preUy = 1; // vertical down + for (const tilt of [15, 30, -20, 45]) { + const x = bakeTiltTransform(srcW, srcH, tilt); + const a = x.mapPoint(start[0], start[1]); + const b = x.mapPoint(end[0], end[1]); + const dx = b[0] - a[0], dy = b[1] - a[1]; + const len = Math.hypot(dx, dy) || 1; + const ux = dx / len, uy = dy / len; + // Expected: rotate (preUx, preUy) clockwise by tilt + const rad = (((tilt % 360) + 360) % 360) * Math.PI / 180; + const cos = Math.cos(rad), sin = Math.sin(rad); + const expUx = preUx * cos - preUy * sin; + const expUy = preUx * sin + preUy * cos; + const dot = ux * expUx + uy * expUy; + assert.ok(dot > 0.999, `tilt=${tilt} direction mismatch dot=${dot}`); + } +}); diff --git a/frontend/src/png/rotateCal.ts b/frontend/src/png/rotateCal.ts index 2c75311..31153b9 100644 --- a/frontend/src/png/rotateCal.ts +++ b/frontend/src/png/rotateCal.ts @@ -12,28 +12,43 @@ export interface RotatedCal { source: Pick; } +/** Pure math for the same clockwise-about-centre bake used by rotateCalibration. */ +export interface BakeTiltTransform { + /** True when tilt is ~0° (identity; no resample). */ + identity: boolean; + tiltDeg: number; + srcW: number; + srcH: number; + outW: number; + outH: number; + /** Map a point from pre-bake source coords into baked canvas coords. */ + mapPoint: (x: number, y: number) => [number, number]; +} + /** - * Rotate source clockwise by tiltDeg (same sign as CSS/canvas rotate). - * Returns a new bitmap the caller must close, plus recomputed mask geometry. - * Identity (≈0°) returns clones of inputs without resampling. + * Clockwise rotation about image centre; output canvas sized to AABB of rotated + * corners — identical sizing to rotateCalibration (canvas translate+rotate path). */ -export async function rotateCalibration( - bitmap: ImageBitmap, - mask: Mask, - source: Pick, +export function bakeTiltTransform( + srcW: number, + srcH: number, tiltDeg: number, -): Promise { +): BakeTiltTransform { const a = ((tiltDeg % 360) + 360) % 360; if (a < 0.05 || a > 359.95) { - // No meaningful rotation — hand back the same bitmap reference; caller closes once. - return { bitmap, mask, source }; + return { + identity: true, + tiltDeg: a, + srcW, + srcH, + outW: srcW, + outH: srcH, + mapPoint: (x, y) => [x, y], + }; } - const srcW = source.widthPx; - const srcH = source.heightPx; const rad = (a * Math.PI) / 180; const cos = Math.cos(rad); const sin = Math.sin(rad); - // Axis-aligned bounds of rotated rectangle corners. const corners = [ [0, 0], [srcW, 0], @@ -53,6 +68,39 @@ export async function rotateCalibration( } const outW = Math.max(1, Math.ceil(maxX - minX)); const outH = Math.max(1, Math.ceil(maxY - minY)); + return { + identity: false, + tiltDeg: a, + srcW, + srcH, + outW, + outH, + mapPoint: (x, y) => { + const cx = x - srcW / 2; + const cy = y - srcH / 2; + return [cx * cos - cy * sin + outW / 2, cx * sin + cy * cos + outH / 2]; + }, + }; +} + +/** + * Rotate source clockwise by tiltDeg (same sign as CSS/canvas rotate). + * Returns a new bitmap the caller must close, plus recomputed mask geometry. + * Identity (≈0°) returns clones of inputs without resampling. + */ +export async function rotateCalibration( + bitmap: ImageBitmap, + mask: Mask, + source: Pick, + tiltDeg: number, +): Promise { + const xform = bakeTiltTransform(source.widthPx, source.heightPx, tiltDeg); + if (xform.identity) { + // No meaningful rotation — hand back the same bitmap reference; caller closes once. + return { bitmap, mask, source }; + } + const { srcW, srcH, outW, outH, tiltDeg: a } = xform; + const rad = (a * Math.PI) / 180; const canvas = new OffscreenCanvas(outW, outH); const ctx = canvas.getContext('2d')!; ctx.translate(outW / 2, outH / 2);