Fix the tangent angle at the curve's start and rotate cards to follow the curve direction. Add a stacking curve visualization shown with surface debug.
408 lines
12 KiB
TypeScript
408 lines
12 KiB
TypeScript
/**
|
||
* Stacking — the format's positioning process (`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 };
|
||
} |