/** * Stacking — the format's positioning process (`docs/bgm/format.md` §4). * * Given a route's `stacking` strategy and a piece's position in its path's * stack, compute the offset/rotation to apply. Parts are spread along an SVG * `curve` relative to the route's anchor, `step length` apart, aligned per the * strategy. */ import { useMemo } from 'react'; import type { Stacking } from '@tts/bgm'; export interface StackOffset { /** x offset from the anchor. */ x: number; /** y offset from the anchor. */ y: number; /** Rotation in degrees. */ rotation: number; /** Vertical (surface-normal) offset from the anchor, in mm. */ z: number; /** Rotation in degrees about the card's local Y (long) axis. */ tilt: number; } /** The identity offset: no stacking applied. */ export const NO_OFFSET: StackOffset = { x: 0, y: 0, rotation: 0, z: 0, tilt: 0 }; /** * Compute the offset/rotation for the piece at `index` of a `stackSize`-piece * stack, given the route's stacking strategy. Returns `NO_OFFSET` when the * stack is empty. Every placed part gets a default 1° tilt unless overridden. */ export function stackingOffset( stacking: Stacking | undefined, index: number, stackSize: number, ): StackOffset { if (stackSize <= 0) return NO_OFFSET; // `limit` selects which pieces are shown; the offset is computed over the // shown span. `0` (or absent) shows all. const shown = applyLimit(stacking?.limit, stackSize); const shownIndex = shown.indexOf(index); if (shownIndex < 0) return NO_OFFSET; // `tilt` rotates each shown part about its local Y (long) axis by the same // amount. It applies even without a curve. Defaults to 1° when a stacking // strategy doesn't specify a tilt. const tilt = stacking?.tilt ?? 1; // The horizontal position along the curve (or a straight pile when there's // no curve), plus the normalized progress used to ramp the z height. let x = 0; let y = 0; let rotation = 0; let u = shown.length > 1 ? shownIndex / (shown.length - 1) : 0; if (stacking?.curve) { const curve = parsePath(stacking.curve); const length = curve.length; if (length > 0) { // Step length: curve length / max(steps, # parts − 1). A single part // sits at the start of the curve. const steps = stacking.steps ?? 1; const span = Math.max(steps, shown.length - 1); const step = length / span; // Alignment: how far the whole span is inset from the curve's start. const spanLength = step * (shown.length - 1); let start = 0; if (stacking.align === 'end') start = length - spanLength; else if (stacking.align === 'center') start = (length - spanLength) / 2; const distance = start + shownIndex * step; const point = pointAt(curve, distance); x = point.x; y = point.y; rotation = point.angle; u = distance / length; } } // The z height ramps linearly from `zStart` to `zEnd` across the curve's // span, lifting the stack in 3D. const zStart = stacking?.zStart ?? 0; const zEnd = stacking?.zEnd ?? 0; const z = zStart + (zEnd - zStart) * u; if (x === 0 && y === 0 && rotation === 0 && z === 0 && tilt === 0) return NO_OFFSET; return { x, y, rotation, z, tilt }; } /** The stacking hook: memoized `stackingOffset` for a piece. */ export function useStacking( stacking: Stacking | undefined, index: number, stackSize: number, ): StackOffset { return useMemo(() => stackingOffset(stacking, index, stackSize), [stacking, index, stackSize]); } /** Apply a stacking `limit` to a stack size, returning the shown indices. */ function applyLimit(limit: number | undefined, stackSize: number): number[] { const indices = Array.from({ length: stackSize }, (_, i) => i); if (!limit || limit === 0) return indices; if (limit > 0) return indices.slice(0, limit); return indices.slice(limit); } // --- SVG path sampling --- /** A sampled point along a path. */ interface PathPoint { x: number; y: number; /** Cumulative arc length from the path start. */ t: number; } /** A parsed path: a dense polyline approximation with cumulative lengths. */ interface SampledPath { points: PathPoint[]; length: number; } /** * Parse an SVG path `d` string into a dense polyline approximation. Supports * the common commands (M/L/H/V/C/S/Q/T/A/Z, absolute and relative). This is a * small, dependency-free helper for curve length and point-at-distance. */ export function parsePath(d: string): SampledPath { const tokens = tokenize(d); const points: PathPoint[] = []; let cx = 0; let cy = 0; let startX = 0; let startY = 0; let i = 0; let cmd = 'M'; const push = (x: number, y: number) => { cx = x; cy = y; points.push({ x, y, t: 0 }); }; const rel = (v: number, base: number) => (cmd === cmd.toLowerCase() ? base + v : v); while (i < tokens.length) { const tok = tokens[i]!; if (/[a-zA-Z]/.test(tok)) { cmd = tok; i++; // `Z` closes the path and takes no arguments; handle it immediately. if (cmd.toUpperCase() === 'Z') { push(startX, startY); continue; } continue; } const num = () => { const v = parseFloat(tokens[i]!); i++; return v; }; switch (cmd.toUpperCase()) { case 'M': { const x = rel(num(), cx); const y = rel(num(), cy); push(x, y); startX = x; startY = y; break; } case 'L': { const x = rel(num(), cx); const y = rel(num(), cy); push(x, y); break; } case 'H': { const x = rel(num(), cx); push(x, cy); break; } case 'V': { const y = rel(num(), cy); push(cx, y); break; } case 'C': { const x1 = rel(num(), cx); const y1 = rel(num(), cy); const x2 = rel(num(), cx); const y2 = rel(num(), cy); const x = rel(num(), cx); const y = rel(num(), cy); sampleCubic(points, cx, cy, x1, y1, x2, y2, x, y); push(x, y); break; } case 'S': { // Reflect the previous control point; without one, use the current point. const prev = points[points.length - 2]; const x1 = prev ? 2 * cx - prev.x : cx; const y1 = prev ? 2 * cy - prev.y : cy; const x2 = rel(num(), cx); const y2 = rel(num(), cy); const x = rel(num(), cx); const y = rel(num(), cy); sampleCubic(points, cx, cy, x1, y1, x2, y2, x, y); push(x, y); break; } case 'Q': { const x1 = rel(num(), cx); const y1 = rel(num(), cy); const x = rel(num(), cx); const y = rel(num(), cy); sampleQuadratic(points, cx, cy, x1, y1, x, y); push(x, y); break; } case 'T': { const prev = points[points.length - 2]; const x1 = prev ? 2 * cx - prev.x : cx; const y1 = prev ? 2 * cy - prev.y : cy; const x = rel(num(), cx); const y = rel(num(), cy); sampleQuadratic(points, cx, cy, x1, y1, x, y); push(x, y); break; } case 'A': { const rx = Math.abs(num()); const ry = Math.abs(num()); const rot = (num() * Math.PI) / 180; const largeArc = num() !== 0; const sweep = num() !== 0; const x = rel(num(), cx); const y = rel(num(), cy); sampleArc(points, cx, cy, rx, ry, rot, largeArc, sweep, x, y); push(x, y); break; } default: throw new Error(`Unsupported SVG path command: ${cmd}`); } } // Compute cumulative arc length. let t = 0; for (let k = 1; k < points.length; k++) { const a = points[k - 1]!; const b = points[k]!; t += Math.hypot(b.x - a.x, b.y - a.y); b.t = t; } return { points, length: t }; } /** Split a path `d` string into command letters and numbers. */ function tokenize(d: string): string[] { const out: string[] = []; const re = /([a-zA-Z])|(-?\d*\.?\d+(?:[eE][+-]?\d+)?)/g; let m: RegExpExecArray | null; while ((m = re.exec(d)) !== null) { out.push(m[1] ?? m[2]!); } return out; } /** Sample a cubic Bezier into the point list (excluding the endpoint). */ function sampleCubic( points: PathPoint[], x0: number, y0: number, x1: number, y1: number, x2: number, y2: number, x3: number, y3: number, ) { for (let s = 1; s < SEGMENTS; s++) { const u = s / SEGMENTS; const v = 1 - u; const x = v * v * v * x0 + 3 * v * v * u * x1 + 3 * v * u * u * x2 + u * u * u * x3; const y = v * v * v * y0 + 3 * v * v * u * y1 + 3 * v * u * u * y2 + u * u * u * y3; points.push({ x, y, t: 0 }); } } /** Sample a quadratic Bezier into the point list (excluding the endpoint). */ function sampleQuadratic( points: PathPoint[], x0: number, y0: number, x1: number, y1: number, x2: number, y2: number, ) { for (let s = 1; s < SEGMENTS; s++) { const u = s / SEGMENTS; const v = 1 - u; const x = v * v * x0 + 2 * v * u * x1 + u * u * x2; const y = v * v * y0 + 2 * v * u * y1 + u * u * y2; points.push({ x, y, t: 0 }); } } /** Sample an elliptical arc into the point list (excluding the endpoint). */ function sampleArc( points: PathPoint[], x0: number, y0: number, rx: number, ry: number, rot: number, largeArc: boolean, sweep: boolean, x1: number, y1: number, ) { // Convert endpoint parameterization to center parameterization. const dx = (x0 - x1) / 2; const dy = (y0 - y1) / 2; const cos = Math.cos(rot); const sin = Math.sin(rot); const px = cos * dx + sin * dy; const py = -sin * dx + cos * dy; const rx2 = rx * rx; const ry2 = ry * ry; const px2 = px * px; const py2 = py * py; const radicand = Math.max(0, (rx2 * ry2 - rx2 * py2 - ry2 * px2) / (rx2 * py2 + ry2 * px2)); const sign = largeArc !== sweep ? 1 : -1; const factor = sign * Math.sqrt(radicand); const cx = (factor * (rx * py)) / ry; const cy = (factor * (-ry * px)) / rx; const cxp = cx * cos - cy * sin + (x0 + x1) / 2; const cyp = cx * sin + cy * cos + (y0 + y1) / 2; const angle = (ux: number, uy: number, vx: number, vy: number) => { const dot = ux * vx + uy * vy; const len = Math.hypot(ux, uy) * Math.hypot(vx, vy); let a = Math.acos(Math.max(-1, Math.min(1, dot / len))); if (ux * vy - uy * vx < 0) a = -a; return a; }; const ux = (px - cx) / rx; const uy = (py - cy) / ry; const vx = (-px - cx) / rx; const vy = (-py - cy) / ry; let theta1 = angle(1, 0, ux, uy); let dtheta = angle(ux, uy, vx, vy); if (!sweep && dtheta > 0) dtheta -= Math.PI * 2; else if (sweep && dtheta < 0) dtheta += Math.PI * 2; for (let s = 1; s < SEGMENTS; s++) { const a = theta1 + (s / SEGMENTS) * dtheta; const cosA = Math.cos(a); const sinA = Math.sin(a); const x = cxp + rx * cosA * cos - ry * sinA * sin; const y = cyp + rx * cosA * sin + ry * sinA * cos; points.push({ x, y, t: 0 }); } } /** Sample density per curve segment. */ const SEGMENTS = 32; /** The point (and tangent angle in degrees) at a distance along a sampled path. */ export function pointAt(path: SampledPath, distance: number): { x: number; y: number; angle: number } { const { points, length } = path; if (points.length === 0) return { x: 0, y: 0, angle: 0 }; const d = Math.max(0, Math.min(distance, length)); if (points.length === 1) return { x: points[0]!.x, y: points[0]!.y, angle: 0 }; let lo = 0; let hi = points.length - 1; while (lo < hi) { const mid = (lo + hi) >> 1; if (points[mid]!.t < d) lo = mid + 1; else hi = mid; } const b = points[lo]!; const a = points[lo - 1] ?? b; const seg = b.t - a.t; const u = seg > 0 ? (d - a.t) / seg : 0; const x = a.x + (b.x - a.x) * u; const y = a.y + (b.y - a.y) * u; // The tangent direction in degrees, matching the format's angle units. At // the very start (lo === 0) the segment is degenerate (a === b), so fall // back to the first segment's direction instead of a 0° angle. const dx = b.x - a.x; const dy = b.y - a.y; const angle = lo === 0 && points.length > 1 ? (Math.atan2(points[1]!.y - points[0]!.y, points[1]!.x - points[0]!.x) * 180) / Math.PI : (Math.atan2(dy, dx) * 180) / Math.PI; return { x, y, angle }; }