The real gravity feature (USER HIGHLIGHT): between islands the stream occasionally ROLLS about its travel axis — everything downstream presents rotated in world space (wall riding; 180 = ceiling world, where the line's net-descent reads as a world CLIMB). Design law: plan space never knows. Generation, occupancy, goldens and every control law stay +y-up; presentation is a per-station rigid frame. - shared/frame.ts: quat/affine math + composeTwist (pivot on the twist point keeps the centerline C0) + SegmentFrame law (segmentFrameAt / twistParam). Moved from client/physics (gen needs it). - path.ts tryReorient: straight void run (8 cells for +/-90, 12 for 180), fat swept-drum claim band, every cell reserved a bare straight; one per segment; weighted into the void rotation (0.45). - world.ts: cumulative presentation frame composes per reorient (as seed-pure as nextStartS); segments stamp entry/exit affines + twist data; the run's kit placements are dropped like jump gaps. - proxies worldify: ribbon verts transform per-vertex through the frame law (the twist run CORKSCREWS smoothly); rigid shapes rotate wholesale as full-quaternion hulls (shim box_q/bumper_q from the groundwork). - rail attachFrames: per-station affines + cached world points; concatRails carries frames (headless drivers). - endless.ts driver: decide() conjugates marble state into plan space, runs the UNCHANGED autopilot, rotates the force/jump back out; neighbors conjugate through pooled slots; falls detect in plan space (local down); stall hops fire along frameUp; respawns/grid spawns present through the station frame; nearest search on worldPts. - Render: per-segment groups carry the frame (twist segments split into entry/exit-framed halves); the twist renders the collision ribbon itself as a violet corkscrew chute (visual == collision by construction); samplePoint/sampleFrame present world samples (camera path, trail, decorative riders). - camera.ts (groundwork earlier): follow-cam up/boom/look ride the subject's local gravity frame, eased. - Headless harness now mirrors the client (worldified proxies, framed rails, conjugated control): the survivability corpus genuinely crosses twists (abs-1 composes -90 then +90); new scenario reorient proves wall-riding on presented geometry; reorient_test pins frame continuity, 180-ceiling law, twist-window placement drop. - Streams reshuffled (new weighted draw): goldens regenerated; scenario pins re-scanned (grav-scan-11 propeller, helix-scan-25, wide-scan-17 icy, wide-scan-21 gravity, jump-scan-4 + wide-scan-10 spurs). genVersion unchanged. 154 tests, check clean. Verified in-browser on #biome-3: chute corkscrews, camera rolls with the pack, the world past the twist presents sideways.
- commit
- 9a30853762936dee801c11685ab2c490735ee2d6
- parent
- 1908048aa4e43a322fb2aecc268a2b0a6423ff4c
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 3 | +- |
| M | src/ | 461 | ++++++++++++++++++++++++++---- |
| M | src/ | 4 | +- |
| M | src/ | 5 | +- |
| M | src/ | 45 | ++- |
| M | src/ | 60 | ++++- |
| M | src/ | 8 | +- |
| M | src/ | 5 | +- |
| M | src/ | 8 | +- |
| M | src/ | 89 | +++++- |
| M | src/ | 74 | ++++- |
| M | src/ | 76 | +++++- |
| M | src/ | 10 | +- |
| R | src/ | 47 | +++ |
| M | tests/ | 2 | +- |
| M | tests/ | 18 | +- |
| M | tests/ | 5 | +- |
| M | tests/ | 2 | +- |
| M | tests/ | 96 | +++--- |
| M | tests/ | 10 | +- |
| M | tests/ | 10 | +- |
| M | tests/ | 181 | +++++++++--- |
| A | tests/ | 99 | ++++++ |
| M | tests/ | 15 | +- |
| M | tests/ | 21 | +- |
| M | tests/ | 3 | +- |
| M | tools/ | 37 | ++- |
| M | tools/ | 2 | +- |
| M | tools/ | 22 | +- |
| M | tools/ | 37 | ++- |
| M | tools/ | 38 | +- |
@@ -161,8 +161,7 @@ export class AmbientAudio {
161161 private master: GainNode | null = null;162162 private padNodes: { osc: OscillatorNode[]; gain: GainNode } | null = null;163163 private airNodes: { gain: GainNode; filter: BiquadFilterNode } | null = null;164- private rollNodes: { gain: GainNode; filter: BiquadFilterNode } | null =165- null;164+ private rollNodes: { gain: GainNode; filter: BiquadFilterNode } | null = null;166165 private mood = "midnight";167166 enabled = false;168167 @@ -31,13 +31,17 @@ import {
3131 type PhysicsModule,3232 } from "./physics/engine.ts";3333 import {34+ attachFrames,3435 gapSegmentsOf,3536 hasAltBranch,3637 type Rail,3738 railForSegment,39+ railFrameAt,3840 stationForce,3941 stationGravityDelta,4042 stationRestitution,43+ stationWorldPoint,44+ stationWorldTan,4145 } from "./physics/rail.ts";4246 import {4347 autopilotForce,@@ -61,11 +65,24 @@ import {
6165 marbleCoreMesh,6266 marbleEnv,6367 } from "./marbleGlass.ts";68+import type { Placement } from "../shared/types.ts";69+import {70+ affineApply,71+ affineUnapply,72+ frameUp,73+ quatRotate,74+ quatUnrotate,75+ type SegmentFrame,76+ segmentFrameAt,77+ twistParam,78+} from "../shared/frame.ts";6479 import {6580 bumperProxies,81+ CHANNEL_PROFILE,6682 channelProxies,6783 islandProxies,6884 wideScalesOf,85+ worldifyProxies,6986 } from "../gen/endless/proxies.ts";7087 import { buildIslandInstanced, type InstancedBuild } from "./islandRender.ts";7188 import { FanField, fanVolumesOf } from "./fanField.ts";@@ -117,8 +134,13 @@ interface BuiltSegment {
117134 entry: { x: number; y: number; z: number; dir: number };118135 /** Per-build GPU state (island instance buffers + cloned materials). */119136 disposables: InstancedBuild[];120- /** Lowest channel base (world y) — physics fall detection. */137+ /** Lowest channel base (PLAN-space y, scaled) — fall detection compares138+ * in plan space (a reoriented racer "falls" along its local down). */121139 lowY: number;140+ /** Presentation frame (M16 gravity reorientation) — undefined = identity. */141+ frame?: SegmentFrame;142+ /** Exit-framed half of a twist segment (placements past the run). */143+ postGroup?: THREE.Group;122144 }123145 124146 interface UiRefs {@@ -150,6 +172,9 @@ export function bootEndless(
150172 trail: 7,151173 height: 3.1,152174 occluders: () => session?.islandMeshes() ?? [],175+ // Gravity reorientation (M16): the lens rolls with the subject's176+ // local gravity frame (eased inside camera.ts).177+ up: () => session?.cameraUp() ?? [0, 1, 0],153178 });154179 const setCamLabel = (mode: CamMode) => {155180 if (ui.camBtn) {@@ -443,6 +468,22 @@ class Session {
443468 private readonly fanField = new FanField();444469 /** Scratch for the per-decision wind force (allocation-free). */445470 private readonly windScratch: [number, number, number] = [0, 0, 0];471+ /** M16 conjugation scratch (reoriented segments): marble state maps472+ * into plan space for the control laws, forces map back out. */473+ private readonly planPos: [number, number, number] = [0, 0, 0];474+ private readonly planVel: [number, number, number] = [0, 0, 0];475+ private readonly planF: [number, number, number] = [0, 0, 0];476+ private readonly frameScratch: [number, number, number] = [0, 0, 0];477+ /** Pooled neighbor slots (separation partners conjugate per-racer). */478+ private readonly neighborPool: {479+ pos: [number, number, number];480+ bravery: number;481+ }[] = Array.from({ length: 4 }, () => ({482+ pos: [0, 0, 0] as [number, number, number],483+ bravery: 1,484+ }));485+ /** Local up of the camera subject (follow-cam rolls with the frame). */486+ private readonly camUp: [number, number, number] = [0, 1, 0];446487 /** Active diamond branch per segment — respawns alternate it, so a447488 * fall's retry may ride the OTHER branch (respawn variety). */448489 private readonly branchOf = new Map<number, "main" | "alt">();@@ -708,11 +749,22 @@ class Session {
708749 const { at, lane, tier } = slotPos(k);709750 const tt = rail.tans[at];710751 const nl = Math.hypot(tt[2], tt[0]) || 1;711- this.engine.spawnMarble(i, [752+ const gridPos: [number, number, number] = [712753 rail.pts[at][0] + (tt[2] / nl) * lane,713754 rail.pts[at][1] + 0.4 + tier * 0.35,714755 rail.pts[at][2] + (-tt[0] / nl) * lane,715- ], [tt[0] * 2.2, 0, tt[2] * 2.2]);756+ ];757+ const gridVel: [number, number, number] = [758+ tt[0] * 2.2,759+ 0,760+ tt[2] * 2.2,761+ ];762+ const gf = railFrameAt(rail, at);763+ if (gf) {764+ affineApply(gf, gridPos, gridPos);765+ quatRotate(gf.q, gridVel, gridVel);766+ }767+ this.engine.spawnMarble(i, gridPos, gridVel);716768 let obj: THREE.Group | null = null;717769 let pivot: THREE.Group | null = null;718770 let materials: THREE.Material[] = [];@@ -857,6 +909,36 @@ class Session {
857909 return this.engine !== null;858910 }859911 912+ /** Apply a built segment's presentation frame to a plan-space sample913+ * (camera path, trail, decorative riders live in world space). */914+ private presentSample(915+ seg: BuiltSegment,916+ pos: THREE.Vector3,917+ tan: THREE.Vector3 | null,918+ ): void {919+ const fr = seg.frame;920+ if (!fr) return;921+ this.frameScratch[0] = pos.x;922+ this.frameScratch[1] = pos.y;923+ this.frameScratch[2] = pos.z;924+ const f = fr.twist925+ ? segmentFrameAt(fr, twistParam(fr, this.frameScratch))926+ : fr.entry;927+ affineApply(f, this.frameScratch, this.frameScratch);928+ pos.set(this.frameScratch[0], this.frameScratch[1], this.frameScratch[2]);929+ if (tan) {930+ this.frameScratch[0] = tan.x;931+ this.frameScratch[1] = tan.y;932+ this.frameScratch[2] = tan.z;933+ quatRotate(f.q, this.frameScratch, this.frameScratch);934+ tan.set(935+ this.frameScratch[0],936+ this.frameScratch[1],937+ this.frameScratch[2],938+ );939+ }940+ }941+860942 /** World point at global arc length s (clamped to the built window). */861943 samplePoint(s: number, out: THREE.Vector3): boolean {862944 const seg = this.segmentAt(s);@@ -866,6 +948,7 @@ class Session {
866948 Math.max(0, (s - seg.startS) / seg.length),867949 );868950 seg.curve.getPointAt(u, out);951+ this.presentSample(seg, out, null);869952 return true;870953 }871954 @@ -876,9 +959,16 @@ class Session {
876959 const u = Math.min(1, Math.max(0, (s - seg.startS) / seg.length));877960 seg.curve.getPointAt(u, pos);878961 seg.curve.getTangentAt(u, tan);962+ this.presentSample(seg, pos, tan);879963 return true;880964 }881965 966+ /** Local up of the camera subject (M16 — the follow cam rolls with967+ * the subject's gravity frame). */968+ cameraUp(): readonly [number, number, number] {969+ return this.camUp;970+ }971+882972 /** Island instanced meshes of the built window (camera occluders). */883973 islandMeshes(): THREE.Object3D[] {884974 const out: THREE.Object3D[] = [];@@ -915,44 +1005,98 @@ class Session {
9151005 9161006 private build(i: number): BuiltSegment {9171007 const data = this.world.get(i);1008+ const fr = data.frame;9181009 const group = new THREE.Group();9191010 group.name = `segment:${i}`;1011+ // Gravity reorientation (M16): all segment data is PLAN-space; the1012+ // scene groups carry the presentation frame. A twist splits the1013+ // segment into an entry-framed group and an exit-framed group (the1014+ // run's own kit pieces were dropped — the corkscrew chute below is1015+ // the run's geometry).1016+ let postGroup: THREE.Group | null = null;1017+ if (fr) {1018+ group.quaternion.set(...fr.entry.q);1019+ group.position.set(...fr.entry.t);1020+ if (fr.twist) {1021+ postGroup = new THREE.Group();1022+ postGroup.name = `segment:${i}:post`;1023+ postGroup.quaternion.set(...fr.exit.q);1024+ postGroup.position.set(...fr.exit.t);1025+ }1026+ }1027+ const groupFor = (pos: readonly [number, number, number]): THREE.Group =>1028+ postGroup && fr!.twist && twistParam(fr!, pos) > fr!.twist.len / 21029+ ? postGroup1030+ : group;9201031 const spinners: Spinner[] = [];9211032 const missing = new Set<string>();922- instantiatePlacements(923- data.placements,924- this.assets,925- group,926- spinners,927- missing,928- (p) =>929- // Zone surfaces READ as their own pipe color regardless of930- // chapter color (icy = ice-pale, gravity = violet-charged).931- p.zone === "icy"932- ? ICE_PIPE933- : p.zone === "gravity"934- ? GRAVITY_PIPE935- : p.chapter === undefined936- ? null937- : this.chapterColor(p.chapter),938- );1033+ const pipeColorOf = (p: Placement) =>1034+ // Zone surfaces READ as their own pipe color regardless of1035+ // chapter color (icy = ice-pale, gravity = violet-charged).1036+ p.zone === "icy"1037+ ? ICE_PIPE1038+ : p.zone === "gravity"1039+ ? GRAVITY_PIPE1040+ : p.chapter === undefined1041+ ? null1042+ : this.chapterColor(p.chapter);1043+ if (postGroup) {1044+ instantiatePlacements(1045+ data.placements.filter((p) => groupFor(p.pos) === group),1046+ this.assets,1047+ group,1048+ spinners,1049+ missing,1050+ pipeColorOf,1051+ );1052+ instantiatePlacements(1053+ data.placements.filter((p) => groupFor(p.pos) === postGroup),1054+ this.assets,1055+ postGroup,1056+ spinners,1057+ missing,1058+ pipeColorOf,1059+ );1060+ } else {1061+ instantiatePlacements(1062+ data.placements,1063+ this.assets,1064+ group,1065+ spinners,1066+ missing,1067+ pipeColorOf,1068+ );1069+ }9391070 // Islands render INSTANCED with per-instance color variation (strata,940- // moss/rust accents, per-island foliage lean) — islandRender.ts.1071+ // moss/rust accents, per-island foliage lean) — islandRender.ts. An1072+ // island lives wholly on one side of a twist (the run cells are1073+ // exclusively claimed), so its whole build lands in one group.9411074 const disposables = data.islands.map((isle) =>942- buildIslandInstanced(isle, this.assets, group, missing)1075+ buildIslandInstanced(1076+ isle,1077+ this.assets,1078+ isle.placements.length > 01079+ ? groupFor(isle.placements[0].pos as [number, number, number])1080+ : group,1081+ missing,1082+ )9431083 );9441084 // Tunnel render variants (glass / wire) — position-hashed, visual9451085 // only, deterministic per seed.9461086 disposables.push(applyTunnelVariants(group, this.ctx.renderer));1087+ if (postGroup) {1088+ disposables.push(applyTunnelVariants(postGroup, this.ctx.renderer));1089+ }9471090 for (const id of missing) {9481091 console.warn(`[endless] unknown pieceId "${id}" — skipped`);9491092 }9501093 this.root.add(group);1094+ if (postGroup) this.root.add(postGroup);9511095 9521096 const curve = buildOpenCurve(data.path);9531097 const length = curve.getLength();9541098 if (this.engine) {955- this.engine.addSegment(i, [1099+ let proxies = [9561100 ...channelProxies(9571101 data.path,9581102 true,@@ -961,19 +1105,54 @@ class Session {
9611105 ),9621106 ...data.islands.flatMap((isle) => islandProxies(isle.placements)),9631107 ...bumperProxies(data.placements),964- ]);1108+ ];1109+ // Present in world space: the twist-run ribbon corkscrews between1110+ // the frames; rigid downstream shapes rotate wholesale.1111+ if (fr) proxies = worldifyProxies(proxies, fr);1112+ this.engine.addSegment(i, proxies);9651113 // Dense rail (points/tangents/s/mods) for nearest-point progress and9661114 // the assist force — the shared law (rail.ts), one truth with the9671115 // headless tests.968- this.rails.set(969- i,970- railForSegment(data, this.branchOf.get(i) === "alt"),971- );1116+ const rail = railForSegment(data, this.branchOf.get(i) === "alt");1117+ if (fr) attachFrames(rail, fr);1118+ this.rails.set(i, rail);9721119 // Fan wind capsules (M16): derived from the fan placements —9731120 // deterministic, no rng. Ledge fans face the channel, so their9741121 // capsules cross it (lateral gusts); wind-hinted fans carry their975- // own tuning (boost corridors, lip updrafts).976- this.fanField.addSegment(i, fanVolumesOf(data.placements));1122+ // own tuning (boost corridors, lip updrafts). Reoriented segments1123+ // blow their wind in the presented frame.1124+ let volumes = fanVolumesOf(data.placements);1125+ if (fr) {1126+ volumes = volumes.map((v) => {1127+ const vf = fr.twist1128+ ? segmentFrameAt(fr, twistParam(fr, v.origin))1129+ : fr.entry;1130+ return {1131+ ...v,1132+ origin: affineApply(vf, v.origin) as [number, number, number],1133+ dir: quatRotate(vf.q, v.dir) as [number, number, number],1134+ };1135+ });1136+ }1137+ this.fanField.addSegment(i, volumes);1138+ // The twist chute visual: the SAME cross-section law as the1139+ // collision ribbon, extruded along the rail's framed stations —1140+ // what you ride is what you hit. Violet-charged like gravity1141+ // zones (the set-piece vocabulary already reads that way).1142+ if (fr?.twist) {1143+ const mesh = this.buildTwistChute(rail, fr);1144+ if (mesh) {1145+ this.root.add(mesh);1146+ disposables.push({1147+ meshes: [],1148+ dispose: () => {1149+ this.root.remove(mesh);1150+ mesh.geometry.dispose();1151+ (mesh.material as THREE.Material).dispose();1152+ },1153+ } as unknown as InstancedBuild);1154+ }1155+ }9771156 }9781157 const prev = this.sMeta.get(i - 1);9791158 const startS = i === 0 ? 0 : (prev ? prev.start + prev.len : 0);@@ -998,6 +1177,8 @@ class Session {
9981177 },9991178 disposables,10001179 lowY: Math.min(...data.plan.cells.map((c) => c.baseY)) * 2,1180+ ...(fr ? { frame: fr } : {}),1181+ ...(postGroup ? { postGroup } : {}),10011182 };10021183 this.built.set(i, seg);10031184 this.islandsSeen += data.islands.length;@@ -1022,6 +1203,73 @@ class Session {
10221203 return seg;10231204 }10241205 1206+ /** The reorient run's chute: the channel cross-section (the collision1207+ * ribbon's own profile) extruded along the rail's FRAMED stations —1208+ * each ring rolls a little further, so the chute corkscrews from the1209+ * entry frame into the exit frame. What you ride is what you hit. */1210+ private buildTwistChute(rail: Rail, fr: SegmentFrame): THREE.Mesh | null {1211+ const tw = fr.twist!;1212+ const frames = rail.frames;1213+ if (!Array.isArray(frames)) return null;1214+ const ring: number[] = [];1215+ for (let i = 0; i < rail.pts.length; i++) {1216+ const t = twistParam(fr, rail.pts[i]);1217+ if (t < -0.6 || t > tw.len + 0.6) continue;1218+ ring.push(i);1219+ }1220+ if (ring.length < 2) return null;1221+ const N = CHANNEL_PROFILE.length;1222+ const positions = new Float32Array(ring.length * N * 3);1223+ const scratch: [number, number, number] = [0, 0, 0];1224+ let vi = 0;1225+ for (const i of ring) {1226+ const p = rail.pts[i];1227+ const tan = rail.tans[i];1228+ // The run is straight and flat in plan space — the lateral axis is1229+ // the horizontal perp of the tangent, up is +y.1230+ const nl = Math.hypot(tan[0], tan[2]) || 1;1231+ const ux = tan[2] / nl;1232+ const uz = -tan[0] / nl;1233+ const f = frames[i] ?? fr.entry;1234+ for (const [lat, up] of CHANNEL_PROFILE) {1235+ scratch[0] = p[0] + ux * lat;1236+ scratch[1] = p[1] + up;1237+ scratch[2] = p[2] + uz * lat;1238+ const w = affineApply(f, scratch);1239+ positions[vi++] = w[0];1240+ positions[vi++] = w[1];1241+ positions[vi++] = w[2];1242+ }1243+ }1244+ const indices: number[] = [];1245+ for (let r = 0; r < ring.length - 1; r++) {1246+ for (let c = 0; c < N - 1; c++) {1247+ const a = r * N + c;1248+ const b = a + 1;1249+ const c2 = a + N;1250+ const d = c2 + 1;1251+ indices.push(a, c2, b, b, c2, d);1252+ }1253+ }1254+ const geo = new THREE.BufferGeometry();1255+ geo.setAttribute("position", new THREE.BufferAttribute(positions, 3));1256+ geo.setIndex(indices);1257+ geo.computeVertexNormals();1258+ const mat = new THREE.MeshStandardMaterial({1259+ color: new THREE.Color(1260+ GRAVITY_PIPE[0] / 255,1261+ GRAVITY_PIPE[1] / 255,1262+ GRAVITY_PIPE[2] / 255,1263+ ),1264+ roughness: 0.55,1265+ metalness: 0.1,1266+ side: THREE.DoubleSide,1267+ });1268+ const mesh = new THREE.Mesh(geo, mat);1269+ mesh.name = "twist-chute";1270+ return mesh;1271+ }1272+10251273 private disposeSegment(seg: BuiltSegment): void {10261274 this.built.delete(seg.index);10271275 this.spawning.delete(seg.index);@@ -1046,6 +1294,21 @@ class Session {
10461294 ),10471295 disposables: seg.disposables,10481296 });1297+ if (seg.postGroup) {1298+ this.exiting.push({1299+ group: seg.postGroup,1300+ anim: new ExitAnim(1301+ seg.postGroup,1302+ new THREE.Vector3(1303+ entry.x * TRACK_SCALE,1304+ entry.y * TRACK_SCALE,1305+ entry.z * TRACK_SCALE,1306+ ),1307+ new THREE.Vector3(step[0], 0, step[1]),1308+ ),1309+ disposables: [],1310+ });1311+ }10491312 }10501313 10511314 /** Keep the render window [cur−BEHIND, cur+AHEAD] built. */@@ -1157,6 +1420,16 @@ class Session {
11571420 }11581421 engine.setRestitution(r.idx, stationRestitution(rail, r.nearest.idx));11591422 const nextRail = this.rails.get(r.nearest.seg + 1) ?? null;1423+ // M16 conjugation: on a reoriented segment the control laws run in1424+ // PLAN space — marble state maps in through the station frame, the1425+ // decided force maps back out. The autopilot never sees the wall.1426+ const frame = railFrameAt(rail, r.nearest.idx);1427+ let cpos: readonly [number, number, number] = r.pos;1428+ let cvel: readonly [number, number, number] = r.vel;1429+ if (frame) {1430+ cpos = affineUnapply(frame, r.pos, this.planPos);1431+ cvel = quatUnrotate(frame.q, r.vel, this.planVel);1432+ }11601433 let f: [number, number, number] | null;11611434 if (this.useAutopilot) {11621435 r.simT += FIXED_DT * K;@@ -1175,21 +1448,29 @@ class Session {
11751448 let gapAhead: number | null = null;11761449 let gapBehind: number | null = null;11771450 this.othersScratch.length = 0;1451+ const addNeighbor = (o: Racer) => {1452+ const slot = this.neighborPool[this.othersScratch.length];1453+ if (frame) {1454+ affineUnapply(frame, o.pos, slot.pos);1455+ } else {1456+ slot.pos[0] = o.pos[0];1457+ slot.pos[1] = o.pos[1];1458+ slot.pos[2] = o.pos[2];1459+ }1460+ slot.bravery = o.pilotEff.bravery;1461+ this.othersScratch.push(slot);1462+ };11781463 for (let k = k0 + 1; k < this.sorted.length && k <= k0 + 3; k++) {11791464 const o = this.sorted[k];11801465 const d = o.s - r.s;11811466 if (gapAhead === null && d > 0.05) gapAhead = d;1182- if (d < 2 && this.othersScratch.length < 4) {1183- this.othersScratch.push({ pos: o.pos, bravery: o.pilotEff.bravery });1184- }1467+ if (d < 2 && this.othersScratch.length < 4) addNeighbor(o);11851468 }11861469 for (let k = k0 - 1; k >= 0 && k >= k0 - 3; k--) {11871470 const o = this.sorted[k];11881471 const d = r.s - o.s;11891472 if (gapBehind === null && d > 0.05) gapBehind = d;1190- if (d < 2 && this.othersScratch.length < 4) {1191- this.othersScratch.push({ pos: o.pos, bravery: o.pilotEff.bravery });1192- }1473+ if (d < 2 && this.othersScratch.length < 4) addNeighbor(o);11931474 }11941475 const headroom =11951476 r.dbg.vSafe !== undefined && r.dbg.wishspeed !== undefined@@ -1206,8 +1487,8 @@ class Session {
12061487 f = autopilotForce(12071488 rail,12081489 r.nearest.idx,1209- r.pos,1210- r.vel,1490+ cpos,1491+ cvel,12111492 r.mass,12121493 nextRail,12131494 r.pilotEff,@@ -1218,13 +1499,14 @@ class Session {
12181499 const j = wantJump(12191500 rail,12201501 r.nearest.idx,1221- r.pos,1222- r.vel,1502+ cpos,1503+ cvel,12231504 nextRail,12241505 r.pilotEff,12251506 );12261507 if (j) {1227- engine.jump(r.idx, j[0], j[1]);1508+ const n = frame ? quatRotate(frame.q, j[0], this.frameScratch) : j[0];1509+ engine.jump(r.idx, n, j[1]);12281510 if (!r.wasHop && r.idx === 0) this.audio.cue("launch");12291511 r.wasHop = true;12301512 } else r.wasHop = false;@@ -1232,12 +1514,16 @@ class Session {
12321514 f = stationForce(12331515 rail,12341516 r.nearest.idx,1235- r.pos,1236- r.vel,1517+ cpos,1518+ cvel,12371519 r.mass,12381520 nextRail,12391521 );12401522 }1523+ // Rotate the plan-space control force into the presented world.1524+ if (f && frame) {1525+ f = quatRotate(frame.q, f, this.planF) as [number, number, number];1526+ }12411527 // Gravity zones as per-marble delta forces (world gravity is global12421528 // and fixed — one vector cannot serve N marbles).12431529 const gd = stationGravityDelta(rail, r.nearest.idx, r.mass);@@ -1418,8 +1704,14 @@ class Session {
14181704 }14191705 14201706 // Falls + stalls, per marble. The hero keeps the checkpoint1421- // escalation; peers respawn behind the pack tail.1422- const fell = r.pos[1] < lowest - 10;1707+ // escalation; peers respawn behind the pack tail. Reoriented1708+ // racers fall along their LOCAL down — compare in plan space1709+ // (lowY is plan-space by construction).1710+ const fFall = rail ? railFrameAt(rail, r.nearest.idx) : null;1711+ const planY = fFall1712+ ? affineUnapply(fFall, r.pos, this.frameScratch)[1]1713+ : r.pos[1];1714+ const fell = planY < lowest - 10;14231715 if (r.idx === 0) {14241716 if (fell) {14251717 heroFell = true;@@ -1436,7 +1728,11 @@ class Session {
14361728 this.stallClock > 2.5 && !this.stallHopped && this.useAutopilot14371729 ) {14381730 this.stallHopped = true;1439- engine.jump(0, [0, 1, 0], 4.2);1731+ engine.jump(1732+ 0,1733+ fFall ? frameUp(fFall, this.frameScratch) : [0, 1, 0],1734+ 4.2,1735+ );14401736 this.audio.cue("launch");14411737 }14421738 if (this.stallClock > 6) this.respawn();@@ -1452,7 +1748,11 @@ class Session {
14521748 r.stallClock += dt;14531749 if (r.stallClock > 2.5 && !r.stallHopped && this.useAutopilot) {14541750 r.stallHopped = true;1455- engine.jump(r.idx, [0, 1, 0], 4.2);1751+ engine.jump(1752+ r.idx,1753+ fFall ? frameUp(fFall, this.frameScratch) : [0, 1, 0],1754+ 4.2,1755+ );14561756 }14571757 }14581758 if (fell || r.stallClock > 6 || r.s < windowLoS - 2) {@@ -1483,6 +1783,16 @@ class Session {
14831783 followed = top.s > current.s + 0.75 ? top : current;14841784 }14851785 this.followedIdx = followed.idx;1786+ // Follow-cam local up (M16): the lens rolls with the subject's1787+ // gravity frame (camera.ts eases it; identity frames keep world +y).1788+ const fRail = this.rails.get(followed.nearest.seg);1789+ const fFrame = fRail ? railFrameAt(fRail, followed.nearest.idx) : null;1790+ if (fFrame) frameUp(fFrame, this.camUp);1791+ else {1792+ this.camUp[0] = 0;1793+ this.camUp[1] = 1;1794+ this.camUp[2] = 0;1795+ }14861796 this.speed = followed.speed;14871797 // The WORLD anchors to the HERO: streaming, culling, milestones and14881798 // respawn fill all key on marbleS = hero progress (ratcheted in the@@ -1593,15 +1903,21 @@ class Session {
15931903 const t = rail.tans[idx];15941904 const nl = Math.hypot(t[2], t[0]) || 1;15951905 const lane = ((r.idx % 5) - 2) * 0.13;1596- engine.spawnMarble(1597- r.idx,1598- [1599- rail.pts[idx][0] + (t[2] / nl) * lane,1600- rail.pts[idx][1] + 0.4 + (r.idx % 7) * 0.12,1601- rail.pts[idx][2] + (-t[0] / nl) * lane,1602- ],1603- [t[0] * 2.2, 0, t[2] * 2.2],1604- );1906+ // Compose the drop in PLAN space (lane beside the line, a hop above1907+ // the floor), then present it through the station frame — reoriented1908+ // respawns materialize beside their wall-riding pack.1909+ const spawnPos: [number, number, number] = [1910+ rail.pts[idx][0] + (t[2] / nl) * lane,1911+ rail.pts[idx][1] + 0.4 + (r.idx % 7) * 0.12,1912+ rail.pts[idx][2] + (-t[0] / nl) * lane,1913+ ];1914+ const spawnVel: [number, number, number] = [t[0] * 2.2, 0, t[2] * 2.2];1915+ const sf = railFrameAt(rail, idx);1916+ if (sf) {1917+ affineApply(sf, spawnPos, spawnPos);1918+ quatRotate(sf.q, spawnVel, spawnVel);1919+ }1920+ engine.spawnMarble(r.idx, spawnPos, spawnVel);16051921 r.nearest = { seg: seg.index, idx };16061922 r.s = seg.startS + rail.s[idx];16071923 r.lastProgressS = r.s;@@ -1722,10 +2038,13 @@ class Session {
17222038 const consider = (sIdx: number, from: number, to: number) => {17232039 const rail = this.rails.get(sIdx);17242040 if (!rail) return;2041+ // Reoriented rails compare in PRESENTED world space (worldPts is2042+ // the frame-applied cache); identity rails' pts ARE world.2043+ const pts = rail.worldPts ?? rail.pts;17252044 const lo = Math.max(0, from);1726- const hi = Math.min(rail.pts.length - 1, to);2045+ const hi = Math.min(pts.length - 1, to);17272046 for (let i = lo; i <= hi; i++) {1728- const r = rail.pts[i];2047+ const r = pts[i];17292048 const d = (r[0] - pos[0]) ** 2 + (r[1] - pos[1]) ** 2 +17302049 (r[2] - pos[2]) ** 2;17312050 if (d < bestD) {@@ -1778,16 +2097,26 @@ class Session {
17782097 if (hasAltBranch(data)) {17792098 const next = this.branchOf.get(cp.index) === "alt" ? "main" : "alt";17802099 this.branchOf.set(cp.index, next);1781- this.rails.set(cp.index, railForSegment(data, next === "alt"));2100+ const swapped = railForSegment(data, next === "alt");2101+ // The rebuilt branch rail must re-learn its presentation frames.2102+ if (data.frame) attachFrames(swapped, data.frame);2103+ this.rails.set(cp.index, swapped);17822104 }17832105 const rail = this.rails.get(cp.index);17842106 if (!rail) return;17852107 const t = rail.tans[0];1786- this.engine.spawnMarble(1787- 0,1788- [rail.pts[0][0], rail.pts[0][1] + 0.4, rail.pts[0][2]],1789- [t[0] * 2.2, 0, t[2] * 2.2],1790- );2108+ const spawnPos: [number, number, number] = [2109+ rail.pts[0][0],2110+ rail.pts[0][1] + 0.4,2111+ rail.pts[0][2],2112+ ];2113+ const spawnVel: [number, number, number] = [t[0] * 2.2, 0, t[2] * 2.2];2114+ const sf = railFrameAt(rail, 0);2115+ if (sf) {2116+ affineApply(sf, spawnPos, spawnPos);2117+ quatRotate(sf.q, spawnVel, spawnVel);2118+ }2119+ this.engine.spawnMarble(0, spawnPos, spawnVel);17912120 this.nearest = { seg: cp.index, idx: 0 };17922121 this.marbleS = cp.startS;17932122 const hero = this.racers[0];@@ -1908,6 +2237,10 @@ class Session {
19082237 for (const seg of [...this.built.values()]) {19092238 this.root.remove(seg.group);19102239 seg.group.clear();2240+ if (seg.postGroup) {2241+ this.root.remove(seg.postGroup);2242+ seg.postGroup.clear();2243+ }19112244 for (const d of seg.disposables) d.dispose();19122245 }19132246 this.built.clear();@@ -245,7 +245,9 @@ export class PeerRingCore {
245245 246246 const identity: PeerIdentity = {247247 meshId: rng.weighted(MESH_CHOICES),248- tint: rng.chance(0.25) ? null : drawTint(rng.float(), rng.float(), rng.float()),248+ tint: rng.chance(0.25)249+ ? null250+ : drawTint(rng.float(), rng.float(), rng.float()),249251 scale: 0.8 + rng.float() * 0.5,250252 lateral: (rng.chance(0.5) ? 1 : -1) * (0.06 + rng.float() * 0.18),251253 wobbleHz: 0.15 + rng.float() * 0.35,@@ -28,7 +28,7 @@
2828 * springs, descent pulls) stay shared with rail.ts — the autopilot only2929 * replaces the propulsion/braking half of stationForce.3030 */31-import { RAIL_STEP, type Rail, type RailMod } from "./rail.ts";31+import { type Rail, RAIL_STEP, type RailMod } from "./rail.ts";3232 import { GRAVITY_Y } from "./engine.ts";3333 import { rngFamily } from "../../gen/rng.ts";3434 @@ -396,7 +396,8 @@ export function autopilotForce(
396396 397397 // --- speed planning: online backward brake sweep over the lookahead ---398398 const sHere = rail.s[idx];399- const targetHere = CRUISE * (mod?.speed ?? 1) * (boosting ? pilot.boostMult : 1);399+ const targetHere = CRUISE * (mod?.speed ?? 1) *400+ (boosting ? pilot.boostMult : 1);400401 let vSafe = VMAX;401402 let climbTy = t[1];402403 let prev: readonly [number, number, number] = t;@@ -220,17 +220,48 @@ export class PhysicsEngine {
220220 continue;221221 }222222 if (p.kind === "bumper") {223- this.m._ph_add_bumper(223+ if (p.quat) {224+ this.m._ph_add_bumper_q(225+ p.center[0],226+ p.center[1],227+ p.center[2],228+ p.radius,229+ p.height,230+ p.restitution,231+ p.quat[0],232+ p.quat[1],233+ p.quat[2],234+ p.quat[3],235+ );236+ } else {237+ this.m._ph_add_bumper(238+ p.center[0],239+ p.center[1],240+ p.center[2],241+ p.radius,242+ p.height,243+ p.restitution,244+ );245+ }246+ continue;247+ }248+ // Reoriented chunks (M16) carry a full quaternion — box and slab249+ // land on the same oriented-hull path (a slab IS a box hull; its250+ // yaw/pitch were folded into the quat by worldifyProxies).251+ if (p.quat) {252+ this.m._ph_add_box_q(224253 p.center[0],225254 p.center[1],226255 p.center[2],227- p.radius,228- p.height,229- p.restitution,256+ p.half[0],257+ p.half[1],258+ p.half[2],259+ p.quat[0],260+ p.quat[1],261+ p.quat[2],262+ p.quat[3],230263 );231- continue;232- }233- if (p.kind === "box") {264+ } else if (p.kind === "box") {234265 this.m._ph_add_box(235266 p.center[0],236267 p.center[1],@@ -22,7 +22,10 @@ import {
2222 affineApply,2323 isIdentityAffine,2424 quatRotate,25-} from "./frame.ts";25+ type SegmentFrame,26+ segmentFrameAt,27+ twistParam,28+} from "../../shared/frame.ts";2629 2730 /** Resolved per-station physics modifier. */2831 export interface RailMod {@@ -65,6 +68,32 @@ export interface Rail {
6568 * twist zone varies along the rail. Drivers conjugate marble state6669 * through the frame — the autopilot never sees a wall or ceiling. */6770 frames?: Affine | (Affine | null)[];71+ /** Cached presented-world station points (filled by attachFrames) —72+ * nearest-station search and respawns compare in world space. */73+ worldPts?: [number, number, number][];74+}75+76+/** Attach a segment's presentation frames to its rail: per-station77+ * affines (a partial roll inside the twist run) + cached world points.78+ * Rails without reorient history skip this entirely. */79+export function attachFrames(rail: Rail, f: SegmentFrame): void {80+ if (!f.twist) {81+ if (isIdentityAffine(f.entry)) return;82+ rail.frames = f.entry;83+ rail.worldPts = rail.pts.map((p) =>84+ affineApply(f.entry, p) as [number, number, number]85+ );86+ return;87+ }88+ const frames: Affine[] = [];89+ const worldPts: [number, number, number][] = [];90+ for (const p of rail.pts) {91+ const fr = segmentFrameAt(f, twistParam(f, p));92+ frames.push(fr);93+ worldPts.push(affineApply(fr, p) as [number, number, number]);94+ }95+ rail.frames = frames;96+ rail.worldPts = worldPts;6897 }6998 7099 /** Station frame (plan → world), or null for identity. */@@ -81,6 +110,13 @@ export function stationWorldPoint(
81110 idx: number,82111 out: [number, number, number] = [0, 0, 0],83112 ): [number, number, number] {113+ const w = rail.worldPts?.[idx];114+ if (w) {115+ out[0] = w[0];116+ out[1] = w[1];117+ out[2] = w[2];118+ return out;119+ }84120 const p = rail.pts[idx];85121 const f = railFrameAt(rail, idx);86122 if (!f) {@@ -145,6 +181,10 @@ const ZONE_MODS: Record<string, RailMod> = {
145181 // branch mouth (the composite opens one wall there) and branch riders146182 // square on the diving board.147183 spur: { center: true },184+ // Reorient run (M16): the channel corkscrews around the centerline —185+ // plan-space centering (rotated to world by the frame) holds the186+ // marble on the line while the floor rolls under it.187+ reorient: { center: true },148188 };149189 150190 /** The station spacing every driver samples at. */@@ -227,15 +267,31 @@ export function railForSegment(seg: RailSource, useAlt = false): Rail {
227267 return rail;228268 }229269 230-/** Chain per-segment rails into one (headless drivers). */270+/** Chain per-segment rails into one (headless drivers). Presentation271+ * frames (M16) survive the concat: any framed input promotes the output272+ * to per-station frames (null = identity) + world points. */231273 export function concatRails(rails: readonly Rail[]): Rail {232274 const out: Rail = { pts: [], tans: [], s: [], mods: [] };233275 let base = 0;276+ const framed = rails.some((r) => r.frames !== undefined);277+ if (framed) {278+ out.frames = [];279+ out.worldPts = [];280+ }234281 for (const r of rails) {235282 out.pts.push(...r.pts);236283 out.tans.push(...r.tans);237284 for (const s of r.s) out.s.push(base + s);238285 out.mods.push(...r.mods);286+ if (framed) {287+ const frames = out.frames as (Affine | null)[];288+ for (let i = 0; i < r.pts.length; i++) {289+ frames.push(railFrameAt(r, i));290+ out.worldPts!.push(291+ r.worldPts?.[i] ?? (r.pts[i].slice() as [number, number, number]),292+ );293+ }294+ }239295 if (r.s.length > 0) base += r.s[r.s.length - 1] + RAIL_STEP;240296 }241297 return out;@@ -16,7 +16,9 @@ export interface Trail {
1616 dispose(): void;1717 }1818 19-export function createTrail(color: THREE.ColorRepresentation = 0x7fd4ff): Trail {19+export function createTrail(20+ color: THREE.ColorRepresentation = 0x7fd4ff,21+): Trail {2022 const positions = new Float32Array(MAX_POINTS * 2 * 3);2123 const colors = new Float32Array(MAX_POINTS * 2 * 4);2224 const index: number[] = [];@@ -89,7 +91,9 @@ export function createTrail(color: THREE.ColorRepresentation = 0x7fd4ff): Trail
8991 update(pos: THREE.Vector3, camera: THREE.Camera): void {9092 const head = history[0];9193 if (head && pos.distanceTo(head) > JUMP_RESET) history.length = 0;92- if (!history[0] || pos.distanceToSquared(history[0]) >= MIN_STEP * MIN_STEP) {94+ if (95+ !history[0] || pos.distanceToSquared(history[0]) >= MIN_STEP * MIN_STEP96+ ) {9397 history.unshift(pos.clone());9498 if (history.length > MAX_POINTS) history.length = MAX_POINTS;9599 } else {@@ -165,9 +165,8 @@ export function assemble(
165165 Array.from(166166 { length: def.moves.length },167167 (_, j) => win.start + j,168- ).every((idx) =>169- opts.localGroundY!(idx) === elevation.baseY[idx]170- ) && style.solidAt(0)168+ ).every((idx) => opts.localGroundY!(idx) === elevation.baseY[idx]) &&169+ style.solidAt(0)171170 : style.solidAt(baseY);172171 const useSolid = grounded && def.solidId !== undefined &&173172 (!def.solidExtends || solidSupported(win, xf));@@ -87,7 +87,9 @@ export function planIslandFurniture(
8787 // launch true, not pinball).8888 for (const crossing of plan.crossings) {8989 for (const w of windows) {90- if (w.start < crossing.start || w.start >= crossing.start + crossing.len) {90+ if (91+ w.start < crossing.start || w.start >= crossing.start + crossing.len92+ ) {9193 continue;9294 }9395 if (w.def.id !== "straight-wide") continue;@@ -140,7 +142,9 @@ export function planIslandFurniture(
140142 );141143 if (!hp) continue;142144 143- const r = rng.child(`isle/${crossing.id}/side/${sc.cell[0]},${sc.cell[1]}`);145+ const r = rng.child(146+ `isle/${crossing.id}/side/${sc.cell[0]},${sc.cell[1]}`,147+ );144148 const away: Dir = sc.side === "L"145149 ? turnLeft(host.dirIn)146150 : turnRight(host.dirIn);@@ -140,7 +140,18 @@ export interface IslandCrossing {
140140 export interface PlanMod {141141 start: number;142142 len: number;143- kind: "boost" | "jump" | "propeller" | "shock" | "icy" | "gravity" | "spur";143+ kind:144+ | "boost"145+ | "jump"146+ | "propeller"147+ | "shock"148+ | "icy"149+ | "gravity"150+ | "spur"151+ | "reorient";152+ /** Reorient only: signed roll (radians) about the run's travel axis —153+ * everything downstream presents rotated (wall/ceiling riding). */154+ angle?: number;144155 }145156 146157 /** Takeable spur drop (M13's loop-mode takeable.ts wired into the void):@@ -420,6 +431,7 @@ class SegmentBuilder {
420431 ["icy", 0.7],421432 ["gravity", 0.5],422433 ["spurDrop", 0.35],434+ ["reorient", 0.45],423435 ] as const,424436 );425437 const attempts: (() => boolean)[] = [];@@ -476,6 +488,8 @@ class SegmentBuilder {
476488 return this.tryGravity(rng);477489 case "spurDrop":478490 return this.trySpurDrop(rng);491+ case "reorient":492+ return this.tryReorient(rng);479493 }480494 for (const attempt of attempts) if (attempt()) return true;481495 return false;@@ -891,9 +905,7 @@ class SegmentBuilder {
891905 const probed = this.probe(steps);892906 if (!probed) return false;893907 const host = probed.cells[0];894- const w = side === "L"895- ? turnLeft(this.pose.dir)896- : turnRight(this.pose.dir);908+ const w = side === "L" ? turnLeft(this.pose.dir) : turnRight(this.pose.dir);897909 const [wx, wz] = DIR_STEP[w];898910 const bx = host.x + wx;899911 const bz = host.z + wz;@@ -933,6 +945,58 @@ class SegmentBuilder {
933945 return true;934946 }935947 948+ /**949+ * GRAVITY REORIENTATION RUN (M16 — the real gravity feature, USER950+ * HIGHLIGHT): a straight void run where the whole stream ROLLS about951+ * its travel axis by ±90° or 180° — every island, channel and marble952+ * downstream presents rotated in world space (wall/ceiling riding).953+ * Plan space never knows: generation, occupancy and the control laws954+ * stay +y-up; world.ts composes the presentation frame and the client955+ * conjugates marble state through it. Here we only walk the straight956+ * run with a FAT claim band (the rolling channel sweeps its walls957+ * around the centerline) and mark the window. The kit pieces over the958+ * run are dropped (world.ts) — the twist renders as the collision959+ * ribbon itself, corkscrewing between the two frames.960+ */961+ private hadReorient = false;962+ private tryReorient(rng: Rng): boolean {963+ // One per segment (the presentation frame carries a single twist);964+ // hero spawn runway: never open the very first segment mid-roll.965+ if (this.hadReorient) return false;966+ if (this.index === 0 && this.cells.length < 6) return false;967+ const angle = rng.weighted(968+ [969+ [Math.PI / 2, 2],970+ [-Math.PI / 2, 2],971+ [Math.PI, 1],972+ ] as const,973+ );974+ // Roll rate stays gentle: 90° over 8 cells, 180° over 12.975+ const len = Math.abs(angle) > Math.PI / 2 + 1e-6 ? 12 : 8;976+ const steps: Step[] = Array.from({ length: len }, () => ({977+ move: "F" as const,978+ // The rotating cross-section sweeps wall tips (±0.46 track) above979+ // AND below the floor line — claim the swept drum, not just the980+ // channel band.981+ band: [-0.75, CHANNEL_CLEARANCE] as const,982+ }));983+ const start = this.cells.length;984+ if (!this.commitSteps(steps)) return false;985+ this.mods.push({ start, len, kind: "reorient", angle });986+ // Pin every run cell to a bare straight: the twist ribbon assumes987+ // the centerline — no wave/arch/funnel may realize under the roll.988+ for (let j = 0; j < len; j++) {989+ this.reservations.push({990+ start: start + j,991+ len: 1,992+ pieceIds: ["straight"],993+ reversed: false,994+ });995+ }996+ this.hadReorient = true;997+ return true;998+ }999+9361000 /** Last resort when boxed in: steep 1.5 chute straight down and out. */9371001 private tryEscape(rng: Rng): boolean {9381002 for (const delta of ["1.5", "1", "0.5"]) {@@ -1015,8 +1079,7 @@ class SegmentBuilder {
10151079 // reaches BACK across the delta. Mass headroom −1 preserved.10161080 const lo = (k === 0 ? Math.min(0, signed) : Math.min(0, -signed)) -10171081 1;1018- const hi =1019- (k === 0 ? Math.max(0, signed) : Math.max(0, -signed)) +1082+ const hi = (k === 0 ? Math.max(0, signed) : Math.max(0, -signed)) +10201083 CHANNEL_CLEARANCE;10211084 slopeCells.set(j++, {10221085 move: "F",@@ -1132,9 +1195,7 @@ class SegmentBuilder {
11321195 /** True when [start, start+len) (absolute cell indices) overlaps any11331196 * committed reservation. */11341197 const overlapsReservation = (s0: number, len: number): boolean =>1135- this.reservations.some((r) =>1136- r.start < s0 + len && s0 < r.start + r.len1137- );1198+ this.reservations.some((r) => r.start < s0 + len && s0 < r.start + r.len);11381199 11391200 const branchCells: (readonly [number, number])[] = [];11401201 if (rng.child("diamond").chance(0.5)) {@@ -1308,10 +1369,12 @@ class SegmentBuilder {
13081369 cell.move !== "F" || cell.baseY !== profile[j] ||13091370 overlapsReservation(start + j, 1)13101371 ) continue;1311- for (const [side, dir] of [1312- ["L", turnLeft(cell.dirIn)],1313- ["R", turnRight(cell.dirIn)],1314- ] as const) {1372+ for (1373+ const [side, dir] of [1374+ ["L", turnLeft(cell.dirIn)],1375+ ["R", turnRight(cell.dirIn)],1376+ ] as const1377+ ) {13151378 const [dx, dz] = DIR_STEP[dir];13161379 const fx = cell.x + dx;13171380 const fz = cell.z + dz;@@ -16,14 +16,27 @@
1616 import type { LoopPath, Placement } from "../../shared/types.ts";1717 import { segLength, segPoint } from "../geom.ts";1818 import { TRACK_SCALE } from "./scale.ts";19+import {20+ affineApply,21+ isIdentityAffine,22+ type Quat,23+ quatFromAxisAngle,24+ quatMul,25+ type SegmentFrame,26+ segmentFrameAt,27+ twistParam,28+} from "../../shared/frame.ts";1929 20-/** Oriented slab: yaw about +y then pitch about local +x (shim contract). */30+/** Oriented slab: yaw about +y then pitch about local +x (shim contract).31+ * `quat` (M16 reoriented chunks) supersedes yaw/pitch when present —32+ * worldifyProxies folds them into one full orientation. */2133 export interface SlabProxy {2234 kind: "slab";2335 center: readonly [number, number, number];2436 half: readonly [number, number, number];2537 yaw: number;2638 pitch: number;39+ quat?: readonly [number, number, number, number];2740 }2841 2942 /** Axis-aligned box (island blocks), yaw in quarter turns. */@@ -31,6 +44,7 @@ export interface BoxProxy {
3144 kind: "box";3245 center: readonly [number, number, number];3346 half: readonly [number, number, number];47+ quat?: readonly [number, number, number, number];3448 }3549 3650 /** Smooth channel ribbon: one triangle mesh per contiguous strip (M11 —@@ -54,10 +68,57 @@ export interface BumperProxy {
5468 radius: number;5569 height: number;5670 restitution: number;71+ quat?: readonly [number, number, number, number];5772 }5873 5974 export type CollisionProxy = SlabProxy | BoxProxy | MeshProxy | BumperProxy;6075 76+/**77+ * Present plan-space proxies in world space (M16 gravity reorientation):78+ * mesh vertices transform per-vertex through the segment's frame law (a79+ * ribbon crossing the twist run corkscrews smoothly between the two80+ * frames); rigid shapes (slabs/boxes/bumpers) pick the frame at their81+ * CENTER (set-pieces never straddle the run — its cells are reserved82+ * bare straights) and carry the full orientation as a quaternion.83+ * Identity frames return the input untouched.84+ */85+export function worldifyProxies(86+ proxies: readonly CollisionProxy[],87+ f: SegmentFrame,88+): CollisionProxy[] {89+ if (!f.twist && isIdentityAffine(f.entry)) return [...proxies];90+ const frameAt = (p: readonly [number, number, number]) =>91+ f.twist ? segmentFrameAt(f, twistParam(f, p)) : f.entry;92+ return proxies.map((p): CollisionProxy => {93+ if (p.kind === "mesh") {94+ const v = p.vertices;95+ const out = new Array<number>(v.length);96+ const scratch: [number, number, number] = [0, 0, 0];97+ for (let i = 0; i < v.length; i += 3) {98+ scratch[0] = v[i];99+ scratch[1] = v[i + 1];100+ scratch[2] = v[i + 2];101+ const w = affineApply(frameAt(scratch), scratch);102+ out[i] = w[0];103+ out[i + 1] = w[1];104+ out[i + 2] = w[2];105+ }106+ return { ...p, vertices: out };107+ }108+ const fr = frameAt(p.center);109+ if (isIdentityAffine(fr)) return p;110+ const center = affineApply(fr, p.center) as [number, number, number];111+ if (p.kind === "bumper") return { ...p, center, quat: fr.q };112+ if (p.kind === "box") return { ...p, center, quat: fr.q };113+ // Slab: fold its own yaw·pitch into the frame orientation.114+ const local = quatMul(115+ quatFromAxisAngle([0, 1, 0], p.yaw),116+ quatFromAxisAngle([1, 0, 0], p.pitch),117+ );118+ return { ...p, center, quat: quatMul(fr.q, local) as Quat };119+ });120+}121+61122 /** Bumper collision sizing, world units: the mushroom prop at the62123 * furniture scale (cap radius ≈ 0.18, height ≈ 0.41) with the hull a hair63124 * inside the cap so the marble bounces off the visible rim. */@@ -87,7 +148,7 @@ export function bumperProxies(
87148 /** Channel cross-section half-profile (lateral, up) in world units at the88149 * slalom scale: flat floor, gutter fillets, near-vertical walls with a89150 * slight outward flare — the shape a marble rolls smoothly in. */90-const PROFILE: readonly (readonly [number, number])[] = [151+export const CHANNEL_PROFILE: readonly (readonly [number, number])[] = [91152 [-0.92, 1.1],92153 [-0.86, 0.66],93154 [-0.80, 0.52],@@ -694,7 +755,7 @@ function ribbonOf(
694755 ): MeshProxy {695756 const vertices: number[] = [];696757 const indices: number[] = [];697- const N = PROFILE.length;758+ const N = CHANNEL_PROFILE.length;698759 let last: Station | null = null;699760 const rings: Station[] = [];700761 // Boundary rings: stations are mid-samples, so the strip would stop@@ -784,9 +845,8 @@ function ribbonOf(
784845 // Catch-mouth flare: lateral scale ramps 1.35 → 1 over the first785846 // 2 world units (heights untouched — the floor band widens and the786847 // gutter walls converge downstream).787- const latScale = (flareStart788- ? 1 + 0.35 * Math.max(0, 1 - arc / 2.0)789- : 1) * st.wScale;848+ const latScale = (flareStart ? 1 + 0.35 * Math.max(0, 1 - arc / 2.0) : 1) *849+ st.wScale;790850 // Frame from the true tangent: right = up × t, upV = t × right.791851 const ty = Math.sin(-st.pitch);792852 const th = Math.cos(st.pitch);@@ -801,7 +861,7 @@ function ribbonOf(
801861 t[2] * right[0] - t[0] * right[2],802862 t[0] * right[1] - t[1] * right[0],803863 ];804- for (const [lat, up] of PROFILE) {864+ for (const [lat, up] of CHANNEL_PROFILE) {805865 vertices.push(806866 st.p[0] + right[0] * lat * latScale + upV[0] * up,807867 st.p[1] + right[1] * lat * latScale + upV[1] * up,@@ -34,9 +34,19 @@ import {
3434 applyXform,3535 DIR_STEP,3636 segLength,37+ segPoint,3738 turnRight,3839 windowXform,3940 } from "../geom.ts";41+import {42+ type Affine,43+ AFFINE_IDENTITY,44+ affineApply,45+ composeTwist,46+ isIdentityAffine,47+ quatRotate,48+ type SegmentFrame,49+} from "../../shared/frame.ts";4050 import { CHANNEL_CLEARANCE, Occupancy, OccupancyTx } from "./occupancy.ts";4151 import { scaleSeg, scaleVec, TRACK_SCALE } from "./scale.ts";4252 import {@@ -72,6 +82,11 @@ export interface EndlessSegment {
7282 islands: Island[];7383 /** Cumulative arc length of the line at this segment's entry. */7484 startS: number;85+ /** Presentation frame (M16 gravity reorientation): how this segment's86+ * plan-space data maps into world space. Omitted = identity (no87+ * reorient history). All plan/path/placement data above stays88+ * plan-space +y-up regardless. */89+ frame?: SegmentFrame;7590 }7691 7792 const key = (x: number, z: number) => `${x},${z}`;@@ -96,6 +111,9 @@ export class EndlessWorld {
96111 private firstLive = 0;97112 /** Cumulative arc length at the head — survives culling. */98113 private nextStartS = 0;114+ /** Cumulative presentation frame (M16) — reorient runs compose onto115+ * it; as seed-pure as nextStartS (generation is strictly forward). */116+ private frame: Affine = AFFINE_IDENTITY;99117 100118 constructor(seed: string, catalog: DecorCatalog = { packs: [] }) {101119 this.seed = seed;@@ -374,9 +392,7 @@ export class EndlessWorld {
374392 // the segment's own receiver channel Δ exactly 1.0 below.375393 const spurPieces: { placement: Placement; host: number }[] = [];376394 for (const spur of plan.spurs) {377- const wi = windows.findIndex((w) =>378- w.start === spur.host && w.len === 1379- );395+ const wi = windows.findIndex((w) => w.start === spur.host && w.len === 1);380396 if (wi < 0) continue; // reserved by the planner — always found381397 const ls = assembled.loop.segments[wi];382398 const hp = ls.placementIndex >= 0@@ -466,6 +482,7 @@ export class EndlessWorld {
466482 const MOD_PRIORITY: readonly string[] = [467483 "jump",468484 "propeller",485+ "reorient",469486 "spur",470487 "boost",471488 "gravity",@@ -514,7 +531,12 @@ export class EndlessWorld {
514531 const ch = cellChapter(windows[wi].start);515532 for (let k = startIdx; k < end; k++) {516533 chapterOf[k] = ch;517- if (windowMods[wi] === "jump" || windowMods[wi] === "propeller") {534+ if (535+ windowMods[wi] === "jump" || windowMods[wi] === "propeller" ||536+ windowMods[wi] === "reorient"537+ ) {538+ // Reorient runs drop their kit pieces too — the corkscrewing539+ // collision ribbon is rendered directly (visual == collision).518540 dropped.add(k);519541 }520542 if (windowMods[wi] === "icy" || windowMods[wi] === "gravity") {@@ -627,6 +649,51 @@ export class EndlessWorld {
627649 totalLength: assembled.loop.totalLength * TRACK_SCALE,628650 };629651 652+ // Gravity reorientation (M16): a reorient run ROLLS everything653+ // downstream about its travel axis. The cumulative presentation654+ // frame is composed here — culling never rewinds generation, so the655+ // running frame is as seed-pure as nextStartS. Plan data stays656+ // +y-up; only the stamped frame record tells the client how this657+ // segment PRESENTS.658+ const entryFrame = this.frame;659+ let frame: SegmentFrame | undefined;660+ const reorient = plan.mods.find((m) => m.kind === "reorient");661+ if (reorient && reorient.angle !== undefined) {662+ const from = windows.findIndex((w) => w.start === reorient.start);663+ let to = from;664+ while (665+ to + 1 < windows.length &&666+ windows[to + 1].start < reorient.start + reorient.len667+ ) to++;668+ if (from < 0) {669+ throw new Error(670+ `reorient window missing at segment ${i} (seed ${this.seed})`,671+ );672+ }673+ let len = 0;674+ for (let k = from; k <= to; k++) len += path.segments[k].length;675+ const first = path.segments[from].curve[0];676+ const p0 = segPoint(first, 0);677+ const cell = plan.cells[reorient.start];678+ const [dx, dz] = DIR_STEP[cell.dirIn];679+ const axis: [number, number, number] = [dx, 0, dz];680+ const pivot: [number, number, number] = [p0[0], p0[1], p0[2]];681+ const exit = composeTwist(682+ entryFrame,683+ quatRotate(entryFrame.q, axis),684+ reorient.angle,685+ affineApply(entryFrame, pivot),686+ );687+ frame = {688+ entry: entryFrame,689+ exit,690+ twist: { angle: reorient.angle, axis, pivot, len, from, to },691+ };692+ this.frame = exit;693+ } else if (!isIdentityAffine(entryFrame)) {694+ frame = { entry: entryFrame, exit: entryFrame };695+ }696+630697 this.segments.push({631698 index: i,632699 plan,@@ -637,6 +704,7 @@ export class EndlessWorld {
637704 path,638705 islands,639706 startS: this.nextStartS,707+ ...(frame ? { frame } : {}),640708 });641709 this.nextStartS += path.totalLength;642710 this.cullBefore(i - KEEP_SEGMENTS);@@ -204,7 +204,15 @@ export function planTakeableSpurs(
204204 const supportPrefix = style.supportColor ? "support-color" : "support";205205 const supportId = (segment: string) =>206206 `${supportPrefix}-${style.supportKind}-${segment}`;207- emitSupportStack(spurPlacements, supportId, at(1)[0], at(1)[1], y, w, false);207+ emitSupportStack(208+ spurPlacements,209+ supportId,210+ at(1)[0],211+ at(1)[1],212+ y,213+ w,214+ false,215+ );208216 emitSupportStack(spurPlacements, supportId, at(4)[0], at(4)[1], y, w, true);209217 for (let n = 1; n <= 4; n++) cells.push(key(...at(n)));210218 tip = at(3);@@ -136,3 +136,50 @@ export function frameUp(
136136 ): [number, number, number] {137137 return quatRotate(a.q, [0, 1, 0], out);138138 }139+140+/** 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). */144+export 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+}161+162+/** Frame at axial parameter t (world units along the twist axis from the163+ * pivot): entry before the run, exit after, a partial roll inside. */164+export 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+}172+173+/** 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. */176+export function twistParam(177+ f: SegmentFrame,178+ p: readonly [number, number, number],179+): number {180+ const tw = f.twist;181+ if (!tw) return 0;182+ return (p[0] - tw.pivot[0]) * tw.axis[0] +183+ (p[1] - tw.pivot[1]) * tw.axis[1] +184+ (p[2] - tw.pivot[2]) * tw.axis[2];185+}@@ -173,7 +173,14 @@ Deno.test("boost economy: spend on triggers with headroom, recharge on restraint
173173 st = boostStep(st, DEFAULT_PILOT, dt, 4, 5, null);174174 assert(st.fuel < f0, "burning must drain fuel");175175 // …and the envelope capping the boost ends the burn (corner ahead).176- st = boostStep({ fuel: 0.8, boosting: true }, DEFAULT_PILOT, dt, 0.05, 5, null);176+ st = boostStep(177+ { fuel: 0.8, boosting: true },178+ DEFAULT_PILOT,179+ dt,180+ 0.05,181+ 5,182+ null,183+ );177184 assert(!st.boosting, "a capped envelope must end the burn");178185 // An empty tank ends the burn.179186 st = boostStep({ fuel: 0.01, boosting: true }, DEFAULT_PILOT, dt, 4, 5, null);@@ -182,7 +189,14 @@ Deno.test("boost economy: spend on triggers with headroom, recharge on restraint
182189 st = boostStep({ fuel: 0.5, boosting: false }, DEFAULT_PILOT, dt, 4, 20, 20);183190 assert(!st.boosting && st.fuel > 0.5, "restraint must recharge");184191 // Overflow: a full tank on open track bleeds even without rivals.185- st = boostStep({ fuel: 1, boosting: false }, DEFAULT_PILOT, dt, 4, null, null);192+ st = boostStep(193+ { fuel: 1, boosting: false },194+ DEFAULT_PILOT,195+ dt,196+ 4,197+ null,198+ null,199+ );186200 assert(st.boosting, "an overflowing tank must spend");187201 // No headroom (curve ahead): never ignite, whatever the trigger.188202 st = boostStep({ fuel: 1, boosting: false }, DEFAULT_PILOT, dt, 0.2, 2, 1);@@ -63,7 +63,10 @@ Deno.test("endless: 20-seed fuzz — continuity, clearance, anchors, memory", ()
6363 // guarantee holds among CO-LIVE segments (within KEEP_SEGMENTS). The6464 // line may legally reoccupy long-culled space — that geometry was6565 // despawned well behind the render window, so nothing visible overlaps.66- const channels = new Map<string, { y: number; seg: number; idx: number }[]>();66+ const channels = new Map<67+ string,68+ { y: number; seg: number; idx: number }[]69+ >();6770 6871 let prevEnd: readonly [number, number, number] | null = null;6972 let prevExit: { x: number; z: number } | null = null;@@ -1,59 +1,59 @@
11 {22 "__version": "g535114c0fc9e",33 "alpha": [4- "620a7e82882399f6",5- "d3f1bd21fa5acba0",6- "c6780404ffa88a97",7- "92d303076b7dfc98",8- "8eee50563239ce82",9- "3f359c281e9fc492",10- "0f29d59a52befa82",11- "6b86644211e6720d",12- "4e5fce22fcf780ec",13- "4695f8dfb5896376",14- "be86dba6b6db3e4c",15- "924b5a7059613da6"4+ "52272b9e11d63b0d",5+ "41b8e0bb30161b61",6+ "8d6e8787aedd31f7",7+ "5539b36b00ef8fe6",8+ "478120c5bece97f8",9+ "c4f276bfb88bcafc",10+ "7570b3f1a7694801",11+ "82e226841c07ecc9",12+ "c0039a293491de03",13+ "52022f6fe5ad9b72",14+ "d49af4a43fc95c0f",15+ "fe629be3ce28e969"1616 ],1717 "zx9k2m4p1q": [18- "090f0e78413a17b2",19- "31b77d36ab040fe0",20- "e0ce61bd54a53b95",21- "67333b162cb56d77",22- "2436a657bd6e30c9",23- "b5dd98cc25a8a812",24- "11a664e9c7d97e71",25- "2895507c98512dca",26- "2bd41b506600fb04",27- "5a1039071f7ab20d",28- "5291aa43b4e01ff4",29- "bec4fa5d4178d57f"18+ "74fb045a2dda1057",19+ "4eb8b28c68379765",20+ "6010ad69646d8ed9",21+ "6169c35d49f509b3",22+ "5290df1210145737",23+ "347a60354030e565",24+ "868555782745f932",25+ "550c15fb4d8040ba",26+ "336ed3eaf267a0c4",27+ "e61535a7055d7907",28+ "b252538c97a9da30",29+ "138344911f0f0517"3030 ],3131 "dami": [32- "6384b026c8b9cac1",33- "743336240edb75ae",34- "974f3c577308cbfd",35- "4f26ee6b0126f788",36- "63ba72b805927e64",37- "a3b008bef7070780",38- "90de8a43783be44a",39- "e47b29a9abe27af4",40- "6211aa22111e0ffa",41- "29bc7feab695757f",42- "aac26fdac0998105",43- "79cab7e1aead250e"32+ "9c8776c4a3ed2ffe",33+ "ab9be2e024b6eff0",34+ "5fe8e5ec24136751",35+ "e036d1b06b43b845",36+ "145a2a615f4fa375",37+ "eb3ae2074729e6e4",38+ "a4a999cc4a8d2d0c",39+ "1806f6531ed7526f",40+ "8b7cd798b47683b4",41+ "75287a3932b0fa57",42+ "e9eb0c2f232a38a1",43+ "6afe3104d1270670"4444 ],4545 "marbles": [46- "170f1cf2a52888ed",47- "be8fc17fe5fc1022",48- "12826f679848dbc6",49- "c69b253404805da3",50- "e88cdad798736c42",51- "b77edb0c8fabde6f",52- "75b641db6d06f0e9",53- "863cd5cef9d23e2c",54- "0b8d4c23cb422194",55- "8ef83aff9e547390",56- "f8149e3f4d64aa02",57- "1a5e55de24296ffc"46+ "95b5cab70b08812f",47+ "ed2bf534893f4e67",48+ "78c593ad1269a98d",49+ "8c8fd758c561218e",50+ "ef75023d88f58e8d",51+ "550e77dd221c4365",52+ "9d61d8a5589b9d79",53+ "9df7c8cc1a5352fc",54+ "494384fba6c4ff64",55+ "ce656a1181dff206",56+ "e1d4b67f738015fa",57+ "4028db0d0152ad0d"5858 ]5959 }@@ -164,8 +164,9 @@ function race(
164164 if (o === r || fell[oi]) continue;165165 others.push({ pos: states[oi].pos, bravery: o.pilot.bravery });166166 const d = o.s - r.s;167- if (d > 0.05) gapAhead = gapAhead === null ? d : Math.min(gapAhead, d);168- else if (d < -0.05) {167+ if (d > 0.05) {168+ gapAhead = gapAhead === null ? d : Math.min(gapAhead, d);169+ } else if (d < -0.05) {169170 gapBehind = gapBehind === null ? -d : Math.min(gapBehind, -d);170171 }171172 }@@ -311,7 +312,10 @@ Deno.test("respawn ghost: materializing inside the pack kicks nobody, then resol
311312 st.vel[1] - before[k].vel[1],312313 st.vel[2] - before[k].vel[2],313314 );314- assert(dv < 0.6, `neighbor ${i} kicked by ghost spawn (|dv| ${dv.toFixed(2)})`);315+ assert(316+ dv < 0.6,317+ `neighbor ${i} kicked by ghost spawn (|dv| ${dv.toFixed(2)})`,318+ );315319 }316320 317321 // (b) It clears the pack and resolidifies within the 4s hard cap,@@ -154,13 +154,19 @@ Deno.test("peers: behaviour envelopes hold across many draws", () => {
154154 assert(b.gravity > 0 && b.drag > 0);155155 const id = p.identity;156156 assert(id.scale >= 0.8 && id.scale <= 1.3 + 1e-9);157- assert(Math.abs(id.lateral) >= 0.06 - 1e-9 && Math.abs(id.lateral) <= 0.24 + 1e-9);157+ assert(158+ Math.abs(id.lateral) >= 0.06 - 1e-9 &&159+ Math.abs(id.lateral) <= 0.24 + 1e-9,160+ );158161 if (id.tint) {159162 for (const c of id.tint) assert(c >= 0 && c <= 255);160163 }161164 }162165 // Identity variety: the ring should not be a monoculture.163- assert(new Set(seen.map((p) => p.identity.meshId)).size >= 2, "one mesh only");166+ assert(167+ new Set(seen.map((p) => p.identity.meshId)).size >= 2,168+ "one mesh only",169+ );164170 assert(seen.some((p) => p.identity.tint !== null), "no tinted peers");165171 });166172 @@ -17,6 +17,7 @@ import {
1717 channelProxies,1818 islandProxies,1919 wideScalesOf,20+ worldifyProxies,2021 } from "../src/gen/endless/proxies.ts";2122 import {2223 type MarbleState,@@ -24,14 +25,23 @@ import {
2425 type PhysicsModule,2526 } from "../src/client/physics/engine.ts";2627 import {28+ attachFrames,2729 concatRails,2830 gapSegmentsOf,2931 type Rail,3032 railForSegment,33+ railFrameAt,3134 stationGravity,3235 stationGravityDelta,3336 stationRestitution,3437 } from "../src/client/physics/rail.ts";38+import {39+ affineApply,40+ affineUnapply,41+ quatRotate,42+ quatUnrotate,43+ twistParam,44+} from "../src/shared/frame.ts";3545 import {3646 autopilotForce,3747 boostStep,@@ -76,7 +86,15 @@ function drive(
7686 useAlt = false,7787 onStep?: (pos: readonly [number, number, number]) => void,7888 ): RunResult {79- return driveWith(seed, segments, simSeconds, stopAtS, spawnAtS, useAlt, onStep);89+ return driveWith(90+ seed,91+ segments,92+ simSeconds,93+ stopAtS,94+ spawnAtS,95+ useAlt,96+ onStep,97+ );8098 }8199 82100 let module_: PhysicsModule | null = null;@@ -98,12 +116,19 @@ function driveWith(
98116 let lowest = Infinity;99117 for (let i = 0; i < segments; i++) {100118 const seg = w.get(i);101- rails.push(railForSegment(seg, useAlt));102- engine.addSegment(i, [119+ const segRail = railForSegment(seg, useAlt);120+ // Gravity reorientation (M16): mirror the client — frames on the121+ // rail, proxies presented in world space (the twist ribbon really122+ // corkscrews; downstream statics really rotate).123+ if (seg.frame) attachFrames(segRail, seg.frame);124+ rails.push(segRail);125+ let proxies = [103126 ...channelProxies(seg.path, true, gapSegmentsOf(seg), wideScalesOf(seg)),104127 ...seg.islands.flatMap((isle) => islandProxies(isle.placements)),105128 ...bumperProxies(seg.placements),106- ]);129+ ];130+ if (seg.frame) proxies = worldifyProxies(proxies, seg.frame);131+ engine.addSegment(i, proxies);107132 for (const c of seg.plan.cells) lowest = Math.min(lowest, c.baseY * 2);108133 }109134 // ONE rail law with the client driver: rail.ts builds stations + mods.@@ -112,13 +137,16 @@ function driveWith(
112137 while (spawnIdx < rail.s.length - 1 && rail.s[spawnIdx] < spawnAtS) {113138 spawnIdx++;114139 }115- const start = rail.pts[spawnIdx];140+ const spawnPos: [number, number, number] = [...rail.pts[spawnIdx]];141+ spawnPos[1] += 0.4;116142 const t0 = rail.tans[spawnIdx];117- engine.spawnMarble(118- 0,119- [start[0], start[1] + 0.4, start[2]],120- [t0[0] * 2.2, 0, t0[2] * 2.2],121- );143+ const spawnVel: [number, number, number] = [t0[0] * 2.2, 0, t0[2] * 2.2];144+ const spawnFrame = railFrameAt(rail, spawnIdx);145+ if (spawnFrame) {146+ affineApply(spawnFrame, spawnPos, spawnPos);147+ quatRotate(spawnFrame.q, spawnVel, spawnVel);148+ }149+ engine.spawnMarble(0, spawnPos, spawnVel);122150 123151 const pilot = pilotFor(seed);124152 let simT = 0;@@ -145,13 +173,15 @@ function driveWith(
145173 step++146174 ) {147175 engine.step(1 / 60, () => {148- // Nearest-station scan, then the shared per-step control law.176+ // Nearest-station scan (presented world space), then the shared177+ // per-step control law.149178 const p = st.pos;179+ const scanPts = rail.worldPts ?? rail.pts;150180 let bestD = Infinity;151181 const lo = Math.max(0, nearest - 30);152- const hi = Math.min(rail.pts.length - 1, nearest + 60);182+ const hi = Math.min(scanPts.length - 1, nearest + 60);153183 for (let i = lo; i <= hi; i++) {154- const r = rail.pts[i];184+ const r = scanPts[i];155185 const d = (r[0] - p[0]) ** 2 + (r[1] - p[1]) ** 2 +156186 (r[2] - p[2]) ** 2;157187 if (d < bestD) {@@ -160,6 +190,11 @@ function driveWith(
160190 }161191 }162192 engine.setRestitution(0, stationRestitution(rail, nearest));193+ // Conjugate marble state through the station frame — the control194+ // laws run in plan space (the client's decide() does the same).195+ const frame = railFrameAt(rail, nearest);196+ const cpos = frame ? affineUnapply(frame, st.pos) : st.pos;197+ const cvel = frame ? quatUnrotate(frame.q, st.vel) : st.vel;163198 simT += 1 / 60;164199 pilot.offset = pilotOffset(pilot, simT);165200 // The boost economy (competition round): a lone marble still bleeds@@ -168,11 +203,11 @@ function driveWith(
168203 ? dbg.vSafe - dbg.wishspeed169204 : null;170205 boost = boostStep(boost, pilot, 1 / 60, headroom, null, null);171- const f = autopilotForce(206+ let f = autopilotForce(172207 rail,173208 nearest,174- st.pos,175- st.vel,209+ cpos,210+ cvel,176211 engine.mass,177212 null,178213 pilot,@@ -180,6 +215,7 @@ function driveWith(
180215 undefined,181216 boost.boosting,182217 );218+ if (f && frame) f = quatRotate(frame.q, f) as [number, number, number];183219 // Gravity zones as per-marble delta forces (M15 — world gravity is184220 // global and fixed).185221 const gd = stationGravityDelta(rail, nearest, engine.mass);@@ -187,14 +223,18 @@ function driveWith(
187223 const fy = (f?.[1] ?? 0) + (gd?.[1] ?? 0);188224 const fz = (f?.[2] ?? 0) + (gd?.[2] ?? 0);189225 if (fx !== 0 || fy !== 0 || fz !== 0) engine.force(0, fx, fy, fz);190- const j = wantJump(rail, nearest, st.pos, st.vel, null, pilot);191- if (j) engine.jump(0, j[0], j[1]);226+ const j = wantJump(rail, nearest, cpos, cvel, null, pilot);227+ if (j) engine.jump(0, frame ? quatRotate(frame.q, j[0]) : j[0], j[1]);192228 });193229 st = engine.state();194230 onStep?.(st.pos);195231 reachedS = Math.max(reachedS, rail.s[nearest]);196232 minY = Math.min(minY, st.pos[1]);197- if (st.pos[1] < lowest - 8) fell = true;233+ {234+ const fr = railFrameAt(rail, nearest);235+ const planY = fr ? affineUnapply(fr, st.pos)[1] : st.pos[1];236+ if (planY < lowest - 8) fell = true;237+ }198238 if (step % 15 === 0) {199239 mix(st.pos[0]);200240 mix(st.pos[1]);@@ -240,8 +280,9 @@ Deno.test("physics survivability: assisted marble clears early segments", async
240280 241281 Deno.test("scenario jump-gap: cold entry launches, flies, and is caught (both kinds)", async () => {242282 module_ = await factory();243- // One pinned case per gap kind: wide-scan-4 early segments plan a244- // PROPELLER (2-cell gap at 0.35 gravity), wide-scan-5 a plain JUMP.283+ // One pinned case per gap kind: grav-scan-11 early segments plan a284+ // PROPELLER (2-cell gap at 0.35 gravity), wide-scan-5 a plain JUMP285+ // (re-pinned after the reorient clause reshuffled the streams).245286 // (Re-pinned after the spur clause reshuffled every seed's draws —246287 // no seed freeze, so pins follow the stream.)247288 // Locate the flight span on the shared rail, spawn the marble COLD@@ -251,10 +292,12 @@ Deno.test("scenario jump-gap: cold entry launches, flies, and is caught (both ki
251292 // is bounded by the assist brake at 9 u/s, which the catch pan + the252293 // landing cells absorb by construction; hot arrivals are exercised by253294 // the 7-seed survivability gate above.)254- for (const { seed, kind } of [255- { seed: "wide-scan-4", kind: "propeller" },256- { seed: "wide-scan-5", kind: "jump" },257- ]) {295+ for (296+ const { seed, kind } of [297+ { seed: "grav-scan-11", kind: "propeller" },298+ { seed: "wide-scan-5", kind: "jump" },299+ ]300+ ) {258301 const w = new EndlessWorld(seed, catalog);259302 const rails: Rail[] = [];260303 for (let i = 0; i < 3; i++) rails.push(railForSegment(w.get(i)));@@ -289,10 +332,12 @@ Deno.test("scenario lip-undershoot rescue: the jump decision saves a slow arriva
289332 // landing point) and top-up vertical speed so the longer hang time290333 // still crosses. This is the M14b contract: jumping is a DECISION from291334 // the marble's actual state, not a pre-priced launch.292- for (const { seed, kind } of [293- { seed: "wide-scan-4", kind: "propeller" },294- { seed: "wide-scan-5", kind: "jump" },295- ]) {335+ for (336+ const { seed, kind } of [337+ { seed: "grav-scan-11", kind: "propeller" },338+ { seed: "wide-scan-5", kind: "jump" },339+ ]340+ ) {296341 const w = new EndlessWorld(seed, catalog);297342 const rails: Rail[] = [];298343 for (let i = 0; i < 3; i++) rails.push(railForSegment(w.get(i)));@@ -354,20 +399,20 @@ Deno.test("physics determinism: identical trajectory hash across runs", async ()
354399 355400 Deno.test("scenario helix-dive: marble rides the full spiral and the underpass", async () => {356401 module_ = await factory();357- // helix-scan-11 segment 0 hosts a full-helix ensemble (re-pinned358- // after the spur clause reshuffled the stream). The marble must enter the approach corner,402+ // helix-scan-25 segment 0 hosts a full-helix ensemble (re-pinned403+ // after the reorient clause reshuffled the stream). The marble must enter the approach corner,359404 // ride the 360° spiral down 2.0 (helix family mod: banked, ridden360405 // fast), come out through its own underpass, and keep rolling.361- const w = new EndlessWorld("helix-scan-11", catalog);406+ const w = new EndlessWorld("helix-scan-25", catalog);362407 const seg = w.get(0);363408 const r0 = seg.plan.reservations.find((r) =>364409 /^helix-(large-)?(left|right)$/.test(r.pieceIds[0])365410 );366- assert(r0 !== undefined, "helix-scan-11 seg 0 lost its full helix");411+ assert(r0 !== undefined, "helix-scan-25 seg 0 lost its full helix");367412 const wi = seg.windowRanges.findIndex((wr) => wr.start === r0.start);368413 let endS = 0;369414 for (let k = 0; k <= wi; k++) endS += seg.path.segments[k].length;370- const r = drive("helix-scan-11", 3, 60, endS + 8);415+ const r = drive("helix-scan-25", 3, 60, endS + 8);371416 assert(!r.fell, "marble fell during the helix dive");372417 assert(373418 r.reachedS >= endS + 7,@@ -401,16 +446,16 @@ Deno.test("scenario wide-run: marble rides funnel → wide channel → funnel",
401446 402447 Deno.test("scenario icy-run: marble slides the low-grip patch and carries on", async () => {403448 module_ = await factory();404- // jump-scan-13 early segments plan an ICY zone (re-pinned after the449+ // wide-scan-17 early segments plan an ICY zone (re-pinned after the405450 // spur clause; assist authority drops to 0.3 there — the marble must406451 // coast across on carried momentum and regain the line after).407- const w = new EndlessWorld("jump-scan-13", catalog);452+ const w = new EndlessWorld("wide-scan-17", catalog);408453 let icyAt: { seg: number; start: number; len: number } | null = null;409454 for (let i = 0; i < 3 && !icyAt; i++) {410455 const m = w.get(i).plan.mods.find((m) => m.kind === "icy");411456 if (m) icyAt = { seg: i, start: m.start, len: m.len };412457 }413- assert(icyAt !== null, "jump-scan-13 early segments lost their icy zone");458+ assert(icyAt !== null, "wide-scan-17 early segments lost their icy zone");414459 const rails: Rail[] = [];415460 for (let i = 0; i < 4; i++) rails.push(railForSegment(w.get(i)));416461 const rail = concatRails(rails);@@ -419,7 +464,7 @@ Deno.test("scenario icy-run: marble slides the low-grip patch and carries on", a
419464 let last = first;420465 while (rail.mods[last + 1]?.icy) last++;421466 const r = drive(422- "jump-scan-13",467+ "wide-scan-17",423468 4,424469 40,425470 rail.s[last] + 10,@@ -461,18 +506,21 @@ Deno.test("scenario bouncer-pins: marble caroms the wide-deck pins and carries o
461506 462507 Deno.test("scenario gravity-run: tilted gravity surges the marble on the flat", async () => {463508 module_ = await factory();464- // grav-scan-2 segment 0 plans a GRAVITY zone: world gravity tilts509+ // wide-scan-21 segment 0 plans a GRAVITY zone: world gravity tilts465510 // forward along the run (shim gravityDir), so a geometrically flat466511 // straight accelerates the marble — the assist stays silent between467512 // cruise and the envelope cap, gravity itself does the work. The zone468513 // stops 2 cells short of its clause (brake runway) and the marble must469514 // roll on past it.470- const w = new EndlessWorld("grav-scan-2", catalog);515+ const w = new EndlessWorld("wide-scan-21", catalog);471516 let gravAt: number | null = null;472517 for (let i = 0; i < 3 && gravAt === null; i++) {473518 if (w.get(i).plan.mods.some((m) => m.kind === "gravity")) gravAt = i;474519 }475- assert(gravAt !== null, "grav-scan-2 early segments lost their gravity zone");520+ assert(521+ gravAt !== null,522+ "wide-scan-21 early segments lost their gravity zone",523+ );476524 const rails: Rail[] = [];477525 for (let i = 0; i < 4; i++) rails.push(railForSegment(w.get(i)));478526 const rail = concatRails(rails);@@ -491,7 +539,7 @@ Deno.test("scenario gravity-run: tilted gravity surges the marble on the flat",
491539 const plain = stationGravity(rail, 0);492540 assertEquals(plain, [0, -20, 0]);493541 const r = drive(494- "grav-scan-2",542+ "wide-scan-21",495543 4,496544 40,497545 rail.s[last] + 10,@@ -506,19 +554,60 @@ Deno.test("scenario gravity-run: tilted gravity surges the marble on the flat",
506554 );507555 });508556 557+Deno.test("scenario reorient: marble corkscrews the twist and rides the wall", async () => {558+ module_ = await factory();559+ // biome-3 segment 0 rolls +90° about the travel axis (also crossed by560+ // the survivability corpus). The marble must enter the twist chute,561+ // corkscrew through it, and keep making progress DOWNSTREAM of the562+ // run — where the channel is a WALL in world space and only the563+ // conjugated control law + rotated gravity delta keep it in the pipe.564+ const w = new EndlessWorld("biome-3", catalog);565+ const seg = w.get(0);566+ const f = seg.frame;567+ assert(f?.twist, "biome-3 seg 0 lost its reorient — re-scan");568+ const tw = f.twist!;569+ // Drive well past the twist (its axial end, plus a margin).570+ let twistEndS = 0;571+ for (let k = 0; k <= tw.to; k++) twistEndS += seg.path.segments[k].length;572+ const goal = twistEndS + 10;573+ let wallProof = false;574+ const r = drive("biome-3", 4, 120, goal, 0, false, (pos) => {575+ // Wall-riding evidence: past the run, the marble's PLAN height sits576+ // near the floor while its world position is genuinely rotated away577+ // from the plan point (the twist really presented).578+ const t = twistParam(f, affineUnapply(f.exit, pos));579+ if (t > tw.len + 2) {580+ const plan = affineUnapply(f.exit, pos);581+ const dx = pos[0] - plan[0];582+ const dy = pos[1] - plan[1];583+ const dz = pos[2] - plan[2];584+ if (Math.hypot(dx, dy, dz) > 0.5) wallProof = true;585+ }586+ });587+ assert(!r.fell, "marble fell in/after the twist");588+ assert(589+ r.reachedS >= goal - 1,590+ `marble stalled at s=${r.reachedS.toFixed(1)} (goal ${goal.toFixed(1)})`,591+ );592+ assert(593+ wallProof,594+ "marble was never observed on presented (rotated) geometry past the twist",595+ );596+});597+509598 Deno.test("scenario spur-drop: the branch rider falls through onto its own line", async () => {510599 module_ = await factory();511- // spur-scan-7 segment 1 hosts a takeable spur (pinned by spur_test).600+ // jump-scan-4 segment 1 hosts a takeable spur (pinned by spur_test).512601 // Ride the ALT rails: the marble takes the branch at the split, rolls513602 // out over the end-hole board, and must fall THROUGH the hole — proven514603 // by a mid-shaft transit inside the branch cell's column, a place the515604 // MAIN hook never visits at that height — landing on the receiver516605 // passing directly below (Δ 1.0 track), then keep progressing. The517606 // respawn net (fell) must never fire.518- const w = new EndlessWorld("spur-scan-7", catalog);607+ const w = new EndlessWorld("jump-scan-4", catalog);519608 const seg = w.get(1);520609 const spur = seg.plan.spurs[0];521- assert(spur !== undefined, "spur-scan-7 seg 1 lost its spur");610+ assert(spur !== undefined, "jump-scan-4 seg 1 lost its spur");522611 const host = seg.plan.cells[spur.host];523612 const [wx, wz] = DIR_STEP[spur.dir];524613 const holeX = (host.x + wx) * TRACK_SCALE;@@ -527,7 +616,7 @@ Deno.test("scenario spur-drop: the branch rider falls through onto its own line"
527616 const recFloor = deckFloor - TRACK_SCALE;528617 let shaftTransit = false;529618 const goal = w.get(2).startS;530- const r = drive("spur-scan-7", 4, 120, goal, 0, true, (pos) => {619+ const r = drive("jump-scan-4", 4, 120, goal, 0, true, (pos) => {531620 if (532621 Math.hypot(pos[0] - holeX, pos[2] - holeZ) < 0.7 &&533622 pos[1] > recFloor + 0.5 && pos[1] < recFloor + 1.6@@ -0,0 +1,99 @@
1+/**2+ * Gravity reorientation (M16) — generation invariants. The physics half3+ * (a marble genuinely riding a wall through a twist) lives in4+ * physics_test.ts; the survivability corpus also crosses live twists5+ * (abs-1 composes −90° then +90°).6+ */7+import { assert, assertAlmostEquals, assertEquals } from "@std/assert";8+import { EndlessWorld } from "../src/gen/endless/world.ts";9+import {10+ affineApply,11+ frameUp,12+ quatRotate,13+ segmentFrameAt,14+ twistParam,15+} from "../src/shared/frame.ts";16+17+const close = (a: readonly number[], b: readonly number[], eps = 1e-9) => {18+ for (let i = 0; i < a.length; i++) assertAlmostEquals(a[i], b[i], eps);19+};20+21+Deno.test("reorient: pinned seeds twist early and frames stay continuous", () => {22+ // biome-3 rolls +90° in segment 0 (also crossed by the survivability23+ // corpus); abs-1 rolls −90° at 0 then +90° at 2 — the composed exit24+ // must return to identity orientation.25+ const w = new EndlessWorld("biome-3");26+ const s0 = w.get(0);27+ const m = s0.plan.mods.find((mm) => mm.kind === "reorient");28+ assert(m, "biome-3 seg 0 lost its reorient — re-scan");29+ assertAlmostEquals(m.angle!, Math.PI / 2, 1e-12);30+ assert(s0.frame?.twist, "reorient must stamp a twist frame");31+32+ // Frame continuity: every segment's entry is its predecessor's exit,33+ // and the presented centerline is C0 across the twist (the pivot law).34+ let prevExit = s0.frame!.exit;35+ for (let i = 1; i <= 6; i++) {36+ const s = w.get(i);37+ const entry = s.frame?.entry ?? {38+ q: [0, 0, 0, 1] as [number, number, number, number],39+ t: [0, 0, 0] as [number, number, number],40+ };41+ close(entry.q, prevExit.q);42+ close(entry.t, prevExit.t);43+ prevExit = s.frame?.exit ?? entry;44+ }45+46+ // Local up after a +90° roll is horizontal — the run downstream is a47+ // WALL in world space.48+ const up = frameUp(s0.frame!.exit);49+ assertAlmostEquals(up[1], 0, 1e-9);50+ assertAlmostEquals(Math.hypot(up[0], up[2]), 1, 1e-9);51+52+ // The twist window's kit placements were dropped (the corkscrew chute53+ // renders the collision ribbon instead): no placement projects into54+ // the run's interior span.55+ const tw = s0.frame!.twist!;56+ for (const p of s0.placements) {57+ const t = twistParam(s0.frame!, p.pos);58+ assert(59+ t < 0.51 || t > tw.len - 0.51,60+ `placement ${p.pieceId} inside the twist run (t=${t.toFixed(2)})`,61+ );62+ }63+});64+65+Deno.test("reorient: 180° roll presents the descent as a world CLIMB", () => {66+ // dami seg 2 carries a 180° roll: plan-down maps to world-up past the67+ // twist — the ceiling-world signature.68+ const w = new EndlessWorld("dami");69+ for (let i = 0; i < 3; i++) w.get(i);70+ const s = w.get(2);71+ const m = s.plan.mods.find((mm) => mm.kind === "reorient");72+ assert(m, "dami seg 2 lost its 180° reorient — re-scan");73+ assertAlmostEquals(Math.abs(m.angle!), Math.PI, 1e-12);74+ const down = quatRotate(s.frame!.exit.q, [0, -1, 0]);75+ close(down, [0, 1, 0], 1e-9);76+});77+78+Deno.test("reorient: frame law is exact at the run's ends", () => {79+ const w = new EndlessWorld("biome-3");80+ const f = w.get(0).frame!;81+ const tw = f.twist!;82+ // At the pivot the frames agree; past the run the exit frame owns it.83+ close(84+ affineApply(segmentFrameAt(f, 0), tw.pivot),85+ affineApply(f.entry, tw.pivot),86+ );87+ const past: [number, number, number] = [88+ tw.pivot[0] + tw.axis[0] * (tw.len + 3),89+ tw.pivot[1] + tw.axis[1] * (tw.len + 3),90+ tw.pivot[2] + tw.axis[2] * (tw.len + 3),91+ ];92+ close(93+ affineApply(segmentFrameAt(f, twistParam(f, past)), past),94+ affineApply(f.exit, past),95+ );96+ // Determinism: a second world reproduces the identical frame.97+ const w2 = new EndlessWorld("biome-3");98+ assertEquals(w2.get(0).frame, f);99+});@@ -618,11 +618,7 @@ Deno.test("island wedges: slope/diagonal trims respect the surrounding surface",
618618 ...Array.from({ length: 10 }, (_, i) => `jump-scan-${i}`),619619 ];620620 const heightOf = (id: string): number =>621- id.includes("blockQuarter")622- ? 0.25623- : id.includes("blockHalf")624- ? 0.5625- : 1;621+ id.includes("blockQuarter") ? 0.25 : id.includes("blockHalf") ? 0.5 : 1;626622 const DIRV: readonly (readonly [number, number])[] = [627623 [1, 0],628624 [0, 1],@@ -845,7 +841,10 @@ Deno.test("helix dive: full-helix ensembles in the void obey the window contract
845841 for (let i = 0; i < 8; i++) {846842 const seg = w.get(i);847843 const fulls = seg.plan.reservations.filter((r) => isFull(r.pieceIds[0]));848- assert(fulls.length <= 1, `${seed} seg ${i}: ${fulls.length} full helixes`);844+ assert(845+ fulls.length <= 1,846+ `${seed} seg ${i}: ${fulls.length} full helixes`,847+ );849848 for (const r of fulls) {850849 seen++;851850 const def = registry.variants.find(@@ -886,7 +885,9 @@ Deno.test("helix dive: full-helix ensembles in the void obey the window contract
886885 );887886 // The composite's mesh reaches the client (placement at the window).888887 assert(889- seg.placements.some((p) => p.pieceId === def.meshId || (!def.meshId && p.pieceId === def.id)),888+ seg.placements.some((p) =>889+ p.pieceId === def.meshId || (!def.meshId && p.pieceId === def.id)890+ ),890891 `${seed} seg ${i}: no placement for the helix composite`,891892 );892893 }@@ -34,9 +34,10 @@ const catalog: DecorCatalog = {
3434 };3535 3636 /** Scan corpus (same style as the jump/gravity scenario seeds). Pinned37- * exemplars: alpha (golden seed) spurs at segment 0, spur-scan-7 at38- * segment 1 — re-scan with a fresh label family if a reshuffle moves39- * them (tools-free: the corpus loop below prints nothing, just widen). */37+ * exemplars: wide-scan-10 spurs at segment 0, jump-scan-4 at segment 138+ * (re-pinned after the reorient clause reshuffled the streams) —39+ * re-scan with a fresh label family if a reshuffle moves them40+ * (tools-free: the corpus loop below prints nothing, just widen). */4041 const SEEDS = [4142 "alpha",4243 "dami",@@ -46,15 +47,15 @@ const SEEDS = [
4647 const SEGS = 12;4748 4849 Deno.test("spur drop: pinned seeds exhibit a spur early", () => {49- const alpha = new EndlessWorld("alpha", catalog);50+ const scan10 = new EndlessWorld("wide-scan-10", catalog);5051 assert(51- alpha.get(0).plan.spurs.length > 0,52- "seed alpha lost its segment-0 spur — re-scan for a pinned exemplar",52+ scan10.get(0).plan.spurs.length > 0,53+ "seed wide-scan-10 lost its segment-0 spur — re-scan for an exemplar",5354 );54- const scan7 = new EndlessWorld("spur-scan-7", catalog);55+ const scan4 = new EndlessWorld("jump-scan-4", catalog);5556 assert(56- scan7.get(1).plan.spurs.length > 0,57- "seed spur-scan-7 lost its segment-1 spur — re-scan (physics_test " +57+ scan4.get(1).plan.spurs.length > 0,58+ "seed jump-scan-4 lost its segment-1 spur — re-scan (physics_test " +5859 "pins the same seed)",5960 );6061 });@@ -145,7 +146,7 @@ Deno.test("spur drop: every spur obeys the bridge-law contract", () => {
145146 assert(board, `${seed} seg ${i}: no end-hole board on the branch`);146147 assertEquals(147148 board.rotation,148- ((1 - spur.dir + 4) % 4),149+ (1 - spur.dir + 4) % 4,149150 `${seed} seg ${i}: board rotation must open toward the junction`,150151 );151152 @@ -298,7 +298,8 @@ Deno.test("takeable spur: elevated split drains through an end-hole onto the low
298298 );299299 // Landing on the main line; hole on the alt (teardrop) variant.300300 const passes = (301- curves: readonly (readonly import("../src/shared/types.ts").CurveSeg[])[],301+ curves:302+ readonly (readonly import("../src/shared/types.ts").CurveSeg[])[],302303 p: readonly number[],303304 ): boolean =>304305 curves.some((curve) =>@@ -1,17 +1,34 @@
11 import { EndlessWorld } from "/Users/dami/Developer/marbles/src/gen/endless/world.ts";2-import { channelProxies, islandProxies } from "/Users/dami/Developer/marbles/src/gen/endless/proxies.ts";3-const catalog = { packs: [{ slug: "nature-kit", ids: JSON.parse(Deno.readTextFileSync("/Users/dami/Developer/marbles/assets/nature-kit.manifest.json")).pieces.map((p: { id: string }) => p.id) }] };2+import {3+ channelProxies,4+ islandProxies,5+} from "/Users/dami/Developer/marbles/src/gen/endless/proxies.ts";6+const catalog = {7+ packs: [{8+ slug: "nature-kit",9+ ids: JSON.parse(10+ Deno.readTextFileSync(11+ "/Users/dami/Developer/marbles/assets/nature-kit.manifest.json",12+ ),13+ ).pieces.map((p: { id: string }) => p.id),14+ }],15+};416 const seed = Deno.args[0];517 const M = Deno.args.slice(1, 4).map(Number);618 const R = 0.21;719 const w = new EndlessWorld(seed, catalog);820 for (let i = 0; i < 8; i++) {921 const seg = w.get(i);10- const all = [...channelProxies(seg.path), ...seg.islands.flatMap((x) => islandProxies(x.placements))];22+ const all = [23+ ...channelProxies(seg.path),24+ ...seg.islands.flatMap((x) => islandProxies(x.placements)),25+ ];1126 for (const p of all) {1227 if (p.kind === "mesh" || p.kind === "bumper") continue;1328 // Transform marble into slab local frame (yaw then pitch inverse).14- const dx = M[0] - p.center[0], dy = M[1] - p.center[1], dz = M[2] - p.center[2];29+ const dx = M[0] - p.center[0],30+ dy = M[1] - p.center[1],31+ dz = M[2] - p.center[2];1532 const yaw = p.kind === "slab" ? p.yaw : 0;1633 const pitch = p.kind === "slab" ? p.pitch : 0;1734 // inverse yaw@@ -25,14 +42,22 @@ for (let i = 0; i < 8; i++) {
2542 const cy2 = ly * Math.cos(pitch) + lz * Math.sin(pitch) * -1 * -1;2643 const cz2 = -ly * Math.sin(pitch) * -1 * -1 + lz * Math.cos(pitch);2744 ly = ly * Math.cos(pitch) + lz * Math.sin(pitch);28- lz = cz2 === lz ? lz : (-(M[1]-p.center[1]) * Math.sin(pitch) + lz * Math.cos(pitch));45+ lz = cz2 === lz46+ ? lz47+ : (-(M[1] - p.center[1]) * Math.sin(pitch) + lz * Math.cos(pitch));2948 void cy2;3049 const ex = Math.max(Math.abs(lx) - p.half[0], 0);3150 const ey = Math.max(Math.abs(ly) - p.half[1], 0);3251 const ez = Math.max(Math.abs(lz) - p.half[2], 0);3352 const d = Math.hypot(ex, ey, ez);3453 if (d < R) {35- console.log(`${p.kind} CONTACT d=${d.toFixed(3)} c=(${p.center.map((v)=>v.toFixed(2))}) h=(${p.half.map((v)=>v.toFixed(2))}) yaw=${(p.kind==="slab"?p.yaw:0).toFixed(2)} pitch=${(p.kind==="slab"?p.pitch:0).toFixed(2)}`);54+ console.log(55+ `${p.kind} CONTACT d=${d.toFixed(3)} c=(${56+ p.center.map((v) => v.toFixed(2))57+ }) h=(${p.half.map((v) => v.toFixed(2))}) yaw=${58+ (p.kind === "slab" ? p.yaw : 0).toFixed(2)59+ } pitch=${(p.kind === "slab" ? p.pitch : 0).toFixed(2)}`,60+ );3661 }3762 }3863 }@@ -6,8 +6,8 @@ import { EndlessWorld } from "../../src/gen/endless/world.ts";
66 import {77 bumperProxies,88 channelProxies,9- wideScalesOf,109 islandProxies,10+ wideScalesOf,1111 } from "../../src/gen/endless/proxies.ts";1212 import {1313 PhysicsEngine,@@ -1,5 +1,10 @@
1-import { PhysicsEngine, type PhysicsModule } from "/Users/dami/Developer/marbles/src/client/physics/engine.ts";2-const factory = (await import("/Users/dami/Developer/marbles/static/dist/box3d.mjs")).default as () => Promise<PhysicsModule>;1+import {2+ PhysicsEngine,3+ type PhysicsModule,4+} from "/Users/dami/Developer/marbles/src/client/physics/engine.ts";5+const factory =6+ (await import("/Users/dami/Developer/marbles/static/dist/box3d.mjs"))7+ .default as () => Promise<PhysicsModule>;38 49 async function drop(name: string, vertices: number[], indices: number[]) {510 const m = await factory();@@ -8,11 +13,20 @@ async function drop(name: string, vertices: number[], indices: number[]) {
813 engine.spawnMarble(0, [0.5, 1, 0.2], [0, 0, 0]);914 for (let i = 0; i < 120; i++) engine.step(1 / 60);1015 const st = engine.state();11- console.log(`${name}: y=${st.pos[1].toFixed(3)} ${st.pos[1] > 0 ? "OK" : "FELL"}`);16+ console.log(17+ `${name}: y=${st.pos[1].toFixed(3)} ${st.pos[1] > 0 ? "OK" : "FELL"}`,18+ );1219 }1320 1421 // 1. The known-good square, via the engine path (winding [0,2,1]).15-await drop("square-engine", [-2, 0, -2, 2, 0, -2, 2, 0, 2, -2, 0, 2], [0, 2, 1, 0, 3, 2]);22+await drop("square-engine", [-2, 0, -2, 2, 0, -2, 2, 0, 2, -2, 0, 2], [23+ 0,24+ 2,25+ 1,26+ 0,27+ 3,28+ 2,29+]);1630 // 2. Two-ring flat strip, 3 profile points each, ribbon-style indices.1731 // Rings along +x (travel), profile along +z. ribbonOf winding:1832 // (a, b+1, b), (a, a+1, b+1) per quad.@@ -1,10 +1,15 @@
11 // Isolated ribbon repro: synthetic straight + arc paths, marble dropped on22 // the ribbon ONLY (no boxes, no cups). Where does collision break?33 import { channelProxies } from "/Users/dami/Developer/marbles/src/gen/endless/proxies.ts";4-import { PhysicsEngine, type PhysicsModule } from "/Users/dami/Developer/marbles/src/client/physics/engine.ts";4+import {5+ PhysicsEngine,6+ type PhysicsModule,7+} from "/Users/dami/Developer/marbles/src/client/physics/engine.ts";58 import type { LoopPath } from "/Users/dami/Developer/marbles/src/shared/types.ts";69 7-const factory = (await import("/Users/dami/Developer/marbles/static/dist/box3d.mjs")).default as () => Promise<PhysicsModule>;10+const factory =11+ (await import("/Users/dami/Developer/marbles/static/dist/box3d.mjs"))12+ .default as () => Promise<PhysicsModule>;813 914 const straight: LoopPath = {1015 segments: [{@@ -31,20 +36,34 @@ const arc: LoopPath = {
3136 totalLength: 4.7,3237 };3338 34-for (const [name, path, spawn, vel] of [35- ["straight", straight, [1, 1.2, 0], [2, 0, 0]],36- ["arc", arc, [0.3, 1.2, 0.1], [0, 0, 2]],37-] as const) {39+for (40+ const [name, path, spawn, vel] of [41+ ["straight", straight, [1, 1.2, 0], [2, 0, 0]],42+ ["arc", arc, [0.3, 1.2, 0.1], [0, 0, 2]],43+ ] as const44+) {3845 const m = await factory();3946 const engine = new PhysicsEngine(m);4047 const proxies = channelProxies(path, true);4148 const mesh = proxies.find((p) => p.kind === "mesh");42- console.log(`${name}: proxies=${proxies.length} meshVerts=${mesh && mesh.kind === "mesh" ? mesh.vertices.length / 3 : 0} tris=${mesh && mesh.kind === "mesh" ? mesh.indices.length / 3 : 0}`);49+ console.log(50+ `${name}: proxies=${proxies.length} meshVerts=${51+ mesh && mesh.kind === "mesh" ? mesh.vertices.length / 3 : 052+ } tris=${mesh && mesh.kind === "mesh" ? mesh.indices.length / 3 : 0}`,53+ );4354 engine.addSegment(0, proxies);44- engine.spawnMarble(0, spawn as [number, number, number], vel as [number, number, number]);55+ engine.spawnMarble(56+ 0,57+ spawn as [number, number, number],58+ vel as [number, number, number],59+ );4560 for (let i = 0; i < 300; i++) engine.step(1 / 60);4661 const st = engine.state();47- console.log(` after 5s: pos=(${st.pos.map((v) => v.toFixed(2))}) — ${st.pos[1] > 0 ? "RESTING/ROLLING ✓" : "FELL THROUGH ✗"}`);62+ console.log(63+ ` after 5s: pos=(${st.pos.map((v) => v.toFixed(2))}) — ${64+ st.pos[1] > 0 ? "RESTING/ROLLING ✓" : "FELL THROUGH ✗"65+ }`,66+ );4867 }4968 5069 // Diagnose the arc: first triangle normal + ring layout.@@ -2,8 +2,8 @@
22 33 **Verdict: Box3D confirmed as the physics-era engine.**44 5-Box3D (Erin Catto, released 2026-06-30, MIT, C17) — the 3D successor to6-Box2D. Spiked at commit `3fc20f5b453ba9e14cdf54ecafa87a2a4bcdf53c`.5+Box3D (Erin Catto, released 2026-06-30, MIT, C17) — the 3D successor to Box2D.6+Spiked at commit `3fc20f5b453ba9e14cdf54ecafa87a2a4bcdf53c`.77 88 ## Spike setup99 @@ -13,26 +13,26 @@ Box2D. Spiked at commit `3fc20f5b453ba9e14cdf54ecafa87a2a4bcdf53c`.
1313 — builds `libbox3d.a` for wasm32 with ZERO patches.1414 - `emcc spike.c libbox3d.a -I include -O2 -o spike.js && node spike.js`1515 16-`spike.c` here: a slalom-like trough (floor + walls + end caps, tilted 5°)17-with a marble-sized (r=0.2) sphere rolling down 60 units, 1200 steps at18-60 Hz × 4 substeps.16+`spike.c` here: a slalom-like trough (floor + walls + end caps, tilted 5°) with17+a marble-sized (r=0.2) sphere rolling down 60 units, 1200 steps at 60 Hz × 418+substeps.1919 2020 ## Gate results2121 22-- **Determinism**: position-trace FNV hash bit-identical, both in-process23- (two worlds) and across separate runs. Box3D advertises cross-platform22+- **Determinism**: position-trace FNV hash bit-identical, both in-process (two23+ worlds) and across separate runs. Box3D advertises cross-platform2424 determinism + record/replay — matches what we measured.25-- **Performance**: ~228k steps/s single-worker under Node (≈3,800×26- realtime at 60 Hz; the gate was ≥4×). Effectively unlimited headroom.25+- **Performance**: ~228k steps/s single-worker under Node (≈3,800× realtime at26+ 60 Hz; the gate was ≥4×). Effectively unlimited headroom.2727 - **Behavior**: marble slalom maxAbsX settled at exactly the geometric28- wall-contact plane (0.75 − 0.05 wall − 0.2 radius = 0.500) — contact29- precision is excellent. Escaped-marble freefall behaves as expected30- (first run tumbled into the void forever — endless-mode-compatible).28+ wall-contact plane (0.75 − 0.05 wall − 0.2 radius = 0.500) — contact precision29+ is excellent. Escaped-marble freefall behaves as expected (first run tumbled30+ into the void forever — endless-mode-compatible).3131 - **API**: portable C API in the Box2D v3 style (b3DefaultWorldDef /32- b3CreateBody / b3CreateHullShape / b3CreateSphereShape /33- b3CreateMeshShape / b3World_Step / b3Body_GetPosition). Double-precision34- positions by default (b3CreateWorld = b3CreateWorldDoublePrecision) —35- good for the endless line's unbounded coordinates.32+ b3CreateBody / b3CreateHullShape / b3CreateSphereShape / b3CreateMeshShape /33+ b3World_Step / b3Body_GetPosition). Double-precision positions by default34+ (b3CreateWorld = b3CreateWorldDoublePrecision) — good for the endless line's35+ unbounded coordinates.3636 - **Size**: spike .wasm 369 KB (-O2, whole library linked).3737 3838 ## M10 integration plan@@ -42,6 +42,6 @@ with a marble-sized (r=0.2) sphere rolling down 60 units, 1200 steps at
4242 - Hand-written C shim exporting exactly the calls the marble needs4343 (world/step/static hulls+meshes per piece proxy, one dynamic sphere,4444 transforms out) — keep the JS surface tiny.45-- Collision proxies per PieceDef measured from the GLBs (floor ribbon +46- wall strips as hulls; helix/curved floors as trimesh), authored in the47- registry like centerlines.45+- Collision proxies per PieceDef measured from the GLBs (floor ribbon + wall46+ strips as hulls; helix/curved floors as trimesh), authored in the registry47+ like centerlines.