feat(capture): shared crop_fill + tape geometry for #99

- One crop_fill (default 0.9) replaces duplicated FILL in inventory/overlay
- Thin localStorage settings store; crop % only moves long-axis end insets
- Pure tapeGeometry: fixed stage line, stage↔image map, tick span → px
- measure() prefers tape tick span for scale; maskSpanAlong demoted
- rotateCalibration bakes cal tilt into inventory + overlay only
- Fix stale 80%-fill comment on slabBounds
This commit is contained in:
grokbot_ty
2026-09-25 17:41:29 -07:00
parent d20e3d9077
commit 52240083b3
12 changed files with 486 additions and 16 deletions
+92
View File
@@ -0,0 +1,92 @@
/**
* 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'>;
}
/**
* 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 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 };
}
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],
[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));
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 },
};
}