feat(capture): tape-line overlay UI replaces axis rectangle (#99)
Pointer-Events pan/zoom/tilt under a fixed black ~2pt line with rifle-scope end ticks (≥44×44 hit). AxisPanel becomes Tape line step; session persists tape transform; scale = tick distance / L. sveltedrag unavailable for Svelte 4 — Pointer Events path used.
This commit is contained in:
+115
-16
@@ -10,7 +10,11 @@ import { measure } from '../measure/measure';
|
||||
import { orientedRect } from '../geometry/rect';
|
||||
import { encodeInventoryPng, type InventoryInput } from '../png/inventory';
|
||||
import { encodeOverlayPng, type EncodeOverlayInput } from '../png/overlay';
|
||||
import { rotateCalibration } from '../png/rotateCal';
|
||||
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
||||
import { defaultTape, tapeEndpoints, type TapeTransform } from './tapeGeometry';
|
||||
import { getCropFill } from '../settings/cropFill';
|
||||
import type { TapeLine } from './capture.types';
|
||||
|
||||
const DEBOUNCE_MS = 2000;
|
||||
const BUSY_MS = 300;
|
||||
@@ -41,6 +45,8 @@ export interface CaptureSession {
|
||||
setSheet(s: Sheet): void;
|
||||
approveMask(): void;
|
||||
setAxisAngle(deg: number): void;
|
||||
/** #99: replace axis rectangle nudge with full tape transform. */
|
||||
setTape(tape: TapeTransform): void;
|
||||
approveAxis(): void;
|
||||
setLength(e: Eighths): void;
|
||||
setThickness(e: Eighths): void;
|
||||
@@ -206,7 +212,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
version: 1, stage: 'axis', batch: batchPhotos.length ? batchPhotos : [state.source],
|
||||
calibrationIndex: calIdx,
|
||||
source: state.source, maskParams: state.approvedMask.params,
|
||||
mask: strip(state.approvedMask), axisBbox: state.axis.bbox,
|
||||
mask: strip(state.approvedMask), axisBbox: state.axis.bbox, tape: state.axis.tape,
|
||||
lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null,
|
||||
};
|
||||
}
|
||||
@@ -216,7 +222,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
version: 1, stage: 'measure', batch: batchPhotos.length ? batchPhotos : [state.source],
|
||||
calibrationIndex: calIdx,
|
||||
source: state.source, maskParams: state.approvedMask.params,
|
||||
mask: strip(state.approvedMask), axisBbox: state.axis.bbox,
|
||||
mask: strip(state.approvedMask), axisBbox: state.axis.bbox, tape: state.axis.tape,
|
||||
lengthEighths: state.lengthEighths, thicknessEighths: state.thicknessEighths,
|
||||
results: state.results ? rest : null,
|
||||
inventoryPng: state.results?.inventoryPng ?? null,
|
||||
@@ -312,6 +318,37 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
// fall back to the mask's own bbox.
|
||||
const draftAxisBbox = (d: Draft) => d.axisBbox ?? d.mask.bbox;
|
||||
|
||||
const tapeFromDraftOrBbox = (d: Draft, bbox: import('./capture.types').BBox, w: number, h: number): TapeLine => {
|
||||
if (d.tape && Number.isFinite(d.tape.spanPx)) {
|
||||
return { ...d.tape };
|
||||
}
|
||||
// Seed tilt so the mask long side sits near vertical under the fixed line.
|
||||
const seedTilt = 90 - bbox.angleDeg;
|
||||
const t = defaultTape(getCropFill(), seedTilt);
|
||||
const ep = w > 0 && h > 0 ? tapeEndpoints(t, w, h) : { spanPx: 0, start: [0, 0] as [number, number], end: [0, 0] as [number, number], ux: 0, uy: 1 };
|
||||
return { ...t, spanPx: ep.spanPx };
|
||||
};
|
||||
|
||||
const axisWithTape = (bbox: import('./capture.types').BBox, tape: TapeTransform, w: number, h: number) => {
|
||||
const ep = w > 0 && h > 0
|
||||
? tapeEndpoints(tape, w, h)
|
||||
: { spanPx: 0, start: [0, 0] as [number, number], end: [0, 0] as [number, number], ux: 0, uy: 1 };
|
||||
const angleDeg = ep.spanPx > 0
|
||||
? ((Math.atan2(ep.uy, ep.ux) * 180) / Math.PI + 180) % 180
|
||||
: bbox.angleDeg;
|
||||
const nextBbox = orientedRect(bbox, angleDeg);
|
||||
const line: TapeLine = {
|
||||
tiltDeg: tape.tiltDeg,
|
||||
zoom: tape.zoom,
|
||||
panX: tape.panX,
|
||||
panY: tape.panY,
|
||||
tickA: tape.tickA,
|
||||
tickB: tape.tickB,
|
||||
spanPx: ep.spanPx,
|
||||
};
|
||||
return { bbox: nextBbox, tape: line };
|
||||
};
|
||||
|
||||
const restoreFromDraft = async (d: Draft) => {
|
||||
batchPhotos = batchPhotosOf(d);
|
||||
calibrationIndex = typeof d.calibrationIndex === 'number' ? d.calibrationIndex : null;
|
||||
@@ -338,16 +375,29 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
try {
|
||||
const mask = await rederiveMask(source, d.maskParams, d.mask);
|
||||
if (d.stage === 'axis') {
|
||||
set({ k: 'axis', source, approvedMask: mask, axis: { bbox: draftAxisBbox(d) } });
|
||||
const bbox = draftAxisBbox(d);
|
||||
const w = source.widthPx;
|
||||
const h = source.heightPx;
|
||||
const axis = d.tape
|
||||
? axisWithTape(bbox, d.tape, w, h)
|
||||
: { bbox, tape: tapeFromDraftOrBbox(d, bbox, w, h) };
|
||||
set({ k: 'axis', source, approvedMask: mask, axis });
|
||||
return;
|
||||
}
|
||||
if (d.inventoryPng) {
|
||||
if (pngUrl) URL.revokeObjectURL(pngUrl);
|
||||
pngUrl = URL.createObjectURL(d.inventoryPng);
|
||||
}
|
||||
{
|
||||
const bbox = draftAxisBbox(d);
|
||||
const w = source.widthPx;
|
||||
const h = source.heightPx;
|
||||
const axis = d.tape
|
||||
? axisWithTape(bbox, d.tape, w, h)
|
||||
: { bbox, tape: tapeFromDraftOrBbox(d, bbox, w, h) };
|
||||
set({
|
||||
k: 'measure', source, approvedMask: mask,
|
||||
axis: { bbox: draftAxisBbox(d) },
|
||||
axis,
|
||||
lengthEighths: d.lengthEighths, thicknessEighths: d.thicknessEighths,
|
||||
results: d.results
|
||||
? {
|
||||
@@ -358,6 +408,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
: null,
|
||||
batchApply: d.batchApply,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
set({ k: 'mask', source, control: { ...d.maskParams }, overlay: null, failed: true });
|
||||
makeWorker(source.blob);
|
||||
@@ -476,22 +527,47 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
if (state.k !== 'mask' || !state.overlay || state.overlay.slabPx === 0) return;
|
||||
teardownWorker();
|
||||
const overlay = state.overlay;
|
||||
set({ k: 'axis', source: state.source, approvedMask: overlay, axis: { bbox: { corners: overlay.bbox.corners.map((c) => [...c] as [number, number]), longSidePx: overlay.bbox.longSidePx, angleDeg: overlay.bbox.angleDeg } } });
|
||||
const bbox = {
|
||||
corners: overlay.bbox.corners.map((c) => [...c] as [number, number]),
|
||||
longSidePx: overlay.bbox.longSidePx,
|
||||
angleDeg: overlay.bbox.angleDeg,
|
||||
};
|
||||
const w = state.source.widthPx;
|
||||
const h = state.source.heightPx;
|
||||
const seedTilt = 90 - bbox.angleDeg;
|
||||
const tape0 = defaultTape(getCropFill(), seedTilt);
|
||||
const axis = axisWithTape(bbox, tape0, w, h);
|
||||
set({ k: 'axis', source: state.source, approvedMask: overlay, axis });
|
||||
void persist();
|
||||
},
|
||||
|
||||
// Legacy slider path — maps to photo tilt under the fixed tape line (#99).
|
||||
setAxisAngle(deg: number) {
|
||||
if (state.k !== 'axis') return;
|
||||
const a = ((Math.round(deg) % 180) + 180) % 180;
|
||||
set({ ...state, axis: { bbox: orientedRect(state.axis.bbox, a) } });
|
||||
const tape = { ...state.axis.tape, tiltDeg: deg };
|
||||
const axis = axisWithTape(state.axis.bbox, tape, state.source.widthPx, state.source.heightPx);
|
||||
set({ ...state, axis });
|
||||
void persist();
|
||||
},
|
||||
|
||||
setTape(tape: TapeTransform) {
|
||||
if (state.k !== 'axis') return;
|
||||
const axis = axisWithTape(state.axis.bbox, tape, state.source.widthPx, state.source.heightPx);
|
||||
set({ ...state, axis });
|
||||
void persist();
|
||||
},
|
||||
|
||||
approveAxis() {
|
||||
if (state.k !== 'axis') return;
|
||||
const axis = axisWithTape(
|
||||
state.axis.bbox,
|
||||
state.axis.tape,
|
||||
state.source.widthPx,
|
||||
state.source.heightPx,
|
||||
);
|
||||
set({
|
||||
k: 'measure', source: state.source, approvedMask: state.approvedMask,
|
||||
axis: { bbox: state.axis.bbox },
|
||||
axis,
|
||||
lengthEighths: 0, thicknessEighths: 0, results: null,
|
||||
});
|
||||
void persist();
|
||||
@@ -516,14 +592,35 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
const { source, approvedMask, axis, lengthEighths, thicknessEighths } = state;
|
||||
if (lengthEighths === 0 || thicknessEighths === 0) return;
|
||||
const bitmap = await decodeOrientedBitmap(source.blob);
|
||||
const tapeAxis = axis.tape.spanPx > 0
|
||||
? (() => {
|
||||
const ep = tapeEndpoints(axis.tape, source.widthPx, source.heightPx);
|
||||
return { start: ep.start, ux: ep.ux, uy: ep.uy, spanPx: ep.spanPx };
|
||||
})()
|
||||
: null;
|
||||
const base = measure(
|
||||
approvedMask.bitset, source.widthPx, source.heightPx,
|
||||
axis.bbox, lengthEighths, thicknessEighths, approvedMask.slabPx,
|
||||
tapeAxis,
|
||||
);
|
||||
let pngBlob: Blob;
|
||||
let belowFloor = false;
|
||||
// #99: bake photo tilt into cal inventory + overlay only; siblings untouched.
|
||||
let calBitmap = bitmap;
|
||||
let calMask = approvedMask;
|
||||
let calSource = source;
|
||||
let rotatedExtra: ImageBitmap | null = null;
|
||||
try {
|
||||
const r = await encodeInventory({ bitmap, mask: approvedMask, source });
|
||||
const rotated = await rotateCalibration(bitmap, approvedMask, source, axis.tape.tiltDeg);
|
||||
calBitmap = rotated.bitmap;
|
||||
calMask = rotated.mask;
|
||||
calSource = { ...source, widthPx: rotated.source.widthPx, heightPx: rotated.source.heightPx };
|
||||
if (rotated.bitmap !== bitmap) rotatedExtra = rotated.bitmap;
|
||||
} catch {
|
||||
/* keep unrotated cal if bake fails */
|
||||
}
|
||||
try {
|
||||
const r = await encodeInventory({ bitmap: calBitmap, mask: calMask, source: calSource });
|
||||
pngBlob = r.blob;
|
||||
belowFloor = r.sourceBelowFloor;
|
||||
} catch {
|
||||
@@ -531,24 +628,26 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
pngBlob = new Blob([], { type: 'image/png' });
|
||||
}
|
||||
bitmap.close();
|
||||
if (rotatedExtra) rotatedExtra.close();
|
||||
if (pngUrl) URL.revokeObjectURL(pngUrl);
|
||||
pngUrl = URL.createObjectURL(pngBlob);
|
||||
|
||||
// #63: second copy of the calibration inventory PNG with dimension overlays.
|
||||
// Keep clean inventoryPng untouched; never stash overlay into batchApply[cal].
|
||||
// Overlay uses the same baked orientation as the clean cal PNG (#99 scope).
|
||||
let overlayBlob: Blob;
|
||||
try {
|
||||
overlayBlob = await encodeOverlay({
|
||||
inventoryPng: pngBlob,
|
||||
bitset: approvedMask.bitset,
|
||||
widthPx: source.widthPx,
|
||||
heightPx: source.heightPx,
|
||||
bbox: axis.bbox,
|
||||
bitset: calMask.bitset,
|
||||
widthPx: calSource.widthPx,
|
||||
heightPx: calSource.heightPx,
|
||||
bbox: calMask.bbox,
|
||||
scale: base.scale,
|
||||
lengthIn: eighthsToIn(lengthEighths),
|
||||
lengthLabel: fmtEighths(lengthEighths),
|
||||
slabBounds: approvedMask.slabBounds,
|
||||
source,
|
||||
slabBounds: calMask.slabBounds,
|
||||
source: calSource,
|
||||
});
|
||||
} catch {
|
||||
// Deterministic tests may inject a non-decodable inventory blob; still
|
||||
@@ -626,7 +725,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
const m = state.approvedMask;
|
||||
set({ k: 'mask', source: state.source, control: m.params, overlay: m, failed: false });
|
||||
} else if (state.k === 'measure') {
|
||||
set({ k: 'axis', source: state.source, approvedMask: state.approvedMask, axis: { bbox: state.axis.bbox } });
|
||||
set({ k: 'axis', source: state.source, approvedMask: state.approvedMask, axis: state.axis });
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user