// props.js — the prop house. Loads GLBs live from the 3GOD depot // (https://digalot.fyi/3god, GLB at /a/) with a local web/assets/ mirror as // fallback so the demo never hard-fails offline. GLTFLoader + DRACOLoader are // wired exactly like thriftgod's. Read-only against the depot — no writes, ever // (the depot's open-mode/SSRF issue is a write-side problem; see PLAN.md). import * as THREE from 'three'; import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js'; export const DEPOT = 'https://digalot.fyi/3god'; const gltfLoader = new GLTFLoader(); { const d = new DRACOLoader(); d.setDecoderPath('https://unpkg.com/three@0.175.0/examples/jsm/libs/draco/'); gltfLoader.setDRACOLoader(d); } // The catalog. `depot` = filename on 3GOD; `local` = mirror in web/assets/. // material tags feed juice.js voices; kind drives interaction in main.js. export const CATALOG = { record: { local: 'record.glb', material: 'vinyl', kind: 'record', brittle: true, grabbable: true }, crate: { local: 'crate.glb', material: 'wood', kind: 'crate' }, rack: { local: 'rack.glb', material: 'wood', kind: 'rack' }, cashbot: { depot: 'cashbot.glb', local: 'cashbot.glb', material: 'steel', kind: 'prop' }, bin: { depot: 'council-bin.glb', local: 'council-bin.glb', material: 'steel', kind: 'prop' }, // live-only flavor from the depot (no local mirror — absent gracefully if offline) mpc: { depot: 'akai-mpc-live-iii-6fe39f.glb', material: 'steel', kind: 'prop' }, gameboy: { depot: 'gameboybox-3c6cb9.glb', material: 'cardboard', kind: 'prop' }, // --- Lane 2's Destroyulator workplace batch (16 props on the 3GOD shelf, each with a // .fractured.glb sibling → real chunk destruction via loadFractured + physics.js). The // printer is the boss. `local` = mirrored into web/assets/ for offline fallback. 'office-printer': { depot: 'office-printer.glb', local: 'office-printer.glb', material: 'steel', kind: 'printer' }, 'office-desk': { depot: 'office-desk.glb', material: 'wood', kind: 'prop' }, 'crt-monitor': { depot: 'crt-monitor.glb', local: 'crt-monitor.glb', material: 'glass', kind: 'prop', brittle: true }, 'desk-phone': { depot: 'desk-phone.glb', material: 'steel', kind: 'prop' }, 'filing-cabinet': { depot: 'filing-cabinet.glb', local: 'filing-cabinet.glb', material: 'steel', kind: 'prop' }, 'water-cooler': { depot: 'water-cooler.glb', material: 'steel', kind: 'prop' }, 'office-chair': { depot: 'office-chair.glb', material: 'steel', kind: 'prop' }, 'fluorescent-light':{ depot: 'fluorescent-light.glb', material: 'glass', kind: 'prop', brittle: true }, 'coffee-mug': { depot: 'coffee-mug.glb', material: 'glass', kind: 'prop', brittle: true }, 'potted-plant': { depot: 'potted-plant.glb', material: 'wood', kind: 'prop' }, 'wall-clock': { depot: 'wall-clock.glb', material: 'wood', kind: 'prop' }, 'cardboard-box': { depot: 'cardboard-box.glb', material: 'cardboard', kind: 'prop' }, 'turntable': { depot: 'turntable.glb', material: 'wood', kind: 'prop' }, 'amp-speaker': { depot: 'amp-speaker.glb', material: 'wood', kind: 'prop' }, 'cash-register': { depot: 'cash-register.glb', material: 'steel', kind: 'prop' }, 'wall-shelf': { depot: 'wall-shelf.glb', material: 'wood', kind: 'prop' }, }; function depotURL(file) { return `${DEPOT}/a/${encodeURIComponent(file)}`; } function localURL(file) { return `./assets/${encodeURIComponent(file)}`; } // tidy a freshly-loaded gltf scene: drop shadow flags (cheap), keep materials. function prep(scene) { scene.traverse(o => { if (o.isMesh) { o.castShadow = false; o.receiveShadow = false; o.frustumCulled = true; } }); return scene; } const _cache = new Map(); // id -> Promise<{scene, source}> // Load a prop by catalog id. Tries the live depot first, then the local mirror. // Resolves to { scene: THREE.Object3D, source: 'depot'|'local' } or null. export async function loadProp(id) { const def = CATALOG[id]; if (!def) { console.warn('[props] unknown prop', id); return null; } if (_cache.has(id)) return cloneResult(await _cache.get(id)); const promise = (async () => { // 1) live depot if (def.depot) { try { const gltf = await gltfLoader.loadAsync(depotURL(def.depot)); return { scene: prep(gltf.scene), source: 'depot' }; } catch (e) { /* fall through to local */ } } // 2) local mirror if (def.local) { try { const gltf = await gltfLoader.loadAsync(localURL(def.local)); return { scene: prep(gltf.scene), source: 'local' }; } catch (e) { console.warn('[props] local load failed', id, e && e.message); } } return null; })(); _cache.set(id, promise); const res = await promise; return res ? cloneResult(res) : null; } // Try to fetch the pre-fractured sibling (Lane 2 contract #2): .fractured.glb. // Returns an Object3D whose direct children are chunk_* meshes, or null if none. // // Result (template OR the null miss) is cached per id — CRUCIAL: without it a prop that // has no fractured sibling (e.g. record before B.4) re-fetched + 404'd on EVERY spawn. // Now it's one lookup per id for the life of the page; callers still clone the template. const _fracCache = new Map(); // id -> Promise export async function loadFractured(id) { const def = CATALOG[id]; if (!def) return null; if (_fracCache.has(id)) { const tmpl = await _fracCache.get(id); return tmpl ? tmpl.clone(true) : null; } const promise = (async () => { const tryURLs = []; if (def.depot) tryURLs.push(depotURL(def.depot.replace(/\.glb$/i, '') + '.fractured.glb')); if (def.local) tryURLs.push(localURL(def.local.replace(/\.glb$/i, '') + '.fractured.glb')); for (const url of tryURLs) { try { const gltf = await gltfLoader.loadAsync(url); const container = chunkContainer(gltf.scene); // the node whose CHILDREN are chunk_* if (container) return prep(container); } catch (e) { /* not shipped / 404 — cached as a miss below, tried once */ } } return null; // cached miss: brittle props fall back to generative shards, no re-fetch })(); _fracCache.set(id, promise); const tmpl = await promise; return tmpl ? tmpl.clone(true) : null; } // Blender exports the chunks under a `fractured_root` wrapper, so they're NOT direct // children of the loaded scene. physics.js iterates a template's direct children, so we // return the node that actually parents the chunk_* meshes (the scene root itself if the // chunks sit there, otherwise the wrapper). Its own transform is identity, so each chunk's // local position stays the centroid-in-model-space the consumer expects. function chunkContainer(root) { if (root.children.some(c => /^chunk/i.test(c.name))) return root; let found = null; root.traverse(o => { if (!found && o.children && o.children.some(c => /^chunk/i.test(c.name))) found = o; }); return found; } function cloneResult(res) { // clone so each placement is independent (materials shared is fine here) return { scene: res.scene.clone(true), source: res.source }; } // Fetch the live shelf listing (for a quick "N props on the shelf" readout). export async function fetchShelf() { try { const r = await fetch(`${DEPOT}/api/list`, { method: 'GET' }); const j = await r.json(); return Array.isArray(j.assets) ? j.assets : []; } catch (e) { return []; } }