clone

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

Piece bench: gallery is now a mesh/collision/rail diagnostic rig

Dami committed 195570d
USER request (two mesh-vs-collision mismatches spotted in the wild):
?gallery=1 now builds EVERY registry variant (base/reversed/mirrored)
of every track piece through the REAL pipeline — synthetic one-window
skeleton -> assemble -> channelProxies -> railForSegment at the slalom
scale — so the three truths (kit mesh, collision ribbon, rail line)
sit side by side per piece.

- [collision] / 'x': green proxy wireframes + cyan rail-station dots
  over every plot.
- Click a plot to select (miss-tolerant: nearest plot within half a
  cell); [simulate] rains 5 raw Box3D marbles down the selected
  variant's channel (no autopilot — geometry answers honestly);
  [copy info] puts a shareable diagnostic JSON on the clipboard
  (variant flags, mesh bbox, proxy inventory, rail ports).

Open bugs to chase with it: bridge deck visuals sometimes higher than
collision; some turn pieces' LEFT edge mismatched.
commit
195570d3abe2c86f8461b20b80e06fffbb308c27
parent
d49e512270684922fe9a5ed5de88814ac94a1535
author
Dami <dami.etoile@gmail.com>
2 files changed, +513 -1
Msrc/client/main.ts3+-
Asrc/client/pieceBench.ts511++++++++++++++++++++++++++++++
M src/client/main.ts +2 -1
A src/client/pieceBench.ts +511 -0
@@ -0,0 +1,511 @@
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+ */18+import * as THREE from "three";19+import type { Assets } from "./assets.ts";20+import type { SceneCtx } from "./scene.ts";21+import { createCameras } from "./camera.ts";22+import { fitShadowsTo } from "./scene.ts";23+import { buildRegistry, type PieceDef } from "../gen/registry.ts";24+import type { Skeleton, SkeletonCell } from "../gen/topology.ts";25+import type { ElevationPlan } from "../gen/elevation.ts";26+import { assemble } from "../gen/assemble.ts";27+import { pickStyle } from "../gen/style.ts";28+import { rngFamily } from "../gen/rng.ts";29+import { DIR_STEP, turnLeft, turnRight } from "../gen/geom.ts";30+import { channelProxies, type CollisionProxy } from "../gen/endless/proxies.ts";31+import { scaleSeg, scaleVec, TRACK_SCALE } from "../gen/endless/scale.ts";32+import { instantiatePlacements, type Spinner } from "./track.ts";33+import { gapSegmentsOf, type Rail, railForSegment } from "./physics/rail.ts";34+import type { LoopPath } from "../shared/types.ts";35+import {36+ MARBLE_RADIUS,37+ PhysicsEngine,38+ type PhysicsModule,39+} from "./physics/engine.ts";40+import type { Dir } from "../shared/types.ts";41+42+interface Plot {43+ def: PieceDef;44+ group: THREE.Group;45+ origin: THREE.Vector3;46+ path: LoopPath;47+ rail: Rail;48+ proxies: CollisionProxy[];49+ overlay: THREE.Group;50+ label: string;51+}52+53+/** Assemble ONE variant on a synthetic skeleton — the production path. */54+function buildVariant(def: PieceDef): {55+ placements: ReturnType<typeof assemble>["placements"];56+ path: LoopPath;57+} | null {58+ const cells: SkeletonCell[] = [];59+ let x = 0;60+ let z = 0;61+ let dir: Dir = 1; // entry frame: heading +z62+ for (const m of def.moves) {63+ const move = m as "F" | "L" | "R";64+ const dirOut: Dir = move === "F"65+ ? dir66+ : move === "L"67+ ? turnLeft(dir)68+ : turnRight(dir);69+ cells.push({ x, z, dirIn: dir, dirOut, move });70+ dir = dirOut;71+ x += DIR_STEP[dirOut][0];72+ z += DIR_STEP[dirOut][1];73+ }74+ const skeleton: Skeleton = { cells, crossings: [], helixLoops: [] };75+ // Step-at-start: interior cells carry the exit base (what realize/76+ // assemble expect of reserved windows).77+ const elevation: ElevationPlan = {78+ baseY: cells.map((_, j) => (j === 0 ? 0 : def.deckDelta)),79+ reservations: [],80+ maxBaseY: Math.max(0, def.deckDelta),81+ };82+ const style = pickStyle(rngFamily("bench", "style"));83+ try {84+ const out = assemble(skeleton, elevation, style, [85+ { start: 0, len: cells.length, def },86+ ], { open: true });87+ const path: LoopPath = {88+ segments: out.loop.segments.map((s) => ({89+ ...s,90+ curve: s.curve.map(scaleSeg),91+ length: s.length * TRACK_SCALE,92+ ...(s.alt93+ ? {94+ alt: {95+ ...s.alt,96+ curve: s.alt.curve.map(scaleSeg),97+ length: s.alt.length * TRACK_SCALE,98+ },99+ }100+ : {}),101+ })),102+ totalLength: out.loop.totalLength * TRACK_SCALE,103+ };104+ return {105+ placements: out.placements.map((p) => ({106+ ...p,107+ pos: scaleVec(p.pos),108+ scale: TRACK_SCALE,109+ })),110+ path,111+ };112+ } catch (err) {113+ console.warn(`[bench] ${def.id} failed to assemble:`, err);114+ return null;115+ }116+}117+118+/** Collision proxies + rail as wireframe/dot overlays (world offset). */119+function buildOverlay(120+ proxies: readonly CollisionProxy[],121+ rail: Rail,122+): THREE.Group {123+ const g = new THREE.Group();124+ const wire = new THREE.MeshBasicMaterial({125+ color: 0x27e07a,126+ wireframe: true,127+ transparent: true,128+ opacity: 0.55,129+ depthTest: false,130+ });131+ for (const p of proxies) {132+ if (p.kind === "mesh") {133+ const geo = new THREE.BufferGeometry();134+ geo.setAttribute(135+ "position",136+ new THREE.BufferAttribute(new Float32Array(p.vertices), 3),137+ );138+ geo.setIndex([...p.indices]);139+ g.add(new THREE.Mesh(geo, wire));140+ continue;141+ }142+ if (p.kind === "bumper") {143+ const geo = new THREE.CylinderGeometry(p.radius, p.radius, p.height, 10);144+ const m = new THREE.Mesh(geo, wire);145+ m.position.set(p.center[0], p.center[1] + p.height / 2, p.center[2]);146+ g.add(m);147+ continue;148+ }149+ const geo = new THREE.BoxGeometry(150+ p.half[0] * 2,151+ p.half[1] * 2,152+ p.half[2] * 2,153+ );154+ const m = new THREE.Mesh(geo, wire);155+ m.position.set(p.center[0], p.center[1], p.center[2]);156+ if (p.kind === "slab") {157+ m.rotation.set(p.pitch, p.yaw, 0, "YXZ");158+ }159+ g.add(m);160+ }161+ // Rail stations: cyan dots at the control law's centerline.162+ const dots = new THREE.BufferGeometry();163+ dots.setAttribute(164+ "position",165+ new THREE.BufferAttribute(166+ new Float32Array(rail.pts.flatMap((p) => [p[0], p[1], p[2]])),167+ 3,168+ ),169+ );170+ g.add(171+ new THREE.Points(172+ dots,173+ new THREE.PointsMaterial({174+ color: 0x4fd8ff,175+ size: 0.09,176+ depthTest: false,177+ }),178+ ),179+ );180+ return g;181+}182+183+export function buildPieceBench(ctx: SceneCtx, assets: Assets): void {184+ const registry = buildRegistry();185+ const root = new THREE.Group();186+ root.name = "piece-bench";187+ ctx.scene.add(root);188+189+ const plots: Plot[] = [];190+ const spacingX = 14;191+ const spacingZ = 16;192+ const cols = Math.ceil(Math.sqrt(registry.variants.length * 1.4));193+ const spinners: Spinner[] = [];194+ const missing = new Set<string>();195+196+ registry.variants.forEach((def, i) => {197+ const built = buildVariant(def);198+ if (!built) return;199+ const origin = new THREE.Vector3(200+ (i % cols) * spacingX,201+ 0,202+ Math.floor(i / cols) * spacingZ,203+ );204+ const group = new THREE.Group();205+ group.position.copy(origin);206+ instantiatePlacements(207+ built.placements,208+ assets,209+ group,210+ spinners,211+ missing,212+ );213+ root.add(group);214+215+ const rail = railForSegment({216+ path: built.path,217+ pieceFamilies: [def.family],218+ windowMods: [null],219+ });220+ const proxies = channelProxies(221+ built.path,222+ true,223+ gapSegmentsOf({224+ path: built.path,225+ pieceFamilies: [def.family],226+ windowMods: [null],227+ }),228+ );229+ const overlay = buildOverlay(proxies, rail);230+ overlay.position.copy(origin);231+ overlay.visible = false;232+ root.add(overlay);233+234+ const label = `${def.id}${def.reversed ? " ⟲rev" : ""}${235+ def.mirrored ? " ⇋mir" : ""236+ }`;237+ const sprite = makeBenchLabel(label);238+ if (sprite) {239+ sprite.position.set(origin.x, -0.6, origin.z + 2.2);240+ root.add(sprite);241+ }242+ plots.push({243+ def,244+ group,245+ origin,246+ path: built.path,247+ rail,248+ proxies,249+ overlay,250+ label,251+ });252+ });253+ for (const id of missing) console.warn(`[bench] missing mesh "${id}"`);254+255+ // ---- selection + tools ---------------------------------------------------256+ let selected: Plot | null = null;257+ const marker = new THREE.Mesh(258+ new THREE.RingGeometry(0.5, 0.62, 24),259+ new THREE.MeshBasicMaterial({ color: 0xffd75e, depthTest: false }),260+ );261+ marker.rotation.x = -Math.PI / 2;262+ marker.visible = false;263+ root.add(marker);264+265+ const ui = document.createElement("div");266+ Object.assign(ui.style, {267+ position: "fixed",268+ bottom: "14px",269+ left: "50%",270+ transform: "translateX(-50%)",271+ display: "flex",272+ gap: "8px",273+ zIndex: "12",274+ font: "600 12px ui-monospace, Menlo, monospace",275+ });276+ const mkBtn = (text: string, fn: () => void): HTMLButtonElement => {277+ const b = document.createElement("button");278+ b.textContent = text;279+ Object.assign(b.style, {280+ background: "rgba(10,15,26,0.8)",281+ color: "#dce6f5",282+ border: "1px solid rgba(220,230,245,0.4)",283+ padding: "6px 12px",284+ cursor: "pointer",285+ font: "inherit",286+ });287+ b.addEventListener("click", fn);288+ ui.appendChild(b);289+ return b;290+ };291+ const status = document.createElement("span");292+ Object.assign(status.style, {293+ color: "#dce6f5",294+ alignSelf: "center",295+ padding: "0 8px",296+ });297+ status.textContent = "click a piece";298+ ui.appendChild(status);299+ document.body.appendChild(ui);300+301+ let showCollision = false;302+ const collisionBtn = mkBtn("collision: off", () => {303+ showCollision = !showCollision;304+ collisionBtn.textContent = `collision: ${showCollision ? "on" : "off"}`;305+ for (const p of plots) p.overlay.visible = showCollision;306+ });307+308+ // Marble sim on the selected plot (raw physics — no autopilot; the309+ // geometry answers honestly).310+ let engine: PhysicsEngine | null = null;311+ let physModule: PhysicsModule | null = null;312+ let simPlot: Plot | null = null;313+ let simT = 0;314+ const simMarbles: THREE.Mesh[] = [];315+ const marbleMat = new THREE.MeshStandardMaterial({316+ color: 0x9adcff,317+ roughness: 0.3,318+ });319+ const clearSim = () => {320+ for (const m of simMarbles) root.remove(m);321+ simMarbles.length = 0;322+ simPlot = null;323+ };324+ mkBtn("simulate", async () => {325+ if (!selected) {326+ status.textContent = "select a piece first";327+ return;328+ }329+ if (!physModule) {330+ try {331+ const factory =332+ (await import(new URL("/dist/box3d.mjs", location.href).href))333+ .default as () => Promise<PhysicsModule>;334+ physModule = await factory();335+ } catch (err) {336+ status.textContent = "physics unavailable";337+ console.warn("[bench] physics module:", err);338+ return;339+ }340+ }341+ clearSim();342+ engine = new PhysicsEngine(physModule);343+ // Proxies live in plot-local space — the engine world IS this plot.344+ engine.addSegment(0, selected.proxies);345+ const rail = selected.rail;346+ const t0 = rail.tans[0];347+ for (let i = 0; i < 5; i++) {348+ const lane = (i - 2) * 0.12;349+ const nl = Math.hypot(t0[2], t0[0]) || 1;350+ engine.spawnMarble(i, [351+ rail.pts[0][0] + (t0[2] / nl) * lane,352+ rail.pts[0][1] + 0.3 + i * 0.05,353+ rail.pts[0][2] + (-t0[0] / nl) * lane,354+ ], [t0[0] * 3, 0, t0[2] * 3]);355+ const mesh = new THREE.Mesh(356+ new THREE.SphereGeometry(MARBLE_RADIUS, 14, 10),357+ marbleMat,358+ );359+ root.add(mesh);360+ simMarbles.push(mesh);361+ }362+ simPlot = selected;363+ simT = 0;364+ status.textContent = `simulating ${selected.label}`;365+ });366+367+ mkBtn("copy info", () => {368+ if (!selected) {369+ status.textContent = "select a piece first";370+ return;371+ }372+ const p = selected;373+ const box = new THREE.Box3().setFromObject(p.group);374+ box.translate(p.origin.clone().negate());375+ const info = {376+ piece: p.def.id,377+ variant: {378+ reversed: !!p.def.reversed,379+ mirrored: !!p.def.mirrored,380+ moves: p.def.moves,381+ family: p.def.family,382+ deckDelta: p.def.deckDelta,383+ floorIn: p.def.floorIn,384+ floorOut: p.def.floorOut,385+ heightSpan: p.def.heightSpan,386+ meshOffset: p.def.meshOffset,387+ meshRot: p.def.meshRot,388+ },389+ meshBBox: {390+ min: box.min.toArray().map((v: number) => +v.toFixed(3)),391+ max: box.max.toArray().map((v: number) => +v.toFixed(3)),392+ },393+ rail: {394+ stations: p.rail.pts.length,395+ entry: p.rail.pts[0],396+ exit: p.rail.pts[p.rail.pts.length - 1],397+ },398+ proxies: p.proxies.map((pr) =>399+ pr.kind === "mesh"400+ ? { kind: pr.kind, verts: pr.vertices.length / 3 }401+ : pr.kind === "bumper"402+ ? { kind: pr.kind, center: pr.center, r: pr.radius, h: pr.height }403+ : {404+ kind: pr.kind,405+ center: pr.center,406+ half: pr.half,407+ ...(pr.kind === "slab" ? { yaw: pr.yaw, pitch: pr.pitch } : {}),408+ }409+ ),410+ scale: TRACK_SCALE,411+ };412+ navigator.clipboard?.writeText(JSON.stringify(info, null, 2))413+ .then(() => status.textContent = `copied ${p.label}`)414+ .catch(() => status.textContent = "clipboard blocked");415+ });416+417+ // Pick a plot by nearest origin on click.418+ const ray = new THREE.Raycaster();419+ const ndc = new THREE.Vector2();420+ ctx.renderer.domElement.addEventListener("pointerdown", (e: PointerEvent) => {421+ ndc.set(422+ (e.clientX / innerWidth) * 2 - 1,423+ -(e.clientY / innerHeight) * 2 + 1,424+ );425+ ray.setFromCamera(ndc, ctx.camera);426+ const hits = ray.intersectObjects(427+ plots.map((p) => p.group),428+ true,429+ );430+ let plot: Plot | undefined;431+ if (hits.length > 0) {432+ let obj: THREE.Object3D | null = hits[0].object;433+ while (obj && !plots.some((p) => p.group === obj)) obj = obj.parent;434+ plot = plots.find((p) => p.group === obj);435+ } else {436+ // Miss-tolerant: intersect the ground plane, take the nearest plot437+ // origin within half a grid cell.438+ const t = -ray.ray.origin.y / ray.ray.direction.y;439+ if (!(t > 0)) return;440+ const gx = ray.ray.origin.x + ray.ray.direction.x * t;441+ const gz = ray.ray.origin.z + ray.ray.direction.z * t;442+ let best = Infinity;443+ for (const p of plots) {444+ const d = Math.hypot(p.origin.x - gx, p.origin.z - gz);445+ if (d < best) {446+ best = d;447+ plot = p;448+ }449+ }450+ if (best > 8) return;451+ }452+ if (!plot) return;453+ selected = plot;454+ marker.visible = true;455+ marker.position.set(plot.origin.x, 0.03, plot.origin.z);456+ status.textContent = plot.label;457+ });458+459+ // Sim stepping + marble mirroring.460+ ctx.onFrame.add((dt) => {461+ if (!engine || !simPlot) return;462+ simT += dt;463+ engine.step(Math.min(dt, 0.05));464+ for (let i = 0; i < simMarbles.length; i++) {465+ const st = engine.state(i);466+ simMarbles[i].position.set(467+ simPlot.origin.x + st.pos[0],468+ simPlot.origin.y + st.pos[1],469+ simPlot.origin.z + st.pos[2],470+ );471+ simMarbles[i].quaternion.set(...st.quat);472+ }473+ if (simT > 12) clearSim();474+ });475+476+ // 'x' toggles collision ('c' belongs to the camera-mode toggle).477+ addEventListener("keydown", (ev) => {478+ if (ev.key === "x" || ev.key === "X") collisionBtn.click();479+ });480+481+ const box = new THREE.Box3().setFromObject(root);482+ fitShadowsTo(ctx.sun, box);483+ ctx.scene.fog = null;484+ const cams = createCameras(ctx, null, null);485+ cams.frame(box);486+ ctx.onFrame.add((dt) => cams.update(dt));487+}488+489+function makeBenchLabel(text: string): THREE.Sprite | null {490+ const canvas = document.createElement("canvas");491+ const g = canvas.getContext("2d");492+ if (!g) return null;493+ canvas.width = 512;494+ canvas.height = 56;495+ g.font = "600 30px ui-monospace, Menlo, monospace";496+ g.textAlign = "center";497+ g.textBaseline = "middle";498+ g.fillStyle = "#e8eef8";499+ g.fillText(text, 256, 28);500+ const tex = new THREE.CanvasTexture(canvas);501+ tex.colorSpace = THREE.SRGBColorSpace;502+ const sprite = new THREE.Sprite(503+ new THREE.SpriteMaterial({504+ map: tex,505+ transparent: true,506+ depthWrite: false,507+ }),508+ );509+ sprite.scale.set(6.4, 0.7, 1);510+ return sprite;511+}