clone

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

Rail frame law (M16 groundwork): plan-space rails + per-station affines

Dami committed e450578
Rails keep pts/tans/mods in PLAN space (+y up — the autopilot's one
truth); an optional per-rail/per-station Affine maps plan to presented
world. stationGravityDelta rotates the zone gravity through the frame
(wall-riders get pulled into their local floor while world gravity
stays fixed); stationWorldPoint/Tan serve respawns, camera, streaming.
Identity rails hit the exact old code paths.
commit
e450578704c34e10aa758862ec2fd013f8383442
parent
3d2985bf799b460dabb9052a66977f5ab320bf75
author
Dami <dami.etoile@gmail.com>
2 files changed, +105 -1
Msrc/client/physics/rail.ts62++++++++++++++++++++++++++++++-
Mtests/frame_test.ts44+++++++++++++++++++++
M src/client/physics/rail.ts +61 -1
@@ -17,6 +17,12 @@
1717 import type { LoopPath } from "../../shared/types.ts";1818 import { segLength, segPoint } from "../../gen/geom.ts";1919 import { assistForce, GRAVITY_Y, MARBLE_RESTITUTION } from "./engine.ts";20+import {21+ type Affine,22+ affineApply,23+ isIdentityAffine,24+ quatRotate,25+} from "./frame.ts";2026 2127 /** Resolved per-station physics modifier. */2228 export interface RailMod {
@@ -52,6 +58,55 @@ export interface Rail {
5258 /** Cumulative arc length, starting at 0 for this rail. */5359 s: number[];5460 mods: (RailMod | null)[];61+ /** Gravity-reorientation frames (M16): pts/tans/mods stay in PLAN62+ * space (+y up — the control laws' one truth); each station's affine63+ * maps plan → presented world. Undefined = identity (the common case);64+ * a single Affine = the whole rail shares one frame; an array = a65+ * twist zone varies along the rail. Drivers conjugate marble state66+ * through the frame — the autopilot never sees a wall or ceiling. */67+ frames?: Affine | (Affine | null)[];68+}69+70+/** Station frame (plan → world), or null for identity. */71+export function railFrameAt(rail: Rail, idx: number): Affine | null {72+ const f = rail.frames;73+ if (!f) return null;74+ if (Array.isArray(f)) return f[idx] ?? null;75+ return isIdentityAffine(f) ? null : f;76+}77+78+/** Station point in presented WORLD space (respawns, camera, streaming). */79+export function stationWorldPoint(80+ rail: Rail,81+ idx: number,82+ out: [number, number, number] = [0, 0, 0],83+): [number, number, number] {84+ const p = rail.pts[idx];85+ const f = railFrameAt(rail, idx);86+ if (!f) {87+ out[0] = p[0];88+ out[1] = p[1];89+ out[2] = p[2];90+ return out;91+ }92+ return affineApply(f, p, out);93+}94+95+/** Station tangent in presented WORLD space. */96+export function stationWorldTan(97+ rail: Rail,98+ idx: number,99+ out: [number, number, number] = [0, 0, 0],100+): [number, number, number] {101+ const t = rail.tans[idx];102+ const f = railFrameAt(rail, idx);103+ if (!f) {104+ out[0] = t[0];105+ out[1] = t[1];106+ out[2] = t[2];107+ return out;108+ }109+ return quatRotate(f.q, t, out);55110 }56111 57112 /**
@@ -183,13 +238,18 @@ export function stationGravity(
183238 /** Per-marble gravity-zone force (M15): with N marbles in one world the184239 * global gravity vector stays fixed at [0, GRAVITY_Y, 0]; a marble whose185240 * station calls for tilted gravity gets m·(g_zone − g_world) as an extra186- * force — identical acceleration, correct per marble. */241+ * force — identical acceleration, correct per marble. Reoriented frames242+ * (M16) compose here: the zone gravity (plan space, including any tilt)243+ * rotates through the station frame, so a wall-riding marble is pulled244+ * INTO its local floor while world gravity stays put. */187245 export function stationGravityDelta(188246 rail: Rail,189247 idx: number,190248 mass: number,191249 ): [number, number, number] | null {192250 const g = stationGravity(rail, idx);251+ const f = railFrameAt(rail, idx);252+ if (f) quatRotate(f.q, g, g);193253 if (g[0] === 0 && g[1] === GRAVITY_Y && g[2] === 0) return null;194254 return [g[0] * mass, (g[1] - GRAVITY_Y) * mass, g[2] * mass];195255 }
M tests/frame_test.ts +44 -0
@@ -11,6 +11,12 @@ import {
1111 quatRotate,1212 quatUnrotate,1313 } from "../src/client/physics/frame.ts";14+import {15+ type Rail,16+ stationGravityDelta,17+ stationWorldPoint,18+ stationWorldTan,19+} from "../src/client/physics/rail.ts";1420 1521 const close = (1622 a: readonly number[],
@@ -78,3 +84,41 @@ Deno.test("second twist axis follows the presented frame", () => {
7884 // Roll then unroll: back to identity orientation.7985 close(frameUp(f2), [0, 1, 0]);8086 });87+88+Deno.test("rail frames: gravity delta pulls a wall-rider into its floor", () => {89+ // One-station rail heading +x, rolled 90° about the travel axis: the90+ // marble rides what the world sees as a wall; local gravity must point91+ // along world −z (plan −y rotated), and the per-marble delta force is92+ // m·(F·g0 − g0).93+ const frame = composeTwist(AFFINE_IDENTITY, [1, 0, 0], Math.PI / 2, [94+ 0,95+ 0,96+ 0,97+ ]);98+ const rail: Rail = {99+ pts: [[0, 0, 0], [0.5, 0, 0]],100+ tans: [[1, 0, 0], [1, 0, 0]],101+ s: [0, 0.5],102+ mods: [null, null],103+ frames: frame,104+ };105+ const d = stationGravityDelta(rail, 0, 2);106+ if (!d) throw new Error("expected a delta in a reoriented frame");107+ close(d, [0, 40, -40]);108+ // Identity rail: no delta (the fast path must survive the frame hook).109+ close(110+ [111+ stationGravityDelta({ ...rail, frames: undefined }, 0, 2) === null112+ ? 1113+ : 0,114+ ],115+ [1],116+ );117+ // World helpers: a plan point 1 below the axis presents 1 to the side.118+ close(stationWorldPoint(rail, 0), [0, 0, 0]);119+ close(120+ stationWorldPoint({ ...rail, pts: [[0, -1, 0], [0.5, -1, 0]] }, 0),121+ [0, 0, -1],122+ );123+ close(stationWorldTan(rail, 0), [1, 0, 0]);124+});