323f29b649
- 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
118 lines
3.4 KiB
TypeScript
118 lines
3.4 KiB
TypeScript
/**
|
|
* 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 };
|
|
}
|