diff --git a/frontend/src/capture/session.batchApply.test.ts b/frontend/src/capture/session.batchApply.test.ts index 131cbe8..1906e90 100644 --- a/frontend/src/capture/session.batchApply.test.ts +++ b/frontend/src/capture/session.batchApply.test.ts @@ -252,6 +252,35 @@ test('#62: Calculate applies frozen mask params to every non-calibration batch p s.dispose(); }); +test('#62: calibration tape tilt is not copied to sibling photos (#99)', async () => { + installFakes(); + const rotations: (number | undefined)[] = []; + const s = createCaptureSession({ + draft: store, + encodeInventory: async (input) => { + rotations.push(input.rotationDeg); + return { blob: new Blob(['ok-png'], { type: 'image/png' }), sourceBelowFloor: false }; + }, + } as Parameters[0]); + + await driveToMeasure(s, [tinyJpeg('sibling-a'), tinyJpeg('calibration'), tinyJpeg('sibling-b')], 1, { sheet: 'black' }); + s.back(); + s.setTapePose({ + transform: { tiltDeg: 20, zoom: 1, panX: 0, panY: 0 }, + tickStartY: 32, + tickEndY: 608, + sourceW: 1000, + sourceH: 900, + }); + await s.calculateFromAxis(320, 16); + + assert.equal(rotations.length, 3, 'one encoder call for calibration and each sibling'); + assert.equal(rotations[0], 20, 'calibration receives the selected tape tilt'); + assert.equal(rotations[1], undefined, 'sibling photos remain unrotated'); + assert.equal(rotations[2], undefined, 'sibling photos remain unrotated'); + s.dispose(); +}); + test('#62: empty mask on one sibling → per-photo error; others still proceed', async () => { installFakes(); const a = tinyJpeg('a'); // will empty-mask diff --git a/frontend/src/capture/session.calibrate.test.ts b/frontend/src/capture/session.calibrate.test.ts index 34c77ee..a387b98 100644 --- a/frontend/src/capture/session.calibrate.test.ts +++ b/frontend/src/capture/session.calibrate.test.ts @@ -403,6 +403,44 @@ test('calculateFromAxis stays on the tape page until the calibrated result exist s.dispose(); }); +test('calculateFromAxis passes the tape tilt to the calibration PNG and overlay only', async () => { + installFakeMaskWorker(); + (globalThis as unknown as { createImageBitmap: typeof createImageBitmap }).createImageBitmap = + async () => ({ width: MASK_W, height: MASK_H, close() {} }) as ImageBitmap; + + const inventoryTilts: (number | undefined)[] = []; + const overlayTilts: (number | undefined)[] = []; + const s = createCaptureSession({ + draft: store, + encodeInventory: async (input) => { + inventoryTilts.push((input as typeof input & { rotationDeg?: number }).rotationDeg); + return { blob: new Blob(['clean'], { type: 'image/png' }), sourceBelowFloor: false }; + }, + encodeOverlay: async (input) => { + overlayTilts.push((input as typeof input & { rotationDeg?: number }).rotationDeg); + return new Blob(['overlay'], { type: 'image/png' }); + }, + }); + await s.addToBatch(tinyJpeg('cal')); + s.continueBatch(); + s.pickCalibration(0); + s.setSheet('black'); + await settleOverlay(s); + s.approveMask(); + s.setTapePose({ + transform: { tiltDeg: 20, zoom: 1, panX: 0, panY: 0 }, + tickStartY: 32, + tickEndY: 608, + sourceW: MASK_W, + sourceH: MASK_H, + }); + await s.calculateFromAxis(320, 16); + + assert.deepEqual(inventoryTilts, [20], 'calibration inventory must receive tape tilt'); + assert.deepEqual(overlayTilts, [20], 'dimension overlay must receive tape tilt'); + s.dispose(); +}); + test('#61: Calculate produces inventory PNG + M1 measures for calibration source only', async () => { installFakeMaskWorker(); (globalThis as unknown as { createImageBitmap: typeof createImageBitmap }).createImageBitmap = diff --git a/frontend/src/capture/session.ts b/frontend/src/capture/session.ts index 501a2d8..0d21e21 100644 --- a/frontend/src/capture/session.ts +++ b/frontend/src/capture/session.ts @@ -370,7 +370,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { { start: axis.tapeStart, end: axis.tapeEnd }, ); try { - const encoded = await encodeInventory({ bitmap, mask: approvedMask, source, cropFill }); + const encoded = await encodeInventory({ bitmap, mask: approvedMask, source, cropFill, rotationDeg: axis.photoTransform.tiltDeg }); pngBlob = encoded.blob; belowFloor = encoded.sourceBelowFloor; } catch { @@ -390,6 +390,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { slabBounds: approvedMask.slabBounds, source, cropFill, + rotationDeg: axis.photoTransform.tiltDeg, }); } catch { return new Blob(['overlay'], { type: 'image/png' }); diff --git a/frontend/src/png/inventory.test.ts b/frontend/src/png/inventory.test.ts index 669c042..1fd1449 100644 --- a/frontend/src/png/inventory.test.ts +++ b/frontend/src/png/inventory.test.ts @@ -107,6 +107,37 @@ test('90% fill: limiting dimension maps to 0.9 × frame side (#68)', () => { assert.ok(Math.abs(hExtent - 648) <= 2, `height extent ${hExtent} ≈ 648 (1800×0.36)`); }); +test('calibration rotation turns a landscape slab into an upright crop extent (#99)', () => { + const width = 6; + const 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 source: Pick = { widthPx: width, heightPx: height }; + const mask = maskFor(bitset, { x: 1, y: 1, w: 4, h: 2 }); + const photo = makePhoto(width, height, (x, y) => + bitset[y * width + x] ? [200, 40, 20, 255] : [0, 255, 0, 255], + ); + const extent = (rgba: Uint8Array, outW: number, outH: number) => { + let minX = outW, maxX = -1, minY = outH, maxY = -1; + for (let y = 0; y < outH; y++) { + for (let x = 0; x < outW; x++) { + if (rgba[(y * outW + x) * 4 + 3] === 0) continue; + minX = Math.min(minX, x); maxX = Math.max(maxX, x); + minY = Math.min(minY, y); maxY = Math.max(maxY, y); + } + } + return { width: maxX - minX + 1, height: maxY - minY + 1 }; + }; + const unrotated = composeInventoryFrame(photo, mask, source, 1, 0); + const rotated = composeInventoryFrame(photo, mask, source, 1, 90); + assert.ok(extent(unrotated.rgba, unrotated.width, unrotated.height).width > + extent(unrotated.rgba, unrotated.width, unrotated.height).height); + assert.ok(extent(rotated.rgba, rotated.width, rotated.height).height > + extent(rotated.rgba, rotated.width, rotated.height).width); +}); + test('belowFloor: small slab → sourceBelowFloor, outW capped at floor (no upscale)', () => { const source: Pick = { widthPx: 900, heightPx: 700 }; const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1); diff --git a/frontend/src/png/inventory.ts b/frontend/src/png/inventory.ts index 3ce49e9..b4fcf32 100644 --- a/frontend/src/png/inventory.ts +++ b/frontend/src/png/inventory.ts @@ -13,6 +13,8 @@ export interface InventoryInput { source: SourcePick; /** Global crop fill percent. Default 90. */ cropFill?: number; + /** Calibration-photo rotation in degrees; batch siblings remain unrotated. */ + rotationDeg?: number; } export interface Frame { @@ -27,7 +29,11 @@ export function composeInventoryFrame( mask: MaskPick, source: SourcePick, fill = 0.9, + rotationDeg = 0, ): Frame { + if (Number.isFinite(rotationDeg) && Math.abs(rotationDeg % 360) > 1e-8) { + return composeRotatedInventoryFrame(src, mask, source, fill, rotationDeg); + } const b = mask.slabBounds; const x0 = Math.max(0, Math.round(b.x)); const y0 = Math.max(0, Math.round(b.y)); @@ -64,10 +70,130 @@ export function composeInventoryFrame( return { width: outW, height: outH, rgba, sourceBelowFloor: belowFloor }; } +interface PixelBounds { + minX: number; + minY: number; + maxX: number; + maxY: number; +} + +function maskPixelBounds(bitset: Uint8Array, w: number, h: number): PixelBounds | null { + let minX = w, minY = h, maxX = -1, maxY = -1; + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + if (!bitset[y * w + x]) continue; + minX = Math.min(minX, x); minY = Math.min(minY, y); + maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); + } + } + return maxX < 0 ? null : { minX, minY, maxX, maxY }; +} + +function rotatePoint( + x: number, y: number, bounds: PixelBounds, radians: number, +): [number, number] { + const cx = (bounds.minX + bounds.maxX) / 2; + const cy = (bounds.minY + bounds.maxY) / 2; + const dx = x - cx, dy = y - cy; + const cos = Math.cos(radians), sin = Math.sin(radians); + return [cx + dx * cos - dy * sin, cy + dx * sin + dy * cos]; +} + +export interface MaskRotation { + bounds: { x: number; y: number; w: number; h: number }; + toRotated: (x: number, y: number) => [number, number]; + toSource: (x: number, y: number) => [number, number]; +} + +export function createMaskRotation( + bitset: Uint8Array, + w: number, + h: number, + rotationDeg: number, +): MaskRotation { + const pixels = maskPixelBounds(bitset, w, h); + if (!pixels) { + return { + bounds: { x: 0, y: 0, w: 0, h: 0 }, + toRotated: (x, y) => [x, y], + toSource: (x, y) => [x, y], + }; + } + const radians = (rotationDeg * Math.PI) / 180; + const toRotated = (x: number, y: number) => rotatePoint(x, y, pixels, radians); + const toSource = (x: number, y: number) => rotatePoint(x, y, pixels, -radians); + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + if (!bitset[y * w + x]) continue; + const [rx, ry] = toRotated(x, y); + minX = Math.min(minX, rx); minY = Math.min(minY, ry); + maxX = Math.max(maxX, rx); maxY = Math.max(maxY, ry); + } + } + return { + bounds: { x: minX - 0.5, y: minY - 0.5, w: maxX - minX + 1, h: maxY - minY + 1 }, + toRotated, + toSource, + }; +} + +export function rotatedMaskBounds( + bitset: Uint8Array, + w: number, + h: number, + rotationDeg: number, +) { + return createMaskRotation(bitset, w, h, rotationDeg).bounds; +} + +function composeRotatedInventoryFrame( + src: { width: number; height: number; rgba: Uint8Array | Uint8ClampedArray }, + mask: MaskPick, + source: SourcePick, + fill: number, + rotationDeg: number, +): Frame { + const rotation = createMaskRotation(mask.bitset, source.widthPx, source.heightPx, rotationDeg); + if (!(rotation.bounds.w > 0 && rotation.bounds.h > 0)) { + throw new RangeError('Cannot rotate an empty approved mask'); + } + const rotated = rotation.bounds; + const belowFloor = Math.min(rotated.w, rotated.h) < FLOOR; + const outW = belowFloor ? Math.max(1, Math.min(rotated.w, FLOOR)) : FLOOR; + const outH = Math.round((outW * ASPECT_H) / ASPECT_W); + const scale = Math.min((outW * fill) / Math.max(rotated.w, 1), (outH * fill) / Math.max(rotated.h, 1)); + const dw = rotated.w * scale; + const dh = rotated.h * scale; + const ox = (outW - dw) / 2; + const oy = (outH - dh) / 2; + const rgba = new Uint8Array(outW * outH * 4); + for (let y = 0; y < outH; y++) { + for (let x = 0; x < outW; x++) { + if (x + 0.5 < ox || x + 0.5 >= ox + dw || y + 0.5 < oy || y + 0.5 >= oy + dh) continue; + const rx = rotated.x + (x + 0.5 - ox) / scale; + const ry = rotated.y + (y + 0.5 - oy) / scale; + const [sourceX, sourceY] = rotation.toSource(rx, ry); + const sx = Math.round(sourceX); + const sy = Math.round(sourceY); + if (sx < 0 || sy < 0 || sx >= source.widthPx || sy >= source.heightPx) continue; + const siPixel = sy * source.widthPx + sx; + if (mask.bitset[siPixel] !== 1) continue; + const si = siPixel * 4; + const di = (y * outW + x) * 4; + rgba[di] = src.rgba[si]; + rgba[di + 1] = src.rgba[si + 1]; + rgba[di + 2] = src.rgba[si + 2]; + rgba[di + 3] = 255; + } + } + return { width: outW, height: outH, rgba, sourceBelowFloor: belowFloor }; +} + export async function encodeInventoryPng( input: InventoryInput, ): Promise<{ blob: Blob; sourceBelowFloor: boolean }> { - const { bitmap, mask, source, cropFill } = input; + const { bitmap, mask, source, cropFill, rotationDeg } = input; const fill = Math.max(1, Math.min(100, cropFill ?? 90)) / 100; const srcCanvas = new OffscreenCanvas(source.widthPx, source.heightPx); const srcCtx = srcCanvas.getContext('2d')!; @@ -78,6 +204,7 @@ export async function encodeInventoryPng( mask, source, fill, + rotationDeg, ); const out = new OffscreenCanvas(frame.width, frame.height); const ctx = out.getContext('2d')!; diff --git a/frontend/src/png/overlay.test.ts b/frontend/src/png/overlay.test.ts index d3940d9..d3950ec 100644 --- a/frontend/src/png/overlay.test.ts +++ b/frontend/src/png/overlay.test.ts @@ -299,7 +299,37 @@ test('buildOverlayPlan: font smaller than prior 28/scale*1.2 and 22/scale*1.0 (# }); -// --- Length dim must not overshoot wood footprint (#69) --- +test('inventorySourceMap follows calibration rotation for overlay geometry (#99)', () => { + const W = 400, H = 400; + const bitset = new Uint8Array(W * H).fill(1); + const slabBounds = { x: 0, y: 0, w: W, h: H }; + const unrotated = inventorySourceMap( + slabBounds, + { widthPx: W, heightPx: H }, + 800, + 1067, + 0.9, + ); + const rotated = inventorySourceMap( + slabBounds, + { widthPx: W, heightPx: H }, + 800, + 1067, + 0.9, + bitset, + 90, + ); + const horizontal = (map: ReturnType) => { + const a = map.mapToInventory(100, 200); + const b = map.mapToInventory(300, 200); + return [b[0] - a[0], b[1] - a[1]] as const; + }; + const [unrotatedDx, unrotatedDy] = horizontal(unrotated); + const [rotatedDx, rotatedDy] = horizontal(rotated); + assert.ok(Math.abs(unrotatedDx) > Math.abs(unrotatedDy), 'unrotated source axis stays horizontal'); + assert.ok(Math.abs(rotatedDy) > Math.abs(rotatedDx), '90° calibration rotation maps it vertically'); +}); + /** Tilted-axis slab: irregular mask + approved bbox long side ~10° from vertical. */ function tiltedSlabFixture() { diff --git a/frontend/src/png/overlay.ts b/frontend/src/png/overlay.ts index f731be3..5eb8955 100644 --- a/frontend/src/png/overlay.ts +++ b/frontend/src/png/overlay.ts @@ -1,5 +1,6 @@ import type { BBox } from '../capture/capture.types'; import { axisEndpoints } from '../measure/measure'; +import { createMaskRotation } from './inventory'; /** Width overlay stations: 6, 18, 30, … while ≤ L − 6 (tip-exclusive). */ export function overlayWidthStations(lengthIn: number): number[] { @@ -486,15 +487,30 @@ export function inventorySourceMap( outW: number, outH: number, fill = 0.9, + bitset?: Uint8Array, + rotationDeg = 0, ): InventoryMap { - const fillRatio = fill; + if (bitset && Math.abs(rotationDeg % 360) > 1e-8) { + const rotation = createMaskRotation(bitset, source.widthPx, source.heightPx, rotationDeg); + const scale = Math.min((outW * fill) / Math.max(rotation.bounds.w, 1), (outH * fill) / Math.max(rotation.bounds.h, 1)); + const ox = (outW - rotation.bounds.w * scale) / 2; + const oy = (outH - rotation.bounds.h * scale) / 2; + return { + outW, + outH, + mapToInventory: (sx, sy) => { + const [rx, ry] = rotation.toRotated(sx, sy); + return [ox + (rx - rotation.bounds.x) * scale, oy + (ry - rotation.bounds.y) * scale]; + }, + }; + } const x0 = Math.max(0, Math.round(slabBounds.x)); const y0 = Math.max(0, Math.round(slabBounds.y)); const x1 = Math.min(source.widthPx, x0 + Math.max(1, Math.round(slabBounds.w))); const y1 = Math.min(source.heightPx, y0 + Math.max(1, Math.round(slabBounds.h))); const sw = x1 - x0; const sh = y1 - y0; - const scale = Math.min((outW * fillRatio) / Math.max(sw, 1), (outH * fillRatio) / Math.max(sh, 1)); + const scale = Math.min((outW * fill) / Math.max(sw, 1), (outH * fill) / Math.max(sh, 1)); const dw = sw * scale; const dh = sh * scale; const ox = (outW - dw) / 2; @@ -519,6 +535,7 @@ export interface EncodeOverlayInput { slabBounds: { x: number; y: number; w: number; h: number }; source: { widthPx: number; heightPx: number }; cropFill?: number; + rotationDeg?: number; } /** @@ -534,7 +551,15 @@ export async function encodeOverlayPng(input: EncodeOverlayInput): Promise const ctx = canvas.getContext('2d')!; ctx.drawImage(bmp, 0, 0); const fill = Math.max(1, Math.min(100, input.cropFill ?? 90)) / 100; - const { mapToInventory } = inventorySourceMap(input.slabBounds, input.source, outW, outH, fill); + const { mapToInventory } = inventorySourceMap( + input.slabBounds, + input.source, + outW, + outH, + fill, + input.bitset, + input.rotationDeg, + ); const plan = buildOverlayPlan({ bitset: input.bitset, widthPx: input.widthPx,