/** * LANE-RENDER — asset registry + MODELBEAST hot-swap. * * Every entity mesh keys off `MachineDef.asset` through here. No hard-coded ids. * * Default for every key is a procedural placeholder built to the style guide's * two-material rule: a grimy neutral body + exactly ONE emissive accent. Accent colour * precedence (contracts v2): `def.color` (LANE-DATA art-direction) > derived from what * the machine makes (recipes -> outputs -> ItemDef.color) > per-kind accent. * * If `public/assets/models/.glb` exists, it replaces the placeholder factory. * We probe on init and re-probe every 10s in dev, so an asset dropped in mid-session * appears without a reload. GLBs are auto-normalised (scaled to footprint, sat on the * ground) so MODELBEAST can export at any scale and it just lands. */ import * as THREE from 'three'; import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js'; import { RoundedBoxGeometry } from 'three/examples/jsm/geometries/RoundedBoxGeometry.js'; import { clone as cloneSkinned } from 'three/examples/jsm/utils/SkeletonUtils.js'; import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'; import type { GameData, MachineDef } from '../contracts'; import { ACCENT_BY_KIND, ACCENT_MIN_L, BODY_BY_KIND, HEIGHT_BY_KIND, lightnessOf } from './palette'; import { DEFAULT_ERA, applyEraSkin, eraIndex, type Era } from './era'; const MODEL_DIR = 'assets/models'; const POLL_MS = 10_000; /** Per-frame idle animation attached to a placeholder. `t` is seconds (phase-shifted). */ export type IdleFn = (t: number) => void; /** Buffer tanks expose their fill level (0..1) to EntityLayer. */ export type FillFn = (fill: number) => void; export interface Instanceable { geometry: THREE.BufferGeometry; material: THREE.Material | THREE.Material[]; } export interface AssetEntry { key: string; /** Bumps when a GLB hot-swaps in; layers rebuild meshes whose version moved. */ version: number; isGLB: boolean; /** A fresh instance, normalised to the machine footprint, origin at ground centre. */ create(): THREE.Object3D; /** Single geo+mat for InstancedMesh use (belts). Null if the asset can't be instanced. */ instanceable(): Instanceable | null; } export function idleOf(obj: THREE.Object3D): IdleFn | null { return (obj.userData.fktryIdle as IdleFn | undefined) ?? null; } export function fillOf(obj: THREE.Object3D): FillFn | null { return (obj.userData.fktrySetFill as FillFn | undefined) ?? null; } export function clipsOf(obj: THREE.Object3D): THREE.AnimationClip[] { return (obj.userData.fktryClips as THREE.AnimationClip[] | undefined) ?? []; } // ---------------------------------------------------------------- placeholders function bodyMat(color: number): THREE.MeshStandardMaterial { return new THREE.MeshStandardMaterial({ color, roughness: 0.85, metalness: 0.15 }); } function accentMat(color: number): THREE.MeshStandardMaterial { return new THREE.MeshStandardMaterial({ color, emissive: color, emissiveIntensity: 1.35, roughness: 0.35, metalness: 0.1, }); } /** * Procedural placeholder: footprint-sized bevelled box, one emissive accent, one tiny * idle motion. The body mesh is tagged `fktryBody` so EntityLayer knows what to heat — * a GLB has no such split, so there heat tints everything. */ function makePlaceholder(def: MachineDef, accent: number, body_: number, era: Era): THREE.Object3D { const g = new THREE.Group(); const fw = Math.max(0.4, def.footprint.x * 0.92); const fd = Math.max(0.4, def.footprint.y * 0.92); const h = HEIGHT_BY_KIND[def.kind] ?? 0.8; const chassis = bodyMat(body_); applyEraSkin(chassis, era); // chassis only — the accent is the resource, not the decade const body = new THREE.Mesh( new RoundedBoxGeometry(fw, h, fd, 2, Math.min(0.08, h * 0.25)), chassis, ); body.position.y = h / 2; body.castShadow = body.receiveShadow = true; body.userData.fktryBody = true; g.add(body); const am = accentMat(accent); switch (def.kind) { case 'extractor': { // CRT monitor for a head + a drill bit that spins (codex §4). const crt = new THREE.Mesh(new THREE.BoxGeometry(fw * 0.5, h * 0.3, 0.06), am); crt.position.set(0, h * 0.72, -fd / 2 - 0.01); g.add(crt); const drill = new THREE.Mesh(new THREE.ConeGeometry(fw * 0.16, h * 0.5, 6), am.clone()); drill.position.set(0, h * 0.22, fd * 0.22); drill.rotation.x = Math.PI; g.add(drill); g.userData.fktryIdle = ((t: number) => { drill.rotation.y = t * 4; (crt.material as THREE.MeshStandardMaterial).emissiveIntensity = 1.2 + Math.sin(t * 9) * 0.25; // CRT flicker }) satisfies IdleFn; break; } case 'crafter': { const jaw = new THREE.Mesh(new THREE.BoxGeometry(fw * 0.62, h * 0.14, fd * 0.62), am); jaw.position.y = h + 0.02; g.add(jaw); g.userData.fktryIdle = ((t: number) => { jaw.position.y = h + 0.02 + Math.abs(Math.sin(t * 1.6)) * 0.09; }) satisfies IdleFn; break; } case 'power': { const led = new THREE.Mesh(new THREE.BoxGeometry(fw * 0.7, 0.05, fd * 0.12), am); led.position.y = h + 0.02; g.add(led); g.userData.fktryIdle = ((t: number) => { am.emissiveIntensity = 1.0 + Math.sin(t * 2.2) * 0.5; // serene LED breath }) satisfies IdleFn; break; } case 'shipper': { const dish = new THREE.Mesh(new THREE.ConeGeometry(fw * 0.3, h * 0.42, 12, 1, true), am); dish.position.y = h + h * 0.2; g.add(dish); g.userData.fktryIdle = ((t: number) => { dish.rotation.y = t * 1.1; am.emissiveIntensity = 1.2 + Math.sin(t * 6) * 0.35; }) satisfies IdleFn; break; } case 'buffer': { // A tank with a window: the fill column is the machine's whole tell, so it reads // as a level even at iso distance. Driven from bandwidth.stored by EntityLayer. const fillH = h * 0.82; const fill = new THREE.Mesh(new THREE.BoxGeometry(fw * 0.52, fillH, fd * 0.52), am); fill.position.y = 0; fill.scale.y = 0.001; g.add(fill); const rim = new THREE.Mesh(new THREE.TorusGeometry(fw * 0.3, 0.03, 8, 20), am.clone()); rim.position.y = h + 0.03; rim.rotation.x = Math.PI / 2; g.add(rim); g.userData.fktrySetFill = ((f: number) => { const v = THREE.MathUtils.clamp(f, 0, 1); fill.scale.y = Math.max(0.001, v); fill.position.y = (fillH * v) / 2; // grow up from the tank floor }) satisfies FillFn; g.userData.fktryIdle = ((t: number) => { rim.rotation.z = t * 0.8; }) satisfies IdleFn; break; } case 'lab': { // A research bench: low slab, and the "bottled artifact triptych" (§2) standing on // it. Three bottles is the whole silhouette — nothing else in the hall is a row of // little glowing cylinders, which is the point of a placeholder. const bottles: THREE.Mesh[] = []; for (let i = 0; i < 3; i++) { const b = new THREE.Mesh( new THREE.CylinderGeometry(fw * 0.07, fw * 0.07, h * 0.3, 8), i === 0 ? am : am.clone(), ); b.position.set((i - 1) * fw * 0.2, h + h * 0.15, 0); g.add(b); bottles.push(b); } g.userData.fktryIdle = ((t: number) => { // They pulse out of phase — a bench mid-assay, not a machine mid-cycle. bottles.forEach((b, i) => { (b.material as THREE.MeshStandardMaterial).emissiveIntensity = 1.0 + Math.sin(t * 2.4 + i * 2.1) * 0.45; }); }) satisfies IdleFn; break; } case 'splitter': { // Output-cycling tell: a lamp that sweeps across the outputs (codex: round-robin). const bar = new THREE.Mesh(new THREE.BoxGeometry(fw * 0.66, 0.05, fd * 0.16), am); bar.position.y = h + 0.02; g.add(bar); const lamp = new THREE.Mesh(new THREE.SphereGeometry(0.07, 8, 6), am.clone()); lamp.position.y = h + 0.09; g.add(lamp); g.userData.fktryIdle = ((t: number) => { // Steps between output lanes rather than sliding — it's round-robin, not a slider. const step = Math.floor(t * 2) % 2 === 0 ? -1 : 1; lamp.position.x = step * fw * 0.24; (lamp.material as THREE.MeshStandardMaterial).emissiveIntensity = 1.1 + Math.sin(t * 12) * 0.3; }) satisfies IdleFn; break; } default: { const brow = new THREE.Mesh(new THREE.BoxGeometry(fw * 0.55, 0.05, fd * 0.2), am); brow.position.y = h + 0.02; g.add(brow); g.userData.fktryIdle = ((t: number) => { am.emissiveIntensity = 1.1 + Math.sin(t * 3) * 0.25; }) satisfies IdleFn; } } return g; } /** The flat belt plate. Instanced by belts.ts; chevron scroll lives in its material. */ export function beltPlateGeometry(): THREE.BufferGeometry { const geo = new THREE.BoxGeometry(0.94, HEIGHT_BY_KIND.belt, 0.94); geo.translate(0, HEIGHT_BY_KIND.belt / 2, 0); return geo; } // ---------------------------------------------------------------- GLB legibility /** * GLB legibility treatment. Round 6 shipped half of this; round 7 added the half that * was actually load-bearing and cut the other half back to size. * * MODELBEAST bakes albedo into textures and ships NO emissive, so at game zoom on a * near-black ground a real asset read as a dark hole. Round 6 answered that with a * texel-following emissive floor at 0.42 — which worked, but ROUND 7 FOUND THE ROOT * CAUSE it was masking: 13 of the 27 shipped GLBs carry `metallicFactor >= 0.8` (16 at * >= 0.5) and 21 have no metallic-roughness texture, and a metal's diffuse term is * `albedo * (1 - metalness)` — so those materials had almost no diffuse AND, with * `scene.environment === null`, nothing to reflect. They were near-black by arithmetic. * The emissive floor was cosmetics over a lighting hole; env.ts fills the hole. * * So the treatment is now three things, all keyed off data the placeholders already use: * * 1. ENVIRONMENT: the asset's own, stronger share of the procedural IBL. `envMap` is * assigned per material rather than inherited from `scene.environment` on purpose — * three.js overrides `material.envMapIntensity` with `scene.environmentIntensity` * whenever a material falls back to the scene (WebGLRenderer: `material.envMap === * null && scene.environment !== null`), so assigning it explicitly is the ONLY way * the assets can be dosed differently from six rounds of hand-tuned placeholder art. * 2. EMISSIVE FLOOR: still here, at less than half the round-6 value (see below). * 3. ACCENT BEACON (`addAccentBeacon`): the ONE impossible element, in the colour of * what the machine makes — the same accent derivation the placeholder uses. */ /** * The assets' share of the environment. High because the assets are unrealistically * metallic: `metallicFactor: 1` with no MR texture is what the mesh generators emit by * default, not an art decision — the codex describes grimy chassis, not chrome. Until * that is fixed upstream, indirect specular is nearly the whole material response for * half the catalog, so this is where their light has to come from. **If MODELBEAST ever * ships sane metalness, drop this to ~1.0** (measured alternative: clamping metalness to * 0.45 at load for the 21 materials with no MR texture reaches the same luminance at * `envMapIntensity: 1` and is visually near-identical — not shipped, because rewriting * an asset's authored PBR values is a bigger claim than lighting the room it stands in). */ const GLB_ENV_INTENSITY = 2.6; /** * Round 6 shipped 0.42, and with a real environment that is now overexposure: it flattens * the shading the IBL just bought back and pushes every chassis toward a self-lit sticker. * Measured across the 20 machines visible at the showroom close-up frame (mean sRGB * luminance of each body, plain crust ~24 for scale): * * env + floor 0.00 -> darkest machine 38 (already clear of the floor — no holes) * env + floor 0.18 -> darkest 54, demosaicer 77, mv-extractor 71, neural-keyer 81 * no env, floor 0.42 (round 6) -> darkest 57, demosaicer 75, mv-extractor 70, keyer 85 * * So 0.18 lands the catalog within a few percent of round 6's overall brightness while * the IBL, not a flat self-glow, does the shaping. NOT retired: a genuinely black texel * still needs the "never a hole" guarantee, and it's what keeps a machine legible when a * brownout drops tone-mapping exposure to 0.7. */ const GLB_EMISSIVE_FLOOR = 0.18; function treatGLBMaterials(root: THREE.Object3D, env: THREE.Texture | null): void { root.traverse((o) => { const m = o as THREE.Mesh; if (!m.isMesh) return; const mats = Array.isArray(m.material) ? m.material : [m.material]; for (const raw of mats) { const mat = raw as THREE.MeshStandardMaterial; if (!mat || !(mat as { isMeshStandardMaterial?: boolean }).isMeshStandardMaterial) continue; if (env) { mat.envMap = env; mat.envMapIntensity = GLB_ENV_INTENSITY; } const e = mat.emissive; const alreadyLit = !!e && e.r + e.g + e.b > 0.03 && (mat.emissiveIntensity ?? 0) > 0.02; if (!alreadyLit) { // MODELBEAST authored no glow here, so give it the floor. (If it DID author one, // it still gets the environment above — only the emissive is left alone.) if (mat.map) { mat.emissiveMap = mat.map; // white * map = the texture, self-lit mat.emissive = new THREE.Color(0xffffff); } else { mat.emissive = mat.color.clone(); // no texture: self-colour floor } mat.emissiveIntensity = GLB_EMISSIVE_FLOOR; } mat.needsUpdate = true; } }); } /** Unit beacon; instances scale it by footprint and never dispose it (module-shared). */ const BEACON_GEO = new THREE.IcosahedronGeometry(1, 1); /** * Perch the machine's ONE emissive accent on top of a baked GLB and give the asset a * gentle idle pulse — resource colour, identity, and a heartbeat, all from the same * accent the placeholder derives. The idle reads `beacon.material` at call time (not a * captured reference) because EntityLayer clones every GLB material per instance; a * closure over the pre-clone material would drive a mesh nothing renders. */ function addAccentBeacon(root: THREE.Object3D, def: MachineDef, accent: number): void { root.updateMatrixWorld(true); const box = new THREE.Box3().setFromObject(root); const fp = Math.min(def.footprint.x, def.footprint.y); const r = THREE.MathUtils.clamp(fp * 0.1, 0.08, 0.2); const beacon = new THREE.Mesh( BEACON_GEO, new THREE.MeshStandardMaterial({ color: accent, emissive: accent, emissiveIntensity: 1.8, roughness: 0.3, metalness: 0.1, }), ); beacon.scale.setScalar(r); beacon.position.set( (box.min.x + box.max.x) / 2, box.max.y + r * 1.3, (box.min.z + box.max.z) / 2, ); beacon.userData.fktryBeacon = true; root.add(beacon); root.userData.fktryIdle = ((t: number) => { (beacon.material as THREE.MeshStandardMaterial).emissiveIntensity = 1.8 + Math.sin(t * 2.3) * 0.45; }) satisfies IdleFn; } // ---------------------------------------------------------------- GLB handling /** * Fit an arbitrary GLB into the machine footprint, bottom sat on the ground plane. * Exported for `registry.test.ts` — the fit maths is the thing a bad asset export * silently breaks, and it needs no GPU to check. */ export function normaliseGLB( src: THREE.Object3D, def: MachineDef, clips: THREE.AnimationClip[], accent?: number, ): THREE.Object3D { // SkeletonUtils.clone (not Object3D.clone) — rigged meshes need their skeleton rebound // per instance or every copy animates off the first one's bones. const obj = cloneSkinned(src); const box = new THREE.Box3().setFromObject(obj); const size = box.getSize(new THREE.Vector3()); const targetW = Math.max(0.4, def.footprint.x * 0.92); const targetD = Math.max(0.4, def.footprint.y * 0.92); const s = Math.min( size.x > 1e-6 ? targetW / size.x : 1, size.z > 1e-6 ? targetD / size.z : 1, ); if (Number.isFinite(s) && s > 0) obj.scale.setScalar(s); // Re-measure after scaling, then centre on X/Z and sit the base at y=0. const box2 = new THREE.Box3().setFromObject(obj); const c = box2.getCenter(new THREE.Vector3()); obj.position.x -= c.x; obj.position.z -= c.z; obj.position.y -= box2.min.y; obj.traverse((o) => { if ((o as THREE.Mesh).isMesh) o.castShadow = o.receiveShadow = true; }); // A scaled/offset root would fight the mixer's root-motion tracks, so animated GLBs // get wrapped: the mixer drives the inner node, the wrapper does the normalising. let root: THREE.Object3D; if (clips.length) { const wrap = new THREE.Group(); wrap.add(obj); wrap.userData.fktryClips = clips; root = wrap; } else { obj.userData.fktryClips = clips; root = obj; } // Machine create path passes an accent -> perch the beacon. The instanceable path // (belts) passes none: an instanced belt has no room for a beacon and no accent to show. if (accent !== undefined) addAccentBeacon(root, def, accent); return root; } /** * Collapse a GLB into one instanceable geometry. Multi-mesh assets are merged with * groups so a fancy belt segment can't half-render (round 1 only took the first mesh). * Merging needs matching attributes; if they don't line up we keep the first mesh and * say so rather than silently dropping geometry. */ function toInstanceable(root: THREE.Object3D, key: string): Instanceable | null { const geos: THREE.BufferGeometry[] = []; const mats: THREE.Material[] = []; root.updateWorldMatrix(true, true); root.traverse((o) => { const m = o as THREE.Mesh; if (!m.isMesh || !m.geometry) return; geos.push(m.geometry.clone().applyMatrix4(m.matrixWorld)); mats.push(Array.isArray(m.material) ? m.material[0] : m.material); }); if (!geos.length) return null; if (geos.length === 1) return { geometry: geos[0], material: mats[0] }; const merged = mergeGeometries(geos, true); // useGroups: keeps one material per mesh if (merged) return { geometry: merged, material: mats }; console.warn( `[render] "${key}": multi-mesh GLB has mismatched attributes and could not be ` + `merged; instancing the first mesh only (${geos.length} meshes seen)`, ); return { geometry: geos[0], material: mats[0] }; } /** * True only if the URL is really a GLB. A dev server's SPA fallback happily answers * 200 + text/html for a missing file, so check the content-type before we trust it. */ async function glbExists(url: string): Promise { try { const res = await fetch(url, { method: 'HEAD', cache: 'no-store' }); if (!res.ok) return false; const ct = res.headers.get('content-type') ?? ''; return !ct.includes('text/html'); } catch { return false; } } // ---------------------------------------------------------------- registry export class AssetRegistry { /** Bumps on any hot-swap. Layers compare against it to know a rebuild is due. */ version = 0; private entries = new Map(); private defs = new Map(); /** The derived emissive accent per asset key — shared by placeholders and hot-swapped * GLBs so a real asset gets the same one impossible element the placeholder had. */ private accents = new Map(); private eras = new Map(); /** Orchestrator integration fix (round-7 pilot): the farm's PBR meshes are shipped * through `gltf-transform optimize --compress meshopt --texture-compress webp`, which * cuts a Hunyuan3D asset from 7.6 MB to ~420 KB — smaller than the albedo-only sf3d * assets it replaces, WITH metallic-roughness maps. That needs three extensions: * EXT_texture_webp and KHR_mesh_quantization are native to GLTFLoader; meshopt needs * its decoder handed over. Uncompressed GLBs keep loading unchanged. */ private loader = new GLTFLoader().setMeshoptDecoder(MeshoptDecoder); private timer: number | null = null; /** The prefiltered environment (env.ts), handed over by index.ts before init. */ private env: THREE.Texture | null = null; /** Give hot-swapped GLBs their own share of the IBL. Call before `init`. */ setEnvironment(env: THREE.Texture | null): void { this.env = env; } /** Which era a machine is skinned as (derived from tech unlocks; stream by default). */ eraOf(machineId: string): Era { return this.eras.get(machineId) ?? DEFAULT_ERA; } /** * Build every placeholder synchronously, then kick the GLB probe off WITHOUT awaiting * it (round 7). * * This used to `await this.probeAll()`, and main.ts awaits `renderer.init()` before it * starts the frame loop — so the first frame of the game was gated on 27 HEAD requests * plus ~28 MB of GLB fetch/parse. Placeholders are honorable (MASTERPLAN) and they are * already fully built one line above: there is nothing to wait for. Assets now land * through exactly the same hot-swap path a mid-session MODELBEAST drop uses, which is * a path this lane has shipped and verified since round 1 — the first second of a boot * is just the shortest possible version of it. */ init(data: GameData): void { this.eras = eraIndex(data); for (const def of data.machines) { this.defs.set(def.asset, def); const accent = accentFor(def, data); this.accents.set(def.asset, accent); const entry = this.placeholderEntry(def, accent, bodyFor(def), this.eraOf(def.id)); this.entries.set(def.asset, entry); } void this.probeAll(); // deliberately not awaited — see above if (import.meta.env.DEV) { this.timer = setInterval(() => void this.probeAll(), POLL_MS) as unknown as number; } } dispose(): void { if (this.timer !== null) clearInterval(this.timer); } get(key: string): AssetEntry | null { return this.entries.get(key) ?? null; } private placeholderEntry(def: MachineDef, accent: number, body: number, era: Era): AssetEntry { const prev = this.entries.get(def.asset); return { key: def.asset, version: prev ? prev.version : 0, isGLB: false, create: () => makePlaceholder(def, accent, body, era), // Belts are instanced by belts.ts, which supplies the chevron material itself — // this material is only a fallback and is normally discarded. instanceable: () => def.kind === 'belt' ? { geometry: beltPlateGeometry(), material: bodyMat(body) } : null, }; } /** Probe every key that hasn't got a GLB yet. Cheap: one HEAD each, 10s apart. */ private async probeAll(): Promise { const pending = [...this.defs.values()].filter((d) => !this.entries.get(d.asset)?.isGLB); await Promise.all(pending.map((d) => this.tryLoad(d))); } private async tryLoad(def: MachineDef): Promise { const url = `${import.meta.env.BASE_URL}${MODEL_DIR}/${def.asset}.glb`; if (!(await glbExists(url))) return; try { const gltf = await this.loader.loadAsync(url); const prev = this.entries.get(def.asset); const version = (prev?.version ?? 0) + 1; const template = gltf.scene; // Legibility treatment, run ONCE on the shared template: environment + emissive // floor. EntityLayer clones these materials per instance, so per-entity heat/scram // still works on top (and `clone()` carries envMap/envMapIntensity across). treatGLBMaterials(template, this.env); const clips = gltf.animations ?? []; const accent = this.accents.get(def.asset) ?? ACCENT_BY_KIND[def.kind] ?? 0x3fffe0; this.entries.set(def.asset, { key: def.asset, version, isGLB: true, create: () => normaliseGLB(template, def, clips, accent), instanceable: () => toInstanceable(normaliseGLB(template, def, []), def.asset), }); this.version++; console.info( `[render] hot-swapped GLB for "${def.asset}"${clips.length ? ` (+${clips.length} clip(s))` : ''}`, ); } catch (err) { // Corrupt/half-written file (MODELBEAST may still be copying). Keep the // placeholder and let the next poll retry. console.warn(`[render] GLB for "${def.asset}" failed to parse, keeping placeholder`, err); } } } /** * The machine's ONE emissive accent: `MachineDef.accent` (v4) when art-directed, * otherwise the colour of what it makes (recipes -> outputs -> ItemDef.color), otherwise * a per-kind accent. * * `MachineDef.color` is NOT read here — v3 ruled it the chassis; see `bodyFor` below. * * The derived path has a guard: raw products are near-black by design (MDAT ORE is * #1a1a22), and an accent that dark isn't an accent — the seam extractor's CRT head * would be a black panel on a black chassis. Anything below the legibility floor falls * through to the kind accent (for the extractor, CRT blue, as the codex describes it). */ export function accentFor(def: MachineDef, data: GameData): number { // v4: explicit art-direction wins outright, with no legibility floor. DATA choosing a // dark accent is a decision; the floor below exists for DERIVED colours, which are // incidental (item colours are authored for chips, not for machine accents). Same // reasoning as `bodyFor` and `def.color`. if (def.accent) return new THREE.Color(def.accent).getHex(); for (const rid of def.recipes) { const recipe = data.recipes.find((r) => r.id === rid); const outId = recipe && Object.keys(recipe.outputs)[0]; const item = outId ? data.items.find((i) => i.id === outId) : undefined; if (item && lightnessOf(item.color) >= ACCENT_MIN_L) { return new THREE.Color(item.color).getHex(); } } return ACCENT_BY_KIND[def.kind] ?? 0x3fffe0; } /** * The grimy industrial body. `MachineDef.color` lands here rather than on the accent. * * The v2 field is commented "art-direction accent", but every one of the 21 values * LANE-DATA authored is a desaturated industrial neutral matching a codex CHASSIS * description — "grimy yellow chassis" is #8a7a32, the ASIC's "server-blade monolith" * is #b9bec4, the mosh reactor's pressure vessel is #52333e. Style guide §8 is explicit * that ALL saturation belongs to media/artifacts and that each machine gets exactly ONE * impossible element; wiring these to the emissive makes all 21 glow muddy neutral and * deletes that element. Read as bodies they are exactly right, and they art-direct 21 * machines that were previously 7 per-kind greys. Flagged for the orchestrator. */ export function bodyFor(def: MachineDef): number { if (def.color) return new THREE.Color(def.color).getHex(); return BODY_BY_KIND[def.kind] ?? 0x3b3944; }