Files
tts-workshop/packages/tabletop/src/stacking.ts
T
hypercross 345832e389 docs: reorganize docs into bgm and status folders
Group the bgm spec cluster under docs/bgm and move dev logs and plans under docs/status, add an overview index, and update cross-references in the docs, README, and source comments.
2026-08-16 11:57:19 +08:00

408 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 };
}