feat(m4): fixed tape line for length calibration (#99)

The photo pans, zooms, and tilts under a line that stays in the middle
of the frame. Scale is the source-pixel distance between the ticks
divided by the declared length, not the mask span.
This commit is contained in:
Ty
2026-09-24 17:56:21 -07:00
parent d49ff2fab5
commit 6000ac2b0f
23 changed files with 643 additions and 1019 deletions
-10
View File
@@ -8,7 +8,6 @@
"name": "slabuploader-frontend",
"version": "0.1.0",
"dependencies": {
"@neodrag/svelte": "2.3.3",
"idb-keyval": "^6.3.0",
"svelte": "^4.2.20"
},
@@ -496,15 +495,6 @@
"node": "^22.20 || ^24.12 || >=25"
}
},
"node_modules/@neodrag/svelte": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/@neodrag/svelte/-/svelte-2.3.3.tgz",
"integrity": "sha512-avXzhrilsBsnMFljhVAQ7h+6hbSIrvRCJ61GCiGbGISkC1QOhjDCNvPZo2+7KVwiYrnUBx4NRH0kTIqrcxv9Lg==",
"license": "MIT",
"peerDependencies": {
"svelte": "^3.0.0 || ^4.0.0 || ^5.0.0"
}
},
"node_modules/@playwright/test": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
-1
View File
@@ -12,7 +12,6 @@
"test:e2e:headed": "playwright test --headed"
},
"dependencies": {
"@neodrag/svelte": "2.3.3",
"idb-keyval": "^6.3.0",
"svelte": "^4.2.20"
},
+17 -19
View File
@@ -12,14 +12,13 @@
import AxisPanel from './capture/AxisPanel.svelte';
import MeasurePanel from './capture/MeasurePanel.svelte';
import Settings from './create/settings/Settings.svelte';
import SlabList from './create/slabs/SlabList.svelte';
import Review from './create/review/Review.svelte';
import Publish from './create/publish/Publish.svelte';
import {
flow, startSaving, enterReview, enterPublish, markDone, backToCapture, backToReview, type CalibratedCapture,
} from './create/flow';
import { createSlab, uploadPhoto, getSettings, ApiError } from './api/slabs';
import { createSlab, uploadPhoto, getSettings, updateSettings, ApiError } from './api/slabs';
import { clearDraft } from './draft/idb';
const gate = typeof window !== 'undefined' ? supported() : 'window unavailable';
@@ -30,7 +29,6 @@
let pngUrl: string | null = null;
let showSettings = false;
let showSlabs = false;
let cropFill = 90;
let curFlow: import('./create/flow').Flow = { phase: 'capture' };
$: curFlow = $flow;
@@ -43,20 +41,17 @@
});
const unsubBusy = session.busy.subscribe((b) => (busy = b));
let cropFill = 90;
onMount(() => {
// e2e boot guard: `?noload` skips the resume probe so a test can seed the
// device draft into IndexedDB after the origin exists, then reload without
// ?noload to trigger the Resume prompt deterministically.
const boot = new URLSearchParams(window.location.search);
if (!boot.get('noload')) void session.resumeProbe();
// #99: the global crop fill drives both the reference line and the PNG crop.
// Falls back to the 90% default when settings are unreachable.
void getSettings()
.then((s) => {
cropFill = s.crop_fill ?? 90;
session.setCropFill(cropFill);
})
.catch(() => {});
void getSettings().then((s) => {
if (typeof s.crop_fill === 'number') {
cropFill = s.crop_fill;
session.setCropFill(s.crop_fill);
}
}).catch(() => {});
});
onDestroy(() => {
unsubCap();
@@ -226,11 +221,15 @@
{:else if cap.k === 'axis'}
<AxisPanel
state={cap}
setTapeLine={session.setTapeLine}
{cropFill}
setTapePose={session.setTapePose}
setCropFill={(pct) => {
cropFill = pct;
session.setCropFill(pct);
void updateSettings({ crop_fill: pct }).catch(() => {});
}}
approveAxis={session.approveAxis}
back={session.back}
{cropFill}
setCropFill={(pct) => { cropFill = pct; session.setCropFill(pct); }}
/>
{:else}
<MeasurePanel
@@ -257,7 +256,7 @@
<button on:click={() => (showSlabs = !showSlabs)} data-testid="open-slabs">Slabs</button>
</div>
{#if showSettings}
<Settings onCropFillChange={(pct) => { cropFill = pct; session.setCropFill(pct); }} />
<Settings />
{/if}
{#if showSlabs}
<SlabList />
@@ -272,7 +271,6 @@
font-family: system-ui, -apple-system, Segoe UI, sans-serif;
}
.app, .hardstop { max-width: 720px; margin: 0 auto; min-height: 100vh; }
.hardstop { padding: 48px 24px; color: #eee; text-align: center; }
.hardstop h1 { font-size: 1.6rem; }
.detail { color: #888; font-size: 0.85rem; }
+283 -344
View File
@@ -1,423 +1,362 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { Capture, PhotoTransform, TapeLine } from './capture.types';
import { onDestroy, onMount } from 'svelte';
import type { Capture, PhotoTransform } from './capture.types';
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
import {
applyApprovedMask,
CALIBRATION_FRAME,
cropTickPositions,
DEFAULT_PHOTO_TRANSFORM,
pinchPhotoTransform,
photoFitScale,
photoToStage,
stageToPhoto,
tapeLineFromStageTicks,
} from './tapeCalibration';
IDENTITY_TRANSFORM,
cropEnds,
fitScale,
hitRadiusStage,
} from './tapeLine';
export let state: Extract<Capture, { k: 'axis' }>;
export let setTapeLine: (tapeLine: TapeLine, transform: PhotoTransform) => void;
export let cropFill = 90;
export let setTapePose: (pose: {
transform: PhotoTransform;
tickStartY: number;
tickEndY: number;
sourceW: number;
sourceH: number;
}) => void;
export let setCropFill: (pct: number) => void = () => {};
export let approveAxis: () => void;
export let back: () => void;
export let cropFill = 90;
export let setCropFill: (pct: number) => void = () => {};
let stage: HTMLDivElement | undefined;
let canvas: HTMLCanvasElement | undefined;
let bitmap: ImageBitmap | null = null;
let sourceW = state.source.widthPx;
let sourceH = state.source.heightPx;
let stageW = 0;
let stageH = 0;
let transform: PhotoTransform = { ...(state.axis.photoTransform ?? DEFAULT_PHOTO_TRANSFORM) };
let tickA = photoToStage(state.axis.tapeLine.start, sourceW, sourceH, transform)[1];
let tickB = photoToStage(state.axis.tapeLine.end, sourceW, sourceH, transform)[1];
let focusedTick: 'A' | 'B' = 'A';
let drag: 'tickA' | 'tickB' | 'pan' | 'tilt' | 'pinch' | null = null;
let lastPointer: [number, number] = [0, 0];
let pointerStart: [number, number] = [0, 0];
let transformStart = 0;
let pointers = new Map<number, [number, number]>();
let pinchStart: { distance: number; anchor: [number, number] } | null = null;
let panTapCandidate = false;
let previousTap: { time: number; point: [number, number] } | null = null;
let lastCropFill = cropFill;
let persistTimer: ReturnType<typeof setTimeout> | null = null;
const frame = CALIBRATION_FRAME;
const lineX = frame.width / 2;
const tiltX = frame.width / 2;
const tiltY = frame.height - 26;
const tickHitRadius = 38; // 76 logical px keeps the drag target above 44px on a 320px viewport.
let stageEl: HTMLDivElement | undefined;
let canvas: HTMLCanvasElement | undefined;
let bitmap: ImageBitmap | null = null;
let maskLayer: HTMLCanvasElement | null = null;
let sourceW = state.source.widthPx;
let sourceH = state.source.heightPx;
let renderedW = 320;
let transform: PhotoTransform = { ...(state.axis.photoTransform ?? IDENTITY_TRANSFORM) };
let tickA = state.axis.tickStartY;
let tickB = state.axis.tickEndY;
let focus: 'A' | 'B' = 'A';
let fill = cropFill;
let drag: 'tick' | 'tilt' | 'pan' | 'pinch' | null = null;
let pointers = new Map<number, [number, number]>();
let pinch0 = 0;
let zoom0 = 1;
let panGrab: [number, number] = [0, 0];
let tilt0 = 0;
let tiltX0 = 0;
let moved = false;
let downAt: [number, number] = [0, 0];
let lastTap: { t: number; x: number; y: number } | null = null;
function stagePoint(event: PointerEvent | WheelEvent): [number, number] {
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
function publish() {
setTapePose({
transform: { ...transform },
tickStartY: tickA,
tickEndY: tickB,
sourceW,
sourceH,
});
}
function toFrame(event: { clientX: number; clientY: number }): [number, number] {
const rect = canvas!.getBoundingClientRect();
renderedW = rect.width;
return [
((event.clientX - rect.left) / rect.width) * frame.width,
((event.clientY - rect.top) / rect.height) * frame.height,
];
}
function redraw(): void {
if (!canvas || !stageW || !stageH) return;
function draw() {
if (!canvas || !stageEl) return;
const rect = stageEl.getBoundingClientRect();
renderedW = rect.width || renderedW;
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.round(stageW * dpr);
canvas.height = Math.round(stageH * dpr);
canvas.width = Math.max(1, Math.round(rect.width * dpr));
canvas.height = Math.max(1, Math.round(rect.height * dpr));
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.setTransform(dpr * stageW / frame.width, 0, 0, dpr * stageH / frame.height, 0, 0);
ctx.fillStyle = '#161616';
ctx.setTransform((dpr * rect.width) / frame.width, 0, 0, (dpr * rect.height) / frame.height, 0, 0);
ctx.fillStyle = '#111';
ctx.fillRect(0, 0, frame.width, frame.height);
if (bitmap && sourceW > 0 && sourceH > 0) {
const scale = photoFitScale(sourceW, sourceH, transform.tiltDeg) * transform.zoom;
const scale = fitScale(sourceW, sourceH, transform.tiltDeg) * transform.zoom;
ctx.save();
ctx.translate(lineX + transform.panX, frame.height / 2 + transform.panY);
ctx.translate(frame.width / 2 + transform.panX, frame.height / 2 + transform.panY);
ctx.rotate((transform.tiltDeg * Math.PI) / 180);
ctx.scale(scale, scale);
ctx.drawImage(bitmap, -sourceW / 2, -sourceH / 2);
if (maskLayer) {
ctx.globalCompositeOperation = 'destination-in';
ctx.drawImage(maskLayer, -sourceW / 2, -sourceH / 2);
}
ctx.restore();
}
ctx.save();
ctx.lineCap = 'round';
ctx.strokeStyle = 'rgba(255,255,255,.92)';
ctx.lineWidth = 6;
ctx.beginPath(); ctx.moveTo(lineX, tickA); ctx.lineTo(lineX, tickB); ctx.stroke();
const x = frame.width / 2;
const ends = cropEnds(fill);
ctx.strokeStyle = '#000';
ctx.lineWidth = 2.4;
ctx.beginPath(); ctx.moveTo(lineX, tickA); ctx.lineTo(lineX, tickB); ctx.stroke();
for (const [y, id] of [[tickA, 'A'], [tickB, 'B']] as const) {
ctx.strokeStyle = 'rgba(255,255,255,.95)';
ctx.lineWidth = 6;
ctx.beginPath(); ctx.moveTo(lineX - 13, y); ctx.lineTo(lineX + 13, y); ctx.stroke();
ctx.strokeStyle = '#000';
ctx.lineWidth = 2.4;
ctx.beginPath(); ctx.moveTo(lineX - 13, y); ctx.lineTo(lineX + 13, y); ctx.stroke();
ctx.beginPath(); ctx.arc(lineX, y, 4, 0, Math.PI * 2);
ctx.fillStyle = focusedTick === id ? '#ff3d71' : '#000';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x, ends.startY);
ctx.lineTo(x, ends.endY);
ctx.stroke();
for (const [id, y] of [['A', tickA], ['B', tickB]] as const) {
ctx.beginPath();
ctx.moveTo(x - 13, y);
ctx.lineTo(x + 13, y);
ctx.stroke();
ctx.fillStyle = focus === id ? '#e23' : '#000';
ctx.beginPath();
ctx.arc(x, y, 5, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5; ctx.stroke();
}
ctx.beginPath(); ctx.arc(tiltX, tiltY, 17, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(0,0,0,.6)'; ctx.fill();
ctx.strokeStyle = '#00e5ff'; ctx.lineWidth = 1.5; ctx.stroke();
ctx.fillStyle = '#00e5ff'; ctx.font = '15px system-ui'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('↻', tiltX, tiltY + 2);
ctx.fillStyle = '#aaa'; ctx.font = '10.5px system-ui'; ctx.textAlign = 'left';
ctx.fillText('drag to tilt the slab', tiltX + 24, tiltY + 4);
ctx.restore();
ctx.fillStyle = '#eee';
ctx.beginPath();
ctx.arc(x, frame.height - 28, 16, 0, Math.PI * 2);
ctx.fill();
}
function photoPoint(point: [number, number]): [number, number] {
return stageToPhoto(point, sourceW, sourceH, transform);
function reseat(pct: number) {
const ends = cropEnds(pct);
tickA = ends.startY;
tickB = ends.endY;
publish();
draw();
}
function syncCalibration(): void {
const tapeLine = tapeLineFromStageTicks(tickA, tickB, sourceW, sourceH, transform);
setTapeLine(tapeLine, { ...transform });
function zoomAt(point: [number, number], factor: number) {
const z1 = clamp(transform.zoom * factor, 0.5, 3);
if (z1 === transform.zoom) return;
const cx = frame.width / 2;
const cy = frame.height / 2;
const qx = cx + (point[0] - cx - transform.panX) / transform.zoom;
const qy = cy + (point[1] - cy - transform.panY) / transform.zoom;
transform = {
...transform,
zoom: z1,
panX: point[0] - cx - z1 * (qx - cx),
panY: point[1] - cy - z1 * (qy - cy),
};
publish();
draw();
}
function moveTickA(y: number): void {
tickA = Math.max(20, Math.min(tickB - 40, y));
function fit() {
transform = { ...IDENTITY_TRANSFORM, tiltDeg: transform.tiltDeg };
publish();
draw();
}
function moveTickB(y: number): void {
tickB = Math.max(tickA + 40, Math.min(frame.height - 20, y));
}
function queuePersist(): void {
if (persistTimer) clearTimeout(persistTimer);
persistTimer = setTimeout(() => {
persistTimer = null;
syncCalibration();
}, 250);
}
function flushCalibration(): void {
if (persistTimer) clearTimeout(persistTimer);
persistTimer = null;
syncCalibration();
}
function approveCalibration(): void {
flushCalibration();
approveAxis();
}
function backToMask(): void {
flushCalibration();
back();
}
function updatePointer(event: PointerEvent): [number, number] {
const point = stagePoint(event);
pointers.set(event.pointerId, point);
return point;
}
function onPointerDown(event: PointerEvent): void {
const point = updatePointer(event);
if (pointers.size > 1) {
const [a, b] = [...pointers.values()];
const center: [number, number] = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
pinchStart = {
distance: Math.max(1, Math.hypot(b[0] - a[0], b[1] - a[1])),
anchor: photoPoint(center),
};
function onPointerDown(event: PointerEvent) {
const p = toFrame(event);
pointers.set(event.pointerId, p);
canvas!.setPointerCapture(event.pointerId);
moved = false;
downAt = p;
if (pointers.size >= 2) {
const pts = [...pointers.values()];
pinch0 = Math.hypot(pts[0][0] - pts[1][0], pts[0][1] - pts[1][1]) || 1;
zoom0 = transform.zoom;
drag = 'pinch';
panTapCandidate = false;
canvas?.setPointerCapture(event.pointerId);
event.preventDefault();
return;
}
const distA = Math.hypot(point[0] - lineX, point[1] - tickA);
const distB = Math.hypot(point[0] - lineX, point[1] - tickB);
const coreA = distA <= 12;
const coreB = distB <= 12;
if (coreA || coreB) {
focusedTick = distA <= distB ? 'A' : 'B';
drag = focusedTick === 'A' ? 'tickA' : 'tickB';
panTapCandidate = false;
} else if (Math.hypot(point[0] - tiltX, point[1] - tiltY) < 18) {
const x = frame.width / 2;
const hit = hitRadiusStage(renderedW);
if (Math.hypot(p[0] - x, p[1] - tickA) <= hit) {
focus = 'A';
drag = 'tick';
} else if (Math.hypot(p[0] - x, p[1] - tickB) <= hit) {
focus = 'B';
drag = 'tick';
} else if (Math.hypot(p[0] - x, p[1] - (frame.height - 28)) <= 22) {
drag = 'tilt';
pointerStart = point;
transformStart = transform.tiltDeg;
panTapCandidate = false;
} else if (distA <= tickHitRadius || distB <= tickHitRadius) {
focusedTick = distA <= distB ? 'A' : 'B';
drag = focusedTick === 'A' ? 'tickA' : 'tickB';
panTapCandidate = false;
tilt0 = transform.tiltDeg;
tiltX0 = p[0];
} else {
drag = 'pan';
lastPointer = point;
pointerStart = point;
panTapCandidate = true;
panGrab = [p[0] - transform.panX, p[1] - transform.panY];
}
canvas?.setPointerCapture(event.pointerId);
redraw();
event.preventDefault();
draw();
}
function onPointerMove(event: PointerEvent): void {
function onPointerMove(event: PointerEvent) {
const p = toFrame(event);
pointers.set(event.pointerId, p);
if (!drag) return;
const point = updatePointer(event);
if (drag === 'pinch' && pinchStart && pointers.size > 1) {
const [a, b] = [...pointers.values()];
const center: [number, number] = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
const distance = Math.max(1, Math.hypot(b[0] - a[0], b[1] - a[1]));
transform = pinchPhotoTransform(
transform, pinchStart.anchor, center, distance / pinchStart.distance, sourceW, sourceH,
);
pinchStart.distance = distance;
} else if (drag === 'tickA') moveTickA(point[1]);
else if (drag === 'tickB') moveTickB(point[1]);
else if (drag === 'tilt') transform.tiltDeg = transformStart + (point[0] - pointerStart[0]) * 0.6;
else {
if (drag === 'pan' && Math.hypot(point[0] - pointerStart[0], point[1] - pointerStart[1]) > 4) panTapCandidate = false;
transform.panX += point[0] - lastPointer[0];
transform.panY += point[1] - lastPointer[1];
lastPointer = point;
}
redraw();
queuePersist();
}
function onPointerUp(event: PointerEvent): void {
pointers.delete(event.pointerId);
if (!drag) return;
try { canvas?.releasePointerCapture(event.pointerId); } catch { /* already released */ }
if (drag === 'pinch' && pointers.size > 0) {
drag = 'pan';
pinchStart = null;
panTapCandidate = false;
lastPointer = [...pointers.values()][0];
queuePersist();
redraw();
if (Math.hypot(p[0] - downAt[0], p[1] - downAt[1]) > 3) moved = true;
if (drag === 'pinch' && pointers.size >= 2) {
const pts = [...pointers.values()];
const dist = Math.hypot(pts[0][0] - pts[1][0], pts[0][1] - pts[1][1]) || 1;
const mid: [number, number] = [(pts[0][0] + pts[1][0]) / 2, (pts[0][1] + pts[1][1]) / 2];
transform = { ...transform, zoom: zoom0 };
zoomAt(mid, clamp(dist / pinch0, 0.8, 1.25));
return;
}
const wasSlabTap = drag === 'pan' && panTapCandidate;
const tapPoint = stagePoint(event);
drag = null;
pinchStart = null;
panTapCandidate = false;
flushCalibration();
if (wasSlabTap) {
const now = performance.now();
if (previousTap && now - previousTap.time < 350
&& Math.hypot(tapPoint[0] - previousTap.point[0], tapPoint[1] - previousTap.point[1]) < 12) {
previousTap = null;
fitSlab();
} else previousTap = { time: now, point: tapPoint };
if (drag === 'tick') {
const y = clamp(p[1], 8, frame.height - 8);
if (focus === 'A') tickA = clamp(y, 8, tickB - 24);
else tickB = clamp(y, tickA + 24, frame.height - 8);
} else if (drag === 'tilt') {
transform = { ...transform, tiltDeg: tilt0 + (p[0] - tiltX0) * 0.6 };
} else if (drag === 'pan') {
transform = {
...transform,
panX: clamp(p[0] - panGrab[0], -400, 400),
panY: clamp(p[1] - panGrab[1], -400, 400),
};
}
redraw();
publish();
draw();
}
function zoomAt(point: [number, number], factor: number): void {
const anchor = photoPoint(point);
transform.zoom = Math.max(0.5, Math.min(3, transform.zoom * factor));
const after = photoToStage(anchor, sourceW, sourceH, transform);
transform.panX += point[0] - after[0];
transform.panY += point[1] - after[1];
redraw();
queuePersist();
}
function onWheel(event: WheelEvent): void {
event.preventDefault();
zoomAt(stagePoint(event), Math.exp(-event.deltaY * 0.0015));
}
function fitSlab(): void {
transform = { ...transform, zoom: 1, panX: 0, panY: 0 };
redraw();
syncCalibration();
}
function changeTilt(degrees: number): void {
transform = { ...transform, tiltDeg: transform.tiltDeg + degrees };
redraw();
syncCalibration();
}
function changeZoom(factor: number): void {
zoomAt([frame.width / 2, frame.height / 2], factor);
}
function onKeyDown(event: KeyboardEvent): void {
const fine = event.shiftKey ? 0.1 : 1;
if (event.key === 'ArrowLeft') changeTilt(-fine);
else if (event.key === 'ArrowRight') changeTilt(fine);
else if (event.key === 'ArrowUp') {
if (focusedTick === 'A') moveTickA(tickA - fine);
else moveTickB(tickB - fine);
syncCalibration(); redraw();
} else if (event.key === 'ArrowDown') {
if (focusedTick === 'A') moveTickA(tickA + fine);
else moveTickB(tickB + fine);
syncCalibration(); redraw();
} else if (event.key === '+' || event.key === '=') changeZoom(1.1);
else if (event.key === '-' || event.key === '_') changeZoom(1 / 1.1);
else return;
event.preventDefault();
}
function changeCropFill(event: Event): void {
const pct = Number((event.currentTarget as HTMLInputElement).value);
setCropFill(pct);
[tickA, tickB] = cropTickPositions(pct);
syncCalibration();
redraw();
}
$: if (cropFill !== lastCropFill) {
lastCropFill = cropFill;
[tickA, tickB] = cropTickPositions(cropFill);
syncCalibration();
redraw();
}
onMount(() => {
let alive = true;
const resize = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
stageW = entry.contentRect.width;
stageH = entry.contentRect.height;
redraw();
});
if (stage) resize.observe(stage);
void decodeOrientedBitmap(state.source.blob).then(async (decoded) => {
if (!alive) { decoded.close(); return; }
sourceW = decoded.width;
sourceH = decoded.height;
if (state.approvedMask.bitset.length !== sourceW * sourceH) {
decoded.close();
function onPointerUp(event: PointerEvent) {
const p = toFrame(event);
pointers.delete(event.pointerId);
const wasPan = drag === 'pan';
drag = pointers.size ? drag : null;
if (wasPan && !moved) {
const now = performance.now();
if (lastTap && now - lastTap.t < 350 && Math.hypot(p[0] - lastTap.x, p[1] - lastTap.y) < 12) {
lastTap = null;
fit();
return;
}
const maskedCanvas = document.createElement('canvas');
maskedCanvas.width = sourceW; maskedCanvas.height = sourceH;
const maskedCtx = maskedCanvas.getContext('2d');
if (!maskedCtx) { decoded.close(); return; }
maskedCtx.drawImage(decoded, 0, 0);
const pixels = maskedCtx.getImageData(0, 0, sourceW, sourceH);
pixels.data.set(applyApprovedMask(pixels.data, state.approvedMask.bitset));
maskedCtx.putImageData(pixels, 0, 0);
const maskedBitmap = await createImageBitmap(maskedCanvas);
decoded.close();
if (!alive) { maskedBitmap.close(); return; }
bitmap = maskedBitmap;
redraw();
}).catch(() => {});
return () => {
alive = false;
resize.disconnect();
if (persistTimer) clearTimeout(persistTimer);
bitmap?.close(); bitmap = null;
};
lastTap = { t: now, x: p[0], y: p[1] };
}
draw();
}
function onWheel(event: WheelEvent) {
event.preventDefault();
const dy = clamp(event.deltaY, -200, 200);
zoomAt(toFrame(event), clamp(Math.exp(-dy * 0.0015), 0.8, 1.25));
}
function onKey(event: KeyboardEvent) {
const tag = (event.target as HTMLElement | null)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
if (event.key === '+' || event.key === '=') {
zoomAt([frame.width / 2, frame.height / 2], 1.1);
event.preventDefault();
} else if (event.key === '-' || event.key === '_') {
zoomAt([frame.width / 2, frame.height / 2], 1 / 1.1);
event.preventDefault();
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
const step = event.shiftKey ? 0.1 : 1;
transform = { ...transform, tiltDeg: transform.tiltDeg + (event.key === 'ArrowLeft' ? -step : step) };
publish();
draw();
event.preventDefault();
} else if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
const step = (event.shiftKey ? 0.1 : 1) * (event.key === 'ArrowUp' ? -1 : 1);
if (focus === 'A') tickA = clamp(tickA + step, 8, tickB - 24);
else tickB = clamp(tickB + step, tickA + 24, frame.height - 8);
publish();
draw();
event.preventDefault();
}
}
function onFill(event: Event) {
fill = Number((event.currentTarget as HTMLInputElement).value);
setCropFill(fill);
reseat(fill);
}
onMount(async () => {
const bmp = await decodeOrientedBitmap(state.source.blob);
bitmap = bmp;
sourceW = bmp.width;
sourceH = bmp.height;
const bitset = state.approvedMask.bitset;
if (bitset.length === sourceW * sourceH) {
maskLayer = document.createElement('canvas');
maskLayer.width = sourceW;
maskLayer.height = sourceH;
const mctx = maskLayer.getContext('2d');
if (mctx) {
const data = mctx.createImageData(sourceW, sourceH);
for (let i = 0; i < bitset.length; i++) {
if (bitset[i]) data.data[i * 4 + 3] = 255;
}
mctx.putImageData(data, 0, 0);
}
}
publish();
draw();
});
onDestroy(() => bitmap?.close());
</script>
<svelte:window on:keydown={onKey} />
<section class="panel">
<h1>Tape line</h1>
<p class="hint">Drag each tick to where your tape started and ended.</p>
<div class="stage" bind:this={stage}>
<h1>Length</h1>
<p class="hint">Move the slab until its long axis sits on the line. Drag the ticks to the tape ends. The line does not move.</p>
<div class="stage" bind:this={stageEl} data-testid="tape-line">
<canvas
bind:this={canvas}
tabindex="0"
aria-label="Fixed tape line over the masked slab. Drag the slab to pan, use the tilt control, and drag ticks along the line."
on:pointerdown={onPointerDown}
on:pointermove={onPointerMove}
on:pointerup={onPointerUp}
on:pointercancel={onPointerUp}
on:wheel|nonpassive={onWheel}
on:keydown={onKeyDown}
on:wheel={onWheel}
></canvas>
</div>
<p class="hint">The black line is fixed in the middle of the frame. Move the slab instead: drag to pan, wheel or pinch to zoom, and use the rotate handle or arrow keys to tilt until the slab's long axis lies on the line.</p>
<div class="control">
<label for="photo-angle">Tilt the slab</label>
<button type="button" class="thumb" aria-label="Tilt left" on:click={() => changeTilt(-1)}>−</button>
<input id="photo-angle" type="range" min="0" max="359" step="1" value={((transform.tiltDeg % 360) + 360) % 360}
on:input={(event) => { transform.tiltDeg = Number(event.currentTarget.value); redraw(); syncCalibration(); }} />
<button type="button" class="thumb" aria-label="Tilt right" on:click={() => changeTilt(1)}>+</button>
<span class="value">{((transform.tiltDeg % 360) + 360) % 360}°</span>
</div>
<div class="control">
<label for="photo-zoom">Zoom the slab</label>
<button type="button" class="thumb" aria-label="Zoom out" on:click={() => changeZoom(1 / 1.1)}>−</button>
<input id="photo-zoom" type="range" min="50" max="300" step="1" value={Math.round(transform.zoom * 100)}
on:input={(event) => { changeZoom(Number(event.currentTarget.value) / (transform.zoom * 100)); }} />
<span class="value">{Math.round(transform.zoom * 100)}%</span>
<button type="button" class="thumb" aria-label="Zoom in" on:click={() => changeZoom(1.1)}>+</button>
<button type="button" class="minor" on:click={fitSlab}>Fit</button>
</div>
<div class="control">
<label for="crop-fill">Crop fill %</label>
<input id="crop-fill" type="range" min="1" max="100" step="1" value={cropFill} on:input={changeCropFill} />
<span class="value">{cropFill}%</span>
</div>
<p class="hint">Arrow keys tilt the slab; Shift + arrow adjusts by 0.1°. The last tick you grabbed takes the up/down keys (1px, or 0.1px with Shift). Use +/− to zoom; double-click the slab to fit.</p>
<p class="ctl">Tilt</p>
<div class="row">
<button type="button" class="ghost" on:click={backToMask}>Back</button>
<button type="button" class="primary" on:click={approveCalibration}>Approve tape line</button>
<button type="button" class="thumb" aria-label="Tilt counter-clockwise" on:click={() => { transform = { ...transform, tiltDeg: transform.tiltDeg - 1 }; publish(); draw(); }}>−</button>
<input type="range" min="-180" max="180" step="0.1" aria-label="Tilt" value={transform.tiltDeg} on:input={(e) => { transform = { ...transform, tiltDeg: Number(e.currentTarget.value) }; publish(); draw(); }} />
<button type="button" class="thumb" aria-label="Tilt clockwise" on:click={() => { transform = { ...transform, tiltDeg: transform.tiltDeg + 1 }; publish(); draw(); }}>+</button>
<span class="val">{transform.tiltDeg.toFixed(1)}°</span>
</div>
<p class="ctl">Zoom</p>
<div class="row">
<button type="button" class="thumb" aria-label="Zoom out" on:click={() => zoomAt([frame.width / 2, frame.height / 2], 1 / 1.1)}>−</button>
<input type="range" min="50" max="300" step="1" aria-label="Zoom" value={Math.round(transform.zoom * 100)} on:input={(e) => { const z = Number(e.currentTarget.value) / 100; zoomAt([frame.width / 2, frame.height / 2], z / Math.max(transform.zoom, 0.01)); }} />
<button type="button" class="thumb" aria-label="Zoom in" on:click={() => zoomAt([frame.width / 2, frame.height / 2], 1.1)}>+</button>
<span class="val">{Math.round(transform.zoom * 100)}%</span>
</div>
<button type="button" class="ghost fit" on:click={fit}>Fit</button>
<p class="nudge">Wheel or pinch zooms the slab. Arrow keys tilt; up and down nudge the last tick. Shift is a fine step. Double-tap the slab to fit.</p>
<p class="ctl">Crop fill</p>
<div class="row">
<input type="range" min="1" max="100" step="1" aria-label="Crop fill percent" value={fill} on:input={onFill} />
<span class="val">{fill}%</span>
</div>
<div class="actions">
<button type="button" class="ghost" on:click={back}>Back</button>
<button type="button" class="primary" on:click={approveAxis}>Approve tape line</button>
</div>
</section>
<style>
.panel { padding: 16px; color: #eee; }
h1 { font-size: 1.3rem; margin: 0 0 8px; }
.hint { color: #aaa; font-size: 0.85rem; margin: 0 0 12px; line-height: 1.45; }
.stage { width: min(100%, 420px); aspect-ratio: 3 / 4; margin: 0 auto; overflow: hidden; background: #161616; border: 1px solid #333; border-radius: 3px; }
canvas { display: block; width: 100%; height: 100%; touch-action: none; cursor: grab; outline-offset: 2px; }
.control { display: flex; align-items: center; gap: 8px; margin-top: 14px; }
.control label { min-width: 86px; color: #bbb; font-size: 0.82rem; }
input[type='range'] { flex: 1; min-width: 0; accent-color: #00e5ff; }
.value { min-width: 4ch; text-align: right; font-variant-numeric: tabular-nums; color: #00e5ff; }
.thumb, .minor { min-width: 44px; min-height: 44px; border: 1px solid #444; background: #1d1d1d; color: #eee; padding: 6px 9px; }
.thumb { border-radius: 50%; font-size: 1.2rem; flex: none; }
.minor { border-radius: 3px; }
.row { display: flex; gap: 10px; margin-top: 16px; }
.primary { flex: 1; min-height: 48px; padding: 12px; font-size: 1rem; border: 0; background: #00e5ff; color: #002; font-weight: 700; }
.ghost { flex: 0.7; min-height: 48px; padding: 12px; border: 1px solid #444; background: transparent; color: #ccc; }
@media (max-width: 360px) { .control { gap: 6px; } .control label { min-width: 64px; } }
.hint, .nudge { color: #aaa; font-size: 0.85rem; line-height: 1.4; margin: 0 0 12px; }
.ctl { color: #aaa; font-size: 0.78rem; margin: 14px 0 6px; }
.stage { width: 100%; aspect-ratio: 3 / 4; background: #111; border: 1px solid #333; border-radius: 3px; overflow: hidden; touch-action: none; }
canvas { display: block; width: 100%; height: 100%; touch-action: none; }
.row { display: flex; align-items: center; gap: 10px; }
.thumb {
width: 44px; height: 44px; border-radius: 3px; font-size: 1.3rem;
border: 1px solid #444; background: #1d1d1d; color: #eee; flex: none;
}
input[type='range'] { flex: 1; }
.val { min-width: 5ch; text-align: right; font-variant-numeric: tabular-nums; }
.fit { margin-top: 8px; }
.actions { display: flex; gap: 10px; margin-top: 16px; }
.primary {
flex: 2; padding: 12px; font-size: 1rem; border-radius: 3px; border: 0;
background: #00e5ff; color: #002; font-weight: 700;
}
.ghost { padding: 12px 16px; border-radius: 3px; border: 1px solid #444; background: transparent; color: #ccc; }
</style>
+2 -11
View File
@@ -3,7 +3,6 @@
import type { Capture } from './capture.types';
import { inToEighths, eighthsToIn } from './capture.types';
import { fmtEighths } from './session';
import { calibrationScale } from '../png/crop';
import PhotoStage from './PhotoStage.svelte';
import {
buildPhotosReadyLineup,
@@ -55,12 +54,6 @@
return Number.isFinite(inches) && inches > 0 ? inches : null;
};
$: tapeScale = calibrationScale(
state.axis.tapeLine.start,
state.axis.tapeLine.end,
positiveInches(String(lengthIn)) ?? 0,
);
const commitLength = () => {
const inches = positiveInches(String(lengthIn));
const eighths = inches === null ? 0 : inToEighths(inches);
@@ -74,7 +67,7 @@
thicknessIn = eighths === 0 ? '' : String(eighthsToIn(eighths));
};
$: canCalc = positiveInches(String(lengthIn)) !== null && positiveInches(String(thicknessIn)) !== null && tapeScale > 0;
$: canCalc = positiveInches(String(lengthIn)) !== null && positiveInches(String(thicknessIn)) !== null;
const doCalc = async () => {
calcBusy = true;
@@ -100,7 +93,7 @@
<div class="fields">
<div class="field">
<span class="label">Tape length L (distance between ticks)</span>
<span class="label">Length L (tape reading)</span>
<input
type="number"
aria-label="Length L"
@@ -128,7 +121,6 @@
/>
</div>
</div>
<p class="scale">Tape ticks: {Math.hypot(state.axis.tapeLine.end[0] - state.axis.tapeLine.start[0], state.axis.tapeLine.end[1] - state.axis.tapeLine.start[1]).toFixed(1)} source-photo px · Scale {tapeScale.toFixed(2)} px/in</p>
<div class="row">
<button type="button" class="ghost" on:click={back}>Back</button>
@@ -202,7 +194,6 @@
.fields { display: flex; gap: 16px; margin-top: 16px; }
.field { flex: 1; }
.label { display: block; font-size: 0.9rem; color: #aaa; margin-bottom: 8px; }
.scale { margin: 8px 0 0; color: #aaa; font-size: 0.85rem; font-variant-numeric: tabular-nums; }
.field input {
box-sizing: border-box; width: 100%; min-height: 52px; padding: 12px; font-size: 1rem;
border-radius: 10px; border: 1px solid #444; background: #1d1d1d; color: #eee;
+12 -13
View File
@@ -43,16 +43,9 @@ export interface Mask {
slabPx: number; // 0 ⇒ approve blocked, user retakes
bitset: Uint8Array; // source-grid mask, 1 byte/px
bbox: BBox;
slabBounds: ABox; // axis-aligned extent of slab px, for the PNG crop (settings.crop_fill, default 90%)
slabBounds: ABox; // axis-aligned extent of slab px, for the 80%-fill PNG crop
}
/** The owner-positioned start/end points of the tape in oriented source pixels. */
export interface TapeLine {
start: [number, number];
end: [number, number];
}
/** User-selected transform applied to the calibration photo only. */
export interface PhotoTransform {
tiltDeg: number;
zoom: number;
@@ -60,12 +53,15 @@ export interface PhotoTransform {
panY: number;
}
// Direction only: an oriented rectangle over the slab. The arrow reads
// corners[3] → corners[0] (the long side).
// The tape line is a stage fixture. bbox is kept for the dimension overlay.
// Scale is the source-pixel distance between tapeStart and tapeEnd.
export interface Axis {
bbox: BBox;
tapeLine: TapeLine;
photoTransform: PhotoTransform;
tickStartY: number;
tickEndY: number;
tapeStart: [number, number];
tapeEnd: [number, number];
}
export interface Widths {
@@ -154,8 +150,11 @@ export interface Draft {
maskParams: MaskParams; // → re-derive mask on resume (ignored while stage==='batch')
mask: Omit<Mask, 'bitset' | 'params'>; // slabPx, bbox, slabBounds
axisBbox?: BBox | null; // present when stage >= axis; absent on legacy drafts
tapeLine?: TapeLine; // owner-placed tape endpoints; absent on pre-M4 drafts
photoTransform?: PhotoTransform; // calibration photo transform; absent on pre-M4 drafts
photoTransform?: PhotoTransform;
tickStartY?: number;
tickEndY?: number;
tapeStart?: [number, number];
tapeEnd?: [number, number];
lengthEighths: Eighths;
thicknessEighths: Eighths;
results: Omit<Measures, 'inventoryPng' | 'overlayPng'> | null; // present when results computed
+2 -10
View File
@@ -378,10 +378,9 @@ test('#61: Calculate produces inventory PNG + M1 measures for calibration source
s.setSheet('black');
await settleOverlay(s);
s.approveMask();
s.setTapeLine({ start: [0, 10], end: [39, 10] });
s.approveAxis();
// 39 source-photo px between ticks / 40 owner-declared inches = 0.975 px/in.
// L = 40 in (320 eighths), thickness = 2 in (16 eighths) — scale ≈ longest_axis_px / L
s.setLength(320);
s.setThickness(16);
await s.calculate();
@@ -394,7 +393,7 @@ test('#61: Calculate produces inventory PNG + M1 measures for calibration source
const r = cap.results!;
assert.ok(r.inventoryPng instanceof Blob, 'clean inventory PNG blob');
assert.equal(typeof r.scale, 'number');
assert.equal(r.scale, 39 / 40, 'scale comes from the positioned tape endpoints');
assert.ok(r.scale > 0, 'scale px/in > 0');
assert.equal(typeof r.sqft, 'number');
assert.equal(typeof r.bdft, 'number');
// CAPTURE.md: bdft = sqft × thickness_in
@@ -444,9 +443,6 @@ test('#61: other batch photos stay in draft.batch untouched by mask approve', as
assert.equal(before!.batch![2].blob, c);
s.approveMask();
const tapeLine = { start: [2, 3] as [number, number], end: [35, 14] as [number, number] };
const photoTransform = { tiltDeg: 12, zoom: 1.25, panX: 5, panY: -9 };
s.setTapeLine(tapeLine, photoTransform);
await new Promise((r) => setTimeout(r, 20));
const after = store.peek();
@@ -454,8 +450,6 @@ test('#61: other batch photos stay in draft.batch untouched by mask approve', as
assert.equal(after!.stage, 'axis');
assert.equal(after!.calibrationIndex, 1);
assert.equal(after!.source.blob, b, 'draft.source is calibration only');
assert.deepEqual(after!.tapeLine, tapeLine, 'draft persists the owner-positioned tape endpoints');
assert.deepEqual(after!.photoTransform, photoTransform, 'draft persists the calibration photo transform');
assert.equal((after!.batch ?? []).length, 3, 'full batch retained');
assert.equal(after!.batch![0].blob, a, 'batch[0] untouched');
assert.equal(after!.batch![1].blob, b);
@@ -470,8 +464,6 @@ test('#61: other batch photos stay in draft.batch untouched by mask approve', as
assert.equal(meas!.batch![0].blob, a);
assert.equal(meas!.batch![2].blob, c);
assert.equal(meas!.calibrationIndex, 1);
assert.deepEqual(meas!.tapeLine, tapeLine, 'measure draft keeps the same calibration endpoints');
assert.deepEqual(meas!.photoTransform, photoTransform, 'measure draft keeps the calibration transform');
s.dispose();
});
+67 -56
View File
@@ -1,18 +1,16 @@
import { writable } from 'svelte/store';
import type {
BatchPhotoResult, Capture, Draft, Eighths, Mask, MaskParams, Measures, PhotoTransform, Sheet, Source, TapeLine, WorkerIn, WorkerOut,
Axis, BatchPhotoResult, Capture, Draft, Eighths, Mask, MaskParams, Measures, PhotoTransform, Sheet, Source, WorkerIn, WorkerOut,
} from './capture.types';
import {
DEFAULT_MASK_PARAMS, EMPTY_ABOX, EMPTY_BBOX, eighthsToIn,
} from './capture.types';
import { loadDraft, saveDraft, clearDraft } from '../draft/idb';
import { measure } from '../measure/measure';
import { rectAlongLine } from '../geometry/rect';
import { encodeInventoryPng, type InventoryInput } from '../png/inventory';
import { encodeOverlayPng, type EncodeOverlayInput } from '../png/overlay';
import { calibrationScale, FILL_DEFAULT, fillFromSetting } from '../png/crop';
import { cropTickPositions, DEFAULT_PHOTO_TRANSFORM, tapeLineFromStageTicks } from './tapeCalibration';
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
import { IDENTITY_TRANSFORM, cropEnds, tapeSegmentSource } from './tapeLine';
const DEBOUNCE_MS = 2000;
const BUSY_MS = 300;
@@ -42,9 +40,14 @@ export interface CaptureSession {
setSensitivity(n: number): void;
setSheet(s: Sheet): void;
approveMask(): void;
setTapeLine(tapeLine: TapeLine, photoTransform?: PhotoTransform): void;
/** #99: global crop fill from settings (percent or fraction); drives the PNG crop. */
setCropFill(pctOrFraction: number): void;
setTapePose(pose: {
transform: PhotoTransform;
tickStartY: number;
tickEndY: number;
sourceW: number;
sourceH: number;
}): void;
setCropFill(pct: number): void;
approveAxis(): void;
setLength(e: Eighths): void;
setThickness(e: Eighths): void;
@@ -87,8 +90,26 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
let busyTimer: ReturnType<typeof setTimeout> | null = null;
let pngUrl: string | null = null;
let resumed = false;
// #99: global crop fill (fraction). Set from settings; FILL_DEFAULT (90%) until told otherwise.
let cropFill = FILL_DEFAULT;
let cropFill = 90;
let poseTimer: ReturnType<typeof setTimeout> | null = null;
const axisFrom = (
bbox: Axis['bbox'],
sourceW: number,
sourceH: number,
pose?: { transform?: PhotoTransform; tickStartY?: number; tickEndY?: number; tapeStart?: [number, number]; tapeEnd?: [number, number] },
): Axis => {
const ends = cropEnds(cropFill);
const transform = pose?.transform ?? { ...IDENTITY_TRANSFORM };
const tickStartY = pose?.tickStartY ?? ends.startY;
const tickEndY = pose?.tickEndY ?? ends.endY;
const w = sourceW > 0 ? sourceW : 1;
const h = sourceH > 0 ? sourceH : 1;
const seg = pose?.tapeStart && pose?.tapeEnd
? { start: pose.tapeStart, end: pose.tapeEnd }
: tapeSegmentSource(tickStartY, tickEndY, w, h, transform);
return { bbox, photoTransform: transform, tickStartY, tickEndY, tapeStart: seg.start, tapeEnd: seg.end };
};
const set = (next: Capture) => {
state = next;
@@ -213,8 +234,11 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
calibrationIndex: calIdx,
source: state.source, maskParams: state.approvedMask.params,
mask: strip(state.approvedMask), axisBbox: state.axis.bbox,
tapeLine: state.axis.tapeLine,
photoTransform: state.axis.photoTransform,
tickStartY: state.axis.tickStartY,
tickEndY: state.axis.tickEndY,
tapeStart: state.axis.tapeStart,
tapeEnd: state.axis.tapeEnd,
lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null,
};
}
@@ -225,8 +249,11 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
calibrationIndex: calIdx,
source: state.source, maskParams: state.approvedMask.params,
mask: strip(state.approvedMask), axisBbox: state.axis.bbox,
tapeLine: state.axis.tapeLine,
photoTransform: state.axis.photoTransform,
tickStartY: state.axis.tickStartY,
tickEndY: state.axis.tickEndY,
tapeStart: state.axis.tapeStart,
tapeEnd: state.axis.tapeEnd,
lengthEighths: state.lengthEighths, thicknessEighths: state.thicknessEighths,
results: state.results ? rest : null,
inventoryPng: state.results?.inventoryPng ?? null,
@@ -321,13 +348,6 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
// Legacy drafts (pre-axisBbox, with heading) carry no usable axis state;
// fall back to the mask's own bbox.
const draftAxisBbox = (d: Draft) => d.axisBbox ?? d.mask.bbox;
const draftPhotoTransform = (d: Draft) => d.photoTransform ?? { ...DEFAULT_PHOTO_TRANSFORM };
const draftTapeLine = (d: Draft) => {
if (d.tapeLine) return d.tapeLine;
const transform = draftPhotoTransform(d);
const [startY, endY] = cropTickPositions(FILL_DEFAULT);
return tapeLineFromStageTicks(startY, endY, d.source.widthPx, d.source.heightPx, transform);
};
const restoreFromDraft = async (d: Draft) => {
batchPhotos = batchPhotosOf(d);
@@ -355,11 +375,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
try {
const mask = await rederiveMask(source, d.maskParams, d.mask);
if (d.stage === 'axis') {
const photoTransform = draftPhotoTransform(d);
const tapeLine = draftTapeLine(d);
set({ k: 'axis', source, approvedMask: mask, axis: {
bbox: rectAlongLine(draftAxisBbox(d), tapeLine.start, tapeLine.end), tapeLine, photoTransform,
} });
set({ k: 'axis', source, approvedMask: mask, axis: axisFrom(draftAxisBbox(d), source.widthPx, source.heightPx, d) });
return;
}
if (d.inventoryPng) {
@@ -368,11 +384,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
}
set({
k: 'measure', source, approvedMask: mask,
axis: {
bbox: rectAlongLine(draftAxisBbox(d), draftTapeLine(d).start, draftTapeLine(d).end),
tapeLine: draftTapeLine(d),
photoTransform: draftPhotoTransform(d),
},
axis: axisFrom(draftAxisBbox(d), source.widthPx, source.heightPx, d),
lengthEighths: d.lengthEighths, thicknessEighths: d.thicknessEighths,
results: d.results
? {
@@ -501,36 +513,39 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
if (state.k !== 'mask' || !state.overlay || state.overlay.slabPx === 0) return;
teardownWorker();
const overlay = state.overlay;
const photoTransform = { ...DEFAULT_PHOTO_TRANSFORM };
const [startY, endY] = cropTickPositions(cropFill);
const tapeLine = tapeLineFromStageTicks(startY, endY, state.source.widthPx, state.source.heightPx, photoTransform);
set({ k: 'axis', source: state.source, approvedMask: overlay, axis: {
bbox: rectAlongLine(overlay.bbox, tapeLine.start, tapeLine.end), tapeLine, photoTransform,
} });
const bbox = { corners: overlay.bbox.corners.map((c) => [...c] as [number, number]), longSidePx: overlay.bbox.longSidePx, angleDeg: overlay.bbox.angleDeg };
set({ k: 'axis', source: state.source, approvedMask: overlay, axis: axisFrom(bbox, state.source.widthPx, state.source.heightPx) });
void persist();
},
setTapeLine(tapeLine: TapeLine, nextTransform?: PhotoTransform) {
setTapePose(pose) {
if (state.k !== 'axis') return;
const start: [number, number] = [...tapeLine.start];
const end: [number, number] = [...tapeLine.end];
const photoTransform = { ...(nextTransform ?? state.axis.photoTransform) };
const bbox = rectAlongLine(state.axis.bbox, start, end);
if (!(bbox.longSidePx > 0)) return;
set({ ...state, axis: { bbox, tapeLine: { start, end }, photoTransform } });
void persist();
const sourceW = pose.sourceW > 0 ? pose.sourceW : state.source.widthPx;
const sourceH = pose.sourceH > 0 ? pose.sourceH : state.source.heightPx;
const source = sourceW > 0 && sourceH > 0
? { ...state.source, widthPx: sourceW, heightPx: sourceH }
: state.source;
set({
...state,
source,
axis: axisFrom(state.axis.bbox, sourceW, sourceH, pose),
});
if (poseTimer) clearTimeout(poseTimer);
poseTimer = setTimeout(() => {
poseTimer = null;
void persist();
}, 250);
},
setCropFill(pctOrFraction: number) {
// #99: non-retro — only future encodes use this; stored PNGs are untouched.
cropFill = fillFromSetting(pctOrFraction);
setCropFill(pct: number) {
cropFill = Math.max(1, Math.min(100, Math.round(pct)));
},
approveAxis() {
if (state.k !== 'axis') return;
set({
k: 'measure', source: state.source, approvedMask: state.approvedMask,
axis: { bbox: state.axis.bbox, tapeLine: state.axis.tapeLine, photoTransform: state.axis.photoTransform },
axis: state.axis,
lengthEighths: 0, thicknessEighths: 0, results: null,
});
void persist();
@@ -554,17 +569,16 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
if (state.k !== 'measure') return;
const { source, approvedMask, axis, lengthEighths, thicknessEighths } = state;
if (lengthEighths === 0 || thicknessEighths === 0) return;
const scale = calibrationScale(axis.tapeLine.start, axis.tapeLine.end, eighthsToIn(lengthEighths));
if (!(scale > 0) || !Number.isFinite(scale)) return;
const bitmap = await decodeOrientedBitmap(source.blob);
const base = measure(
approvedMask.bitset, source.widthPx, source.heightPx,
axis.bbox, lengthEighths, thicknessEighths, approvedMask.slabPx, scale,
axis.bbox, lengthEighths, thicknessEighths, approvedMask.slabPx,
{ start: axis.tapeStart, end: axis.tapeEnd },
);
let pngBlob: Blob;
let belowFloor = false;
try {
const r = await encodeInventory({ bitmap, mask: approvedMask, source, fill: cropFill, rotationDeg: axis.photoTransform.tiltDeg });
const r = await encodeInventory({ bitmap, mask: approvedMask, source, cropFill });
pngBlob = r.blob;
belowFloor = r.sourceBelowFloor;
} catch {
@@ -590,8 +604,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
lengthLabel: fmtEighths(lengthEighths),
slabBounds: approvedMask.slabBounds,
source,
fill: cropFill,
rotationDeg: axis.photoTransform.tiltDeg,
cropFill,
});
} catch {
// Deterministic tests may inject a non-decodable inventory blob; still
@@ -636,7 +649,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
const siblingBitmap = await decodeOrientedBitmap(photo.blob);
let encoded: { blob: Blob; sourceBelowFloor: boolean };
try {
encoded = await encodeInventory({ bitmap: siblingBitmap, mask: derived.mask, source: siblingSource, fill: cropFill });
encoded = await encodeInventory({ bitmap: siblingBitmap, mask: derived.mask, source: siblingSource, cropFill });
} finally {
siblingBitmap.close();
}
@@ -669,9 +682,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
const m = state.approvedMask;
set({ k: 'mask', source: state.source, control: m.params, overlay: m, failed: false });
} else if (state.k === 'measure') {
set({ k: 'axis', source: state.source, approvedMask: state.approvedMask, axis: {
bbox: state.axis.bbox, tapeLine: state.axis.tapeLine, photoTransform: state.axis.photoTransform,
} });
set({ k: 'axis', source: state.source, approvedMask: state.approvedMask, axis: state.axis });
}
},
@@ -1,57 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
CALIBRATION_FRAME,
applyApprovedMask,
cropTickPositions,
pinchPhotoTransform,
photoToStage,
stageToPhoto,
tapeLineFromStageTicks,
} from './tapeCalibration.ts';
test('the reference line is fixed at the center of the calibration frame', () => {
assert.equal(CALIBRATION_FRAME.width, 480);
assert.equal(CALIBRATION_FRAME.height, 640);
assert.equal(CALIBRATION_FRAME.width / 2, 240);
const [top, bottom] = cropTickPositions(90);
assert.ok(Math.abs(top - 32) < 1e-9);
assert.ok(Math.abs(bottom - 608) < 1e-9);
const transform = { tiltDeg: 21, zoom: 1.4, panX: -18, panY: 11 };
const tape = tapeLineFromStageTicks(100, 530, 2400, 1800, transform);
const start = photoToStage(tape.start, 2400, 1800, transform);
const end = photoToStage(tape.end, 2400, 1800, transform);
assert.ok(Math.abs(start[0] - 240) < 1e-8 && Math.abs(start[1] - 100) < 1e-8);
assert.ok(Math.abs(end[0] - 240) < 1e-8 && Math.abs(end[1] - 530) < 1e-8);
});
test('pan, zoom, and tilt change the photo under the fixed reference line without losing source coordinates', () => {
const transform = { tiltDeg: 31, zoom: 1.7, panX: 15, panY: -24 };
const sourcePoint: [number, number] = [317, 218];
const stagePoint = photoToStage(sourcePoint, 1200, 900, transform);
const restored = stageToPhoto(stagePoint, 1200, 900, transform);
assert.ok(Math.abs(restored[0] - sourcePoint[0]) < 1e-8);
assert.ok(Math.abs(restored[1] - sourcePoint[1]) < 1e-8);
});
test('approved-mask rendering removes background pixels but preserves slab pixels', () => {
const rgba = new Uint8ClampedArray([
180, 90, 30, 255,
20, 200, 40, 255,
]);
const masked = applyApprovedMask(rgba, Uint8Array.of(1, 0));
assert.deepEqual([...masked.slice(0, 4)], [180, 90, 30, 255]);
assert.deepEqual([...masked.slice(4, 8)], [20, 200, 40, 0]);
});
test('pinch zoom keeps the photo point under the moving pinch midpoint', () => {
const transform = { tiltDeg: 17, zoom: 1.2, panX: 11, panY: -8 };
const anchor: [number, number] = [420, 530];
const before = photoToStage(anchor, 1200, 900, transform);
const midpoint: [number, number] = [before[0] + 18, before[1] - 12];
const next = pinchPhotoTransform(transform, anchor, midpoint, 1.5, 1200, 900);
const after = photoToStage(anchor, 1200, 900, next);
assert.ok(Math.abs(after[0] - midpoint[0]) < 1e-8);
assert.ok(Math.abs(after[1] - midpoint[1]) < 1e-8);
assert.ok(Math.abs(next.zoom - 1.8) < 1e-12);
});
-104
View File
@@ -1,104 +0,0 @@
import type { PhotoTransform, TapeLine } from './capture.types';
export const CALIBRATION_FRAME = { width: 480, height: 640 } as const;
export function cropTickPositions(fill: number): [number, number] {
const normalized = Number.isFinite(fill) && fill > 1 ? fill / 100 : fill;
const inset = CALIBRATION_FRAME.height * (1 - Math.max(0.01, Math.min(1, normalized))) / 2;
return [inset, CALIBRATION_FRAME.height - inset];
}
export const DEFAULT_PHOTO_TRANSFORM: PhotoTransform = {
tiltDeg: 0,
zoom: 1,
panX: 0,
panY: 0,
};
export function photoFitScale(width: number, height: number, tiltDeg: number): number {
const radians = (tiltDeg * Math.PI) / 180;
const cos = Math.abs(Math.cos(radians));
const sin = Math.abs(Math.sin(radians));
const rotatedWidth = cos * width + sin * height;
const rotatedHeight = sin * width + cos * height;
return Math.min(
CALIBRATION_FRAME.width / Math.max(rotatedWidth, 1),
CALIBRATION_FRAME.height / Math.max(rotatedHeight, 1),
);
}
/** Map oriented source-photo pixels into the stage; the reference line is not transformed. */
export function photoToStage(
point: [number, number],
width: number,
height: number,
transform: PhotoTransform,
): [number, number] {
const radians = (transform.tiltDeg * Math.PI) / 180;
const scale = photoFitScale(width, height, transform.tiltDeg) * transform.zoom;
const dx = (point[0] - width / 2) * scale;
const dy = (point[1] - height / 2) * scale;
return [
CALIBRATION_FRAME.width / 2 + transform.panX + dx * Math.cos(radians) - dy * Math.sin(radians),
CALIBRATION_FRAME.height / 2 + transform.panY + dx * Math.sin(radians) + dy * Math.cos(radians),
];
}
/** Zoom about a photo anchor while keeping it under the moving pinch midpoint. */
export function pinchPhotoTransform(
transform: PhotoTransform,
anchor: [number, number],
midpoint: [number, number],
zoomFactor: number,
width: number,
height: number,
): PhotoTransform {
const next = { ...transform, zoom: Math.max(0.5, Math.min(3, transform.zoom * zoomFactor)) };
const projected = photoToStage(anchor, width, height, next);
next.panX += midpoint[0] - projected[0];
next.panY += midpoint[1] - projected[1];
return next;
}
/** Invert photoToStage so fixed-line tick positions map back to source pixels. */
export function stageToPhoto(
point: [number, number],
width: number,
height: number,
transform: PhotoTransform,
): [number, number] {
const radians = (-transform.tiltDeg * Math.PI) / 180;
const scale = photoFitScale(width, height, transform.tiltDeg) * transform.zoom;
if (!(scale > 0) || !Number.isFinite(scale)) return [Number.NaN, Number.NaN];
const dx = point[0] - CALIBRATION_FRAME.width / 2 - transform.panX;
const dy = point[1] - CALIBRATION_FRAME.height / 2 - transform.panY;
return [
width / 2 + (dx * Math.cos(radians) - dy * Math.sin(radians)) / scale,
height / 2 + (dx * Math.sin(radians) + dy * Math.cos(radians)) / scale,
];
}
export function tapeLineFromStageTicks(
startY: number,
endY: number,
width: number,
height: number,
transform: PhotoTransform,
): TapeLine {
return {
start: stageToPhoto([CALIBRATION_FRAME.width / 2, startY], width, height, transform),
end: stageToPhoto([CALIBRATION_FRAME.width / 2, endY], width, height, transform),
};
}
/** Remove background alpha using the approved mask without changing slab pixels. */
export function applyApprovedMask(rgba: Uint8ClampedArray, bitset: Uint8Array): Uint8ClampedArray {
if (rgba.length % 4 !== 0 || bitset.length !== rgba.length / 4) {
throw new RangeError('Approved mask dimensions must match photo pixels');
}
const masked = new Uint8ClampedArray(rgba);
for (let pixel = 0; pixel < bitset.length; pixel++) {
if (bitset[pixel] === 0) masked[pixel * 4 + 3] = 0;
}
return masked;
}
+59
View File
@@ -0,0 +1,59 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
CALIBRATION_FRAME,
IDENTITY_TRANSFORM,
calibrationScale,
cropEnds,
hitRadiusStage,
lineX,
sourceTickDistance,
stageToPhoto,
} from './tapeLine.ts';
const moved = { tiltDeg: 17, zoom: 1.8, panX: 40, panY: -25 };
test('the line stays in the middle of the frame when the photo moves', () => {
assert.equal(lineX(), CALIBRATION_FRAME.width / 2);
assert.equal(lineX(), 240);
const a = stageToPhoto([lineX(), 32], 1000, 1500, IDENTITY_TRANSFORM);
const b = stageToPhoto([lineX(), 32], 1000, 1500, moved);
assert.equal(a[0] === b[0] && a[1] === b[1], false);
assert.equal(lineX(), CALIBRATION_FRAME.width / 2);
});
test('crop fill moves only the ends and never the 3:4 frame', () => {
const at90 = cropEnds(90);
assert.equal(at90.startY, 32);
assert.equal(at90.endY, 608);
const at50 = cropEnds(50);
const at100 = cropEnds(100);
assert.equal(CALIBRATION_FRAME.width / CALIBRATION_FRAME.height, 3 / 4);
assert.equal(at50.startY, 160);
assert.equal(at100.startY, 0);
assert.equal(at100.endY, 640);
assert.equal(lineX(), 240);
});
test('tick stage positions are inputs, not a function of the photo transform', () => {
const ends = cropEnds(90);
const still = sourceTickDistance(ends.startY, ends.endY, 800, 1200, IDENTITY_TRANSFORM);
const after = sourceTickDistance(ends.startY, ends.endY, 800, 1200, moved);
assert.ok(still > 0);
assert.notEqual(still, after);
assert.equal(ends.startY, 32);
assert.equal(ends.endY, 608);
});
test('scale is source-pixel tick distance divided by the declared length', () => {
const scale = calibrationScale(32, 608, 480, 640, IDENTITY_TRANSFORM, 72);
const distance = sourceTickDistance(32, 608, 480, 640, IDENTITY_TRANSFORM);
assert.equal(scale, distance / 72);
assert.equal(calibrationScale(32, 608, 480, 640, IDENTITY_TRANSFORM, 0), 0);
});
test('the tick hit target is at least 44px on a 320px-wide stage', () => {
const radius = hitRadiusStage(320);
const screen = radius * (320 / CALIBRATION_FRAME.width);
assert.ok(screen >= 44, `screen hit ${screen}`);
});
+104
View File
@@ -0,0 +1,104 @@
// Fixed tape line for calibration (#99).
// The line is a stage fixture. The photo is the only transformed entity.
// Tick Y values are stage coordinates and are never derived from the photo.
export const CALIBRATION_FRAME = { width: 480, height: 640 } as const;
export interface PhotoTransform {
tiltDeg: number;
zoom: number;
panX: number;
panY: number;
}
export const IDENTITY_TRANSFORM: PhotoTransform = {
tiltDeg: 0,
zoom: 1,
panX: 0,
panY: 0,
};
export function lineX(): number {
return CALIBRATION_FRAME.width / 2;
}
/** (1 - fill) / 2 of the frame's long axis, from the top and the bottom. */
export function cropEnds(fillPercent: number): { startY: number; endY: number } {
const fill = Math.max(1, Math.min(100, Number.isFinite(fillPercent) ? fillPercent : 90));
const inset = (CALIBRATION_FRAME.height * (100 - fill)) / 200;
return { startY: inset, endY: CALIBRATION_FRAME.height - inset };
}
export function fitScale(sourceW: number, sourceH: number, tiltDeg: number): number {
const radians = (tiltDeg * Math.PI) / 180;
const cos = Math.abs(Math.cos(radians));
const sin = Math.abs(Math.sin(radians));
const rotatedW = cos * sourceW + sin * sourceH;
const rotatedH = sin * sourceW + cos * sourceH;
return Math.min(
CALIBRATION_FRAME.width / Math.max(rotatedW, 1),
CALIBRATION_FRAME.height / Math.max(rotatedH, 1),
);
}
/** Inverse of the photo transform. Stage points on the fixed line become source pixels. */
export function stageToPhoto(
point: [number, number],
sourceW: number,
sourceH: number,
transform: PhotoTransform,
): [number, number] {
const scale = fitScale(sourceW, sourceH, transform.tiltDeg) * transform.zoom;
if (!(scale > 0) || !Number.isFinite(scale)) return [Number.NaN, Number.NaN];
const radians = (-transform.tiltDeg * Math.PI) / 180;
const dx = point[0] - lineX() - transform.panX;
const dy = point[1] - CALIBRATION_FRAME.height / 2 - transform.panY;
return [
sourceW / 2 + (dx * Math.cos(radians) - dy * Math.sin(radians)) / scale,
sourceH / 2 + (dx * Math.sin(radians) + dy * Math.cos(radians)) / scale,
];
}
export function sourceTickDistance(
startY: number,
endY: number,
sourceW: number,
sourceH: number,
transform: PhotoTransform,
): number {
const a = stageToPhoto([lineX(), startY], sourceW, sourceH, transform);
const b = stageToPhoto([lineX(), endY], sourceW, sourceH, transform);
return Math.hypot(b[0] - a[0], b[1] - a[1]);
}
export function calibrationScale(
startY: number,
endY: number,
sourceW: number,
sourceH: number,
transform: PhotoTransform,
lengthIn: number,
): number {
if (!(lengthIn > 0)) return 0;
const distance = sourceTickDistance(startY, endY, sourceW, sourceH, transform);
if (!(distance > 0) || !Number.isFinite(distance)) return 0;
return distance / lengthIn;
}
/** Stage-unit radius whose on-screen size is at least 44px at the given stage width. */
export function hitRadiusStage(renderedWidthPx: number): number {
return (44 * CALIBRATION_FRAME.width) / Math.max(renderedWidthPx, 1);
}
export function tapeSegmentSource(
startY: number,
endY: number,
sourceW: number,
sourceH: number,
transform: PhotoTransform,
): { start: [number, number]; end: [number, number] } {
return {
start: stageToPhoto([lineX(), startY], sourceW, sourceH, transform),
end: stageToPhoto([lineX(), endY], sourceW, sourceH, transform),
};
}
+28 -12
View File
@@ -1,12 +1,20 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { minAreaRect, orientedRect, rectAlongLine } from './rect.ts';
import { minAreaRect, orientedRect, maskSpanAlong } from './rect.ts';
import { measure } from '../measure/measure.ts';
// 100×20 horizontal slab
const W = 100, H = 20;
const bitset = new Uint8Array(W * H);
for (let x = 0; x < W; x++) for (let y = 0; y < H; y++) bitset[y * W + x] = 1;
test('maskSpanAlong of a 100×20 slab: X ≈ 99–100, Y ≈ 19–20', () => {
const sx = maskSpanAlong(bitset, W, H, 1, 0);
const sy = maskSpanAlong(bitset, W, H, 0, 1);
assert.ok(sx >= 99 && sx <= 100, `spanX ${sx}`);
assert.ok(sy >= 19 && sy <= 20, `spanY ${sy}`);
});
test('orientedRect changes angleDeg and preserves longSidePx', () => {
const prev = minAreaRect(bitset, W, H);
assert.ok(prev, 'minAreaRect returned a box');
@@ -21,15 +29,23 @@ test('orientedRect changes angleDeg and preserves longSidePx', () => {
assert.ok(orientedRect(prev, 190).angleDeg >= 0 && orientedRect(prev, 190).angleDeg < 180);
});
test('rectAlongLine sets the approved axis from the owner-positioned tape endpoints', () => {
const previous = minAreaRect(bitset, W, H)!;
const start: [number, number] = [20, 30];
const end: [number, number] = [80, 70];
const calibrated = rectAlongLine(previous, start, end);
const center: [number, number] = [50, 50];
assert.ok(Math.abs(calibrated.angleDeg - (Math.atan2(40, 60) * 180) / Math.PI) < 1e-9);
assert.ok(Math.abs(calibrated.longSidePx - Math.hypot(60, 40)) < 1e-9);
assert.ok(Math.abs(calibrated.corners.reduce((sum, point) => sum + point[0], 0) / 4 - center[0]) < 1e-9);
assert.ok(Math.abs(calibrated.corners.reduce((sum, point) => sum + point[1], 0) / 4 - center[1]) < 1e-9);
assert.notEqual(calibrated.angleDeg, previous.angleDeg, 'direction comes from tape endpoints, not mask rectangle');
test('measure scale uses mask span along the axis, not longSidePx', () => {
// Thin slab (20 long, 100 wide): minAreaRect's long side (angleDeg 90) is
// the 100-px dimension along +y. Rotate the axis to angleDeg 0 (+x), which
// crosses only the 20-px slab width — span 19, not longSidePx 99.
const TW = 20, TH = 100;
const thin = new Uint8Array(TW * TH).fill(1);
const thinBox = minAreaRect(thin, TW, TH);
assert.ok(thinBox && thinBox.longSidePx >= 99, `longSide ${thinBox?.longSidePx}`);
const rotated = orientedRect(thinBox, 0);
const lengthEighths = 24; // 3 in
const m = measure(thin, TW, TH, rotated, lengthEighths, 8, TW * TH);
// Scale = span along the axis / length. Span ≈ 19 → scale ≈ 6.3 px/in,
// far from the old longSidePx/3 ≈ 33.
const ex = rotated.corners[0][0] - rotated.corners[3][0];
const ey = rotated.corners[0][1] - rotated.corners[3][1];
const elen = Math.hypot(ex, ey);
const span = maskSpanAlong(thin, TW, TH, ex / elen, ey / elen);
assert.ok(Math.abs(m.scale - span / (lengthEighths / 8)) < 1e-9);
assert.ok(m.scale < 10, `scale ${m.scale} should be span-based, not ${thinBox.longSidePx / 3}`);
});
+14 -36
View File
@@ -1,4 +1,4 @@
import type { ABox, BBox, TapeLine } from '../capture/capture.types';
import type { ABox, BBox } from '../capture/capture.types';
export function slabBounds(bitset: Uint8Array, w: number, h: number): ABox {
let minX = w, minY = h, maxX = -1, maxY = -1;
@@ -113,40 +113,18 @@ export function orientedRect(prev: BBox, angleDeg: number): BBox {
return { corners, longSidePx: prev.longSidePx, angleDeg: a };
}
/** Build the approved axis rectangle from the owner's two tape-line endpoints. */
export function rectAlongLine(prev: BBox, start: [number, number], end: [number, number]): BBox {
const dx = end[0] - start[0];
const dy = end[1] - start[1];
const length = Math.hypot(dx, dy);
if (!(length > 0) || !Number.isFinite(length)) return prev;
const halfW = Math.hypot(
prev.corners[0][0] - prev.corners[1][0],
prev.corners[0][1] - prev.corners[1][1],
) / 2;
const cx = (start[0] + end[0]) / 2;
const cy = (start[1] + end[1]) / 2;
const ux = dx / length;
const uy = dy / length;
const vx = -uy;
const vy = ux;
const halfL = length / 2;
const corners: [number, number][] = [];
for (const [along, across] of [[1, 1], [1, -1], [-1, -1], [-1, 1]] as const) {
corners.push([
cx + along * halfL * ux + across * halfW * vx,
cy + along * halfL * uy + across * halfW * vy,
]);
// Project every slab pixel onto the unit vector (ux, uy); return max − min
// extent in px. 0 when the mask is empty.
export function maskSpanAlong(bitset: Uint8Array, w: number, h: number, ux: number, uy: number): number {
let min = Infinity, max = -Infinity;
for (let y = 0; y < h; y++) {
const row = y * w;
for (let x = 0; x < w; x++) {
if (!bitset[row + x]) continue;
const p = x * ux + y * uy;
if (p < min) min = p;
if (p > max) max = p;
}
}
const angleDeg = ((Math.atan2(uy, ux) * 180) / Math.PI + 180) % 180;
return { corners, longSidePx: length, angleDeg };
}
/** Initial guide positions; the owner still places both ticks at the tape ends. */
export function initialTapeLine(width: number, height: number): TapeLine {
const w = Math.max(1, width);
const h = Math.max(1, height);
return w >= h
? { start: [w * 0.1, h / 2], end: [w * 0.9, h / 2] }
: { start: [w / 2, h * 0.1], end: [w / 2, h * 0.9] };
return max < min ? 0 : max - min;
}
+11 -11
View File
@@ -27,7 +27,7 @@ function tapered(W: number, H: number, lengthEighths: number, tipPx: number) {
test('tapered 48in slab: min/max reflect the bulk, tips excluded', () => {
const { bitset, box, slabPx, lengthEighths } = tapered(480, 100, 384, 20); // tips 2in, bulk 10in
const m = measure(bitset, 480, 100, box, lengthEighths, 8, slabPx, 10);
const m = measure(bitset, 480, 100, box, lengthEighths, 8, slabPx);
assert.equal(m.widths.every6In.length, 7, `stations ${m.widths.every6In.length} (6..42in)`);
for (const e of m.widths.every6In) {
assert.ok(e >= 76 && e <= 84, `station ${e / 8}in must be bulk (~10in)`);
@@ -38,7 +38,7 @@ test('tapered 48in slab: min/max reflect the bulk, tips excluded', () => {
test('uniform slab: interior stations keep the full width', () => {
const { bitset, box, slabPx } = uniform(480, 100);
const m = measure(bitset, 480, 100, box, 384, 8, slabPx, 10);
const m = measure(bitset, 480, 100, box, 384, 8, slabPx);
assert.equal(m.widths.every6In.length, 7);
for (const e of m.widths.every6In) {
assert.ok(e >= 72 && e <= 104, `station ${e / 8}in`);
@@ -47,19 +47,19 @@ test('uniform slab: interior stations keep the full width', () => {
test('short slab L=12in: no interior stations, widths are empty/zero', () => {
const { bitset, box, slabPx } = uniform(120, 100);
const m = measure(bitset, 120, 100, box, 96, 8, slabPx, 10);
const m = measure(bitset, 120, 100, box, 96, 8, slabPx);
assert.deepEqual(m.widths, { min: 0, max: 0, avg: 0, every6In: [] });
});
test('scale follows the tape ticks when they disagree with the mask span', () => {
const { bitset, box, slabPx } = uniform(480, 100);
const tape = { start: [0, 50] as [number, number], end: [240, 50] as [number, number] };
const m = measure(bitset, 480, 100, box, 384, 8, slabPx, tape);
assert.equal(m.scale, 240 / 48);
});
test('short slab L=10in: no interior stations, widths are empty/zero', () => {
const { bitset, box, slabPx } = uniform(100, 100);
const m = measure(bitset, 100, 100, box, 80, 8, slabPx, 10);
const m = measure(bitset, 100, 100, box, 80, 8, slabPx);
assert.deepEqual(m.widths, { min: 0, max: 0, avg: 0, every6In: [] });
});
test('measurement scale is supplied by tape calibration, not inferred from mask span', () => {
const { bitset, box, slabPx } = uniform(480, 100);
const m = measure(bitset, 480, 100, box, 384, 8, slabPx, 20);
assert.equal(m.scale, 20);
assert.ok(m.sqft < 1, `area uses tape scale, got ${m.sqft}`);
});
+12 -5
View File
@@ -1,4 +1,5 @@
import type { BBox, Eighths, Measures, Widths } from '../capture/capture.types';
import { maskSpanAlong } from '../geometry/rect';
// Display / direction: corners[3] → corners[0] is the long side.
export function axisEndpoints(bbox: BBox): { start: [number, number]; end: [number, number] } {
@@ -75,14 +76,20 @@ export function measure(
bbox: BBox,
lengthEighths: Eighths, thicknessEighths: Eighths,
slabPx: number,
scale: number,
tape?: { start: [number, number]; end: [number, number] },
): Omit<Measures, 'inventoryPng'> & { sourceBelowFloor: false } {
const lengthIn = lengthEighths / 8;
const seg = maskAxisSegment(bitset, w, h, bbox);
if (!(scale > 0) || !Number.isFinite(scale)) {
throw new RangeError('Calibration scale must be a positive finite value');
}
const every6In = sampleWidths(bitset, w, h, seg.start, seg.ux, seg.uy, scale, lengthIn);
const tapeSpan = tape
? Math.hypot(tape.end[0] - tape.start[0], tape.end[1] - tape.start[1])
: 0;
const scale = tapeSpan > 0
? tapeSpan / lengthIn
: (seg.span || maskSpanAlong(bitset, w, h, seg.ux, seg.uy)) / lengthIn;
const axisStart = tapeSpan > 0 ? tape!.start : seg.start;
const axisUx = tapeSpan > 0 ? (tape!.end[0] - tape!.start[0]) / tapeSpan : seg.ux;
const axisUy = tapeSpan > 0 ? (tape!.end[1] - tape!.start[1]) / tapeSpan : seg.uy;
const every6In = sampleWidths(bitset, w, h, axisStart, axisUx, axisUy, scale, lengthIn);
let widths: Widths;
if (every6In.length > 0) {
const min = Math.min(...every6In);
-31
View File
@@ -1,31 +0,0 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { calibrationScale, photoToScreen, screenToPhoto } from './crop.ts';
test('tick scale uses endpoint distance in source-photo pixels', () => {
assert.equal(calibrationScale([12, 34], [312, 434], 25), 20);
assert.equal(calibrationScale([12, 34], [12, 34], 25), 0);
assert.equal(calibrationScale([12, 34], [312, 434], 0), 0);
});
test('tick distance and scale survive pan, zoom, and rotation in the photo view', () => {
const a: [number, number] = [830, 420];
const b: [number, number] = [2890, 2510];
const declaredLengthIn = 80;
const expectedScale = Math.hypot(b[0] - a[0], b[1] - a[1]) / declaredLengthIn;
const views = [
{ width: 4032, height: 3024, centerX: 187.5, centerY: 240, scale: 0.08, zoom: 1, angleDeg: 0, panX: 0, panY: 0 },
{ width: 4032, height: 3024, centerX: 187.5, centerY: 240, scale: 0.08, zoom: 2.4, angleDeg: 37, panX: 48, panY: -22 },
{ width: 4032, height: 3024, centerX: 187.5, centerY: 240, scale: 0.08, zoom: 0.7, angleDeg: 121, panX: -70, panY: 35 },
];
for (const view of views) {
const screenA = photoToScreen(a, view);
const screenB = photoToScreen(b, view);
const sourceA = screenToPhoto(screenA, view);
const sourceB = screenToPhoto(screenB, view);
assert.ok(Math.hypot(sourceA[0] - a[0], sourceA[1] - a[1]) < 1e-8);
assert.ok(Math.hypot(sourceB[0] - b[0], sourceB[1] - b[1]) < 1e-8);
assert.ok(Math.abs(calibrationScale(sourceA, sourceB, declaredLengthIn) - expectedScale) < 1e-10);
}
});
-116
View File
@@ -1,116 +0,0 @@
/**
* Single source of truth for the inventory crop geometry (issue #99, req. 4).
*
* The 90% fill and the 3:4 aspect used to be duplicated as `const FILL = 0.9`
* in `png/inventory.ts` and `png/overlay.ts`. Both now read from here, and the
* live fill comes from the global `settings.crop_fill` (default 90%) so the
* reference line and the PNG crop can never disagree.
*/
/** Default fill (90%) — mirrors the `settings.crop_fill` default. */
export const FILL_DEFAULT = 0.9;
export const ASPECT_W = 3;
export const ASPECT_H = 4;
/** Shorter side of the output frame, px. */
export const FLOOR = 800;
export type PhotoPoint = [number, number];
export interface PhotoViewTransform {
width: number;
height: number;
centerX: number;
centerY: number;
/** Source-photo pixels to CSS pixels before the user zoom factor. */
scale: number;
zoom: number;
angleDeg: number;
panX: number;
panY: number;
}
/** Map a point in oriented source-photo pixels into the transformed photo view. */
export function photoToScreen(point: PhotoPoint, view: PhotoViewTransform): PhotoPoint {
const scale = view.scale * view.zoom;
const radians = (view.angleDeg * Math.PI) / 180;
const cos = Math.cos(radians);
const sin = Math.sin(radians);
const dx = (point[0] - view.width / 2) * scale;
const dy = (point[1] - view.height / 2) * scale;
return [
view.centerX + view.panX + dx * cos - dy * sin,
view.centerY + view.panY + dx * sin + dy * cos,
];
}
/** Invert {@link photoToScreen}; screen zoom/pan/rotation never alter source pixels. */
export function screenToPhoto(point: PhotoPoint, view: PhotoViewTransform): PhotoPoint {
const scale = view.scale * view.zoom;
if (!(scale > 0) || !Number.isFinite(scale)) return [Number.NaN, Number.NaN];
const radians = (-view.angleDeg * Math.PI) / 180;
const cos = Math.cos(radians);
const sin = Math.sin(radians);
const dx = point[0] - view.centerX - view.panX;
const dy = point[1] - view.centerY - view.panY;
return [
view.width / 2 + (dx * cos - dy * sin) / scale,
view.height / 2 + (dx * sin + dy * cos) / scale,
];
}
/** Calibrated source-photo pixels per inch from the two tape endpoints. */
export function calibrationScale(start: PhotoPoint, end: PhotoPoint, lengthIn: number): number {
if (!(lengthIn > 0) || !Number.isFinite(lengthIn)) return 0;
if (![...start, ...end].every(Number.isFinite)) return 0;
return Math.hypot(end[0] - start[0], end[1] - start[1]) / lengthIn;
}
/**
* Coerce a `settings.crop_fill` value into a 0 < fill <= 1 scale.
* Accepts a percentage (90) or a fraction (0.9); anything unusable falls back
* to {@link FILL_DEFAULT} rather than silently cropping to zero.
*/
export function fillFromSetting(v: unknown): number {
const n = typeof v === 'number' ? v : Number(v);
if (!Number.isFinite(n) || n <= 0) return FILL_DEFAULT;
return Math.min(1, n > 1 ? n / 100 : n);
}
/** Output frame size: shorter side clamped to {@link FLOOR}. */
export function cropFrameSize(sw: number, sh: number): { outW: number; outH: number; belowFloor: boolean } {
const belowFloor = Math.min(sw, sh) < FLOOR;
const outW = belowFloor ? Math.max(1, Math.min(sw, FLOOR)) : FLOOR;
const outH = Math.round((outW * ASPECT_H) / ASPECT_W);
return { outW, outH, belowFloor };
}
export interface CropGeometry {
scale: number;
dw: number;
dh: number;
ox: number;
oy: number;
}
/** Scale + centring offsets of a `sw × sh` slab inside an `outW × outH` frame. */
export function cropOffsets(
sw: number,
sh: number,
outW: number,
outH: number,
fill: number = FILL_DEFAULT,
): CropGeometry {
const scale = Math.min((outW * fill) / Math.max(sw, 1), (outH * fill) / Math.max(sh, 1));
const dw = sw * scale;
const dh = sh * scale;
return { scale, dw, dh, ox: (outW - dw) / 2, oy: (outH - dh) / 2 };
}
/**
* Fraction of the frame's LONG axis taken by the crop margin (issue #99, req. 3):
* the slab's long edge sits at `oy` (or `ox`) = `outH * (1 - fill) / 2`, so the
* fixed reference line is that same fraction in from the frame edge.
*/
export function cropMarginFraction(fill: number = FILL_DEFAULT): number {
return (1 - fill) / 2;
}
-27
View File
@@ -118,30 +118,3 @@ test('belowFloor: small slab → sourceBelowFloor, outW capped at floor (no upsc
assert.equal(out.sourceBelowFloor, true);
assert.ok(out.width <= 800, `outW ${out.width} must not exceed the floor`);
});
test('calibration rotation turns a landscape slab into an upright crop extent', () => {
const width = 6, 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 = { 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);
for (let i = 0; i < rotated.rgba.length; i += 4) {
if (rotated.rgba[i + 3]) assert.deepEqual([...rotated.rgba.slice(i, i + 3)], [200, 40, 20]);
}
});
+18 -114
View File
@@ -1,5 +1,8 @@
import type { Mask, Source } from '../capture/capture.types';
import { FILL_DEFAULT, cropFrameSize, cropOffsets } from './crop';
const FLOOR = 800; // shorter side, px
const ASPECT_W = 3;
const ASPECT_H = 4;
type MaskPick = Pick<Mask, 'bitset' | 'slabBounds'>;
type SourcePick = Pick<Source, 'widthPx' | 'heightPx'>;
@@ -8,10 +11,8 @@ export interface InventoryInput {
bitmap: ImageBitmap;
mask: MaskPick;
source: SourcePick;
/** Calibration-photo rotation in degrees; batch siblings remain unrotated. */
rotationDeg?: number;
/** Crop fill, 0<fill<=1. Defaults to FILL_DEFAULT (the 90% settings default). */
fill?: number;
/** Global crop fill percent. Default 90. */
cropFill?: number;
}
export interface Frame {
@@ -21,80 +22,12 @@ export interface Frame {
sourceBelowFloor: boolean;
}
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,
};
}
/** Pixel-edge bounds of the approved mask after rotating around its center. */
export function rotatedMaskBounds(bitset: Uint8Array, w: number, h: number, rotationDeg: number) {
return createMaskRotation(bitset, w, h, rotationDeg).bounds;
}
export function composeInventoryFrame(
src: { width: number; height: number; rgba: Uint8Array | Uint8ClampedArray },
mask: MaskPick,
source: SourcePick,
fill: number = FILL_DEFAULT,
rotationDeg = 0,
fill = 0.9,
): 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));
@@ -102,8 +35,14 @@ export function composeInventoryFrame(
const y1 = Math.min(source.heightPx, y0 + Math.max(1, Math.round(b.h)));
const sw = x1 - x0;
const sh = y1 - y0;
const { outW, outH, belowFloor } = cropFrameSize(sw, sh);
const { scale, dw, dh, ox, oy } = cropOffsets(sw, sh, outW, outH, fill);
const belowFloor = Math.min(sw, sh) < FLOOR;
const outW = belowFloor ? Math.max(1, Math.min(sw, FLOOR)) : FLOOR;
const outH = Math.round((outW * ASPECT_H) / ASPECT_W);
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;
const oy = (outH - dh) / 2;
const rgba = new Uint8Array(outW * outH * 4);
const xEnd = ox + dw;
const yEnd = oy + dh;
@@ -125,45 +64,11 @@ export function composeInventoryFrame(
return { width: outW, height: outH, rgba, sourceBelowFloor: belowFloor };
}
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 { outW, outH, belowFloor } = cropFrameSize(rotated.w, rotated.h);
const { scale, dw, dh, ox, oy } = cropOffsets(rotated.w, rotated.h, outW, outH, fill);
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 } = input;
const { bitmap, mask, source, cropFill } = 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')!;
srcCtx.drawImage(bitmap, 0, 0);
@@ -172,8 +77,7 @@ export async function encodeInventoryPng(
{ width: source.widthPx, height: source.heightPx, rgba: img.data },
mask,
source,
input.fill,
input.rotationDeg,
fill,
);
const out = new OffscreenCanvas(frame.width, frame.height);
const ctx = out.getContext('2d')!;
-15
View File
@@ -1117,18 +1117,3 @@ 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('inventory source map rotates dimension graphics with the calibration slab', () => {
const width = 6, 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 map = inventorySourceMap(
{ x: 1, y: 1, w: 4, h: 2 },
{ widthPx: width, heightPx: height },
2, 3, 1, bitset, 90,
).mapToInventory;
const a = map(1, 1.5);
const b = map(4, 1.5);
assert.ok(Math.abs(b[0] - a[0]) < 1e-8, 'horizontal source axis maps vertical after clockwise rotation');
assert.ok(b[1] > a[1]);
});
+10 -23
View File
@@ -1,7 +1,5 @@
import type { BBox } from '../capture/capture.types';
import { axisEndpoints } from '../measure/measure';
import { FILL_DEFAULT, cropOffsets } from './crop';
import { createMaskRotation } from './inventory';
/** Width overlay stations: 6, 18, 30, … while ≤ L − 6 (tip-exclusive). */
export function overlayWidthStations(lengthIn: number): number[] {
@@ -487,29 +485,20 @@ export function inventorySourceMap(
source: { widthPx: number; heightPx: number },
outW: number,
outH: number,
fill: number = FILL_DEFAULT,
bitset?: Uint8Array,
rotationDeg = 0,
fill = 0.9,
): InventoryMap {
if (bitset && Math.abs(rotationDeg % 360) > 1e-8) {
const rotation = createMaskRotation(bitset, source.widthPx, source.heightPx, rotationDeg);
const { scale, ox, oy } = cropOffsets(rotation.bounds.w, rotation.bounds.h, outW, outH, fill);
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 fillRatio = fill;
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, ox, oy } = cropOffsets(sw, sh, outW, outH, fill);
const scale = Math.min((outW * fillRatio) / Math.max(sw, 1), (outH * fillRatio) / Math.max(sh, 1));
const dw = sw * scale;
const dh = sh * scale;
const ox = (outW - dw) / 2;
const oy = (outH - dh) / 2;
return {
outW,
outH,
@@ -529,8 +518,7 @@ export interface EncodeOverlayInput {
lengthLabel: string;
slabBounds: { x: number; y: number; w: number; h: number };
source: { widthPx: number; heightPx: number };
fill?: number;
rotationDeg?: number;
cropFill?: number;
}
/**
@@ -545,9 +533,8 @@ export async function encodeOverlayPng(input: EncodeOverlayInput): Promise<Blob>
const canvas = new OffscreenCanvas(outW, outH);
const ctx = canvas.getContext('2d')!;
ctx.drawImage(bmp, 0, 0);
const { mapToInventory } = inventorySourceMap(
input.slabBounds, input.source, outW, outH, input.fill, input.bitset, input.rotationDeg,
);
const fill = Math.max(1, Math.min(100, input.cropFill ?? 90)) / 100;
const { mapToInventory } = inventorySourceMap(input.slabBounds, input.source, outW, outH, fill);
const plan = buildOverlayPlan({
bitset: input.bitset,
widthPx: input.widthPx,