1/**2 * Track → scene graph.3 *4 * Placement conventions (must match src/gen):5 * - `pos` is the world position of the piece's authored origin (bottom-center).6 * - `rotation` r ∈ 0..3 → `rotation.y = r · π/2`.7 * - `scale` is uniform and decor-only; `mirrored` flips x.8 */9import * as THREE from "three";10import type { Placement, Track } from "../shared/types.ts";11import type { Assets } from "./assets.ts";12import { drawPipeColor } from "./pipeColor.ts";1314export interface Spinner {15 obj: THREE.Object3D;16 axis: THREE.Vector3;17 radPerSec: number;18}1920/**21 * Instantiate placements into `group` (shared by finite tracks and endless22 * segments). Returns materials cloned for goal tinting — the caller owns23 * their disposal; piece clones share the loaded kits' resources.24 */25export function instantiatePlacements(26 placements: readonly Placement[],27 assets: Assets,28 group: THREE.Group,29 spinners: Spinner[],30 missing: Set<string>,31 /** Per-placement pipe color (endless island chapters); null = stock. */32 pipeColorFor?: (p: Placement) => readonly [number, number, number] | null,33): THREE.Material[] {34 const goalMaterials: THREE.Material[] = [];35 for (const [index, p] of placements.entries()) {36 const obj = assets.getPiece(p.pieceId);37 if (!obj) {38 missing.add(p.pieceId);39 continue;40 }41 // Inspect mode resolves picked meshes back to their placement.42 obj.userData.placementIndex = index;43 // Endless follow-camera collision uses the authored semantic role to44 // avoid track walls/floors while ignoring support and scenery clutter.45 obj.userData.placementRole = p.role;46 if (!p.goal && pipeColorFor) {47 assets.applyPipeColor(obj, pipeColorFor(p));48 }49 obj.position.set(p.pos[0], p.pos[1], p.pos[2]);50 obj.rotation.y = p.rotation * Math.PI / 2;51 const s = p.scale ?? 1;52 obj.scale.setScalar(s);53 if (p.mirrored) obj.scale.x = -s;54 if (p.goal) {55 // Lap-line gate: charcoal posts + a checkered cloth slab — a finish56 // flag against any palette.57 obj.traverse((child: THREE.Object3D) => {58 const mesh = child as THREE.Mesh;59 if (!mesh.isMesh) return;60 const mats = Array.isArray(mesh.material)61 ? mesh.material62 : [mesh.material];63 const tinted = mats.map((m: THREE.Material) => {64 const c = (m as THREE.MeshStandardMaterial).clone();65 c.color.set(0x30303a);66 goalMaterials.push(c);67 return c;68 });69 mesh.material = Array.isArray(mesh.material) ? tinted : tinted[0];70 });71 const checker = makeGoalChecker();72 if (checker) {73 obj.add(checker.mesh);74 goalMaterials.push(checker.material);75 }76 }77 group.add(obj);7879 if (p.spin) {80 // Spin only the blade sub-node when the piece has one (fan pieces have81 // a child authored "fan" at the hub — GLTFLoader uniquifies duplicate82 // node names across the file, so it loads as "fan", "fan_1", "fan_2"…).83 // Spinning the whole placement would tumble the housing.84 let target: THREE.Object3D = obj;85 obj.traverse((child: THREE.Object3D) => {86 if (child !== obj && /^fan(_\d+)?$/.test(child.name)) target = child;87 });88 // The planner says [0,0,1] for every fan, but only standing fans have89 // their blade disc in the x/y plane. Table fans (fan-two/-four,90 // fan-simple-*) lie flat with the disc in x/z and must spin about91 // local +y. The blade's own bbox tells them apart: its thin dimension92 // IS the hub axis.93 const axis = new THREE.Vector3(94 p.spin.axis[0],95 p.spin.axis[1],96 p.spin.axis[2],97 ).normalize();98 const blade = target as THREE.Mesh;99 if (target !== obj && blade.isMesh && blade.geometry) {100 blade.geometry.computeBoundingBox();101 const size = blade.geometry.boundingBox!.getSize(new THREE.Vector3());102 if (size.y <= size.x && size.y <= size.z) axis.set(0, 1, 0);103 else if (size.z <= size.x && size.z <= size.y) axis.set(0, 0, 1);104 else axis.set(1, 0, 0);105 }106 spinners.push({107 obj: target,108 axis,109 radPerSec: p.spin.radPerSec,110 });111 }112 }113 return goalMaterials;114}115116export interface BuiltTrack {117 group: THREE.Group;118 spinners: Spinner[];119 /** World bounds of the placed pieces (excludes the ground plane). */120 box: THREE.Box3;121 missing: string[];122 dispose(): void;123}124125/** Ground overshoot, in grid units — wide enough for the fog to eat its edge. */126const GROUND_MARGIN = 90;127128// ---------------------------------------------------------------------------129// Goal gate checker — the lap line reads as a finish flag, not a tinted130// banner. The banner mesh is one primitive on the shared colormap (no cloth131// submesh to retexture), so a thin checkered slab wraps the cloth band:132// banner-high posts sit at ±0.425 with the cloth spanning ~y 0.75–0.95.133// ---------------------------------------------------------------------------134135let checkerTexture: THREE.CanvasTexture | null = null;136const checkerGeometry = new THREE.BoxGeometry(0.86, 0.2, 0.095);137138function getCheckerTexture(): THREE.CanvasTexture | null {139 if (checkerTexture) return checkerTexture;140 const canvas = document.createElement("canvas");141 canvas.width = 256;142 canvas.height = 64;143 const g = canvas.getContext("2d");144 if (!g) return null;145 const COLS = 8;146 const ROWS = 2;147 for (let cx = 0; cx < COLS; cx++) {148 for (let cy = 0; cy < ROWS; cy++) {149 g.fillStyle = (cx + cy) % 2 === 0 ? "#f2f2ee" : "#1c1c22";150 g.fillRect(151 (cx * canvas.width) / COLS,152 (cy * canvas.height) / ROWS,153 canvas.width / COLS,154 canvas.height / ROWS,155 );156 }157 }158 checkerTexture = new THREE.CanvasTexture(canvas);159 checkerTexture.colorSpace = THREE.SRGBColorSpace;160 return checkerTexture;161}162163/** Checkered cloth slab, positioned in the banner's local frame. The caller164 * owns the material's disposal (geometry + texture are shared). */165function makeGoalChecker():166 | { mesh: THREE.Mesh; material: THREE.Material }167 | null {168 const tex = getCheckerTexture();169 if (!tex) return null;170 const material = new THREE.MeshStandardMaterial({171 map: tex,172 roughness: 0.9,173 metalness: 0,174 });175 const mesh = new THREE.Mesh(checkerGeometry, material);176 mesh.position.set(0, 0.85, 0);177 mesh.castShadow = true;178 return { mesh, material };179}180181export function buildTrack(182 track: Track,183 assets: Assets,184 scene: THREE.Scene,185): BuiltTrack {186 assets.applyPalette(track.stats.paletteIndex, drawPipeColor(track.seed));187188 const group = new THREE.Group();189 group.name = `track:${track.seed}`;190 const spinners: Spinner[] = [];191 const missing = new Set<string>();192 // Goal-gate placements get cloned, tinted materials (the kit shares one193 // colormap material — mutating it would restyle every piece).194 const goalMaterials = instantiatePlacements(195 track.placements,196 assets,197 group,198 spinners,199 missing,200 );201202 for (const id of missing) {203 console.warn(`[track] unknown pieceId "${id}" — skipped`);204 }205206 const box = new THREE.Box3().setFromObject(group);207 if (box.isEmpty()) {208 box.set(new THREE.Vector3(-8, 0, -8), new THREE.Vector3(8, 4, 8));209 }210211 // Ground: sized to the track plus a generous margin so the horizon reads as212 // ground rather than sky at any camera angle.213 const size = box.getSize(new THREE.Vector3());214 const center = box.getCenter(new THREE.Vector3());215 const extent = Math.max(size.x, size.z) + GROUND_MARGIN * 2;216 const ground = new THREE.Mesh(217 new THREE.PlaneGeometry(extent, extent),218 new THREE.MeshStandardMaterial({219 color: 0x6f9c58,220 roughness: 1,221 metalness: 0,222 }),223 );224 ground.name = "ground";225 ground.rotation.x = -Math.PI / 2;226 ground.position.set(center.x, 0, center.z);227 ground.receiveShadow = true;228 group.add(ground);229230 scene.add(group);231232 return {233 group,234 spinners,235 box,236 missing: [...missing],237 dispose() {238 scene.remove(group);239 // Only the ground's geometry/material and the goal gate's cloned240 // materials are owned by this build; piece clones share the loaded241 // kit's resources, so disposing them would break every future build.242 ground.geometry.dispose();243 (ground.material as THREE.Material).dispose();244 for (const m of goalMaterials) m.dispose();245 group.clear();246 },247 };248}