1/**2 * Collision proxies (M10) — generic, no per-piece authoring.3 *4 * The channel's physical shape is the same law everywhere (floor at the5 * centerline, walls at ±0.35 track units rising 0.5 above the floor), so6 * proxies are SAMPLED from each segment's world-space centerline: short7 * overlapping slabs (floor + two walls) oriented by the local tangent.8 * Curved pieces just get more slabs. Near-vertical samples (drop-stack9 * shafts — the centerline dives through the hole) emit NOTHING: the marble10 * genuinely falls and the receiving channel below catches it. Island11 * masses are axis-aligned block boxes on the world grid.12 *13 * Shared by the client driver and the headless survivability tests —14 * one sampling law, one truth.15 */16import type { CurveSeg, LoopPath, Placement } from "../../shared/types.ts";17import { segLength, segPoint } from "../geom.ts";18import { TRACK_SCALE } from "./scale.ts";19import {20 affineApply,21 isIdentityAffine,22 type Quat,23 quatFromAxisAngle,24 quatMul,25 type SegmentFrame,26 segmentFrameAt,27 twistParam,28} from "../../shared/frame.ts";2930/** Oriented slab: yaw about +y then pitch about local +x (shim contract).31 * `quat` (M16 reoriented chunks) supersedes yaw/pitch when present —32 * worldifyProxies folds them into one full orientation. */33export interface SlabProxy {34 kind: "slab";35 center: readonly [number, number, number];36 half: readonly [number, number, number];37 yaw: number;38 pitch: number;39 quat?: readonly [number, number, number, number];40}4142/** Axis-aligned box (island blocks), yaw in quarter turns. */43export interface BoxProxy {44 kind: "box";45 center: readonly [number, number, number];46 half: readonly [number, number, number];47 quat?: readonly [number, number, number, number];48}4950/** Smooth channel ribbon: one triangle mesh per contiguous strip (M11 —51 * slab chains made the marble contact awkwardly at every seam; a shared-52 * vertex extrusion is ONE continuous surface, and Box3D's identifyEdges53 * removes internal-edge collisions entirely). */54export interface MeshProxy {55 kind: "mesh";56 /** xyz triples. */57 vertices: number[];58 /** CCW triples into vertices. */59 indices: number[];60}6162/** Bouncer pin (M13): a high-restitution cylinder — the mushroom bumper63 * on wide island decks. Bounce combines via max, so the pin repels the64 * marble regardless of shock zones. */65export interface BumperProxy {66 kind: "bumper";67 center: readonly [number, number, number];68 radius: number;69 height: number;70 restitution: number;71 quat?: readonly [number, number, number, number];72}7374export type CollisionProxy = SlabProxy | BoxProxy | MeshProxy | BumperProxy;7576/**77 * Present plan-space proxies in world space (M16 gravity reorientation):78 * mesh vertices transform per-vertex through the segment's frame law (a79 * ribbon crossing the twist run corkscrews smoothly between the two80 * frames); rigid shapes (slabs/boxes/bumpers) pick the frame at their81 * CENTER (set-pieces never straddle the run — its cells are reserved82 * bare straights) and carry the full orientation as a quaternion.83 * Identity frames return the input untouched.84 */85export function worldifyProxies(86 proxies: readonly CollisionProxy[],87 f: SegmentFrame,88): CollisionProxy[] {89 if (!f.twist && isIdentityAffine(f.entry)) return [...proxies];90 const frameAt = (p: readonly [number, number, number]) =>91 f.twist ? segmentFrameAt(f, twistParam(f, p)) : f.entry;92 return proxies.map((p): CollisionProxy => {93 if (p.kind === "mesh") {94 const v = p.vertices;95 const out = new Array<number>(v.length);96 const scratch: [number, number, number] = [0, 0, 0];97 for (let i = 0; i < v.length; i += 3) {98 scratch[0] = v[i];99 scratch[1] = v[i + 1];100 scratch[2] = v[i + 2];101 const w = affineApply(frameAt(scratch), scratch);102 out[i] = w[0];103 out[i + 1] = w[1];104 out[i + 2] = w[2];105 }106 return { ...p, vertices: out };107 }108 const fr = frameAt(p.center);109 if (isIdentityAffine(fr)) return p;110 const center = affineApply(fr, p.center) as [number, number, number];111 if (p.kind === "bumper") return { ...p, center, quat: fr.q };112 if (p.kind === "box") return { ...p, center, quat: fr.q };113 // Slab: fold its own yaw·pitch into the frame orientation.114 const local = quatMul(115 quatFromAxisAngle([0, 1, 0], p.yaw),116 quatFromAxisAngle([1, 0, 0], p.pitch),117 );118 return { ...p, center, quat: quatMul(fr.q, local) as Quat };119 });120}121122/** Bumper collision sizing, world units: the mushroom prop at the123 * furniture scale (cap radius ≈ 0.18, height ≈ 0.41) with the hull a hair124 * inside the cap so the marble bounces off the visible rim. */125export const BUMPER_RADIUS = 0.16;126export const BUMPER_HEIGHT = 0.42;127export const BUMPER_RESTITUTION = 0.85;128129/** Cylinder proxies for every bumper-flagged placement (positions are130 * already world-scaled by the stream). */131export function bumperProxies(132 placements: readonly { pos: readonly number[]; bumper?: boolean }[],133): BumperProxy[] {134 const out: BumperProxy[] = [];135 for (const p of placements) {136 if (!p.bumper) continue;137 out.push({138 kind: "bumper",139 center: [p.pos[0], p.pos[1], p.pos[2]],140 radius: BUMPER_RADIUS,141 height: BUMPER_HEIGHT,142 restitution: BUMPER_RESTITUTION,143 });144 }145 return out;146}147148/** 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. */156export const CHANNEL_PROFILE: readonly (readonly [number, number])[] = [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],162 [-0.30, 0],163 [0.30, 0],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],169];170171/** Sampling step along the centerline, world units. */172const STEP = 0.6;173/** Channel geometry at slalom scale (track walls ±0.35, height +0.5). */174const FLOOR_HALF_W = 0.38 * TRACK_SCALE;175const FLOOR_HALF_T = 0.15;176const WALL_HALF_W = 0.09 * TRACK_SCALE;177/** Walls span [floor − 0.25, floor + 0.55] world: real kit walls top out at178 * floor + 0.5; the slight over-height forgives curve seams, and the cap179 * matters — taller walls spear through the floor of a channel stacked180 * 1.0 above (drop-stack receivers barricaded their own upper deck). */181const WALL_HALF_H = 0.40;182const WALL_CENTER_LIFT = 0.15;183/** Samples steeper than this are drop shafts (cup + tube, no floor).184 * Only genuine plunges qualify — drop-stack dives are ty ≈ 1.0; steep185 * chutes reach ty ≈ 0.8 mid-cubic and MUST keep their floors (a 0.77186 * chute got cup-walled and threw the marble into the void). */187const VERTICAL_LIMIT = 0.93;188189/** Floor slabs sink slightly so tilt ridges at sample seams stay below the190 * contact surface (poke ≈ halfLen·δ − SINK). */191const SINK = 0.035;192/** Max tangent turn per RING, radians — the crease-rounding law193 * (ribbonOf subdivision): sharp pitch/yaw V's between rings are kickers194 * that launch fast marbles. Distinct from arc STATION sampling, which195 * is boundary-error-bounded below (T3). */196const MAX_TURN = 0.12;197/** Arc tessellation error budget (T3), world units: the chord sagitta198 * at the OUTER channel boundary — what the player actually sees at the199 * offset walls — stays under this. (The old rule bounded center-frame200 * tangent rotation instead: tighter than needed on small radii, and no201 * guarantee at all had radii grown.) ~4% of the marble radius. */202const BOUNDARY_ERR = 0.008;203204/** One sampled station along the path. */205interface Station {206 p: readonly [number, number, number];207 /** This part starts a separate channel, rather than continuing the208 * preceding part (a segment can contain two independent braid lanes). */209 breakBefore?: boolean;210 yaw: number;211 pitch: number;212 halfLen: number;213 /** Vertical drop sample (no floor; cup + guide tube instead). */214 vertical: boolean;215 /** Jump-gap sample (M11): no channel at all — ballistic flight. */216 gap: boolean;217 /** Diamond-window sample (M11): the slab composite replaces the ribbon218 * (two crossing one-sided branch meshes destabilized the solver). */219 diamond: boolean;220 /** Floor-pierce sample (T2): inside a hole's lip zone — the ribbon221 * stops; the aperture composite carries the deck around the bore. */222 hole: boolean;223 dy: number;224 /** Lateral profile WIDENING in world units (wide runs; funnels lerp225 * across their window): everything beyond the flat floor shifts226 * outward by this much, stretching the floor between — the kit widens227 * its channel by moving the walls out, not by scaling the section. */228 wShift: number;229}230231/** Measured wide-channel widening (packed-kit audit 2026-08-09): inner232 * wall face 0.575 grid vs the standard 0.287 → each wall moves out233 * 0.288 grid = 0.576 world. (The old ×2.43 profile SCALE — from the234 * ±0.85 outer-bbox claim — put collision walls outside the visible235 * ±1.7 mesh entirely.) */236const WIDE_SHIFT = 0.576;237/** Profile points with |lat| beyond this are wall-side: they shift by238 * the station's wShift; the flat floor inside stays put. */239const PROFILE_FLAT = 0.31;240241/** Measured drop/hole law (packed-kit audit 2026-08-09, world units):242 * the floor bore is ROUND, r 0.25 grid, in straight-hole AND both243 * end-hole flavors — square vs rounded differ only in cup cosmetics244 * (corner pockets beyond a marble's reach), so ONE authored shape serves245 * every variant. Cup wall inner face sits essentially at the cell edge,246 * rising the kit's 0.25-grid rim over the floor. */247const HOLE_R = 0.5;248const CUP_R = 1.0;249const CUP_H = 0.5;250/** Shaft bore guide: the hole radius plus marble clearance. */251const SHAFT_R = 0.55;252253/** Junction composite walls follow the rim law (profile cap 0.62 over254 * the floor, foot sunk 0.10 below): span [floor−0.10, floor+0.62]. */255const JUNCTION_WALL_LIFT = 0.26;256const JUNCTION_WALL_HALF_H = 0.36;257/** Junction wall line: the kit's measured wall inner face (0.574) plus258 * lateral clearance — matches where the channel ribbon's wall now sits. */259const JUNCTION_HW = 0.66;260/** Branch-mouth wall line where a neighbor ribbon's walls take over. */261const JUNCTION_BR = 0.64;262263/** Per-path-segment lateral WIDENING range [entry, exit] (world units)264 * for wide runs. Explicit per-window port widths (the def's authored265 * widthIn/widthOut — the bench passes them) win; otherwise widths are266 * derived from realized window families (wide windows are always267 * funnel-bracketed by construction; a funnel tapers toward its wide268 * neighbor). Undefined when the segment has no wide vocabulary. */269export function wideScalesOf(270 seg: {271 pieceFamilies: string[];272 widths?: readonly (273 | { widthIn?: string; widthOut?: string }274 | null275 | undefined276 )[];277 },278): Map<number, readonly [number, number]> | undefined {279 const fam = seg.pieceFamilies;280 const out = new Map<number, readonly [number, number]>();281 for (let i = 0; i < fam.length; i++) {282 const w = seg.widths?.[i];283 if (w && (w.widthIn === "wide" || w.widthOut === "wide")) {284 out.set(i, [285 w.widthIn === "wide" ? WIDE_SHIFT : 0,286 w.widthOut === "wide" ? WIDE_SHIFT : 0,287 ]);288 } else if (fam[i] === "wide") out.set(i, [WIDE_SHIFT, WIDE_SHIFT]);289 else if (fam[i] === "funnel") {290 if (fam[i + 1] === "wide") out.set(i, [0, WIDE_SHIFT]);291 else if (fam[i - 1] === "wide") out.set(i, [WIDE_SHIFT, 0]);292 }293 }294 return out.size > 0 ? out : undefined;295}296297/** The segment fields proxy building needs (EndlessSegment satisfies it;298 * mirrors rail.ts RailSource plus the optional braid alt lane). */299export interface ProxySource {300 path: LoopPath;301 altPath?: LoopPath;302 pieceFamilies: string[];303 windowMods: (string | null)[];304 /** Explicit per-window port widths (def-authored) — the bench passes305 * them so an isolated funnel/wide window still gets its true section;306 * production omits them (families + neighbors carry the same truth). */307 widths?: readonly (308 | { widthIn?: string; widthOut?: string }309 | null310 | undefined311 )[];312}313314/** Path-segment indices that are jump gaps — the collision proxies emit315 * no channel there (absence of geometry IS the gap). Lives with the316 * proxy law; rail.ts re-exports it for the drivers. */317export function gapSegmentsOf(318 seg: { windowMods: (string | null)[] },319): Set<number> {320 const out = new Set<number>();321 seg.windowMods.forEach((m, i) => {322 if (m === "jump" || m === "propeller") out.add(i);323 });324 return out;325}326327/** The ONE channel-collision builder (T7 parity): production, the piece328 * bench, the tests, and the drive harness all call THIS — the segment's329 * main ribbon with gaps and wide scales resolved from the segment330 * itself, plus the braid alt lane's plain channel. Separately wired331 * diagnostic paths drew false conclusions (the bench dropped the wide332 * scales and under-reported five wide/funnel variants). */333export function segmentProxies(seg: ProxySource): CollisionProxy[] {334 const proxies = [335 ...channelProxies(seg.path, true, gapSegmentsOf(seg), wideScalesOf(seg)),336 // Braid alt LANE (M17): a plain channel between the junction337 // composites — its ribbon meets their open mouths at the ports.338 ...(seg.altPath ? channelProxies(seg.altPath, true) : []),339 ];340 // Pack independent strips into one mesh without joining their rings.341 // The native shim has a bounded mesh-handle budget per segment; two342 // braids plus jump/drop strips can otherwise exceed it. Each strip343 // retains its own vertices and triangles, so the gaps stay open.344 const meshes = proxies.filter((p): p is MeshProxy => p.kind === "mesh");345 if (meshes.length <= 1) return proxies;346 const joined: MeshProxy = { kind: "mesh", vertices: [], indices: [] };347 for (const mesh of meshes) {348 const offset = joined.vertices.length / 3;349 for (const v of mesh.vertices) joined.vertices.push(v);350 for (const i of mesh.indices) joined.indices.push(i + offset);351 }352 return [joined, ...proxies.filter((p) => p.kind !== "mesh")];353}354355/** Channel slabs for one segment's world-space path. Two passes: sample356 * every part into stations, then emit — drop shafts need LOOKAHEAD (the357 * guide tube opens toward the receiver's exit heading). `gapSegments`358 * are path-segment indices that are jump gaps (rail.ts gapSegmentsOf):359 * they emit no channel, and the landing side gets a catch pan + funnel. */360export function channelProxies(361 path: LoopPath,362 useRibbon = true,363 gapSegments?: ReadonlySet<number>,364 wideScales?: ReadonlyMap<number, readonly [number, number]>,365): CollisionProxy[] {366 let previousEnd: readonly [number, number, number] | null = null;367 const sampleParts = (368 parts: LoopPath["segments"][number]["curve"],369 gap: boolean,370 into: Station[],371 diamond = false,372 wRange: readonly [number, number] | null = null,373 holes: readonly (readonly [number, number, number])[] = [],374 ): void => {375 const windowLen = wRange376 ? parts.reduce((acc, p) => acc + segLength(p), 0) || 1377 : 1;378 let travelled = 0;379 for (const part of parts) {380 const start = segPoint(part, 0);381 const disconnected = previousEnd !== null &&382 Math.hypot(383 start[0] - previousEnd[0],384 start[1] - previousEnd[1],385 start[2] - previousEnd[2],386 ) > 1e-4;387 previousEnd = segPoint(part, 1);388 const len = segLength(part);389 // Curvature-aware sampling: arcs bound the chord-sagitta error at390 // their OUTER channel boundary (inner boundary chords cut inward391 // by strictly less — the outer bound covers both); cubics (jogs)392 // sample finely; lines stay coarse. Every part contributes its own393 // stations, so part boundaries — inflections, curvature steps,394 // width changes — always land between dedicated samples.395 let steps: number;396 if (part.kind === "arc") {397 const outerR = part.radius +398 0.66 + (wRange ? Math.max(wRange[0], wRange[1]) : 0);399 const maxDelta = 2 *400 Math.acos(Math.max(0, 1 - BOUNDARY_ERR / outerR));401 steps = Math.max(402 Math.ceil(len / STEP),403 Math.ceil(Math.abs(part.a1 - part.a0) / maxDelta),404 );405 } else if (part.kind === "cubic") {406 steps = Math.max(1, Math.ceil(len / 0.3));407 } else {408 steps = Math.max(1, Math.ceil(len / STEP));409 }410 const stepLen = len / steps;411 const halfLen = Math.max(0.12, stepLen * 0.72);412 for (let i = 0; i < steps; i++) {413 const p = segPoint(part, (i + 0.5) / steps);414 const a = segPoint(part, i / steps);415 const b = segPoint(part, Math.min(1, (i + 1) / steps));416 const dx = b[0] - a[0];417 const dy = b[1] - a[1];418 const dz = b[2] - a[2];419 const horiz = Math.hypot(dx, dz);420 const total = Math.hypot(dx, dy, dz);421 if (total < 1e-9) continue;422 const vertical = !gap && Math.abs(dy / total) > VERTICAL_LIMIT;423 const wShift = wRange424 ? wRange[0] +425 (wRange[1] - wRange[0]) *426 Math.min(427 1,428 (travelled + len * ((i + 0.5) / steps)) / windowLen,429 )430 : 0;431 // Floor-pierce lip zone (T2): the ribbon must stop AT the bore,432 // not span it — samples whose center lands over/near the hole433 // are marked; the aperture composite carries the deck instead.434 const hole = !vertical && !gap &&435 holes.some((h) =>436 Math.hypot(p[0] - h[0], p[2] - h[2]) < HOLE_R + 0.15437 );438 into.push({439 p: [p[0], p[1], p[2]],440 ...(i === 0 && disconnected ? { breakBefore: true } : {}),441 yaw: vertical ? 0 : Math.atan2(dx, dz),442 pitch: vertical ? 0 : -Math.atan2(dy, horiz),443 halfLen,444 vertical,445 gap,446 diamond,447 hole,448 dy,449 wShift,450 });451 }452 travelled += len;453 }454 };455 const stations: Station[] = [];456 /** Floor-pierce apertures to emit (T2): center + travel yaw, resolved457 * after sampling from the nearest non-vertical station. */458 const apertures: { c: readonly [number, number, number]; yaw: number }[] = [];459 for (const [segIdx, seg] of path.segments.entries()) {460 const holes = (seg.holes ?? []) as readonly [number, number, number][];461 sampleParts(462 seg.curve,463 gapSegments?.has(segIdx) ?? false,464 stations,465 // Composite-carried windows: diamonds/spurs (alt), both braid466 // junctions (the fork carries the alt, the merge its own flag),467 // and grade crossings (host composite / quiet phantom) — the468 // ribbon stops at their ports. V3: a CONTINUATION chain's carrier469 // window is a PLAIN channel (the chain merely hangs there; the470 // real junction lives segments back) — its ribbon must stay.471 (seg.alt !== undefined && seg.alt.contBraid !== true) ||472 seg.braidMerge === true ||473 seg.junction !== undefined,474 wideScales?.get(segIdx) ?? null,475 holes,476 );477 for (const h of holes) apertures.push({ c: h, yaw: 0 });478 }479 // Resolve each aperture's travel yaw from the nearest rolling station.480 for (const a of apertures) {481 let best = Infinity;482 for (const st of stations) {483 if (st.vertical || st.gap) continue;484 const d = Math.hypot(st.p[0] - a.c[0], st.p[2] - a.c[2]);485 if (d < best) {486 best = d;487 a.yaw = st.yaw;488 }489 }490 }491 // Vertical stations inherit the approach yaw; their exit heading is the492 // next non-vertical station's yaw (the receiver's port).493 for (let i = 0; i < stations.length; i++) {494 if (!stations[i].vertical) continue;495 let j = i - 1;496 while (j >= 0 && stations[j].vertical) j--;497 stations[i] = { ...stations[i], yaw: j >= 0 ? stations[j].yaw : 0 };498 }499 // Bore-lip trim (T2, found by the metrics CI): a rolling station can500 // land arbitrarily close to a following dive column, and its ring501 // floor then overhangs the open bore — a shelf falling marbles clip.502 // Stations at dive-DECK height within the bore radius of the column503 // become lip stations: the ribbon stops there and the drop cup's504 // authored annulus floor carries the deck to the rim. (Stations at505 // receiver depth stay — the exit channel runs under the shaft.)506 for (const st of stations) {507 if (!st.vertical) continue;508 const top = st.p[1] + Math.abs(st.dy) / 2;509 for (let j = 0; j < stations.length; j++) {510 const s = stations[j];511 if (s.vertical || s.hole || s.gap) continue;512 if (Math.abs(s.p[1] - top) > 0.3) continue;513 if (Math.hypot(s.p[0] - st.p[0], s.p[2] - st.p[2]) < HOLE_R + 0.1) {514 stations[j] = { ...s, hole: true };515 }516 }517 }518519 const out: CollisionProxy[] = [];520 // M11: the smooth ribbon trimesh is the default channel law (isolated521 // repro chain settled it: one-sided winding for the up×t layout + upV522 // cross-order fix). Slabs remain available for A/B comparison.523 const USE_RIBBON = useRibbon;524 let strip: Station[] = [];525 /** Whether the CURRENT strip's start should extrapolate to its true526 * port: yes at the path start, at segment boundaries, and after a jump527 * gap (the landing catches at its very edge); never out of a shaft. */528 let extendStart = true;529 /** Whether the CURRENT strip starts at a jump landing — its mouth rings530 * flare wide (the ribbon IS the catch funnel). */531 let flareStart = false;532 const flushStrip = (533 extendEnd: boolean,534 followingExtends: boolean,535 followingFlares = false,536 ) => {537 if (strip.length >= 2) {538 if (USE_RIBBON) {539 // Extend to the true strip ends (segment boundaries share ring540 // positions with the neighbor segment → seamless; jump lips run541 // to the gap edge), but never toward a drop shaft (that would542 // overhang the mouth).543 out.push(ribbonOf(strip, extendStart, extendEnd, flareStart));544 } else {545 strip.forEach((st, idx) =>546 emitSlabs(st, out, !extendEnd && idx === strip.length - 1)547 );548 }549 }550 strip = [];551 extendStart = followingExtends;552 flareStart = followingFlares;553 };554 for (let i = 0; i < stations.length; i++) {555 const st = stations[i];556 // Independent lanes must have independent meshes. Connecting their557 // last/first rings creates an invisible floor and wall across the558 // space between them, sometimes cutting through another live track.559 if (st.breakBefore) flushStrip(true, true);560 const [px, , pz] = st.p;561 if (st.diamond) {562 // Diamond window: the ribbon stops at both ports; the slab563 // composite (emitted below, from the segment's own geometry)564 // carries the marble across.565 flushStrip(true, true);566 let k = i;567 while (k < stations.length && stations[k].diamond) k++;568 i = k - 1;569 continue;570 }571 if (st.hole) {572 // Floor-pierce (T2): the ribbon stops short of the bore on both573 // sides — NO extension toward the hole (an extrapolated tongue574 // over the open bore would catch falling marbles). The aperture575 // composite (emitted below) carries the deck around the hole.576 flushStrip(false, false);577 let k = i;578 while (k < stations.length && stations[k].hole) k++;579 i = k - 1;580 continue;581 }582 if (st.gap) {583 // JUMP gap: the lip ribbon runs to the gap edge, then NOTHING — the584 // marble flies. The landing side gets a catch pan reaching back585 // under the flight path plus funnel walls, so undershoots land and586 // roll on instead of clipping the landing's leading face.587 flushStrip(true, true, true);588 let k = i;589 while (k < stations.length && stations[k].gap) k++;590 if (k < stations.length && !stations[k].vertical) {591 emitCatch(stations[k], out);592 }593 i = k - 1;594 continue;595 }596 if (st.vertical) {597 flushStrip(false, false);598 // One CUP PER CONTIGUOUS VERTICAL RUN (per-station emission stacked599 // overlapping cups that wedged the marble — found the hard way).600 // Frame: approach yaw; open side: receiver exit yaw.601 let k = i;602 while (k < stations.length && stations[k].vertical) k++;603 const group = stations.slice(i, k);604 const topY = Math.max(...group.map((g) => g.p[1] + Math.abs(g.dy) / 2));605 const botY = Math.min(...group.map((g) => g.p[1] - Math.abs(g.dy) / 2));606 const exitYaw = k < stations.length ? stations[k].yaw : st.yaw;607 const lx = Math.cos(st.yaw);608 const lz = -Math.sin(st.yaw);609 const fwdX = Math.sin(st.yaw);610 const fwdZ = Math.cos(st.yaw);611 // Authored drop shape (T2/T5, measured): TOP CUP — annulus floor612 // slabs around the open bore (the deck the marble circles before613 // dropping; the bore itself stays open) + octagon cup walls at the614 // measured cell-edge radius with the ENTRY port arch open. The615 // square opening between the four annulus slabs inscribes the616 // round bore; its corner triangles read open where the mesh has617 // floor — beyond a rolling marble's reach from the port.618 const deckY = topY - FLOOR_HALF_T + 0.02; // hair above ribbon sheets619 for (620 const [ox, oz, hx, hz] of [621 [fwdX * 0.75, fwdZ * 0.75, CUP_R + 0.05, 0.25],622 [-fwdX * 0.75, -fwdZ * 0.75, CUP_R + 0.05, 0.25],623 [lx * 0.75, lz * 0.75, 0.25, HOLE_R],624 [-lx * 0.75, -lz * 0.75, 0.25, HOLE_R],625 ] as const626 ) {627 out.push({628 kind: "slab",629 center: [px + ox, deckY, pz + oz],630 half: [hx, FLOOR_HALF_T, hz],631 yaw: st.yaw,632 pitch: 0,633 });634 }635 octaRing(636 out,637 px,638 pz,639 st.yaw,640 CUP_R,641 topY,642 topY + CUP_H,643 st.yaw + Math.PI,644 );645 // SHAFT: full octagonal bore guide between the decks (the hole is646 // round in every flavor — the old square tube was not).647 octaRing(out, px, pz, st.yaw, SHAFT_R, botY + 0.45, topY - 0.02, null);648 // RECEIVER CUP: octagon walls at the measured radius, EXIT port649 // arch open (l/r variants turn — the frame is the exit yaw).650 octaRing(out, px, pz, exitYaw, CUP_R, botY, botY + CUP_H, exitYaw);651 // Bottom pan: the full cup cross-section at receiver-floor level.652 // Its top sits a hair ABOVE the receiver ribbon sheet — arriving653 // even 0.03 below a one-sided mesh means catching its edge forever.654 out.push({655 kind: "slab",656 center: [px, botY - FLOOR_HALF_T + 0.03, pz],657 half: [CUP_R + 0.05, FLOOR_HALF_T, CUP_R + 0.05],658 yaw: st.yaw,659 pitch: 0,660 });661 i = k - 1; // consume the group662 continue;663 }664 strip.push(st);665 }666 flushStrip(true, true);667668 // Diamond windows (M11 branch respawns): one slab composite per window669 // — a wide flat deck, outer flare/straight walls, and a center wedge670 // divider, mirroring the real split-double geometry. (Two overlapping671 // one-sided branch RIBBONS were tried first: their surfaces cross at672 // the split and the wedged marble destabilized the solver.)673 // Takeable spur windows carry the alt marker `spur` and get their own674 // composite instead: the branch deck with the hole OPEN.675 for (const seg of path.segments) {676 if (seg.alt?.spur) emitSpur(seg, out);677 else if (seg.alt?.braid) continue; // braid junctions emitted below678 else if (seg.alt) emitDiamond(seg, out);679 else if (seg.junction === "cross") emitCross(seg, out);680 // junction "quiet" (the crossing's second visit): nothing — the681 // deck belongs to the mesh visit's composite.682 }683 // Floor-pierce apertures (T2): deck lips + walls around each open bore.684 for (const a of apertures) emitAperture(a.c, a.yaw, out);685 // Braid junctions (M17): fork (alt.braid) and merge (braidMerge) share686 // ONE composite shape — a straight junction deck whose branch mouth is687 // OPEN on the alt side (the spur junction without the hole/board/cap).688 // The alt LANE between them is a plain channel — the stream emits689 // channelProxies over EndlessSegment.altPath separately, its ribbon690 // meeting these mouths at the branch ports. The branch side comes from691 // sampling the fork's alt chain just past each junction (the lane sits692 // a full track cell out — the sign is unambiguous).693 // M23: a segment may carry TWO chains (a continuation resolving plus a694 // fresh fork). Chains and merge windows pair IN PATH ORDER — each695 // chain that merges here consumes the next braidMerge window.696 const chainSegs = path.segments.filter((s) => s.alt?.braid);697 const mergeSegs = path.segments.filter((s) => s.braidMerge);698 let mi = 0;699 for (const cs of chainSegs) {700 const total = cs.alt!.length;701 const near = pointAt(cs.alt!.curve, Math.min(2.5, total / 2));702 const far = pointAt(cs.alt!.curve, Math.max(total - 2.5, total / 2));703 // V3: a CONTINUATION chain enters at the segment boundary — its704 // entry host is a plain straight, not a junction; cutting a fork705 // mouth into it would open a hole in an ordinary channel wall.706 if (!cs.alt!.contBraid) emitBraidJunction(cs, near, out);707 // Likewise an OPEN chain exits at the boundary — no merge there.708 if (!cs.alt!.openBraid) {709 const ms = mergeSegs[mi++];710 if (ms) emitBraidJunction(ms, far, out);711 }712 }713 return out;714}715716/** Point at arc length `s` along a curve chain (clamped to its ends). */717function pointAt(718 curve: readonly CurveSeg[],719 s: number,720): [number, number, number] {721 let d = Math.max(0, s);722 for (const part of curve) {723 const len = segLength(part);724 if (d <= len) {725 const p = segPoint(part, len === 0 ? 0 : d / len);726 return [p[0], p[1], p[2]];727 }728 d -= len;729 }730 const p = segPoint(curve[curve.length - 1], 1);731 return [p[0], p[1], p[2]];732}733734/** Slab composite for a braid junction window (fork AND merge — the735 * shape is flow-symmetric). World geometry (the 1-cell host window is736 * straight and flat): the split-junction deck with the branch mouth open737 * toward `ref` (a point on the alt lane beside this junction), mouth738 * side walls out to the branch PORT at the cell face — where the alt739 * lane's own ribbon takes over — and wall stubs framing the mouth on the740 * main line. Deck top a hair above the neighboring ribbon sheets (the741 * spur/diamond law: arriving under a one-sided mesh edge means catching742 * it forever). */743function emitBraidJunction(744 seg: LoopPath["segments"][number],745 ref: readonly [number, number, number],746 out: CollisionProxy[],747): void {748 const entry = segPoint(seg.curve[0], 0);749 const lastPart = seg.curve[seg.curve.length - 1];750 const exit = segPoint(lastPart, 1);751 const dx = exit[0] - entry[0];752 const dz = exit[2] - entry[2];753 const L = Math.hypot(dx, dz);754 if (L < 1e-6) return;755 const yaw = Math.atan2(dx, dz);756 const fwd: [number, number] = [dx / L, dz / L];757 let lat: [number, number] = [fwd[1], -fwd[0]];758 const floorY = entry[1];759 // Orient +side toward the branch.760 const mx = entry[0] + fwd[0] * (L / 2);761 const mz = entry[2] + fwd[1] * (L / 2);762 if ((ref[0] - mx) * lat[0] + (ref[2] - mz) * lat[1] < 0) {763 lat = [-lat[0], -lat[1]];764 }765 const branchYaw = Math.atan2(lat[0], lat[1]);766 const at = (767 along: number,768 side: number,769 ): [number, number] => [770 entry[0] + fwd[0] * along + lat[0] * side,771 entry[2] + fwd[1] * along + lat[1] * side,772 ];773 const wall = (774 a: readonly [number, number],775 b: readonly [number, number],776 ): void => {777 const [ax, az] = at(a[0], a[1]);778 const [bx, bz] = at(b[0], b[1]);779 const wx = bx - ax;780 const wz = bz - az;781 const wlen = Math.hypot(wx, wz);782 out.push({783 kind: "slab",784 center: [(ax + bx) / 2, floorY + JUNCTION_WALL_LIFT, (az + bz) / 2],785 half: [0.09, JUNCTION_WALL_HALF_H, wlen / 2],786 yaw: Math.atan2(wx, wz),787 pitch: 0,788 });789 };790791 const HW = JUNCTION_HW; // main wall line (measured inner face + clearance)792 const BR = JUNCTION_BR; // branch wall line (ribbon walls take over)793 const EDGE = L / 2 + 0.05; // branch port at the cell face (+ seal)794 const deckY = floorY - FLOOR_HALF_T + 0.02;795796 // Junction deck (main lane), reaching to both ports.797 {798 const [cx, cz] = at(L / 2, 0);799 out.push({800 kind: "slab",801 center: [cx, deckY, cz],802 half: [HW, FLOOR_HALF_T, L / 2 + 0.05],803 yaw,804 pitch: 0,805 });806 }807 // Branch mouth deck out to the port.808 {809 const s0 = HW - 0.15;810 const [cx, cz] = at(L / 2, (s0 + EDGE) / 2);811 out.push({812 kind: "slab",813 center: [cx, deckY, cz],814 half: [BR, FLOOR_HALF_T, (EDGE - s0) / 2],815 yaw: branchYaw,816 pitch: 0,817 });818 }819 // Main-lane walls: solid on the far side; mouth-framing stubs on the820 // branch side (the neighbor ribbons' walls stop at the ports).821 wall([-0.05, -HW], [L + 0.05, -HW]);822 wall([-0.05, HW], [L / 2 - BR, HW]);823 wall([L / 2 + BR, HW], [L + 0.05, HW]);824 // Mouth side walls, junction wall line out to the branch port (the alt825 // ribbon's own gutter walls continue from there).826 wall([L / 2 - BR, HW], [L / 2 - BR, EDGE]);827 wall([L / 2 + BR, HW], [L / 2 + BR, EDGE]);828}829830/** Authored 4-way grade-crossing composite (T2, measured): a flat cell831 * deck reaching all four ports plus a SOLID corner post per quadrant832 * (the kit's corner blocks run square from the wall line to the cell833 * corner — thin L walls left the posts' outer verts 0.29 uncovered) —834 * every port arch open. Replaces the single-route ribbon (which left835 * the perpendicular arm floorless in isolation, and in production two836 * overlapping same-height ribbons walled each other's route off). */837function emitCross(838 seg: LoopPath["segments"][number],839 out: CollisionProxy[],840): void {841 const entry = segPoint(seg.curve[0], 0);842 const lastPart = seg.curve[seg.curve.length - 1];843 const exit = segPoint(lastPart, 1);844 const dx = exit[0] - entry[0];845 const dz = exit[2] - entry[2];846 const L = Math.hypot(dx, dz);847 if (L < 1e-6) return;848 const yaw = Math.atan2(dx, dz);849 const fwd: [number, number] = [dx / L, dz / L];850 const lat: [number, number] = [fwd[1], -fwd[0]];851 const floorY = entry[1];852 const cx = entry[0] + fwd[0] * (L / 2);853 const cz = entry[2] + fwd[1] * (L / 2);854 const E = L / 2 + 0.05; // cell face + seal855 // Deck: the whole cell, top a hair above the neighbor ribbon sheets.856 out.push({857 kind: "slab",858 center: [cx, floorY - FLOOR_HALF_T + 0.02, cz],859 half: [E, FLOOR_HALF_T, E],860 yaw,861 pitch: 0,862 });863 // Corner posts: solid squares from the wall line (kit inner face864 // 0.574 + clearance) to the cell corner, one per quadrant.865 const P = 0.62;866 const half = (E - P) / 2;867 const mid = (P + E) / 2;868 for (const sa of [-1, 1]) {869 for (const sl of [-1, 1]) {870 out.push({871 kind: "slab",872 center: [873 cx + fwd[0] * sa * mid + lat[0] * sl * mid,874 floorY + JUNCTION_WALL_LIFT,875 cz + fwd[1] * sa * mid + lat[1] * sl * mid,876 ],877 half: [half, JUNCTION_WALL_HALF_H, half],878 yaw,879 pitch: 0,880 });881 }882 }883}884885/** Slab composite for a split–merge diamond window. World geometry (the886 * window is straight and flat): branches at lateral ±2, diverge/merge887 * cubics 4 world long each, N straights between; wall lines at the kit's888 * measured inner faces (outer = branch + 0.574, wedge = branch − 0.574,889 * + clearance — the old ±0.95 line overshot the sculpted deck and wedge890 * by ~0.4 each side). Two-sided box slabs — crossings and overlaps are891 * harmless. */892function emitDiamond(893 seg: LoopPath["segments"][number],894 out: CollisionProxy[],895): void {896 const entry = segPoint(seg.curve[0], 0);897 const last = seg.curve[seg.curve.length - 1];898 const exit = segPoint(last, 1);899 const dx = exit[0] - entry[0];900 const dz = exit[2] - entry[2];901 const L = Math.hypot(dx, dz);902 if (L < 1e-6) return;903 const yaw = Math.atan2(dx, dz);904 const fwd: [number, number] = [dx / L, dz / L];905 const lat: [number, number] = [fwd[1], -fwd[0]];906 const floorY = entry[1];907 const at = (908 along: number,909 side: number,910 ): [number, number, number] => [911 entry[0] + fwd[0] * along + lat[0] * side,912 floorY,913 entry[2] + fwd[1] * along + lat[1] * side,914 ];915 const wall = (916 a: readonly [number, number],917 b: readonly [number, number],918 ): void => {919 // Wall slab from (along, side) a → b at deck level.920 const ax = entry[0] + fwd[0] * a[0] + lat[0] * a[1];921 const az = entry[2] + fwd[1] * a[0] + lat[1] * a[1];922 const bx = entry[0] + fwd[0] * b[0] + lat[0] * b[1];923 const bz = entry[2] + fwd[1] * b[0] + lat[1] * b[1];924 const wx = bx - ax;925 const wz = bz - az;926 const wlen = Math.hypot(wx, wz);927 out.push({928 kind: "slab",929 center: [(ax + bx) / 2, floorY + JUNCTION_WALL_LIFT, (az + bz) / 2],930 half: [0.09, JUNCTION_WALL_HALF_H, wlen / 2],931 yaw: Math.atan2(wx, wz),932 pitch: 0,933 });934 };935936 const C = 4.0; // diverge/merge cubic length (2 track cells)937 const B = 2.0; // branch lateral offset938 const HW = JUNCTION_HW; // wall line (measured inner face + clearance)939 // Deck: the full 3-column footprint, top a hair above the neighboring940 // ribbon sheets so entry/exit never catch a mesh edge.941 const mid = at(L / 2, 0);942 out.push({943 kind: "slab",944 center: [mid[0], floorY - FLOOR_HALF_T + 0.02, mid[2]],945 half: [B + HW, FLOOR_HALF_T, L / 2 + 0.05],946 yaw,947 pitch: 0,948 });949 for (const side of [-1, 1]) {950 // Outer walls: entry flare, branch straight, merge flare.951 wall([0, side * HW], [C, side * (B + HW)]);952 wall([C, side * (B + HW)], [L - C, side * (B + HW)]);953 wall([L - C, side * (B + HW)], [L, side * HW]);954 // Center wedge: tip where the inner channel edges cross (≈ half the955 // cubic), widening to the island between the branch straights.956 wall([C / 2, 0], [C, side * (B - HW)]);957 wall([C, side * (B - HW)], [L - C, side * (B - HW)]);958 wall([L - C, side * (B - HW)], [L - C / 2, 0]);959 }960}961962/** Slab composite for a takeable spur window (endless respawn variety).963 * World geometry (the 1-cell host window is straight and flat): the964 * split-junction deck with the branch mouth open on one side, the965 * diving-board floor running out to the HOLE LIP and then STOPPING (no966 * floor over the hole — the fall is real physics), branch side walls out967 * to a cap wall past the hole that reaches down into the drop shaft (a968 * rolling marble can never clear it: it leaves the lip falling, so it969 * always meets the cap below its top and drops in), a near shaft guide970 * under the floor lip, and a catch pan a hair above the receiver's971 * ribbon floor Δ exactly 1.0 track (2.0 world) below — the receiver is972 * the segment's OWN line passing back under the branch cell by973 * construction, so every fall lands on track. */974function emitSpur(975 seg: LoopPath["segments"][number],976 out: CollisionProxy[],977): void {978 if (!seg.alt) return;979 const entry = segPoint(seg.curve[0], 0);980 const lastPart = seg.curve[seg.curve.length - 1];981 const exit = segPoint(lastPart, 1);982 const dx = exit[0] - entry[0];983 const dz = exit[2] - entry[2];984 const L = Math.hypot(dx, dz);985 if (L < 1e-6) return;986 const yaw = Math.atan2(dx, dz);987 const fwd: [number, number] = [dx / L, dz / L];988 let lat: [number, number] = [fwd[1], -fwd[0]];989 const floorY = entry[1];990 // Orient +side toward the branch: the alt centerline ends out over the991 // hole, so its endpoint picks the sign.992 const altLast = seg.alt.curve[seg.alt.curve.length - 1];993 const tip = segPoint(altLast, 1);994 const mx = entry[0] + fwd[0] * (L / 2);995 const mz = entry[2] + fwd[1] * (L / 2);996 if ((tip[0] - mx) * lat[0] + (tip[2] - mz) * lat[1] < 0) {997 lat = [-lat[0], -lat[1]];998 }999 const branchYaw = Math.atan2(lat[0], lat[1]);1000 const at = (1001 along: number,1002 side: number,1003 ): [number, number] => [1004 entry[0] + fwd[0] * along + lat[0] * side,1005 entry[2] + fwd[1] * along + lat[1] * side,1006 ];1007 const wall = (1008 a: readonly [number, number],1009 b: readonly [number, number],1010 ): void => {1011 const [ax, az] = at(a[0], a[1]);1012 const [bx, bz] = at(b[0], b[1]);1013 const wx = bx - ax;1014 const wz = bz - az;1015 const wlen = Math.hypot(wx, wz);1016 out.push({1017 kind: "slab",1018 center: [(ax + bx) / 2, floorY + JUNCTION_WALL_LIFT, (az + bz) / 2],1019 half: [0.09, JUNCTION_WALL_HALF_H, wlen / 2],1020 yaw: Math.atan2(wx, wz),1021 pitch: 0,1022 });1023 };10241025 const HW = JUNCTION_HW; // main wall line (measured inner face + clearance)1026 const BR = JUNCTION_BR; // branch wall line (ribbon walls take over)1027 const HOLE = TRACK_SCALE; // hole center: the branch cell center1028 const NEAR = HOLE - 0.55; // the floor lip — the hole opens here1029 const CAP = HOLE + 0.6; // cap wall past the hole1030 const recY = floorY - TRACK_SCALE; // Δ 1.0 track below (bridge law)1031 const deckY = floorY - FLOOR_HALF_T + 0.02; // hair above ribbon sheets10321033 // Junction deck (main lane), reaching to both ports.1034 {1035 const [cx, cz] = at(L / 2, 0);1036 out.push({1037 kind: "slab",1038 center: [cx, deckY, cz],1039 half: [HW, FLOOR_HALF_T, L / 2 + 0.05],1040 yaw,1041 pitch: 0,1042 });1043 }1044 // Diving-board floor: junction edge out to the hole lip. NOTHING past1045 // the lip — the opening IS the set-piece.1046 {1047 const s0 = HW - 0.15;1048 const [cx, cz] = at(L / 2, (s0 + NEAR) / 2);1049 out.push({1050 kind: "slab",1051 center: [cx, deckY, cz],1052 half: [BR, FLOOR_HALF_T, (NEAR - s0) / 2],1053 yaw: branchYaw,1054 pitch: 0,1055 });1056 }1057 // Main-lane walls: solid on the far side; mouth-framing stubs on the1058 // branch side (the neighbor ribbons' walls stop at the ports).1059 wall([-0.05, -HW], [L + 0.05, -HW]);1060 wall([-0.05, HW], [L / 2 - BR, HW]);1061 wall([L / 2 + BR, HW], [L + 0.05, HW]);1062 // Branch side walls, junction mouth out past the hole to the cap.1063 wall([L / 2 - BR, HW], [L / 2 - BR, CAP]);1064 wall([L / 2 + BR, HW], [L / 2 + BR, CAP]);1065 // Cap wall: crosses the branch past the hole and reaches DOWN into the1066 // shaft (top at wall height, bottom just above the receiver's own wall1067 // tops — the marble cannot slip under, and the receiver channel stays1068 // clear beneath it).1069 {1070 const top = floorY + 0.62;1071 const bot = recY + 0.75;1072 const [cx, cz] = at(L / 2, CAP);1073 out.push({1074 kind: "slab",1075 center: [cx, (top + bot) / 2, cz],1076 half: [BR, (top - bot) / 2, 0.09],1077 yaw: branchYaw,1078 pitch: 0,1079 });1080 }1081 // Near shaft guide under the floor lip: a cap-side bounce-back drops1082 // straight down inside the shaft instead of drifting back under the1083 // board and over the receiver's near wall.1084 {1085 const top = floorY - 0.2;1086 const bot = recY + 0.75;1087 const [cx, cz] = at(L / 2, NEAR);1088 out.push({1089 kind: "slab",1090 center: [cx, (top + bot) / 2, cz],1091 half: [BR, (top - bot) / 2, 0.09],1092 yaw: branchYaw,1093 pitch: 0,1094 });1095 }1096 // Catch pan under the hole, a hair ABOVE the receiver ribbon sheet1097 // (arriving below a one-sided mesh means catching its edge forever —1098 // the drop-shaft law).1099 {1100 const [cx, cz] = at(L / 2, HOLE);1101 out.push({1102 kind: "slab",1103 center: [cx, recY - FLOOR_HALF_T + 0.03, cz],1104 half: [0.75, FLOOR_HALF_T, 0.75],1105 yaw,1106 pitch: 0,1107 });1108 }1109}11101111/** Catch pan at a jump landing, reaching back under the flight path. Its1112 * top sits a hair ABOVE the landing ribbon sheet (arriving below a1113 * one-sided mesh means catching its edge forever). Lateral catching is1114 * the RIBBON MOUTH's job: the landing strip's first rings flare wide1115 * (ribbonOf flareStart), so off-center landings meet flat floor and the1116 * converging gutters shepherd the marble to center — funnel wall slabs1117 * were tried twice and either clipped shallow flights from above or made1118 * wedge pockets at their tips. */1119function emitCatch(landing: Station, out: CollisionProxy[]): void {1120 const fwdX = Math.sin(landing.yaw);1121 const fwdZ = Math.cos(landing.yaw);1122 const floorY = landing.p[1];1123 const cx = landing.p[0] - fwdX * 1.0;1124 const cz = landing.p[2] - fwdZ * 1.0;1125 out.push({1126 kind: "slab",1127 center: [cx, floorY - FLOOR_HALF_T + 0.03, cz],1128 half: [1.1, FLOOR_HALF_T, 1.05],1129 yaw: landing.yaw,1130 pitch: 0,1131 });1132}11331134/** Octagonal wall ring (the measured drop-cup law): slab walls on each1135 * side of an octagon of INNER radius `r` (slabs pushed outward by their1136 * half-thickness), spanning [yLo, yHi]. `open` is the outward yaw of an1137 * open port side — it must align with a side normal (pass the matching1138 * frame yaw): that side is skipped and its two neighbours are CLIPPED1139 * back to the kit's port half-width so no wall intrudes into the arch.1140 * `null` = fully closed ring. */1141function octaRing(1142 out: CollisionProxy[],1143 cx: number,1144 cz: number,1145 yaw: number,1146 r: number,1147 yLo: number,1148 yHi: number,1149 open: number | null,1150): void {1151 /** Kit port arch half-width (measured 0.574) + marble clearance. */1152 const PORT_HALF_W = 0.62;1153 const rv = r / Math.cos(Math.PI / 8);1154 const openDir = open === null1155 ? null1156 : ([Math.sin(open), Math.cos(open)] as const);1157 for (let k = 0; k < 8; k++) {1158 const theta = yaw + k * (Math.PI / 4);1159 const a0 = theta - Math.PI / 8;1160 const a1 = theta + Math.PI / 8;1161 let ax = cx + Math.sin(a0) * rv;1162 let az = cz + Math.cos(a0) * rv;1163 let bx = cx + Math.sin(a1) * rv;1164 let bz = cz + Math.cos(a1) * rv;1165 if (openDir) {1166 const nd = Math.sin(theta) * openDir[0] + Math.cos(theta) * openDir[1];1167 if (nd > 0.99) continue; // the open port side itself1168 if (nd > 0.5) {1169 // Port neighbour: pull its arch-side endpoint back to the port1170 // edge (lateral distance from the open axis).1171 const latOf = (ex: number, ez: number) =>1172 (ex - cx) * openDir[1] - (ez - cz) * openDir[0];1173 const la = latOf(ax, az);1174 const lb = latOf(bx, bz);1175 const target = Math.sign(la + lb) * PORT_HALF_W;1176 if (Math.abs(la) < PORT_HALF_W) {1177 const t = (target - la) / (lb - la);1178 if (t > 0 && t < 1) {1179 ax += (bx - ax) * t;1180 az += (bz - az) * t;1181 }1182 } else if (Math.abs(lb) < PORT_HALF_W) {1183 const t = (target - lb) / (la - lb);1184 if (t > 0 && t < 1) {1185 bx += (ax - bx) * t;1186 bz += (az - bz) * t;1187 }1188 }1189 }1190 }1191 const wx = bx - ax;1192 const wz = bz - az;1193 const wlen = Math.hypot(wx, wz);1194 if (wlen < 0.05) continue;1195 out.push({1196 kind: "slab",1197 center: [1198 (ax + bx) / 2 + Math.sin(theta) * 0.09,1199 (yLo + yHi) / 2,1200 (az + bz) / 2 + Math.cos(theta) * 0.09,1201 ],1202 half: [0.09, (yHi - yLo) / 2, wlen / 2],1203 yaw: Math.atan2(wx, wz),1204 pitch: 0,1205 });1206 }1207}12081209/** Deck composite around an open floor bore (T2 — straight-hole): lip1210 * floor slabs up/downstream overlapping the stopped ribbon, thin side1211 * slivers between bore and wall (the render-distance CI measured the1212 * kit's bore collar 0.24 from collision without them), side walls1213 * spanning the pierced window, and a bore guide ring just under the1214 * deck so a lip clip drops clean instead of drifting under the deck.1215 * NOTHING below the guide: hole placement guarantees track underneath1216 * by construction (hole semantics, USER RULE), and the fall lands on1217 * that channel's own one-sided ribbon from above. */1218function emitAperture(1219 c: readonly [number, number, number],1220 yaw: number,1221 out: CollisionProxy[],1222): void {1223 const fwdX = Math.sin(yaw);1224 const fwdZ = Math.cos(yaw);1225 const lx = Math.cos(yaw);1226 const lz = -Math.sin(yaw);1227 const deckY = c[1] - FLOOR_HALF_T + 0.02; // hair above ribbon sheets1228 const off = FLOOR_HALF_W + WALL_HALF_W - 0.04; // emitSlabs wall line1229 for (const s of [-1, 1] as const) {1230 out.push({1231 kind: "slab",1232 center: [1233 c[0] + fwdX * s * (HOLE_R + 0.55),1234 deckY,1235 c[2] + fwdZ * s * (HOLE_R + 0.55),1236 ],1237 half: [FLOOR_HALF_W, FLOOR_HALF_T, 0.55],1238 yaw,1239 pitch: 0,1240 });1241 out.push({1242 kind: "slab",1243 center: [1244 c[0] + lx * s * off,1245 c[1] + WALL_CENTER_LIFT,1246 c[2] + lz * s * off,1247 ],1248 half: [WALL_HALF_W, WALL_HALF_H, HOLE_R + 1.1],1249 yaw,1250 pitch: 0,1251 });1252 // Side sliver deck between bore and wall: only ~0.11 wide, but the1253 // kit's floor is there and the bore collar otherwise floats 0.241254 // from any collision.1255 out.push({1256 kind: "slab",1257 center: [1258 c[0] + lx * s * (HOLE_R + 0.06),1259 deckY,1260 c[2] + lz * s * (HOLE_R + 0.06),1261 ],1262 half: [0.11, FLOOR_HALF_T, HOLE_R + 0.12],1263 yaw,1264 pitch: 0,1265 });1266 }1267 const g = HOLE_R + 0.09;1268 for (1269 const [ox, oz, hx, hz] of [1270 [fwdX * g, fwdZ * g, 0.55, 0.09],1271 [-fwdX * g, -fwdZ * g, 0.55, 0.09],1272 [lx * g, lz * g, 0.09, 0.55],1273 [-lx * g, -lz * g, 0.09, 0.55],1274 ] as const1275 ) {1276 out.push({1277 kind: "slab",1278 center: [c[0] + ox, c[1] - 0.55, c[2] + oz],1279 half: [hx, 0.4, hz],1280 yaw,1281 pitch: 0,1282 });1283 }1284}12851286/** Slab law (proven, M10): floor follows slope; walls are vertical. The1287 * floor slab approaching a drop shaft pulls back so it doesn't overhang1288 * the mouth. */1289function emitSlabs(1290 st: Station,1291 out: CollisionProxy[],1292 trimForward = false,1293): void {1294 const [px, py, pz] = st.p;1295 const trim = trimForward ? 0.3 : 0;1296 const tx = Math.sin(st.yaw);1297 const tz = Math.cos(st.yaw);1298 out.push({1299 kind: "slab",1300 center: [px - tx * trim, py - FLOOR_HALF_T - SINK, pz - tz * trim],1301 half: [FLOOR_HALF_W, FLOOR_HALF_T, Math.max(0.1, st.halfLen - trim)],1302 yaw: st.yaw,1303 pitch: st.pitch,1304 });1305 const lx = Math.cos(st.yaw);1306 const lz = -Math.sin(st.yaw);1307 const off = FLOOR_HALF_W + WALL_HALF_W - 0.04;1308 for (const side of [-1, 1]) {1309 out.push({1310 kind: "slab",1311 center: [1312 px + lx * off * side,1313 py + WALL_CENTER_LIFT,1314 pz + lz * off * side,1315 ],1316 half: [WALL_HALF_W, WALL_HALF_H, st.halfLen + 0.06],1317 yaw: st.yaw,1318 pitch: 0,1319 });1320 }1321}13221323/** Extrude the channel PROFILE along a strip of stations into one mesh1324 * with shared ring vertices (C0-continuous — no seams to catch on).1325 * `flareStart` widens the first rings laterally (×1.35 at the mouth,1326 * blending to ×1 over ~2 world units): the jump-landing catch mouth —1327 * off-center ballistic landings meet flat floor instead of a gutter1328 * edge, and the converging gutters center the marble as it rolls. */1329function ribbonOf(1330 strip: readonly Station[],1331 extendStart: boolean,1332 extendEnd: boolean,1333 flareStart = false,1334): MeshProxy {1335 const vertices: number[] = [];1336 const indices: number[] = [];1337 const N = CHANNEL_PROFILE.length;1338 let last: Station | null = null;1339 const rings: Station[] = [];1340 // Boundary rings: stations are mid-samples, so the strip would stop1341 // half a step short of the port on each side — the gap that dropped1342 // marbles at every segment boundary. Extrapolate to the true ends1343 // (+0.05 overlap so welded neighbors seal).1344 const extrapolate = (st: Station, dir: 1 | -1): Station => {1345 const stepLen = st.halfLen / 0.72;1346 const d = (stepLen / 2 + 0.05) * dir;1347 const ty = Math.sin(-st.pitch);1348 const th = Math.cos(st.pitch);1349 return {1350 ...st,1351 p: [1352 st.p[0] + Math.sin(st.yaw) * th * d,1353 st.p[1] + ty * d,1354 st.p[2] + Math.cos(st.yaw) * th * d,1355 ],1356 };1357 };1358 const raw = [1359 ...(extendStart ? [extrapolate(strip[0], -1)] : []),1360 ...strip,1361 ...(extendEnd ? [extrapolate(strip[strip.length - 1], 1)] : []),1362 ];1363 // Valley/crest rounding: a sharp pitch (or yaw) crease between rings is1364 // a KICKER — a marble arriving fast reflects off the V and launches1365 // over the wall (chute valleys threw marbles laterally; slabs had1366 // incidentally rounded these with overlaps). Subdivide any step whose1367 // frame turns faster than MAX_TURN into blended rings.1368 const source: Station[] = [];1369 const angleDelta = (a: number, b: number): number => {1370 let d = b - a;1371 while (d > Math.PI) d -= 2 * Math.PI;1372 while (d < -Math.PI) d += 2 * Math.PI;1373 return d;1374 };1375 for (let i = 0; i < raw.length; i++) {1376 if (i === 0) {1377 source.push(raw[i]);1378 continue;1379 }1380 const a = raw[i - 1];1381 const b = raw[i];1382 const dPitch = b.pitch - a.pitch;1383 const dYaw = angleDelta(a.yaw, b.yaw);1384 const steps = Math.max(1385 1,1386 Math.ceil(Math.max(Math.abs(dPitch), Math.abs(dYaw)) / MAX_TURN),1387 );1388 for (let j = 1; j <= steps; j++) {1389 const t = j / steps;1390 source.push({1391 ...b,1392 p: [1393 a.p[0] + (b.p[0] - a.p[0]) * t,1394 a.p[1] + (b.p[1] - a.p[1]) * t,1395 a.p[2] + (b.p[2] - a.p[2]) * t,1396 ],1397 yaw: a.yaw + dYaw * t,1398 pitch: a.pitch + dPitch * t,1399 });1400 }1401 }1402 for (const st of source) {1403 if (1404 last &&1405 Math.hypot(1406 st.p[0] - last.p[0],1407 st.p[1] - last.p[1],1408 st.p[2] - last.p[2],1409 ) < 0.051410 ) continue; // near-duplicate ring → degenerate triangles1411 rings.push(st);1412 last = st;1413 }1414 let arc = 0;1415 for (const [ri, st] of rings.entries()) {1416 if (ri > 0) {1417 const prev = rings[ri - 1];1418 arc += Math.hypot(1419 st.p[0] - prev.p[0],1420 st.p[1] - prev.p[1],1421 st.p[2] - prev.p[2],1422 );1423 }1424 // Catch-mouth flare: lateral scale ramps 1.35 → 1 over the first1425 // 2 world units (heights untouched — the floor band widens and the1426 // gutter walls converge downstream).1427 const latScale = flareStart ? 1 + 0.35 * Math.max(0, 1 - arc / 2.0) : 1;1428 // Frame from the true tangent: right = up × t, upV = t × right.1429 const ty = Math.sin(-st.pitch);1430 const th = Math.cos(st.pitch);1431 const t = [Math.sin(st.yaw) * th, ty, Math.cos(st.yaw) * th];1432 const rl = Math.hypot(t[2], t[0]) || 1;1433 const right = [t[2] / rl, 0, -t[0] / rl];1434 // up = t × right (NOT right × t — that hangs the gutters and walls1435 // BELOW the floor; the straight repro masked it because the flat1436 // middle of the profile sits at up=0 either way).1437 const upV = [1438 t[1] * right[2] - t[2] * right[1],1439 t[2] * right[0] - t[0] * right[2],1440 t[0] * right[1] - t[1] * right[0],1441 ];1442 for (const [lat, up] of CHANNEL_PROFILE) {1443 // Width law (T1, measured): wall-side points shift OUT by the1444 // station's widening; the flat floor stays put and the sheet1445 // between stretches — the kit widens by moving walls, not by1446 // scaling the section.1447 const l0 = lat * latScale;1448 const l = Math.abs(l0) > PROFILE_FLAT1449 ? l0 + Math.sign(l0) * st.wShift1450 : l0;1451 vertices.push(1452 st.p[0] + right[0] * l + upV[0] * up,1453 st.p[1] + right[1] * l + upV[1] * up,1454 st.p[2] + right[2] * l + upV[2] * up,1455 );1456 }1457 }1458 for (let r = 0; r < rings.length - 1; r++) {1459 const a = r * N;1460 const b = (r + 1) * N;1461 for (let j = 0; j < N - 1; j++) {1462 // Winding: Box3D mesh collision is ONE-SIDED. ribbonOf lays the1463 // profile along right = up × t (−z for +x travel), so THIS order1464 // faces the channel interior — verified by the isolated repro chain1465 // (square → mini-ribbon → real ribbon; handedness matters).1466 indices.push(a + j, b + j, b + j + 1);1467 indices.push(a + j, b + j + 1, a + j + 1);1468 }1469 }1470 return { kind: "mesh", vertices, indices };1471}14721473/** Heights of the cliff block family (world units). */1474function blockHeight(pieceId: string): number | null {1475 if (pieceId.includes("cliff_blockQuarter")) return 0.25;1476 if (pieceId.includes("cliff_blockHalf")) return 0.5;1477 if (pieceId.includes("cliff_block")) return 1;1478 // Island mass families: Platformer block voxels (measured 1.0 full /1479 // 0.5 low courses; the 0.082 grass-lip overhang is cosmetic — the1480 // box stays the 1x1 course like every mass family).1481 if (/^platformer-kit:block-(grass|snow)-low/.test(pieceId)) return 0.5;1482 if (/^platformer-kit:block-(grass|snow)/.test(pieceId)) return 1;1483 return null;1484}14851486/** Box proxies for an island's structural blocks (caps/decor are cosmetic1487 * — the marble never touches them from the channel). */1488export function islandProxies(1489 placements: readonly Placement[],1490): BoxProxy[] {1491 const out: BoxProxy[] = [];1492 for (const p of placements) {1493 if (p.role !== "support") continue;1494 const h = blockHeight(p.pieceId);1495 if (h === null) continue;1496 out.push({1497 kind: "box",1498 center: [p.pos[0], p.pos[1] + h / 2, p.pos[2]],1499 half: [0.5, h / 2, 0.5],1500 });1501 }1502 return out;1503}