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

- One crop_fill (default 0.9) replaces duplicated FILL in inventory/overlay
- Thin localStorage settings store; crop % only moves long-axis end insets
- Pure tapeGeometry: fixed stage line, stage↔image map, tick span → px
- measure() prefers tape tick span for scale; maskSpanAlong demoted
- rotateCalibration bakes cal tilt into inventory + overlay only
- Fix stale 80%-fill comment on slabBounds
This commit is contained in:
grokbot_ty
2026-09-25 17:41:29 -07:00
parent d20e3d9077
commit 52240083b3
12 changed files with 486 additions and 16 deletions
+18 -4
View File
@@ -33,7 +33,7 @@ export interface ABox {
// Minimum-area bounding rectangle of the mask, source px.
export interface BBox {
corners: [number, number][]; // 4 corners; corners[3] → corners[0] is the long side
longSidePx: number; // geometric extent only; scale uses mask span along the axis
longSidePx: number; // geometric extent only; scale prefers tape tick span (#99)
angleDeg: number; // long-side angle, degrees, [0, 180)
}
@@ -43,13 +43,26 @@ export interface Mask {
slabPx: number; // 0 ⇒ approve blocked, user retakes
bitset: Uint8Array; // source-grid mask, 1 byte/px
bbox: BBox;
slabBounds: ABox; // axis-aligned extent of slab px, for the 80%-fill PNG crop
slabBounds: ABox; // axis-aligned extent of slab px, for the crop_fill PNG crop (default 90%)
}
// Tape-line calibration (#99): photo tilt/pan/zoom under a fixed middle line;
// ticks declare L. bbox is derived for overlay/width sampling direction.
export interface TapeLine {
tiltDeg: number;
zoom: number;
panX: number;
panY: number;
/** Tick Y in logical stage units (STAGE_H = 640). */
tickA: number;
tickB: number;
/** Image-space distance between ticks (px); drives scale = spanPx / L. */
spanPx: number;
}
// Direction only: an oriented rectangle over the slab. The arrow reads
// corners[3] → corners[0] (the long side).
export interface Axis {
bbox: BBox;
tape: TapeLine;
}
export interface Widths {
@@ -138,6 +151,7 @@ export interface Draft {
maskParams: MaskParams; // → re-derive mask on resume (ignored while stage==='batch')
mask: Omit<Mask, 'bitset' | 'params'>; // slabPx, bbox, slabBounds
axisBbox?: BBox | null; // present when stage >= axis; absent on legacy drafts
tape?: TapeLine | null; // #99 tape-line transform; absent on legacy drafts
lengthEighths: Eighths;
thicknessEighths: Eighths;
results: Omit<Measures, 'inventoryPng' | 'overlayPng'> | null; // present when results computed
+53
View File
@@ -0,0 +1,53 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
STAGE_H, STAGE_W, clampTickPair, containFit, defaultTape, defaultTicks,
lineX, stageToImage, tapeEndpoints,
} from './tapeGeometry.ts';
import { cropMarginFraction, DEFAULT_CROP_FILL } from '../png/crop.ts';
test('defaultTicks sit at crop insets for 90% fill', () => {
const t = defaultTicks(0.9);
const inset = STAGE_H * cropMarginFraction(0.9);
assert.ok(Math.abs(t.tickA - inset) < 1e-9);
assert.ok(Math.abs(t.tickB - (STAGE_H - inset)) < 1e-9);
});
test('lineX is the middle of the 3:4 stage', () => {
assert.equal(lineX(), STAGE_W / 2);
});
test('identity tape: stage centre maps near image centre', () => {
const imgW = 480, imgH = 640;
const tape = defaultTape(DEFAULT_CROP_FILL, 0);
const [ix, iy] = stageToImage(STAGE_W / 2, STAGE_H / 2, tape, imgW, imgH);
assert.ok(Math.abs(ix - imgW / 2) < 1e-6, `ix=${ix}`);
assert.ok(Math.abs(iy - imgH / 2) < 1e-6, `iy=${iy}`);
});
test('tapeEndpoints span equals tick distance in image px at identity (matching aspect)', () => {
const imgW = 480, imgH = 640;
const tape = defaultTape(0.9, 0);
const ep = tapeEndpoints(tape, imgW, imgH);
const { scale0 } = containFit(imgW, imgH);
const expected = Math.abs(tape.tickB - tape.tickA) / scale0;
assert.ok(Math.abs(ep.spanPx - expected) < 1e-6, `span ${ep.spanPx} vs ${expected}`);
// Direction should be +y (down the stage → down the image)
assert.ok(Math.abs(ep.ux) < 1e-6, `ux=${ep.ux}`);
assert.ok(ep.uy > 0.99, `uy=${ep.uy}`);
});
test('zoom scales inverse: 2× zoom halves image-space tick span', () => {
const imgW = 480, imgH = 640;
const base = defaultTape(0.9, 0);
const zoomed = { ...base, zoom: 2 };
const a = tapeEndpoints(base, imgW, imgH).spanPx;
const b = tapeEndpoints(zoomed, imgW, imgH).spanPx;
assert.ok(Math.abs(b * 2 - a) < 1e-6, `base=${a} zoomed=${b}`);
});
test('clampTickPair enforces gap and order', () => {
const p = clampTickPair(400, 100, 40);
assert.ok(p.tickB - p.tickA >= 40);
assert.ok(p.tickA <= p.tickB);
});
+117
View File
@@ -0,0 +1,117 @@
/**
* Pure tape-line geometry (#99).
* Fixed vertical line in the middle of a 3:4 stage; photo pans/zooms/tilts;
* ticks slide on the line; L↔px from tick distance in image space.
*/
import { cropMarginFraction, DEFAULT_CROP_FILL } from '../png/crop';
/** Logical stage matching the wireframe / inventory aspect. */
export const STAGE_W = 480;
export const STAGE_H = 640;
export interface TapeTransform {
/** Photo tilt in degrees (CSS-style; positive = clockwise). */
tiltDeg: number;
zoom: number;
/** Pan in stage units. */
panX: number;
panY: number;
/** Tick Y positions in stage units. */
tickA: number;
tickB: number;
}
export function defaultTicks(fill: number = DEFAULT_CROP_FILL): { tickA: number; tickB: number } {
const inset = STAGE_H * cropMarginFraction(fill);
return { tickA: inset, tickB: STAGE_H - inset };
}
export function defaultTape(fill: number = DEFAULT_CROP_FILL, seedTiltDeg = 0): TapeTransform {
const t = defaultTicks(fill);
return {
tiltDeg: seedTiltDeg,
zoom: 1,
panX: 0,
panY: 0,
tickA: t.tickA,
tickB: t.tickB,
};
}
const rad = (deg: number) => (deg * Math.PI) / 180;
/** Contain-fit of the photo into the stage at zoom=1, pan=0. */
export function containFit(imgW: number, imgH: number): { scale0: number; offX: number; offY: number } {
const scale0 = Math.min(STAGE_W / Math.max(imgW, 1), STAGE_H / Math.max(imgH, 1));
return {
scale0,
offX: (STAGE_W - imgW * scale0) / 2,
offY: (STAGE_H - imgH * scale0) / 2,
};
}
/**
* Map a stage point through the inverse photo transform into source image pixels.
* Transform order (forward): contain-fit → zoom+tilt about stage centre → pan.
*/
export function stageToImage(
sx: number,
sy: number,
tape: TapeTransform,
imgW: number,
imgH: number,
): [number, number] {
const cx = STAGE_W / 2;
const cy = STAGE_H / 2;
const { scale0, offX, offY } = containFit(imgW, imgH);
// undo pan
let x = sx - tape.panX - cx;
let y = sy - tape.panY - cy;
// undo rotate(tilt) scale(zoom) about centre
const z = tape.zoom || 1;
const a = -rad(tape.tiltDeg);
const cos = Math.cos(a);
const sin = Math.sin(a);
const xr = (x * cos - y * sin) / z;
const yr = (x * sin + y * cos) / z;
x = xr + cx;
y = yr + cy;
// undo contain fit
const ix = (x - offX) / scale0;
const iy = (y - offY) / scale0;
return [ix, iy];
}
export function lineX(): number {
return STAGE_W / 2;
}
/** Image-space endpoints of the two ticks + span in px. */
export function tapeEndpoints(
tape: TapeTransform,
imgW: number,
imgH: number,
): { start: [number, number]; end: [number, number]; spanPx: number; ux: number; uy: number } {
const start = stageToImage(lineX(), tape.tickA, tape, imgW, imgH);
const end = stageToImage(lineX(), tape.tickB, tape, imgW, imgH);
const dx = end[0] - start[0];
const dy = end[1] - start[1];
const spanPx = Math.hypot(dx, dy);
const len = spanPx || 1;
return { start, end, spanPx, ux: dx / len, uy: dy / len };
}
export function clampTickPair(a: number, b: number, gap = 40): { tickA: number; tickB: number } {
let tickA = a;
let tickB = b;
if (tickB < tickA) {
const t = tickA;
tickA = tickB;
tickB = t;
}
const minY = 0;
const maxY = STAGE_H;
tickA = Math.max(minY, Math.min(tickA, maxY - gap));
tickB = Math.min(maxY, Math.max(tickB, tickA + gap));
return { tickA, tickB };
}