clone

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

main
marbles / tests / frame_test.ts
frame_test.ts 4.3K
1import { assert, assertAlmostEquals } from "@std/assert";2import {3 AFFINE_IDENTITY,4 affineApply,5 affineUnapply,6 composeTwist,7 frameUp,8 isIdentityAffine,9 quatFromAxisAngle,10 quatMul,11 quatRotate,12 quatUnrotate,13} from "../src/shared/frame.ts";14import {15 type Rail,16 stationGravityDelta,17 stationWorldPoint,18 stationWorldTan,19} from "../src/client/physics/rail.ts";2021const close = (22 a: readonly number[],23 b: readonly number[],24 eps = 1e-9,25): void => {26 for (let i = 0; i < a.length; i++) assertAlmostEquals(a[i], b[i], eps);27};2829Deno.test("quat rotate matches axis-angle expectations", () => {30 // 90° about +x maps +y → +z (right-handed).31 const q = quatFromAxisAngle([1, 0, 0], Math.PI / 2);32 close(quatRotate(q, [0, 1, 0]), [0, 0, 1]);33 // 180° about +z maps +x → −x.34 const q2 = quatFromAxisAngle([0, 0, 1], Math.PI);35 close(quatRotate(q2, [1, 0, 0]), [-1, 0, 0]);36});3738Deno.test("unrotate inverts rotate; mul composes", () => {39 const a = quatFromAxisAngle([0, 1, 0], 0.7);40 const b = quatFromAxisAngle([1, 0, 0], -1.2);41 const v: [number, number, number] = [0.3, -1.7, 2.2];42 close(quatUnrotate(a, quatRotate(a, v)), v);43 // (a·b) applied = a applied after b.44 close(quatRotate(quatMul(a, b), v), quatRotate(a, quatRotate(b, v)));45});4647Deno.test("affine apply/unapply round-trip", () => {48 const f = composeTwist(AFFINE_IDENTITY, [1, 0, 0], Math.PI / 2, [0, 3, 5]);49 const p: [number, number, number] = [4, 1, -2];50 close(affineUnapply(f, affineApply(f, p)), p);51});5253Deno.test("twist about the travel axis keeps the pivot line fixed", () => {54 // Travel along +x at pivot (0, 2, 0): any point ON the axis line maps55 // to itself; a point 1 below the line maps 1 to the side (90° roll).56 const f = composeTwist(AFFINE_IDENTITY, [1, 0, 0], Math.PI / 2, [0, 2, 0]);57 close(affineApply(f, [7, 2, 0]), [7, 2, 0]);58 const side = affineApply(f, [7, 1, 0]); // 1 below the line59 assertAlmostEquals(Math.hypot(side[1] - 2, side[2]), 1, 1e-9);60 assertAlmostEquals(side[0], 7, 1e-9);61 // Local up after a 90° roll about +x: +y → +z.62 close(frameUp(f), [0, 0, 1]);63});6465Deno.test("two 90° twists compose to a 180° frame", () => {66 const f1 = composeTwist(AFFINE_IDENTITY, [1, 0, 0], Math.PI / 2, [0, 0, 0]);67 const f2 = composeTwist(f1, [1, 0, 0], Math.PI / 2, [10, 0, 0]);68 const up = frameUp(f2);69 close(up, [0, -1, 0]);70 // Ceiling world: plan-down (net-descent) now maps to world-up.71 close(quatRotate(f2.q, [0, -1, 0]), [0, 1, 0]);72});7374Deno.test("second twist axis follows the presented frame", () => {75 // After a 90° roll about +x, the line still travels +x; a later twist76 // about the CURRENT world travel axis composes cleanly and the pivot77 // continuity holds at the second twist point.78 const f1 = composeTwist(AFFINE_IDENTITY, [1, 0, 0], Math.PI / 2, [0, 0, 0]);79 const pivotPlan: [number, number, number] = [6, 0, 0];80 const pivotWorld = affineApply(f1, pivotPlan);81 const f2 = composeTwist(f1, [1, 0, 0], -Math.PI / 2, pivotWorld);82 close(affineApply(f2, pivotPlan), pivotWorld);83 assert(isIdentityAffine(AFFINE_IDENTITY));84 // Roll then unroll: back to identity orientation.85 close(frameUp(f2), [0, 1, 0]);86});8788Deno.test("rail frames: gravity delta pulls a wall-rider into its floor", () => {89 // One-station rail heading +x, rolled 90° about the travel axis: the90 // marble rides what the world sees as a wall; local gravity must point91 // along world −z (plan −y rotated), and the per-marble delta force is92 // m·(F·g0 − g0).93 const frame = composeTwist(AFFINE_IDENTITY, [1, 0, 0], Math.PI / 2, [94 0,95 0,96 0,97 ]);98 const rail: Rail = {99 pts: [[0, 0, 0], [0.5, 0, 0]],100 tans: [[1, 0, 0], [1, 0, 0]],101 s: [0, 0.5],102 mods: [null, null],103 frames: frame,104 };105 const d = stationGravityDelta(rail, 0, 2);106 if (!d) throw new Error("expected a delta in a reoriented frame");107 close(d, [0, 40, -40]);108 // Identity rail: no delta (the fast path must survive the frame hook).109 close(110 [111 stationGravityDelta({ ...rail, frames: undefined }, 0, 2) === null112 ? 1113 : 0,114 ],115 [1],116 );117 // World helpers: a plan point 1 below the axis presents 1 to the side.118 close(stationWorldPoint(rail, 0), [0, 0, 0]);119 close(120 stationWorldPoint({ ...rail, pts: [[0, -1, 0], [0.5, -1, 0]] }, 0),121 [0, 0, -1],122 );123 close(stationWorldTan(rail, 0), [1, 0, 0]);124});