1/**2 * Piece registry: hand-authored, geometry-calibrated defs for the Marble Kit3 * path pieces the generator can realize, plus derived reversed variants.4 *5 * All numbers here were measured from the GLBs (channel floor = base + 0.25,6 * walls to base + 0.5, ramp tier deltas, curve arc radii). Defs are authored7 * in "entry frame" — see geom.ts. validateRegistry() in tests asserts the8 * internal consistency of every def.9 */10import type { CurveSeg, Dir, Vec3 } from "../shared/types.ts";11import {12 DIR_STEP,13 moveLabel,14 opposite,15 reverseSeg,16 rotP,17 segPoint,18 turnLeft,19 turnRight,20} from "./geom.ts";2122export interface PortPose {23 /** Port position in entry frame (y = base height, not channel floor). */24 pos: Vec3;25 /** Direction of travel through the port. */26 dir: Dir;27 /** Base height at the port (multiple of 0.25 in practice). */28 baseY: number;29}3031/** Secondary mesh of a composite piece, posed in entry frame. */32export interface Overlay {33 id: string;34 offset: Vec3;35 /** Extra quarter-turns on top of the placement rotation. */36 rot?: Dir;37 /** Render this mesh x-flipped (independent of the def's own flag). */38 mirrored?: boolean;39}4041export interface PieceDef {42 /** Piece identity (style weights, reservations key on this). */43 id: string;44 /** GLB mesh to place, when it differs from `id` (e.g. tunnel = straight). */45 meshId?: string;46 /** Extra meshes stacked on the base mesh, offsets in entry frame. */47 overlays?: readonly Overlay[];48 family: string;49 /** Skeleton moves consumed, e.g. "F", "L", "FLF", "FFLFF". */50 moves: string;51 /** Exit base height − entry base height. */52 deckDelta: number;53 /** Channel floor above local base at entry/exit (0.25 for most pieces). */54 floorIn: number;55 floorOut: number;56 /** GLB origin position in entry frame. */57 meshOffset: Vec3;58 /** Extra quarter-turns to apply to the mesh on top of placement rotation. */59 meshRot: Dir;60 /** All cells occupied (entry frame offsets; entry cell = [0,0]). */61 footprint: readonly (readonly [number, number])[];62 /** Cells the channel actually passes through, in order (supports go here). */63 supportCells: readonly (readonly [number, number])[];64 /** Centerline at channel-floor height, entry port → exit port. */65 centerline: readonly CurveSeg[];66 /**67 * Alternative path through the piece (a split–merge diamond's other68 * branch). Same entry/exit ports as `centerline`; the marble alternates69 * per lap.70 */71 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";79 /** Occupied y band above local base (for collision). */80 heightSpan: readonly [number, number];81 /** GLB id of the closed-underside variant, if the kit has one. */82 solidId?: string;83 baseWeight: number;84 /** True when this def was derived by reversing another. */85 reversed?: boolean;86 /** Render the base mesh x-flipped (defs derived via mirrorDef). */87 mirrored?: boolean;88 /** Channel width at the entry/exit port (wide = 1.7-cell channel). */89 widthIn?: "narrow" | "wide";90 widthOut?: "narrow" | "wide";91 /** Only placeable via reservations (feature planner), never free WFC. */92 reservedOnly?: boolean;93 /**94 * Emit no mesh placement — the piece's geometry is provided by another95 * window sharing its cell (the second visit of a grade crossing). The96 * centerline still contributes to the loop.97 */98 noMesh?: boolean;99 /**100 * Channel doesn't hug cell-center lanes on its F cells (waves, bends,101 * s-curves, wide pieces) — elevated supports use plain columns snapped to102 * the centerline instead of cell-center cradle stacks.103 */104 columnSupports?: boolean;105 /**106 * Plate tiles (short s-curve jogs): the mesh is a square slab with the107 * channel carved diagonally through, leaving triangular platform wings.108 * These cells (entry frame, OUTSIDE the footprint) must carry track for109 * the wings to read as embedded deck — the WFC skips the candidate when110 * any wing cell is bare grass. Standard corners stay the default.111 */112 plateSides?: readonly (readonly [number, number])[];113 /**114 * The solid variant fills the full footprint square(s) beyond the pipe115 * (corner/curve blocks), unlike open forms whose outline is contour-cut.116 * Assembly uses the solid ONLY when that extended surface abuts other117 * same-height deck; otherwise the pipe-only open form is placed (user118 * rule: no solid block corners jutting where nothing touches them).119 */120 solidExtends?: boolean;121}122123const F = 0.25; // standard channel floor above base124const W = 0.5; // wall top above base125126function line(a: Vec3, b: Vec3): CurveSeg {127 return { kind: "line", a, b };128}129function cubic(p0: Vec3, p1: Vec3, p2: Vec3, p3: Vec3): CurveSeg {130 return { kind: "cubic", p: [p0, p1, p2, p3] };131}132133/** Quarter arc for an L-turn with channel radius `radius`: enters at134 * (0, ·, −0.5) heading +z, exits at (−radius, ·, −0.5+radius) heading −x.135 * Arc center sits at (−radius, ·, −0.5) — calibrated against corner /136 * curve / curve-large geometry. */137function leftArc(radius: number, yIn: number, yOut: number): CurveSeg {138 return {139 kind: "arc",140 center: [-radius, 0, -0.5],141 radius,142 a0: 0,143 a1: Math.PI / 2,144 y0: yIn,145 y1: yOut,146 };147}148149/** Build the defs table. Kept as a function for testability. */150function buildDefs(): PieceDef[] {151 const defs: PieceDef[] = [];152153 // --- straights -----------------------------------------------------------154 defs.push({155 id: "straight",156 family: "straight",157 moves: "F",158 deckDelta: 0,159 floorIn: F,160 floorOut: F,161 meshOffset: [0, 0, 0],162 meshRot: 0,163 footprint: [[0, 0]],164 supportCells: [[0, 0]],165 centerline: [line([0, F, -0.5], [0, F, 0.5])],166 heightSpan: [0, W],167 baseWeight: 10,168 });169170 // tunnel: the GLB is only the arch shell (top half) — it seats on a171 // straight with its feet on the wall tops (measured against Sample.png).172 defs.push({173 id: "tunnel",174 meshId: "straight",175 overlays: [{ id: "tunnel", offset: [0, W, 0] }],176 family: "tunnel",177 moves: "F",178 deckDelta: 0,179 floorIn: F,180 floorOut: F,181 meshOffset: [0, 0, 0],182 meshRot: 0,183 footprint: [[0, 0]],184 supportCells: [[0, 0]],185 centerline: [line([0, F, -0.5], [0, F, 0.5])],186 heightSpan: [0, W + 0.6],187 baseWeight: 1.5,188 });189190 // straight-hole: a straight whose floor is pierced — on a bridge deck the191 // marble has a CHANCE to fall through onto the channel below (user rule:192 // holes require track below, guaranteed here because only bridge-crossing193 // reservations place it). The centerline is the roll-over path; the fall194 // is the client's per-lap deviation.195 defs.push({196 id: "straight-hole",197 family: "straight",198 moves: "F",199 deckDelta: 0,200 floorIn: F,201 floorOut: F,202 meshOffset: [0, 0, 0],203 meshRot: 0,204 footprint: [[0, 0]],205 supportCells: [[0, 0]],206 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]],210 heightSpan: [0, W],211 reservedOnly: true,212 baseWeight: 1,213 });214215 // Tunnel runs: contiguous covered stretches (the M1 singleton reads as a216 // lone gate; a run reads as an actual tunnel). Composite of one straight +217 // arch per cell — same measured seating as the singleton.218 for (const len of [2, 3]) {219 const cells: [number, number][] = [];220 const overlays: Overlay[] = [{ id: "tunnel", offset: [0, W, 0] }];221 for (let j = 0; j < len; j++) {222 cells.push([0, j]);223 if (j > 0) {224 overlays.push({ id: "straight", offset: [0, 0, j] });225 overlays.push({ id: "tunnel", offset: [0, W, j] });226 }227 }228 defs.push({229 id: `tunnel-run-${len}`,230 meshId: "straight",231 overlays,232 family: "tunnel",233 moves: "F".repeat(len),234 deckDelta: 0,235 floorIn: F,236 floorOut: F,237 meshOffset: [0, 0, 0],238 meshRot: 0,239 footprint: cells,240 supportCells: cells,241 centerline: [line([0, F, -0.5], [0, F, len - 0.5])],242 heightSpan: [0, W + 0.6],243 baseWeight: len === 2 ? 2.5 : 1.5,244 });245 }246247 // Drop-stacks (user design): an end-hole stacked FLUSH on a plain end248 // piece (0.5 grid — nothing floats). The marble enters the upper port,249 // falls through the hole into the cup below, and exits the receiver's250 // port — a guaranteed vertical transfer. The channel network is251 // deliberately non-connected here; the LOOP closes through the fall (the252 // centerline carries a vertical segment, and the gravity model makes the253 // marble properly plunge). One cell, deckDelta −0.5; the exit port may254 // continue straight or turn (the cup is round): F/L/R variants, rounded255 // and square flavors. Reservation-only, placed by the descent vocabulary256 // and the corner-drop planner.257 for (const flavor of ["rounded", "square"] as const) {258 for (259 const [turn, moves, exitRot] of [260 ["f", "F", 2],261 ["l", "L", 1],262 ["r", "R", 3],263 ] as const264 ) {265 const exit = turn === "f"266 ? [0, 0.5]267 : turn === "l"268 ? [-0.5, 0]269 : [0.5, 0];270 defs.push({271 id: `drop-stack-${flavor}-${turn}`,272 meshId: `end-hole-${flavor}`,273 overlays: [274 // Receiver cup at ground; its port (local −z) faces the exit.275 { id: `end-${flavor}`, offset: [0, -0.5, 0], rot: exitRot },276 ],277 family: "drop",278 moves,279 deckDelta: -0.5,280 floorIn: F,281 floorOut: F,282 meshOffset: [0, 0, 0],283 meshRot: 0,284 footprint: [[0, 0]],285 supportCells: [[0, 0]],286 centerline: [287 line([0, F, -0.5], [0, F, 0]),288 line([0, F, 0], [0, F - 0.5, 0]),289 line([0, F - 0.5, 0], [exit[0], F - 0.5, exit[1]]),290 ],291 heightSpan: [-0.5, W],292 reservedOnly: true,293 baseWeight: 2,294 });295 }296 }297298 // Grade crossing: 4-way symmetric block (measured: the straight's port299 // signature on all four faces, floor 0.25). The ring visits its cell twice300 // (perpendicular, same height, pierce mutation); the first visit places301 // the mesh, the second is the noMesh phantom so the loop centerline still302 // chains. Reservation-only — free WFC never crosses itself.303 for (const phantom of [false, true]) {304 defs.push({305 id: phantom ? "cross-over" : "cross",306 ...(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",311 family: "cross",312 moves: "F",313 deckDelta: 0,314 floorIn: F,315 floorOut: F,316 meshOffset: [0, 0, 0],317 meshRot: 0,318 footprint: [[0, 0]],319 supportCells: [[0, 0]],320 centerline: [line([0, F, -0.5], [0, F, 0.5])],321 heightSpan: [0, W],322 reservedOnly: true,323 baseWeight: 1,324 });325 }326327 // --- turns ---------------------------------------------------------------328 // corner: 1×1, quarter arc r=0.5, turns south→west (L).329 defs.push({330 id: "corner",331 family: "corner",332 moves: "L",333 deckDelta: 0,334 floorIn: F,335 floorOut: F,336 meshOffset: [0, 0, 0],337 meshRot: 0,338 footprint: [[0, 0]],339 supportCells: [[0, 0]],340 centerline: [leftArc(0.5, F, F)],341 heightSpan: [0, W],342 solidId: "corner-solid",343 solidExtends: true,344 baseWeight: 6,345 });346347 // curve: 2×2 block, entry cell south-east of block; window F,L,F.348 defs.push({349 id: "curve",350 family: "curve",351 moves: "FLF",352 deckDelta: 0,353 floorIn: F,354 floorOut: F,355 meshOffset: [-0.5, 0, 0.5],356 meshRot: 0,357 footprint: [[0, 0], [0, 1], [-1, 1], [-1, 0]],358 supportCells: [[0, 0], [0, 1], [-1, 1]],359 centerline: [leftArc(1.5, F, F)],360 heightSpan: [0, W],361 solidId: "curve-solid",362 solidExtends: true,363 baseWeight: 5,364 });365366 // curve-large: 3×3 block; window F,F,L,F,F.367 defs.push({368 id: "curve-large",369 family: "curve",370 moves: "FFLFF",371 deckDelta: 0,372 floorIn: F,373 floorOut: F,374 meshOffset: [-1, 0, 1],375 meshRot: 0,376 footprint: [377 [0, 0],378 [0, 1],379 [0, 2],380 [-1, 0],381 [-1, 1],382 [-1, 2],383 [-2, 0],384 [-2, 1],385 [-2, 2],386 ],387 supportCells: [[0, 0], [0, 1], [-1, 1], [-1, 2], [-2, 2]],388 centerline: [leftArc(2.5, F, F)],389 heightSpan: [0, W],390 solidId: "curve-solid-large",391 solidExtends: true,392 baseWeight: 3,393 });394395 // --- climbs --------------------------------------------------------------396 // slant a–d exist, but only b/c/d have deltas on the 0.5 support grid.397 const slantDelta = { b: 0.5, c: 1.0, d: 1.5 } as const;398 for (const tier of ["b", "c", "d"] as const) {399 const d = slantDelta[tier];400 defs.push({401 id: `slant-${tier}`,402 family: "slant",403 moves: "F",404 deckDelta: d,405 floorIn: F,406 floorOut: F,407 meshOffset: [0, 0, 0],408 meshRot: 0,409 footprint: [[0, 0]],410 supportCells: [[0, 0]],411 centerline: [line([0, F, -0.5], [0, F + d, 0.5])],412 heightSpan: [0, W + d],413 solidId: `slant-solid-${tier}`,414 baseWeight: tier === "b" ? 3 : tier === "c" ? 2 : 1,415 });416 defs.push({417 id: `slant-long-${tier}`,418 family: "slant",419 moves: "FF",420 deckDelta: d,421 floorIn: F,422 floorOut: F,423 meshOffset: [0, 0, 0.5],424 meshRot: 0,425 footprint: [[0, 0], [0, 1]],426 supportCells: [[0, 0], [0, 1]],427 centerline: [line([0, F, -0.5], [0, F + d, 1.5])],428 heightSpan: [0, W + d],429 solidId: `slant-solid-long-${tier}`,430 baseWeight: 3,431 });432 // ramp-long: same span/delta, S-eased profile.433 defs.push({434 id: `ramp-long-${tier}`,435 family: "ramp",436 moves: "FF",437 deckDelta: d,438 floorIn: F,439 floorOut: F,440 meshOffset: [0, 0, 0.5],441 meshRot: 0,442 footprint: [[0, 0], [0, 1]],443 supportCells: [[0, 0], [0, 1]],444 centerline: [445 cubic(446 [0, F, -0.5],447 [0, F, 0.3],448 [0, F + d, 0.7],449 [0, F + d, 1.5],450 ),451 ],452 heightSpan: [0, W + d],453 solidId: `ramp-long-solid-${tier}`,454 baseWeight: 4,455 });456 }457458 // ramp-start + ramp-end pairs: each rises the tier delta over one cell,459 // eased. Modeled as a single 2-cell composite is unnecessary — each is a460 // valid 1-cell piece; the elevation system reserves them as pairs so the461 // mid-climb base stays on the vocabulary's grid. Tier deltas measured:462 // b 0.25, c 0.5, d 0.75 (a = 0.125 is off every usable grid — skipped).463 for (464 const [tier, d, weight] of [["b", 0.25, 2], ["c", 0.5, 3], ["d", 0.75, 2]]465 .map((t) => t as [string, number, number])466 ) {467 defs.push({468 id: `ramp-start-${tier}`,469 family: "ramp",470 moves: "F",471 deckDelta: d,472 floorIn: F,473 floorOut: F,474 meshOffset: [0, 0, 0],475 meshRot: 0,476 footprint: [[0, 0]],477 supportCells: [[0, 0]],478 centerline: [479 cubic(480 [0, F, -0.5],481 [0, F, -0.1],482 [0, F + d * 0.56, 0.28],483 [0, F + d, 0.5],484 ),485 ],486 heightSpan: [0, W + d],487 solidId: `ramp-start-solid-${tier}`,488 baseWeight: weight,489 });490 defs.push({491 id: `ramp-end-${tier}`,492 family: "ramp",493 moves: "F",494 deckDelta: d,495 floorIn: F,496 floorOut: F,497 meshOffset: [0, 0, 0],498 meshRot: 0,499 footprint: [[0, 0]],500 supportCells: [[0, 0]],501 centerline: [502 cubic(503 [0, F, -0.5],504 [0, F + d * 0.44, -0.28],505 [0, F + d, 0.1],506 [0, F + d, 0.5],507 ),508 ],509 heightSpan: [0, W + d],510 solidId: `ramp-end-solid-${tier}`,511 baseWeight: weight,512 });513 }514515 // --- bends (U-turns) -----------------------------------------------------516 // Measured: both ports on the authored −x face at cell centers, and the517 // semicircle's diameter lies exactly ON the port face (zero lead-in), arc518 // center at (bboxMin.x, 0), radii 0.5/1.5/2.5, floor 0.25 throughout.519 // meshRot 3 turns the authored +x entry into the entry frame's +z.520 defs.push({521 id: "bend",522 family: "bend",523 moves: "LL",524 deckDelta: 0,525 floorIn: F,526 floorOut: F,527 meshOffset: [-0.5, 0, 0],528 meshRot: 3,529 footprint: [[0, 0], [-1, 0]],530 supportCells: [[0, 0], [-1, 0]],531 centerline: [532 {533 kind: "arc",534 center: [-0.5, 0, -0.5],535 radius: 0.5,536 a0: 0,537 a1: Math.PI,538 y0: F,539 y1: F,540 },541 ],542 heightSpan: [0, W],543 baseWeight: 14,544 });545 defs.push({546 id: "bend-medium",547 family: "bend",548 moves: "FLFFLF",549 deckDelta: 0,550 floorIn: F,551 floorOut: F,552 meshOffset: [-1.5, 0, 0.5],553 meshRot: 3,554 footprint: [555 [0, 0],556 [0, 1],557 [-1, 1],558 [-2, 1],559 [-3, 1],560 [-3, 0],561 [-1, 0],562 [-2, 0],563 ],564 supportCells: [[0, 0], [0, 1], [-1, 1], [-2, 1], [-3, 1], [-3, 0]],565 centerline: [566 {567 kind: "arc",568 center: [-1.5, 0, -0.5],569 radius: 1.5,570 a0: 0,571 a1: Math.PI,572 y0: F,573 y1: F,574 },575 ],576 heightSpan: [0, W],577 solidId: "bend-solid-medium",578 columnSupports: true,579 baseWeight: 16,580 });581 {582 const footprint: [number, number][] = [];583 for (let x = -5; x <= 0; x++) {584 for (let z = 0; z <= 2; z++) footprint.push([x, z]);585 }586 defs.push({587 id: "bend-large",588 family: "bend",589 moves: "FFLFFFFLFF",590 deckDelta: 0,591 floorIn: F,592 floorOut: F,593 meshOffset: [-2.5, 0, 1.0],594 meshRot: 3,595 footprint,596 supportCells: [597 [0, 0],598 [0, 1],599 [0, 2],600 [-1, 2],601 [-2, 2],602 [-3, 2],603 [-4, 2],604 [-5, 2],605 [-5, 1],606 [-5, 0],607 ],608 centerline: [609 {610 kind: "arc",611 center: [-2.5, 0, -0.5],612 radius: 2.5,613 a0: 0,614 a1: Math.PI,615 y0: F,616 y1: F,617 },618 ],619 heightSpan: [0, W],620 solidId: "bend-solid-large",621 columnSupports: true,622 baseWeight: 16,623 });624 }625626 // --- helixes (descending turns) ------------------------------------------627 // Measured: each helix is exactly its flat cousin's arc — curve /628 // curve-large for quarters, bend-medium / bend-large semicircles for629 // halves (same ports, radii, footprints) — with the channel floor630 // descending LINEARLY IN ANGLE. Quarters drop 0.5, halves drop 1.0; the631 // mesh is grounded on its exit side, so meshOffset.y = −drop puts the632 // entry base at the entry frame's 0. Forward defs are DESCENTS633 // (deckDelta < 0 keeps them out of free WFC collapse); the elevation634 // planner reserves them at turn windows ("corner drops"). Reversed635 // variants are gentle spiral climbs (~0.21/cell). Native right-handed636 // GLBs are exact mirrors — same convention as s-curves.637 const helixQuarterLeft: PieceDef = {638 id: "helix-quarter-left",639 family: "helix",640 moves: "FLF",641 deckDelta: -0.5,642 floorIn: F,643 floorOut: F,644 meshOffset: [-0.5, -0.5, 0.5],645 meshRot: 0,646 footprint: [[0, 0], [0, 1], [-1, 1], [-1, 0]],647 supportCells: [[0, 0], [0, 1], [-1, 1]],648 centerline: [leftArc(1.5, F, F - 0.5)],649 heightSpan: [-0.5, W],650 columnSupports: true,651 baseWeight: 8,652 };653 defs.push(helixQuarterLeft);654 defs.push({655 ...mirrorDef(helixQuarterLeft, "helix-quarter-right"),656 meshId: undefined,657 mirrored: false,658 });659 const helixLargeQuarterLeft: PieceDef = {660 id: "helix-large-quarter-left",661 family: "helix",662 moves: "FFLFF",663 deckDelta: -0.5,664 floorIn: F,665 floorOut: F,666 meshOffset: [-1, -0.5, 1],667 meshRot: 0,668 footprint: [669 [0, 0],670 [0, 1],671 [0, 2],672 [-1, 0],673 [-1, 1],674 [-1, 2],675 [-2, 0],676 [-2, 1],677 [-2, 2],678 ],679 supportCells: [[0, 0], [0, 1], [-1, 1], [-1, 2], [-2, 2]],680 centerline: [leftArc(2.5, F, F - 0.5)],681 heightSpan: [-0.5, W],682 columnSupports: true,683 baseWeight: 6,684 };685 defs.push(helixLargeQuarterLeft);686 defs.push({687 ...mirrorDef(helixLargeQuarterLeft, "helix-large-quarter-right"),688 meshId: undefined,689 mirrored: false,690 });691 const helixHalfLeft: PieceDef = {692 id: "helix-half-left",693 family: "helix",694 moves: "FLFFLF",695 deckDelta: -1.0,696 floorIn: F,697 floorOut: F,698 meshOffset: [-1.5, -1.0, 0.5],699 meshRot: 3,700 footprint: [701 [0, 0],702 [0, 1],703 [-1, 1],704 [-2, 1],705 [-3, 1],706 [-3, 0],707 [-1, 0],708 [-2, 0],709 ],710 supportCells: [[0, 0], [0, 1], [-1, 1], [-2, 1], [-3, 1], [-3, 0]],711 centerline: [712 {713 kind: "arc",714 center: [-1.5, 0, -0.5],715 radius: 1.5,716 a0: 0,717 a1: Math.PI,718 y0: F,719 y1: F - 1.0,720 },721 ],722 heightSpan: [-1.0, W],723 columnSupports: true,724 baseWeight: 8,725 };726 defs.push(helixHalfLeft);727 defs.push({728 ...mirrorDef(helixHalfLeft, "helix-half-right"),729 meshId: undefined,730 mirrored: false,731 });732 {733 const footprint: [number, number][] = [];734 for (let x = -5; x <= 0; x++) {735 for (let z = 0; z <= 2; z++) footprint.push([x, z]);736 }737 const helixLargeHalfLeft: PieceDef = {738 id: "helix-large-half-left",739 family: "helix",740 moves: "FFLFFFFLFF",741 deckDelta: -1.0,742 floorIn: F,743 floorOut: F,744 meshOffset: [-2.5, -1.0, 1.0],745 meshRot: 3,746 footprint,747 supportCells: [748 [0, 0],749 [0, 1],750 [0, 2],751 [-1, 2],752 [-2, 2],753 [-3, 2],754 [-4, 2],755 [-5, 2],756 [-5, 1],757 [-5, 0],758 ],759 centerline: [760 {761 kind: "arc",762 center: [-2.5, 0, -0.5],763 radius: 2.5,764 a0: 0,765 a1: Math.PI,766 y0: F,767 y1: F - 1.0,768 },769 ],770 heightSpan: [-1.0, W],771 columnSupports: true,772 baseWeight: 6,773 };774 defs.push(helixLargeHalfLeft);775 defs.push({776 ...mirrorDef(helixLargeHalfLeft, "helix-large-half-right"),777 meshId: undefined,778 mirrored: false,779 });780 }781782 // Split–merge diamonds: split-double fans the channel into two parallel783 // branches one lane out each side (measured: center entry, exits at x±1784 // heading +z over a 3×2 block); N straights per branch; a 180°-rotated785 // split-double merges them back. One composite reserved def per N — the786 // feature planner places them on straight runs with both flanks free.787 // centerline = LEFT branch, altCenterline = RIGHT; the client alternates788 // per lap. Branch jogs are z-linear-third cubics (the lane-shift law).789 for (const N of [1, 2, 3]) {790 const len = 4 + N;791 const footprint: [number, number][] = [];792 for (let dx = -1; dx <= 1; dx++) {793 for (let j = 0; j < len; j++) footprint.push([dx, j]);794 }795 const overlays: Overlay[] = [796 { id: "split-double", offset: [0, 0, 0.5] },797 { id: "split-double", offset: [0, 0, N + 2.5], rot: 2 },798 ];799 for (let j = 0; j < N; j++) {800 overlays.push({ id: "straight", offset: [-1, 0, 2 + j] });801 overlays.push({ id: "straight", offset: [1, 0, 2 + j] });802 }803 const branch = (side: -1 | 1): CurveSeg[] => [804 cubic(805 [0, F, -0.5],806 [0, F, 2 / 3 - 0.5],807 [side, F, 4 / 3 - 0.5],808 [side, F, 1.5],809 ),810 line([side, F, 1.5], [side, F, N + 1.5]),811 cubic(812 [side, F, N + 1.5],813 [side, F, N + 1.5 + 2 / 3],814 [0, F, N + 1.5 + 4 / 3],815 [0, F, N + 3.5],816 ),817 ];818 defs.push({819 id: `diamond-${N}`,820 meshId: "split-double",821 overlays: overlays.slice(1),822 family: "split",823 moves: "F".repeat(len),824 deckDelta: 0,825 floorIn: F,826 floorOut: F,827 meshOffset: [0, 0, 0.5],828 meshRot: 0,829 footprint,830 supportCells: footprint.filter(([dx]) => dx === 0),831 centerline: branch(-1),832 altCenterline: branch(1),833 heightSpan: [0, W],834 columnSupports: true,835 reservedOnly: true,836 baseWeight: 4,837 });838 }839840 // Full helixes: a 360° spiral (drop 2.0, linear in angle) whose entry and841 // exit share ONE port edge at the block's mid-face — the ring passes under842 // the spiral's own start. One reserved ensemble window covers it all:843 // approach corner (top level, corner overlay), the perimeter spiral, and844 // the underpass straights (ground level, straight overlays) back out845 // through the first spiral cells. The window's moves REVISIT its first846 // cells (the topology helix-loop mutation makes the matching ring), so847 // footprints dedup and the mesh provides both levels. Mesh grounded at the848 // exit side: meshOffset.y = −2. Only the elevation planner reserves these849 // (with a mandatory 2.0 climb elsewhere on the ring).850 for (const large of [false, true]) {851 const R = large ? 2.5 : 1.5; // spiral lane radius852 const N = large ? 6 : 4; // block is N×N cells853 const side = N - 1;854 const under = N / 2; // underpass length in cells (2 small, 3 large)855 // Walk: A(0,0) R turn, ccw around the block perimeter (F…L per side),856 // then back through A and the first cells (the underpass), exiting +x.857 let moves = "R";858 moves += "F".repeat(under - 1) + "L"; // A → along +x to the corner859 for (let s = 0; s < 3; s++) moves += "F".repeat(side - 1) + "L";860 moves += "F".repeat(side - 1 - under); // return leg, short of A861 moves += "F"; // through A again862 moves += "F".repeat(under); // underpass B, C(, D)863 // Trace cells for footprint/supports.864 const walk: [number, number][] = [];865 let cx = 0, cz = 0, dir = 1;866 for (const m of moves) {867 dir = m === "F" ? dir : m === "L" ? (dir + 1) % 4 : (dir + 3) % 4;868 walk.push([cx, cz]);869 cx += [1, 0, -1, 0][dir];870 cz += [0, 1, 0, -1][dir];871 }872 const uniq = new Map<string, [number, number]>();873 for (const c of walk) uniq.set(`${c[0]},${c[1]}`, c);874 // Interior cells (the helix's central column) complete the footprint.875 const xs = [...uniq.values()].map((c) => c[0]);876 const zs = [...uniq.values()].map((c) => c[1]);877 const [x0, x1] = [Math.min(...xs), Math.max(...xs)];878 const [z0, z1] = [Math.min(...zs), Math.max(...zs)];879 const footprint: [number, number][] = [];880 for (let fx = x0; fx <= x1; fx++) {881 for (let fz = z0; fz <= z1; fz++) footprint.push([fx, fz]);882 }883 const overlays: Overlay[] = [884 // Approach turn at the top — mirrored corner = R turn.885 { id: "corner", offset: [0, 0, 0], mirrored: true },886 ];887 for (let j = 1; j <= under; j++) {888 overlays.push({ id: "straight", offset: [j, -2, 0], rot: 1 });889 }890 const def: PieceDef = {891 id: large ? "helix-large-left" : "helix-left",892 overlays,893 family: "helix",894 moves,895 deckDelta: -2,896 floorIn: F,897 floorOut: F,898 meshOffset: [0.5, -2, R],899 meshRot: 2,900 footprint,901 supportCells: [...uniq.values()],902 centerline: [903 // Approach corner: R quarter, r 0.5, flat at the top level.904 {905 kind: "arc",906 center: [0.5, 0, -0.5],907 radius: 0.5,908 a0: Math.PI,909 a1: Math.PI / 2,910 y0: F,911 y1: F,912 },913 // The spiral: full ccw turn, floor linear in angle (measured).914 {915 kind: "arc",916 center: [0.5, 0, R],917 radius: R,918 a0: -Math.PI / 2,919 a1: Math.PI * 1.5,920 y0: F,921 y1: F - 2,922 },923 line([0.5, F - 2, 0], [under + 0.5, F - 2, 0]),924 ],925 heightSpan: [-2, W],926 columnSupports: true,927 reservedOnly: true,928 baseWeight: 4,929 };930 defs.push(def);931 defs.push({932 ...mirrorDef(def, large ? "helix-large-right" : "helix-right"),933 meshId: undefined,934 mirrored: false,935 });936 }937938 // --- s-curves ------------------------------------------------------------939 // Measured: two mirrored tangent-continuous 90° arcs of the curve /940 // curve-large lane radii; the lane heading is momentarily perpendicular to941 // the travel axis at the inflection. Point-symmetric, so the reversed942 // variant collapses onto the forward def. Kenney's "left" jogs +x in the943 // entry frame — our "R" chirality; the native right meshes are exact944 // mirrors, so their defs are derived via mirrorDef with the flip cleared.945 const sCurveLeft: PieceDef = {946 id: "s-curve-left",947 family: "s-curve",948 moves: "FRFFLF",949 deckDelta: 0,950 floorIn: F,951 floorOut: F,952 meshOffset: [1.5, 0, 1.0],953 meshRot: 0,954 footprint: [955 [0, 0],956 [0, 1],957 [1, 0],958 [1, 1],959 [2, 1],960 [2, 2],961 [3, 1],962 [3, 2],963 ],964 supportCells: [[0, 0], [0, 1], [1, 1], [2, 1], [3, 1], [3, 2]],965 centerline: [966 {967 kind: "arc",968 center: [1.5, 0, -0.5],969 radius: 1.5,970 a0: Math.PI,971 a1: Math.PI / 2,972 y0: F,973 y1: F,974 },975 {976 kind: "arc",977 center: [1.5, 0, 2.5],978 radius: 1.5,979 a0: -Math.PI / 2,980 a1: 0,981 y0: F,982 y1: F,983 },984 ],985 heightSpan: [0, W],986 columnSupports: true,987 baseWeight: 12,988 };989 defs.push(sCurveLeft);990 defs.push({991 ...mirrorDef(sCurveLeft, "s-curve-right"),992 meshId: undefined,993 mirrored: false,994 });995 const sCurveLeftLarge: PieceDef = {996 id: "s-curve-left-large",997 family: "s-curve",998 moves: "FFRFFFFLFF",999 deckDelta: 0,1000 floorIn: F,1001 floorOut: F,1002 meshOffset: [2.5, 0, 2.0],1003 meshRot: 0,1004 footprint: [1005 [0, 0],1006 [0, 1],1007 [0, 2],1008 [1, 0],1009 [1, 1],1010 [1, 2],1011 [2, 1],1012 [2, 2],1013 [3, 2],1014 [3, 3],1015 [4, 2],1016 [4, 3],1017 [4, 4],1018 [5, 2],1019 [5, 3],1020 [5, 4],1021 ],1022 supportCells: [1023 [0, 0],1024 [0, 1],1025 [0, 2],1026 [1, 2],1027 [2, 2],1028 [3, 2],1029 [4, 2],1030 [5, 2],1031 [5, 3],1032 [5, 4],1033 ],1034 centerline: [1035 {1036 kind: "arc",1037 center: [2.5, 0, -0.5],1038 radius: 2.5,1039 a0: Math.PI,1040 a1: Math.PI / 2,1041 y0: F,1042 y1: F,1043 },1044 {1045 kind: "arc",1046 center: [2.5, 0, 4.5],1047 radius: 2.5,1048 a0: -Math.PI / 2,1049 a1: 0,1050 y0: F,1051 y1: F,1052 },1053 ],1054 heightSpan: [0, W],1055 columnSupports: true,1056 baseWeight: 14,1057 };1058 defs.push(sCurveLeftLarge);1059 defs.push({1060 ...mirrorDef(sCurveLeftLarge, "s-curve-right-large"),1061 meshId: undefined,1062 mirrored: false,1063 });10641065 // s-curve-short tiers: smootherstep jogs of 0.25 / 0.5 / 1.0 over two1066 // cells. Only tier c is grid-aligned on its own; a/b enter as paired1067 // combos (wiggle = jog out + mirrored jog back; lane-shift = two chained1068 // b jogs). Cubics use z-linear control thirds ⇒ x(z) = smoothstep — close1069 // enough to the measured smootherstep at these amplitudes.1070 const jogSeg = (1071 x0: number,1072 x1: number,1073 z0: number,1074 z1: number,1075 ): CurveSeg =>1076 cubic(1077 [x0, F, z0],1078 [x0, F, z0 + (z1 - z0) / 3],1079 [x1, F, z1 - (z1 - z0) / 3],1080 [x1, F, z1],1081 );1082 const shortC: PieceDef = {1083 id: "s-curve-short-left-c",1084 family: "s-curve",1085 moves: "FRL",1086 deckDelta: 0,1087 floorIn: F,1088 floorOut: F,1089 meshOffset: [0.5, 0, 0.5],1090 meshRot: 0,1091 footprint: [[0, 0], [0, 1], [1, 0], [1, 1]],1092 supportCells: [[0, 0], [0, 1], [1, 1]],1093 // Platform wings: left of the entry lane, right of the exit lane.1094 plateSides: [[-1, 0], [2, 1]],1095 centerline: [jogSeg(0, 1, -0.5, 1.5)],1096 heightSpan: [0, W],1097 columnSupports: true,1098 baseWeight: 5,1099 };1100 defs.push(shortC);1101 defs.push(mirrorDef(shortC, "s-curve-short-right-c"));11021103 for (const [tier, amp] of [["a", 0.125], ["b", 0.25]] as const) {1104 const wiggle: PieceDef = {1105 id: `wiggle-${tier}`,1106 family: "s-curve",1107 meshId: `s-curve-short-left-${tier}`,1108 moves: "FFFF",1109 deckDelta: 0,1110 floorIn: F,1111 floorOut: F,1112 meshOffset: [amp, 0, 0.5],1113 meshRot: 0,1114 overlays: [1115 {1116 id: `s-curve-short-left-${tier}`,1117 offset: [amp, 0, 2.5],1118 mirrored: true,1119 },1120 ],1121 footprint: [[0, 0], [0, 1], [0, 2], [0, 3], [1, 1], [1, 2]],1122 supportCells: [[0, 0], [0, 1], [0, 2], [0, 3]],1123 // b-tier plates are wide enough to read as platforms; a-tier wings1124 // are sub-0.35 slivers and pass as chamfers.1125 ...(tier === "b" ? { plateSides: [[-1, 0], [-1, 3]] as const } : {}),1126 centerline: [1127 jogSeg(0, 2 * amp, -0.5, 1.5),1128 jogSeg(2 * amp, 0, 1.5, 3.5),1129 ],1130 heightSpan: [0, W],1131 columnSupports: true,1132 baseWeight: 2.5,1133 };1134 defs.push(wiggle);1135 defs.push(mirrorDef(wiggle, `wiggle-${tier}-mirror`));1136 }11371138 const laneShiftB: PieceDef = {1139 id: "lane-shift-b",1140 family: "s-curve",1141 meshId: "s-curve-short-left-b",1142 moves: "FRLFF",1143 deckDelta: 0,1144 floorIn: F,1145 floorOut: F,1146 meshOffset: [0.25, 0, 0.5],1147 meshRot: 0,1148 overlays: [{ id: "s-curve-short-left-b", offset: [0.75, 0, 2.5] }],1149 footprint: [[0, 0], [0, 1], [0, 2], [1, 1], [1, 2], [1, 3]],1150 supportCells: [[0, 0], [0, 1], [1, 1], [1, 2], [1, 3]],1151 plateSides: [[-1, 0], [2, 3]],1152 centerline: [jogSeg(0, 0.5, -0.5, 1.5), jogSeg(0.5, 1, 1.5, 3.5)],1153 heightSpan: [0, W],1154 columnSupports: true,1155 baseWeight: 3,1156 };1157 defs.push(laneShiftB);1158 defs.push(mirrorDef(laneShiftB, "lane-shift-b-mirror"));11591160 // --- waves (lateral) and bumps (vertical) --------------------------------1161 // Measured: waves are purely lateral, bumps purely vertical, both1162 // smootherstep-eased trough→crest→trough over 4 cells with the peak at1163 // mid-piece. Wave ports sit at lane −A of the mesh frame, so the mesh1164 // shifts +A to put the port lanes on the cell centerline (crest at +2A).1165 for (1166 const [tier, amp, w] of [1167 ["a", 0.125, 3],1168 ["b", 0.25, 3.2],1169 ["c", 0.5, 3.5],1170 ] as const1171 ) {1172 const wave: PieceDef = {1173 id: `wave-${tier}`,1174 family: "wave",1175 moves: "FFFF",1176 deckDelta: 0,1177 floorIn: F,1178 floorOut: F,1179 meshOffset: [amp, 0, 1.5],1180 meshRot: 0,1181 footprint: [[0, 0], [0, 1], [0, 2], [0, 3], [1, 1], [1, 2]],1182 supportCells: [[0, 0], [0, 1], [0, 2], [0, 3]],1183 centerline: [1184 jogSeg(0, 2 * amp, -0.5, 1.5),1185 jogSeg(2 * amp, 0, 1.5, 3.5),1186 ],1187 heightSpan: [0, W],1188 solidId: `wave-solid-${tier}`,1189 columnSupports: true,1190 baseWeight: w,1191 };1192 defs.push(wave);1193 defs.push(mirrorDef(wave, `wave-${tier}-mirror`));1194 }11951196 const bumpSeg = (y0: number, y1: number, z0: number, z1: number): CurveSeg =>1197 cubic(1198 [0, y0, z0],1199 [0, y0, z0 + (z1 - z0) / 3],1200 [0, y1, z1 - (z1 - z0) / 3],1201 [0, y1, z1],1202 );1203 for (1204 const [tier, rise, w] of [1205 ["a", 0.25, 4],1206 ["b", 0.5, 3.5],1207 ["c", 1.0, 2],1208 ["d", 1.5, 1],1209 ] as const1210 ) {1211 defs.push({1212 id: `bump-${tier}`,1213 family: "bump",1214 moves: "FFFF",1215 deckDelta: 0,1216 floorIn: F,1217 floorOut: F,1218 meshOffset: [0, 0, 1.5],1219 meshRot: 0,1220 footprint: [[0, 0], [0, 1], [0, 2], [0, 3]],1221 supportCells: [[0, 0], [0, 1], [0, 2], [0, 3]],1222 centerline: [1223 bumpSeg(F, F + rise, -0.5, 1.5),1224 bumpSeg(F + rise, F, 1.5, 3.5),1225 ],1226 heightSpan: [0, W + rise],1227 solidId: `bump-solid-${tier}`,1228 baseWeight: w,1229 });1230 }12311232 // --- wide channel --------------------------------------------------------1233 // straight-wide: 1.7-wide channel centered on the cell, bulging 0.35 into1234 // each flanking cell (hence the 3-cell footprint). funnel: exactly linear1235 // narrow→wide taper across one cell; funnel-long = straight + funnel +1236 // straight-wide fused. All reservedOnly: the feature planner emits them as1237 // funnel-bracketed runs, which is what makes width continuity a1238 // by-construction invariant. (curve-wide measured off-grid — its ports1239 // sit on ±0.85 bbox faces and cannot mate with the 1-unit port lattice;1240 // deferred to a later milestone as a set-piece macro.)1241 defs.push({1242 id: "straight-wide",1243 family: "wide",1244 moves: "F",1245 deckDelta: 0,1246 floorIn: F,1247 floorOut: F,1248 widthIn: "wide",1249 widthOut: "wide",1250 meshOffset: [0, 0, 0],1251 meshRot: 0,1252 footprint: [[0, 0], [1, 0], [-1, 0]],1253 supportCells: [[0, 0]],1254 centerline: [line([0, F, -0.5], [0, F, 0.5])],1255 heightSpan: [0, W],1256 reservedOnly: true,1257 columnSupports: true,1258 baseWeight: 1,1259 });1260 defs.push({1261 id: "funnel",1262 family: "funnel",1263 moves: "F",1264 deckDelta: 0,1265 floorIn: F,1266 floorOut: F,1267 widthIn: "narrow",1268 widthOut: "wide",1269 meshOffset: [0, 0, 0],1270 meshRot: 0,1271 footprint: [[0, 0], [1, 0], [-1, 0]],1272 supportCells: [[0, 0]],1273 centerline: [line([0, F, -0.5], [0, F, 0.5])],1274 heightSpan: [0, W],1275 reservedOnly: true,1276 columnSupports: true,1277 baseWeight: 1,1278 });1279 defs.push({1280 id: "funnel-long",1281 family: "funnel",1282 moves: "FFF",1283 deckDelta: 0,1284 floorIn: F,1285 floorOut: F,1286 widthIn: "narrow",1287 widthOut: "wide",1288 meshOffset: [0, 0, 1],1289 meshRot: 0,1290 footprint: [[0, 0], [0, 1], [0, 2], [1, 1], [-1, 1], [1, 2], [-1, 2]],1291 supportCells: [[0, 0], [0, 1], [0, 2]],1292 centerline: [line([0, F, -0.5], [0, F, 2.5])],1293 heightSpan: [0, W],1294 reservedOnly: true,1295 columnSupports: true,1296 baseWeight: 1,1297 });12981299 return defs;1300}13011302/** Reverse a def: traverse exit→entry. Gives mirrored turns and descents. */1303export function reverseDef(def: PieceDef): PieceDef {1304 const exit = exitPose(def);1305 const hIn = opposite(exit.dir); // new travel direction at old exit1306 const rho = ((hIn - 1 + 4) % 4) as Dir; // rotate old frame into new frame1307 const mapP = (v: Vec3): Vec3 => {1308 const [x, z] = rotP(v[0], v[2], rho);1309 return [x, v[1] - def.deckDelta, z];1310 };1311 // Translation: rotated old-exit-port must land at (0, ·, −0.5).1312 const rotExit = mapP(exit.pos);1313 const t: Vec3 = [-rotExit[0], 0, -0.5 - rotExit[2]];1314 const shift = (v: Vec3): Vec3 => {1315 const p = mapP(v);1316 return [p[0] + t[0], p[1] + t[1], p[2] + t[2]];1317 };1318 const shiftCell = (c: readonly [number, number]): [number, number] => {1319 const [x, z] = rotP(c[0], c[1], rho);1320 return [Math.round(x + t[0]), Math.round(z + t[2])];1321 };1322 const shiftSeg = (seg: CurveSeg): CurveSeg => {1323 const s = reverseSeg(seg);1324 switch (s.kind) {1325 case "line":1326 return { kind: "line", a: shift(s.a), b: shift(s.b) };1327 case "cubic":1328 return {1329 kind: "cubic",1330 p: [shift(s.p[0]), shift(s.p[1]), shift(s.p[2]), shift(s.p[3])],1331 };1332 case "arc": {1333 const c = shift(s.center);1334 const angleShift = -rho * Math.PI / 2;1335 return {1336 ...s,1337 center: [c[0], 0, c[2]],1338 a0: s.a0 + angleShift,1339 a1: s.a1 + angleShift,1340 y0: s.y0 - def.deckDelta,1341 y1: s.y1 - def.deckDelta,1342 };1343 }1344 }1345 };1346 const moves = def.moves.split("").reverse()1347 .map((m) => m === "L" ? "R" : m === "R" ? "L" : "F").join("");1348 return {1349 ...def,1350 moves,1351 deckDelta: -def.deckDelta,1352 floorIn: def.floorOut,1353 floorOut: def.floorIn,1354 widthIn: def.widthOut,1355 widthOut: def.widthIn,1356 meshOffset: shift(def.meshOffset),1357 overlays: def.overlays?.map((o) => ({1358 ...o,1359 offset: shift(o.offset),1360 rot: (((o.rot ?? 0) + rho) % 4) as Dir,1361 })),1362 meshRot: ((def.meshRot + rho) % 4) as Dir,1363 footprint: def.footprint.map(shiftCell),1364 plateSides: def.plateSides?.map(shiftCell),1365 supportCells: def.supportCells.map(shiftCell).reverse(),1366 centerline: def.centerline.map(shiftSeg).reverse(),1367 altCenterline: def.altCenterline?.map(shiftSeg).reverse(),1368 holes: def.holes?.map(shift),1369 reversed: !def.reversed,1370 };1371}13721373/**1374 * Mirror a def about the entry-frame x=0 plane (entry port maps to itself,1375 * L↔R swap). The mesh renders with scale(−1,1,1): world = T·R(r)·Mirror, so1376 * the local pre-rotation flips sign (M·R(θ) = R(−θ)·M).1377 */1378export function mirrorDef(def: PieceDef, id: string): PieceDef {1379 const mp = (v: Vec3): Vec3 => [-v[0], v[1], v[2]];1380 const mCell = (c: readonly [number, number]): [number, number] => [1381 -c[0],1382 c[1],1383 ];1384 const mSeg = (seg: CurveSeg): CurveSeg => {1385 switch (seg.kind) {1386 case "line":1387 return { kind: "line", a: mp(seg.a), b: mp(seg.b) };1388 case "cubic":1389 return {1390 kind: "cubic",1391 p: [mp(seg.p[0]), mp(seg.p[1]), mp(seg.p[2]), mp(seg.p[3])],1392 };1393 case "arc":1394 // x → −x maps angle θ → π − θ (cos flips, sin preserved) and1395 // reverses the sweep direction, which is exactly the mirrored turn.1396 return {1397 ...seg,1398 center: mp(seg.center),1399 a0: Math.PI - seg.a0,1400 a1: Math.PI - seg.a1,1401 };1402 }1403 };1404 return {1405 ...def,1406 id,1407 // The mirrored def renders the ORIGINAL mesh flipped — its own id is not1408 // a GLB node.1409 meshId: def.meshId ?? def.id,1410 moves: def.moves.split("")1411 .map((m) => m === "L" ? "R" : m === "R" ? "L" : "F").join(""),1412 meshOffset: mp(def.meshOffset),1413 meshRot: ((4 - def.meshRot) % 4) as Dir,1414 mirrored: !def.mirrored,1415 overlays: def.overlays?.map((o) => ({1416 ...o,1417 offset: mp(o.offset),1418 rot: ((4 - (o.rot ?? 0)) % 4) as Dir,1419 mirrored: !o.mirrored,1420 })),1421 footprint: def.footprint.map(mCell),1422 supportCells: def.supportCells.map(mCell),1423 plateSides: def.plateSides?.map(mCell),1424 centerline: def.centerline.map(mSeg),1425 altCenterline: def.altCenterline?.map(mSeg),1426 holes: def.holes?.map(mp),1427 };1428}14291430/** Walk a def's moves from the entry cell; returns per-cell [cell, dirIn]. */1431export function windowCells(1432 def: PieceDef,1433): { cell: [number, number]; dirIn: Dir; dirOut: Dir }[] {1434 const out: { cell: [number, number]; dirIn: Dir; dirOut: Dir }[] = [];1435 let cx = 0, cz = 0;1436 let dir: Dir = 1;1437 for (const m of def.moves) {1438 const dirOut: Dir = m === "F"1439 ? dir1440 : m === "L"1441 ? turnLeft(dir)1442 : turnRight(dir);1443 out.push({ cell: [cx, cz], dirIn: dir, dirOut });1444 dir = dirOut;1445 cx += DIR_STEP[dirOut][0];1446 cz += DIR_STEP[dirOut][1];1447 }1448 return out;1449}14501451/** Exit pose of a def in entry frame (derived from its moves). */1452export function exitPose(def: PieceDef): PortPose {1453 const cells = windowCells(def);1454 const last = cells[cells.length - 1];1455 const [dx, dz] = DIR_STEP[last.dirOut];1456 return {1457 pos: [last.cell[0] + dx * 0.5, def.deckDelta, last.cell[1] + dz * 0.5],1458 dir: last.dirOut,1459 baseY: def.deckDelta,1460 };1461}14621463export const ENTRY_POSE: PortPose = { pos: [0, 0, -0.5], dir: 1, baseY: 0 };14641465export interface PieceRegistry {1466 /** Forward + reversed defs, ready for window matching. */1467 variants: readonly PieceDef[];1468 byMoves: ReadonlyMap<string, readonly PieceDef[]>;1469 /** Marble-kit decor ids usable by the decor system. */1470 decorIds: readonly string[];1471}14721473export function buildRegistry(): PieceRegistry {1474 const fwd = buildDefs();1475 const variants: PieceDef[] = [...fwd];1476 for (const def of fwd) {1477 const rev = reverseDef(def);1478 // Skip reversed defs identical to forward (symmetric straights) — but a1479 // width-asymmetric piece (funnel) reversed is a genuinely new variant.1480 if (1481 rev.moves === def.moves && rev.deckDelta === def.deckDelta &&1482 rev.widthIn === def.widthIn && rev.widthOut === def.widthOut1483 ) continue;1484 variants.push(rev);1485 }1486 const byMoves = new Map<string, PieceDef[]>();1487 for (const v of variants) {1488 const list = byMoves.get(v.moves) ?? [];1489 list.push(v);1490 byMoves.set(v.moves, list);1491 }1492 return {1493 variants,1494 byMoves,1495 decorIds: [1496 "tree",1497 "tree-tall",1498 "tree-large",1499 "tree-tall-large",1500 "banner",1501 "banner-high",1502 "fan-standing-two",1503 "fan-standing-four",1504 ],1505 };1506}15071508/** Registry self-consistency checks (called from tests and dev builds). */1509export function validateRegistry(reg: PieceRegistry): string[] {1510 const errors: string[] = [];1511 for (const def of reg.variants) {1512 const tag = `${def.id}${def.reversed ? " (reversed)" : ""}`;1513 const cells = windowCells(def);1514 // moves length == supportCells length? (support cells track the channel,1515 // same count as window for M1 pieces)1516 if (cells.length !== def.moves.length) {1517 errors.push(`${tag}: windowCells mismatch`);1518 }1519 // Every window cell must be inside the footprint.1520 for (const c of cells) {1521 if (!def.footprint.some(([x, z]) => x === c.cell[0] && z === c.cell[1])) {1522 errors.push(`${tag}: window cell ${c.cell} outside footprint`);1523 }1524 }1525 // Centerline endpoints must sit at the entry/exit ports at floor height.1526 const first = def.centerline[0];1527 const last = def.centerline[def.centerline.length - 1];1528 const start = segPoint(first, 0);1529 const end = segPoint(last, 1);1530 const exit = exitPose(def);1531 const expStart: Vec3 = [0, def.floorIn, -0.5];1532 const expEnd: Vec3 = [1533 exit.pos[0],1534 def.deckDelta + def.floorOut,1535 exit.pos[2],1536 ];1537 for (let i = 0; i < 3; i++) {1538 if (Math.abs(start[i] - expStart[i]) > 1e-6) {1539 errors.push(`${tag}: centerline start ${start} != ${expStart}`);1540 break;1541 }1542 }1543 for (let i = 0; i < 3; i++) {1544 if (Math.abs(end[i] - expEnd[i]) > 1e-6) {1545 errors.push(`${tag}: centerline end ${end} != ${expEnd}`);1546 break;1547 }1548 }1549 // Alt branch (diamonds): same ports as the main centerline.1550 if (def.altCenterline) {1551 const aStart = segPoint(def.altCenterline[0], 0);1552 const aEnd = segPoint(def.altCenterline[def.altCenterline.length - 1], 1);1553 for (let i = 0; i < 3; i++) {1554 if (Math.abs(aStart[i] - expStart[i]) > 1e-6) {1555 errors.push(`${tag}: altCenterline start ${aStart} != ${expStart}`);1556 break;1557 }1558 }1559 for (let i = 0; i < 3; i++) {1560 if (Math.abs(aEnd[i] - expEnd[i]) > 1e-6) {1561 errors.push(`${tag}: altCenterline end ${aEnd} != ${expEnd}`);1562 break;1563 }1564 }1565 }15661567 // Centerline segment chaining (C0).1568 for (let s = 1; s < def.centerline.length; s++) {1569 const a = segPoint(def.centerline[s - 1], 1);1570 const b = segPoint(def.centerline[s], 0);1571 if (Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]) > 1e-6) {1572 errors.push(`${tag}: centerline gap at segment ${s}`);1573 }1574 }1575 // Reversing twice must round-trip.1576 if (!def.reversed) {1577 const twice = reverseDef(reverseDef(def));1578 if (1579 twice.moves !== def.moves ||1580 Math.abs(twice.deckDelta - def.deckDelta) > 1e-9 ||1581 JSON.stringify(twice.footprint.slice().sort()) !==1582 JSON.stringify(def.footprint.slice().sort())1583 ) {1584 errors.push(`${tag}: reverse(reverse(def)) != def`);1585 }1586 }1587 }1588 return errors;1589}15901591// Re-export for consumers that only need the label helper.1592export { moveLabel };