PROCITY/web/js/interiors/context.js
m3ultra 0e9d3fb0f2 Lane C: on-demand seeded shop interiors (library + test page)
Every shop door opens into a unique, believable, seeded interior — generated
in ~4ms, byte-identical every revisit (shop.seed), themed by shop.type.

- interiors.js: buildInterior(shop, THREE, opts) public API — pure fn of shop,
  returns {group, spawn, exits, places, dims, placement, dispose()}.
- theme.js: 9 CITY_SPEC type recipes (archetype/wallpaper/floor bias, clutter,
  counter pos, fittings mix, stock kind, signage) + type aliasing + one-time
  mergeRegistry() override seam for Lane F. Standalone (no hard registry dep).
- shell.js: room shell from lot x archetype (cosy/gallery/wide/hall/pokey),
  glazed shopfront + street backdrop, blocked back doorway, interior lighting.
- fittings.js: parametric kit ported from 90sDJsim + extended (bins, crates,
  4 shelf types, VHS aisle, glass case, counter+till, fridge, magazine/spinner
  racks, armchair, escalator, pegboard, barred window, returns slot, art).
- layout.js: per-archetype zones, thriftgod shuffled wall-slot system,
  occupancy grid, guaranteed door->counter flood-fill path (pull/carve).
- stock.js: v1 visual stock (pooled canvas sleeves/spines/boxes/garments/snacks
  with price stickers) + stockAdapter hook for BaseGod content later.
- context.js: seed sub-streams + shared-geometry cache + leak-free disposeAll().
- glb.js: optional GLB hero-prop upgrade via Lane E manifest (off by default,
  primitive fallback).
- interior_test.html: standalone page — seed/type/archetype, first-person walk,
  wireframe/occupancy/path debug, 50-room soak (perf + leak + determinism).

Acceptance (verified): same seed -> identical placement (0/810 mismatches);
9 types x 5 archetypes render sensibly (docs/shots/laneC grid); build <50ms
(steady ~4ms, soak worst 8ms); leak-free dispose (geo/tex delta 0); door->counter
path always exists (0 fails, 0 carves); runs with zero assets and zero network.
Adversarial multi-agent review: 5 findings, all fixed and re-verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 12:08:12 +10:00

169 lines
7.2 KiB
JavaScript

// 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];
}