clone

an endless marble-race generator in the browser; the url is the seed, so every track can be shared

main
marbles / src / shared / frame.ts
frame.ts 6.2K
1/**2 * Frame math for gravity reorientation (M16): a reorient zone rolls the3 * stream about its travel axis, so every station downstream of a twist4 * carries a rigid AFFINE mapping plan space (the generator's +y-up world)5 * into presented world space. Rails, autopilot and all control math stay6 * in plan space — drivers conjugate marble state in, run the unchanged7 * laws, and rotate forces back out. Pure data + functions (no three.js):8 * one truth for the client driver and the headless tests.9 */1011/** Unit quaternion [x, y, z, w]. */12export type Quat = [number, number, number, number];1314/** Rigid transform: world = rotate(q, plan) + t. */15export interface Affine {16 q: Quat;17 t: [number, number, number];18}1920export const Q_IDENTITY: Quat = [0, 0, 0, 1];2122export const AFFINE_IDENTITY: Affine = {23 q: [0, 0, 0, 1],24 t: [0, 0, 0],25};2627export function quatFromAxisAngle(28 axis: readonly [number, number, number],29 angle: number,30): Quat {31 const h = angle / 2;32 const s = Math.sin(h);33 return [axis[0] * s, axis[1] * s, axis[2] * s, Math.cos(h)];34}3536export function quatMul(a: Quat, b: Quat): Quat {37 return [38 a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],39 a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],40 a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],41 a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2],42 ];43}4445/** Rotate vector v by quaternion q (v' = q v q⁻¹). */46export function quatRotate(47 q: Quat,48 v: readonly [number, number, number],49 out: [number, number, number] = [0, 0, 0],50): [number, number, number] {51 // t = 2 q_v × v; v' = v + q_w t + q_v × t52 const tx = 2 * (q[1] * v[2] - q[2] * v[1]);53 const ty = 2 * (q[2] * v[0] - q[0] * v[2]);54 const tz = 2 * (q[0] * v[1] - q[1] * v[0]);55 out[0] = v[0] + q[3] * tx + q[1] * tz - q[2] * ty;56 out[1] = v[1] + q[3] * ty + q[2] * tx - q[0] * tz;57 out[2] = v[2] + q[3] * tz + q[0] * ty - q[1] * tx;58 return out;59}6061/** Rotate v by the INVERSE of q (unit quats: conjugate). */62export function quatUnrotate(63 q: Quat,64 v: readonly [number, number, number],65 out: [number, number, number] = [0, 0, 0],66): [number, number, number] {67 return quatRotate([-q[0], -q[1], -q[2], q[3]], v, out);68}6970export function isIdentityQuat(q: Quat): boolean {71 return q[0] === 0 && q[1] === 0 && q[2] === 0;72}7374export function isIdentityAffine(a: Affine): boolean {75 return isIdentityQuat(a.q) && a.t[0] === 0 && a.t[1] === 0 && a.t[2] === 0;76}7778/** world = q·plan + t. */79export function affineApply(80 a: Affine,81 p: readonly [number, number, number],82 out: [number, number, number] = [0, 0, 0],83): [number, number, number] {84 quatRotate(a.q, p, out);85 out[0] += a.t[0];86 out[1] += a.t[1];87 out[2] += a.t[2];88 return out;89}9091/** plan = q⁻¹·(world − t). */92export function affineUnapply(93 a: Affine,94 w: readonly [number, number, number],95 out: [number, number, number] = [0, 0, 0],96): [number, number, number] {97 const v: [number, number, number] = [98 w[0] - a.t[0],99 w[1] - a.t[1],100 w[2] - a.t[2],101 ];102 return quatUnrotate(a.q, v, out);103}104105/**106 * Compose a twist onto a running frame: rotate everything downstream by107 * `angle` about the world-space `axisWorld` line through `pivotWorld`,108 * AFTER the existing frame. The pivot is the twist point itself, so the109 * channel centerline stays continuous through the roll.110 */111export function composeTwist(112 frame: Affine,113 axisWorld: readonly [number, number, number],114 angle: number,115 pivotWorld: readonly [number, number, number],116): Affine {117 const r = quatFromAxisAngle(axisWorld, angle);118 const q = quatMul(r, frame.q);119 // world' = r·(world − pivot) + pivot; world = q0·plan + t0120 // ⇒ t' = r·(t0 − pivot) + pivot121 const rt = quatRotate(r, [122 frame.t[0] - pivotWorld[0],123 frame.t[1] - pivotWorld[1],124 frame.t[2] - pivotWorld[2],125 ]);126 return {127 q,128 t: [rt[0] + pivotWorld[0], rt[1] + pivotWorld[1], rt[2] + pivotWorld[2]],129 };130}131132/** Local up (plan +y) expressed in world space. */133export function frameUp(134 a: Affine,135 out: [number, number, number] = [0, 0, 0],136): [number, number, number] {137 return quatRotate(a.q, [0, 1, 0], out);138}139140/** A segment's presentation frames (EndlessSegment.frame): entry/exit141 * affines in SCALED plan coordinates, plus the in-segment twist when a142 * reorient window lives here. Segments without any reorient history143 * omit the whole record (identity fast path). */144export interface SegmentFrame {145 entry: Affine;146 exit: Affine;147 twist?: {148 /** Roll angle (radians, signed) about the travel axis. */149 angle: number;150 /** Plan-space unit travel axis of the twist run. */151 axis: [number, number, number];152 /** Plan-space centerline point where the roll begins (scaled). */153 pivot: [number, number, number];154 /** Axial length of the twist run (world units, scaled). */155 len: number;156 /** Path-window range realizing the run (inclusive). */157 from: number;158 to: number;159 };160}161162/** Frame at axial parameter t (world units along the twist axis from the163 * pivot): entry before the run, exit after, a partial roll inside. */164export function segmentFrameAt(f: SegmentFrame, t: number): Affine {165 const tw = f.twist;166 if (!tw || t <= 0) return f.entry;167 if (t >= tw.len) return f.exit;168 const axisWorld = quatRotate(f.entry.q, tw.axis);169 const pivotWorld = affineApply(f.entry, tw.pivot);170 return composeTwist(f.entry, axisWorld, tw.angle * (t / tw.len), pivotWorld);171}172173/** Axial parameter of a plan-space point against the twist run (world174 * units; ≤0 before, ≥len after). Segments without a twist are entry175 * everywhere — callers should shortcut on f.twist. The field is a pure176 * axial blend and therefore CONTINUOUS everywhere — the one property a177 * presentation field must keep (an attempted far-lateral snap to the178 * end frames tore a seam through geometry straddling its boundary).179 * The PLANNER guarantees no non-run geometry of the segment projects180 * axially into the open window (path.ts reorient window veto), so the181 * blend region only ever contains the run's own corridor. */182export function twistParam(183 f: SegmentFrame,184 p: readonly [number, number, number],185): number {186 const tw = f.twist;187 if (!tw) return 0;188 return (p[0] - tw.pivot[0]) * tw.axis[0] +189 (p[1] - tw.pivot[1]) * tw.axis[1] +190 (p[2] - tw.pivot[2]) * tw.axis[2];191}