clone

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

MEASURED COLLISION (audit round): the proxies now match the kit, and CI proves it

Dami committed 38d562a
Full remediation of docs/collision-mesh-audit.md (119/119 pieces,
re-audited clean same day — docs/collision-mesh-reaudit.md), built on
headless GLB measurement (gltf-transform over marble-kit.glb, pattern
tools/measure_ports.ts). Measured truths, grid units: wall inner face
0.287, rim floor+0.25, floor bore ROUND r 0.25 in straight-hole and
BOTH end-hole flavors, wide inner wall 0.575, split-double walls at
branch ±0.287.

- PARITY (T7): segmentProxies() is the ONE channel builder for
  production, the ?gallery=1 bench, and the sims (the bench had been
  omitting wide scales — a diagnostic-only wiring defect that falsified
  five audit rows). gapSegmentsOf lives with it; rail.ts re-exports.
  Bench asserts finite mesh bounds, frames empty plots safely, and
  ghosts cross-over's host mesh (a deliberate noMesh phantom).
- APERTURES (T2/T5): PieceDef.holes → LoopSegment.holes opens a real
  bore in the ribbon (lip decks, side slivers, walls, under-deck guide
  ring). Drop-stacks get an authored measured compound: annulus deck,
  octagonal cup (r 1.0 world) with the entry arch open, octagonal
  shaft, receiver cup opened toward the true exit yaw, pan law kept.
- WIDTH (T1): widening is an additive wall SHIFT (0.576 world/side),
  not the old x2.43 section scale that put wide walls outside the
  visible +-1.7 mesh; the bench passes def-authored port widths so an
  isolated funnel tapers honestly.
- RIM (T6): CHANNEL_PROFILE tops at 0.62 over floor (measured rim 0.5
  + 0.12 solver cap), wall face 0.62 lateral; junction composite walls
  follow the same law (diamond/spur/braid tightened ~0.4/side to the
  measured split-double faces).
- TURNS (T3): arc stations bounded by outer-boundary chord sagitta
  (0.008 world) instead of a fixed angle; crease rounding stays as the
  documented kicker-prevention law.
- JUNCTIONS (T2): the cross cell emits ONE authored 4-way composite
  (deck + solid corner posts, all four port arches open) from the mesh
  visit; the phantom is silent — two same-height route ribbons used to
  wall each other's route off.

