// PROCITY Lane C — build context: the seed + disposal backbone every interior module shares. // // Two hard constraints this file exists to satisfy (CITY_SPEC + LANE_C acceptance): // 1. DETERMINISM. All randomness in an interior derives from `shop.seed`. We hand out // INDEPENDENT sub-streams keyed by a salt string, so adding a fitting in one subsystem // never shifts the wallpaper pick in another. Math.random() is banned in generation. // 2. LEAK-FREE DISPOSAL. The 50-room soak must return renderer.info.memory to baseline. // Every geometry / material / CanvasTexture we mint per room is tracked and freed in // dispose(). Globally-cached FILE textures (loaders.loadTex) are shared across rooms by // design and are NOT disposed here — that's a bounded cache, not a leak. import { mulberry32, xmur3 } from '../core/prng.js'; import * as THREE_NS from 'three'; // Deterministic 32-bit hash of a salt string → xor'd into the seed for an independent stream. const hash = (s) => xmur3(s)(); // One shared TextureLoader for per-surface tiled loads (each surface needs its own repeat). const _texLoader = new THREE_NS.TextureLoader(); export class Ctx { constructor(THREE, seed) { this.THREE = THREE; this.seed = seed >>> 0; this._geometries = new Set(); // per-room BufferGeometry — dispose all this._materials = new Set(); // per-room Material — dispose all this._canvasTex = new Set(); // per-room CanvasTexture + tiled file textures — dispose all this._streams = new Map(); // salt → mulberry32 fn (memoized so a salt is stable) this._geoCache = new Map(); // dims-key → shared geometry (stock items reuse; disposed once) this._disposed = false; } // An independent, memoized seeded stream for a subsystem. stream('wallpaper') always returns // the same sequence for a given seed, regardless of what other subsystems do. stream(salt) { let s = this._streams.get(salt); if (!s) { s = mulberry32((this.seed ^ hash(salt)) >>> 0); this._streams.set(salt, s); } return s; } // A one-shot seeded value 0..1 (fresh stream, not advancing anyone else's). rand(salt) { return mulberry32((this.seed ^ hash(salt)) >>> 0)(); } // ---- tracked resource factories ------------------------------------------------- geom(g) { this._geometries.add(g); return g; } // Shared geometry caches — the same box/plane/cyl dims are reused across the many stock items in a // room (a book barn has hundreds of identically-sized spines). Cuts allocation + build time hugely; // each cached geometry is tracked once and disposed once. Dims rounded to 2mm to key the cache. boxGeo(w, h, d) { const k = `b${Math.round(w * 500)},${Math.round(h * 500)},${Math.round(d * 500)}`; let g = this._geoCache.get(k); if (!g) { g = this.geom(new this.THREE.BoxGeometry(w, h, d)); this._geoCache.set(k, g); } return g; } planeGeo(w, h) { const k = `p${Math.round(w * 500)},${Math.round(h * 500)}`; let g = this._geoCache.get(k); if (!g) { g = this.geom(new this.THREE.PlaneGeometry(w, h)); this._geoCache.set(k, g); } return g; } cylGeo(r, h, seg) { const k = `c${Math.round(r * 500)},${Math.round(h * 500)},${seg}`; let g = this._geoCache.get(k); if (!g) { g = this.geom(new this.THREE.CylinderGeometry(r, r, h, seg)); this._geoCache.set(k, g); } return g; } // MeshStandardMaterial with sane 90s-matte defaults; colour accepts hex/number/THREE.Color. mat(color = 0xcccccc, roughness = 0.9, opts = {}) { const m = new this.THREE.MeshStandardMaterial({ color: new this.THREE.Color(color), roughness, metalness: 0, ...opts, }); this._materials.add(m); return m; } // Cheap box helper — the workhorse. Geometry shared via cache; material tracked for disposal. box(w, h, d, material, x = 0, y = 0, z = 0, parent = null) { const me = new this.THREE.Mesh(this.boxGeo(w, h, d), material); me.position.set(x, y, z); if (parent) parent.add(me); return me; } plane(w, h, material, parent = null) { const me = new this.THREE.Mesh(this.planeGeo(w, h), material); if (parent) parent.add(me); return me; } // Cylinder helper (rails, uprights, spinner posts). rz rotates for horizontal rails. cyl(r, h, material, x = 0, y = 0, z = 0, parent = null, rz = 0, seg = 12) { const me = new this.THREE.Mesh(this.cylGeo(r, h, seg), material); me.position.set(x, y, z); me.rotation.z = rz; if (parent) parent.add(me); return me; } // Per-room CanvasTexture (tracked → disposed). Use for signs and stock sleeves. canvasTexture(canvas) { const t = new this.THREE.CanvasTexture(canvas); t.colorSpace = this.THREE.SRGBColorSpace; t.anisotropy = 4; this._canvasTex.add(t); return t; } // Skin a material with a tiled file texture, thriftgod-style: the map is assigned ONLY inside the // load callback (so nothing renders a mapless-but-marked texture → no "no image data" warnings, // and the flat-colour fallback shows until the file resolves). Each tiled surface gets its OWN // texture (per-wall/floor repeat differs). The texture is TRACKED immediately for disposal; if the // room is disposed before the load finishes, the callback no-ops (guarded by _disposed). skin(material, url, { repeat = null, tint = 0xcccccc } = {}) { const t = _texLoader.load(url, () => { if (this._disposed) return; material.map = t; material.color.set(tint); material.needsUpdate = true; }, undefined, () => { /* missing asset → fallback colour stays */ }); t.colorSpace = this.THREE.SRGBColorSpace; t.anisotropy = 4; if (repeat) { t.wrapS = t.wrapT = this.THREE.RepeatWrapping; t.repeat.set(repeat[0], repeat[1]); } this._canvasTex.add(t); // per-room texture → freed in disposeAll() return material; } disposeAll() { this._disposed = true; // late texture-load callbacks will no-op after this for (const g of this._geometries) g.dispose(); for (const m of this._materials) m.dispose(); for (const t of this._canvasTex) t.dispose(); this._geometries.clear(); this._materials.clear(); this._canvasTex.clear(); this._streams.clear(); this._geoCache.clear(); } // Snapshot for the leak assertion / debugging. counts() { return { geometries: this._geometries.size, materials: this._materials.size, canvasTextures: this._canvasTex.size, }; } } // Seeded helpers that take an explicit stream fn (so callers stay deterministic). export const pick = (r, arr) => arr[(r() * arr.length) | 0]; export const irange = (r, lo, hi) => lo + ((r() * (hi - lo + 1)) | 0); export const frange = (r, lo, hi) => lo + r() * (hi - lo); export const chance = (r, p) => r() < p; export function shuffle(r, arr) { for (let i = arr.length - 1; i > 0; i--) { const j = (r() * (i + 1)) | 0; [arr[i], arr[j]] = [arr[j], arr[i]]; } return arr; } // Weighted pick: items = [{...,weight}]; returns the item. export function weighted(r, items) { const total = items.reduce((s, it) => s + (it.weight || 1), 0); let t = r() * total; for (const it of items) { t -= (it.weight || 1); if (t <= 0) return it; } return items[items.length - 1]; }