/** * 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 }; }