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:
@@ -26,12 +26,12 @@ Sources reconciled 2026-09-18 from the current branch docs plus the `pre-simplif
|
|||||||
| CAPTURE-45 | A Sensitivity change debounces 2000 ms; a Sheet change runs immediately; the overlay recomputes in a worker and the last good overlay stays visible. | shipped | frontend/src/capture/session.calibrate.test.ts |
|
| CAPTURE-45 | A Sensitivity change debounces 2000 ms; a Sheet change runs immediately; the overlay recomputes in a worker and the last good overlay stays visible. | shipped | frontend/src/capture/session.calibrate.test.ts |
|
||||||
| CAPTURE-50 | The mask overlay renders on the source photo and the owner approves it; approval is blocked when the mask has no slab pixels. | shipped | — |
|
| CAPTURE-50 | The mask overlay renders on the source photo and the owner approves it; approval is blocked when the mask has no slab pixels. | shipped | — |
|
||||||
| CAPTURE-55 | The mask must be a single contiguous slab contour; otherwise the resolution is a retake. | shipped | — |
|
| CAPTURE-55 | The mask must be a single contiguous slab contour; otherwise the resolution is a retake. | shipped | — |
|
||||||
| CAPTURE-60 | The owner aligns the length axis to the prevailing slab length and confirms it; the tape-line calibration is independent of the mask's minimum-area bounding rectangle. | M4 | frontend/src/geometry/rect.test.ts |
|
| CAPTURE-60 | The owner aligns the length axis to the prevailing slab length and confirms it; the tape-line calibration is independent of the mask's minimum-area bounding rectangle. | M4 | frontend/src/capture/tapeLine.test.ts |
|
||||||
| CAPTURE-65 | On a nearly square slab the owner chooses which axis is length. | shipped | — |
|
| CAPTURE-65 | On a nearly square slab the owner chooses which axis is length. | shipped | — |
|
||||||
| CAPTURE-70 | The owner enters length and thickness in 1/8 inch steps. | shipped | frontend/src/capture/measureInputs.test.ts |
|
| CAPTURE-70 | The owner enters length and thickness in 1/8 inch steps. | shipped | frontend/src/capture/measureInputs.test.ts |
|
||||||
| CAPTURE-75 | Scale equals the pixel distance between the two owner-positioned tape-line ticks divided by the owner-declared length; mask span (`maskSpanAlong`) is not checked or used as a length or scale source. | M4 | — |
|
| CAPTURE-75 | Scale equals the pixel distance between the two owner-positioned tape-line ticks divided by the owner-declared length; mask span (`maskSpanAlong`) is not checked or used as a length or scale source. | M4 | frontend/src/measure/measure.test.ts |
|
||||||
| CAPTURE-170 | The calibration overlay shows a draggable tape-line with two rifle-scope end ticks; the owner positions the ticks at the tape's actual start and end. | M4 | — |
|
| CAPTURE-170 | The calibration overlay shows a draggable tape-line with two rifle-scope end ticks; the owner positions the ticks at the tape's actual start and end. | M4 | frontend/src/capture/tapeLine.test.ts |
|
||||||
| CAPTURE-175 | The owner-declared length is the distance represented by the tape-line ticks, and scale uses their pixel distance in source-photo coordinates, independent of mask span. | M4 | — |
|
| CAPTURE-175 | The owner-declared length is the distance represented by the tape-line ticks, and scale uses their pixel distance in source-photo coordinates, independent of mask span. | M4 | frontend/src/measure/measure.test.ts |
|
||||||
| CAPTURE-80 | Widths sample every 6 inches perpendicular to the approved axis, dropping the first and last 6 inches; min, max, and average are reported; if length is 12 inches or less all three are zero. | shipped | frontend/src/measure/measure.test.ts |
|
| CAPTURE-80 | Widths sample every 6 inches perpendicular to the approved axis, dropping the first and last 6 inches; min, max, and average are reported; if length is 12 inches or less all three are zero. | shipped | frontend/src/measure/measure.test.ts |
|
||||||
| CAPTURE-85 | sqft equals mask_pixels divided by scale squared divided by 144, rounded half-up to 2 decimals. | shipped | frontend/src/measure/measure.test.ts |
|
| CAPTURE-85 | sqft equals mask_pixels divided by scale squared divided by 144, rounded half-up to 2 decimals. | shipped | frontend/src/measure/measure.test.ts |
|
||||||
| CAPTURE-90 | bdft equals sqft times thickness_in, rounded half-up to 2 decimals, with no divide-by-12; 1 sqft at 1 inch equals 1 bdft. | shipped | frontend/src/measure/measure.test.ts |
|
| CAPTURE-90 | bdft equals sqft times thickness_in, rounded half-up to 2 decimals, with no divide-by-12; 1 sqft at 1 inch equals 1 bdft. | shipped | frontend/src/measure/measure.test.ts |
|
||||||
|
|||||||
Generated
-10
@@ -8,7 +8,6 @@
|
|||||||
"name": "slabuploader-frontend",
|
"name": "slabuploader-frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@neodrag/svelte": "2.3.3",
|
|
||||||
"idb-keyval": "^6.3.0",
|
"idb-keyval": "^6.3.0",
|
||||||
"svelte": "^4.2.20"
|
"svelte": "^4.2.20"
|
||||||
},
|
},
|
||||||
@@ -496,15 +495,6 @@
|
|||||||
"node": "^22.20 || ^24.12 || >=25"
|
"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": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.63.0",
|
"version": "1.63.0",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
"test:e2e:headed": "playwright test --headed"
|
"test:e2e:headed": "playwright test --headed"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@neodrag/svelte": "2.3.3",
|
|
||||||
"idb-keyval": "^6.3.0",
|
"idb-keyval": "^6.3.0",
|
||||||
"svelte": "^4.2.20"
|
"svelte": "^4.2.20"
|
||||||
},
|
},
|
||||||
|
|||||||
+17
-19
@@ -12,14 +12,13 @@
|
|||||||
import AxisPanel from './capture/AxisPanel.svelte';
|
import AxisPanel from './capture/AxisPanel.svelte';
|
||||||
import MeasurePanel from './capture/MeasurePanel.svelte';
|
import MeasurePanel from './capture/MeasurePanel.svelte';
|
||||||
import Settings from './create/settings/Settings.svelte';
|
import Settings from './create/settings/Settings.svelte';
|
||||||
|
|
||||||
import SlabList from './create/slabs/SlabList.svelte';
|
import SlabList from './create/slabs/SlabList.svelte';
|
||||||
import Review from './create/review/Review.svelte';
|
import Review from './create/review/Review.svelte';
|
||||||
import Publish from './create/publish/Publish.svelte';
|
import Publish from './create/publish/Publish.svelte';
|
||||||
import {
|
import {
|
||||||
flow, startSaving, enterReview, enterPublish, markDone, backToCapture, backToReview, type CalibratedCapture,
|
flow, startSaving, enterReview, enterPublish, markDone, backToCapture, backToReview, type CalibratedCapture,
|
||||||
} from './create/flow';
|
} 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';
|
import { clearDraft } from './draft/idb';
|
||||||
|
|
||||||
const gate = typeof window !== 'undefined' ? supported() : 'window unavailable';
|
const gate = typeof window !== 'undefined' ? supported() : 'window unavailable';
|
||||||
@@ -30,7 +29,6 @@
|
|||||||
let pngUrl: string | null = null;
|
let pngUrl: string | null = null;
|
||||||
let showSettings = false;
|
let showSettings = false;
|
||||||
let showSlabs = false;
|
let showSlabs = false;
|
||||||
let cropFill = 90;
|
|
||||||
|
|
||||||
let curFlow: import('./create/flow').Flow = { phase: 'capture' };
|
let curFlow: import('./create/flow').Flow = { phase: 'capture' };
|
||||||
$: curFlow = $flow;
|
$: curFlow = $flow;
|
||||||
@@ -43,20 +41,17 @@
|
|||||||
});
|
});
|
||||||
const unsubBusy = session.busy.subscribe((b) => (busy = b));
|
const unsubBusy = session.busy.subscribe((b) => (busy = b));
|
||||||
|
|
||||||
|
let cropFill = 90;
|
||||||
|
|
||||||
onMount(() => {
|
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);
|
const boot = new URLSearchParams(window.location.search);
|
||||||
if (!boot.get('noload')) void session.resumeProbe();
|
if (!boot.get('noload')) void session.resumeProbe();
|
||||||
// #99: the global crop fill drives both the reference line and the PNG crop.
|
void getSettings().then((s) => {
|
||||||
// Falls back to the 90% default when settings are unreachable.
|
if (typeof s.crop_fill === 'number') {
|
||||||
void getSettings()
|
cropFill = s.crop_fill;
|
||||||
.then((s) => {
|
session.setCropFill(s.crop_fill);
|
||||||
cropFill = s.crop_fill ?? 90;
|
}
|
||||||
session.setCropFill(cropFill);
|
}).catch(() => {});
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
});
|
});
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
unsubCap();
|
unsubCap();
|
||||||
@@ -226,11 +221,15 @@
|
|||||||
{:else if cap.k === 'axis'}
|
{:else if cap.k === 'axis'}
|
||||||
<AxisPanel
|
<AxisPanel
|
||||||
state={cap}
|
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}
|
approveAxis={session.approveAxis}
|
||||||
back={session.back}
|
back={session.back}
|
||||||
{cropFill}
|
|
||||||
setCropFill={(pct) => { cropFill = pct; session.setCropFill(pct); }}
|
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<MeasurePanel
|
<MeasurePanel
|
||||||
@@ -257,7 +256,7 @@
|
|||||||
<button on:click={() => (showSlabs = !showSlabs)} data-testid="open-slabs">Slabs</button>
|
<button on:click={() => (showSlabs = !showSlabs)} data-testid="open-slabs">Slabs</button>
|
||||||
</div>
|
</div>
|
||||||
{#if showSettings}
|
{#if showSettings}
|
||||||
<Settings onCropFillChange={(pct) => { cropFill = pct; session.setCropFill(pct); }} />
|
<Settings />
|
||||||
{/if}
|
{/if}
|
||||||
{#if showSlabs}
|
{#if showSlabs}
|
||||||
<SlabList />
|
<SlabList />
|
||||||
@@ -272,7 +271,6 @@
|
|||||||
font-family: system-ui, -apple-system, Segoe UI, sans-serif;
|
font-family: system-ui, -apple-system, Segoe UI, sans-serif;
|
||||||
}
|
}
|
||||||
.app, .hardstop { max-width: 720px; margin: 0 auto; min-height: 100vh; }
|
.app, .hardstop { max-width: 720px; margin: 0 auto; min-height: 100vh; }
|
||||||
|
|
||||||
.hardstop { padding: 48px 24px; color: #eee; text-align: center; }
|
.hardstop { padding: 48px 24px; color: #eee; text-align: center; }
|
||||||
.hardstop h1 { font-size: 1.6rem; }
|
.hardstop h1 { font-size: 1.6rem; }
|
||||||
.detail { color: #888; font-size: 0.85rem; }
|
.detail { color: #888; font-size: 0.85rem; }
|
||||||
|
|||||||
@@ -1,423 +1,362 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import type { Capture, PhotoTransform, TapeLine } from './capture.types';
|
import type { Capture, PhotoTransform } from './capture.types';
|
||||||
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
||||||
import {
|
import {
|
||||||
applyApprovedMask,
|
|
||||||
CALIBRATION_FRAME,
|
CALIBRATION_FRAME,
|
||||||
cropTickPositions,
|
IDENTITY_TRANSFORM,
|
||||||
DEFAULT_PHOTO_TRANSFORM,
|
cropEnds,
|
||||||
pinchPhotoTransform,
|
fitScale,
|
||||||
photoFitScale,
|
hitRadiusStage,
|
||||||
photoToStage,
|
} from './tapeLine';
|
||||||
stageToPhoto,
|
|
||||||
tapeLineFromStageTicks,
|
|
||||||
} from './tapeCalibration';
|
|
||||||
|
|
||||||
export let state: Extract<Capture, { k: 'axis' }>;
|
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 approveAxis: () => void;
|
||||||
export let back: () => 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 frame = CALIBRATION_FRAME;
|
||||||
const lineX = frame.width / 2;
|
let stageEl: HTMLDivElement | undefined;
|
||||||
const tiltX = frame.width / 2;
|
let canvas: HTMLCanvasElement | undefined;
|
||||||
const tiltY = frame.height - 26;
|
let bitmap: ImageBitmap | null = null;
|
||||||
const tickHitRadius = 38; // 76 logical px keeps the drag target above 44px on a 320px viewport.
|
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();
|
const rect = canvas!.getBoundingClientRect();
|
||||||
|
renderedW = rect.width;
|
||||||
return [
|
return [
|
||||||
((event.clientX - rect.left) / rect.width) * frame.width,
|
((event.clientX - rect.left) / rect.width) * frame.width,
|
||||||
((event.clientY - rect.top) / rect.height) * frame.height,
|
((event.clientY - rect.top) / rect.height) * frame.height,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function redraw(): void {
|
function draw() {
|
||||||
if (!canvas || !stageW || !stageH) return;
|
if (!canvas || !stageEl) return;
|
||||||
|
const rect = stageEl.getBoundingClientRect();
|
||||||
|
renderedW = rect.width || renderedW;
|
||||||
const dpr = window.devicePixelRatio || 1;
|
const dpr = window.devicePixelRatio || 1;
|
||||||
canvas.width = Math.round(stageW * dpr);
|
canvas.width = Math.max(1, Math.round(rect.width * dpr));
|
||||||
canvas.height = Math.round(stageH * dpr);
|
canvas.height = Math.max(1, Math.round(rect.height * dpr));
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
ctx.setTransform(dpr * stageW / frame.width, 0, 0, dpr * stageH / frame.height, 0, 0);
|
ctx.setTransform((dpr * rect.width) / frame.width, 0, 0, (dpr * rect.height) / frame.height, 0, 0);
|
||||||
ctx.fillStyle = '#161616';
|
ctx.fillStyle = '#111';
|
||||||
ctx.fillRect(0, 0, frame.width, frame.height);
|
ctx.fillRect(0, 0, frame.width, frame.height);
|
||||||
|
|
||||||
if (bitmap && sourceW > 0 && sourceH > 0) {
|
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.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.rotate((transform.tiltDeg * Math.PI) / 180);
|
||||||
ctx.scale(scale, scale);
|
ctx.scale(scale, scale);
|
||||||
ctx.drawImage(bitmap, -sourceW / 2, -sourceH / 2);
|
ctx.drawImage(bitmap, -sourceW / 2, -sourceH / 2);
|
||||||
|
if (maskLayer) {
|
||||||
|
ctx.globalCompositeOperation = 'destination-in';
|
||||||
|
ctx.drawImage(maskLayer, -sourceW / 2, -sourceH / 2);
|
||||||
|
}
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
const x = frame.width / 2;
|
||||||
ctx.save();
|
const ends = cropEnds(fill);
|
||||||
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();
|
|
||||||
ctx.strokeStyle = '#000';
|
ctx.strokeStyle = '#000';
|
||||||
ctx.lineWidth = 2.4;
|
ctx.lineWidth = 2;
|
||||||
ctx.beginPath(); ctx.moveTo(lineX, tickA); ctx.lineTo(lineX, tickB); ctx.stroke();
|
ctx.beginPath();
|
||||||
for (const [y, id] of [[tickA, 'A'], [tickB, 'B']] as const) {
|
ctx.moveTo(x, ends.startY);
|
||||||
ctx.strokeStyle = 'rgba(255,255,255,.95)';
|
ctx.lineTo(x, ends.endY);
|
||||||
ctx.lineWidth = 6;
|
ctx.stroke();
|
||||||
ctx.beginPath(); ctx.moveTo(lineX - 13, y); ctx.lineTo(lineX + 13, y); ctx.stroke();
|
for (const [id, y] of [['A', tickA], ['B', tickB]] as const) {
|
||||||
ctx.strokeStyle = '#000';
|
ctx.beginPath();
|
||||||
ctx.lineWidth = 2.4;
|
ctx.moveTo(x - 13, y);
|
||||||
ctx.beginPath(); ctx.moveTo(lineX - 13, y); ctx.lineTo(lineX + 13, y); ctx.stroke();
|
ctx.lineTo(x + 13, y);
|
||||||
ctx.beginPath(); ctx.arc(lineX, y, 4, 0, Math.PI * 2);
|
ctx.stroke();
|
||||||
ctx.fillStyle = focusedTick === id ? '#ff3d71' : '#000';
|
ctx.fillStyle = focus === id ? '#e23' : '#000';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, 5, 0, Math.PI * 2);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5; ctx.stroke();
|
|
||||||
}
|
}
|
||||||
ctx.beginPath(); ctx.arc(tiltX, tiltY, 17, 0, Math.PI * 2);
|
ctx.fillStyle = '#eee';
|
||||||
ctx.fillStyle = 'rgba(0,0,0,.6)'; ctx.fill();
|
ctx.beginPath();
|
||||||
ctx.strokeStyle = '#00e5ff'; ctx.lineWidth = 1.5; ctx.stroke();
|
ctx.arc(x, frame.height - 28, 16, 0, Math.PI * 2);
|
||||||
ctx.fillStyle = '#00e5ff'; ctx.font = '15px system-ui'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
ctx.fill();
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function photoPoint(point: [number, number]): [number, number] {
|
function reseat(pct: number) {
|
||||||
return stageToPhoto(point, sourceW, sourceH, transform);
|
const ends = cropEnds(pct);
|
||||||
|
tickA = ends.startY;
|
||||||
|
tickB = ends.endY;
|
||||||
|
publish();
|
||||||
|
draw();
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncCalibration(): void {
|
function zoomAt(point: [number, number], factor: number) {
|
||||||
const tapeLine = tapeLineFromStageTicks(tickA, tickB, sourceW, sourceH, transform);
|
const z1 = clamp(transform.zoom * factor, 0.5, 3);
|
||||||
setTapeLine(tapeLine, { ...transform });
|
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 {
|
function fit() {
|
||||||
tickA = Math.max(20, Math.min(tickB - 40, y));
|
transform = { ...IDENTITY_TRANSFORM, tiltDeg: transform.tiltDeg };
|
||||||
|
publish();
|
||||||
|
draw();
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveTickB(y: number): void {
|
function onPointerDown(event: PointerEvent) {
|
||||||
tickB = Math.max(tickA + 40, Math.min(frame.height - 20, y));
|
const p = toFrame(event);
|
||||||
}
|
pointers.set(event.pointerId, p);
|
||||||
|
canvas!.setPointerCapture(event.pointerId);
|
||||||
function queuePersist(): void {
|
moved = false;
|
||||||
if (persistTimer) clearTimeout(persistTimer);
|
downAt = p;
|
||||||
persistTimer = setTimeout(() => {
|
if (pointers.size >= 2) {
|
||||||
persistTimer = null;
|
const pts = [...pointers.values()];
|
||||||
syncCalibration();
|
pinch0 = Math.hypot(pts[0][0] - pts[1][0], pts[0][1] - pts[1][1]) || 1;
|
||||||
}, 250);
|
zoom0 = transform.zoom;
|
||||||
}
|
|
||||||
|
|
||||||
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),
|
|
||||||
};
|
|
||||||
drag = 'pinch';
|
drag = 'pinch';
|
||||||
panTapCandidate = false;
|
|
||||||
canvas?.setPointerCapture(event.pointerId);
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const distA = Math.hypot(point[0] - lineX, point[1] - tickA);
|
const x = frame.width / 2;
|
||||||
const distB = Math.hypot(point[0] - lineX, point[1] - tickB);
|
const hit = hitRadiusStage(renderedW);
|
||||||
const coreA = distA <= 12;
|
if (Math.hypot(p[0] - x, p[1] - tickA) <= hit) {
|
||||||
const coreB = distB <= 12;
|
focus = 'A';
|
||||||
if (coreA || coreB) {
|
drag = 'tick';
|
||||||
focusedTick = distA <= distB ? 'A' : 'B';
|
} else if (Math.hypot(p[0] - x, p[1] - tickB) <= hit) {
|
||||||
drag = focusedTick === 'A' ? 'tickA' : 'tickB';
|
focus = 'B';
|
||||||
panTapCandidate = false;
|
drag = 'tick';
|
||||||
} else if (Math.hypot(point[0] - tiltX, point[1] - tiltY) < 18) {
|
} else if (Math.hypot(p[0] - x, p[1] - (frame.height - 28)) <= 22) {
|
||||||
drag = 'tilt';
|
drag = 'tilt';
|
||||||
pointerStart = point;
|
tilt0 = transform.tiltDeg;
|
||||||
transformStart = transform.tiltDeg;
|
tiltX0 = p[0];
|
||||||
panTapCandidate = false;
|
|
||||||
} else if (distA <= tickHitRadius || distB <= tickHitRadius) {
|
|
||||||
focusedTick = distA <= distB ? 'A' : 'B';
|
|
||||||
drag = focusedTick === 'A' ? 'tickA' : 'tickB';
|
|
||||||
panTapCandidate = false;
|
|
||||||
} else {
|
} else {
|
||||||
drag = 'pan';
|
drag = 'pan';
|
||||||
lastPointer = point;
|
panGrab = [p[0] - transform.panX, p[1] - transform.panY];
|
||||||
pointerStart = point;
|
|
||||||
panTapCandidate = true;
|
|
||||||
}
|
}
|
||||||
canvas?.setPointerCapture(event.pointerId);
|
|
||||||
redraw();
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
draw();
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPointerMove(event: PointerEvent): void {
|
function onPointerMove(event: PointerEvent) {
|
||||||
|
const p = toFrame(event);
|
||||||
|
pointers.set(event.pointerId, p);
|
||||||
if (!drag) return;
|
if (!drag) return;
|
||||||
const point = updatePointer(event);
|
if (Math.hypot(p[0] - downAt[0], p[1] - downAt[1]) > 3) moved = true;
|
||||||
if (drag === 'pinch' && pinchStart && pointers.size > 1) {
|
if (drag === 'pinch' && pointers.size >= 2) {
|
||||||
const [a, b] = [...pointers.values()];
|
const pts = [...pointers.values()];
|
||||||
const center: [number, number] = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
|
const dist = Math.hypot(pts[0][0] - pts[1][0], pts[0][1] - pts[1][1]) || 1;
|
||||||
const distance = Math.max(1, Math.hypot(b[0] - a[0], b[1] - a[1]));
|
const mid: [number, number] = [(pts[0][0] + pts[1][0]) / 2, (pts[0][1] + pts[1][1]) / 2];
|
||||||
transform = pinchPhotoTransform(
|
transform = { ...transform, zoom: zoom0 };
|
||||||
transform, pinchStart.anchor, center, distance / pinchStart.distance, sourceW, sourceH,
|
zoomAt(mid, clamp(dist / pinch0, 0.8, 1.25));
|
||||||
);
|
|
||||||
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();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const wasSlabTap = drag === 'pan' && panTapCandidate;
|
if (drag === 'tick') {
|
||||||
const tapPoint = stagePoint(event);
|
const y = clamp(p[1], 8, frame.height - 8);
|
||||||
drag = null;
|
if (focus === 'A') tickA = clamp(y, 8, tickB - 24);
|
||||||
pinchStart = null;
|
else tickB = clamp(y, tickA + 24, frame.height - 8);
|
||||||
panTapCandidate = false;
|
} else if (drag === 'tilt') {
|
||||||
flushCalibration();
|
transform = { ...transform, tiltDeg: tilt0 + (p[0] - tiltX0) * 0.6 };
|
||||||
if (wasSlabTap) {
|
} else if (drag === 'pan') {
|
||||||
const now = performance.now();
|
transform = {
|
||||||
if (previousTap && now - previousTap.time < 350
|
...transform,
|
||||||
&& Math.hypot(tapPoint[0] - previousTap.point[0], tapPoint[1] - previousTap.point[1]) < 12) {
|
panX: clamp(p[0] - panGrab[0], -400, 400),
|
||||||
previousTap = null;
|
panY: clamp(p[1] - panGrab[1], -400, 400),
|
||||||
fitSlab();
|
};
|
||||||
} else previousTap = { time: now, point: tapPoint };
|
|
||||||
}
|
}
|
||||||
redraw();
|
publish();
|
||||||
|
draw();
|
||||||
}
|
}
|
||||||
|
|
||||||
function zoomAt(point: [number, number], factor: number): void {
|
function onPointerUp(event: PointerEvent) {
|
||||||
const anchor = photoPoint(point);
|
const p = toFrame(event);
|
||||||
transform.zoom = Math.max(0.5, Math.min(3, transform.zoom * factor));
|
pointers.delete(event.pointerId);
|
||||||
const after = photoToStage(anchor, sourceW, sourceH, transform);
|
const wasPan = drag === 'pan';
|
||||||
transform.panX += point[0] - after[0];
|
drag = pointers.size ? drag : null;
|
||||||
transform.panY += point[1] - after[1];
|
if (wasPan && !moved) {
|
||||||
redraw();
|
const now = performance.now();
|
||||||
queuePersist();
|
if (lastTap && now - lastTap.t < 350 && Math.hypot(p[0] - lastTap.x, p[1] - lastTap.y) < 12) {
|
||||||
}
|
lastTap = null;
|
||||||
|
fit();
|
||||||
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();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const maskedCanvas = document.createElement('canvas');
|
lastTap = { t: now, x: p[0], y: p[1] };
|
||||||
maskedCanvas.width = sourceW; maskedCanvas.height = sourceH;
|
}
|
||||||
const maskedCtx = maskedCanvas.getContext('2d');
|
draw();
|
||||||
if (!maskedCtx) { decoded.close(); return; }
|
}
|
||||||
maskedCtx.drawImage(decoded, 0, 0);
|
|
||||||
const pixels = maskedCtx.getImageData(0, 0, sourceW, sourceH);
|
function onWheel(event: WheelEvent) {
|
||||||
pixels.data.set(applyApprovedMask(pixels.data, state.approvedMask.bitset));
|
event.preventDefault();
|
||||||
maskedCtx.putImageData(pixels, 0, 0);
|
const dy = clamp(event.deltaY, -200, 200);
|
||||||
const maskedBitmap = await createImageBitmap(maskedCanvas);
|
zoomAt(toFrame(event), clamp(Math.exp(-dy * 0.0015), 0.8, 1.25));
|
||||||
decoded.close();
|
}
|
||||||
if (!alive) { maskedBitmap.close(); return; }
|
|
||||||
bitmap = maskedBitmap;
|
function onKey(event: KeyboardEvent) {
|
||||||
redraw();
|
const tag = (event.target as HTMLElement | null)?.tagName;
|
||||||
}).catch(() => {});
|
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
|
||||||
return () => {
|
if (event.key === '+' || event.key === '=') {
|
||||||
alive = false;
|
zoomAt([frame.width / 2, frame.height / 2], 1.1);
|
||||||
resize.disconnect();
|
event.preventDefault();
|
||||||
if (persistTimer) clearTimeout(persistTimer);
|
} else if (event.key === '-' || event.key === '_') {
|
||||||
bitmap?.close(); bitmap = null;
|
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>
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:keydown={onKey} />
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h1>Tape line</h1>
|
<h1>Length</h1>
|
||||||
<p class="hint">Drag each tick to where your tape started and ended.</p>
|
<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={stage}>
|
<div class="stage" bind:this={stageEl} data-testid="tape-line">
|
||||||
<canvas
|
<canvas
|
||||||
bind:this={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:pointerdown={onPointerDown}
|
||||||
on:pointermove={onPointerMove}
|
on:pointermove={onPointerMove}
|
||||||
on:pointerup={onPointerUp}
|
on:pointerup={onPointerUp}
|
||||||
on:pointercancel={onPointerUp}
|
on:pointercancel={onPointerUp}
|
||||||
on:wheel|nonpassive={onWheel}
|
on:wheel={onWheel}
|
||||||
on:keydown={onKeyDown}
|
|
||||||
></canvas>
|
></canvas>
|
||||||
</div>
|
</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">
|
<p class="ctl">Tilt</p>
|
||||||
<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>
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button type="button" class="ghost" on:click={backToMask}>Back</button>
|
<button type="button" class="thumb" aria-label="Tilt counter-clockwise" on:click={() => { transform = { ...transform, tiltDeg: transform.tiltDeg - 1 }; publish(); draw(); }}>−</button>
|
||||||
<button type="button" class="primary" on:click={approveCalibration}>Approve tape line</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.panel { padding: 16px; color: #eee; }
|
.panel { padding: 16px; color: #eee; }
|
||||||
h1 { font-size: 1.3rem; margin: 0 0 8px; }
|
h1 { font-size: 1.3rem; margin: 0 0 8px; }
|
||||||
.hint { color: #aaa; font-size: 0.85rem; margin: 0 0 12px; line-height: 1.45; }
|
.hint, .nudge { color: #aaa; font-size: 0.85rem; line-height: 1.4; margin: 0 0 12px; }
|
||||||
.stage { width: min(100%, 420px); aspect-ratio: 3 / 4; margin: 0 auto; overflow: hidden; background: #161616; border: 1px solid #333; border-radius: 3px; }
|
.ctl { color: #aaa; font-size: 0.78rem; margin: 14px 0 6px; }
|
||||||
canvas { display: block; width: 100%; height: 100%; touch-action: none; cursor: grab; outline-offset: 2px; }
|
.stage { width: 100%; aspect-ratio: 3 / 4; background: #111; border: 1px solid #333; border-radius: 3px; overflow: hidden; touch-action: none; }
|
||||||
.control { display: flex; align-items: center; gap: 8px; margin-top: 14px; }
|
canvas { display: block; width: 100%; height: 100%; touch-action: none; }
|
||||||
.control label { min-width: 86px; color: #bbb; font-size: 0.82rem; }
|
.row { display: flex; align-items: center; gap: 10px; }
|
||||||
input[type='range'] { flex: 1; min-width: 0; accent-color: #00e5ff; }
|
.thumb {
|
||||||
.value { min-width: 4ch; text-align: right; font-variant-numeric: tabular-nums; color: #00e5ff; }
|
width: 44px; height: 44px; border-radius: 3px; font-size: 1.3rem;
|
||||||
.thumb, .minor { min-width: 44px; min-height: 44px; border: 1px solid #444; background: #1d1d1d; color: #eee; padding: 6px 9px; }
|
border: 1px solid #444; background: #1d1d1d; color: #eee; flex: none;
|
||||||
.thumb { border-radius: 50%; font-size: 1.2rem; flex: none; }
|
}
|
||||||
.minor { border-radius: 3px; }
|
input[type='range'] { flex: 1; }
|
||||||
.row { display: flex; gap: 10px; margin-top: 16px; }
|
.val { min-width: 5ch; text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
.primary { flex: 1; min-height: 48px; padding: 12px; font-size: 1rem; border: 0; background: #00e5ff; color: #002; font-weight: 700; }
|
.fit { margin-top: 8px; }
|
||||||
.ghost { flex: 0.7; min-height: 48px; padding: 12px; border: 1px solid #444; background: transparent; color: #ccc; }
|
.actions { display: flex; gap: 10px; margin-top: 16px; }
|
||||||
@media (max-width: 360px) { .control { gap: 6px; } .control label { min-width: 64px; } }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import type { Capture } from './capture.types';
|
import type { Capture } from './capture.types';
|
||||||
import { inToEighths, eighthsToIn } from './capture.types';
|
import { inToEighths, eighthsToIn } from './capture.types';
|
||||||
import { fmtEighths } from './session';
|
import { fmtEighths } from './session';
|
||||||
import { calibrationScale } from '../png/crop';
|
|
||||||
import PhotoStage from './PhotoStage.svelte';
|
import PhotoStage from './PhotoStage.svelte';
|
||||||
import {
|
import {
|
||||||
buildPhotosReadyLineup,
|
buildPhotosReadyLineup,
|
||||||
@@ -55,12 +54,6 @@
|
|||||||
return Number.isFinite(inches) && inches > 0 ? inches : null;
|
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 commitLength = () => {
|
||||||
const inches = positiveInches(String(lengthIn));
|
const inches = positiveInches(String(lengthIn));
|
||||||
const eighths = inches === null ? 0 : inToEighths(inches);
|
const eighths = inches === null ? 0 : inToEighths(inches);
|
||||||
@@ -74,7 +67,7 @@
|
|||||||
thicknessIn = eighths === 0 ? '' : String(eighthsToIn(eighths));
|
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 () => {
|
const doCalc = async () => {
|
||||||
calcBusy = true;
|
calcBusy = true;
|
||||||
@@ -100,7 +93,7 @@
|
|||||||
|
|
||||||
<div class="fields">
|
<div class="fields">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<span class="label">Tape length L (distance between ticks)</span>
|
<span class="label">Length L (tape reading)</span>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
aria-label="Length L"
|
aria-label="Length L"
|
||||||
@@ -128,7 +121,6 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="row">
|
||||||
<button type="button" class="ghost" on:click={back}>Back</button>
|
<button type="button" class="ghost" on:click={back}>Back</button>
|
||||||
@@ -202,7 +194,6 @@
|
|||||||
.fields { display: flex; gap: 16px; margin-top: 16px; }
|
.fields { display: flex; gap: 16px; margin-top: 16px; }
|
||||||
.field { flex: 1; }
|
.field { flex: 1; }
|
||||||
.label { display: block; font-size: 0.9rem; color: #aaa; margin-bottom: 8px; }
|
.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 {
|
.field input {
|
||||||
box-sizing: border-box; width: 100%; min-height: 52px; padding: 12px; font-size: 1rem;
|
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;
|
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
|
slabPx: number; // 0 ⇒ approve blocked, user retakes
|
||||||
bitset: Uint8Array; // source-grid mask, 1 byte/px
|
bitset: Uint8Array; // source-grid mask, 1 byte/px
|
||||||
bbox: BBox;
|
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 {
|
export interface PhotoTransform {
|
||||||
tiltDeg: number;
|
tiltDeg: number;
|
||||||
zoom: number;
|
zoom: number;
|
||||||
@@ -60,12 +53,15 @@ export interface PhotoTransform {
|
|||||||
panY: number;
|
panY: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Direction only: an oriented rectangle over the slab. The arrow reads
|
// The tape line is a stage fixture. bbox is kept for the dimension overlay.
|
||||||
// corners[3] → corners[0] (the long side).
|
// Scale is the source-pixel distance between tapeStart and tapeEnd.
|
||||||
export interface Axis {
|
export interface Axis {
|
||||||
bbox: BBox;
|
bbox: BBox;
|
||||||
tapeLine: TapeLine;
|
|
||||||
photoTransform: PhotoTransform;
|
photoTransform: PhotoTransform;
|
||||||
|
tickStartY: number;
|
||||||
|
tickEndY: number;
|
||||||
|
tapeStart: [number, number];
|
||||||
|
tapeEnd: [number, number];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Widths {
|
export interface Widths {
|
||||||
@@ -154,8 +150,11 @@ export interface Draft {
|
|||||||
maskParams: MaskParams; // → re-derive mask on resume (ignored while stage==='batch')
|
maskParams: MaskParams; // → re-derive mask on resume (ignored while stage==='batch')
|
||||||
mask: Omit<Mask, 'bitset' | 'params'>; // slabPx, bbox, slabBounds
|
mask: Omit<Mask, 'bitset' | 'params'>; // slabPx, bbox, slabBounds
|
||||||
axisBbox?: BBox | null; // present when stage >= axis; absent on legacy drafts
|
axisBbox?: BBox | null; // present when stage >= axis; absent on legacy drafts
|
||||||
tapeLine?: TapeLine; // owner-placed tape endpoints; absent on pre-M4 drafts
|
photoTransform?: PhotoTransform;
|
||||||
photoTransform?: PhotoTransform; // calibration photo transform; absent on pre-M4 drafts
|
tickStartY?: number;
|
||||||
|
tickEndY?: number;
|
||||||
|
tapeStart?: [number, number];
|
||||||
|
tapeEnd?: [number, number];
|
||||||
lengthEighths: Eighths;
|
lengthEighths: Eighths;
|
||||||
thicknessEighths: Eighths;
|
thicknessEighths: Eighths;
|
||||||
results: Omit<Measures, 'inventoryPng' | 'overlayPng'> | null; // present when results computed
|
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');
|
s.setSheet('black');
|
||||||
await settleOverlay(s);
|
await settleOverlay(s);
|
||||||
s.approveMask();
|
s.approveMask();
|
||||||
s.setTapeLine({ start: [0, 10], end: [39, 10] });
|
|
||||||
s.approveAxis();
|
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.setLength(320);
|
||||||
s.setThickness(16);
|
s.setThickness(16);
|
||||||
await s.calculate();
|
await s.calculate();
|
||||||
@@ -394,7 +393,7 @@ test('#61: Calculate produces inventory PNG + M1 measures for calibration source
|
|||||||
const r = cap.results!;
|
const r = cap.results!;
|
||||||
assert.ok(r.inventoryPng instanceof Blob, 'clean inventory PNG blob');
|
assert.ok(r.inventoryPng instanceof Blob, 'clean inventory PNG blob');
|
||||||
assert.equal(typeof r.scale, 'number');
|
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.sqft, 'number');
|
||||||
assert.equal(typeof r.bdft, 'number');
|
assert.equal(typeof r.bdft, 'number');
|
||||||
// CAPTURE.md: bdft = sqft × thickness_in
|
// 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);
|
assert.equal(before!.batch![2].blob, c);
|
||||||
|
|
||||||
s.approveMask();
|
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));
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
|
||||||
const after = store.peek();
|
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!.stage, 'axis');
|
||||||
assert.equal(after!.calibrationIndex, 1);
|
assert.equal(after!.calibrationIndex, 1);
|
||||||
assert.equal(after!.source.blob, b, 'draft.source is calibration only');
|
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 ?? []).length, 3, 'full batch retained');
|
||||||
assert.equal(after!.batch![0].blob, a, 'batch[0] untouched');
|
assert.equal(after!.batch![0].blob, a, 'batch[0] untouched');
|
||||||
assert.equal(after!.batch![1].blob, b);
|
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![0].blob, a);
|
||||||
assert.equal(meas!.batch![2].blob, c);
|
assert.equal(meas!.batch![2].blob, c);
|
||||||
assert.equal(meas!.calibrationIndex, 1);
|
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();
|
s.dispose();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
import { writable } from 'svelte/store';
|
import { writable } from 'svelte/store';
|
||||||
import type {
|
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';
|
} from './capture.types';
|
||||||
import {
|
import {
|
||||||
DEFAULT_MASK_PARAMS, EMPTY_ABOX, EMPTY_BBOX, eighthsToIn,
|
DEFAULT_MASK_PARAMS, EMPTY_ABOX, EMPTY_BBOX, eighthsToIn,
|
||||||
} from './capture.types';
|
} from './capture.types';
|
||||||
import { loadDraft, saveDraft, clearDraft } from '../draft/idb';
|
import { loadDraft, saveDraft, clearDraft } from '../draft/idb';
|
||||||
import { measure } from '../measure/measure';
|
import { measure } from '../measure/measure';
|
||||||
import { rectAlongLine } from '../geometry/rect';
|
|
||||||
import { encodeInventoryPng, type InventoryInput } from '../png/inventory';
|
import { encodeInventoryPng, type InventoryInput } from '../png/inventory';
|
||||||
import { encodeOverlayPng, type EncodeOverlayInput } from '../png/overlay';
|
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 { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
||||||
|
import { IDENTITY_TRANSFORM, cropEnds, tapeSegmentSource } from './tapeLine';
|
||||||
|
|
||||||
const DEBOUNCE_MS = 2000;
|
const DEBOUNCE_MS = 2000;
|
||||||
const BUSY_MS = 300;
|
const BUSY_MS = 300;
|
||||||
@@ -42,9 +40,14 @@ export interface CaptureSession {
|
|||||||
setSensitivity(n: number): void;
|
setSensitivity(n: number): void;
|
||||||
setSheet(s: Sheet): void;
|
setSheet(s: Sheet): void;
|
||||||
approveMask(): void;
|
approveMask(): void;
|
||||||
setTapeLine(tapeLine: TapeLine, photoTransform?: PhotoTransform): void;
|
setTapePose(pose: {
|
||||||
/** #99: global crop fill from settings (percent or fraction); drives the PNG crop. */
|
transform: PhotoTransform;
|
||||||
setCropFill(pctOrFraction: number): void;
|
tickStartY: number;
|
||||||
|
tickEndY: number;
|
||||||
|
sourceW: number;
|
||||||
|
sourceH: number;
|
||||||
|
}): void;
|
||||||
|
setCropFill(pct: number): void;
|
||||||
approveAxis(): void;
|
approveAxis(): void;
|
||||||
setLength(e: Eighths): void;
|
setLength(e: Eighths): void;
|
||||||
setThickness(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 busyTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let pngUrl: string | null = null;
|
let pngUrl: string | null = null;
|
||||||
let resumed = false;
|
let resumed = false;
|
||||||
// #99: global crop fill (fraction). Set from settings; FILL_DEFAULT (90%) until told otherwise.
|
let cropFill = 90;
|
||||||
let cropFill = FILL_DEFAULT;
|
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) => {
|
const set = (next: Capture) => {
|
||||||
state = next;
|
state = next;
|
||||||
@@ -213,8 +234,11 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
calibrationIndex: calIdx,
|
calibrationIndex: calIdx,
|
||||||
source: state.source, maskParams: state.approvedMask.params,
|
source: state.source, maskParams: state.approvedMask.params,
|
||||||
mask: strip(state.approvedMask), axisBbox: state.axis.bbox,
|
mask: strip(state.approvedMask), axisBbox: state.axis.bbox,
|
||||||
tapeLine: state.axis.tapeLine,
|
|
||||||
photoTransform: state.axis.photoTransform,
|
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,
|
lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -225,8 +249,11 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
calibrationIndex: calIdx,
|
calibrationIndex: calIdx,
|
||||||
source: state.source, maskParams: state.approvedMask.params,
|
source: state.source, maskParams: state.approvedMask.params,
|
||||||
mask: strip(state.approvedMask), axisBbox: state.axis.bbox,
|
mask: strip(state.approvedMask), axisBbox: state.axis.bbox,
|
||||||
tapeLine: state.axis.tapeLine,
|
|
||||||
photoTransform: state.axis.photoTransform,
|
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,
|
lengthEighths: state.lengthEighths, thicknessEighths: state.thicknessEighths,
|
||||||
results: state.results ? rest : null,
|
results: state.results ? rest : null,
|
||||||
inventoryPng: state.results?.inventoryPng ?? 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;
|
// Legacy drafts (pre-axisBbox, with heading) carry no usable axis state;
|
||||||
// fall back to the mask's own bbox.
|
// fall back to the mask's own bbox.
|
||||||
const draftAxisBbox = (d: Draft) => d.axisBbox ?? d.mask.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) => {
|
const restoreFromDraft = async (d: Draft) => {
|
||||||
batchPhotos = batchPhotosOf(d);
|
batchPhotos = batchPhotosOf(d);
|
||||||
@@ -355,11 +375,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
try {
|
try {
|
||||||
const mask = await rederiveMask(source, d.maskParams, d.mask);
|
const mask = await rederiveMask(source, d.maskParams, d.mask);
|
||||||
if (d.stage === 'axis') {
|
if (d.stage === 'axis') {
|
||||||
const photoTransform = draftPhotoTransform(d);
|
set({ k: 'axis', source, approvedMask: mask, axis: axisFrom(draftAxisBbox(d), source.widthPx, source.heightPx, d) });
|
||||||
const tapeLine = draftTapeLine(d);
|
|
||||||
set({ k: 'axis', source, approvedMask: mask, axis: {
|
|
||||||
bbox: rectAlongLine(draftAxisBbox(d), tapeLine.start, tapeLine.end), tapeLine, photoTransform,
|
|
||||||
} });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (d.inventoryPng) {
|
if (d.inventoryPng) {
|
||||||
@@ -368,11 +384,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
}
|
}
|
||||||
set({
|
set({
|
||||||
k: 'measure', source, approvedMask: mask,
|
k: 'measure', source, approvedMask: mask,
|
||||||
axis: {
|
axis: axisFrom(draftAxisBbox(d), source.widthPx, source.heightPx, d),
|
||||||
bbox: rectAlongLine(draftAxisBbox(d), draftTapeLine(d).start, draftTapeLine(d).end),
|
|
||||||
tapeLine: draftTapeLine(d),
|
|
||||||
photoTransform: draftPhotoTransform(d),
|
|
||||||
},
|
|
||||||
lengthEighths: d.lengthEighths, thicknessEighths: d.thicknessEighths,
|
lengthEighths: d.lengthEighths, thicknessEighths: d.thicknessEighths,
|
||||||
results: d.results
|
results: d.results
|
||||||
? {
|
? {
|
||||||
@@ -501,36 +513,39 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
if (state.k !== 'mask' || !state.overlay || state.overlay.slabPx === 0) return;
|
if (state.k !== 'mask' || !state.overlay || state.overlay.slabPx === 0) return;
|
||||||
teardownWorker();
|
teardownWorker();
|
||||||
const overlay = state.overlay;
|
const overlay = state.overlay;
|
||||||
const photoTransform = { ...DEFAULT_PHOTO_TRANSFORM };
|
const bbox = { corners: overlay.bbox.corners.map((c) => [...c] as [number, number]), longSidePx: overlay.bbox.longSidePx, angleDeg: overlay.bbox.angleDeg };
|
||||||
const [startY, endY] = cropTickPositions(cropFill);
|
set({ k: 'axis', source: state.source, approvedMask: overlay, axis: axisFrom(bbox, state.source.widthPx, state.source.heightPx) });
|
||||||
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,
|
|
||||||
} });
|
|
||||||
void persist();
|
void persist();
|
||||||
},
|
},
|
||||||
|
|
||||||
setTapeLine(tapeLine: TapeLine, nextTransform?: PhotoTransform) {
|
setTapePose(pose) {
|
||||||
if (state.k !== 'axis') return;
|
if (state.k !== 'axis') return;
|
||||||
const start: [number, number] = [...tapeLine.start];
|
const sourceW = pose.sourceW > 0 ? pose.sourceW : state.source.widthPx;
|
||||||
const end: [number, number] = [...tapeLine.end];
|
const sourceH = pose.sourceH > 0 ? pose.sourceH : state.source.heightPx;
|
||||||
const photoTransform = { ...(nextTransform ?? state.axis.photoTransform) };
|
const source = sourceW > 0 && sourceH > 0
|
||||||
const bbox = rectAlongLine(state.axis.bbox, start, end);
|
? { ...state.source, widthPx: sourceW, heightPx: sourceH }
|
||||||
if (!(bbox.longSidePx > 0)) return;
|
: state.source;
|
||||||
set({ ...state, axis: { bbox, tapeLine: { start, end }, photoTransform } });
|
set({
|
||||||
void persist();
|
...state,
|
||||||
|
source,
|
||||||
|
axis: axisFrom(state.axis.bbox, sourceW, sourceH, pose),
|
||||||
|
});
|
||||||
|
if (poseTimer) clearTimeout(poseTimer);
|
||||||
|
poseTimer = setTimeout(() => {
|
||||||
|
poseTimer = null;
|
||||||
|
void persist();
|
||||||
|
}, 250);
|
||||||
},
|
},
|
||||||
|
|
||||||
setCropFill(pctOrFraction: number) {
|
setCropFill(pct: number) {
|
||||||
// #99: non-retro — only future encodes use this; stored PNGs are untouched.
|
cropFill = Math.max(1, Math.min(100, Math.round(pct)));
|
||||||
cropFill = fillFromSetting(pctOrFraction);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
approveAxis() {
|
approveAxis() {
|
||||||
if (state.k !== 'axis') return;
|
if (state.k !== 'axis') return;
|
||||||
set({
|
set({
|
||||||
k: 'measure', source: state.source, approvedMask: state.approvedMask,
|
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,
|
lengthEighths: 0, thicknessEighths: 0, results: null,
|
||||||
});
|
});
|
||||||
void persist();
|
void persist();
|
||||||
@@ -554,17 +569,16 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
if (state.k !== 'measure') return;
|
if (state.k !== 'measure') return;
|
||||||
const { source, approvedMask, axis, lengthEighths, thicknessEighths } = state;
|
const { source, approvedMask, axis, lengthEighths, thicknessEighths } = state;
|
||||||
if (lengthEighths === 0 || thicknessEighths === 0) return;
|
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 bitmap = await decodeOrientedBitmap(source.blob);
|
||||||
const base = measure(
|
const base = measure(
|
||||||
approvedMask.bitset, source.widthPx, source.heightPx,
|
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 pngBlob: Blob;
|
||||||
let belowFloor = false;
|
let belowFloor = false;
|
||||||
try {
|
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;
|
pngBlob = r.blob;
|
||||||
belowFloor = r.sourceBelowFloor;
|
belowFloor = r.sourceBelowFloor;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -590,8 +604,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
lengthLabel: fmtEighths(lengthEighths),
|
lengthLabel: fmtEighths(lengthEighths),
|
||||||
slabBounds: approvedMask.slabBounds,
|
slabBounds: approvedMask.slabBounds,
|
||||||
source,
|
source,
|
||||||
fill: cropFill,
|
cropFill,
|
||||||
rotationDeg: axis.photoTransform.tiltDeg,
|
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Deterministic tests may inject a non-decodable inventory blob; still
|
// 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);
|
const siblingBitmap = await decodeOrientedBitmap(photo.blob);
|
||||||
let encoded: { blob: Blob; sourceBelowFloor: boolean };
|
let encoded: { blob: Blob; sourceBelowFloor: boolean };
|
||||||
try {
|
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 {
|
} finally {
|
||||||
siblingBitmap.close();
|
siblingBitmap.close();
|
||||||
}
|
}
|
||||||
@@ -669,9 +682,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
const m = state.approvedMask;
|
const m = state.approvedMask;
|
||||||
set({ k: 'mask', source: state.source, control: m.params, overlay: m, failed: false });
|
set({ k: 'mask', source: state.source, control: m.params, overlay: m, failed: false });
|
||||||
} else if (state.k === 'measure') {
|
} else if (state.k === 'measure') {
|
||||||
set({ k: 'axis', source: state.source, approvedMask: state.approvedMask, axis: {
|
set({ k: 'axis', source: state.source, approvedMask: state.approvedMask, axis: state.axis });
|
||||||
bbox: state.axis.bbox, tapeLine: state.axis.tapeLine, photoTransform: state.axis.photoTransform,
|
|
||||||
} });
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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 { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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
|
// 100×20 horizontal slab
|
||||||
const W = 100, H = 20;
|
const W = 100, H = 20;
|
||||||
const bitset = new Uint8Array(W * H);
|
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;
|
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', () => {
|
test('orientedRect changes angleDeg and preserves longSidePx', () => {
|
||||||
const prev = minAreaRect(bitset, W, H);
|
const prev = minAreaRect(bitset, W, H);
|
||||||
assert.ok(prev, 'minAreaRect returned a box');
|
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);
|
assert.ok(orientedRect(prev, 190).angleDeg >= 0 && orientedRect(prev, 190).angleDeg < 180);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('rectAlongLine sets the approved axis from the owner-positioned tape endpoints', () => {
|
test('measure scale uses mask span along the axis, not longSidePx', () => {
|
||||||
const previous = minAreaRect(bitset, W, H)!;
|
// Thin slab (20 long, 100 wide): minAreaRect's long side (angleDeg 90) is
|
||||||
const start: [number, number] = [20, 30];
|
// the 100-px dimension along +y. Rotate the axis to angleDeg 0 (+x), which
|
||||||
const end: [number, number] = [80, 70];
|
// crosses only the 20-px slab width — span 19, not longSidePx 99.
|
||||||
const calibrated = rectAlongLine(previous, start, end);
|
const TW = 20, TH = 100;
|
||||||
const center: [number, number] = [50, 50];
|
const thin = new Uint8Array(TW * TH).fill(1);
|
||||||
assert.ok(Math.abs(calibrated.angleDeg - (Math.atan2(40, 60) * 180) / Math.PI) < 1e-9);
|
const thinBox = minAreaRect(thin, TW, TH);
|
||||||
assert.ok(Math.abs(calibrated.longSidePx - Math.hypot(60, 40)) < 1e-9);
|
assert.ok(thinBox && thinBox.longSidePx >= 99, `longSide ${thinBox?.longSidePx}`);
|
||||||
assert.ok(Math.abs(calibrated.corners.reduce((sum, point) => sum + point[0], 0) / 4 - center[0]) < 1e-9);
|
const rotated = orientedRect(thinBox, 0);
|
||||||
assert.ok(Math.abs(calibrated.corners.reduce((sum, point) => sum + point[1], 0) / 4 - center[1]) < 1e-9);
|
const lengthEighths = 24; // 3 in
|
||||||
assert.notEqual(calibrated.angleDeg, previous.angleDeg, 'direction comes from tape endpoints, not mask rectangle');
|
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 {
|
export function slabBounds(bitset: Uint8Array, w: number, h: number): ABox {
|
||||||
let minX = w, minY = h, maxX = -1, maxY = -1;
|
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 };
|
return { corners, longSidePx: prev.longSidePx, angleDeg: a };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build the approved axis rectangle from the owner's two tape-line endpoints. */
|
// Project every slab pixel onto the unit vector (ux, uy); return max − min
|
||||||
export function rectAlongLine(prev: BBox, start: [number, number], end: [number, number]): BBox {
|
// extent in px. 0 when the mask is empty.
|
||||||
const dx = end[0] - start[0];
|
export function maskSpanAlong(bitset: Uint8Array, w: number, h: number, ux: number, uy: number): number {
|
||||||
const dy = end[1] - start[1];
|
let min = Infinity, max = -Infinity;
|
||||||
const length = Math.hypot(dx, dy);
|
for (let y = 0; y < h; y++) {
|
||||||
if (!(length > 0) || !Number.isFinite(length)) return prev;
|
const row = y * w;
|
||||||
|
for (let x = 0; x < w; x++) {
|
||||||
const halfW = Math.hypot(
|
if (!bitset[row + x]) continue;
|
||||||
prev.corners[0][0] - prev.corners[1][0],
|
const p = x * ux + y * uy;
|
||||||
prev.corners[0][1] - prev.corners[1][1],
|
if (p < min) min = p;
|
||||||
) / 2;
|
if (p > max) max = p;
|
||||||
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,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
const angleDeg = ((Math.atan2(uy, ux) * 180) / Math.PI + 180) % 180;
|
return max < min ? 0 : max - min;
|
||||||
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] };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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', () => {
|
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 { 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)`);
|
assert.equal(m.widths.every6In.length, 7, `stations ${m.widths.every6In.length} (6..42in)`);
|
||||||
for (const e of m.widths.every6In) {
|
for (const e of m.widths.every6In) {
|
||||||
assert.ok(e >= 76 && e <= 84, `station ${e / 8}in must be bulk (~10in)`);
|
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', () => {
|
test('uniform slab: interior stations keep the full width', () => {
|
||||||
const { bitset, box, slabPx } = uniform(480, 100);
|
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);
|
assert.equal(m.widths.every6In.length, 7);
|
||||||
for (const e of m.widths.every6In) {
|
for (const e of m.widths.every6In) {
|
||||||
assert.ok(e >= 72 && e <= 104, `station ${e / 8}in`);
|
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', () => {
|
test('short slab L=12in: no interior stations, widths are empty/zero', () => {
|
||||||
const { bitset, box, slabPx } = uniform(120, 100);
|
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: [] });
|
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', () => {
|
test('short slab L=10in: no interior stations, widths are empty/zero', () => {
|
||||||
const { bitset, box, slabPx } = uniform(100, 100);
|
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: [] });
|
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 type { BBox, Eighths, Measures, Widths } from '../capture/capture.types';
|
||||||
|
import { maskSpanAlong } from '../geometry/rect';
|
||||||
|
|
||||||
// Display / direction: corners[3] → corners[0] is the long side.
|
// Display / direction: corners[3] → corners[0] is the long side.
|
||||||
export function axisEndpoints(bbox: BBox): { start: [number, number]; end: [number, number] } {
|
export function axisEndpoints(bbox: BBox): { start: [number, number]; end: [number, number] } {
|
||||||
@@ -75,14 +76,20 @@ export function measure(
|
|||||||
bbox: BBox,
|
bbox: BBox,
|
||||||
lengthEighths: Eighths, thicknessEighths: Eighths,
|
lengthEighths: Eighths, thicknessEighths: Eighths,
|
||||||
slabPx: number,
|
slabPx: number,
|
||||||
scale: number,
|
tape?: { start: [number, number]; end: [number, number] },
|
||||||
): Omit<Measures, 'inventoryPng'> & { sourceBelowFloor: false } {
|
): Omit<Measures, 'inventoryPng'> & { sourceBelowFloor: false } {
|
||||||
const lengthIn = lengthEighths / 8;
|
const lengthIn = lengthEighths / 8;
|
||||||
const seg = maskAxisSegment(bitset, w, h, bbox);
|
const seg = maskAxisSegment(bitset, w, h, bbox);
|
||||||
if (!(scale > 0) || !Number.isFinite(scale)) {
|
const tapeSpan = tape
|
||||||
throw new RangeError('Calibration scale must be a positive finite value');
|
? Math.hypot(tape.end[0] - tape.start[0], tape.end[1] - tape.start[1])
|
||||||
}
|
: 0;
|
||||||
const every6In = sampleWidths(bitset, w, h, seg.start, seg.ux, seg.uy, scale, lengthIn);
|
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;
|
let widths: Widths;
|
||||||
if (every6In.length > 0) {
|
if (every6In.length > 0) {
|
||||||
const min = Math.min(...every6In);
|
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.equal(out.sourceBelowFloor, true);
|
||||||
assert.ok(out.width <= 800, `outW ${out.width} must not exceed the floor`);
|
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 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 MaskPick = Pick<Mask, 'bitset' | 'slabBounds'>;
|
||||||
type SourcePick = Pick<Source, 'widthPx' | 'heightPx'>;
|
type SourcePick = Pick<Source, 'widthPx' | 'heightPx'>;
|
||||||
@@ -8,10 +11,8 @@ export interface InventoryInput {
|
|||||||
bitmap: ImageBitmap;
|
bitmap: ImageBitmap;
|
||||||
mask: MaskPick;
|
mask: MaskPick;
|
||||||
source: SourcePick;
|
source: SourcePick;
|
||||||
/** Calibration-photo rotation in degrees; batch siblings remain unrotated. */
|
/** Global crop fill percent. Default 90. */
|
||||||
rotationDeg?: number;
|
cropFill?: number;
|
||||||
/** Crop fill, 0<fill<=1. Defaults to FILL_DEFAULT (the 90% settings default). */
|
|
||||||
fill?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Frame {
|
export interface Frame {
|
||||||
@@ -21,80 +22,12 @@ export interface Frame {
|
|||||||
sourceBelowFloor: boolean;
|
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(
|
export function composeInventoryFrame(
|
||||||
src: { width: number; height: number; rgba: Uint8Array | Uint8ClampedArray },
|
src: { width: number; height: number; rgba: Uint8Array | Uint8ClampedArray },
|
||||||
mask: MaskPick,
|
mask: MaskPick,
|
||||||
source: SourcePick,
|
source: SourcePick,
|
||||||
fill: number = FILL_DEFAULT,
|
fill = 0.9,
|
||||||
rotationDeg = 0,
|
|
||||||
): Frame {
|
): Frame {
|
||||||
if (Number.isFinite(rotationDeg) && Math.abs(rotationDeg % 360) > 1e-8) {
|
|
||||||
return composeRotatedInventoryFrame(src, mask, source, fill, rotationDeg);
|
|
||||||
}
|
|
||||||
const b = mask.slabBounds;
|
const b = mask.slabBounds;
|
||||||
const x0 = Math.max(0, Math.round(b.x));
|
const x0 = Math.max(0, Math.round(b.x));
|
||||||
const y0 = Math.max(0, Math.round(b.y));
|
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 y1 = Math.min(source.heightPx, y0 + Math.max(1, Math.round(b.h)));
|
||||||
const sw = x1 - x0;
|
const sw = x1 - x0;
|
||||||
const sh = y1 - y0;
|
const sh = y1 - y0;
|
||||||
const { outW, outH, belowFloor } = cropFrameSize(sw, sh);
|
const belowFloor = Math.min(sw, sh) < FLOOR;
|
||||||
const { scale, dw, dh, ox, oy } = cropOffsets(sw, sh, outW, outH, fill);
|
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 rgba = new Uint8Array(outW * outH * 4);
|
||||||
const xEnd = ox + dw;
|
const xEnd = ox + dw;
|
||||||
const yEnd = oy + dh;
|
const yEnd = oy + dh;
|
||||||
@@ -125,45 +64,11 @@ export function composeInventoryFrame(
|
|||||||
return { width: outW, height: outH, rgba, sourceBelowFloor: belowFloor };
|
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(
|
export async function encodeInventoryPng(
|
||||||
input: InventoryInput,
|
input: InventoryInput,
|
||||||
): Promise<{ blob: Blob; sourceBelowFloor: boolean }> {
|
): 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 srcCanvas = new OffscreenCanvas(source.widthPx, source.heightPx);
|
||||||
const srcCtx = srcCanvas.getContext('2d')!;
|
const srcCtx = srcCanvas.getContext('2d')!;
|
||||||
srcCtx.drawImage(bitmap, 0, 0);
|
srcCtx.drawImage(bitmap, 0, 0);
|
||||||
@@ -172,8 +77,7 @@ export async function encodeInventoryPng(
|
|||||||
{ width: source.widthPx, height: source.heightPx, rgba: img.data },
|
{ width: source.widthPx, height: source.heightPx, rgba: img.data },
|
||||||
mask,
|
mask,
|
||||||
source,
|
source,
|
||||||
input.fill,
|
fill,
|
||||||
input.rotationDeg,
|
|
||||||
);
|
);
|
||||||
const out = new OffscreenCanvas(frame.width, frame.height);
|
const out = new OffscreenCanvas(frame.width, frame.height);
|
||||||
const ctx = out.getContext('2d')!;
|
const ctx = out.getContext('2d')!;
|
||||||
|
|||||||
@@ -1117,18 +1117,3 @@ test('buildOverlayPlan: tapered tips (~20% max width) included; length span ≥
|
|||||||
const tipSpanSrc = tipHi - tipLo;
|
const tipSpanSrc = tipHi - tipLo;
|
||||||
assert.ok(tipSpanSrc <= woodSpan + 2, `tips must not overshoot wood (${tipSpanSrc} vs ${woodSpan})`);
|
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 type { BBox } from '../capture/capture.types';
|
||||||
import { axisEndpoints } from '../measure/measure';
|
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). */
|
/** Width overlay stations: 6, 18, 30, … while ≤ L − 6 (tip-exclusive). */
|
||||||
export function overlayWidthStations(lengthIn: number): number[] {
|
export function overlayWidthStations(lengthIn: number): number[] {
|
||||||
@@ -487,29 +485,20 @@ export function inventorySourceMap(
|
|||||||
source: { widthPx: number; heightPx: number },
|
source: { widthPx: number; heightPx: number },
|
||||||
outW: number,
|
outW: number,
|
||||||
outH: number,
|
outH: number,
|
||||||
fill: number = FILL_DEFAULT,
|
fill = 0.9,
|
||||||
bitset?: Uint8Array,
|
|
||||||
rotationDeg = 0,
|
|
||||||
): InventoryMap {
|
): InventoryMap {
|
||||||
if (bitset && Math.abs(rotationDeg % 360) > 1e-8) {
|
const fillRatio = fill;
|
||||||
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 x0 = Math.max(0, Math.round(slabBounds.x));
|
const x0 = Math.max(0, Math.round(slabBounds.x));
|
||||||
const y0 = Math.max(0, Math.round(slabBounds.y));
|
const y0 = Math.max(0, Math.round(slabBounds.y));
|
||||||
const x1 = Math.min(source.widthPx, x0 + Math.max(1, Math.round(slabBounds.w)));
|
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 y1 = Math.min(source.heightPx, y0 + Math.max(1, Math.round(slabBounds.h)));
|
||||||
const sw = x1 - x0;
|
const sw = x1 - x0;
|
||||||
const sh = y1 - y0;
|
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 {
|
return {
|
||||||
outW,
|
outW,
|
||||||
outH,
|
outH,
|
||||||
@@ -529,8 +518,7 @@ export interface EncodeOverlayInput {
|
|||||||
lengthLabel: string;
|
lengthLabel: string;
|
||||||
slabBounds: { x: number; y: number; w: number; h: number };
|
slabBounds: { x: number; y: number; w: number; h: number };
|
||||||
source: { widthPx: number; heightPx: number };
|
source: { widthPx: number; heightPx: number };
|
||||||
fill?: number;
|
cropFill?: number;
|
||||||
rotationDeg?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -545,9 +533,8 @@ export async function encodeOverlayPng(input: EncodeOverlayInput): Promise<Blob>
|
|||||||
const canvas = new OffscreenCanvas(outW, outH);
|
const canvas = new OffscreenCanvas(outW, outH);
|
||||||
const ctx = canvas.getContext('2d')!;
|
const ctx = canvas.getContext('2d')!;
|
||||||
ctx.drawImage(bmp, 0, 0);
|
ctx.drawImage(bmp, 0, 0);
|
||||||
const { mapToInventory } = inventorySourceMap(
|
const fill = Math.max(1, Math.min(100, input.cropFill ?? 90)) / 100;
|
||||||
input.slabBounds, input.source, outW, outH, input.fill, input.bitset, input.rotationDeg,
|
const { mapToInventory } = inventorySourceMap(input.slabBounds, input.source, outW, outH, fill);
|
||||||
);
|
|
||||||
const plan = buildOverlayPlan({
|
const plan = buildOverlayPlan({
|
||||||
bitset: input.bitset,
|
bitset: input.bitset,
|
||||||
widthPx: input.widthPx,
|
widthPx: input.widthPx,
|
||||||
|
|||||||
Reference in New Issue
Block a user