guts/web/js/world/spline.js
jing 111ade7d12 [lane A] The swept room + the acid sea, built — and it corrected me four times
The shape read in LANE_A_NOTES was written from reading code, so I built it to
find out if it was true. Flyable: web/dev/laneA_world.html?dbg=1&assets=1&lvl=swept
Nothing here is imposed on anyone: every addition is additive and a level that
opts out behaves exactly as today. C can bin the whole proposal and lose nothing.

WHAT THE PROTOTYPE DISPROVED (my own cost estimates, all four):
- "radial resolution must scale with radius" — wrong. 64 segments is a 0.05u
  circle error at r=70. The arena needs it because it fbm-displaces; the tube's
  wave is smooth in theta. No change made.
- "cap geometry at the span ends" — a thinko. The canal is continuous; a room is
  a fat part of it. Only the fundus dome caps, and it stays an icosphere.
- cost — cheaper than feared: 9 draws / 98k tris worst-frame for a radius-70
  cathedral vs 8 / 74k for the L2 corridor. No leak over a full sweep.
- "one schema flag" — held. `segments[].mode: "open"` is the entire ask.

THE TWO REAL COSTS, NEITHER PREDICTED:
1. D's `tile[0]` is a COUNT, not a density — only a texel size if radius never
   changes. Measured 6.1:1 smear at r=70. The wall now derives the count from
   each ring's own arc length (aRadius attr + uThetaSpan); D's authored numbers
   keep their exact meaning at their reference radius, no re-authoring. That fix
   then laid a seam down the canal (a fractional count can't wrap) -> rounded to
   an integer, seam gone. Not a no-op on corridors: L2's hiatus takes 3 repeats
   where it took 4, which is the texel size correctly holding still as the pipe
   narrows. Eyeballed.
2. MY OWN PINCH GUARD WAS WRONG and would have blocked C. The fixture scored
   1.30 and "failed" the >1.5 law; it was never bent. stats() divided the
   tightest turn ANYWHERE by the widest radius ANYWHERE — 996 units apart, a
   bend in the radius-13 pylorus against the radius of the sea. Locally its
   worst point is 6.4. pinchRatio is now min over s of turnRadius(s)/radius(s).
   Provably one-directional (global <= local by construction) so nothing that
   passed can fail; real levels GAINED headroom: L2 3.32->4.16, L3 1.91->6.99,
   L1 2.11->6.06. This mattered — the guard is what backs the round-1 promise
   that C never has to think about curvature.

THE ACID SEA (world/acid.js, `level.acid {from,to,height,biome}`):
Flat emissive #c8ff3a, level and NOT tube-following — that's the mechanic. The
best find of the session: `height` is ONE NUMBER that tunes the level. Measured:
-18 -> 0% of the sea floor is dry shallow, -55 -> 14%, -62 -> 48%. So C's "position
IS the resource" falls out of the radius wobble they already author — the mucus
shallows are just "where the wall rises above the waterline", nothing to place —
and rising acid literally drowns them: free escalation, same scalar their event
pump drives. Also disproved my own claim: the centreline stays level (+/-1u over
700u), so shallows come from radius wobble, NOT the canal's bends. C must author
vertical bends deliberately if they want depth by anatomy.

Stated plainly, not papered over: the waterline is prototype quality (the plane
meets faceted rings and the shoreline steps). Acid does not drain the coat —
depthAt(pos) is the hook and the cost is Lane B's call. Nothing drives height but
DBG until C's events do.

qa GREEN incl. the new provenance gate; spline selfcheck green; L2 corridor
re-eyeballed for regression. Rulings requested from F in NOTES §-> Lane F.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 18:04:16 +10:00

504 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// world/spline.js (Lane A) — the math core of the canal. Deliberately imports NOTHING but
// core/rng.js: no three.js, so `node web/js/world/spline.js --selfcheck` runs it headless in
// qa.sh. index.js wraps these plain {x,y,z} numbers into THREE objects at the contract
// boundary (docs/TECH.md §THE WORLD CONTRACT).
//
// Model
// -----
// The centreline is a graph over the -Z axis: P(u) = (X(u), Y(u), -u). Two consequences we
// rely on everywhere: it can never self-intersect, and `u ≈ -pos.z` is a free, close seed for
// project(). X/Y are sums of three seeded low-frequency sines, smoothstep-blended across
// segment joins so the tangent never kinks.
//
// Amplitudes are NOT authored in units — they're solved from a curvature budget. A tube bends
// through itself when the centreline's turn radius drops below the tube radius, so `curviness`
// means "fraction of the tightest bend this pipe's radius can survive":
// kappa_budget(s) = curviness(s) / (PINCH_SAFETY * baseRadius(s))
// split across axes and octaves by fixed weights, then inverted per octave (A = w*kappa/f^2).
// A wide pipe therefore straightens itself automatically and Lane C never has to think about
// curvature. selfcheck measures the result (`pinchRatio`) rather than trusting this argument —
// it caught the first version of this file, where amplitude was authored in units and a
// curviness-0.95 join into a radius-16 segment folded the tube shut.
//
// u is NOT arclength (|dP/du| >= 1). Everything public is in arclength `s`, mapped through
// LUTs built by one forward march.
import { createRng } from '../core/rng.js';
// --- tiny vec3 (plain objects; index.js converts) ---------------------------------------
const V = (x = 0, y = 0, z = 0) => ({ x, y, z });
const sub = (a, b) => V(a.x - b.x, a.y - b.y, a.z - b.z);
const dot = (a, b) => a.x * b.x + a.y * b.y + a.z * b.z;
const len = (a) => Math.sqrt(dot(a, a));
const norm = (a) => { const l = len(a) || 1; return V(a.x / l, a.y / l, a.z / l); };
const cross = (a, b) => V(a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, a.x * b.y - a.y * b.x);
const lerpV = (a, b, t) => V(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t, a.z + (b.z - a.z) * t);
const clamp = (x, lo, hi) => (x < lo ? lo : x > hi ? hi : x);
const lerp = (a, b, t) => a + (b - a) * t;
const smoothstep = (e0, e1, x) => { const t = clamp((x - e0) / (e1 - e0), 0, 1); return t * t * (3 - 2 * t); };
// --- tuning -----------------------------------------------------------------------------
// OMEGA is the peristaltic contraction *rate*, global to the whole canal and constant in
// time — physiologically it's how often the muscle fires, which doesn't change because you
// crossed into a wider pipe. The wavenumber is what varies: k(s) = OMEGA / crestSpeed(s), so
// phase is the integral K(s) = ∫k ds (phaseAt), which stays continuous across flow changes —
// plain `k*s - w*t` does not, and tears the wave at every segment join.
// 3.08 = 0.22 rad/unit x 14 units/s, i.e. the stub's round-0 esophagus wave.
export const OMEGA = 3.08;
// Crest-speed law (TECH §FROZEN v1.1, round-2 ruling #1). Round 1 made a crest travel at
// exactly flow(s), which felt right and played wrong: B measured that surfing then loses to
// throttle-mashing (throttleMax 1.4 × flow > 1.0 × flow), so the level's signature mechanic
// was strictly dominated. The wave has to outrun the player. crestSpeed = 1.6 × flow beats
// 1.4 and stays "gameplay wave == visual wave" — the thing you see is the thing you ride.
// Consequence: crest spacing grows 1.6× (45.7u at flow 14, was 28.5u). Rings are further
// apart and move faster, which reads better on a speed level anyway.
export const CREST_FACTOR = 1.6;
const DU = 0.5; // march step in curve parameter
const DS = 0.5; // frame/phase LUT spacing in arclength
const NOISE_TAB = 1024;
const RADIUS_WAVELENGTH = 55; // units per radius-fbm octave-0 cycle
const PINCH_SAFETY = 2.2; // min turn radius = this x base radius (>2 covers wobble peaks)
// Blend widths (units) for smoothstep-crossfading segment params across a join. Nothing ever
// steps: a hard radius change would read as a level seam, and C's constrictions are supposed
// to feel like the body narrowing.
//
// `base`/`wobble` at ±25 => a 50-unit taper, which is what C's L2 was authored against
// (LANE_C_NOTES → A #4: "assumes a smooth blend over ~4060 units", their biggest open
// dependency on me). Round 1 was ±12; widened here to match the design. At L2's 12→9 step
// (s 1100) that's ~3.5s of narrowing at flow 16 — you feel the body close in, not a wall.
// `curviness`/`curvBase` feed centreline *amplitude*, which has a long lever arm: ramping it
// over a short span is itself a hard lateral swerve, i.e. curvature. They get a wider ramp.
// `waveAmp` tracks radius: a segment that is calmer is calmer over the same taper it narrows.
const BLEND = { curviness: 60, curvBase: 60, base: 25, wobble: 25, flow: 12, waveAmp: 25 };
// [wavelength, share of the axis curvature budget]. Y is tamer than X on purpose: a canal that
// writhes vertically as hard as it does laterally would swing the parallel-transport frame
// around and make Lane B's chase cam seasick. Axis shares are set so that even with every
// octave peaking together, sqrt(X^2 + Y^2) lands exactly on the budget.
const OCT_X = [[240, 0.5], [110, 0.3], [60, 0.2]];
const OCT_Y = [[190, 0.5], [85, 0.3], [45, 0.2]];
const AXIS_X = 0.8, AXIS_Y = 0.6;
/** Solve each octave's amplitude from its share of a unit curvature budget: kappa ~ A*f^2. */
function octaveCoefs(octaves, axisShare) {
return octaves.map(([wavelength, weight]) => {
const f = (2 * Math.PI) / wavelength;
return { f, coef: (weight * axisShare) / (f * f) };
});
}
const GET = {
curviness: (g) => (typeof g.curviness === 'number' ? g.curviness : 0.5),
base: (g) => (g.radius && typeof g.radius.base === 'number' ? g.radius.base : 10),
wobble: (g) => (g.radius && typeof g.radius.wobble === 'number' ? g.radius.wobble : 0.2),
flow: (g) => (typeof g.flow === 'number' ? g.flow : 10),
// schema v2 `segments[].wave: { amp }` (round-2 ruling #8, C's request): the diaphragmatic
// hiatus is a fixed muscular ring and does NOT have big peristaltic waves, but it's also the
// level's tightest hole — so biome-wide amplitude put the biggest wave in the smallest gap.
// index.js fills this from the biome registry before we ever see it, so it's always a number.
waveAmp: (g) => (g.wave && typeof g.wave.amp === 'number' ? g.wave.amp : 1.0),
};
GET.curvBase = GET.base;
/**
* Build the canal math for a level. Deterministic: same `level.seed` (or injected rng) =>
* byte-identical output, forever. See world.hash().
* @param {object} level level JSON (TECH.md §Level data schema v0)
* @param {function} [rng] core/rng.js createRng() instance; defaults to level.seed
*/
export function buildSpline(level, rng) {
if (!level || !Array.isArray(level.segments) || level.segments.length === 0)
throw new Error('[world] level.segments is required and must be non-empty');
const R = rng || createRng((level.seed ?? 0) >>> 0);
// --- segment table (declared arclength boundaries; C's events index this same s) -------
const spans = [];
let acc = 0;
for (const seg of level.segments) {
const length = typeof seg.length === 'number' && seg.length > 0 ? seg.length : 100;
spans.push({ s0: acc, s1: acc + length, seg });
acc += length;
}
const L = acc;
function idxAt(s) {
// linear scan: levels have a handful of segments, and a branchy binary search here would
// cost more than it saves.
for (let i = 0; i < spans.length; i++) if (s < spans[i].s1) return i;
return spans.length - 1;
}
/** Segment param at s, smoothstep-blended across joins so nothing steps discontinuously. */
function paramAt(s, key) {
const get = GET[key], w = BLEND[key];
const i = idxAt(s);
const sp = spans[i];
const v = get(sp.seg);
if (i > 0 && s < sp.s0 + w)
return lerp(get(spans[i - 1].seg), v, smoothstep(sp.s0 - w, sp.s0 + w, s));
if (i < spans.length - 1 && s > sp.s1 - w)
return lerp(v, get(spans[i + 1].seg), smoothstep(sp.s1 - w, sp.s1 + w, s));
return v;
}
// --- seeded noise ---------------------------------------------------------------------
const tab = new Float32Array(NOISE_TAB);
{ const r = R('world.radius'); for (let i = 0; i < NOISE_TAB; i++) tab[i] = r() * 2 - 1; }
function vnoise(x) {
const i = Math.floor(x), f = x - i;
const a = tab[i & (NOISE_TAB - 1)], b = tab[(i + 1) & (NOISE_TAB - 1)];
return lerp(a, b, f * f * (3 - 2 * f));
}
function fbm1(x) {
let sum = 0, amp = 1, freq = 1, normSum = 0;
for (let o = 0; o < 3; o++) { sum += amp * vnoise(x * freq + o * 17.3); normSum += amp; amp *= 0.5; freq *= 2.03; }
return sum / normSum; // [-1, 1]
}
const radiusAt = (s) => paramAt(s, 'base') * (1 + paramAt(s, 'wobble') * fbm1(s / RADIUS_WAVELENGTH));
// --- centreline -----------------------------------------------------------------------
const cx = octaveCoefs(OCT_X, AXIS_X), cy = octaveCoefs(OCT_Y, AXIS_Y);
const phX = cx.map(() => R('world.spline')() * Math.PI * 2);
const phY = cy.map(() => R('world.spline')() * Math.PI * 2);
/** How hard the canal is allowed to bend at s: curviness as a fraction of the pinch limit. */
const curvBudgetAt = (s) => paramAt(s, 'curviness') / (PINCH_SAFETY * Math.max(1, paramAt(s, 'curvBase')));
/** `budget` is kappa (1/units), not a length — octave coefs turn it into lateral offsets. */
function centreWith(u, budget) {
let x = 0, y = 0;
for (let i = 0; i < cx.length; i++) x += budget * cx[i].coef * Math.sin(u * cx[i].f + phX[i]);
for (let i = 0; i < cy.length; i++) y += budget * cy[i].coef * Math.sin(u * cy[i].f + phY[i]);
return V(x, y, -u);
}
// --- the march: u -> s, and the curvature budget sampled along it ----------------------
// The budget is a function of arclength (that's where segments are declared), but the curve
// is evaluated in u — so we resolve it during the march, where s is already known, and
// cache the schedule per u-step. One pass, no circularity.
const uToS = [0];
const ampArr = [curvBudgetAt(0)];
{
let s = 0, prev = centreWith(0, ampArr[0]), n = 0;
const MAXN = Math.ceil((L / DU) * 1.5) + 16;
while (s < L && n < MAXN) {
n++;
const amp = curvBudgetAt(s); // s lags one step: deterministic, sub-step error
const p = centreWith(n * DU, amp);
s += len(sub(p, prev));
uToS.push(s); ampArr.push(amp);
prev = p;
}
}
const uMax = (uToS.length - 1) * DU;
const ampAtU = (u) => {
const t = clamp(u / DU, 0, ampArr.length - 1), i = Math.min(Math.floor(t), ampArr.length - 2);
return lerp(ampArr[i], ampArr[i + 1], t - i);
};
const centre = (u) => centreWith(u, ampAtU(u));
const sOfU = (u) => {
const t = clamp(u / DU, 0, uToS.length - 1), i = Math.min(Math.floor(t), uToS.length - 2);
return lerp(uToS[i], uToS[i + 1], t - i);
};
// uniform s -> u table, inverted by a monotone merge-walk (uToS is strictly increasing)
const M = Math.ceil(L / DS) + 1;
const sToU = new Float64Array(M);
{
let j = 0;
for (let i = 0; i < M; i++) {
const target = Math.min(i * DS, L);
while (j < uToS.length - 2 && uToS[j + 1] < target) j++;
const s0 = uToS[j], s1 = uToS[j + 1];
const f = s1 > s0 ? (target - s0) / (s1 - s0) : 0;
sToU[i] = (j + f) * DU;
}
}
const uOfS = (s) => {
const t = clamp(s, 0, L) / DS, i = Math.min(Math.floor(t), M - 2);
return lerp(sToU[i], sToU[i + 1], t - i);
};
// --- frames: parallel transport, NOT Frenet -------------------------------------------
// Frenet's normal flips through inflection points; on a canal that writhes that shows up as
// the camera snapping upside-down mid-corridor (LANE_A_WORLD.md calls this out by name).
// Parallel transport carries the previous normal forward by the minimal rotation, so it's
// history-dependent => must be integrated once here and looked up, never recomputed ad hoc.
const posArr = new Float64Array(M * 3);
const tanArr = new Float64Array(M * 3);
const norArr = new Float64Array(M * 3);
{
const H = 0.25;
const tangentAtU = (u) => {
const a = centre(Math.max(0, u - H)), b = centre(Math.min(uMax, u + H));
return norm(sub(b, a));
};
const put = (arr, i, v) => { arr[i * 3] = v.x; arr[i * 3 + 1] = v.y; arr[i * 3 + 2] = v.z; };
let prevT = tangentAtU(uOfS(0));
// seed the frame from world up; the canal is a graph over -Z so it can never be vertical
let n = norm(sub(V(0, 1, 0), V(prevT.x * prevT.y, prevT.y * prevT.y, prevT.z * prevT.y)));
put(posArr, 0, centre(uOfS(0))); put(tanArr, 0, prevT); put(norArr, 0, n);
for (let i = 1; i < M; i++) {
const u = uOfS(Math.min(i * DS, L));
const p = centre(u), t = tangentAtU(u);
const axis = cross(prevT, t);
const sinA = len(axis), cosA = dot(prevT, t);
if (sinA > 1e-9) { // Rodrigues: rotate n by the frame's own turn
const k = norm(axis), angle = Math.atan2(sinA, cosA);
const c = Math.cos(angle), sN = Math.sin(angle), kv = cross(k, n), kd = dot(k, n);
n = V(n.x * c + kv.x * sN + k.x * kd * (1 - c),
n.y * c + kv.y * sN + k.y * kd * (1 - c),
n.z * c + kv.z * sN + k.z * kd * (1 - c));
}
n = norm(V(n.x - t.x * dot(n, t), n.y - t.y * dot(n, t), n.z - t.z * dot(n, t))); // re-orthogonalize
put(posArr, i, p); put(tanArr, i, t); put(norArr, i, n);
prevT = t;
}
}
const at = (arr, i) => V(arr[i * 3], arr[i * 3 + 1], arr[i * 3 + 2]);
// --- peristalsis phase: K(s) = ∫ k dx, trapezoid on the same grid ----------------------
const crestSpeedAt = (s) => CREST_FACTOR * Math.max(1, paramAt(s, 'flow'));
const kAt = (s) => OMEGA / crestSpeedAt(s);
const waveAmpAt = (s) => paramAt(s, 'waveAmp');
const phaseArr = new Float64Array(M);
for (let i = 1; i < M; i++)
phaseArr[i] = phaseArr[i - 1] + 0.5 * (kAt((i - 1) * DS) + kAt(i * DS)) * DS;
const phaseAt = (s) => {
const t = clamp(s, 0, L) / DS, i = Math.min(Math.floor(t), M - 2);
return lerp(phaseArr[i], phaseArr[i + 1], t - i);
};
/** Frame at arclength s. pos/tan/nor/bin + geometric radius. */
function frameAt(s) {
const t = clamp(s, 0, L) / DS, i = Math.min(Math.floor(t), M - 2), f = t - i;
const pos = lerpV(at(posArr, i), at(posArr, i + 1), f);
const tan = norm(lerpV(at(tanArr, i), at(tanArr, i + 1), f));
let nor = lerpV(at(norArr, i), at(norArr, i + 1), f);
nor = norm(V(nor.x - tan.x * dot(nor, tan), nor.y - tan.y * dot(nor, tan), nor.z - tan.z * dot(nor, tan)));
return { pos, tan, nor, bin: cross(tan, nor), radius: radiusAt(s) };
}
/**
* World -> spline space. Seeded from u = -pos.z (exact for the centreline's own axis, so
* the seed error is only the lateral tilt), then fixed-point: s += (p - C(s))·T(s).
* Converges at rate ~ curvature*rho (<0.5 in tube mode). Step-clamped so a point far off
* the centreline (arena mode) walks instead of exploding.
*/
function project(p, hint) {
let s = typeof hint === 'number' ? clamp(hint, 0, L) : sOfU(clamp(-p.z, 0, uMax));
for (let i = 0; i < 6; i++) {
const f = frameAt(s);
const d = dot(sub(p, f.pos), f.tan);
const next = clamp(s + clamp(d, -8, 8), 0, L);
if (Math.abs(next - s) < 1e-4) { s = next; break; }
s = next;
}
const f = frameAt(s);
const rel = sub(p, f.pos);
const along = dot(rel, f.tan);
return {
s,
theta: Math.atan2(dot(rel, f.bin), dot(rel, f.nor)),
rho: Math.sqrt(Math.max(0, dot(rel, rel) - along * along)), // true cross-section radius
};
}
/** Golden determinism hash over sampled frames + radius + wave phase. qa.sh compares. */
function hash() {
let h = 2166136261 >>> 0;
const push = (x) => { h ^= Math.round(x * 1000) & 0xffff; h = Math.imul(h, 16777619); };
for (let s = 0; s <= L; s += 10) {
const f = frameAt(s);
push(f.pos.x); push(f.pos.y); push(f.pos.z); push(f.radius);
push(f.nor.x); push(f.nor.y); push(f.nor.z); push(phaseAt(s));
}
return (h >>> 0).toString(16);
}
/**
* Measured geometry health — the pinch guard is the one that bites.
*
* `pinchRatio` is LOCAL: the worst turnRadius(s)/radius(s) over the canal, i.e. the tightest
* bend measured against the tube's radius AT THAT s. It used to be global — min turn radius
* anywhere ÷ max radius anywhere — which is the same number whenever radius is roughly
* constant (every corridor level: L2 and L3 are unaffected) and is nonsense the moment it
* isn't. Round 2's swept-room prototype scored 1.30 and "failed": it was dividing a bend in
* the radius-13 pylorus by the radius of the sea 996 units away, two places that cannot fold
* into each other. Locally its worst point is 6.4. The old metric could only ever cry wolf,
* never miss a real fold — but a false alarm on the pinch guard is not harmless: the guard is
* what lets Lane C author `curviness` without thinking about curvature (round-1 decision #1),
* and a guard that fails valid rooms takes that guarantee away exactly when C needs it.
*
* The change is provably one-directional — global min turn ÷ global max radius is <= the
* local ratio everywhere by construction — so nothing that passed the round-1 guard can fail
* this one. Real levels gained headroom, they didn't lose it: L2 3.32 -> 4.16, L3 1.91 -> 6.99.
*
* Neither metric has ever covered NON-LOCAL self-intersection — the canal looping back and
* passing through itself far from where it bent. That needs a proximity test over the whole
* centreline, not a curvature test, and no level has come close to needing one. Stated so the
* gap is known rather than assumed away.
*/
function stats() {
let maxCurvature = 0, sAtMax = 0, maxRadius = 0, minRadius = Infinity;
for (let i = 1; i < M - 1; i++) {
const k = len(sub(at(tanArr, i + 1), at(tanArr, i - 1))) / (2 * DS);
if (k > maxCurvature) { maxCurvature = k; sAtMax = i * DS; }
}
for (let s = 0; s <= L; s += 1) {
const r = radiusAt(s);
if (r > maxRadius) maxRadius = r;
if (r < minRadius) minRadius = r;
}
// The local guard, on the same grid the curvature is measured on.
let pinchRatio = Infinity, sAtPinch = 0;
for (let i = 1; i < M - 1; i++) {
const k = len(sub(at(tanArr, i + 1), at(tanArr, i - 1))) / (2 * DS);
const s = i * DS;
const ratio = (k > 0 ? 1 / k : Infinity) / Math.max(0.001, radiusAt(s));
if (ratio < pinchRatio) { pinchRatio = ratio; sAtPinch = s; }
}
const minTurnRadius = maxCurvature > 0 ? 1 / maxCurvature : Infinity;
return {
length: L, gridPoints: M, uMax,
maxCurvature, sAtMaxCurvature: sAtMax, minTurnRadius,
minRadius, maxRadius,
pinchRatio, // <1 => the tube folds through itself on a bend
sAtPinch, // where to look when it does
pinchRatioGlobal: minTurnRadius / maxRadius, // the old number, kept for comparison only
};
}
return {
length: L, spans, uMax,
centre, sOfU, uOfS, ampAtU,
radiusAt, frameAt, project, phaseAt, kAt, paramAt,
crestSpeedAt, waveAmpAt,
omega: OMEGA,
hash, stats,
segmentAt: (s) => spans[idxAt(clamp(s, 0, L))].seg,
};
}
// ---------------------------------------------------------------------------------------
// headless selfcheck: `node web/js/world/spline.js --selfcheck` (qa.sh gate)
// Browsers never reach this (no `process`).
// ---------------------------------------------------------------------------------------
async function selfcheck() {
const { FIXTURE } = await import('./_fixture.js');
let fail = 0;
const ok = (name, cond, detail = '') => {
if (cond) console.log(` \x1b[32m✓\x1b[0m ${name}${detail ? ' — ' + detail : ''}`);
else { fail++; console.log(` \x1b[31m✗\x1b[0m ${name}${detail ? ' — ' + detail : ''}`); }
};
console.log('world/spline selfcheck (fixture: %s)', FIXTURE.id);
const a = buildSpline(FIXTURE);
const b = buildSpline(FIXTURE);
const c = buildSpline({ ...FIXTURE, seed: FIXTURE.seed + 1 });
const st = a.stats();
ok('determinism: same seed => same hash', a.hash() === b.hash(), a.hash());
ok('determinism: different seed => different hash', a.hash() !== c.hash(), `${a.hash()} vs ${c.hash()}`);
const declared = FIXTURE.segments.reduce((n, s) => n + s.length, 0);
ok('arclength: length == declared total', Math.abs(a.length - declared) < 1e-6, `${a.length}`);
let mono = true, prev = -1;
for (let s = 0; s <= a.length; s += 1) { const u = a.uOfS(s); if (u < prev) mono = false; prev = u; }
ok('arclength: s -> u monotone', mono);
let maxRt = 0;
for (let s = 0; s <= a.length; s += 3) maxRt = Math.max(maxRt, Math.abs(a.sOfU(a.uOfS(s)) - s));
ok('arclength: s -> u -> s round-trip < 0.05', maxRt < 0.05, `max err ${maxRt.toExponential(2)}`);
let orth = 0, unit = 0;
for (let s = 0; s <= a.length; s += 2) {
const f = a.frameAt(s);
orth = Math.max(orth, Math.abs(f.tan.x * f.nor.x + f.tan.y * f.nor.y + f.tan.z * f.nor.z));
unit = Math.max(unit, Math.abs(Math.hypot(f.nor.x, f.nor.y, f.nor.z) - 1), Math.abs(Math.hypot(f.bin.x, f.bin.y, f.bin.z) - 1));
}
ok('frames: orthonormal (tan·nor ~ 0, |nor| = |bin| = 1)', orth < 1e-9 && unit < 1e-9,
`tan·nor ${orth.toExponential(1)}, unit err ${unit.toExponential(1)}`);
// parallel transport must not spin the frame up: total twist stays bounded & smooth
let maxTwist = 0;
for (let s = 1; s <= a.length; s += 1) {
const p = a.frameAt(s - 1).nor, q = a.frameAt(s).nor;
maxTwist = Math.max(maxTwist, Math.acos(Math.min(1, Math.abs(p.x * q.x + p.y * q.y + p.z * q.z))));
}
ok('frames: no roll snap (max per-unit normal turn < 0.2 rad)', maxTwist < 0.2, `${maxTwist.toFixed(4)} rad`);
ok('geometry: tube does not pinch (LOCAL turnRadius(s) / radius(s) > 1.5)', st.pinchRatio > 1.5,
`worst at s=${st.sAtPinch.toFixed(0)} => ${st.pinchRatio.toFixed(2)}x` +
` (global min/max ratio, the round-1 metric, would say ${st.pinchRatioGlobal.toFixed(2)}x)`);
// project() round-trip: place a point at a known (s, theta, rho), recover it
let maxSerr = 0, maxTerr = 0, maxRerr = 0;
for (let s = 5; s < a.length - 5; s += 7) {
for (const theta of [0, 1.3, -2.4, 3.0]) {
const rho = a.radiusAt(s) * 0.6;
const f = a.frameAt(s);
const p = {
x: f.pos.x + (f.nor.x * Math.cos(theta) + f.bin.x * Math.sin(theta)) * rho,
y: f.pos.y + (f.nor.y * Math.cos(theta) + f.bin.y * Math.sin(theta)) * rho,
z: f.pos.z + (f.nor.z * Math.cos(theta) + f.bin.z * Math.sin(theta)) * rho,
};
const q = a.project(p);
maxSerr = Math.max(maxSerr, Math.abs(q.s - s));
let dt = Math.abs(q.theta - theta) % (Math.PI * 2);
if (dt > Math.PI) dt = Math.PI * 2 - dt;
maxTerr = Math.max(maxTerr, dt);
maxRerr = Math.max(maxRerr, Math.abs(q.rho - rho));
}
}
ok('project: recovers s within 0.25', maxSerr < 0.25, `max ${maxSerr.toFixed(4)}`);
ok('project: recovers theta within 0.02 rad', maxTerr < 0.02, `max ${maxTerr.toFixed(5)}`);
ok('project: recovers rho within 0.05', maxRerr < 0.05, `max ${maxRerr.toFixed(4)}`);
// wave: phase monotone, and the crest-speed law (TECH FROZEN v1.1)
let phaseMono = true;
for (let s = 1; s <= a.length; s += 1) if (a.phaseAt(s) <= a.phaseAt(s - 1)) phaseMono = false;
ok('wave: phase K(s) strictly increasing', phaseMono);
const kEso = a.kAt(50), kSto = a.kAt(a.length - 20);
ok('wave: k == OMEGA/(CREST_FACTOR*flow)', Math.abs(kEso - OMEGA / (CREST_FACTOR * 14)) < 1e-6, `k=${kEso.toFixed(4)}`);
ok('wave: crestSpeed == 1.6 x local flow, in every biome',
Math.abs(a.crestSpeedAt(50) - 1.6 * 14) < 0.01 && Math.abs(a.crestSpeedAt(a.length - 20) - 1.6 * 4) < 0.01,
`${a.crestSpeedAt(50).toFixed(2)} u/s eso (flow 14), ${a.crestSpeedAt(a.length - 20).toFixed(2)} u/s stomach (flow 4)`);
// the law exists to make surfing the fast line — assert the thing B actually depends on
ok('wave: crest outruns a throttle-mashing player (crestSpeed > 1.4 x flow)',
a.crestSpeedAt(50) > 1.4 * 14, `${a.crestSpeedAt(50).toFixed(1)} > ${(1.4 * 14).toFixed(1)} u/s`);
ok('wave: OMEGA/kAt(s) == crestSpeedAt(s) (phase and speed agree)',
Math.abs(OMEGA / a.kAt(300) - a.crestSpeedAt(300)) < 1e-9);
// per-segment wave override (schema v2): fixture segment 3 declares wave.amp 0.55
const segWave = buildSpline({
...FIXTURE,
segments: FIXTURE.segments.map((s, i) => (i === 2 ? { ...s, wave: { amp: 0.55 } } : { ...s, wave: { amp: 1.4 } })),
});
ok('wave: per-segment amp override honoured', Math.abs(segWave.waveAmpAt(650) - 0.55) < 1e-6,
`amp at s=650 => ${segWave.waveAmpAt(650).toFixed(3)}`);
ok('wave: amp blends across the join (no step)',
Math.abs(segWave.waveAmpAt(540) - (1.4 + 0.55) / 2) < 0.02,
`amp at the join => ${segWave.waveAmpAt(540).toFixed(3)} (midpoint of 1.4 and 0.55)`);
// C's biggest dependency: radius must taper, not cliff (LANE_C_NOTES → A #4)
let maxRadiusStep = 0;
for (let s = 1; s <= a.length; s += 0.5) maxRadiusStep = Math.max(maxRadiusStep, Math.abs(a.radiusAt(s) - a.radiusAt(s - 0.5)));
ok('radius: no cliff at segment joins (max step < 0.15 u per 0.5 u)', maxRadiusStep < 0.15,
`max ${maxRadiusStep.toFixed(4)} u/step`);
console.log(` stats: L=${st.length} grid=${st.gridPoints} maxK=${st.maxCurvature.toFixed(4)} (s=${st.sAtMaxCurvature}) radius=${st.minRadius.toFixed(1)}..${st.maxRadius.toFixed(1)} hash=${a.hash()}`);
console.log(fail === 0 ? '\x1b[32mworld/spline: OK\x1b[0m' : `\x1b[31mworld/spline: ${fail} FAILED\x1b[0m`);
return fail;
}
if (typeof process !== 'undefined' && process.argv && process.argv.includes('--selfcheck')) {
selfcheck().then((f) => process.exit(f === 0 ? 0 : 1));
}