1/**2 * Width parity (T1/T7, collision-mesh audit 2026-08-09): the swept3 * collision profile must carry the kit's measured variable width in4 * EVERY environment. The audit found the bench omitted the wide scales5 * (a diagnostic-only wiring defect) and the old ×2.43 profile scale put6 * wide collision walls outside the visible ±1.7 mesh; width is now an7 * additive wall shift (measured 0.576 world per side) flowing through8 * the ONE shared builder. These tests build isolated bench-style9 * variants — exactly what the piece bench renders — and assert the10 * ribbon's lateral extents against the measured mesh.11 */12import { assert } from "@std/assert";13import { buildRegistry, type PieceDef } from "../src/gen/registry.ts";14import { assemble } from "../src/gen/assemble.ts";15import { pickStyle } from "../src/gen/style.ts";16import { rngFamily } from "../src/gen/rng.ts";17import { DIR_STEP, turnLeft, turnRight } from "../src/gen/geom.ts";18import { scaleSeg, scaleVec, TRACK_SCALE } from "../src/gen/endless/scale.ts";19import {20 CHANNEL_PROFILE,21 type CollisionProxy,22 segmentProxies,23} from "../src/gen/endless/proxies.ts";24import type { Skeleton, SkeletonCell } from "../src/gen/topology.ts";25import type { ElevationPlan } from "../src/gen/elevation.ts";26import type { Dir, LoopPath } from "../src/shared/types.ts";2728/** Build one variant on a synthetic skeleton — the piece-bench path. */29function benchPath(def: PieceDef): LoopPath {30 const cells: SkeletonCell[] = [];31 let x = 0;32 let z = 0;33 let dir: Dir = 1;34 for (const m of def.moves) {35 const move = m as "F" | "L" | "R";36 const dirOut: Dir = move === "F"37 ? dir38 : move === "L"39 ? turnLeft(dir)40 : turnRight(dir);41 cells.push({ x, z, dirIn: dir, dirOut, move });42 dir = dirOut;43 x += DIR_STEP[dirOut][0];44 z += DIR_STEP[dirOut][1];45 }46 const skeleton: Skeleton = { cells, crossings: [], helixLoops: [] };47 const elevation: ElevationPlan = {48 baseY: cells.map((_, j) => (j === 0 ? 0 : def.deckDelta)),49 reservations: [],50 maxBaseY: Math.max(0, def.deckDelta),51 };52 const style = pickStyle(rngFamily("bench", "style"));53 const out = assemble(skeleton, elevation, style, [54 { start: 0, len: cells.length, def },55 ], { open: true });56 return {57 segments: out.loop.segments.map((s) => ({58 ...s,59 curve: s.curve.map(scaleSeg),60 length: s.length * TRACK_SCALE,61 ...(s.holes ? { holes: s.holes.map(scaleVec) } : {}),62 })),63 totalLength: out.loop.totalLength * TRACK_SCALE,64 };65}6667function benchProxies(def: PieceDef): CollisionProxy[] {68 return segmentProxies({69 path: benchPath(def),70 pieceFamilies: [def.family],71 windowMods: [null],72 widths: [{ widthIn: def.widthIn, widthOut: def.widthOut }],73 });74}7576/** Max |x| (lateral — bench travel is +z) of the first/last profile77 * ring of the variant's single ribbon strip. */78function endRingWidths(proxies: CollisionProxy[]): [number, number] {79 const meshes = proxies.filter((p) => p.kind === "mesh");80 assert(meshes.length === 1, `expected one ribbon, got ${meshes.length}`);81 const v = meshes[0].vertices;82 const N = CHANNEL_PROFILE.length;83 const ringMax = (start: number) => {84 let m = 0;85 for (let i = 0; i < N; i++) m = Math.max(m, Math.abs(v[(start + i) * 3]));86 return m;87 };88 return [ringMax(0), ringMax(v.length / 3 - N)];89}9091const registry = buildRegistry();92const variant = (id: string, reversed: boolean): PieceDef => {93 const def = registry.variants.find((d) =>94 d.id === id && !!d.reversed === reversed && !d.mirrored95 );96 assert(def, `registry variant ${id}${reversed ? " reversed" : ""} missing`);97 return def!;98};99100/** Measured extents (world units): standard profile rim at ±0.66; wide101 * adds the 0.576 shift → ±1.24. Thresholds sit between the two. */102const NARROW_MAX = 0.9;103const WIDE_MIN = 1.1;104105Deno.test("straight-wide: collision is wide across the whole window", () => {106 const [entry, exit] = endRingWidths(benchProxies(variant("straight-wide", false)));107 assert(entry > WIDE_MIN, `entry ${entry} not wide`);108 assert(exit > WIDE_MIN, `exit ${exit} not wide`);109});110111Deno.test("funnel: collision tapers narrow → wide", () => {112 const [entry, exit] = endRingWidths(benchProxies(variant("funnel", false)));113 assert(entry < NARROW_MAX, `entry ${entry} not narrow`);114 assert(exit > WIDE_MIN, `exit ${exit} not wide`);115});116117Deno.test("funnel reversed: collision tapers wide → narrow", () => {118 const [entry, exit] = endRingWidths(benchProxies(variant("funnel", true)));119 assert(entry > WIDE_MIN, `entry ${entry} not wide`);120 assert(exit < NARROW_MAX, `exit ${exit} not narrow`);121});122123Deno.test("funnel-long both directions: taper spans the 3-cell window", () => {124 for (const reversed of [false, true]) {125 const [entry, exit] = endRingWidths(126 benchProxies(variant("funnel-long", reversed)),127 );128 const [narrow, wide] = reversed ? [exit, entry] : [entry, exit];129 assert(narrow < NARROW_MAX, `narrow end ${narrow} (rev=${reversed})`);130 assert(wide > WIDE_MIN, `wide end ${wide} (rev=${reversed})`);131 }132});133134Deno.test("straight (control): standard width at both ports", () => {135 const [entry, exit] = endRingWidths(benchProxies(variant("straight", false)));136 assert(entry < NARROW_MAX && exit < NARROW_MAX, `${entry}/${exit}`);137});