QUANTITATIVE CI (tests/collision_metrics_test.ts + collision_width_test):
ring floor/lateral/rim budgets (3009 rings, worst floor err 0.041 at
slant-d's intentional crest rounding), RENDER-derived directed distance
(40k GLB channel verts within 0.2 of the collision surface; corridor
excludes port-face bands — shared boundaries the neighbor's ring
covers — and drop shells' cosmetic corners), bore-openness probes (13),
drop compound probes (12), diamond compound probes (3), cross probes,
and per-variant coverage accounting (rings 113 / render-dist 101 /
probes 17 / paired-by-design 1; bench-build failures fail loudly).
The CI caught three real defects during bring-up: approach ribbons
overhanging drop bores (lip-trimmed), missing bore side slivers, and
thin cross corner Ls (now solid posts).

174 tests green, deno task check clean.
commit
38d562aafc37964742db8c997283d7e10eff4e7d
parent
d32bc7844122c0aa1810d61f2f1524fe6c9ba911
author
Dami <dami.etoile@gmail.com>
17 files changed, +2283 -175
Adocs/collision-mesh-audit.csv120++++
Adocs/collision-mesh-audit.md219+++++++
Adocs/collision-mesh-reaudit.csv120++++
Adocs/collision-mesh-reaudit.md46++
Mprogress.md6+-
Msrc/client/endless.ts17+-
Msrc/client/physics/rail.ts11+-
Msrc/client/pieceBench.ts227++++++--
Msrc/gen/assemble.ts11+-
Msrc/gen/endless/proxies.ts582++++++++++++++++---
Msrc/gen/endless/world.ts9+-
Msrc/gen/registry.ts16+
Msrc/shared/types.ts12+
Atests/collision_metrics_test.ts906++++++++++++++++++++++++++++++
Atests/collision_width_test.ts137+++++
Mtests/multimarble_test.ts8+-
Mtests/physics_test.ts11+-
A docs/collision-mesh-audit.csv +120 -0
@@ -0,0 +1,120 @@
1+ordinal,piece,reversed,mirrored,family,floor_fit,wall_fit,aperture_fit,severity,issue_categories,observation,recommended_techniques2+"1","straight","false","false","straight","aligned","over-conservative","n/a","medium","wall_guard_overconservative","Floor ribbon follows the visible channel; collision walls extend materially above the rendered rim.","T1;T6"3+"2","tunnel","false","false","tunnel","aligned","over-conservative","n/a","medium","wall_guard_overconservative;roof_clearance_not_mesh_derived","Floor follows the tunnel, but generic tall guards consume clearance without deriving from the arch interior.","T4;T6"4+"3","straight-hole","false","false","straight","fail-closed","over-conservative","fail-closed","critical","hole_capped_by_ribbon;generic_drop_point;wall_guard_overconservative","The generic 72-vertex ribbon spans straight through the visible hole, creating a solid collision floor.","T2;T4"5+"4","tunnel-run-2","false","false","tunnel","aligned","over-conservative","n/a","medium","wall_guard_overconservative;roof_clearance_not_mesh_derived","Floor follows the tunnel, but generic tall guards consume clearance without deriving from the arch interior.","T4;T6"6+"5","tunnel-run-3","false","false","tunnel","aligned","over-conservative","n/a","medium","wall_guard_overconservative;roof_clearance_not_mesh_derived","Floor follows the tunnel, but generic tall guards consume clearance without deriving from the arch interior.","T4;T6"7+"6","drop-stack-rounded-f","false","false","drop","partial","poor","poor","critical","generic_drop_cup;shaft_shape_mismatch;exit_turn_not_mesh_derived;wall_guard_overconservative","Entry and receiver align locally, but the same square five-slab tube/pan approximates the rounded forward shaft; aperture, wall curvature, and exit mouth are not followed.","T2;T4;T5"8+"7","drop-stack-rounded-l","false","false","drop","partial","poor","poor","critical","generic_drop_cup;shaft_shape_mismatch;exit_turn_not_mesh_derived;wall_guard_overconservative","Entry and receiver align locally, but the same square five-slab tube/pan approximates the rounded turning shaft; aperture, wall curvature, and exit mouth are not followed.","T2;T4;T5"9+"8","drop-stack-rounded-r","false","false","drop","partial","poor","poor","critical","generic_drop_cup;shaft_shape_mismatch;exit_turn_not_mesh_derived;wall_guard_overconservative","Entry and receiver align locally, but the same square five-slab tube/pan approximates the rounded turning shaft; aperture, wall curvature, and exit mouth are not followed.","T2;T4;T5"10+"9","drop-stack-square-f","false","false","drop","partial","poor","poor","critical","generic_drop_cup;shaft_shape_mismatch;exit_turn_not_mesh_derived;wall_guard_overconservative","Entry and receiver align locally, but the same square five-slab tube/pan approximates the square forward shaft; aperture, wall curvature, and exit mouth are not followed.","T2;T4;T5"11+"10","drop-stack-square-l","false","false","drop","partial","poor","poor","critical","generic_drop_cup;shaft_shape_mismatch;exit_turn_not_mesh_derived;wall_guard_overconservative","Entry and receiver align locally, but the same square five-slab tube/pan approximates the square turning shaft; aperture, wall curvature, and exit mouth are not followed.","T2;T4;T5"12+"11","drop-stack-square-r","false","false","drop","partial","poor","poor","critical","generic_drop_cup;shaft_shape_mismatch;exit_turn_not_mesh_derived;wall_guard_overconservative","Entry and receiver align locally, but the same square five-slab tube/pan approximates the square turning shaft; aperture, wall curvature, and exit mouth are not followed.","T2;T4;T5"13+"12","cross","false","false","cross","route-only","partial","n/a","high","cross_arm_uncovered;single_route_ribbon;wall_guard_overconservative","Collision follows only the active straight route; the visible perpendicular cross arm has no matching floor or walls.","T2;T4"14+"13","cross-over","false","false","cross","not-evaluable","not-evaluable","not-evaluable","blocker","missing_render_mesh;null_mesh_bbox;bench_invalid","Mesh bounds are null and focus stays on the prior cross asset, so collision-to-mesh comparison is invalid.","T7"15+"14","corner","false","false","corner","localized_over_under_cut","over-conservative","n/a","high","short_turn_chord_error;inner_outer_edge_mismatch;wall_guard_overconservative","Four-station 90° turn has localized inner/outer mismatch near apex and ports; guard walls are much too tall.","T3;T6"16+"15","curve","false","false","curve","localized_over_under_cut","over-conservative","n/a","medium","turn_edge_mismatch;wall_guard_overconservative","Floor is broadly aligned with small inner/outer deviation through the short radius; wall height is excessive.","T3;T6"17+"16","curve-large","false","false","curve","localized_over_under_cut","over-conservative","n/a","medium","turn_edge_mismatch;wall_guard_overconservative","Floor is broadly aligned with small inner/outer deviation through the large radius; wall height is excessive.","T3;T6"18+"17","slant-b","false","false","slant","mostly-aligned","over-conservative","n/a","medium","wall_guard_overconservative","Longitudinal floor profile is close to the authored slope; side guards extend far above the visible rim.","T1;T6"19+"18","slant-long-b","false","false","slant","mostly-aligned","over-conservative","n/a","medium","wall_guard_overconservative","Longitudinal floor profile is close to the authored slope; side guards extend far above the visible rim.","T1;T6"20+"19","ramp-long-b","false","false","ramp","mostly-aligned","over-conservative","n/a","medium","wall_guard_overconservative","Longitudinal floor profile is close to the authored slope; side guards extend far above the visible rim.","T1;T6"21+"20","slant-c","false","false","slant","mostly-aligned","over-conservative","n/a","medium","wall_guard_overconservative","Longitudinal floor profile is close to the authored slope; side guards extend far above the visible rim.","T1;T6"22+"21","slant-long-c","false","false","slant","mostly-aligned","over-conservative","n/a","medium","wall_guard_overconservative","Longitudinal floor profile is close to the authored slope; side guards extend far above the visible rim.","T1;T6"23+"22","ramp-long-c","false","false","ramp","mostly-aligned","over-conservative","n/a","medium","wall_guard_overconservative","Longitudinal floor profile is close to the authored slope; side guards extend far above the visible rim.","T1;T6"24+"23","slant-d","false","false","slant","mostly-aligned","over-conservative","n/a","medium","wall_guard_overconservative","Longitudinal floor profile is close to the authored slope; side guards extend far above the visible rim.","T1;T6"25+"24","slant-long-d","false","false","slant","mostly-aligned","over-conservative","n/a","medium","wall_guard_overconservative","Longitudinal floor profile is close to the authored slope; side guards extend far above the visible rim.","T1;T6"26+"25","ramp-long-d","false","false","ramp","mostly-aligned","over-conservative","n/a","medium","wall_guard_overconservative","Longitudinal floor profile is close to the authored slope; side guards extend far above the visible rim.","T1;T6"27+"26","ramp-start-b","false","false","ramp","mostly-aligned","over-conservative","n/a","medium","wall_guard_overconservative","Longitudinal floor profile is close to the authored slope; side guards extend far above the visible rim.","T1;T6"28+"27","ramp-end-b","false","false","ramp","mostly-aligned","over-conservative","n/a","medium","wall_guard_overconservative","Longitudinal floor profile is close to the authored slope; side guards extend far above the visible rim.","T1;T6"29+"28","ramp-start-c","false","false","ramp","mostly-aligned","over-conservative","n/a","medium","wall_guard_overconservative","Longitudinal floor profile is close to the authored slope; side guards extend far above the visible rim.","T1;T6"30+"29","ramp-end-c","false","false","ramp","mostly-aligned","over-conservative","n/a","medium","wall_guard_overconservative","Longitudinal floor profile is close to the authored slope; side guards extend far above the visible rim.","T1;T6"31+"30","ramp-start-d","false","false","ramp","mostly-aligned","over-conservative","n/a","medium","wall_guard_overconservative","Longitudinal floor profile is close to the authored slope; side guards extend far above the visible rim.","T1;T6"32+"31","ramp-end-d","false","false","ramp","mostly-aligned","over-conservative","n/a","medium","wall_guard_overconservative","Longitudinal floor profile is close to the authored slope; side guards extend far above the visible rim.","T1;T6"33+"32","bend","false","false","bend","localized_over_under_cut","over-conservative","n/a","high","turn_chord_error;inner_outer_edge_mismatch;wall_guard_overconservative","Tight double turn visibly over/undercuts near the center obstruction.","T3;T4;T6"34+"33","bend-medium","false","false","bend","localized_over_under_cut","over-conservative","n/a","medium","turn_chord_error;inner_outer_edge_mismatch;wall_guard_overconservative","Longer-radius double turn has smaller inner/outer edge drift; wall guard dominates.","T3;T4;T6"35+"34","bend-large","false","false","bend","localized_over_under_cut","over-conservative","n/a","medium","turn_chord_error;inner_outer_edge_mismatch;wall_guard_overconservative","Longer-radius double turn has smaller inner/outer edge drift; wall guard dominates.","T3;T4;T6"36+"35","helix-quarter-left","false","false","helix","mostly-aligned","over-conservative","n/a","medium","turn_edge_mismatch;wall_guard_overconservative","Spiral floor broadly tracks the mesh; residual error is at inner/outer gutters and the over-tall guard.","T3;T4;T6"37+"36","helix-quarter-right","false","false","helix","mostly-aligned","over-conservative","n/a","medium","turn_edge_mismatch;wall_guard_overconservative","Spiral floor broadly tracks the mesh; residual error is at inner/outer gutters and the over-tall guard.","T3;T4;T6"38+"37","helix-large-quarter-left","false","false","helix","mostly-aligned","over-conservative","n/a","medium","turn_edge_mismatch;wall_guard_overconservative","Spiral floor broadly tracks the mesh; residual error is at inner/outer gutters and the over-tall guard.","T3;T4;T6"39+"38","helix-large-quarter-right","false","false","helix","mostly-aligned","over-conservative","n/a","medium","turn_edge_mismatch;wall_guard_overconservative","Spiral floor broadly tracks the mesh; residual error is at inner/outer gutters and the over-tall guard.","T3;T4;T6"40+"39","helix-half-left","false","false","helix","mostly-aligned","over-conservative","n/a","medium","turn_edge_mismatch;wall_guard_overconservative","Spiral floor broadly tracks the mesh; residual error is at inner/outer gutters and the over-tall guard.","T3;T4;T6"41+"40","helix-half-right","false","false","helix","mostly-aligned","over-conservative","n/a","medium","turn_edge_mismatch;wall_guard_overconservative","Spiral floor broadly tracks the mesh; residual error is at inner/outer gutters and the over-tall guard.","T3;T4;T6"42+"41","helix-large-half-left","false","false","helix","mostly-aligned","over-conservative","n/a","medium","turn_edge_mismatch;wall_guard_overconservative","Spiral floor broadly tracks the mesh; residual error is at inner/outer gutters and the over-tall guard.","T3;T4;T6"43+"42","helix-large-half-right","false","false","helix","mostly-aligned","over-conservative","n/a","medium","turn_edge_mismatch;wall_guard_overconservative","Spiral floor broadly tracks the mesh; residual error is at inner/outer gutters and the over-tall guard.","T3;T4;T6"44+"43","diamond-1","false","false","split","coarse-composite","coarse-composite","n/a","high","split_slab_composite;deck_and_wedge_overshoot;not_mesh_derived","Thirteen slabs approximate the split deck and wedge; boxes extend beyond sculpted split/merge edges.","T2;T5"45+"44","diamond-2","false","false","split","coarse-composite","coarse-composite","n/a","high","split_slab_composite;deck_and_wedge_overshoot;not_mesh_derived","Thirteen slabs approximate the split deck and wedge; boxes extend beyond sculpted split/merge edges.","T2;T5"46+"45","diamond-3","false","false","split","coarse-composite","coarse-composite","n/a","high","split_slab_composite;deck_and_wedge_overshoot;not_mesh_derived","Thirteen slabs approximate the split deck and wedge; boxes extend beyond sculpted split/merge edges.","T2;T5"47+"46","helix-left","false","false","helix","mostly-aligned","over-conservative","n/a","medium","turn_edge_mismatch;wall_guard_overconservative","Spiral floor broadly tracks the mesh; residual error is at inner/outer gutters and the over-tall guard.","T3;T4;T6"48+"47","helix-right","false","false","helix","mostly-aligned","over-conservative","n/a","medium","turn_edge_mismatch;wall_guard_overconservative","Spiral floor broadly tracks the mesh; residual error is at inner/outer gutters and the over-tall guard.","T3;T4;T6"49+"48","helix-large-left","false","false","helix","mostly-aligned","over-conservative","n/a","medium","turn_edge_mismatch;wall_guard_overconservative","Spiral floor broadly tracks the mesh; residual error is at inner/outer gutters and the over-tall guard.","T3;T4;T6"50+"49","helix-large-right","false","false","helix","mostly-aligned","over-conservative","n/a","medium","turn_edge_mismatch;wall_guard_overconservative","Spiral floor broadly tracks the mesh; residual error is at inner/outer gutters and the over-tall guard.","T3;T4;T6"51+"50","s-curve-left","false","false","s-curve","mostly-aligned","over-conservative","n/a","medium","compound_turn_edge_mismatch;wall_guard_overconservative","Floor broadly aligns; smaller edge drift remains around inflections and wall guard dominates.","T3;T6"52+"51","s-curve-right","false","false","s-curve","mostly-aligned","over-conservative","n/a","medium","compound_turn_edge_mismatch;wall_guard_overconservative","Floor broadly aligns; smaller edge drift remains around inflections and wall guard dominates.","T3;T6"53+"52","s-curve-left-large","false","false","s-curve","mostly-aligned","over-conservative","n/a","medium","compound_turn_edge_mismatch;wall_guard_overconservative","Floor broadly aligns; smaller edge drift remains around inflections and wall guard dominates.","T3;T6"54+"53","s-curve-right-large","false","false","s-curve","mostly-aligned","over-conservative","n/a","medium","compound_turn_edge_mismatch;wall_guard_overconservative","Floor broadly aligns; smaller edge drift remains around inflections and wall guard dominates.","T3;T6"55+"54","s-curve-short-left-c","false","false","s-curve","localized_over_under_cut","over-conservative","n/a","high","inflection_edge_mismatch;wall_guard_overconservative","Tight inflection visibly spills at inner/outer edges; mirror/reverse inherits it.","T3;T6"56+"55","s-curve-short-right-c","false","true","s-curve","localized_over_under_cut","over-conservative","n/a","high","inflection_edge_mismatch;wall_guard_overconservative","Tight inflection visibly spills at inner/outer edges; mirror/reverse inherits it.","T3;T6"57+"56","wiggle-a","false","false","s-curve","mostly-aligned","over-conservative","n/a","medium","compound_turn_edge_mismatch;wall_guard_overconservative","Floor broadly aligns; smaller edge drift remains around inflections and wall guard dominates.","T3;T6"58+"57","wiggle-a-mirror","false","true","s-curve","mostly-aligned","over-conservative","n/a","medium","compound_turn_edge_mismatch;wall_guard_overconservative","Floor broadly aligns; smaller edge drift remains around inflections and wall guard dominates.","T3;T6"59+"58","wiggle-b","false","false","s-curve","mostly-aligned","over-conservative","n/a","medium","compound_turn_edge_mismatch;wall_guard_overconservative","Floor broadly aligns; smaller edge drift remains around inflections and wall guard dominates.","T3;T6"60+"59","wiggle-b-mirror","false","true","s-curve","mostly-aligned","over-conservative","n/a","medium","compound_turn_edge_mismatch;wall_guard_overconservative","Floor broadly aligns; smaller edge drift remains around inflections and wall guard dominates.","T3;T6"61+"60","lane-shift-b","false","false","s-curve","mostly-aligned","over-conservative","n/a","medium","compound_turn_edge_mismatch;wall_guard_overconservative","Floor broadly aligns; smaller edge drift remains around inflections and wall guard dominates.","T3;T6"62+"61","lane-shift-b-mirror","false","true","s-curve","mostly-aligned","over-conservative","n/a","medium","compound_turn_edge_mismatch;wall_guard_overconservative","Floor broadly aligns; smaller edge drift remains around inflections and wall guard dominates.","T3;T6"63+"62","wave-a","false","false","wave","mostly-aligned","over-conservative","n/a","medium","lateral_cubic_edge_drift;wall_guard_overconservative","Dense ribbon broadly follows the cubic mesh; residual edge drift is smaller than the over-tall wall guard.","T3;T6"64+"63","wave-a-mirror","false","true","wave","mostly-aligned","over-conservative","n/a","medium","lateral_cubic_edge_drift;wall_guard_overconservative","Dense ribbon broadly follows the cubic mesh; residual edge drift is smaller than the over-tall wall guard.","T3;T6"65+"64","wave-b","false","false","wave","mostly-aligned","over-conservative","n/a","medium","lateral_cubic_edge_drift;wall_guard_overconservative","Dense ribbon broadly follows the cubic mesh; residual edge drift is smaller than the over-tall wall guard.","T3;T6"66+"65","wave-b-mirror","false","true","wave","mostly-aligned","over-conservative","n/a","medium","lateral_cubic_edge_drift;wall_guard_overconservative","Dense ribbon broadly follows the cubic mesh; residual edge drift is smaller than the over-tall wall guard.","T3;T6"67+"66","wave-c","false","false","wave","mostly-aligned","over-conservative","n/a","medium","lateral_cubic_edge_drift;wall_guard_overconservative","Dense ribbon broadly follows the cubic mesh; residual edge drift is smaller than the over-tall wall guard.","T3;T6"68+"67","wave-c-mirror","false","true","wave","mostly-aligned","over-conservative","n/a","medium","lateral_cubic_edge_drift;wall_guard_overconservative","Dense ribbon broadly follows the cubic mesh; residual edge drift is smaller than the over-tall wall guard.","T3;T6"69+"68","bump-a","false","false","bump","mostly-aligned","over-conservative","n/a","medium","vertical_cubic_edge_drift;wall_guard_overconservative","Dense ribbon broadly follows the cubic mesh; residual edge drift is smaller than the over-tall wall guard.","T3;T6"70+"69","bump-b","false","false","bump","mostly-aligned","over-conservative","n/a","medium","vertical_cubic_edge_drift;wall_guard_overconservative","Dense ribbon broadly follows the cubic mesh; residual edge drift is smaller than the over-tall wall guard.","T3;T6"71+"70","bump-c","false","false","bump","mostly-aligned","over-conservative","n/a","medium","vertical_cubic_edge_drift;wall_guard_overconservative","Dense ribbon broadly follows the cubic mesh; residual edge drift is smaller than the over-tall wall guard.","T3;T6"72+"71","bump-d","false","false","bump","mostly-aligned","over-conservative","n/a","medium","vertical_cubic_edge_drift;wall_guard_overconservative","Dense ribbon broadly follows the cubic mesh; residual edge drift is smaller than the over-tall wall guard.","T3;T6"73+"72","straight-wide","false","false","wide","under-width","under-width_and_over-tall","n/a","critical","standard_width_ribbon;variable_width_missing_in_bench;wall_guard_overconservative","Visible mesh spans ±1.7 while the bench emits the standard-width ribbon; floor and walls under-cover the channel.","T1;T7"74+"73","funnel","false","false","funnel","under-width","under-width_and_over-tall","n/a","critical","standard_width_ribbon;variable_width_missing_in_bench;wall_guard_overconservative","The narrow-to-wide taper is absent from collision; the bench emits standard width across the whole piece.","T1;T7"75+"74","funnel-long","false","false","funnel","under-width","under-width_and_over-tall","n/a","critical","standard_width_ribbon;variable_width_missing_in_bench;wall_guard_overconservative","The narrow-to-wide taper is absent from collision; the bench emits standard width across the whole piece.","T1;T7"76+"75","drop-stack-rounded-f","true","false","drop","partial","poor","poor","critical","generic_drop_cup;shaft_shape_mismatch;exit_turn_not_mesh_derived;wall_guard_overconservative","Entry and receiver align locally, but the same square five-slab tube/pan approximates the rounded forward shaft; aperture, wall curvature, and exit mouth are not followed.","T2;T4;T5"77+"76","drop-stack-rounded-l","true","false","drop","partial","poor","poor","critical","generic_drop_cup;shaft_shape_mismatch;exit_turn_not_mesh_derived;wall_guard_overconservative","Entry and receiver align locally, but the same square five-slab tube/pan approximates the rounded turning shaft; aperture, wall curvature, and exit mouth are not followed.","T2;T4;T5"78+"77","drop-stack-rounded-r","true","false","drop","partial","poor","poor","critical","generic_drop_cup;shaft_shape_mismatch;exit_turn_not_mesh_derived;wall_guard_overconservative","Entry and receiver align locally, but the same square five-slab tube/pan approximates the rounded turning shaft; aperture, wall curvature, and exit mouth are not followed.","T2;T4;T5"79+"78","drop-stack-square-f","true","false","drop","partial","poor","poor","critical","generic_drop_cup;shaft_shape_mismatch;exit_turn_not_mesh_derived;wall_guard_overconservative","Entry and receiver align locally, but the same square five-slab tube/pan approximates the square forward shaft; aperture, wall curvature, and exit mouth are not followed.","T2;T4;T5"80+"79","drop-stack-square-l","true","false","drop","partial","poor","poor","critical","generic_drop_cup;shaft_shape_mismatch;exit_turn_not_mesh_derived;wall_guard_overconservative","Entry and receiver align locally, but the same square five-slab tube/pan approximates the square turning shaft; aperture, wall curvature, and exit mouth are not followed.","T2;T4;T5"81+"80","drop-stack-square-r","true","false","drop","partial","poor","poor","critical","generic_drop_cup;shaft_shape_mismatch;exit_turn_not_mesh_derived;wall_guard_overconservative","Entry and receiver align locally, but the same square five-slab tube/pan approximates the square turning shaft; aperture, wall curvature, and exit mouth are not followed.","T2;T4;T5"82+"81","corner","true","false","corner","localized_over_under_cut","over-conservative","n/a","high","short_turn_chord_error;inner_outer_edge_mismatch;wall_guard_overconservative","Four-station 90° turn has localized inner/outer mismatch near apex and ports; guard walls are much too tall.","T3;T6"83+"82","curve","true","false","curve","localized_over_under_cut","over-conservative","n/a","medium","turn_edge_mismatch;wall_guard_overconservative","Floor is broadly aligned with small inner/outer deviation through the short radius; wall height is excessive.","T3;T6"84+"83","curve-large","true","false","curve","localized_over_under_cut","over-conservative","n/a","medium","turn_edge_mismatch;wall_guard_overconservative","Floor is broadly aligned with small inner/outer deviation through the large radius; wall height is excessive.","T3;T6"85+"84","slant-b","true","false","slant","mostly-aligned","over-conservative","n/a","medium","wall_guard_overconservative","Longitudinal floor profile is close to the authored slope; side guards extend far above the visible rim.","T1;T6"86+"85","slant-long-b","true","false","slant","mostly-aligned","over-conservative","n/a","medium","wall_guard_overconservative","Longitudinal floor profile is close to the authored slope; side guards extend far above the visible rim.","T1;T6"87+"86","ramp-long-b","true","false","ramp","mostly-aligned","over-conservative","n/a","medium","wall_guard_overconservative","Longitudinal floor profile is close to the authored slope; side guards extend far above the visible rim.","T1;T6"88+"87","slant-c","true","false","slant","mostly-aligned","over-conservative","n/a","medium","wall_guard_overconservative","Longitudinal floor profile is close to the authored slope; side guards extend far above the visible rim.","T1;T6"89+"88","slant-long-c","true","false","slant","mostly-aligned","over-conservative","n/a","medium","wall_guard_overconservative","Longitudinal floor profile is close to the authored slope; side guards extend far above the visible rim.","T1;T6"90+"89","ramp-long-c","true","false","ramp","mostly-aligned","over-conservative","n/a","medium","wall_guard_overconservative","Longitudinal floor profile is close to the authored slope; side guards extend far above the visible rim.","T1;T6"91+"90","slant-d","true","false","slant","mostly-aligned","over-conservative","n/a","medium","wall_guard_overconservative","Longitudinal floor profile is close to the authored slope; side guards extend far above the visible rim.","T1;T6"92+"91","slant-long-d","true","false","slant","mostly-aligned","over-conservative","n/a","medium","wall_guard_overconservative","Longitudinal floor profile is close to the authored slope; side guards extend far above the visible rim.","T1;T6"93+"92","ramp-long-d","true","false","ramp","mostly-aligned","over-conservative","n/a","medium","wall_guard_overconservative","Longitudinal floor profile is close to the authored slope; side guards extend far above the visible rim.","T1;T6"94+"93","ramp-start-b","true","false","ramp","mostly-aligned","over-conservative","n/a","medium","wall_guard_overconservative","Longitudinal floor profile is close to the authored slope; side guards extend far above the visible rim.","T1;T6"95+"94","ramp-end-b","true","false","ramp","mostly-aligned","over-conservative","n/a","medium","wall_guard_overconservative","Longitudinal floor profile is close to the authored slope; side guards extend far above the visible rim.","T1;T6"96+"95","ramp-start-c","true","false","ramp","mostly-aligned","over-conservative","n/a","medium","wall_guard_overconservative","Longitudinal floor profile is close to the authored slope; side guards extend far above the visible rim.","T1;T6"97+"96","ramp-end-c","true","false","ramp","mostly-aligned","over-conservative","n/a","medium","wall_guard_overconservative","Longitudinal floor profile is close to the authored slope; side guards extend far above the visible rim.","T1;T6"98+"97","ramp-start-d","true","false","ramp","mostly-aligned","over-conservative","n/a","medium","wall_guard_overconservative","Longitudinal floor profile is close to the authored slope; side guards extend far above the visible rim.","T1;T6"99+"98","ramp-end-d","true","false","ramp","mostly-aligned","over-conservative","n/a","medium","wall_guard_overconservative","Longitudinal floor profile is close to the authored slope; side guards extend far above the visible rim.","T1;T6"100+"99","bend","true","false","bend","localized_over_under_cut","over-conservative","n/a","high","turn_chord_error;inner_outer_edge_mismatch;wall_guard_overconservative","Tight double turn visibly over/undercuts near the center obstruction.","T3;T4;T6"101+"100","bend-medium","true","false","bend","localized_over_under_cut","over-conservative","n/a","medium","turn_chord_error;inner_outer_edge_mismatch;wall_guard_overconservative","Longer-radius double turn has smaller inner/outer edge drift; wall guard dominates.","T3;T4;T6"102+"101","bend-large","true","false","bend","localized_over_under_cut","over-conservative","n/a","medium","turn_chord_error;inner_outer_edge_mismatch;wall_guard_overconservative","Longer-radius double turn has smaller inner/outer edge drift; wall guard dominates.","T3;T4;T6"103+"102","helix-quarter-left","true","false","helix","mostly-aligned","over-conservative","n/a","medium","turn_edge_mismatch;wall_guard_overconservative","Spiral floor broadly tracks the mesh; residual error is at inner/outer gutters and the over-tall guard.","T3;T4;T6"104+"103","helix-quarter-right","true","false","helix","mostly-aligned","over-conservative","n/a","medium","turn_edge_mismatch;wall_guard_overconservative","Spiral floor broadly tracks the mesh; residual error is at inner/outer gutters and the over-tall guard.","T3;T4;T6"105+"104","helix-large-quarter-left","true","false","helix","mostly-aligned","over-conservative","n/a","medium","turn_edge_mismatch;wall_guard_overconservative","Spiral floor broadly tracks the mesh; residual error is at inner/outer gutters and the over-tall guard.","T3;T4;T6"106+"105","helix-large-quarter-right","true","false","helix","mostly-aligned","over-conservative","n/a","medium","turn_edge_mismatch;wall_guard_overconservative","Spiral floor broadly tracks the mesh; residual error is at inner/outer gutters and the over-tall guard.","T3;T4;T6"107+"106","helix-half-left","true","false","helix","mostly-aligned","over-conservative","n/a","medium","turn_edge_mismatch;wall_guard_overconservative","Spiral floor broadly tracks the mesh; residual error is at inner/outer gutters and the over-tall guard.","T3;T4;T6"108+"107","helix-half-right","true","false","helix","mostly-aligned","over-conservative","n/a","medium","turn_edge_mismatch;wall_guard_overconservative","Spiral floor broadly tracks the mesh; residual error is at inner/outer gutters and the over-tall guard.","T3;T4;T6"109+"108","helix-large-half-left","true","false","helix","mostly-aligned","over-conservative","n/a","medium","turn_edge_mismatch;wall_guard_overconservative","Spiral floor broadly tracks the mesh; residual error is at inner/outer gutters and the over-tall guard.","T3;T4;T6"110+"109","helix-large-half-right","true","false","helix","mostly-aligned","over-conservative","n/a","medium","turn_edge_mismatch;wall_guard_overconservative","Spiral floor broadly tracks the mesh; residual error is at inner/outer gutters and the over-tall guard.","T3;T4;T6"111+"110","helix-left","true","false","helix","mostly-aligned","over-conservative","n/a","medium","turn_edge_mismatch;wall_guard_overconservative","Spiral floor broadly tracks the mesh; residual error is at inner/outer gutters and the over-tall guard.","T3;T4;T6"112+"111","helix-right","true","false","helix","mostly-aligned","over-conservative","n/a","medium","turn_edge_mismatch;wall_guard_overconservative","Spiral floor broadly tracks the mesh; residual error is at inner/outer gutters and the over-tall guard.","T3;T4;T6"113+"112","helix-large-left","true","false","helix","mostly-aligned","over-conservative","n/a","medium","turn_edge_mismatch;wall_guard_overconservative","Spiral floor broadly tracks the mesh; residual error is at inner/outer gutters and the over-tall guard.","T3;T4;T6"114+"113","helix-large-right","true","false","helix","mostly-aligned","over-conservative","n/a","medium","turn_edge_mismatch;wall_guard_overconservative","Spiral floor broadly tracks the mesh; residual error is at inner/outer gutters and the over-tall guard.","T3;T4;T6"115+"114","s-curve-short-left-c","true","false","s-curve","localized_over_under_cut","over-conservative","n/a","high","inflection_edge_mismatch;wall_guard_overconservative","Tight inflection visibly spills at inner/outer edges; mirror/reverse inherits it.","T3;T6"116+"115","s-curve-short-right-c","true","true","s-curve","localized_over_under_cut","over-conservative","n/a","high","inflection_edge_mismatch;wall_guard_overconservative","Tight inflection visibly spills at inner/outer edges; mirror/reverse inherits it.","T3;T6"117+"116","lane-shift-b","true","false","s-curve","mostly-aligned","over-conservative","n/a","medium","compound_turn_edge_mismatch;wall_guard_overconservative","Floor broadly aligns; smaller edge drift remains around inflections and wall guard dominates.","T3;T6"118+"117","lane-shift-b-mirror","true","true","s-curve","mostly-aligned","over-conservative","n/a","medium","compound_turn_edge_mismatch;wall_guard_overconservative","Floor broadly aligns; smaller edge drift remains around inflections and wall guard dominates.","T3;T6"119+"118","funnel","true","false","funnel","under-width","under-width_and_over-tall","n/a","critical","standard_width_ribbon;variable_width_missing_in_bench;wall_guard_overconservative","The wide-to-narrow taper is absent from collision; the bench emits standard width across the whole piece.","T1;T7"120+"119","funnel-long","true","false","funnel","under-width","under-width_and_over-tall","n/a","critical","standard_width_ribbon;variable_width_missing_in_bench;wall_guard_overconservative","The wide-to-narrow taper is absent from collision; the bench emits standard width across the whole piece.","T1;T7"
A docs/collision-mesh-audit.md +219 -0
@@ -0,0 +1,219 @@
1+# Collision-to-mesh audit — piece bench2+3+Audit date: 2026-08-09 4+Source: `?endless=1&peers=0&gallery=1` 5+Inventory: 119/119 registry variants reviewed with collision wireframes enabled6+7+The per-piece results are in [`collision-mesh-audit.csv`](./collision-mesh-audit.csv).8+This was a static geometry audit: every ordinal was selected explicitly through9+the CUA-accessible numeric selector, the selected ordinal/name was validated,10+and the collision-on render was compared with the visible mesh. The yellow ring11+is only the selection marker and was ignored. No simulation outcome is inferred12+from a visual pass alone.13+14+## Result summary15+16+| Severity | Pieces | Meaning |17+|---|---:|---|18+| blocker | 1 | Render mesh is missing/invalid, so comparison cannot be made |19+| critical | 18 | Collision changes topology or leaves a major part of the mesh unsupported |20+| high | 12 | Local mismatch is likely to change reachable motion or contacts |21+| medium | 88 | Floor is broadly usable, but guard or edge fit is visibly inaccurate |22+23+The dominant issue classes are:24+25+- **Over-conservative ribbon wall guard — 115 pieces.** The floor is often26+ close, but the collision profile rises far above the visible rim. The current27+ profile reaches `up=1.1` world units (`CHANNEL_PROFILE` in28+ `src/gen/endless/proxies.ts`) while the rendered channel rim is much lower.29+ This consumes tunnel/stack clearance and masks legitimate edge exits.30+- **Openings and drop shafts — 13 pieces.** `straight-hole` is completely31+ capped by the ordinary ribbon. All twelve drop-stack variants use the same32+ square five-slab tube/pan regardless of rounded vs square aperture, forward33+ vs turning exit, or reversed direction.34+- **Variable width — 5 pieces.** `straight-wide`, both funnels, and both35+ reversed funnels are rendered at up to ±1.7 but the bench shows a standard36+ ribbon. There is also a diagnostic parity defect: production calls37+ `channelProxies(..., wideScalesOf(data))` in `src/client/endless.ts`, while38+ `src/client/pieceBench.ts` omits the fourth argument. Extracting a shared39+ segment-to-proxies helper is safer than fixing the bench call independently.40+- **Turns and curved profiles — 62 pieces.** Long curves, helixes, waves, and41+ bumps broadly follow the visible floor. Error concentrates at inner/outer42+ boundaries, tight corners, double bends, and short S-curve inflections. The43+ current angular threshold bounds center-frame rotation, not the geometric44+ error of the offset channel boundaries.45+- **Junction composites — 4 pieces.** `cross` covers only the active straight46+ route. `diamond-1..3` use thirteen slabs; the boxes visibly overshoot the47+ sculpted split/merge deck and center wedge.48+- **Invalid asset/bench state — 1 piece.** `cross-over` reports null mesh bounds49+ and focus remains on the prior `cross`, so its row is intentionally marked50+ `not-evaluable`.51+52+## Recommended technique map53+54+The CSV uses these short technique IDs.55+56+### T1 — Sweep the actual, variable cross-section57+58+Keep the useful one-piece ribbon model, but make width and rim height functions59+of arc length and piece metadata. A path extrusion with explicit subdivision is60+established practice; Three.js exposes the same model through61+[`ExtrudeGeometry.extrudePath`](https://threejs.org/docs/pages/ExtrudeGeometry.html).62+For this project, the important change is to pass `wScale` in every environment63+and use a mesh-derived rim profile instead of one global `CHANNEL_PROFILE`.64+65+### T2 — Author apertures and junctions as topology, not drop points66+67+Holes need boundary loops and open faces. Give `straight-hole`, drop-stack68+mouths, crosses, and split/merge pieces dedicated collision data in the GLB (or69+an adjacent compact collider asset). A procedural alternative is an outer70+contour plus explicit hole contours; Three.js `Shape` models this directly with71+[`Shape.holes`](https://threejs.org/docs/pages/Shape.html). Do not infer a hole72+only from a near-vertical centerline tangent.73+74+### T3 — Use an error-bounded adaptive turn tessellation75+76+Replace the global `MAX_TURN` rule with an error target in world units. For an77+offset boundary radius `r`, choose angular steps so the chord sagitta78+`r * (1 - cos(delta / 2))` stays below the allowed collision-to-mesh error.79+Evaluate both inner and outer offsets and add mandatory rings at ports,80+inflections, curvature extrema, and width changes. This directly controls the81+over/undercut the player sees, rather than only controlling tangent rotation.82+83+### T4 — Cook a simplified static triangle mesh from the render source84+85+These tracks are static environmental geometry, the strongest case for a86+cleaned/welded concave collider. PhysX triangle-mesh cooking validates, welds,87+builds adjacency, and constructs an acceleration structure88+([PhysX Geometry guide](https://docs.nvidia.com/gameworks/content/gameworkslibrary/physx/guide/Manual/Geometry.html)).89+Unity likewise recommends non-convex mesh colliders where high-fidelity static90+collision is essential and documents mesh cleaning/welding91+([Unity mesh cooking](https://docs.unity3d.com/jp/current/Manual/physics-optimization-cpu-mesh-cooking-options.html)).92+Godot describes concave trimeshes as the most accurate static-level option,93+with the usual warning about fast small bodies94+([ConcavePolygonShape3D](https://docs.godotengine.org/en/stable/classes/class_concavepolygonshape3d.html)).95+96+For Marbles, generate a low-poly collision surface during asset import, retain97+the exact aperture and rim boundaries, weld it, and keep Box3D's internal-edge98+identification enabled. The render mesh itself need not be the runtime collider.99+100+### T5 — Use compound convex pieces where a concave surface is unreliable101+102+Drop tubes and split/junction walls can be a small, authored compound of convex103+parts. When automatic decomposition is useful, CoACD is specifically designed104+to preserve collision conditions with fewer convex components105+([CoACD project and SIGGRAPH 2022 paper](https://github.com/SarahWeiii/CoACD)).106+This is preferable to axis-aligned boxes that ignore a curved mouth or turning107+shaft, but authored parts will be more predictable for this small kit.108+109+### T6 — Separate geometric fidelity from solver forgiveness110+111+Do not encode contact tolerance by making walls much taller or wider than the112+art. Keep collider geometry close to the mesh and apply small, measured solver113+margins. PhysX explicitly separates `contactOffset` and `restOffset` for this114+purpose115+([Advanced Collision Detection](https://docs.nvidia.com/gameworks/content/gameworkslibrary/physx/guide/Manual/AdvancedCollisionDetection.html)).116+For Box3D, establish the equivalent narrow margin through sphere radius/contact117+settings, then test at the maximum marble speed.118+119+Add CI measurements per piece: signed floor-height error, lateral boundary120+error, aperture false-positive area, and minimum roof/stack clearance. Report121+max and percentile errors and fail on a piece-specific budget. This turns the122+gallery from a visual-only regression tool into a quantitative one.123+124+### T7 — Enforce asset and bench/runtime parity125+126+- Assert every selected render group has finite, non-empty bounds; this catches127+ `cross-over` before a camera operation or audit.128+- Build collision through one shared function used by production, the bench,129+ tests, and simulation. The current wide-scale argument mismatch proves that130+ separately wired diagnostic paths can produce false conclusions.131+- Add stable piece number, piece id, family, mesh bounds, proxy composition, and132+ production options to the accessible bench state (now present in the updated133+ gallery UI).134+135+## Suggested order of work136+137+1. Fix `cross-over` asset/bounds and share the production proxy builder with the138+ bench so the diagnostic is trustworthy.139+2. Replace `straight-hole` and drop-stack generic logic with authored aperture140+ and shaft colliders.141+3. Make width a mandatory part of the swept profile and add parity tests for all142+ five wide/funnel variants.143+4. Reduce the wall profile to the visible rim plus a small solver margin.144+5. Convert turn sampling from angle-bounded to boundary-error-bounded, starting145+ with `corner`, `bend`, and the four short S-curve variants.146+6. Replace cross/diamond slabs with authored junction collision surfaces or a147+ small compound-convex set.148+149+## Remediation — 2026-08-09 (same day, ready for re-audit)150+151+All six work items landed; new kit truths were measured headlessly from152+`assets/marble-kit.glb` (scratch tooling patterned on `tools/measure_ports.ts`):153+wall inner face 0.287 grid, rim floor+0.25 grid, floor bore ROUND r 0.25 grid154+in `straight-hole` and BOTH end-hole flavors (flavors differ only in cup155+cosmetics), wide inner wall 0.575 grid → widening is an additive wall shift,156+not a section scale.157+158+1. **Parity (T7)** — `segmentProxies` in `src/gen/endless/proxies.ts` is now159+ the ONE channel builder used by production, the bench, and the sims;160+ `gapSegmentsOf` moved beside it (rail.ts re-exports). The bench asserts161+ finite mesh bounds, frames empty plots safely, and renders `cross-over`162+ (a deliberate noMesh phantom, not a broken asset) with its ghosted host163+ mesh so the row is evaluable.164+2. **Apertures (T2/T5)** — `PieceDef.holes` → `LoopSegment.holes` opens a165+ real bore in the ribbon with lip decks, side walls, and an under-deck166+ guide ring (`emitAperture`). Drop-stacks get an authored measured shape:167+ annulus deck floor, octagonal cup walls (r 1.0 world) with the entry168+ arch open, octagonal shaft bore, receiver cup opened toward the true169+ exit yaw, and the pan law kept.170+3. **Width (T1)** — width flows as a measured additive wall shift171+ (0.576 world) through `wideScalesOf`; the bench passes def-authored172+ `widthIn/widthOut` so isolated funnels taper. Parity tests:173+ `tests/collision_width_test.ts`.174+4. **Rim (T6)** — `CHANNEL_PROFILE` now tops at 0.62 over the floor175+ (measured rim 0.5 + 0.12 solver cap) with the wall face at 0.62 lateral176+ (measured 0.574 + clearance); junction composite walls follow the same177+ law.178+5. **Turns (T3)** — arc stations are chosen by outer-boundary chord-sagitta179+ budget (`BOUNDARY_ERR` 0.008 world) instead of a fixed angle; ring-level180+ crease rounding (kicker prevention) is unchanged and documented as181+ deliberate forgiveness geometry.182+6. **Junctions (T2)** — the cross cell emits one authored 4-way composite183+ (deck + corner wall Ls, all four port arches open) from the mesh visit;184+ the phantom stays silent — previously the two same-height route ribbons185+ walled each other off. Diamond/spur/braid composites tightened to the186+ measured split-double wall faces (branch ± 0.574 + clearance).187+188+**Quantitative CI (the audit's T6 closing ask)** —189+`tests/collision_metrics_test.ts` measures every registry variant: signed190+ring-floor error vs centerline (budget 0.05, worst measured 0.041 at the191+slant-d crest rounding), ring lateral extents, rim cap band, bore openness192+probes for all 13 hole/drop bores, and cross junction coverage probes. It193+found and fixed one real defect during bring-up: approach ribbons194+overhanging drop bores (now lip-trimmed in the proxy law).195+196+**Re-audit gap closure (2026-08-09, after the fresh CUA pass in197+`collision-mesh-reaudit.md`)** — the CI now also measures against the198+RENDER MESH itself, not just centerline + constants:199+200+- *Render-to-collision directed distance*: every GLB channel-surface201+ vertex (placement-transformed, corridor-filtered to the driveable band;202+ port-face bands excluded — those are shared window boundaries covered by203+ the neighbor's ring) must lie within 0.2 world of the collision surface204+ (point-to-slab + point-to-ribbon-triangle). 40k+ vertices across all205+ variants; worst 0.19. Bringing this up hardened the colliders further:206+ straight-hole gained its bore side-sliver decks (collar verts were 0.24207+ adrift) and the cross corner Ls became solid corner posts matching the208+ kit's blocks (0.29 adrift at the post corners).209+- *Drop compound probes* (all 12): entry arch open, far/lateral cup walls210+ present, shaft bore guide present, receiver exit mouth open toward the211+ true exit heading with its far wall present, pan present.212+- *Diamond compound probes* (all 3): branch decks present, outer and wedge213+ walls at the measured lines, branch lanes and entry throat clear.214+- *Coverage accounting*: bench-build failures fail the suite; every215+ variant must be evaluated by ≥1 metric family (rings 113, render-dist216+ 101, probes 17, paired-by-design 1 — `cross-over`, whose collision is217+ the co-located cross composite).218+219+Suite: 174 tests green.
A docs/collision-mesh-reaudit.csv +120 -0
@@ -0,0 +1,120 @@
1+ordinal,piece,reversed,mirrored,family,cua_floor,cua_walls,cua_feature,cua_result,residual_or_gap,generated_tool_coverage,comparison,observation2+"1","straight","false","false","straight","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."3+"2","tunnel","false","false","tunnel","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."4+"3","straight-hole","false","false","straight","aligned","aligned","open","pass","none","13-bore openness and lip-deck probes","agrees","The authored eight-slab aperture visibly leaves the round bore open and supports the surrounding lip."5+"4","tunnel-run-2","false","false","tunnel","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."6+"5","tunnel-run-3","false","false","tunnel","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."7+"6","drop-stack-rounded-f","false","false","drop","aligned at entry and receiver","authored octagonal compound","open","pass-with-confidence-gap","compound_shape_not_mesh_distance_tested","bore-open and lip-deck probes only","agrees on topology; CI is less complete","Annulus deck, octagonal cup/shaft, true-yaw receiver, and pan follow the visible drop topology; no five-slab generic tube remains, but CI does not measure compound-to-render distance."8+"7","drop-stack-rounded-l","false","false","drop","aligned at entry and receiver","authored octagonal compound","open","pass-with-confidence-gap","compound_shape_not_mesh_distance_tested","bore-open and lip-deck probes only","agrees on topology; CI is less complete","Annulus deck, octagonal cup/shaft, true-yaw receiver, and pan follow the visible drop topology; no five-slab generic tube remains, but CI does not measure compound-to-render distance."9+"8","drop-stack-rounded-r","false","false","drop","aligned at entry and receiver","authored octagonal compound","open","pass-with-confidence-gap","compound_shape_not_mesh_distance_tested","bore-open and lip-deck probes only","agrees on topology; CI is less complete","Annulus deck, octagonal cup/shaft, true-yaw receiver, and pan follow the visible drop topology; no five-slab generic tube remains, but CI does not measure compound-to-render distance."10+"9","drop-stack-square-f","false","false","drop","aligned at entry and receiver","authored octagonal compound","open","pass-with-confidence-gap","compound_shape_not_mesh_distance_tested","bore-open and lip-deck probes only","agrees on topology; CI is less complete","Annulus deck, octagonal cup/shaft, true-yaw receiver, and pan follow the visible drop topology; no five-slab generic tube remains, but CI does not measure compound-to-render distance."11+"10","drop-stack-square-l","false","false","drop","aligned at entry and receiver","authored octagonal compound","open","pass-with-confidence-gap","compound_shape_not_mesh_distance_tested","bore-open and lip-deck probes only","agrees on topology; CI is less complete","Annulus deck, octagonal cup/shaft, true-yaw receiver, and pan follow the visible drop topology; no five-slab generic tube remains, but CI does not measure compound-to-render distance."12+"11","drop-stack-square-r","false","false","drop","aligned at entry and receiver","authored octagonal compound","open","pass-with-confidence-gap","compound_shape_not_mesh_distance_tested","bore-open and lip-deck probes only","agrees on topology; CI is less complete","Annulus deck, octagonal cup/shaft, true-yaw receiver, and pan follow the visible drop topology; no five-slab generic tube remains, but CI does not measure compound-to-render distance."13+"12","cross","false","false","cross","aligned four-way deck","aligned corner stubs","four mouths open","pass","none","cross deck/port/wall probes","agrees","The four-way collision deck covers both axes and the four port mouths remain visibly open."14+"13","cross-over","false","false","cross","shared host only","none by design","production-paired","conditional","isolated_phantom_requires_cross_host","no isolated collider metric (quiet phantom)","consistent by design, not independently proven","The gallery correctly ghosts the host mesh and emits no proxy; production relies on the co-located cross composite."15+"14","corner","false","false","corner","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."16+"15","curve","false","false","curve","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."17+"16","curve-large","false","false","curve","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."18+"17","slant-b","false","false","slant","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."19+"18","slant-long-b","false","false","slant","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."20+"19","ramp-long-b","false","false","ramp","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."21+"20","slant-c","false","false","slant","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."22+"21","slant-long-c","false","false","slant","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."23+"22","ramp-long-c","false","false","ramp","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."24+"23","slant-d","false","false","slant","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."25+"24","slant-long-d","false","false","slant","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."26+"25","ramp-long-d","false","false","ramp","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."27+"26","ramp-start-b","false","false","ramp","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."28+"27","ramp-end-b","false","false","ramp","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."29+"28","ramp-start-c","false","false","ramp","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."30+"29","ramp-end-c","false","false","ramp","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."31+"30","ramp-start-d","false","false","ramp","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."32+"31","ramp-end-d","false","false","ramp","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."33+"32","bend","false","false","bend","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."34+"33","bend-medium","false","false","bend","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."35+"34","bend-large","false","false","bend","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."36+"35","helix-quarter-left","false","false","helix","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."37+"36","helix-quarter-right","false","false","helix","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."38+"37","helix-large-quarter-left","false","false","helix","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."39+"38","helix-large-quarter-right","false","false","helix","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."40+"39","helix-half-left","false","false","helix","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."41+"40","helix-half-right","false","false","helix","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."42+"41","helix-large-half-left","false","false","helix","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."43+"42","helix-large-half-right","false","false","helix","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."44+"43","diamond-1","false","false","split","aligned active deck","tightened slab compound","n/a","pass-with-confidence-gap","slab_compound_not_mesh_distance_tested","no dedicated diamond geometry metric","CUA passes driven surface; CI has a gap","The top deck and branch lanes align visually; slab volumes remain broader below the sculpted mesh, and no render-distance metric covers them."45+"44","diamond-2","false","false","split","aligned active deck","tightened slab compound","n/a","pass-with-confidence-gap","slab_compound_not_mesh_distance_tested","no dedicated diamond geometry metric","CUA passes driven surface; CI has a gap","The top deck and branch lanes align visually; slab volumes remain broader below the sculpted mesh, and no render-distance metric covers them."46+"45","diamond-3","false","false","split","aligned active deck","tightened slab compound","n/a","pass-with-confidence-gap","slab_compound_not_mesh_distance_tested","no dedicated diamond geometry metric","CUA passes driven surface; CI has a gap","The top deck and branch lanes align visually; slab volumes remain broader below the sculpted mesh, and no render-distance metric covers them."47+"46","helix-left","false","false","helix","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."48+"47","helix-right","false","false","helix","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."49+"48","helix-large-left","false","false","helix","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."50+"49","helix-large-right","false","false","helix","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."51+"50","s-curve-left","false","false","s-curve","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."52+"51","s-curve-right","false","false","s-curve","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."53+"52","s-curve-left-large","false","false","s-curve","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."54+"53","s-curve-right-large","false","false","s-curve","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."55+"54","s-curve-short-left-c","false","false","s-curve","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."56+"55","s-curve-short-right-c","false","true","s-curve","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."57+"56","wiggle-a","false","false","s-curve","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."58+"57","wiggle-a-mirror","false","true","s-curve","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."59+"58","wiggle-b","false","false","s-curve","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."60+"59","wiggle-b-mirror","false","true","s-curve","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."61+"60","lane-shift-b","false","false","s-curve","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."62+"61","lane-shift-b-mirror","false","true","s-curve","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."63+"62","wave-a","false","false","wave","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."64+"63","wave-a-mirror","false","true","wave","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."65+"64","wave-b","false","false","wave","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."66+"65","wave-b-mirror","false","true","wave","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."67+"66","wave-c","false","false","wave","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."68+"67","wave-c-mirror","false","true","wave","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."69+"68","bump-a","false","false","bump","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."70+"69","bump-b","false","false","bump","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."71+"70","bump-c","false","false","bump","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."72+"71","bump-d","false","false","bump","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."73+"72","straight-wide","false","false","wide","aligned","aligned","n/a","pass","none","ribbon metrics plus dedicated width parity","agrees","Collision follows the full/tapered wide mesh in the authored direction; reversed funnels taper correctly."74+"73","funnel","false","false","funnel","aligned","aligned","n/a","pass","none","ribbon metrics plus dedicated width parity","agrees","Collision follows the full/tapered wide mesh in the authored direction; reversed funnels taper correctly."75+"74","funnel-long","false","false","funnel","aligned","aligned","n/a","pass","none","ribbon metrics plus dedicated width parity","agrees","Collision follows the full/tapered wide mesh in the authored direction; reversed funnels taper correctly."76+"75","drop-stack-rounded-f","true","false","drop","aligned at entry and receiver","authored octagonal compound","open","pass-with-confidence-gap","compound_shape_not_mesh_distance_tested","bore-open and lip-deck probes only","agrees on topology; CI is less complete","Annulus deck, octagonal cup/shaft, true-yaw receiver, and pan follow the visible drop topology; no five-slab generic tube remains, but CI does not measure compound-to-render distance."77+"76","drop-stack-rounded-l","true","false","drop","aligned at entry and receiver","authored octagonal compound","open","pass-with-confidence-gap","compound_shape_not_mesh_distance_tested","bore-open and lip-deck probes only","agrees on topology; CI is less complete","Annulus deck, octagonal cup/shaft, true-yaw receiver, and pan follow the visible drop topology; no five-slab generic tube remains, but CI does not measure compound-to-render distance."78+"77","drop-stack-rounded-r","true","false","drop","aligned at entry and receiver","authored octagonal compound","open","pass-with-confidence-gap","compound_shape_not_mesh_distance_tested","bore-open and lip-deck probes only","agrees on topology; CI is less complete","Annulus deck, octagonal cup/shaft, true-yaw receiver, and pan follow the visible drop topology; no five-slab generic tube remains, but CI does not measure compound-to-render distance."79+"78","drop-stack-square-f","true","false","drop","aligned at entry and receiver","authored octagonal compound","open","pass-with-confidence-gap","compound_shape_not_mesh_distance_tested","bore-open and lip-deck probes only","agrees on topology; CI is less complete","Annulus deck, octagonal cup/shaft, true-yaw receiver, and pan follow the visible drop topology; no five-slab generic tube remains, but CI does not measure compound-to-render distance."80+"79","drop-stack-square-l","true","false","drop","aligned at entry and receiver","authored octagonal compound","open","pass-with-confidence-gap","compound_shape_not_mesh_distance_tested","bore-open and lip-deck probes only","agrees on topology; CI is less complete","Annulus deck, octagonal cup/shaft, true-yaw receiver, and pan follow the visible drop topology; no five-slab generic tube remains, but CI does not measure compound-to-render distance."81+"80","drop-stack-square-r","true","false","drop","aligned at entry and receiver","authored octagonal compound","open","pass-with-confidence-gap","compound_shape_not_mesh_distance_tested","bore-open and lip-deck probes only","agrees on topology; CI is less complete","Annulus deck, octagonal cup/shaft, true-yaw receiver, and pan follow the visible drop topology; no five-slab generic tube remains, but CI does not measure compound-to-render distance."82+"81","corner","true","false","corner","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."83+"82","curve","true","false","curve","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."84+"83","curve-large","true","false","curve","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."85+"84","slant-b","true","false","slant","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."86+"85","slant-long-b","true","false","slant","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."87+"86","ramp-long-b","true","false","ramp","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."88+"87","slant-c","true","false","slant","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."89+"88","slant-long-c","true","false","slant","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."90+"89","ramp-long-c","true","false","ramp","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."91+"90","slant-d","true","false","slant","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."92+"91","slant-long-d","true","false","slant","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."93+"92","ramp-long-d","true","false","ramp","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."94+"93","ramp-start-b","true","false","ramp","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."95+"94","ramp-end-b","true","false","ramp","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."96+"95","ramp-start-c","true","false","ramp","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."97+"96","ramp-end-c","true","false","ramp","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."98+"97","ramp-start-d","true","false","ramp","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."99+"98","ramp-end-d","true","false","ramp","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."100+"99","bend","true","false","bend","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."101+"100","bend-medium","true","false","bend","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."102+"101","bend-large","true","false","bend","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."103+"102","helix-quarter-left","true","false","helix","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."104+"103","helix-quarter-right","true","false","helix","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."105+"104","helix-large-quarter-left","true","false","helix","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."106+"105","helix-large-quarter-right","true","false","helix","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."107+"106","helix-half-left","true","false","helix","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."108+"107","helix-half-right","true","false","helix","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."109+"108","helix-large-half-left","true","false","helix","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."110+"109","helix-large-half-right","true","false","helix","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."111+"110","helix-left","true","false","helix","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."112+"111","helix-right","true","false","helix","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."113+"112","helix-large-left","true","false","helix","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."114+"113","helix-large-right","true","false","helix","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."115+"114","s-curve-short-left-c","true","false","s-curve","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."116+"115","s-curve-short-right-c","true","true","s-curve","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."117+"116","lane-shift-b","true","false","s-curve","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."118+"117","lane-shift-b-mirror","true","true","s-curve","aligned","aligned","n/a","pass","none","ribbon floor/lateral/rim metrics","agrees","Fresh CUA overlay follows the rendered floor and rim with no visible width, turn, or guard-height regression."119+"118","funnel","true","false","funnel","aligned","aligned","n/a","pass","none","ribbon metrics plus dedicated width parity","agrees","Collision follows the full/tapered wide mesh in the authored direction; reversed funnels taper correctly."120+"119","funnel-long","true","false","funnel","aligned","aligned","n/a","pass","none","ribbon metrics plus dedicated width parity","agrees","Collision follows the full/tapered wide mesh in the authored direction; reversed funnels taper correctly."
A docs/collision-mesh-reaudit.md +46 -0
@@ -0,0 +1,46 @@
1+# Collision-to-mesh re-audit — fresh CUA vs generated CI2+3+Audit date: 2026-08-09 4+Gallery: `?endless=1&peers=0&gallery=1` 5+Inventory: 119/119 registry variants selected explicitly with collision enabled6+7+The cache-contaminated run was discarded. This report is based on a fresh hard-reloaded gallery bundle. Per-piece results are in [`collision-mesh-reaudit.csv`](./collision-mesh-reaudit.csv); the original failure inventory and prior-art technique map remain in [`collision-mesh-audit.md`](./collision-mesh-audit.md).8+9+## Verdict10+11+No critical or high collision-to-mesh regression was reproduced. The repaired gallery visibly resolves the four known classes:12+13+- Wide collision now fills `straight-wide` and tapers correctly through both funnel lengths and reversed variants.14+- `straight-hole` has a real open bore. All 12 drop variants use authored annulus/cup/shaft/receiver compounds instead of the old five-slab generic tube.15+- Corners, bends, curves, helixes, waves, bumps, S-curves, and their reversed/mirrored variants track the visible floor boundary without the former turn under/overcut.16+- Ribbon guard tops now sit close to the rendered rim rather than forming a tall invisible wall.17+18+| Fresh CUA result | Pieces | Interpretation |19+|---|---:|---|20+| pass | 103 | Visible driven surface, width, rim, and any opening/junction agree with the render mesh |21+| pass with confidence gap | 15 | 12 drop and 3 diamond compounds look correct on the driven surface, but CI does not measure their full collision-to-render distance |22+| conditional | 1 | `cross-over` is a quiet phantom and intentionally has no isolated proxy; it relies on the co-located `cross` composite in production |23+24+## CUA-to-CI comparison25+26+The generated suites passed: 3 collision-metric tests plus 5 width-parity tests. They report 3,009 ribbon rings, worst signed floor error `0.0405` world units at `slant-d`, 13 open bores with lip decks, and a passing four-port cross composite. The complete project suite is also green: 170 tests, 0 failures; `deno task check` passes.27+28+| Area | Fresh CUA | Generated tooling | Assessment |29+|---|---|---|---|30+| Regular/curved ribbons | Floors and rims visually align for forward, reverse, and mirror variants | Ring floor, lateral span, and cap budgets | Strong agreement |31+| Wide/funnels | Full width and both taper directions align | Dedicated entry/exit width assertions | Strong agreement |32+| Hole/drop topology | All 13 bores are visibly open; drop cups and receiver yaw look authored | Bore-clear and lip-deck point probes | Agreement on topology |33+| Cross | Four-way floor is present and all mouths are open | Deck, mouth, and corner probes | Strong agreement |34+| Drop compound shape | Octagonal compound visually follows the cup/shaft far better than the prior generic stack | No compound-to-render distance metric | CI coverage gap |35+| Diamonds | Driven top deck and branches align; slab boxes remain broader below the sculpted mesh | No dedicated diamond geometry metric | CI coverage gap |36+| `cross-over` | Ghosted host mesh, no proxy by design | No isolated collision metric | Production-pair dependency |37+38+## What the regression does and does not prove39+40+The ribbon test is useful, but its `across 119 variants` output should not be read as 119 direct render-mesh comparisons. It iterates the registry, silently skips bench-build failures, and measures only `mesh` proxies; slab-only compounds are skipped by the ring loop. Its truth source is the centerline plus measured profile constants, not render triangles or a sampled render surface. The broad lateral (`0.55..1.40`) and cap (`0.30..0.80`) windows also permit drift that a direct distance metric would catch.41+42+Recommended next regression: sample both the rendered collider reference and collision surface, then assert directed surface distance (or signed-distance/occupancy probes) separately for driveable floor, wall/rim, aperture boundary, and underside. At minimum, add per-piece coverage accounting so every variant reports which metric family actually evaluated it, plus dedicated drop and diamond compound probes.43+44+## Prior-art technique map45+46+The baseline report already documents the relevant prior art and links: variable cross-section sweeps (T1), explicit hole contours and authored topology (T2), sagitta/error-bounded turn tessellation (T3), simplified concave static meshes and mesh cooking (T4), compound convex decomposition/CoACD (T5), solver margins separated from geometry (T6), and shared bench/runtime construction with asset assertions (T7). Those recommendations still apply; the current remediation visibly implements T1, T2, T3, T6, and T7, with authored compound geometry in the T5 role for drops and junctions.
M progress.md +5 -1
M src/client/endless.ts +4 -13
M src/client/physics/rail.ts +3 -8
M src/client/pieceBench.ts +174 -53
@@ -27,11 +27,14 @@ import { assemble } from "../gen/assemble.ts";
2727 import { pickStyle } from "../gen/style.ts";2828 import { rngFamily } from "../gen/rng.ts";2929 import { DIR_STEP, turnLeft, turnRight } from "../gen/geom.ts";30-import { channelProxies, type CollisionProxy } from "../gen/endless/proxies.ts";30+import {31+ type CollisionProxy,32+ segmentProxies,33+} from "../gen/endless/proxies.ts";3134 import { scaleSeg, scaleVec, TRACK_SCALE } from "../gen/endless/scale.ts";3235 import { instantiatePlacements, type Spinner } from "./track.ts";3336 import { installCua } from "./cua.ts";34-import { gapSegmentsOf, type Rail, railForSegment } from "./physics/rail.ts";37+import { type Rail, railForSegment } from "./physics/rail.ts";3538 import type { LoopPath } from "../shared/types.ts";3639 import {3740 MARBLE_RADIUS,
@@ -50,6 +53,53 @@ interface Plot {
5053 proxies: CollisionProxy[];5154 overlay: THREE.Group;5255 label: string;56+ /** Finite, non-empty render bounds (T7 assertion). Phantoms pass via57+ * their ghosted host mesh; false is a loud data defect. */58+ meshValid: boolean;59+}60+61+function diagnosticInfo(p: Plot) {62+ const box = new THREE.Box3().setFromObject(p.group);63+ box.translate(p.origin.clone().negate());64+ return {65+ piece: p.def.id,66+ variant: {67+ reversed: !!p.def.reversed,68+ mirrored: !!p.def.mirrored,69+ moves: p.def.moves,70+ family: p.def.family,71+ deckDelta: p.def.deckDelta,72+ floorIn: p.def.floorIn,73+ floorOut: p.def.floorOut,74+ heightSpan: p.def.heightSpan,75+ meshOffset: p.def.meshOffset,76+ meshRot: p.def.meshRot,77+ },78+ meshValid: p.meshValid,79+ phantom: !!p.def.noMesh,80+ meshBBox: {81+ min: box.min.toArray().map((v: number) => +v.toFixed(3)),82+ max: box.max.toArray().map((v: number) => +v.toFixed(3)),83+ },84+ rail: {85+ stations: p.rail.pts.length,86+ entry: p.rail.pts[0],87+ exit: p.rail.pts[p.rail.pts.length - 1],88+ },89+ proxies: p.proxies.map((pr) =>90+ pr.kind === "mesh"91+ ? { kind: pr.kind, verts: pr.vertices.length / 3 }92+ : pr.kind === "bumper"93+ ? { kind: pr.kind, center: pr.center, r: pr.radius, h: pr.height }94+ : {95+ kind: pr.kind,96+ center: pr.center,97+ half: pr.half,98+ ...(pr.kind === "slab" ? { yaw: pr.yaw, pitch: pr.pitch } : {}),99+ }100+ ),101+ scale: TRACK_SCALE,102+ };53103 }54104 55105 /** Assemble ONE variant on a synthetic skeleton — the production path. */
@@ -91,6 +141,8 @@ function buildVariant(def: PieceDef): {
91141 ...s,92142 curve: s.curve.map(scaleSeg),93143 length: s.length * TRACK_SCALE,144+ // Floor-pierce centers scale with the track (LoopSegment law).145+ ...(s.holes ? { holes: s.holes.map(scaleVec) } : {}),94146 ...(s.alt95147 ? {96148 alt: {
@@ -212,22 +264,61 @@ export function buildPieceBench(ctx: SceneCtx, assets: Assets): void {
212264 spinners,213265 missing,214266 );267+ // Bench-evaluable phantoms (T7): a noMesh def (cross-over — a268+ // crossing's second visit) places no mesh of its own, which left the269+ // plot with NULL bounds, a stuck camera, and an unauditable row. Show270+ // the HOST mesh the marble actually sees at that cell, ghosted and271+ // perpendicular to the phantom's travel.272+ if (def.noMesh && def.meshId) {273+ const ghost = new THREE.Group();274+ instantiatePlacements(275+ [{276+ pieceId: def.meshId,277+ pos: [0, 0, 0],278+ rotation: turnLeft(1),279+ role: "track",280+ scale: TRACK_SCALE,281+ }],282+ assets,283+ ghost,284+ spinners,285+ missing,286+ );287+ ghost.traverse((o: THREE.Object3D) => {288+ const mesh = o as THREE.Mesh;289+ if (mesh.isMesh && !Array.isArray(mesh.material)) {290+ const mat = mesh.material.clone();291+ mat.transparent = true;292+ mat.opacity = 0.45;293+ mesh.material = mat;294+ }295+ });296+ group.add(ghost);297+ }215298 root.add(group);299+ // T7 assertion: every plot must have finite, non-empty mesh bounds —300+ // a silent empty made an audit row not-evaluable.301+ const bbox = new THREE.Box3().setFromObject(group);302+ const meshValid = !bbox.isEmpty() &&303+ Number.isFinite(bbox.min.x) && Number.isFinite(bbox.max.x);304+ if (!meshValid) {305+ console.error(`[bench] ${def.id}: empty/non-finite mesh bounds`);306+ }216307 217- const rail = railForSegment({308+ // ONE segment source for rail AND proxies, fed to the same builder309+ // production uses (T7 parity — the bench used to omit the wide310+ // scales and lied about all five wide/funnel variants).311+ const src = {218312 path: built.path,219313 pieceFamilies: [def.family],220314 windowMods: [null],221- });222- const proxies = channelProxies(223- built.path,224- true,225- gapSegmentsOf({226- path: built.path,227- pieceFamilies: [def.family],228- windowMods: [null],229- }),230- );315+ // Def-authored port widths: an ISOLATED funnel/wide window still316+ // gets its true section (production derives the same from its317+ // funnel-bracketed neighbors).318+ widths: [{ widthIn: def.widthIn, widthOut: def.widthOut }],319+ };320+ const rail = railForSegment(src);321+ const proxies = segmentProxies(src);231322 const overlay = buildOverlay(proxies, rail);232323 overlay.position.copy(origin);233324 overlay.visible = false;
@@ -250,6 +341,7 @@ export function buildPieceBench(ctx: SceneCtx, assets: Assets): void {
250341 proxies,251342 overlay,252343 label,344+ meshValid,253345 });254346 });255347 for (const id of missing) console.warn(`[bench] missing mesh "${id}"`);
@@ -265,6 +357,8 @@ export function buildPieceBench(ctx: SceneCtx, assets: Assets): void {
265357 root.add(marker);266358 267359 const ui = document.createElement("div");360+ ui.setAttribute("role", "toolbar");361+ ui.setAttribute("aria-label", "Piece collision audit controls");268362 Object.assign(ui.style, {269363 position: "fixed",270364 bottom: "14px",
@@ -291,9 +385,16 @@ export function buildPieceBench(ctx: SceneCtx, assets: Assets): void {
291385 return b;292386 };293387 const prevBtn = mkBtn("◀", () => {});388+ prevBtn.setAttribute("aria-label", "Previous piece");389+ prevBtn.title = "Previous piece";294390 const nextBtn = mkBtn("▶", () => {});391+ nextBtn.setAttribute("aria-label", "Next piece");392+ nextBtn.title = "Next piece";295393 const jumpInput = document.createElement("input");296- jumpInput.placeholder = "jump to id…";394+ jumpInput.placeholder = "piece # or id…";395+ jumpInput.setAttribute("aria-label", "Piece number or id");396+ jumpInput.autocomplete = "off";397+ jumpInput.spellcheck = false;297398 Object.assign(jumpInput.style, {298399 background: "rgba(10,15,26,0.8)",299400 color: "#dce6f5",
@@ -304,6 +405,9 @@ export function buildPieceBench(ctx: SceneCtx, assets: Assets): void {
304405 });305406 ui.appendChild(jumpInput);306407 const status = document.createElement("span");408+ status.setAttribute("role", "status");409+ status.setAttribute("aria-live", "polite");410+ status.setAttribute("aria-label", "Selected piece");307411 Object.assign(status.style, {308412 color: "#dce6f5",309413 alignSelf: "center",
@@ -321,6 +425,28 @@ export function buildPieceBench(ctx: SceneCtx, assets: Assets): void {
321425 ui.appendChild(status);322426 document.body.appendChild(ui);323427 428+ // CUA-readable selected-piece facts. This intentionally stays compact so it429+ // does not cover the mesh while keeping the exact proxy composition in the430+ // accessibility tree and in screenshots used for collision audits.431+ const auditFacts = document.createElement("pre");432+ auditFacts.setAttribute("aria-label", "Selected piece collision facts");433+ Object.assign(auditFacts.style, {434+ position: "fixed",435+ right: "12px",436+ bottom: "58px",437+ zIndex: "12",438+ margin: "0",439+ maxWidth: "min(420px, 42vw)",440+ padding: "7px 9px",441+ background: "rgba(10,15,26,0.82)",442+ color: "#b9c8dd",443+ border: "1px solid rgba(220,230,245,0.25)",444+ font: "500 10px/1.35 ui-monospace, Menlo, monospace",445+ whiteSpace: "pre-wrap",446+ pointerEvents: "none",447+ });448+ document.body.appendChild(auditFacts);449+324450 let showCollision = false;325451 const collisionBtn = mkBtn("collision: off", () => {326452 showCollision = !showCollision;
@@ -400,45 +526,7 @@ export function buildPieceBench(ctx: SceneCtx, assets: Assets): void {
400526 return;401527 }402528 const p = selected;403- const box = new THREE.Box3().setFromObject(p.group);404- box.translate(p.origin.clone().negate());405- const info = {406- piece: p.def.id,407- variant: {408- reversed: !!p.def.reversed,409- mirrored: !!p.def.mirrored,410- moves: p.def.moves,411- family: p.def.family,412- deckDelta: p.def.deckDelta,413- floorIn: p.def.floorIn,414- floorOut: p.def.floorOut,415- heightSpan: p.def.heightSpan,416- meshOffset: p.def.meshOffset,417- meshRot: p.def.meshRot,418- },419- meshBBox: {420- min: box.min.toArray().map((v: number) => +v.toFixed(3)),421- max: box.max.toArray().map((v: number) => +v.toFixed(3)),422- },423- rail: {424- stations: p.rail.pts.length,425- entry: p.rail.pts[0],426- exit: p.rail.pts[p.rail.pts.length - 1],427- },428- proxies: p.proxies.map((pr) =>429- pr.kind === "mesh"430- ? { kind: pr.kind, verts: pr.vertices.length / 3 }431- : pr.kind === "bumper"432- ? { kind: pr.kind, center: pr.center, r: pr.radius, h: pr.height }433- : {434- kind: pr.kind,435- center: pr.center,436- half: pr.half,437- ...(pr.kind === "slab" ? { yaw: pr.yaw, pitch: pr.pitch } : {}),438- }439- ),440- scale: TRACK_SCALE,441- };529+ const info = diagnosticInfo(p);442530 navigator.clipboard?.writeText(JSON.stringify(info, null, 2))443531 .then(() => status.textContent = `copied ${p.label}`)444532 .catch(() => status.textContent = "clipboard blocked");
@@ -560,14 +648,46 @@ export function buildPieceBench(ctx: SceneCtx, assets: Assets): void {
560648 marker.visible = true;561649 marker.position.set(plot.origin.x, 0.03, plot.origin.z);562650 const pbox = new THREE.Box3().setFromObject(plot.group);651+ // Empty bounds must not strand the camera on the previous piece652+ // (the cross-over audit blocker) — frame the plot origin instead.653+ if (pbox.isEmpty()) {654+ pbox.setFromCenterAndSize(655+ plot.origin.clone().add(new THREE.Vector3(0, 1, 0)),656+ new THREE.Vector3(6, 4, 6),657+ );658+ }563659 // A one-cell piece frames too tight for orbit's min distance — pad.564660 pbox.expandByScalar(1.2);565661 cams.frame(pbox);662+ jumpInput.value = String(current + 1);566663 status.textContent = `${current + 1}/${plots.length} ${plot.label}`;664+ const info = diagnosticInfo(plot);665+ const proxyKinds = info.proxies.map((p) =>666+ "verts" in p ? `mesh(${p.verts}v)` : p.kind667+ ).join(", ");668+ auditFacts.textContent = [669+ `piece ${current + 1}/${plots.length}: ${plot.label}`,670+ `family=${info.variant.family} moves=${info.variant.moves}`,671+ `meshBBox=${JSON.stringify(info.meshBBox)}`,672+ `proxies=${proxyKinds || "none"} railStations=${info.rail.stations}`,673+ ...(info.phantom674+ ? ["phantom (noMesh) — ghosted HOST mesh shown for comparison"]675+ : []),676+ ...(info.meshValid ? [] : ["⚠ MESH BOUNDS EMPTY — not evaluable"]),677+ ].join("\n");567678 };568679 const jump = (q: string): boolean => {569680 const needle = q.trim().toLowerCase();570681 if (!needle) return false;682+ if (/^\d+$/.test(needle)) {683+ const ordinal = Number(needle);684+ if (ordinal >= 1 && ordinal <= plots.length) {685+ focus(ordinal - 1);686+ return true;687+ }688+ status.textContent = `piece number must be 1–${plots.length}`;689+ return false;690+ }571691 const at = plots.findIndex((p) => p.label.toLowerCase().includes(needle));572692 if (at < 0) {573693 status.textContent = `no piece matching "${q}"`;
@@ -602,6 +722,7 @@ export function buildPieceBench(ctx: SceneCtx, assets: Assets): void {
602722 piece: current >= 0 ? plots[current].label : null,603723 collision: showCollision,604724 simulating: simPlot ? simPlot.label : null,725+ diagnostic: current >= 0 ? diagnosticInfo(plots[current]) : null,605726 }));606727 607728 focus(0);
M src/gen/assemble.ts +10 -1
M src/gen/endless/proxies.ts +496 -86
@@ -145,22 +145,27 @@ export function bumperProxies(
145145 return out;146146 }147147 148-/** Channel cross-section half-profile (lateral, up) in world units at the149- * slalom scale: flat floor, gutter fillets, near-vertical walls with a150- * slight outward flare — the shape a marble rolls smoothly in. */148+/** Channel cross-section half-profile (lateral, up) in world units at149+ * the slalom scale: flat floor, gutter fillets, near-vertical walls.150+ * MEASURED (T6, packed-kit audit 2026-08-09): wall inner face at 0.574,151+ * rim at floor + 0.5 — the profile hugs those with a small clearance152+ * (+0.05 lateral so the marble never pinches against the visible wall)153+ * and a 0.12 flare cap over the rim as the solver margin for seam154+ * kickers. The old guard reached up=1.1 — more than double the visible155+ * rim — consuming tunnel/stack clearance and masking real edge exits. */151156 export const CHANNEL_PROFILE: readonly (readonly [number, number])[] = [152- [-0.92, 1.1],153- [-0.86, 0.66],154- [-0.80, 0.52],155- [-0.74, 0.16],156- [-0.62, 0.03],157+ [-0.66, 0.62],158+ [-0.62, 0.50],159+ [-0.60, 0.24],160+ [-0.56, 0.10],161+ [-0.50, 0.03],157162 [-0.30, 0],158163 [0.30, 0],159- [0.62, 0.03],160- [0.74, 0.16],161- [0.80, 0.52],162- [0.86, 0.66],163- [0.92, 1.1],164+ [0.50, 0.03],165+ [0.56, 0.10],166+ [0.60, 0.24],167+ [0.62, 0.50],168+ [0.66, 0.62],164169 ];165170 166171 /** Sampling step along the centerline, world units. */
@@ -186,8 +191,17 @@ const VERTICAL_LIMIT = 0.93;
186191 /** Floor slabs sink slightly so tilt ridges at sample seams stay below the187192 * contact surface (poke ≈ halfLen·δ − SINK). */188193 const SINK = 0.035;189-/** Max tangent turn per slab, radians — bounds seam ridges on curves. */194+/** Max tangent turn per RING, radians — the crease-rounding law195+ * (ribbonOf subdivision): sharp pitch/yaw V's between rings are kickers196+ * that launch fast marbles. Distinct from arc STATION sampling, which197+ * is boundary-error-bounded below (T3). */190198 const MAX_TURN = 0.12;199+/** Arc tessellation error budget (T3), world units: the chord sagitta200+ * at the OUTER channel boundary — what the player actually sees at the201+ * offset walls — stays under this. (The old rule bounded center-frame202+ * tangent rotation instead: tighter than needed on small radii, and no203+ * guarantee at all had radii grown.) ~4% of the marble radius. */204+const BOUNDARY_ERR = 0.008;191205 192206 /** One sampled station along the path. */193207 interface Station {
@@ -202,34 +216,128 @@ interface Station {
202216 /** Diamond-window sample (M11): the slab composite replaces the ribbon203217 * (two crossing one-sided branch meshes destabilized the solver). */204218 diamond: boolean;219+ /** Floor-pierce sample (T2): inside a hole's lip zone — the ribbon220+ * stops; the aperture composite carries the deck around the bore. */221+ hole: boolean;205222 dy: number;206- /** Lateral profile scale (wide runs: the kit's 1.7-unit channel vs the207- * 0.7 default; funnels lerp across their window). */208- wScale: number;223+ /** Lateral profile WIDENING in world units (wide runs; funnels lerp224+ * across their window): everything beyond the flat floor shifts225+ * outward by this much, stretching the floor between — the kit widens226+ * its channel by moving the walls out, not by scaling the section. */227+ wShift: number;209228 }210229 211-/** Wide channel lateral scale: kit wide walls ±0.85 track vs ±0.35. */212-const WIDE_SCALE = 0.85 / 0.35;230+/** Measured wide-channel widening (packed-kit audit 2026-08-09): inner231+ * wall face 0.575 grid vs the standard 0.287 → each wall moves out232+ * 0.288 grid = 0.576 world. (The old ×2.43 profile SCALE — from the233+ * ±0.85 outer-bbox claim — put collision walls outside the visible234+ * ±1.7 mesh entirely.) */235+const WIDE_SHIFT = 0.576;236+/** Profile points with |lat| beyond this are wall-side: they shift by237+ * the station's wShift; the flat floor inside stays put. */238+const PROFILE_FLAT = 0.31;213239 214-/** Per-path-segment lateral width range [entry, exit] for wide runs —240+/** Measured drop/hole law (packed-kit audit 2026-08-09, world units):241+ * the floor bore is ROUND, r 0.25 grid, in straight-hole AND both242+ * end-hole flavors — square vs rounded differ only in cup cosmetics243+ * (corner pockets beyond a marble's reach), so ONE authored shape serves244+ * every variant. Cup wall inner face sits essentially at the cell edge,245+ * rising the kit's 0.25-grid rim over the floor. */246+const HOLE_R = 0.5;247+const CUP_R = 1.0;248+const CUP_H = 0.5;249+/** Shaft bore guide: the hole radius plus marble clearance. */250+const SHAFT_R = 0.55;251+252+/** Junction composite walls follow the rim law (profile cap 0.62 over253+ * the floor, foot sunk 0.10 below): span [floor−0.10, floor+0.62]. */254+const JUNCTION_WALL_LIFT = 0.26;255+const JUNCTION_WALL_HALF_H = 0.36;256+/** Junction wall line: the kit's measured wall inner face (0.574) plus257+ * lateral clearance — matches where the channel ribbon's wall now sits. */258+const JUNCTION_HW = 0.66;259+/** Branch-mouth wall line where a neighbor ribbon's walls take over. */260+const JUNCTION_BR = 0.64;261+262+/** Per-path-segment lateral WIDENING range [entry, exit] (world units)263+ * for wide runs. Explicit per-window port widths (the def's authored264+ * widthIn/widthOut — the bench passes them) win; otherwise widths are215265 * derived from realized window families (wide windows are always216266 * funnel-bracketed by construction; a funnel tapers toward its wide217267 * neighbor). Undefined when the segment has no wide vocabulary. */218268 export function wideScalesOf(219- seg: { pieceFamilies: string[] },269+ seg: {270+ pieceFamilies: string[];271+ widths?: readonly (272+ | { widthIn?: string; widthOut?: string }273+ | null274+ | undefined275+ )[];276+ },220277 ): Map<number, readonly [number, number]> | undefined {221278 const fam = seg.pieceFamilies;222279 const out = new Map<number, readonly [number, number]>();223280 for (let i = 0; i < fam.length; i++) {224- if (fam[i] === "wide") out.set(i, [WIDE_SCALE, WIDE_SCALE]);281+ const w = seg.widths?.[i];282+ if (w && (w.widthIn === "wide" || w.widthOut === "wide")) {283+ out.set(i, [284+ w.widthIn === "wide" ? WIDE_SHIFT : 0,285+ w.widthOut === "wide" ? WIDE_SHIFT : 0,286+ ]);287+ } else if (fam[i] === "wide") out.set(i, [WIDE_SHIFT, WIDE_SHIFT]);225288 else if (fam[i] === "funnel") {226- if (fam[i + 1] === "wide") out.set(i, [1, WIDE_SCALE]);227- else if (fam[i - 1] === "wide") out.set(i, [WIDE_SCALE, 1]);289+ if (fam[i + 1] === "wide") out.set(i, [0, WIDE_SHIFT]);290+ else if (fam[i - 1] === "wide") out.set(i, [WIDE_SHIFT, 0]);228291 }229292 }230293 return out.size > 0 ? out : undefined;231294 }232295 296+/** The segment fields proxy building needs (EndlessSegment satisfies it;297+ * mirrors rail.ts RailSource plus the optional braid alt lane). */298+export interface ProxySource {299+ path: LoopPath;300+ altPath?: LoopPath;301+ pieceFamilies: string[];302+ windowMods: (string | null)[];303+ /** Explicit per-window port widths (def-authored) — the bench passes304+ * them so an isolated funnel/wide window still gets its true section;305+ * production omits them (families + neighbors carry the same truth). */306+ widths?: readonly (307+ | { widthIn?: string; widthOut?: string }308+ | null309+ | undefined310+ )[];311+}312+313+/** Path-segment indices that are jump gaps — the collision proxies emit314+ * no channel there (absence of geometry IS the gap). Lives with the315+ * proxy law; rail.ts re-exports it for the drivers. */316+export function gapSegmentsOf(317+ seg: { windowMods: (string | null)[] },318+): Set<number> {319+ const out = new Set<number>();320+ seg.windowMods.forEach((m, i) => {321+ if (m === "jump" || m === "propeller") out.add(i);322+ });323+ return out;324+}325+326+/** The ONE channel-collision builder (T7 parity): production, the piece327+ * bench, the tests, and the drive harness all call THIS — the segment's328+ * main ribbon with gaps and wide scales resolved from the segment329+ * itself, plus the braid alt lane's plain channel. Separately wired330+ * diagnostic paths drew false conclusions (the bench dropped the wide331+ * scales and under-reported five wide/funnel variants). */332+export function segmentProxies(seg: ProxySource): CollisionProxy[] {333+ return [334+ ...channelProxies(seg.path, true, gapSegmentsOf(seg), wideScalesOf(seg)),335+ // Braid alt LANE (M17): a plain channel between the junction336+ // composites — its ribbon meets their open mouths at the ports.337+ ...(seg.altPath ? channelProxies(seg.altPath, true) : []),338+ ];339+}340+233341 /** Channel slabs for one segment's world-space path. Two passes: sample234342 * every part into stations, then emit — drop shafts need LOOKAHEAD (the235343 * guide tube opens toward the receiver's exit heading). `gapSegments`
@@ -247,6 +355,7 @@ export function channelProxies(
247355 into: Station[],248356 diamond = false,249357 wRange: readonly [number, number] | null = null,358+ holes: readonly (readonly [number, number, number])[] = [],250359 ): void => {251360 const windowLen = wRange252361 ? parts.reduce((acc, p) => acc + segLength(p), 0) || 1
@@ -254,16 +363,27 @@ export function channelProxies(
254363 let travelled = 0;255364 for (const part of parts) {256365 const len = segLength(part);257- // Curvature-aware sampling: arcs bound the per-slab turn angle;258- // cubics (jogs) sample finely; lines stay coarse.259- const steps = part.kind === "arc"260- ? Math.max(366+ // Curvature-aware sampling: arcs bound the chord-sagitta error at367+ // their OUTER channel boundary (inner boundary chords cut inward368+ // by strictly less — the outer bound covers both); cubics (jogs)369+ // sample finely; lines stay coarse. Every part contributes its own370+ // stations, so part boundaries — inflections, curvature steps,371+ // width changes — always land between dedicated samples.372+ let steps: number;373+ if (part.kind === "arc") {374+ const outerR = part.radius +375+ 0.66 + (wRange ? Math.max(wRange[0], wRange[1]) : 0);376+ const maxDelta = 2 *377+ Math.acos(Math.max(0, 1 - BOUNDARY_ERR / outerR));378+ steps = Math.max(261379 Math.ceil(len / STEP),262- Math.ceil(Math.abs(part.a1 - part.a0) / MAX_TURN),263- )264- : part.kind === "cubic"265- ? Math.max(1, Math.ceil(len / 0.3))266- : Math.max(1, Math.ceil(len / STEP));380+ Math.ceil(Math.abs(part.a1 - part.a0) / maxDelta),381+ );382+ } else if (part.kind === "cubic") {383+ steps = Math.max(1, Math.ceil(len / 0.3));384+ } else {385+ steps = Math.max(1, Math.ceil(len / STEP));386+ }267387 const stepLen = len / steps;268388 const halfLen = Math.max(0.12, stepLen * 0.72);269389 for (let i = 0; i < steps; i++) {
@@ -277,14 +397,21 @@ export function channelProxies(
277397 const total = Math.hypot(dx, dy, dz);278398 if (total < 1e-9) continue;279399 const vertical = !gap && Math.abs(dy / total) > VERTICAL_LIMIT;280- const wScale = wRange400+ const wShift = wRange281401 ? wRange[0] +282402 (wRange[1] - wRange[0]) *283403 Math.min(284404 1,285405 (travelled + len * ((i + 0.5) / steps)) / windowLen,286406 )287- : 1;407+ : 0;408+ // Floor-pierce lip zone (T2): the ribbon must stop AT the bore,409+ // not span it — samples whose center lands over/near the hole410+ // are marked; the aperture composite carries the deck instead.411+ const hole = !vertical && !gap &&412+ holes.some((h) =>413+ Math.hypot(p[0] - h[0], p[2] - h[2]) < HOLE_R + 0.15414+ );288415 into.push({289416 p: [p[0], p[1], p[2]],290417 yaw: vertical ? 0 : Math.atan2(dx, dz),
@@ -293,25 +420,47 @@ export function channelProxies(
293420 vertical,294421 gap,295422 diamond,423+ hole,296424 dy,297- wScale,425+ wShift,298426 });299427 }300428 travelled += len;301429 }302430 };303431 const stations: Station[] = [];432+ /** Floor-pierce apertures to emit (T2): center + travel yaw, resolved433+ * after sampling from the nearest non-vertical station. */434+ const apertures: { c: readonly [number, number, number]; yaw: number }[] =435+ [];304436 for (const [segIdx, seg] of path.segments.entries()) {437+ const holes = (seg.holes ?? []) as readonly [number, number, number][];305438 sampleParts(306439 seg.curve,307440 gapSegments?.has(segIdx) ?? false,308441 stations,309- // Composite-carried windows: diamonds/spurs (alt) and both braid310- // junctions (the fork carries the alt, the merge its own flag) —311- // the ribbon stops at their ports.312- seg.alt !== undefined || seg.braidMerge === true,442+ // Composite-carried windows: diamonds/spurs (alt), both braid443+ // junctions (the fork carries the alt, the merge its own flag),444+ // and grade crossings (host composite / quiet phantom) — the445+ // ribbon stops at their ports.446+ seg.alt !== undefined || seg.braidMerge === true ||447+ seg.junction !== undefined,313448 wideScales?.get(segIdx) ?? null,449+ holes,314450 );451+ for (const h of holes) apertures.push({ c: h, yaw: 0 });452+ }453+ // Resolve each aperture's travel yaw from the nearest rolling station.454+ for (const a of apertures) {455+ let best = Infinity;456+ for (const st of stations) {457+ if (st.vertical || st.gap) continue;458+ const d = Math.hypot(st.p[0] - a.c[0], st.p[2] - a.c[2]);459+ if (d < best) {460+ best = d;461+ a.yaw = st.yaw;462+ }463+ }315464 }316465 // Vertical stations inherit the approach yaw; their exit heading is the317466 // next non-vertical station's yaw (the receiver's port).
@@ -321,6 +470,25 @@ export function channelProxies(
321470 while (j >= 0 && stations[j].vertical) j--;322471 stations[i] = { ...stations[i], yaw: j >= 0 ? stations[j].yaw : 0 };323472 }473+ // Bore-lip trim (T2, found by the metrics CI): a rolling station can474+ // land arbitrarily close to a following dive column, and its ring475+ // floor then overhangs the open bore — a shelf falling marbles clip.476+ // Stations at dive-DECK height within the bore radius of the column477+ // become lip stations: the ribbon stops there and the drop cup's478+ // authored annulus floor carries the deck to the rim. (Stations at479+ // receiver depth stay — the exit channel runs under the shaft.)480+ for (const st of stations) {481+ if (!st.vertical) continue;482+ const top = st.p[1] + Math.abs(st.dy) / 2;483+ for (let j = 0; j < stations.length; j++) {484+ const s = stations[j];485+ if (s.vertical || s.hole || s.gap) continue;486+ if (Math.abs(s.p[1] - top) > 0.3) continue;487+ if (Math.hypot(s.p[0] - st.p[0], s.p[2] - st.p[2]) < HOLE_R + 0.1) {488+ stations[j] = { ...s, hole: true };489+ }490+ }491+ }324492 325493 const out: CollisionProxy[] = [];326494 // M11: the smooth ribbon trimesh is the default channel law (isolated
@@ -359,7 +527,7 @@ export function channelProxies(
359527 };360528 for (let i = 0; i < stations.length; i++) {361529 const st = stations[i];362- const [px, py, pz] = st.p;530+ const [px, , pz] = st.p;363531 if (st.diamond) {364532 // Diamond window: the ribbon stops at both ports; the slab365533 // composite (emitted below, from the segment's own geometry)
@@ -370,6 +538,17 @@ export function channelProxies(
370538 i = k - 1;371539 continue;372540 }541+ if (st.hole) {542+ // Floor-pierce (T2): the ribbon stops short of the bore on both543+ // sides — NO extension toward the hole (an extrapolated tongue544+ // over the open bore would catch falling marbles). The aperture545+ // composite (emitted below) carries the deck around the hole.546+ flushStrip(false, false);547+ let k = i;548+ while (k < stations.length && stations[k].hole) k++;549+ i = k - 1;550+ continue;551+ }373552 if (st.gap) {374553 // JUMP gap: the lip ribbon runs to the gap edge, then NOTHING — the375554 // marble flies. The landing side gets a catch pan reaching back
@@ -395,48 +574,57 @@ export function channelProxies(
395574 const topY = Math.max(...group.map((g) => g.p[1] + Math.abs(g.dy) / 2));396575 const botY = Math.min(...group.map((g) => g.p[1] - Math.abs(g.dy) / 2));397576 const exitYaw = k < stations.length ? stations[k].yaw : st.yaw;398- const exitX = Math.sin(exitYaw);399- const exitZ = Math.cos(exitYaw);400577 const lx = Math.cos(st.yaw);401578 const lz = -Math.sin(st.yaw);402579 const fwdX = Math.sin(st.yaw);403580 const fwdZ = Math.cos(st.yaw);404- out.push({405- kind: "slab",406- center: [px + fwdX * 1.0, topY + 0.45, pz + fwdZ * 1.0],407- half: [0.55, 0.5, 0.1],408- yaw: st.yaw,409- pitch: 0,410- });411- const tubeTop = topY - 0.1;412- const tubeBot = botY + 0.1;413- const tubeHalf = Math.max(0.15, (tubeTop - tubeBot) / 2);414- const tubeCy = (tubeTop + tubeBot) / 2;581+ // Authored drop shape (T2/T5, measured): TOP CUP — annulus floor582+ // slabs around the open bore (the deck the marble circles before583+ // dropping; the bore itself stays open) + octagon cup walls at the584+ // measured cell-edge radius with the ENTRY port arch open. The585+ // square opening between the four annulus slabs inscribes the586+ // round bore; its corner triangles read open where the mesh has587+ // floor — beyond a rolling marble's reach from the port.588+ const deckY = topY - FLOOR_HALF_T + 0.02; // hair above ribbon sheets415589 for (416590 const [ox, oz, hx, hz] of [417- [fwdX * 0.62, fwdZ * 0.62, 0.55, 0.1],418- [-fwdX * 0.62, -fwdZ * 0.62, 0.55, 0.1],419- [lx * 0.62, lz * 0.62, 0.1, 0.55],420- [-lx * 0.62, -lz * 0.62, 0.1, 0.55],591+ [fwdX * 0.75, fwdZ * 0.75, CUP_R + 0.05, 0.25],592+ [-fwdX * 0.75, -fwdZ * 0.75, CUP_R + 0.05, 0.25],593+ [lx * 0.75, lz * 0.75, 0.25, HOLE_R],594+ [-lx * 0.75, -lz * 0.75, 0.25, HOLE_R],421595 ] as const422596 ) {423- const along = (ox * exitX + oz * exitZ) / 0.62;424- if (along > 0.7) continue;425597 out.push({426598 kind: "slab",427- center: [px + ox, tubeCy, pz + oz],428- half: [hx, tubeHalf, hz],599+ center: [px + ox, deckY, pz + oz],600+ half: [hx, FLOOR_HALF_T, hz],429601 yaw: st.yaw,430602 pitch: 0,431603 });432604 }433- // Bottom pan: the full shaft cross-section at receiver-floor level.605+ octaRing(606+ out,607+ px,608+ pz,609+ st.yaw,610+ CUP_R,611+ topY,612+ topY + CUP_H,613+ st.yaw + Math.PI,614+ );615+ // SHAFT: full octagonal bore guide between the decks (the hole is616+ // round in every flavor — the old square tube was not).617+ octaRing(out, px, pz, st.yaw, SHAFT_R, botY + 0.45, topY - 0.02, null);618+ // RECEIVER CUP: octagon walls at the measured radius, EXIT port619+ // arch open (l/r variants turn — the frame is the exit yaw).620+ octaRing(out, px, pz, exitYaw, CUP_R, botY, botY + CUP_H, exitYaw);621+ // Bottom pan: the full cup cross-section at receiver-floor level.434622 // Its top sits a hair ABOVE the receiver ribbon sheet — arriving435623 // even 0.03 below a one-sided mesh means catching its edge forever.436624 out.push({437625 kind: "slab",438626 center: [px, botY - FLOOR_HALF_T + 0.03, pz],439- half: [0.75, FLOOR_HALF_T, 0.75],627+ half: [CUP_R + 0.05, FLOOR_HALF_T, CUP_R + 0.05],440628 yaw: st.yaw,441629 pitch: 0,442630 });
@@ -458,7 +646,12 @@ export function channelProxies(
458646 if (seg.alt?.spur) emitSpur(seg, out);459647 else if (seg.alt?.braid) continue; // braid junctions emitted below460648 else if (seg.alt) emitDiamond(seg, out);649+ else if (seg.junction === "cross") emitCross(seg, out);650+ // junction "quiet" (the crossing's second visit): nothing — the651+ // deck belongs to the mesh visit's composite.461652 }653+ // Floor-pierce apertures (T2): deck lips + walls around each open bore.654+ for (const a of apertures) emitAperture(a.c, a.yaw, out);462655 // Braid junctions (M17): fork (alt.braid) and merge (braidMerge) share463656 // ONE composite shape — a straight junction deck whose branch mouth is464657 // OPEN on the alt side (the spur junction without the hole/board/cap).
@@ -547,15 +740,15 @@ function emitBraidJunction(
547740 const wlen = Math.hypot(wx, wz);548741 out.push({549742 kind: "slab",550- center: [(ax + bx) / 2, floorY + 0.35, (az + bz) / 2],551- half: [0.09, 0.4, wlen / 2],743+ center: [(ax + bx) / 2, floorY + JUNCTION_WALL_LIFT, (az + bz) / 2],744+ half: [0.09, JUNCTION_WALL_HALF_H, wlen / 2],552745 yaw: Math.atan2(wx, wz),553746 pitch: 0,554747 });555748 };556749 557- const HW = 0.95; // main channel half width at the walls558- const BR = 0.86; // branch channel half width (ribbon wall line)750+ const HW = JUNCTION_HW; // main wall line (measured inner face + clearance)751+ const BR = JUNCTION_BR; // branch wall line (ribbon walls take over)559752 const EDGE = L / 2 + 0.05; // branch port at the cell face (+ seal)560753 const deckY = floorY - FLOOR_HALF_T + 0.02;561754
@@ -593,10 +786,68 @@ function emitBraidJunction(
593786 wall([L / 2 + BR, HW], [L / 2 + BR, EDGE]);594787 }595788 789+/** Authored 4-way grade-crossing composite (T2, measured): a flat cell790+ * deck reaching all four ports plus a SOLID corner post per quadrant791+ * (the kit's corner blocks run square from the wall line to the cell792+ * corner — thin L walls left the posts' outer verts 0.29 uncovered) —793+ * every port arch open. Replaces the single-route ribbon (which left794+ * the perpendicular arm floorless in isolation, and in production two795+ * overlapping same-height ribbons walled each other's route off). */796+function emitCross(797+ seg: LoopPath["segments"][number],798+ out: CollisionProxy[],799+): void {800+ const entry = segPoint(seg.curve[0], 0);801+ const lastPart = seg.curve[seg.curve.length - 1];802+ const exit = segPoint(lastPart, 1);803+ const dx = exit[0] - entry[0];804+ const dz = exit[2] - entry[2];805+ const L = Math.hypot(dx, dz);806+ if (L < 1e-6) return;807+ const yaw = Math.atan2(dx, dz);808+ const fwd: [number, number] = [dx / L, dz / L];809+ const lat: [number, number] = [fwd[1], -fwd[0]];810+ const floorY = entry[1];811+ const cx = entry[0] + fwd[0] * (L / 2);812+ const cz = entry[2] + fwd[1] * (L / 2);813+ const E = L / 2 + 0.05; // cell face + seal814+ // Deck: the whole cell, top a hair above the neighbor ribbon sheets.815+ out.push({816+ kind: "slab",817+ center: [cx, floorY - FLOOR_HALF_T + 0.02, cz],818+ half: [E, FLOOR_HALF_T, E],819+ yaw,820+ pitch: 0,821+ });822+ // Corner posts: solid squares from the wall line (kit inner face823+ // 0.574 + clearance) to the cell corner, one per quadrant.824+ const P = 0.62;825+ const half = (E - P) / 2;826+ const mid = (P + E) / 2;827+ for (const sa of [-1, 1]) {828+ for (const sl of [-1, 1]) {829+ out.push({830+ kind: "slab",831+ center: [832+ cx + fwd[0] * sa * mid + lat[0] * sl * mid,833+ floorY + JUNCTION_WALL_LIFT,834+ cz + fwd[1] * sa * mid + lat[1] * sl * mid,835+ ],836+ half: [half, JUNCTION_WALL_HALF_H, half],837+ yaw,838+ pitch: 0,839+ });840+ }841+ }842+}843+596844 /** Slab composite for a split–merge diamond window. World geometry (the597- * window is straight and flat): branches at lateral ±2, channel half598- * width 0.92, diverge/merge cubics 4 world long each, N straights599- * between. Two-sided box slabs — crossings and overlaps are harmless. */845+ * window is straight and flat): branches at lateral ±2, diverge/merge846+ * cubics 4 world long each, N straights between; wall lines at the kit's847+ * measured inner faces (outer = branch + 0.574, wedge = branch − 0.574,848+ * + clearance — the old ±0.95 line overshot the sculpted deck and wedge849+ * by ~0.4 each side). Two-sided box slabs — crossings and overlaps are850+ * harmless. */600851 function emitDiamond(601852 seg: LoopPath["segments"][number],602853 out: CollisionProxy[],
@@ -634,8 +885,8 @@ function emitDiamond(
634885 const wlen = Math.hypot(wx, wz);635886 out.push({636887 kind: "slab",637- center: [(ax + bx) / 2, floorY + 0.35, (az + bz) / 2],638- half: [0.09, 0.4, wlen / 2],888+ center: [(ax + bx) / 2, floorY + JUNCTION_WALL_LIFT, (az + bz) / 2],889+ half: [0.09, JUNCTION_WALL_HALF_H, wlen / 2],639890 yaw: Math.atan2(wx, wz),640891 pitch: 0,641892 });
@@ -643,7 +894,7 @@ function emitDiamond(
643894 644895 const C = 4.0; // diverge/merge cubic length (2 track cells)645896 const B = 2.0; // branch lateral offset646- const HW = 0.95; // channel half width at the walls897+ const HW = JUNCTION_HW; // wall line (measured inner face + clearance)647898 // Deck: the full 3-column footprint, top a hair above the neighboring648899 // ribbon sheets so entry/exit never catch a mesh edge.649900 const mid = at(L / 2, 0);
@@ -723,15 +974,15 @@ function emitSpur(
723974 const wlen = Math.hypot(wx, wz);724975 out.push({725976 kind: "slab",726- center: [(ax + bx) / 2, floorY + 0.35, (az + bz) / 2],727- half: [0.09, 0.4, wlen / 2],977+ center: [(ax + bx) / 2, floorY + JUNCTION_WALL_LIFT, (az + bz) / 2],978+ half: [0.09, JUNCTION_WALL_HALF_H, wlen / 2],728979 yaw: Math.atan2(wx, wz),729980 pitch: 0,730981 });731982 };732983 733- const HW = 0.95; // main channel half width at the walls734- const BR = 0.86; // branch channel half width (ribbon wall line)984+ const HW = JUNCTION_HW; // main wall line (measured inner face + clearance)985+ const BR = JUNCTION_BR; // branch wall line (ribbon walls take over)735986 const HOLE = TRACK_SCALE; // hole center: the branch cell center736987 const NEAR = HOLE - 0.55; // the floor lip — the hole opens here737988 const CAP = HOLE + 0.6; // cap wall past the hole
@@ -775,8 +1026,8 @@ function emitSpur(
7751026 // tops — the marble cannot slip under, and the receiver channel stays7761027 // clear beneath it).7771028 {778- const top = floorY + 0.75;779- const bot = recY + 1.25;1029+ const top = floorY + 0.62;1030+ const bot = recY + 0.75;7801031 const [cx, cz] = at(L / 2, CAP);7811032 out.push({7821033 kind: "slab",
@@ -791,7 +1042,7 @@ function emitSpur(
7911042 // board and over the receiver's near wall.7921043 {7931044 const top = floorY - 0.2;794- const bot = recY + 1.25;1045+ const bot = recY + 0.75;7951046 const [cx, cz] = at(L / 2, NEAR);7961047 out.push({7971048 kind: "slab",
@@ -839,6 +1090,158 @@ function emitCatch(landing: Station, out: CollisionProxy[]): void {
8391090 });8401091 }8411092 1093+/** Octagonal wall ring (the measured drop-cup law): slab walls on each1094+ * side of an octagon of INNER radius `r` (slabs pushed outward by their1095+ * half-thickness), spanning [yLo, yHi]. `open` is the outward yaw of an1096+ * open port side — it must align with a side normal (pass the matching1097+ * frame yaw): that side is skipped and its two neighbours are CLIPPED1098+ * back to the kit's port half-width so no wall intrudes into the arch.1099+ * `null` = fully closed ring. */1100+function octaRing(1101+ out: CollisionProxy[],1102+ cx: number,1103+ cz: number,1104+ yaw: number,1105+ r: number,1106+ yLo: number,1107+ yHi: number,1108+ open: number | null,1109+): void {1110+ /** Kit port arch half-width (measured 0.574) + marble clearance. */1111+ const PORT_HALF_W = 0.62;1112+ const rv = r / Math.cos(Math.PI / 8);1113+ const openDir = open === null1114+ ? null1115+ : ([Math.sin(open), Math.cos(open)] as const);1116+ for (let k = 0; k < 8; k++) {1117+ const theta = yaw + k * (Math.PI / 4);1118+ const a0 = theta - Math.PI / 8;1119+ const a1 = theta + Math.PI / 8;1120+ let ax = cx + Math.sin(a0) * rv;1121+ let az = cz + Math.cos(a0) * rv;1122+ let bx = cx + Math.sin(a1) * rv;1123+ let bz = cz + Math.cos(a1) * rv;1124+ if (openDir) {1125+ const nd = Math.sin(theta) * openDir[0] + Math.cos(theta) * openDir[1];1126+ if (nd > 0.99) continue; // the open port side itself1127+ if (nd > 0.5) {1128+ // Port neighbour: pull its arch-side endpoint back to the port1129+ // edge (lateral distance from the open axis).1130+ const latOf = (ex: number, ez: number) =>1131+ (ex - cx) * openDir[1] - (ez - cz) * openDir[0];1132+ const la = latOf(ax, az);1133+ const lb = latOf(bx, bz);1134+ const target = Math.sign(la + lb) * PORT_HALF_W;1135+ if (Math.abs(la) < PORT_HALF_W) {1136+ const t = (target - la) / (lb - la);1137+ if (t > 0 && t < 1) {1138+ ax += (bx - ax) * t;1139+ az += (bz - az) * t;1140+ }1141+ } else if (Math.abs(lb) < PORT_HALF_W) {1142+ const t = (target - lb) / (la - lb);1143+ if (t > 0 && t < 1) {1144+ bx += (ax - bx) * t;1145+ bz += (az - bz) * t;1146+ }1147+ }1148+ }1149+ }1150+ const wx = bx - ax;1151+ const wz = bz - az;1152+ const wlen = Math.hypot(wx, wz);1153+ if (wlen < 0.05) continue;1154+ out.push({1155+ kind: "slab",1156+ center: [1157+ (ax + bx) / 2 + Math.sin(theta) * 0.09,1158+ (yLo + yHi) / 2,1159+ (az + bz) / 2 + Math.cos(theta) * 0.09,1160+ ],1161+ half: [0.09, (yHi - yLo) / 2, wlen / 2],1162+ yaw: Math.atan2(wx, wz),1163+ pitch: 0,1164+ });1165+ }1166+}1167+1168+/** Deck composite around an open floor bore (T2 — straight-hole): lip1169+ * floor slabs up/downstream overlapping the stopped ribbon, thin side1170+ * slivers between bore and wall (the render-distance CI measured the1171+ * kit's bore collar 0.24 from collision without them), side walls1172+ * spanning the pierced window, and a bore guide ring just under the1173+ * deck so a lip clip drops clean instead of drifting under the deck.1174+ * NOTHING below the guide: hole placement guarantees track underneath1175+ * by construction (hole semantics, USER RULE), and the fall lands on1176+ * that channel's own one-sided ribbon from above. */1177+function emitAperture(1178+ c: readonly [number, number, number],1179+ yaw: number,1180+ out: CollisionProxy[],1181+): void {1182+ const fwdX = Math.sin(yaw);1183+ const fwdZ = Math.cos(yaw);1184+ const lx = Math.cos(yaw);1185+ const lz = -Math.sin(yaw);1186+ const deckY = c[1] - FLOOR_HALF_T + 0.02; // hair above ribbon sheets1187+ const off = FLOOR_HALF_W + WALL_HALF_W - 0.04; // emitSlabs wall line1188+ for (const s of [-1, 1] as const) {1189+ out.push({1190+ kind: "slab",1191+ center: [1192+ c[0] + fwdX * s * (HOLE_R + 0.55),1193+ deckY,1194+ c[2] + fwdZ * s * (HOLE_R + 0.55),1195+ ],1196+ half: [FLOOR_HALF_W, FLOOR_HALF_T, 0.55],1197+ yaw,1198+ pitch: 0,1199+ });1200+ out.push({1201+ kind: "slab",1202+ center: [1203+ c[0] + lx * s * off,1204+ c[1] + WALL_CENTER_LIFT,1205+ c[2] + lz * s * off,1206+ ],1207+ half: [WALL_HALF_W, WALL_HALF_H, HOLE_R + 1.1],1208+ yaw,1209+ pitch: 0,1210+ });1211+ // Side sliver deck between bore and wall: only ~0.11 wide, but the1212+ // kit's floor is there and the bore collar otherwise floats 0.241213+ // from any collision.1214+ out.push({1215+ kind: "slab",1216+ center: [1217+ c[0] + lx * s * (HOLE_R + 0.06),1218+ deckY,1219+ c[2] + lz * s * (HOLE_R + 0.06),1220+ ],1221+ half: [0.11, FLOOR_HALF_T, HOLE_R + 0.12],1222+ yaw,1223+ pitch: 0,1224+ });1225+ }1226+ const g = HOLE_R + 0.09;1227+ for (1228+ const [ox, oz, hx, hz] of [1229+ [fwdX * g, fwdZ * g, 0.55, 0.09],1230+ [-fwdX * g, -fwdZ * g, 0.55, 0.09],1231+ [lx * g, lz * g, 0.09, 0.55],1232+ [-lx * g, -lz * g, 0.09, 0.55],1233+ ] as const1234+ ) {1235+ out.push({1236+ kind: "slab",1237+ center: [c[0] + ox, c[1] - 0.55, c[2] + oz],1238+ half: [hx, 0.4, hz],1239+ yaw,1240+ pitch: 0,1241+ });1242+ }1243+}1244+8421245 /** Slab law (proven, M10): floor follows slope; walls are vertical. The8431246 * floor slab approaching a drop shaft pulls back so it doesn't overhang8441247 * the mouth. */
@@ -980,8 +1383,7 @@ function ribbonOf(
9801383 // Catch-mouth flare: lateral scale ramps 1.35 → 1 over the first9811384 // 2 world units (heights untouched — the floor band widens and the9821385 // gutter walls converge downstream).983- const latScale = (flareStart ? 1 + 0.35 * Math.max(0, 1 - arc / 2.0) : 1) *984- st.wScale;1386+ const latScale = flareStart ? 1 + 0.35 * Math.max(0, 1 - arc / 2.0) : 1;9851387 // Frame from the true tangent: right = up × t, upV = t × right.9861388 const ty = Math.sin(-st.pitch);9871389 const th = Math.cos(st.pitch);
@@ -997,10 +1399,18 @@ function ribbonOf(
9971399 t[0] * right[1] - t[1] * right[0],9981400 ];9991401 for (const [lat, up] of CHANNEL_PROFILE) {1402+ // Width law (T1, measured): wall-side points shift OUT by the1403+ // station's widening; the flat floor stays put and the sheet1404+ // between stretches — the kit widens by moving walls, not by1405+ // scaling the section.1406+ const l0 = lat * latScale;1407+ const l = Math.abs(l0) > PROFILE_FLAT1408+ ? l0 + Math.sign(l0) * st.wShift1409+ : l0;10001410 vertices.push(1001- st.p[0] + right[0] * lat * latScale + upV[0] * up,1002- st.p[1] + right[1] * lat * latScale + upV[1] * up,1003- st.p[2] + right[2] * lat * latScale + upV[2] * up,1411+ st.p[0] + right[0] * l + upV[0] * up,1412+ st.p[1] + right[1] * l + upV[1] * up,1413+ st.p[2] + right[2] * l + upV[2] * up,10041414 );10051415 }10061416 }
M src/gen/endless/world.ts +8 -1
M src/gen/registry.ts +16 -0
@@ -69,6 +69,13 @@ export interface PieceDef {
6969 * per lap.7070 */7171 altCenterline?: readonly CurveSeg[];72+ /** Floor-pierce centers at floor height (entry frame): the collision73+ * ribbon opens the kit's round r=0.25 aperture at each (T2). */74+ holes?: readonly Vec3[];75+ /** Grade-crossing collision role (T2): "cross" emits the authored76+ * 4-way junction composite; "quiet" (the noMesh phantom) suppresses77+ * its ribbon — the shared deck comes from the mesh visit. */78+ junction?: "cross" | "quiet";7279 /** Occupied y band above local base (for collision). */7380 heightSpan: readonly [number, number];7481 /** GLB id of the closed-underside variant, if the kit has one. */
@@ -197,6 +204,9 @@ function buildDefs(): PieceDef[] {
197204 footprint: [[0, 0]],198205 supportCells: [[0, 0]],199206 centerline: [line([0, F, -0.5], [0, F, 0.5])],207+ // Measured: round bore r 0.25 at the cell center — the collision208+ // ribbon opens a real aperture there (T2).209+ holes: [[0, F, 0]],200210 heightSpan: [0, W],201211 reservedOnly: true,202212 baseWeight: 1,
@@ -294,6 +304,10 @@ function buildDefs(): PieceDef[] {
294304 defs.push({295305 id: phantom ? "cross-over" : "cross",296306 ...(phantom ? { meshId: "cross", noMesh: true } : {}),307+ // Junction collision (T2): the mesh visit emits the 4-way308+ // composite; the phantom stays quiet — its route's floor IS the309+ // shared deck.310+ junction: phantom ? "quiet" : "cross",297311 family: "cross",298312 moves: "F",299313 deckDelta: 0,
@@ -1351,6 +1365,7 @@ export function reverseDef(def: PieceDef): PieceDef {
13511365 supportCells: def.supportCells.map(shiftCell).reverse(),13521366 centerline: def.centerline.map(shiftSeg).reverse(),13531367 altCenterline: def.altCenterline?.map(shiftSeg).reverse(),1368+ holes: def.holes?.map(shift),13541369 reversed: !def.reversed,13551370 };13561371 }
@@ -1408,6 +1423,7 @@ export function mirrorDef(def: PieceDef, id: string): PieceDef {
14081423 plateSides: def.plateSides?.map(mCell),14091424 centerline: def.centerline.map(mSeg),14101425 altCenterline: def.altCenterline?.map(mSeg),1426+ holes: def.holes?.map(mp),14111427 };14121428 }14131429
M src/shared/types.ts +12 -0
A tests/collision_metrics_test.ts +906 -0
@@ -0,0 +1,906 @@
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+ */27+import { assert } from "@std/assert";28+import { NodeIO } from "@gltf-transform/core";29+import { KHRONOS_EXTENSIONS } from "@gltf-transform/extensions";30+import { buildRegistry, type PieceDef } from "../src/gen/registry.ts";31+import { assemble } from "../src/gen/assemble.ts";32+import { pickStyle } from "../src/gen/style.ts";33+import { rngFamily } from "../src/gen/rng.ts";34+import {35+ DIR_STEP,36+ segLength,37+ segPoint,38+ turnLeft,39+ turnRight,40+} from "../src/gen/geom.ts";41+import { scaleSeg, scaleVec, TRACK_SCALE } from "../src/gen/endless/scale.ts";42+import {43+ CHANNEL_PROFILE,44+ type CollisionProxy,45+ type MeshProxy,46+ segmentProxies,47+ type SlabProxy,48+} from "../src/gen/endless/proxies.ts";49+import type { Skeleton, SkeletonCell } from "../src/gen/topology.ts";50+import type { ElevationPlan } from "../src/gen/elevation.ts";51+import type { Dir, LoopPath } from "../src/shared/types.ts";52+53+// ---------------------------------------------------------------------------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. */60+const 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. */63+const LAT_MIN = 0.55;64+const LAT_MAX = 1.40;65+/** Rim cap over the ring floor (vertical; pitched frames foreshorten). */66+const CAP_MIN = 0.30;67+const CAP_MAX = 0.80;68+/** Bore probes: no collision surface top inside this radius at deck69+ * level; the bore itself is r 0.5. */70+const BORE_CLEAR_R = 0.40;71+/** Lip probes ride just outside the bore. */72+const 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). */79+const RENDER_DIST = 0.2;80+81+// ---------------------------------------------------------------------------82+// bench-style build (the piece-bench path — one window per variant)83+// ---------------------------------------------------------------------------84+interface BenchPlacement {85+ pieceId: string;86+ pos: readonly number[];87+ rotation: Dir;88+ mirrored?: boolean;89+}90+91+function 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+}138+139+/** Fine centerline samples: main line, alt branches, and (implicitly)140+ * the vertical dive columns — every part is sampled. */141+function 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+}159+160+// ---------------------------------------------------------------------------161+// geometry queries162+// ---------------------------------------------------------------------------163+/** Point-in-slab (yaw/pitch YXZ, matching the bench overlay/render). */164+function 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+}173+174+function 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+}195+196+/** Squared distance from a point to a slab's box. */197+function 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+}204+205+/** Squared distance from point p to triangle abc (Ericson, RTCD 5.1.5). */206+function 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+}257+258+/** 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). */261+interface RibbonIndex {262+ mesh: MeshProxy;263+ mids: [number, number, number][];264+}265+266+function 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+}281+282+function 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+}316+317+// ---------------------------------------------------------------------------318+// render mesh loading (the same GLB the gallery renders)319+// ---------------------------------------------------------------------------320+const GLB = new URL("../assets/marble-kit.glb", import.meta.url).pathname;321+const io = new NodeIO().registerExtensions(KHRONOS_EXTENSIONS);322+const gltfRoot = (await io.read(GLB)).getRoot();323+324+type V3 = [number, number, number];325+const vertCache = new Map<string, V3[] | null>();326+327+/** 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. */331+function 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+}367+368+/** World-space render verts of a bench placement (track.ts law:369+ * world = pos + RotY(r·π/2) · (scale·v, x negated when mirrored)). */370+function 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+}388+389+// ---------------------------------------------------------------------------390+// shared per-variant analysis (built once, used by every test)391+// ---------------------------------------------------------------------------392+const registry = buildRegistry();393+const N = CHANNEL_PROFILE.length;394+const FLAT_A = 5; // profile index of (−0.30, 0)395+const FLAT_B = 6; // profile index of (+0.30, 0)396+397+interface 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+}408+409+const buildFailures: string[] = [];410+const ANALYSES: Analysis[] = [];411+for (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+}435+436+/** Dive columns (drop family): [x, topY, botY, z] per vertical line. */437+function 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+}455+456+// ---------------------------------------------------------------------------457+// tests458+// ---------------------------------------------------------------------------459+Deno.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+});525+526+Deno.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+});654+655+Deno.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+});713+714+Deno.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+});783+784+Deno.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+});836+837+Deno.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+});871+872+Deno.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+});
A tests/collision_width_test.ts +137 -0
@@ -0,0 +1,137 @@
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+ */12+import { assert } from "@std/assert";13+import { buildRegistry, type PieceDef } from "../src/gen/registry.ts";14+import { assemble } from "../src/gen/assemble.ts";15+import { pickStyle } from "../src/gen/style.ts";16+import { rngFamily } from "../src/gen/rng.ts";17+import { DIR_STEP, turnLeft, turnRight } from "../src/gen/geom.ts";18+import { scaleSeg, scaleVec, TRACK_SCALE } from "../src/gen/endless/scale.ts";19+import {20+ CHANNEL_PROFILE,21+ type CollisionProxy,22+ segmentProxies,23+} from "../src/gen/endless/proxies.ts";24+import type { Skeleton, SkeletonCell } from "../src/gen/topology.ts";25+import type { ElevationPlan } from "../src/gen/elevation.ts";26+import type { Dir, LoopPath } from "../src/shared/types.ts";27+28+/** Build one variant on a synthetic skeleton — the piece-bench path. */29+function 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+}66+67+function 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+}75+76+/** Max |x| (lateral — bench travel is +z) of the first/last profile77+ * ring of the variant's single ribbon strip. */78+function 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+}90+91+const registry = buildRegistry();92+const 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+};99+100+/** Measured extents (world units): standard profile rim at ±0.66; wide101+ * adds the 0.576 shift → ±1.24. Thresholds sit between the two. */102+const NARROW_MAX = 0.9;103+const WIDE_MIN = 1.1;104+105+Deno.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+});110+111+Deno.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+});116+117+Deno.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+});122+123+Deno.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+});133+134+Deno.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+});
M tests/multimarble_test.ts +3 -5
M tests/physics_test.ts +4 -7