clone

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

main
marbles / src / client / pieceBench.ts
pieceBench.ts 25.7K
1/**2 * Piece bench (?gallery=1, USER diagnostic request): every WFC variant3 * of every track piece, built through the REAL pipeline — the same4 * skeleton → assemble → collision proxies → rail law the game runs, at5 * the slalom scale — so the three truths (mesh, collision, rail) can be6 * compared side by side per piece.7 *8 * - C / [collision] button: toggle collision-proxy wireframes + rail9 * station dots over every plot.10 * - Click a plot to SELECT it; [simulate] rains physics marbles down11 * the selected variant's channel (raw Box3D, no autopilot — geometry12 * answers honestly); [copy info] puts a shareable diagnostic JSON on13 * the clipboard (variant, mesh bbox, proxies, rail ports).14 *15 * Found-by-bench so far (user reports): bridge deck visuals sometimes16 * higher than collision; some turn pieces' LEFT edge mismatched.17 */18import * as THREE from "three";19import type { Assets } from "./assets.ts";20import type { SceneCtx } from "./scene.ts";21import { createCameras } from "./camera.ts";22import { fitShadowsTo } from "./scene.ts";23import { buildRegistry, type PieceDef } from "../gen/registry.ts";24import type { Skeleton, SkeletonCell } from "../gen/topology.ts";25import type { ElevationPlan } from "../gen/elevation.ts";26import { assemble } from "../gen/assemble.ts";27import { pickStyle } from "../gen/style.ts";28import { rngFamily } from "../gen/rng.ts";29import { DIR_STEP, turnLeft, turnRight } from "../gen/geom.ts";30import { type CollisionProxy, segmentProxies } from "../gen/endless/proxies.ts";31import { scaleSeg, scaleVec, TRACK_SCALE } from "../gen/endless/scale.ts";32import { instantiatePlacements, type Spinner } from "./track.ts";33import { installCua } from "./cua.ts";34import { type Rail, railForSegment } from "./physics/rail.ts";35import type { LoopPath } from "../shared/types.ts";36import {37 MARBLE_RADIUS,38 PhysicsEngine,39 type PhysicsModule,40} from "./physics/engine.ts";41import { autopilotForce, pilotFor } from "./physics/autopilot.ts";42import type { Dir } from "../shared/types.ts";4344interface Plot {45 def: PieceDef;46 group: THREE.Group;47 origin: THREE.Vector3;48 path: LoopPath;49 rail: Rail;50 proxies: CollisionProxy[];51 overlay: THREE.Group;52 labelSprite: THREE.Sprite | null;53 label: string;54 /** Finite, non-empty render bounds (T7 assertion). Phantoms pass via55 * their ghosted host mesh; false is a loud data defect. */56 meshValid: boolean;57}5859function diagnosticInfo(p: Plot) {60 const box = new THREE.Box3().setFromObject(p.group);61 box.translate(p.origin.clone().negate());62 return {63 piece: p.def.id,64 variant: {65 reversed: !!p.def.reversed,66 mirrored: !!p.def.mirrored,67 moves: p.def.moves,68 family: p.def.family,69 deckDelta: p.def.deckDelta,70 floorIn: p.def.floorIn,71 floorOut: p.def.floorOut,72 heightSpan: p.def.heightSpan,73 meshOffset: p.def.meshOffset,74 meshRot: p.def.meshRot,75 },76 meshValid: p.meshValid,77 phantom: !!p.def.noMesh,78 meshBBox: {79 min: box.min.toArray().map((v: number) => +v.toFixed(3)),80 max: box.max.toArray().map((v: number) => +v.toFixed(3)),81 },82 rail: {83 stations: p.rail.pts.length,84 entry: p.rail.pts[0],85 exit: p.rail.pts[p.rail.pts.length - 1],86 },87 proxies: p.proxies.map((pr) =>88 pr.kind === "mesh"89 ? { kind: pr.kind, verts: pr.vertices.length / 3 }90 : pr.kind === "bumper"91 ? { kind: pr.kind, center: pr.center, r: pr.radius, h: pr.height }92 : {93 kind: pr.kind,94 center: pr.center,95 half: pr.half,96 ...(pr.kind === "slab" ? { yaw: pr.yaw, pitch: pr.pitch } : {}),97 }98 ),99 scale: TRACK_SCALE,100 };101}102103/** Assemble ONE variant on a synthetic skeleton — the production path. */104function buildVariant(def: PieceDef): {105 placements: ReturnType<typeof assemble>["placements"];106 path: LoopPath;107} | null {108 const cells: SkeletonCell[] = [];109 let x = 0;110 let z = 0;111 let dir: Dir = 1; // entry frame: heading +z112 for (const m of def.moves) {113 const move = m as "F" | "L" | "R";114 const dirOut: Dir = move === "F"115 ? dir116 : move === "L"117 ? turnLeft(dir)118 : turnRight(dir);119 cells.push({ x, z, dirIn: dir, dirOut, move });120 dir = dirOut;121 x += DIR_STEP[dirOut][0];122 z += DIR_STEP[dirOut][1];123 }124 const skeleton: Skeleton = { cells, crossings: [], helixLoops: [] };125 // Step-at-start: interior cells carry the exit base (what realize/126 // assemble expect of reserved windows).127 const elevation: ElevationPlan = {128 baseY: cells.map((_, j) => (j === 0 ? 0 : def.deckDelta)),129 reservations: [],130 maxBaseY: Math.max(0, def.deckDelta),131 };132 const style = pickStyle(rngFamily("bench", "style"));133 try {134 const out = assemble(skeleton, elevation, style, [135 { start: 0, len: cells.length, def },136 ], { open: true });137 const path: LoopPath = {138 segments: out.loop.segments.map((s) => ({139 ...s,140 curve: s.curve.map(scaleSeg),141 length: s.length * TRACK_SCALE,142 // Floor-pierce centers scale with the track (LoopSegment law).143 ...(s.holes ? { holes: s.holes.map(scaleVec) } : {}),144 ...(s.alt145 ? {146 alt: {147 ...s.alt,148 curve: s.alt.curve.map(scaleSeg),149 length: s.alt.length * TRACK_SCALE,150 },151 }152 : {}),153 })),154 totalLength: out.loop.totalLength * TRACK_SCALE,155 };156 return {157 placements: out.placements.map((p) => ({158 ...p,159 pos: scaleVec(p.pos),160 scale: TRACK_SCALE,161 })),162 path,163 };164 } catch (err) {165 console.warn(`[bench] ${def.id} failed to assemble:`, err);166 return null;167 }168}169170/** Collision proxies + rail as wireframe/dot overlays (world offset). */171function buildOverlay(172 proxies: readonly CollisionProxy[],173 rail: Rail,174): THREE.Group {175 const g = new THREE.Group();176 const wire = new THREE.MeshBasicMaterial({177 color: 0x27e07a,178 wireframe: true,179 transparent: true,180 opacity: 0.55,181 depthTest: false,182 });183 for (const p of proxies) {184 if (p.kind === "mesh") {185 const geo = new THREE.BufferGeometry();186 geo.setAttribute(187 "position",188 new THREE.BufferAttribute(new Float32Array(p.vertices), 3),189 );190 geo.setIndex([...p.indices]);191 g.add(new THREE.Mesh(geo, wire));192 continue;193 }194 if (p.kind === "bumper") {195 const geo = new THREE.CylinderGeometry(p.radius, p.radius, p.height, 10);196 const m = new THREE.Mesh(geo, wire);197 m.position.set(p.center[0], p.center[1] + p.height / 2, p.center[2]);198 g.add(m);199 continue;200 }201 const geo = new THREE.BoxGeometry(202 p.half[0] * 2,203 p.half[1] * 2,204 p.half[2] * 2,205 );206 const m = new THREE.Mesh(geo, wire);207 m.position.set(p.center[0], p.center[1], p.center[2]);208 if (p.kind === "slab") {209 m.rotation.set(p.pitch, p.yaw, 0, "YXZ");210 }211 g.add(m);212 }213 // Rail stations: cyan dots at the control law's centerline.214 const dots = new THREE.BufferGeometry();215 dots.setAttribute(216 "position",217 new THREE.BufferAttribute(218 new Float32Array(rail.pts.flatMap((p) => [p[0], p[1], p[2]])),219 3,220 ),221 );222 g.add(223 new THREE.Points(224 dots,225 new THREE.PointsMaterial({226 color: 0x4fd8ff,227 size: 0.09,228 depthTest: false,229 }),230 ),231 );232 return g;233}234235export function buildPieceBench(ctx: SceneCtx, assets: Assets): void {236 const registry = buildRegistry();237 const root = new THREE.Group();238 root.name = "piece-bench";239 ctx.scene.add(root);240241 const plots: Plot[] = [];242 const spacingX = 14;243 const spacingZ = 16;244 const cols = Math.ceil(Math.sqrt(registry.variants.length * 1.4));245 const spinners: Spinner[] = [];246 const missing = new Set<string>();247248 registry.variants.forEach((def, i) => {249 const built = buildVariant(def);250 if (!built) return;251 const origin = new THREE.Vector3(252 (i % cols) * spacingX,253 0,254 Math.floor(i / cols) * spacingZ,255 );256 const group = new THREE.Group();257 group.position.copy(origin);258 instantiatePlacements(259 built.placements,260 assets,261 group,262 spinners,263 missing,264 );265 // Bench-evaluable phantoms (T7): a noMesh def (cross-over — a266 // crossing's second visit) places no mesh of its own, which left the267 // plot with NULL bounds, a stuck camera, and an unauditable row. Show268 // the HOST mesh the marble actually sees at that cell, ghosted and269 // perpendicular to the phantom's travel.270 if (def.noMesh && def.meshId) {271 const ghost = new THREE.Group();272 instantiatePlacements(273 [{274 pieceId: def.meshId,275 pos: [0, 0, 0],276 rotation: turnLeft(1),277 role: "track",278 scale: TRACK_SCALE,279 }],280 assets,281 ghost,282 spinners,283 missing,284 );285 ghost.traverse((o: THREE.Object3D) => {286 const mesh = o as THREE.Mesh;287 if (mesh.isMesh && !Array.isArray(mesh.material)) {288 const mat = mesh.material.clone();289 mat.transparent = true;290 mat.opacity = 0.45;291 mesh.material = mat;292 }293 });294 group.add(ghost);295 }296 root.add(group);297 // T7 assertion: every plot must have finite, non-empty mesh bounds —298 // a silent empty made an audit row not-evaluable.299 const bbox = new THREE.Box3().setFromObject(group);300 const meshValid = !bbox.isEmpty() &&301 Number.isFinite(bbox.min.x) && Number.isFinite(bbox.max.x);302 if (!meshValid) {303 console.error(`[bench] ${def.id}: empty/non-finite mesh bounds`);304 }305306 // ONE segment source for rail AND proxies, fed to the same builder307 // production uses (T7 parity — the bench used to omit the wide308 // scales and lied about all five wide/funnel variants).309 const src = {310 path: built.path,311 pieceFamilies: [def.family],312 windowMods: [null],313 // Def-authored port widths: an ISOLATED funnel/wide window still314 // gets its true section (production derives the same from its315 // funnel-bracketed neighbors).316 widths: [{ widthIn: def.widthIn, widthOut: def.widthOut }],317 };318 const rail = railForSegment(src);319 const proxies = segmentProxies(src);320 const overlay = buildOverlay(proxies, rail);321 overlay.position.copy(origin);322 overlay.visible = false;323 root.add(overlay);324325 const label = `${def.id}${def.reversed ? " ⟲rev" : ""}${326 def.mirrored ? " ⇋mir" : ""327 }`;328 const sprite = makeBenchLabel(label);329 if (sprite) {330 sprite.position.set(origin.x, -0.6, origin.z + 2.2);331 root.add(sprite);332 }333 plots.push({334 def,335 group,336 origin,337 path: built.path,338 rail,339 proxies,340 overlay,341 labelSprite: sprite,342 label,343 meshValid,344 });345 });346 for (const id of missing) console.warn(`[bench] missing mesh "${id}"`);347348 // ---- selection + tools ---------------------------------------------------349 let selected: Plot | null = null;350 const marker = new THREE.Mesh(351 new THREE.RingGeometry(0.5, 0.62, 24),352 new THREE.MeshBasicMaterial({ color: 0xffd75e, depthTest: false }),353 );354 marker.rotation.x = -Math.PI / 2;355 marker.visible = false;356 root.add(marker);357358 const ui = document.createElement("div");359 ui.className = "piece-bench-toolbar";360 ui.setAttribute("role", "toolbar");361 ui.setAttribute("aria-label", "Piece collision audit controls");362 Object.assign(ui.style, {363 position: "fixed",364 bottom: "14px",365 left: "50%",366 transform: "translateX(-50%)",367 display: "flex",368 gap: "8px",369 zIndex: "12",370 font: "600 12px ui-monospace, Menlo, monospace",371 });372 const mkBtn = (text: string, fn: () => void): HTMLButtonElement => {373 const b = document.createElement("button");374 b.textContent = text;375 Object.assign(b.style, {376 background: "rgba(10,15,26,0.8)",377 color: "#dce6f5",378 border: "1px solid rgba(220,230,245,0.4)",379 padding: "6px 12px",380 cursor: "pointer",381 font: "inherit",382 });383 b.addEventListener("click", fn);384 ui.appendChild(b);385 return b;386 };387 const prevBtn = mkBtn("◀", () => {});388 prevBtn.setAttribute("aria-label", "Previous piece");389 prevBtn.title = "Previous piece";390 const nextBtn = mkBtn("▶", () => {});391 nextBtn.setAttribute("aria-label", "Next piece");392 nextBtn.title = "Next piece";393 const jumpInput = document.createElement("input");394 jumpInput.placeholder = "piece # or id…";395 jumpInput.setAttribute("aria-label", "Piece number or id");396 jumpInput.autocomplete = "off";397 jumpInput.spellcheck = false;398 Object.assign(jumpInput.style, {399 background: "rgba(10,15,26,0.8)",400 color: "#dce6f5",401 border: "1px solid rgba(220,230,245,0.4)",402 padding: "6px 8px",403 width: "130px",404 font: "inherit",405 });406 ui.appendChild(jumpInput);407 const status = document.createElement("span");408 status.setAttribute("role", "status");409 status.setAttribute("aria-live", "polite");410 status.setAttribute("aria-label", "Selected piece");411 Object.assign(status.style, {412 color: "#dce6f5",413 alignSelf: "center",414 padding: "0 8px",415 // Stable toolbar (USER): the piece name sits in a FIXED-width center416 // slot so the outer buttons never shift as names change length.417 width: "240px",418 textAlign: "center",419 whiteSpace: "nowrap",420 overflow: "hidden",421 textOverflow: "ellipsis",422 flex: "0 0 auto",423 });424 status.textContent = "click a piece";425 ui.appendChild(status);426 document.body.appendChild(ui);427428 // CUA-readable selected-piece facts. This intentionally stays compact so it429 // does not cover the mesh while keeping the exact proxy composition in the430 // accessibility tree and in screenshots used for collision audits.431 const auditFacts = document.createElement("pre");432 auditFacts.className = "piece-bench-facts";433 auditFacts.setAttribute("aria-label", "Selected piece collision facts");434 Object.assign(auditFacts.style, {435 position: "fixed",436 right: "12px",437 bottom: "58px",438 zIndex: "12",439 margin: "0",440 maxWidth: "min(420px, 42vw)",441 padding: "7px 9px",442 background: "rgba(10,15,26,0.82)",443 color: "#b9c8dd",444 border: "1px solid rgba(220,230,245,0.25)",445 font: "500 10px/1.35 ui-monospace, Menlo, monospace",446 whiteSpace: "pre-wrap",447 pointerEvents: "none",448 });449 document.body.appendChild(auditFacts);450451 // Keep the diagnostic controls usable on phone-sized windows. The bench452 // is an audit surface, so clipped controls defeat its primary purpose.453 const responsiveStyle = document.createElement("style");454 responsiveStyle.textContent = `455 @media (max-width: 600px) {456 .piece-bench-toolbar {457 left: 8px !important;458 right: 8px !important;459 bottom: 8px !important;460 width: auto !important;461 transform: none !important;462 flex-wrap: wrap !important;463 justify-content: center !important;464 }465 .piece-bench-toolbar [role="status"] {466 order: -1;467 width: auto !important;468 flex: 1 0 calc(100% - 16px) !important;469 }470 .piece-bench-toolbar input {471 width: auto !important;472 min-width: 90px;473 flex: 1 1 120px;474 }475 .piece-bench-facts {476 left: 8px !important;477 right: 8px !important;478 bottom: 118px !important;479 max-width: none !important;480 max-height: 25vh;481 overflow: hidden;482 }483 }484 `;485 document.head.appendChild(responsiveStyle);486487 let showCollision = false;488 const collisionBtn = mkBtn("collision: off", () => {489 showCollision = !showCollision;490 collisionBtn.textContent = `collision: ${showCollision ? "on" : "off"}`;491 for (const p of plots) p.overlay.visible = showCollision;492 });493494 // Marble sim on the selected plot (raw physics — no autopilot; the495 // geometry answers honestly).496 let engine: PhysicsEngine | null = null;497 let physModule: PhysicsModule | null = null;498 let simPlot: Plot | null = null;499 let simT = 0;500 const simMarbles: THREE.Mesh[] = [];501 const marbleMat = new THREE.MeshStandardMaterial({502 color: 0x9adcff,503 roughness: 0.3,504 });505 const simNearest: number[] = [];506 const clearSim = () => {507 engine?.dispose();508 engine = null;509 for (const m of simMarbles) root.remove(m);510 simMarbles.length = 0;511 simNearest.length = 0;512 simPlot = null;513 };514 const simSpawn = (i: number) => {515 if (!engine || !simPlot) return;516 const rail = simPlot.rail;517 const t0 = rail.tans[0];518 const lane = (i - 2) * 0.12;519 const nl = Math.hypot(t0[2], t0[0]) || 1;520 engine.spawnMarble(i, [521 rail.pts[0][0] + (t0[2] / nl) * lane,522 rail.pts[0][1] + 0.3 + i * 0.05,523 rail.pts[0][2] + (-t0[0] / nl) * lane,524 ], [t0[0] * 2.2, 0, t0[2] * 2.2]);525 simNearest[i] = 0;526 };527 mkBtn("simulate", async () => {528 if (!selected) {529 status.textContent = "select a piece first";530 return;531 }532 if (!physModule) {533 try {534 const factory =535 (await import(new URL("/dist/box3d.mjs", location.href).href))536 .default as () => Promise<PhysicsModule>;537 physModule = await factory();538 } catch (err) {539 status.textContent = "physics unavailable";540 console.warn("[bench] physics module:", err);541 return;542 }543 }544 clearSim();545 engine = new PhysicsEngine(physModule);546 // Proxies live in plot-local space — the engine world IS this plot.547 engine.addSegment(0, selected.proxies);548 simPlot = selected;549 for (let i = 0; i < 5; i++) {550 simSpawn(i);551 const mesh = new THREE.Mesh(552 new THREE.SphereGeometry(MARBLE_RADIUS, 14, 10),553 marbleMat,554 );555 root.add(mesh);556 simMarbles.push(mesh);557 }558 simT = 0;559 status.textContent = `simulating ${selected.label}`;560 });561562 mkBtn("copy info", () => {563 if (!selected) {564 status.textContent = "select a piece first";565 return;566 }567 const p = selected;568 const info = diagnosticInfo(p);569 navigator.clipboard?.writeText(JSON.stringify(info, null, 2))570 .then(() => status.textContent = `copied ${p.label}`)571 .catch(() => status.textContent = "clipboard blocked");572 });573574 // Pick a plot by nearest origin on click.575 let onPick: (plot: Plot) => void = () => {};576 const ray = new THREE.Raycaster();577 const ndc = new THREE.Vector2();578 ctx.renderer.domElement.addEventListener("pointerdown", (e: PointerEvent) => {579 ndc.set(580 (e.clientX / innerWidth) * 2 - 1,581 -(e.clientY / innerHeight) * 2 + 1,582 );583 ray.setFromCamera(ndc, ctx.camera);584 const hits = ray.intersectObjects(585 plots.map((p) => p.group),586 true,587 );588 let plot: Plot | undefined;589 if (hits.length > 0) {590 let obj: THREE.Object3D | null = hits[0].object;591 while (obj && !plots.some((p) => p.group === obj)) obj = obj.parent;592 plot = plots.find((p) => p.group === obj);593 } else {594 // Miss-tolerant: intersect the ground plane, take the nearest plot595 // origin within half a grid cell.596 const t = -ray.ray.origin.y / ray.ray.direction.y;597 if (!(t > 0)) return;598 const gx = ray.ray.origin.x + ray.ray.direction.x * t;599 const gz = ray.ray.origin.z + ray.ray.direction.z * t;600 let best = Infinity;601 for (const p of plots) {602 const d = Math.hypot(p.origin.x - gx, p.origin.z - gz);603 if (d < best) {604 best = d;605 plot = p;606 }607 }608 if (best > 8) return;609 }610 if (!plot) return;611 onPick(plot);612 });613614 // Sim stepping: the REAL wishdir autopilot steers each marble across615 // the piece (USER: raw physics just fell into the void on curves) —616 // nearest station on the plot's rail, autopilotForce, per-marble617 // personality. Fallen marbles respawn at the entry, so the piece gets618 // continuous traffic for the whole run.619 ctx.onFrame.add((dt) => {620 if (!engine || !simPlot) return;621 simT += dt;622 const rail = simPlot.rail;623 engine.step(Math.min(dt, 0.05), () => {624 for (let i = 0; i < simMarbles.length; i++) {625 const st = engine!.state(i);626 let best = simNearest[i] ?? 0;627 let bestD = Infinity;628 const lo = Math.max(0, best - 20);629 const hi = Math.min(rail.pts.length - 1, best + 30);630 for (let k = lo; k <= hi; k++) {631 const r = rail.pts[k];632 const d = (r[0] - st.pos[0]) ** 2 + (r[1] - st.pos[1]) ** 2 +633 (r[2] - st.pos[2]) ** 2;634 if (d < bestD) {635 bestD = d;636 best = k;637 }638 }639 simNearest[i] = best;640 const f = autopilotForce(641 rail,642 best,643 st.pos,644 st.vel,645 engine!.massOf(i),646 null,647 pilotFor("bench", i),648 );649 if (f) engine!.force(i, f[0], f[1], f[2]);650 }651 });652 const floorY = rail.pts.reduce((a, p) => Math.min(a, p[1]), Infinity);653 for (let i = 0; i < simMarbles.length; i++) {654 const st = engine.state(i);655 if (st.pos[1] < floorY - 4) simSpawn(i);656 simMarbles[i].position.set(657 simPlot.origin.x + st.pos[0],658 simPlot.origin.y + st.pos[1],659 simPlot.origin.z + st.pos[2],660 );661 simMarbles[i].quaternion.set(...st.quat);662 }663 if (simT > 15) clearSim();664 });665666 // 'x' toggles collision ('c' belongs to the camera-mode toggle).667 addEventListener("keydown", (ev) => {668 if (ev.key === "x" || ev.key === "X") collisionBtn.click();669 });670671 const box = new THREE.Box3().setFromObject(root);672 fitShadowsTo(ctx.sun, box);673 ctx.scene.fog = null;674 // Dark void backdrop (USER): pieces, collision wires and rail dots675 // read far better than on the daylight sky.676 ctx.scene.background = new THREE.Color(0x0b1018);677 const cams = createCameras(ctx, null, null);678 ctx.onFrame.add((dt) => cams.update(dt));679680 // ---- single-piece focus (USER): orbit cam per piece + navigation ------681 let current = -1;682 const focus = (i: number) => {683 if (plots.length === 0) return;684 current = ((i % plots.length) + plots.length) % plots.length;685 const plot = plots[current];686 const compact = innerWidth <= 600;687 for (const p of plots) {688 if (p.labelSprite) p.labelSprite.visible = !compact;689 }690 selected = plot;691 marker.visible = true;692 marker.position.set(plot.origin.x, 0.03, plot.origin.z);693 const pbox = new THREE.Box3().setFromObject(plot.group);694 if (!compact && plot.labelSprite) pbox.expandByObject(plot.labelSprite);695 // Empty bounds must not strand the camera on the previous piece696 // (the cross-over audit blocker) — frame the plot origin instead.697 if (pbox.isEmpty()) {698 pbox.setFromCenterAndSize(699 plot.origin.clone().add(new THREE.Vector3(0, 1, 0)),700 new THREE.Vector3(6, 4, 6),701 );702 }703 // A one-cell piece frames too tight for orbit's min distance — pad.704 pbox.expandByScalar(1.2);705 cams.frame(pbox);706 jumpInput.value = String(current + 1);707 status.textContent = `${current + 1}/${plots.length} ${plot.label}`;708 const info = diagnosticInfo(plot);709 const proxyKinds = info.proxies.map((p) =>710 "verts" in p ? `mesh(${p.verts}v)` : p.kind711 ).join(", ");712 auditFacts.textContent = [713 `piece ${current + 1}/${plots.length}: ${plot.label}`,714 `family=${info.variant.family} moves=${info.variant.moves}`,715 `meshBBox=${JSON.stringify(info.meshBBox)}`,716 `proxies=${proxyKinds || "none"} railStations=${info.rail.stations}`,717 ...(info.phantom718 ? ["phantom (noMesh) — ghosted HOST mesh shown for comparison"]719 : []),720 ...(info.meshValid ? [] : ["⚠ MESH BOUNDS EMPTY — not evaluable"]),721 ].join("\n");722 };723 const jump = (q: string): boolean => {724 const needle = q.trim().toLowerCase();725 if (!needle) return false;726 if (/^\d+$/.test(needle)) {727 const ordinal = Number(needle);728 if (ordinal >= 1 && ordinal <= plots.length) {729 focus(ordinal - 1);730 return true;731 }732 status.textContent = `piece number must be 1–${plots.length}`;733 return false;734 }735 const at = plots.findIndex((p) => p.label.toLowerCase().includes(needle));736 if (at < 0) {737 status.textContent = `no piece matching "${q}"`;738 return false;739 }740 focus(at);741 return true;742 };743 prevBtn.addEventListener("click", () => focus(current - 1));744 nextBtn.addEventListener("click", () => focus(current + 1));745 jumpInput.addEventListener("keydown", (ev) => {746 if (ev.key === "Enter") jump(jumpInput.value);747 ev.stopPropagation();748 });749 addEventListener("keydown", (ev) => {750 if (document.activeElement === jumpInput) return;751 if (ev.key === "ArrowLeft") focus(current - 1);752 if (ev.key === "ArrowRight") focus(current + 1);753 });754 addEventListener("resize", () => focus(current));755 // Clicking a plot focuses it too.756 onPick = (plot) => focus(plots.indexOf(plot));757758 // CUA path: scripted navigation + bench state for harnesses.759 (globalThis as unknown as Record<string, unknown>).benchGo = (d: number) =>760 focus(current + (d | 0));761 (globalThis as unknown as Record<string, unknown>).benchJump = (id: string) =>762 jump(String(id));763 installCua(ctx, () => ({764 mode: "bench",765 index: current,766 total: plots.length,767 piece: current >= 0 ? plots[current].label : null,768 collision: showCollision,769 simulating: simPlot ? simPlot.label : null,770 diagnostic: current >= 0 ? diagnosticInfo(plots[current]) : null,771 }));772773 focus(0);774}775776function makeBenchLabel(text: string): THREE.Sprite | null {777 const canvas = document.createElement("canvas");778 const g = canvas.getContext("2d");779 if (!g) return null;780 canvas.width = 512;781 canvas.height = 56;782 g.font = "600 30px ui-monospace, Menlo, monospace";783 g.textAlign = "center";784 g.textBaseline = "middle";785 g.fillStyle = "#e8eef8";786 g.fillText(text, 256, 28);787 const tex = new THREE.CanvasTexture(canvas);788 tex.colorSpace = THREE.SRGBColorSpace;789 const sprite = new THREE.Sprite(790 new THREE.SpriteMaterial({791 map: tex,792 transparent: true,793 depthWrite: false,794 }),795 );796 sprite.scale.set(6.4, 0.7, 1);797 return sprite;798}