/** * Bake calibration photo tilt into a rotated bitmap + mask (#99 transform scope). * Used only for the calibration inventory PNG + dimension-overlay clone. * Batch siblings are never passed through here. */ import type { Mask, Source } from '../capture/capture.types'; import { minAreaRect, slabBounds } from '../geometry/rect'; export interface RotatedCal { bitmap: ImageBitmap; mask: Mask; 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]; } /** * Clockwise rotation about image centre; output canvas sized to AABB of rotated * corners — identical sizing to rotateCalibration (canvas translate+rotate path). */ export function bakeTiltTransform( srcW: number, srcH: number, tiltDeg: number, ): BakeTiltTransform { const a = ((tiltDeg % 360) + 360) % 360; if (a < 0.05 || a > 359.95) { return { identity: true, tiltDeg: a, srcW, srcH, outW: srcW, outH: srcH, mapPoint: (x, y) => [x, y], }; } const rad = (a * Math.PI) / 180; const cos = Math.cos(rad); const sin = Math.sin(rad); const corners = [ [0, 0], [srcW, 0], [srcW, srcH], [0, srcH], ].map(([x, y]) => { const cx = x - srcW / 2; const cy = y - srcH / 2; return [cx * cos - cy * sin, cx * sin + cy * cos] as [number, number]; }); let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; for (const [x, y] of corners) { if (x < minX) minX = x; if (x > maxX) maxX = x; if (y < minY) minY = y; if (y > maxY) maxY = y; } 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); ctx.rotate(rad); ctx.drawImage(bitmap, -srcW / 2, -srcH / 2); // Nearest-neighbour resample of the bitset into the rotated frame. const bitset = new Uint8Array(outW * outH); const invCos = Math.cos(-rad); const invSin = Math.sin(-rad); for (let y = 0; y < outH; y++) { for (let x = 0; x < outW; x++) { const dx = x - outW / 2; const dy = y - outH / 2; const sx = Math.round(dx * invCos - dy * invSin + srcW / 2); const sy = Math.round(dx * invSin + dy * invCos + srcH / 2); if (sx < 0 || sy < 0 || sx >= srcW || sy >= srcH) continue; bitset[y * outW + x] = mask.bitset[sy * srcW + sx]; } } let slabPx = 0; for (let i = 0; i < bitset.length; i++) if (bitset[i]) slabPx++; const bounds = slabBounds(bitset, outW, outH); const bbox = minAreaRect(bitset, outW, outH) ?? mask.bbox; const outBmp = canvas.transferToImageBitmap(); return { bitmap: outBmp, mask: { params: mask.params, slabPx, bitset, bbox, slabBounds: bounds, }, source: { widthPx: outW, heightPx: outH }, }; }