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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user