1/**2 * Quantitative collision-fidelity CI (T6, collision-mesh audit3 * 2026-08-09; extended for the re-audit's coverage gaps): turns the4 * piece gallery from a visual regression tool into a measured one.5 * Every registry variant is built bench-style through the ONE shared6 * proxy builder, then checked with hard per-metric budgets:7 *8 * - signed floor-height error of every ribbon ring vs the centerline9 * - lateral half-width of every ring (wall line + wide shift window)10 * - rim cap height over the floor (guard present, never a guard WALL)11 * - RENDER-derived directed distance: actual GLB channel-surface12 * vertices (placement-transformed, corridor-filtered) must lie13 * within budget of the collision surface — the truth source is the14 * render mesh itself, not just centerline + constants15 * - aperture openness over every floor bore (straight-hole, drop tops)16 * - authored drop compound probes (cup arches, shaft, receiver, pan)17 * - diamond compound probes (branch decks, outer/wedge walls, clear lanes)18 * - cross junction probes (deck, open mouths, walled corners)19 * - per-variant coverage accounting: every variant reports which20 * metric families evaluated it; bench-build failures fail loudly21 *22 * Budgets are world units. The kit truths behind them (wall inner face23 * 0.574, rim floor+0.5, bore r 0.5, wide shift 0.576) were measured24 * headlessly from assets/marble-kit.glb — see docs/collision-mesh-audit.md25 * and docs/collision-mesh-reaudit.md.26 */27import { assert } from "@std/assert";28import { NodeIO } from "@gltf-transform/core";29import { KHRONOS_EXTENSIONS } from "@gltf-transform/extensions";30import { buildRegistry, type PieceDef } from "../src/gen/registry.ts";31import { assemble } from "../src/gen/assemble.ts";32import { pickStyle } from "../src/gen/style.ts";33import { rngFamily } from "../src/gen/rng.ts";34import {35 DIR_STEP,36 segLength,37 segPoint,38 turnLeft,39 turnRight,40} from "../src/gen/geom.ts";41import { scaleSeg, scaleVec, TRACK_SCALE } from "../src/gen/endless/scale.ts";42import {43 CHANNEL_PROFILE,44 type CollisionProxy,45 type MeshProxy,46 segmentProxies,47 type SlabProxy,48} from "../src/gen/endless/proxies.ts";49import type { Skeleton, SkeletonCell } from "../src/gen/topology.ts";50import type { ElevationPlan } from "../src/gen/elevation.ts";51import type { Dir, LoopPath } from "../src/shared/types.ts";5253// ---------------------------------------------------------------------------54// budgets (world units)55// ---------------------------------------------------------------------------56/** Ribbon floor vs centerline. The crease-rounding law (kicker57 * prevention) deliberately cuts pitch-V corners with chord rings — up58 * to ~0.04 on the steepest slant crest; that is intended forgiveness59 * geometry, so the budget sits just above it. */60const FLOOR_ERR = 0.05;61/** Ring lateral half-width window: rim line 0.66 (+0.576 wide shift,62 * ×1.35 catch flare) with slack for pitched frames. */63const LAT_MIN = 0.55;64const LAT_MAX = 1.40;65/** Rim cap over the ring floor (vertical; pitched frames foreshorten). */66const CAP_MIN = 0.30;67const CAP_MAX = 0.80;68/** Bore probes: no collision surface top inside this radius at deck69 * level; the bore itself is r 0.5. */70const BORE_CLEAR_R = 0.40;71/** Lip probes ride just outside the bore. */72const HOLE_PROBE_R = 0.75;73/** Render→collision directed distance: every corridor render vertex74 * must have collision surface within this. Known honest gaps set the75 * level: the omitted 0.07 bore side slivers put straight-hole rim-side76 * verts ~0.17 from the guide ring, and the profile's 3-point gutter77 * approximates the kit's curved fillet. Still 2–6× tighter than the78 * pre-remediation defect classes (0.4–1.3). */79const RENDER_DIST = 0.2;8081// ---------------------------------------------------------------------------82// bench-style build (the piece-bench path — one window per variant)83// ---------------------------------------------------------------------------84interface BenchPlacement {85 pieceId: string;86 pos: readonly number[];87 rotation: Dir;88 mirrored?: boolean;89}9091function benchBuild(92 def: PieceDef,93): { path: LoopPath; placements: BenchPlacement[] } {94 const cells: SkeletonCell[] = [];95 let x = 0;96 let z = 0;97 let dir: Dir = 1;98 for (const m of def.moves) {99 const move = m as "F" | "L" | "R";100 const dirOut: Dir = move === "F"101 ? dir102 : move === "L"103 ? turnLeft(dir)104 : turnRight(dir);105 cells.push({ x, z, dirIn: dir, dirOut, move });106 dir = dirOut;107 x += DIR_STEP[dirOut][0];108 z += DIR_STEP[dirOut][1];109 }110 const skeleton: Skeleton = { cells, crossings: [], helixLoops: [] };111 const elevation: ElevationPlan = {112 baseY: cells.map((_, j) => (j === 0 ? 0 : def.deckDelta)),113 reservations: [],114 maxBaseY: Math.max(0, def.deckDelta),115 };116 const style = pickStyle(rngFamily("bench", "style"));117 const out = assemble(skeleton, elevation, style, [118 { start: 0, len: cells.length, def },119 ], { open: true });120 return {121 path: {122 segments: out.loop.segments.map((s) => ({123 ...s,124 curve: s.curve.map(scaleSeg),125 length: s.length * TRACK_SCALE,126 ...(s.holes ? { holes: s.holes.map(scaleVec) } : {}),127 })),128 totalLength: out.loop.totalLength * TRACK_SCALE,129 },130 placements: out.placements.map((p) => ({131 pieceId: p.pieceId,132 pos: scaleVec(p.pos as [number, number, number]),133 rotation: p.rotation,134 ...(p.mirrored ? { mirrored: true } : {}),135 })),136 };137}138139/** Fine centerline samples: main line, alt branches, and (implicitly)140 * the vertical dive columns — every part is sampled. */141function centerlineSamples(path: LoopPath): [number, number, number][] {142 const pts: [number, number, number][] = [];143 const sample = (parts: LoopPath["segments"][number]["curve"]) => {144 for (const part of parts) {145 const len = segLength(part);146 const steps = Math.max(2, Math.ceil(len / 0.1));147 for (let i = 0; i <= steps; i++) {148 const p = segPoint(part, i / steps);149 pts.push([p[0], p[1], p[2]]);150 }151 }152 };153 for (const seg of path.segments) {154 sample(seg.curve);155 if (seg.alt) sample(seg.alt.curve);156 }157 return pts;158}159160// ---------------------------------------------------------------------------161// geometry queries162// ---------------------------------------------------------------------------163/** Point-in-slab (yaw/pitch YXZ, matching the bench overlay/render). */164function slabContains(165 s: SlabProxy,166 p: readonly [number, number, number],167): boolean {168 const l = slabLocal(s, p);169 return Math.abs(l[0]) <= s.half[0] + 1e-6 &&170 Math.abs(l[1]) <= s.half[1] + 1e-6 &&171 Math.abs(l[2]) <= s.half[2] + 1e-6;172}173174function slabLocal(175 s: SlabProxy,176 p: readonly [number, number, number],177): [number, number, number] {178 let dx = p[0] - s.center[0];179 let dy = p[1] - s.center[1];180 let dz = p[2] - s.center[2];181 const cy = Math.cos(-s.yaw);182 const sy = Math.sin(-s.yaw);183 const x1 = dx * cy + dz * sy;184 const z1 = -dx * sy + dz * cy;185 dx = x1;186 dz = z1;187 const cp = Math.cos(-s.pitch);188 const sp = Math.sin(-s.pitch);189 const y2 = dy * cp - dz * sp;190 const z2 = dy * sp + dz * cp;191 dy = y2;192 dz = z2;193 return [dx, dy, dz];194}195196/** Squared distance from a point to a slab's box. */197function slabDist2(s: SlabProxy, p: readonly [number, number, number]): number {198 const l = slabLocal(s, p);199 const dx = Math.max(0, Math.abs(l[0]) - s.half[0]);200 const dy = Math.max(0, Math.abs(l[1]) - s.half[1]);201 const dz = Math.max(0, Math.abs(l[2]) - s.half[2]);202 return dx * dx + dy * dy + dz * dz;203}204205/** Squared distance from point p to triangle abc (Ericson, RTCD 5.1.5). */206function triDist2(207 p: readonly [number, number, number],208 a: readonly [number, number, number],209 b: readonly [number, number, number],210 c: readonly [number, number, number],211): number {212 const ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];213 const ac = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];214 const ap = [p[0] - a[0], p[1] - a[1], p[2] - a[2]];215 const d1 = ab[0] * ap[0] + ab[1] * ap[1] + ab[2] * ap[2];216 const d2 = ac[0] * ap[0] + ac[1] * ap[1] + ac[2] * ap[2];217 const at = (218 q: readonly [number, number, number],219 ): number => (p[0] - q[0]) ** 2 + (p[1] - q[1]) ** 2 + (p[2] - q[2]) ** 2;220 if (d1 <= 0 && d2 <= 0) return at(a);221 const bp = [p[0] - b[0], p[1] - b[1], p[2] - b[2]];222 const d3 = ab[0] * bp[0] + ab[1] * bp[1] + ab[2] * bp[2];223 const d4 = ac[0] * bp[0] + ac[1] * bp[1] + ac[2] * bp[2];224 if (d3 >= 0 && d4 <= d3) return at(b);225 const vc = d1 * d4 - d3 * d2;226 if (vc <= 0 && d1 >= 0 && d3 <= 0) {227 const t = d1 / (d1 - d3);228 return at([a[0] + ab[0] * t, a[1] + ab[1] * t, a[2] + ab[2] * t]);229 }230 const cp = [p[0] - c[0], p[1] - c[1], p[2] - c[2]];231 const d5 = ab[0] * cp[0] + ab[1] * cp[1] + ab[2] * cp[2];232 const d6 = ac[0] * cp[0] + ac[1] * cp[1] + ac[2] * cp[2];233 if (d6 >= 0 && d5 <= d6) return at(c);234 const vb = d5 * d2 - d1 * d6;235 if (vb <= 0 && d2 >= 0 && d6 <= 0) {236 const t = d2 / (d2 - d6);237 return at([a[0] + ac[0] * t, a[1] + ac[1] * t, a[2] + ac[2] * t]);238 }239 const va = d3 * d6 - d5 * d4;240 if (va <= 0 && d4 - d3 >= 0 && d5 - d6 >= 0) {241 const t = (d4 - d3) / ((d4 - d3) + (d5 - d6));242 return at([243 b[0] + (c[0] - b[0]) * t,244 b[1] + (c[1] - b[1]) * t,245 b[2] + (c[2] - b[2]) * t,246 ]);247 }248 const denom = 1 / (va + vb + vc);249 const v = vb * denom;250 const w = vc * denom;251 return at([252 a[0] + ab[0] * v + ac[0] * w,253 a[1] + ab[1] * v + ac[1] * w,254 a[2] + ab[2] * v + ac[2] * w,255 ]);256}257258/** Ribbon rings midpoints + a windowed point-to-surface query (rings259 * are ordered along the strip, so only the neighborhood of the nearest260 * ring needs triangle tests). */261interface RibbonIndex {262 mesh: MeshProxy;263 mids: [number, number, number][];264}265266function indexRibbon(mesh: MeshProxy): RibbonIndex {267 const v = mesh.vertices;268 const rings = v.length / 3 / N;269 const mids: [number, number, number][] = [];270 for (let r = 0; r < rings; r++) {271 const a = (r * N + FLAT_A) * 3;272 const b = (r * N + FLAT_B) * 3;273 mids.push([274 (v[a] + v[b]) / 2,275 (v[a + 1] + v[b + 1]) / 2,276 (v[a + 2] + v[b + 2]) / 2,277 ]);278 }279 return { mesh, mids };280}281282function ribbonDist2(283 ri: RibbonIndex,284 p: readonly [number, number, number],285): number {286 const v = ri.mesh.vertices;287 let bestRing = 0;288 let best = Infinity;289 for (let r = 0; r < ri.mids.length; r++) {290 const m = ri.mids[r];291 const d = (m[0] - p[0]) ** 2 + (m[1] - p[1]) ** 2 + (m[2] - p[2]) ** 2;292 if (d < best) {293 best = d;294 bestRing = r;295 }296 }297 const vert = (i: number): [number, number, number] => [298 v[i * 3],299 v[i * 3 + 1],300 v[i * 3 + 2],301 ];302 let out = Infinity;303 const r0 = Math.max(0, bestRing - 2);304 const r1 = Math.min(ri.mids.length - 2, bestRing + 1);305 for (let r = r0; r <= r1; r++) {306 for (let j = 0; j < N - 1; j++) {307 const a = vert(r * N + j);308 const b = vert((r + 1) * N + j);309 const c = vert((r + 1) * N + j + 1);310 const d = vert(r * N + j + 1);311 out = Math.min(out, triDist2(p, a, b, c), triDist2(p, a, c, d));312 }313 }314 return out;315}316317// ---------------------------------------------------------------------------318// render mesh loading (the same GLB the gallery renders)319// ---------------------------------------------------------------------------320const GLB = new URL("../assets/marble-kit.glb", import.meta.url).pathname;321const io = new NodeIO().registerExtensions(KHRONOS_EXTENSIONS);322const gltfRoot = (await io.read(GLB)).getRoot();323324type V3 = [number, number, number];325const vertCache = new Map<string, V3[] | null>();326327/** Piece verts in piece-local grid units, relative to the node origin.328 * Pack nodes carry translation only (the pipeline bakes rotations), so329 * subtracting the node origin is exact — the same assumption the330 * measurement tooling validated against known kit dimensions. */331function pieceVerts(pieceId: string): V3[] | null {332 const cached = vertCache.get(pieceId);333 if (cached !== undefined) return cached;334 const node = gltfRoot.listNodes().find((n) => n.getName() === pieceId);335 if (!node) {336 vertCache.set(pieceId, null);337 return null;338 }339 const mul = (m: number[], v: V3): V3 => [340 m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12],341 m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13],342 m[2] * v[0] + m[6] * v[1] + m[10] * v[2] + m[14],343 ];344 const origin = mul(node.getWorldMatrix() as unknown as number[], [0, 0, 0]);345 const verts: V3[] = [];346 const walk = (n: typeof node) => {347 const mesh = n.getMesh();348 if (mesh) {349 const world = n.getWorldMatrix() as unknown as number[];350 for (const prim of mesh.listPrimitives()) {351 const pos = prim.getAttribute("POSITION");352 if (!pos) continue;353 const arr = pos.getArray()!;354 for (let i = 0; i < arr.length; i += 3) {355 const w = mul(world, [arr[i], arr[i + 1], arr[i + 2]]);356 verts.push([w[0] - origin[0], w[1] - origin[1], w[2] - origin[2]]);357 }358 }359 }360 for (const c of n.listChildren()) walk(c);361 };362 walk(node);363 const out = verts.length > 0 ? verts : null;364 vertCache.set(pieceId, out);365 return out;366}367368/** World-space render verts of a bench placement (track.ts law:369 * world = pos + RotY(r·π/2) · (scale·v, x negated when mirrored)). */370function placementVerts(p: BenchPlacement): V3[] {371 const verts = pieceVerts(p.pieceId);372 if (!verts) return [];373 const a = p.rotation * Math.PI / 2;374 const ca = Math.cos(a);375 const sa = Math.sin(a);376 const mx = p.mirrored ? -1 : 1;377 return verts.map((v) => {378 const x = v[0] * mx * TRACK_SCALE;379 const y = v[1] * TRACK_SCALE;380 const z = v[2] * TRACK_SCALE;381 return [382 p.pos[0] + x * ca + z * sa,383 p.pos[1] + y,384 p.pos[2] - x * sa + z * ca,385 ];386 });387}388389// ---------------------------------------------------------------------------390// shared per-variant analysis (built once, used by every test)391// ---------------------------------------------------------------------------392const registry = buildRegistry();393const N = CHANNEL_PROFILE.length;394const FLAT_A = 5; // profile index of (−0.30, 0)395const FLAT_B = 6; // profile index of (+0.30, 0)396397interface Analysis {398 def: PieceDef;399 path: LoopPath;400 placements: BenchPlacement[];401 proxies: CollisionProxy[];402 slabs: SlabProxy[];403 ribbons: RibbonIndex[];404 cl: V3[];405 /** coverage accounting, filled in by the tests */406 coverage: { rings: number; renderVerts: number; probes: number };407}408409const buildFailures: string[] = [];410const ANALYSES: Analysis[] = [];411for (const def of registry.variants) {412 try {413 const { path, placements } = benchBuild(def);414 const proxies = segmentProxies({415 path,416 pieceFamilies: [def.family],417 windowMods: [null],418 widths: [{ widthIn: def.widthIn, widthOut: def.widthOut }],419 });420 ANALYSES.push({421 def,422 path,423 placements,424 proxies,425 slabs: proxies.filter((p): p is SlabProxy => p.kind === "slab"),426 ribbons: proxies.filter((p): p is MeshProxy => p.kind === "mesh")427 .map(indexRibbon),428 cl: centerlineSamples(path),429 coverage: { rings: 0, renderVerts: 0, probes: 0 },430 });431 } catch (err) {432 buildFailures.push(`${def.id}: ${err}`);433 }434}435436/** Dive columns (drop family): [x, topY, botY, z] per vertical line. */437function diveColumns(path: LoopPath): [number, number, number, number][] {438 const out: [number, number, number, number][] = [];439 for (const seg of path.segments) {440 for (const part of seg.curve) {441 if (part.kind !== "line") continue;442 const horiz = Math.hypot(part.b[0] - part.a[0], part.b[2] - part.a[2]);443 if (horiz < 1e-6 && Math.abs(part.b[1] - part.a[1]) > 0.5) {444 out.push([445 part.a[0],446 Math.max(part.a[1], part.b[1]),447 Math.min(part.a[1], part.b[1]),448 part.a[2],449 ]);450 }451 }452 }453 return out;454}455456// ---------------------------------------------------------------------------457// tests458// ---------------------------------------------------------------------------459Deno.test("ribbon fidelity: floor/lateral/rim budgets across ALL variants", () => {460 let rings = 0;461 let worstFloor = 0;462 let worstPiece = "";463 for (const a of ANALYSES) {464 for (const ri of a.ribbons) {465 const v = ri.mesh.vertices;466 const ringCount = ri.mids.length;467 for (let r = 0; r < ringCount; r++) {468 // Boundary rings extrapolate 0.05 past the true port as the469 // weld seal — their floor rides the tangent beyond this piece's470 // clamped centerline; skip the FLOOR check there.471 const boundary = r === 0 || r === ringCount - 1;472 const mid = ri.mids[r];473 // nearest centerline sample in 3D (helixes revisit (x,z)474 // columns at several heights — horizontal-only matches lie)475 let best = Infinity;476 let clY = mid[1];477 for (const c of a.cl) {478 const d = (c[0] - mid[0]) ** 2 + (c[2] - mid[2]) ** 2 +479 (c[1] - mid[1]) ** 2;480 if (d < best) {481 best = d;482 clY = c[1];483 }484 }485 const floorErr = mid[1] - clY;486 if (!boundary) {487 if (Math.abs(floorErr) > Math.abs(worstFloor)) {488 worstFloor = floorErr;489 worstPiece = a.def.id;490 }491 assert(492 Math.abs(floorErr) <= FLOOR_ERR,493 `${a.def.id}: ring floor err ${floorErr.toFixed(3)} @ ${mid}`,494 );495 }496 let lat = 0;497 let top = -Infinity;498 for (let i = 0; i < N; i++) {499 const px = v[(r * N + i) * 3];500 const py = v[(r * N + i) * 3 + 1];501 const pz = v[(r * N + i) * 3 + 2];502 lat = Math.max(lat, Math.hypot(px - mid[0], pz - mid[2]));503 top = Math.max(top, py);504 }505 assert(506 lat >= LAT_MIN && lat <= LAT_MAX,507 `${a.def.id}: ring half-width ${lat.toFixed(3)} @ ${mid}`,508 );509 const cap = top - mid[1];510 assert(511 cap >= CAP_MIN && cap <= CAP_MAX,512 `${a.def.id}: rim cap ${cap.toFixed(3)} @ ${mid}`,513 );514 rings++;515 a.coverage.rings++;516 }517 }518 }519 assert(rings > 3000, `only ${rings} rings measured`);520 console.log(521 ` ${rings} rings across ${ANALYSES.length} variants; ` +522 `worst floor err ${worstFloor.toFixed(4)} (${worstPiece})`,523 );524});525526Deno.test("render-to-collision distance: GLB channel verts across ALL variants", () => {527 let total = 0;528 let worst = 0;529 let worstPiece = "";530 let missingMesh = 0;531 for (const a of ANALYSES) {532 // cross-over is a quiet phantom by design: no isolated proxies —533 // its collision is the paired cross composite (production law).534 if (a.def.junction === "quiet") continue;535 // corridor: horizontal radius from the nearest centerline sample.536 // Drop cells are special: the cup interior reaches ~1.0 from the537 // dive COLUMN, while the square outer shell's cosmetic corners sit538 // at 1.41 — so the wide radius applies only around the column and539 // the rolling lanes keep the standard one.540 const wide = a.def.widthIn === "wide" || a.def.widthOut === "wide";541 const radius = 0.72 + (wide ? 0.6 : 0);542 const cols = a.def.family === "drop" ? diveColumns(a.path) : [];543 // cross cells are small and fully authored — take the whole cell544 const crossCenter = a.def.junction === "cross"545 ? (() => {546 const entry = segPoint(a.path.segments[0].curve[0], 0);547 const last = a.path.segments[0].curve.at(-1)!;548 const exit = segPoint(last, 1);549 return [550 (entry[0] + exit[0]) / 2,551 entry[1],552 (entry[2] + exit[2]) / 2,553 ] as const;554 })()555 : null;556 // Port-face bands: verts on the piece's own cut faces belong to the557 // SHARED window boundary — in production the neighbor's ring stands558 // there (a steep piece's own ring leans with its pitch and reaches559 // the vertical cut face only via the neighbor). Skip a thin band.560 const firstPart = a.path.segments[0].curve[0];561 const lastSeg2 = a.path.segments[a.path.segments.length - 1];562 const lastPart = lastSeg2.curve[lastSeg2.curve.length - 1];563 const portBands: { p: V3; d: [number, number] }[] = [];564 for (565 const [part, t0, t1] of [[firstPart, 0, 0.05], [lastPart, 1, 0.95]] as const566 ) {567 const p = segPoint(part, t0);568 const q = segPoint(part, t1);569 const dx0 = p[0] - q[0];570 const dz0 = p[2] - q[2];571 const h = Math.hypot(dx0, dz0);572 if (h < 1e-6) continue;573 portBands.push({574 p: [p[0], p[1], p[2]],575 d: [dx0 / h, dz0 / h],576 });577 }578 const onPortFace = (w: V3): boolean =>579 portBands.some(({ p, d }) => {580 const rx = w[0] - p[0];581 const rz = w[2] - p[2];582 return Math.abs(rx * d[0] + rz * d[1]) < 0.12 &&583 Math.hypot(rx, rz) < 1.2;584 });585 let sampled = 0;586 for (const pl of a.placements) {587 const verts = placementVerts(pl);588 if (verts.length === 0) {589 missingMesh++;590 continue;591 }592 for (const w of verts) {593 // corridor filter: near the lane, in the driveable band —594 // outer shells, undersides, and tunnel arch tops are the595 // channel's surroundings, not its driven surface.596 let best = Infinity;597 let clY = 0;598 for (const c of a.cl) {599 const d = (c[0] - w[0]) ** 2 + (c[2] - w[2]) ** 2 +600 (c[1] - w[1]) ** 2;601 if (d < best) {602 best = d;603 clY = c[1];604 }605 }606 const dy = w[1] - clY;607 if (dy < -0.1 || dy > 0.55) continue;608 if (onPortFace(w)) continue;609 if (610 crossCenter &&611 Math.hypot(w[0] - crossCenter[0], w[2] - crossCenter[2]) > 1.45612 ) continue;613 // Also require true horizontal proximity for non-cross pieces.614 if (!crossCenter) {615 let hBest = Infinity;616 for (const c of a.cl) {617 if (Math.abs(c[1] - w[1]) > 0.6) continue;618 hBest = Math.min(619 hBest,620 Math.hypot(c[0] - w[0], c[2] - w[2]),621 );622 }623 const nearColumn = cols.some(([cx, , , cz]) =>624 Math.hypot(cx - w[0], cz - w[2]) <= 1.12625 );626 if (hBest > radius && !nearColumn) continue;627 }628 // directed distance to the collision surface629 let d2 = Infinity;630 for (const s of a.slabs) d2 = Math.min(d2, slabDist2(s, w));631 for (const ri of a.ribbons) d2 = Math.min(d2, ribbonDist2(ri, w));632 const d = Math.sqrt(d2);633 if (d > worst) {634 worst = d;635 worstPiece = a.def.id;636 }637 assert(638 d <= RENDER_DIST,639 `${a.def.id}: render vert ${d.toFixed(3)} from collision @ ` +640 w.map((x) => x.toFixed(2)).join(","),641 );642 sampled++;643 }644 }645 a.coverage.renderVerts += sampled;646 total += sampled;647 }648 assert(total > 5000, `only ${total} render verts sampled`);649 console.log(650 ` ${total} render channel verts checked; worst distance ` +651 `${worst.toFixed(3)} (${worstPiece}); placements without GLB mesh: ${missingMesh}`,652 );653});654655Deno.test("apertures: every floor bore is OPEN and its deck is present", () => {656 let bores = 0;657 for (const a of ANALYSES) {658 if (!a.def.holes && a.def.family !== "drop") continue;659 const centers: V3[] = [];660 for (const seg of a.path.segments) {661 for (const h of seg.holes ?? []) centers.push([h[0], h[1], h[2]]);662 }663 for (const [cx, topY, , cz] of diveColumns(a.path)) {664 centers.push([cx, topY, cz]);665 }666 assert(centers.length > 0, `${a.def.id}: no bore found`);667 for (const c of centers) {668 bores++;669 for (let k = 0; k < 8; k++) {670 const th = (k / 8) * Math.PI * 2;671 const probe: V3 = [672 c[0] + Math.sin(th) * BORE_CLEAR_R * 0.8,673 c[1] - 0.02,674 c[2] + Math.cos(th) * BORE_CLEAR_R * 0.8,675 ];676 for (const s of a.slabs) {677 assert(678 !slabContains(s, probe),679 `${a.def.id}: bore blocked by slab @ ${680 probe.map((x) => x.toFixed(2))681 }`,682 );683 }684 for (const ri of a.ribbons) {685 const v = ri.mesh.vertices;686 for (let i = 0; i < v.length; i += 3) {687 const dx = v[i] - c[0];688 const dy = v[i + 1] - c[1];689 const dz = v[i + 2] - c[2];690 assert(691 !(Math.hypot(dx, dz) < BORE_CLEAR_R && Math.abs(dy) < 0.1),692 `${a.def.id}: ribbon vertex inside the bore`,693 );694 }695 }696 }697 const deckHits = [0, 1, 2, 3].filter((k) => {698 const th = (k / 4) * Math.PI * 2;699 const probe: V3 = [700 c[0] + Math.sin(th) * HOLE_PROBE_R,701 c[1] - 0.05,702 c[2] + Math.cos(th) * HOLE_PROBE_R,703 ];704 return a.slabs.some((s) => slabContains(s, probe));705 }).length;706 assert(deckHits >= 2, `${a.def.id}: bore lip deck missing (${deckHits}/4)`);707 a.coverage.probes++;708 }709 }710 assert(bores >= 13, `only ${bores} bores checked`);711 console.log(` ${bores} bores verified open with lip decks`);712});713714Deno.test("drop compounds: cup arches, shaft, receiver mouth, pan per variant", () => {715 let drops = 0;716 for (const a of ANALYSES) {717 if (a.def.family !== "drop") continue;718 drops++;719 const cols = diveColumns(a.path);720 assert(cols.length === 1, `${a.def.id}: ${cols.length} dive columns`);721 const [cx, topY, botY, cz] = cols[0];722 // approach fwd: from the curve part ENDING at the column top;723 // exit dir: from the part STARTING at the column bottom.724 let fwd: [number, number] = [0, 1];725 let exitDir: [number, number] = [0, 1];726 for (const seg of a.path.segments) {727 for (const part of seg.curve) {728 if (part.kind !== "line") continue;729 const dx = part.b[0] - part.a[0];730 const dz = part.b[2] - part.a[2];731 const h = Math.hypot(dx, dz);732 if (h < 1e-6) continue;733 if (734 Math.abs(part.b[0] - cx) < 1e-3 && Math.abs(part.b[2] - cz) < 1e-3735 ) fwd = [dx / h, dz / h];736 if (737 Math.abs(part.a[0] - cx) < 1e-3 && Math.abs(part.a[2] - cz) < 1e-3738 ) exitDir = [dx / h, dz / h];739 }740 }741 const hit = (p: V3) => a.slabs.some((s) => slabContains(s, p));742 const cupY = topY + 0.25;743 const recY = botY + 0.25;744 // entry arch OPEN (marble rolls in through the port)745 assert(746 !hit([cx - fwd[0] * 1.09, cupY, cz - fwd[1] * 1.09]),747 `${a.def.id}: cup entry arch blocked`,748 );749 // far + lateral cup walls PRESENT750 for (751 const [ox, oz] of [752 [fwd[0], fwd[1]],753 [fwd[1], -fwd[0]],754 [-fwd[1], fwd[0]],755 ]756 ) {757 assert(758 hit([cx + ox * 1.09, cupY, cz + oz * 1.09]),759 `${a.def.id}: cup wall missing toward ${ox},${oz}`,760 );761 }762 // shaft bore guide between the decks763 assert(764 hit([cx + fwd[0] * 0.64, (topY + botY) / 2, cz + fwd[1] * 0.64]),765 `${a.def.id}: shaft wall missing`,766 );767 // receiver: exit mouth OPEN toward the true exit heading, far wall768 // present opposite it, pan under the cup769 assert(770 !hit([cx + exitDir[0] * 1.09, recY, cz + exitDir[1] * 1.09]),771 `${a.def.id}: receiver exit mouth blocked`,772 );773 assert(774 hit([cx - exitDir[0] * 1.09, recY, cz - exitDir[1] * 1.09]),775 `${a.def.id}: receiver far wall missing`,776 );777 assert(hit([cx, botY - 0.05, cz]), `${a.def.id}: receiver pan missing`);778 a.coverage.probes++;779 }780 assert(drops === 12, `expected 12 drop variants, saw ${drops}`);781 console.log(` ${drops} drop compounds probed (arches/shaft/receiver/pan)`);782});783784Deno.test("diamond compounds: branch decks, outer/wedge walls, clear lanes", () => {785 let diamonds = 0;786 for (const a of ANALYSES) {787 if (a.def.family !== "split") continue;788 diamonds++;789 const entry = segPoint(a.path.segments[0].curve[0], 0);790 const lastSeg = a.path.segments[0];791 const exit = segPoint(lastSeg.curve[lastSeg.curve.length - 1], 1);792 const dx = exit[0] - entry[0];793 const dz = exit[2] - entry[2];794 const L = Math.hypot(dx, dz);795 const fwd = [dx / L, dz / L];796 const lat = [fwd[1], -fwd[0]];797 const floorY = entry[1];798 const at = (along: number, side: number, y: number): V3 => [799 entry[0] + fwd[0] * along + lat[0] * side,800 y,801 entry[2] + fwd[1] * along + lat[1] * side,802 ];803 const hit = (p: V3) => a.slabs.some((s) => slabContains(s, p));804 const B = 2.0;805 for (const s of [-1, 1]) {806 // deck under each branch lane at mid-window807 assert(808 hit(at(L / 2, s * B, floorY - 0.05)),809 `${a.def.id}: branch deck missing (side ${s})`,810 );811 // outer wall at the measured line; wedge wall inside812 assert(813 hit(at(L / 2, s * (B + 0.75), floorY + 0.3)),814 `${a.def.id}: outer wall missing (side ${s})`,815 );816 assert(817 hit(at(L / 2, s * (B - 0.75), floorY + 0.3)),818 `${a.def.id}: wedge wall missing (side ${s})`,819 );820 // the branch LANE itself is clear at wall height821 assert(822 !hit(at(L / 2, s * B, floorY + 0.3)),823 `${a.def.id}: branch lane blocked (side ${s})`,824 );825 }826 // entry throat clear827 assert(828 !hit(at(0.3, 0, floorY + 0.3)),829 `${a.def.id}: entry throat blocked`,830 );831 a.coverage.probes++;832 }833 assert(diamonds >= 3, `expected ≥3 diamond variants, saw ${diamonds}`);834 console.log(` ${diamonds} diamond compounds probed (decks/walls/lanes)`);835});836837Deno.test("cross junction: deck at all four ports, corners walled, mouths open", () => {838 const a = ANALYSES.find((x) => x.def.id === "cross" && !x.def.reversed);839 assert(a);840 const entry = segPoint(a!.path.segments[0].curve[0], 0);841 const last = a!.path.segments[0].curve[a!.path.segments[0].curve.length - 1];842 const exit = segPoint(last, 1);843 const c = [844 (entry[0] + exit[0]) / 2,845 entry[1],846 (entry[2] + exit[2]) / 2,847 ] as const;848 const hit = (p: V3) => a!.slabs.some((s) => slabContains(s, p));849 for (const [ox, oz] of [[0, 0.9], [0, -0.9], [0.9, 0], [-0.9, 0]]) {850 assert(851 hit([c[0] + ox, c[1] - 0.05, c[2] + oz]),852 `cross: no deck at port approach ${ox},${oz}`,853 );854 }855 for (const [ox, oz] of [[0, 1.0], [0, -1.0], [1.0, 0], [-1.0, 0]]) {856 assert(857 !hit([c[0] + ox, c[1] + 0.3, c[2] + oz]),858 `cross: port mouth blocked at ${ox},${oz}`,859 );860 }861 for (const sx of [-1, 1]) {862 for (const sz of [-1, 1]) {863 assert(864 hit([c[0] + sx * 0.71, c[1] + 0.3, c[2] + sz * 0.71]),865 `cross: corner ${sx},${sz} unwalled`,866 );867 }868 }869 a!.coverage.probes++;870});871872Deno.test("coverage accounting: every variant evaluated, no silent skips", () => {873 assert(874 buildFailures.length === 0,875 `bench-build failures:\n${buildFailures.join("\n")}`,876 );877 assert(878 ANALYSES.length === registry.variants.length,879 `${ANALYSES.length}/${registry.variants.length} variants analyzed`,880 );881 const families = { rings: 0, render: 0, probes: 0, paired: 0 };882 const uncovered: string[] = [];883 for (const a of ANALYSES) {884 if (a.coverage.rings > 0) families.rings++;885 if (a.coverage.renderVerts > 0) families.render++;886 if (a.coverage.probes > 0) families.probes++;887 if (a.def.junction === "quiet") {888 // cross-over: collision comes from the paired cross composite —889 // covered there, conditional by design (re-audit verdict).890 families.paired++;891 continue;892 }893 if (a.coverage.rings === 0 && a.coverage.renderVerts === 0) {894 uncovered.push(a.def.id);895 }896 }897 assert(898 uncovered.length === 0,899 `variants with NO metric coverage: ${uncovered.join(", ")}`,900 );901 console.log(902 ` coverage: rings ${families.rings}, render-dist ${families.render}, ` +903 `probes ${families.probes}, paired-by-design ${families.paired} ` +904 `(of ${ANALYSES.length})`,905 );906});