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:
Generated
-10
@@ -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",
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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}`);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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')!;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user