Landed before the cut: crestSpeed(s) + CREST_FACTOR 1.6 (ruling #1) with selfcheck asserts; radius blend widened +/-12 -> +/-25 (C's #4 dependency) + no-cliff selfcheck; per-segment wave.amp override as per-vertex aWaveA (ruling #8); colorspace law in the wall shader (ruling #2); TBN normal maps + matcap + dual detail layers (D's perturb(), trap documented in-shader); sample(s, out) v1.2; slug map shrunk to the two real mismatches. Evidence: docs/shots/laneA/round2_L2_*.png. Cut off before: NOTES/progress, stomach-arena shape read for C (task #7). Committed by F to protect the shared tree; spline + qa selfchecks GREEN at commit time. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
471 lines
24 KiB
JavaScript
471 lines
24 KiB
JavaScript
// 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 ~40–60 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. */
|
||
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;
|
||
}
|
||
const minTurnRadius = maxCurvature > 0 ? 1 / maxCurvature : Infinity;
|
||
return {
|
||
length: L, gridPoints: M, uMax,
|
||
maxCurvature, sAtMaxCurvature: sAtMax, minTurnRadius,
|
||
minRadius, maxRadius,
|
||
pinchRatio: minTurnRadius / maxRadius, // <1 => tube folds through itself on a bend
|
||
};
|
||
}
|
||
|
||
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 (minTurnRadius / maxRadius > 1.5)', st.pinchRatio > 1.5,
|
||
`turn R ${st.minTurnRadius.toFixed(1)} vs tube R ${st.maxRadius.toFixed(1)} => ${st.pinchRatio.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));
|
||
}
|