Files
SlabUploader/frontend/src/png/rotateCal.ts
T
grokbot_ty 4ea2618475 fix(overlay): Photos-ready dims follow tape, not wood MAR
After rotateCalibration, map tape tick A→B through bakeTiltTransform into
baked cal coords, orient overlay bbox with orientedRect(..., tapeAngle), and
pass optional axis into buildOverlayPlan so length || tape and widths ⊥ tape.
2026-09-26 08:08:21 -07:00

141 lines
4.2 KiB
TypeScript

/**
* 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<Source, 'widthPx' | 'heightPx'>;
}
/** 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<Source, 'widthPx' | 'heightPx'>,
tiltDeg: number,
): Promise<RotatedCal> {
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 },
};
}