diff --git a/C-progress.md b/C-progress.md
new file mode 100644
index 0000000..9959103
--- /dev/null
+++ b/C-progress.md
@@ -0,0 +1,147 @@
+# LANE C — INTERIORS · progress (PROCITY-C)
+
+*Status: **v1 complete & verified**. Standalone interiors library + test page. Every shop door opens
+into a unique, seeded, themed interior, generated on demand in ~4ms, byte-identical every revisit.*
+
+Last updated: 2026-07-14 · owner: PROCITY-C · reviewer: Fable
+
+---
+
+## What shipped
+
+All files under my lane's ownership (`web/js/interiors/*`, `web/interior_test.html`) — no other lane's
+files touched. Lane A's `core/registry.js` hadn't landed, so the lane is **fully standalone** (its 9
+type recipes live in `theme.js`) with a `mergeRegistry()` hook for Lane F to wire the registry later.
+
+| file | role |
+|---|---|
+| [interiors.js](web/js/interiors/interiors.js) | **public API** — `buildInterior(shop, THREE, opts)` |
+| [theme.js](web/js/interiors/theme.js) | 9 shop-type recipes (archetype/wallpaper/floor bias, clutter, counter pos, fittings mix, stock kind, signage) + aliasing + registry-override hook |
+| [shell.js](web/js/interiors/shell.js) | room shell: floor/walls/ceiling from lot×archetype, glazed shopfront + street backdrop, blocked back doorway, interior lighting |
+| [fittings.js](web/js/interiors/fittings.js) | parametric fittings kit (ported from 90sDJsim + extended): bins, crates, racks, 4 shelf types, VHS aisle, glass case, counter+till, fridge, magazine/spinner racks, armchair, escalator, pegboard, barred window, returns slot, art frames |
+| [layout.js](web/js/interiors/layout.js) | the placer: per-archetype zones, shuffled wall-slot system, occupancy grid, **guaranteed door→counter flood-fill path** |
+| [stock.js](web/js/interiors/stock.js) | v1 visual stock: pooled canvas-texture sleeves/spines/boxes/garments/snacks with price stickers (dig.js trick) + `stockAdapter` hook for BaseGod data |
+| [context.js](web/js/interiors/context.js) | build/dispose/seed backbone: independent seeded sub-streams, shared-geometry cache, leak-free `disposeAll()` |
+| [glb.js](web/js/interiors/glb.js) | **optional** GLB hero-prop upgrade — reads Lane E's `manifest.json`, swaps primitives for depot GLBs, placeholder-persists. OFF by default; primitive fallback always |
+| [interior_test.html](web/interior_test.html) | standalone test page (below) |
+
+## Public API (the contract Lane B/F build against)
+
+```js
+import { buildInterior } from './js/interiors/interiors.js';
+import * as THREE from 'three';
+
+const room = buildInterior(shop, THREE, opts?);
+scene.add(room.group);
+// … player walks; on hitting room.exits[i], fire procity:exitShop …
+room.dispose(); // frees ALL per-room GPU resources; removes group from parent
+
+// room = {
+// group, // THREE.Group, self-contained (lights included)
+// spawn: { x, z, ry }, // enter just inside the door, facing −Z (into the shop)
+// exits: [ { x, z, w, toStreet } ], // door back to street
+// places: [ …meshes w/ userData ], // interactables: counter, bin, case, fridge, returns, exit
+// dims: { W, D, H, archetype, type },
+// placement, // deterministic summary (deep-equal per seed)
+// pathOK, buildMs, counts(), dispose()
+// }
+```
+
+`shop` is tolerant — recognized fields `{ id, type, name, seed, storeys, lot:{w,d} }`:
+- **seed** (uint32) is the ONLY randomness source; falls back to a hash of id/name/type if absent.
+- **type** — any CITY_SPEC type (`record opshop toy book video pawn milkbar dept stall`) **or** a
+ thriftgod/Overpass alias (`music charity pawnbroker market video_games …`); defaults to `opshop`.
+- **lot** `{w,d}` metres — the room adapts to it (never exceeds it); absent → archetype ranges decide.
+- **storeys** drives ceiling height 3.2–4.5m.
+- `opts`: `{ archetype?: 'cosy'|'gallery'|'wide'|'hall'|'pokey', stockAdapter?, useGLB?, manifest?, manifestUrl? }`.
+ Registry override is a **one-time global** — `import { mergeRegistry } from interiors.js; mergeRegistry(registry)`
+ at init (NOT a per-build opt — a per-call mutation of the shared recipe table would break same-seed determinism).
+
+**Coordinate convention** (shared with a future Lane B wiring): `+Z` = door/street side, `−Z` = back,
+origin = room centre, floor `y = 0`. Everything is in the returned `group`'s local space.
+
+### For Lane B (door wiring, later)
+`buildInterior` is a pure function — call it on `procity:enterShop`, add `room.group` to the scene,
+teleport the player to `room.spawn`. On `procity:exitShop` (player reaches `room.exits[0]`) call
+`room.dispose()` and restore the street. The street window fake is Lane B's; I own the inside view
+(a bright canvas street backdrop plane is already behind the glazing).
+
+### For content phase (BaseGod / GODVERSE)
+Pass `opts.stockAdapter = (shop, slotKind) => ({ texture }) | ({ mesh }) | null`. Returning a texture
+skins that stock item's face; a mesh replaces the item; null → procedural placeholder. No layout/
+fittings changes needed. `slotKind ∈ { sleeve, spine, box, snack, magazine, garment, treasure }`.
+
+## Test page — `web/interior_test.html`
+
+Serve: `cd web && python3 -m http.server 8130` → open `/interior_test.html`.
+- seed input · type dropdown (9) · archetype override (auto + 5) · lot `w×d`/auto · re-roll · seed ±
+- first-person walk (PointerLock + WASD) with occupancy-grid collision + room clamp
+- debug toggles: **wireframe** (F), **occupancy grid** (G), **path** (spawn/counter markers)
+- **▶ 50-room soak test**: builds+disposes 50 seeded rooms, asserts ms/room, leaked geometries vs
+ baseline, and determinism (same seed → identical placement)
+
+## Acceptance — all met
+
+| requirement | result |
+|---|---|
+| Same seed twice → identical room (deep-equal placement) | ✅ 0 mismatches / 810 builds |
+| All 9 types × 5 archetypes render sensibly | ✅ contact sheet `docs/shots/laneC/_grid_9types_5archetypes.jpg` |
+| Build < 50ms/room | ✅ steady-state ~4ms, clean-soak worst ~8ms (first-ever build ~60ms one-time cold-JIT; occasional ~60ms GC pauses only under back-to-back torture loops — not a real per-room cost) |
+| Dispose leak-free (info.memory → baseline) | ✅ soak leak: geo 0, tex 0 |
+| Flood-fill door→counter always exists | ✅ 0 path-fails, 0 carves / 810 builds |
+| Runs with zero assets & zero network | ✅ seeded flat-colour fallback under every texture; map only assigned on load |
+
+**Full sweep** (9 types × {auto+5 archetypes} × 3 seeds × {null,4×4,5×14,20×20,3×3} lots = 810 builds):
+`throws 0 · pathFail 0 · determinismFail 0 · carved 0`. Warm perf: all < 50ms (worst 33.2ms).
+**Soak** (50 rooms): `avg 4.0ms · worst 9.6ms · leakGeo 0 · leakTex 0 · detFail 0 · pathFail 0`.
+
+## Key decisions & notes for review
+
+- **90sDJsim `fittings.js` ported** (it was written to be lifted): `clothesRack`, `shelfUnit`,
+ `recordBin`, `table`, `counter`, `pegboard`, garment-silhouette canvas trick. Extended with the
+ fittings CITY_SPEC's registry needs (cube/metal/book/VHS shelving, glass case, fridge, spinner,
+ magazine rack, armchair, escalator prop, returns slot, barred window, listening corner).
+- **thriftgod `buildShop` ported**: the 5 archetypes, the shuffled wall-slot system, bins with fanned
+ sleeves wearing covers, corner counter+till, crooked wall art, blocked back doorway.
+- **Determinism** via independent seeded sub-streams keyed by a salt string (`ctx.stream('wallpaper')`
+ etc.) so adding a fitting in one subsystem never shifts another subsystem's picks.
+- **Leak-free** via a tracked build context: per-room geometry/material/CanvasTexture/tiled-file-texture
+ are freed in `dispose()`; globally-cached file textures (`core/loaders.loadTex`) are shared and not
+ disposed (bounded cache). Dispose-before-texture-load race is guarded (`_disposed`).
+- **Perf**: shared-geometry cache (`ctx.boxGeo/planeGeo/cylGeo`) collapses the hundreds of identical
+ stock items in a room down to ~80–110 geometries; spine density tuned so a book barn is ~600 meshes.
+- **GLB upgrades**: `web/assets/manifest.json` (Lane E) is read if present, primitive fallback always.
+ Not present yet → 100% primitives, which is why the test page runs asset-free.
+
+## Integration with sibling lanes (landed in parallel)
+- **Lane A `core/registry.js`** — its `SHOP_TYPE_IDS` match my 9 recipe keys exactly. I kept the
+ interior recipes local (the registry doesn't carry archetype-bias/clutter/counter-pos/stock detail),
+ with `mergeRegistry()` as the one-time override seam for Lane F.
+- **Lane E `web/assets/manifest.json`** — its `fittings` section (9 depot GLBs with footprints) is now
+ wired through `glb.js` (opt-in `useGLB`, primitive fallback). Depot GLBs may not be uploaded yet;
+ verified the fallback (unreachable depot → primitives persist, no crash, no leak).
+
+## Adversarial review — 5 findings, all fixed
+
+Ran a 5-dimension adversarial multi-agent review (determinism / disposal-leaks / path-guarantee /
+three-api / edge-robustness) with a per-finding verify pass. 5 real defects surfaced; all fixed and
+re-verified (830-build sweep + 50-room soak green after each fix):
+
+| # | sev | file | defect | fix |
+|---|---|---|---|---|
+| 1 | med | interiors.js | per-build `opts.registry` permanently mutated the shared recipe table → latent same-seed determinism break across differing registry states | removed per-build option; registry override is now a one-time global `mergeRegistry()` (Lane F setup) |
+| 2 | low | interiors.js | explicit `null` opts threw (`opts = {}` default only covers `undefined`) | normalize `opts = opts \|\| {}` |
+| 3 | low | shell.js | empty `archetypeBias` array → `bias[0][0]` on `[]` throws | fall back to uniform bias when missing **or** empty |
+| 4 | med | layout.js | fittings pulled by the path loop left phantom interactables in `places` (dangling after dispose) | collect `places` from survivors at the end, not incrementally at placement |
+| 5 | low | layout.js | `rebuildOcc` re-stamped wall-mounted fittings that `placeAtWall` never stamped → phantom floor obstacles after a blocker-pull | mark wall-mounted `noStamp`; skip in `rebuildOcc` **and** exclude from the removable pool |
+
+Verify evidence (post-fix): `nullOpts ok · phantomPlaces 0/332 · determinismAcrossBuilds identical ·
+emptyBias ok · sweep 810 {throws 0, pathFail 0, detFail 0} · soak {avg 4.2ms, worst 8ms, leakGeo 0,
+leakTex 0}`. (Reviewers also confirmed no disposal/leak or three.js-contract defects.)
+
+## Not done (out of scope for v1 / depends on other lanes)
+- Real item data / economy (content phase — `stockAdapter` hook is ready).
+- GLB hero props are **wired** (`glb.js`, opt-in) but not visually validated end-to-end — needs the
+ depot GLBs actually uploaded (Lane E) + `useGLB` flipped on by Lane B/F. Primitive path is the tested baseline.
+- Interior-mapping shader for street windows (Lane B stretch goal).
+- Back-room beyond the blocked doorway (v2).
diff --git a/docs/shots/laneC/.gitkeep b/docs/shots/laneC/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/docs/shots/laneC/_grid_9types_5archetypes.jpg b/docs/shots/laneC/_grid_9types_5archetypes.jpg
new file mode 100644
index 0000000..8cd7f04
Binary files /dev/null and b/docs/shots/laneC/_grid_9types_5archetypes.jpg differ
diff --git a/web/interior_test.html b/web/interior_test.html
new file mode 100644
index 0000000..8866ce0
--- /dev/null
+++ b/web/interior_test.html
@@ -0,0 +1,284 @@
+
+
+
+
+
+PROCITY · Lane C — Interior Test
+
+
+
+
+
+
PROCITY · Lane C
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Click to walk · WASD move · mouse look · Esc release · G grid · F wireframe
+
+
+
+
+
diff --git a/web/js/interiors/context.js b/web/js/interiors/context.js
new file mode 100644
index 0000000..7e45ed8
--- /dev/null
+++ b/web/js/interiors/context.js
@@ -0,0 +1,168 @@
+// 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];
+}
diff --git a/web/js/interiors/fittings.js b/web/js/interiors/fittings.js
new file mode 100644
index 0000000..381bef8
--- /dev/null
+++ b/web/js/interiors/fittings.js
@@ -0,0 +1,369 @@
+// PROCITY Lane C — parametric fittings kit. Ported from 90sDJsim/web/world/fittings.js (the kit was
+// "written to be lifted") and extended with the fittings the CITY_SPEC registry needs: cube/metal/
+// book/VHS shelving, glass case, fridge, magazine rack, spinner rack, armchair, escalator prop,
+// returns slot, barred window, listening corner, crate.
+//
+// Every builder is `(ctx, o, r) → Fitting` where:
+// ctx : the shared Ctx (tracked box/mat/cyl for leak-free disposal, canvas textures)
+// o : sizing opts (w/h/d/count…), all optional — sensible defaults per fitting
+// r : a seeded stream (mulberry32) so a shop is byte-identical every revisit
+//
+// Fitting = {
+// group, // THREE.Group built at LOCAL origin, sitting on floor y=0,
+// // footprint centred on (0,0). The layout placer positions it.
+// footprint: { w, d }, // floor bounding box (metres) for the occupancy grid
+// height, // top of the fitting (metres)
+// slots: [ StockSlot… ], // where stock.js should place visual stock (may be empty)
+// places: [ mesh… ], // interactables to surface in buildInterior().places
+// }
+//
+// StockSlot = { kind:'sleeve'|'spine'|'box'|'garment'|'treasure'|'snack'|'magazine',
+// x,y,z, ry, run, depth, height, count, lean } (local to group)
+//
+// GLB UPGRADE: if Lane E's web/assets/manifest.json maps a fitting id → depot GLB, buildInterior
+// swaps the primitive for the GLB (placeholder-persists). The kit itself is 100% primitives so the
+// test page runs with zero assets and zero network.
+
+const MAT = {
+ chrome: (ctx) => ctx.mat('#b8bcc4', 0.35, { metalness: 0.8 }),
+ wire: (ctx) => ctx.mat('#8a8d94', 0.4, { metalness: 0.6 }),
+ wood: (ctx) => ctx.mat('#6d4a30', 0.8),
+ darkwood: (ctx) => ctx.mat('#4a3524', 0.8),
+ lightwood: (ctx) => ctx.mat('#9c8d76', 0.75),
+ peg: (ctx) => ctx.mat('#c8a878', 0.85),
+ glass: (ctx) => ctx.mat('#bfe0ea', 0.06, { transparent: true, opacity: 0.22 }),
+ metal: (ctx) => ctx.mat('#33343c', 0.5, { metalness: 0.5 }),
+ black: (ctx) => ctx.mat('#2a2a30', 0.5, { metalness: 0.35 }),
+};
+
+// A muted 90s-Australian stock/garment palette for placeholder colours (real skins come via stock).
+const GARB = ['#7a4a5a', '#4a5a7a', '#5a7a4a', '#8a7a4a', '#6a4a7a', '#a06a5a', '#4a6a6a', '#907a8a',
+ '#b0894a', '#4f6270', '#75565a', '#5d6b45'];
+const garb = (ctx, r) => ctx.mat(GARB[(r() * GARB.length) | 0], 0.85);
+
+// ───────────────────────────────────────────────────────────────────────────────────
+// RECORD STORE
+
+// Angled record bins on a timber base; each bin fans a run of leaning sleeves.
+function recordBin(ctx, o = {}, r) {
+ const g = new ctx.THREE.Group();
+ const w = o.w || 1.2, d = 0.72, bins = o.bins ?? (2 + (r() * 2 | 0));
+ const wood = MAT.wood(ctx);
+ ctx.box(w, 0.5, d, wood, 0, 0.25, 0, g); // base cabinet
+ const slots = [];
+ for (let b = 0; b < bins; b++) {
+ const cx = -w / 2 + (w / bins) * (b + 0.5);
+ // faceW = sleeve width across the bin; run = packing length front-to-back; tilt = fan-back angle.
+ slots.push({ kind: 'sleeve', x: cx, y: 0.5, z: 0, faceW: (w / bins) * 0.82, run: d * 0.82,
+ height: 0.32, count: 14, tilt: -0.42 });
+ }
+ g.userData = { kind: 'bin', interactable: true };
+ return { group: g, footprint: { w: w + 0.1, d: d + 0.1 }, height: 0.9, slots, places: [g] };
+}
+
+// Open plastic/timber crate of records on the floor — the dig-through classic.
+function crate(ctx, o = {}, r) {
+ const g = new ctx.THREE.Group();
+ const w = o.w || 0.55, d = o.d || 0.45, h = 0.34;
+ const m = ctx.mat(r() < 0.5 ? '#8a6a45' : '#4a5a6a', 0.8);
+ const th = 0.02;
+ ctx.box(w, th, d, m, 0, h * 0.72 - th / 2, 0, g); // raised floor (records ride up)
+ ctx.box(th, h, d, m, -w / 2, h / 2, 0, g); ctx.box(th, h, d, m, w / 2, h / 2, 0, g);
+ ctx.box(w, h, th, m, 0, h / 2, -d / 2, g); ctx.box(w, h * 0.6, th, m, 0, h * 0.3, d / 2, g);
+ const slots = [{ kind: 'sleeve', x: 0, y: h * 0.72, z: 0, faceW: w * 0.82, run: d * 0.82,
+ height: 0.28, count: 12, tilt: -0.3 }];
+ g.userData = { kind: 'bin', interactable: true };
+ return { group: g, footprint: { w: w + 0.06, d: d + 0.06 }, height: h, slots, places: [g] };
+}
+
+// A listening corner: a low bench + a boxy hi-fi + headphones on a stand.
+function listeningCorner(ctx, o = {}, r) {
+ const g = new ctx.THREE.Group();
+ const wood = MAT.darkwood(ctx), black = MAT.black(ctx);
+ ctx.box(1.0, 0.42, 0.5, MAT.wood(ctx), 0, 0.21, 0, g); // bench
+ ctx.box(0.5, 0.16, 0.34, black, -0.2, 0.5, 0, g); // amp/deck
+ ctx.box(0.16, 0.04, 0.16, MAT.metal(ctx), -0.2, 0.6, 0, g); // platter
+ ctx.cyl(0.02, 0.7, MAT.chrome(ctx), 0.34, 0.35, 0, g); // headphone stand post
+ ctx.box(0.16, 0.14, 0.1, black, 0.34, 0.74, 0, g); // cans
+ g.userData = { kind: 'prop' };
+ return { group: g, footprint: { w: 1.1, d: 0.6 }, height: 0.9, slots: [], places: [] };
+}
+
+// ───────────────────────────────────────────────────────────────────────────────────
+// SHELVING FAMILY
+
+function _shelfFrame(ctx, g, w, h, d, m, shelves) {
+ [-w / 2, w / 2].forEach(sx => ctx.box(0.05, h, d, m, sx, h / 2, 0, g)); // uprights
+ ctx.box(w, 0.04, d, m, 0, 0.04, 0, g); // base
+ ctx.box(w, 0.04, d, m, 0, h, 0, g); // top
+ ctx.box(w, h, 0.03, MAT.darkwood(ctx), 0, h / 2, -d / 2 + 0.015, g); // back panel
+ const levels = [];
+ for (let i = 1; i <= shelves; i++) { const sy = h * i / (shelves + 1); ctx.box(w, 0.04, d, m, 0, sy, 0, g); levels.push(sy); }
+ return levels;
+}
+
+// Timber/laminate shelf unit (bric-a-brac). Wall-hugging.
+function wallShelf(ctx, o = {}, r) {
+ const g = new ctx.THREE.Group();
+ const w = o.w || 1.4, h = o.h || 1.8, d = o.d || 0.4, shelves = o.shelves ?? 4;
+ const levels = _shelfFrame(ctx, g, w, h, d, MAT.lightwood(ctx), shelves);
+ const slots = levels.map(sy => ({ kind: 'box', x: 0, y: sy + 0.02, z: 0.02, ry: 0,
+ run: w - 0.24, depth: d * 0.7, height: h / (shelves + 2), count: 4, lean: false }));
+ return { group: g, footprint: { w, d }, height: h, slots, places: [] };
+}
+
+// Wire/metal shelving (op-shop, pawn, milkbar back-of-house look).
+function metalShelf(ctx, o = {}, r) {
+ const g = new ctx.THREE.Group();
+ const w = o.w || 1.2, h = o.h || 1.9, d = o.d || 0.42, shelves = o.shelves ?? 4;
+ const levels = _shelfFrame(ctx, g, w, h, d, MAT.wire(ctx), shelves);
+ const slots = levels.map(sy => ({ kind: 'box', x: 0, y: sy + 0.02, z: 0, ry: 0,
+ run: w - 0.2, depth: d * 0.72, height: h / (shelves + 2), count: 5, lean: false }));
+ return { group: g, footprint: { w, d }, height: h, slots, places: [] };
+}
+
+// Cube / pigeonhole shelving (toy, dept). A grid of open cubes, each a little display box.
+function cubeShelf(ctx, o = {}, r) {
+ const g = new ctx.THREE.Group();
+ const cols = o.cols ?? (2 + (r() * 2 | 0)), rows = o.rows ?? 4;
+ const cw = 0.42, ch = 0.42, d = o.d || 0.4;
+ const w = cols * cw, h = rows * ch;
+ const m = ctx.mat(r() < 0.5 ? '#c9b28a' : '#d0d3d8', 0.75);
+ for (let c = 0; c <= cols; c++) ctx.box(0.03, h, d, m, -w / 2 + c * cw, h / 2, 0, g);
+ for (let rr = 0; rr <= rows; rr++) ctx.box(w, 0.03, d, m, 0, rr * ch, 0, g);
+ ctx.box(w, h, 0.02, MAT.darkwood(ctx), 0, h / 2, -d / 2, g);
+ const slots = [];
+ for (let c = 0; c < cols; c++) for (let rr = 0; rr < rows; rr++)
+ slots.push({ kind: 'box', x: -w / 2 + (c + 0.5) * cw, y: rr * ch + 0.05, z: 0.02, ry: 0,
+ run: cw - 0.1, depth: d * 0.6, height: ch - 0.08, count: 1, lean: false });
+ return { group: g, footprint: { w, d }, height: h, slots, places: [] };
+}
+
+// Tall bookshelf, many narrow shelves fanned with leaning spines.
+function bookshelf(ctx, o = {}, r) {
+ const g = new ctx.THREE.Group();
+ const w = o.w || 1.0, h = o.h || 2.1, d = o.d || 0.3, shelves = o.shelves ?? 5;
+ const levels = _shelfFrame(ctx, g, w, h, d, MAT.darkwood(ctx), shelves);
+ const slots = levels.map(sy => ({ kind: 'spine', x: 0, y: sy + 0.02, z: 0, ry: 0,
+ run: w - 0.16, depth: d * 0.7, height: h / (shelves + 2) - 0.04, count: 11, lean: true }));
+ return { group: g, footprint: { w, d }, height: h, slots, places: [] };
+}
+
+// VHS aisle — double-sided tall shelf of tape spines, the video-store centrepiece row.
+function vhsAisle(ctx, o = {}, r) {
+ const g = new ctx.THREE.Group();
+ const w = o.w || 1.6, h = o.h || 1.7, d = o.d || 0.5, shelves = o.shelves ?? 4;
+ const m = ctx.mat('#3a3f4a', 0.6);
+ [-w / 2, w / 2].forEach(sx => ctx.box(0.06, h, d, m, sx, h / 2, 0, g));
+ ctx.box(w, 0.05, d, m, 0, 0.05, 0, g); ctx.box(w, 0.1, d, m, 0, h, 0, g);
+ ctx.box(w, h, 0.03, m, 0, h / 2, 0, g); // central spine divider (double-sided)
+ const slots = [];
+ for (let i = 1; i <= shelves; i++) {
+ const sy = h * i / (shelves + 1);
+ ctx.box(w, 0.04, d, m, 0, sy, 0, g);
+ slots.push({ kind: 'spine', x: 0, y: sy + 0.02, z: d / 4, ry: 0, run: w - 0.18, depth: d * 0.35, height: h / (shelves + 2) - 0.03, count: 12, lean: false });
+ slots.push({ kind: 'spine', x: 0, y: sy + 0.02, z: -d / 4, ry: Math.PI, run: w - 0.18, depth: d * 0.35, height: h / (shelves + 2) - 0.03, count: 12, lean: false });
+ }
+ return { group: g, footprint: { w, d }, height: h, slots, places: [] };
+}
+
+// Spinner rack — rotating wire stand (paperbacks / postcards).
+function spinnerRack(ctx, o = {}, r) {
+ const g = new ctx.THREE.Group();
+ const h = o.h || 1.6, r0 = 0.4;
+ const wire = MAT.wire(ctx);
+ ctx.cyl(0.03, h, wire, 0, h / 2, 0, g);
+ ctx.cyl(0.28, 0.04, wire, 0, 0.04, 0, g);
+ const slots = [];
+ for (let tier = 0; tier < 4; tier++) {
+ const ty = 0.5 + tier * 0.32;
+ for (let f = 0; f < 4; f++) {
+ const a = f / 4 * Math.PI * 2;
+ slots.push({ kind: 'box', x: Math.cos(a) * r0, y: ty, z: Math.sin(a) * r0, ry: Math.PI / 2 - a,
+ run: 0.28, depth: 0.04, height: 0.26, count: 1, lean: false });
+ }
+ }
+ return { group: g, footprint: { w: 2 * r0 + 0.2, d: 2 * r0 + 0.2 }, height: h, slots, places: [] };
+}
+
+// ───────────────────────────────────────────────────────────────────────────────────
+// GARMENTS
+
+// Straight clothes rail on two uprights, hung with garment slots.
+function clothesRack(ctx, o = {}, r) {
+ const g = new ctx.THREE.Group();
+ const w = o.w || 1.6, h = o.h || 1.6, n = o.count ?? (9 + (r() * 4 | 0));
+ const chrome = MAT.chrome(ctx);
+ [-w / 2, w / 2].forEach(sx => { ctx.cyl(0.02, h, chrome, sx, h / 2, 0, g); ctx.box(0.45, 0.03, 0.45, chrome, sx, 0.02, 0, g); });
+ ctx.cyl(0.018, w, chrome, 0, h - 0.05, 0, g, Math.PI / 2);
+ const slots = [{ kind: 'garment', x: 0, y: h - 0.05, z: 0, ry: 0, run: w - 0.2, depth: 0.34, height: 0.52, count: n, lean: false }];
+ return { group: g, footprint: { w: w + 0.1, d: 0.5 }, height: h, slots, places: [] };
+}
+
+// ───────────────────────────────────────────────────────────────────────────────────
+// TABLES / COUNTERS / CASES
+
+// Trestle / bargain table with scattered goods on top.
+function trestleTable(ctx, o = {}, r) {
+ const g = new ctx.THREE.Group();
+ const w = o.w || 1.4, h = o.h || 0.75, d = o.d || 0.7;
+ const wood = MAT.wood(ctx), dark = MAT.darkwood(ctx);
+ ctx.box(w, 0.05, d, wood, 0, h, 0, g);
+ [[-w / 2 + 0.08, -d / 2 + 0.08], [w / 2 - 0.08, -d / 2 + 0.08], [-w / 2 + 0.08, d / 2 - 0.08], [w / 2 - 0.08, d / 2 - 0.08]]
+ .forEach(([lx, lz]) => ctx.box(0.06, h, 0.06, dark, lx, h / 2, lz, g));
+ const slots = [{ kind: 'box', x: 0, y: h + 0.03, z: 0, ry: 0, run: w - 0.3, depth: d - 0.2, height: 0.2, count: o.items ?? 7, lean: false, scatter: true }];
+ return { group: g, footprint: { w, d }, height: h + 0.25, slots, places: [] };
+}
+
+// Shop counter with till and register screen. `interactable` — the pay point.
+function counter(ctx, o = {}, r) {
+ const g = new ctx.THREE.Group();
+ const w = o.w || 1.8, h = o.h || 1.0, d = o.d || 0.6;
+ const wood = MAT.wood(ctx), dark = MAT.darkwood(ctx);
+ const body = ctx.box(w, h, d, wood, 0, h / 2, 0, g);
+ ctx.box(w + 0.1, 0.06, d + 0.1, dark, 0, h, 0, g); // benchtop
+ ctx.box(0.34, 0.22, 0.28, MAT.black(ctx), w / 2 - 0.35, h + 0.15, 0, g); // till
+ const scr = ctx.plane(0.14, 0.1, ctx.mat('#0a1a0a', 0.4, { emissive: new ctx.THREE.Color('#123') }), g);
+ scr.position.set(w / 2 - 0.35, h + 0.28, 0.14); scr.rotation.x = -0.5;
+ body.userData = { kind: 'counter', interactable: true };
+ g.userData = { kind: 'counter', interactable: true };
+ return { group: g, footprint: { w: w + 0.1, d: d + 0.1 }, height: h + 0.3, slots: [], places: [g] };
+}
+
+// Glass display case — timber base + tinted glass top, a few "treasures" inside.
+function glassCase(ctx, o = {}, r) {
+ const g = new ctx.THREE.Group();
+ const w = o.w || 1.2, d = o.d || 0.5;
+ ctx.box(w, 0.72, d, ctx.mat('#5a4632', 0.6), 0, 0.36, 0, g); // base
+ ctx.box(w, 0.46, d, MAT.glass(ctx), 0, 0.95, 0, g); // glass top
+ const slots = [{ kind: 'treasure', x: 0, y: 0.78, z: 0, ry: 0, run: w - 0.2, depth: d - 0.15, height: 0.16, count: 3 + (r() * 3 | 0), lean: false }];
+ g.userData = { kind: 'case', interactable: true };
+ return { group: g, footprint: { w, d }, height: 1.2, slots, places: [g] };
+}
+
+// ───────────────────────────────────────────────────────────────────────────────────
+// SPECIALS
+
+// Wall pegboard hung with small goods (video posters / pawn tools / hardware).
+function pegboard(ctx, o = {}, r) {
+ // Built centred on local y=0 so it mounts by its centre at mountY.
+ const g = new ctx.THREE.Group();
+ const w = o.w || 1.4, h = o.h || 1.2;
+ ctx.box(w, h, 0.03, MAT.peg(ctx), 0, 0, 0, g);
+ const slots = [{ kind: 'box', x: 0, y: 0, z: 0.05, ry: 0, run: w - 0.3, depth: 0.05, height: h - 0.3, count: o.items ?? 8, lean: false, scatter: true, wall: true }];
+ return { group: g, footprint: { w, d: 0.12 }, height: h, slots, places: [], wallMounted: true, mountY: 1.4 };
+}
+
+// Drinks fridge — glass door, glowing interior, stacked cans/bottles (milk bar).
+function fridge(ctx, o = {}, r) {
+ const g = new ctx.THREE.Group();
+ const w = o.w || 0.9, h = o.h || 2.0, d = o.d || 0.6;
+ const shell = ctx.mat('#e8ecef', 0.5);
+ ctx.box(w, h, d, shell, 0, h / 2, 0, g);
+ ctx.box(w - 0.08, h - 0.5, 0.02, ctx.mat('#0c1418', 0.7, { emissive: new ctx.THREE.Color('#0a1a24') }), 0, h / 2 + 0.05, d / 2 - 0.03, g); // dark interior
+ ctx.box(w - 0.06, h - 0.46, 0.02, MAT.glass(ctx), 0, h / 2 + 0.05, d / 2, g); // glass door
+ const slots = [];
+ for (let i = 0; i < 4; i++) slots.push({ kind: 'snack', x: 0, y: 0.4 + i * 0.42, z: d / 2 - 0.12, ry: 0, run: w - 0.16, depth: 0.1, height: 0.3, count: 8, lean: false });
+ g.userData = { kind: 'fridge', interactable: true };
+ return { group: g, footprint: { w, d }, height: h, slots, places: [g] };
+}
+
+// Magazine rack — angled tiers of mag faces (milk bar window, book shop).
+function magazineRack(ctx, o = {}, r) {
+ // Floor-standing tiered rack (leans against a wall / sits in the window). Base at y=0.
+ const g = new ctx.THREE.Group();
+ const w = o.w || 1.0, h = o.h || 1.4, tiers = 4;
+ const m = ctx.mat('#7a5a3a', 0.75);
+ ctx.box(w, 0.04, 0.35, m, 0, 0.06, 0, g);
+ const slots = [];
+ for (let t = 0; t < tiers; t++) {
+ const ty = 0.35 + t * (h - 0.4) / tiers;
+ ctx.box(w, 0.03, 0.28, m, 0, ty, -0.08, g);
+ slots.push({ kind: 'magazine', x: 0, y: ty + 0.02, z: 0.02, tilt: -0.35, run: w - 0.1, depth: 0.24, height: (h - 0.4) / tiers - 0.02, count: 4, lean: true });
+ }
+ return { group: g, footprint: { w, d: 0.4 }, height: h, slots, places: [] };
+}
+
+// Reading armchair — a cosy corner nook (book shop).
+function armchair(ctx, o = {}, r) {
+ const g = new ctx.THREE.Group();
+ const m = ctx.mat(['#6a4a3a', '#4a5a4a', '#5a4a5a'][(r() * 3) | 0], 0.9);
+ ctx.box(0.7, 0.16, 0.7, m, 0, 0.42, 0, g); // seat
+ ctx.box(0.7, 0.5, 0.14, m, 0, 0.7, -0.28, g); // back
+ ctx.box(0.12, 0.4, 0.7, m, -0.29, 0.55, 0, g); ctx.box(0.12, 0.4, 0.7, m, 0.29, 0.55, 0, g); // arms
+ ctx.box(0.7, 0.42, 0.7, m, 0, 0.21, 0, g); // base
+ g.userData = { kind: 'prop' };
+ return { group: g, footprint: { w: 0.8, d: 0.8 }, height: 0.95, slots: [], places: [] };
+}
+
+// Barred shop window (pawn) — a wall-mounted frame with vertical bars over dim glass.
+function barredWindow(ctx, o = {}, r) {
+ // Built centred on local y=0 so it mounts by its centre at mountY.
+ const g = new ctx.THREE.Group();
+ const w = o.w || 1.4, h = o.h || 1.1;
+ ctx.box(w, h, 0.06, ctx.mat('#4a4038', 0.7), 0, 0, -0.03, g); // frame/backing
+ ctx.box(w - 0.12, h - 0.12, 0.02, ctx.mat('#26303a', 0.3, { transparent: true, opacity: 0.5 }), 0, 0, 0.01, g);
+ const bar = MAT.metal(ctx);
+ const nb = 6;
+ for (let i = 0; i <= nb; i++) ctx.cyl(0.015, h - 0.1, bar, -w / 2 + 0.06 + i * ((w - 0.12) / nb), 0, 0.04, g);
+ return { group: g, footprint: { w, d: 0.12 }, height: h, slots: [], places: [], wallMounted: true, mountY: 1.2 };
+}
+
+// Returns slot — a wall/counter chute with a "RETURNS" plate (video store).
+function returnsSlot(ctx, o = {}, r) {
+ const g = new ctx.THREE.Group();
+ ctx.box(0.5, 0.8, 0.4, ctx.mat('#5a4632', 0.7), 0, 0.4, 0, g);
+ ctx.box(0.34, 0.06, 0.05, MAT.black(ctx), 0, 0.62, 0.2, g); // the slot
+ g.userData = { kind: 'returns', interactable: true };
+ return { group: g, footprint: { w: 0.5, d: 0.4 }, height: 0.8, slots: [], places: [g] };
+}
+
+// Escalator prop — the dept-store grand-hall centrepiece (static, decorative).
+function escalator(ctx, o = {}, r) {
+ const g = new ctx.THREE.Group();
+ const w = o.w || 1.2, len = o.len || 4.0, rise = o.rise || 2.2;
+ const metal = MAT.metal(ctx), rail = MAT.chrome(ctx);
+ // inclined stepped ramp
+ const steps = 12;
+ for (let i = 0; i < steps; i++) {
+ const t = i / (steps - 1);
+ ctx.box(w, 0.12, len / steps + 0.02, metal, 0, 0.1 + t * rise, -len / 2 + t * len, g);
+ }
+ ctx.box(w + 0.1, 0.3, len, ctx.mat('#3a3f47', 0.6), 0, rise / 2 - 0.1, 0, g); // side skirt
+ // inclined handrails (a box the length of the slope, tilted to match the ramp)
+ const slope = Math.hypot(rise, len), ang = Math.atan2(rise, len);
+ [-w / 2 - 0.05, w / 2 + 0.05].forEach(sx => {
+ const rl = ctx.box(0.06, 0.06, slope, rail, sx, 0.9 + rise / 2, 0, g);
+ rl.rotation.x = ang;
+ });
+ g.userData = { kind: 'prop' };
+ return { group: g, footprint: { w: w + 0.2, d: len }, height: rise + 1.0, slots: [], places: [] };
+}
+
+// Art frame (wall decor) — a framed canvas plane. Colour placeholder (art-*.jpg is Lane E future).
+function artFrame(ctx, o = {}, r) {
+ const g = new ctx.THREE.Group();
+ const sz = o.w || (0.7 + r() * 0.5);
+ ctx.box(sz + 0.08, sz + 0.08, 0.04, ctx.mat('#4a3a28', 0.6), 0, 0, -0.01, g); // frame
+ const artCol = new ctx.THREE.Color().setHSL(r(), 0.35, 0.45);
+ ctx.plane(sz, sz, ctx.mat(artCol, 0.7), g);
+ return { group: g, footprint: { w: sz, d: 0.08 }, height: sz, slots: [], places: [], wallMounted: true, mountY: 1.5 };
+}
+
+export const FITTINGS = {
+ recordBin, crate, listeningCorner,
+ wallShelf, metalShelf, cubeShelf, bookshelf, vhsAisle, spinnerRack,
+ clothesRack,
+ trestleTable, counter, glassCase,
+ pegboard, fridge, magazineRack, armchair, barredWindow, returnsSlot, escalator, artFrame,
+};
+
+// Build a fitting by kind. Unknown kinds fall back to a wall shelf (never crash).
+export function buildFitting(kind, ctx, o, r) {
+ const fn = FITTINGS[kind] || FITTINGS.wallShelf;
+ const fit = fn(ctx, o || {}, r);
+ // frameCount = children present before any procedural stock is added, so the optional GLB upgrade
+ // (glb.js) can hide just the primitive frame and keep the stock sitting on the detailed prop.
+ fit.frameCount = fit.group.children.length;
+ return fit;
+}
diff --git a/web/js/interiors/glb.js b/web/js/interiors/glb.js
new file mode 100644
index 0000000..d1a8b03
--- /dev/null
+++ b/web/js/interiors/glb.js
@@ -0,0 +1,66 @@
+// PROCITY Lane C — optional GLB-upgrade layer. Reads Lane E's web/assets/manifest.json and swaps a
+// primitive fitting for a detailed depot GLB where one exists ("reserve the detailed GLB hero props
+// for where the camera gets close" — RESEARCH). Strictly ADDITIVE and OFF by default:
+// • the whole lane runs 100% on primitives with no manifest and no network (LANE_C acceptance);
+// • enable per build with opts.useGLB (interiors.js), or globally via preloadManifest().
+//
+// Pattern (house law): promise-cached loader, PLACEHOLDER-PERSISTS — the primitive shows immediately
+// and stays until the GLB resolves; if the depot is unreachable the primitive stays forever (never a
+// crash, never a blank). The GLB is footprint-fitted to the primitive it replaces, so occupancy/paths
+// already computed by layout.js stay valid.
+
+import { loadGLB } from '../core/loaders.js';
+import { clone as skeletonClone } from 'three/addons/utils/SkeletonUtils.js';
+
+// my fitting kind → manifest.fittings id (only kinds with a depot GLB; others stay primitive)
+const KIND_TO_GLB = {
+ crate: 'record_crate', recordBin: 'record_crate',
+ metalShelf: 'wire_shelf', wallShelf: 'wire_shelf',
+ clothesRack: 'clothes_rack',
+ bookshelf: 'bookshelf',
+ cubeShelf: 'cube_shelf_wide',
+ counter: 'counter',
+ trestleTable: 'work_table',
+};
+
+let _manifestP = null;
+// Fetch + cache the manifest. Returns null (not throw) if absent/unreachable → primitives everywhere.
+export function preloadManifest(url = 'assets/manifest.json') {
+ return (_manifestP ||= fetch(url).then(r => (r.ok ? r.json() : null)).catch(() => null));
+}
+export function manifestReady() { return _manifestP && typeof _manifestP.then === 'function' ? _manifestP : Promise.resolve(null); }
+
+// Upgrade one placed fitting in-place, if the manifest maps its kind. No-op when disabled/unavailable.
+// `manifest` is the resolved object (or null). ctx is the room build context (for the _disposed guard).
+export function upgradeFitting(ctx, fitting, kind, manifest) {
+ if (!manifest || !manifest.fittings) return;
+ const id = KIND_TO_GLB[kind];
+ const entry = id && manifest.fittings[id];
+ if (!entry || !entry.file) return;
+
+ loadGLB(`depot:${entry.file}`).then(gltf => {
+ if (!gltf || ctx._disposed || !fitting.group.parent) return; // room rebuilt / disposed mid-flight
+ const THREE = ctx.THREE;
+ const inst = skeletonClone(gltf.scene); // clone-safe even if skinned
+ // fit the GLB to the primitive's footprint (GLB convention: origin at base, facing −Z, metres)
+ const bb = new THREE.Box3().setFromObject(inst);
+ const size = new THREE.Vector3(); bb.getSize(size);
+ const want = fitting.footprint, targetW = Math.max(want.w, want.d);
+ const glbW = Math.max(size.x, size.z) || 1;
+ const s = targetW / glbW;
+ inst.scale.setScalar(s);
+ inst.position.y -= bb.min.y * s; // plant on the floor
+ // hide the primitive FRAME (the first frameCount children) but keep procedural stock — which was
+ // added after the frame — visible on top of the detailed GLB shelf/counter.
+ const frameCount = fitting.frameCount ?? fitting.group.children.length;
+ for (let i = 0; i < frameCount && i < fitting.group.children.length; i++) fitting.group.children[i].visible = false;
+ inst.userData = { glbUpgrade: true };
+ fitting.group.add(inst);
+ });
+}
+
+// Upgrade every placed fitting in a room. Called from buildInterior when GLB mode is on.
+export function upgradeRoom(ctx, placed, manifest) {
+ if (!manifest) return;
+ for (const p of placed) if (!p.removed) upgradeFitting(ctx, p.fitting, p.kind, manifest);
+}
diff --git a/web/js/interiors/interiors.js b/web/js/interiors/interiors.js
new file mode 100644
index 0000000..5312206
--- /dev/null
+++ b/web/js/interiors/interiors.js
@@ -0,0 +1,117 @@
+// PROCITY Lane C — public API. The Vuntra move: every shop door opens into a unique, believable,
+// seeded interior, generated on demand in <50ms, byte-identical every revisit (shop.seed), themed
+// by shop.type. This is a standalone library + test page; Lane B wires it to real doors later via
+// the procity:enterShop / procity:exitShop events.
+//
+// import { buildInterior } from './js/interiors/interiors.js';
+// import * as THREE from 'three';
+// const room = buildInterior(shop, THREE); // shop = a CityPlan shop record + its lot dims
+// scene.add(room.group);
+// // … player walks around, hits room.exits …
+// room.dispose(); // frees all per-room geometry/material/textures
+//
+// buildInterior(shop, THREE, opts?) → {
+// group, // THREE.Group, self-contained (lights included). Add to a scene.
+// spawn: { x, z, ry }, // where/what-heading the player enters at (just inside the door)
+// exits: [ { x, z, w, toStreet } ], // door(s) back to the street
+// places: [ …meshes with userData ], // interactables: counter, bins, case, fridge, returns, exit
+// dims: { W, D, H, archetype, type }, // room metrics
+// placement, // deterministic placement summary (deep-equal per seed)
+// pathOK, // door→counter connectivity held (always true; carved if needed)
+// dispose(), // release GPU resources; removes group from its parent
+// }
+//
+// `shop` is tolerant. Recognized fields: { id, type, name, seed, storeys, lot:{w,d} }.
+// • seed : uint32 — the ONLY randomness source (falls back to a hash of id/name if absent)
+// • type : any CITY_SPEC type or thriftgod alias (see theme.js); defaults to 'opshop'
+// • lot : { w, d } metres — room adapts to it; if absent, archetype ranges decide the size
+// • storeys: ceiling height driver (1 → 3.2m … up to 4.5m)
+// opts: { archetype?: 'cosy'|'gallery'|'wide'|'hall'|'pokey', stockAdapter? }
+//
+// Registry override (Lane F): call mergeRegistry(registry) ONCE at init to let authored registry data
+// override recipe fields. It is deliberately NOT a per-build option — a per-call mutation of the shared
+// recipe table would break the same-seed determinism guarantee for every later build.
+
+import { Ctx } from './context.js';
+import { getRecipe, mergeRegistry, SHOP_TYPES } from './theme.js';
+import { ARCHETYPE_KEYS, chooseArchetype, computeDims, buildShell } from './shell.js';
+import { layout } from './layout.js';
+import { preloadManifest, upgradeRoom } from './glb.js';
+import { xmur3 } from '../core/prng.js';
+
+export { SHOP_TYPES, ARCHETYPE_KEYS, mergeRegistry };
+export { preloadManifest } from './glb.js'; // Lane F: preload once so GLB upgrades apply synchronously
+
+let glbManifest = null; // resolved manifest cache (once the depot answers)
+
+function normalizeShop(shop) {
+ const s = shop || {};
+ let seed = s.seed;
+ if (seed == null) seed = xmur3(`${s.id ?? ''}:${s.name ?? ''}:${s.type ?? ''}`)();
+ const lot = s.lot && s.lot.w && s.lot.d ? { w: +s.lot.w, d: +s.lot.d }
+ : (s.lotW && s.lotD ? { w: +s.lotW, d: +s.lotD } : null);
+ return {
+ id: s.id ?? 'shop',
+ type: s.type ?? 'opshop',
+ name: s.name ?? '',
+ seed: seed >>> 0,
+ storeys: s.storeys ?? 1,
+ lot,
+ raw: s,
+ };
+}
+
+export function buildInterior(shop, THREE, opts) {
+ opts = opts || {}; // tolerate undefined AND explicit null
+ const t0 = (typeof performance !== 'undefined' ? performance.now() : 0);
+
+ const norm = normalizeShop(shop);
+ const recipe = getRecipe(norm.type);
+ const ctx = new Ctx(THREE, norm.seed);
+
+ // room shape → dims → shell
+ const archetype = chooseArchetype(recipe, ctx.stream('archetype'), opts.archetype);
+ const dims = computeDims(ctx, recipe, archetype, norm.lot, norm.storeys);
+ const shell = buildShell(ctx, { recipe, archetype, dims });
+
+ // fittings + wall decor + stock + walkable-path guarantee
+ const lay = layout(ctx, { recipe, dims, shell, adapter: opts.stockAdapter || null, shop: norm });
+
+ // OPTIONAL GLB hero-prop upgrade (off by default). Placeholder-persists: primitives stay until (and
+ // unless) the depot answers. `opts.useGLB` enables it; the manifest may be preloaded (glb.preloadManifest)
+ // or passed as opts.manifest. Never blocks the build; never required for a fully-usable room.
+ if (opts.useGLB) {
+ const manifest = opts.manifest || (glbManifest && glbManifest.__resolved) || null;
+ if (manifest) upgradeRoom(ctx, lay.placed, manifest);
+ else preloadManifest(opts.manifestUrl).then(m => {
+ if (m && !ctx._disposed) { glbManifest = { __resolved: m }; upgradeRoom(ctx, lay.placed, m); }
+ });
+ }
+
+ const group = shell.group;
+ const buildMs = (typeof performance !== 'undefined' ? performance.now() : 0) - t0;
+
+ let disposed = false;
+ const api = {
+ group,
+ spawn: shell.spawn,
+ exits: shell.exits,
+ places: lay.places,
+ dims: { ...dims, archetype, type: recipe.key },
+ recipe: { key: recipe.key, label: recipe.label, counterPos: recipe.counterPos, clutter: recipe.clutter },
+ placement: lay.placement,
+ pathOK: lay.pathOK,
+ carved: lay.carved,
+ buildMs,
+ _debug: { grid: lay.grid, spawnCell: lay.spawnCell, targetCell: lay.targetCell, ctx },
+ counts: () => ctx.counts(),
+ dispose() {
+ if (disposed) return;
+ disposed = true;
+ if (group.parent) group.parent.remove(group);
+ // drop lights/children refs, then free tracked GPU resources
+ ctx.disposeAll();
+ },
+ };
+ return api;
+}
diff --git a/web/js/interiors/layout.js b/web/js/interiors/layout.js
new file mode 100644
index 0000000..fb57f7f
--- /dev/null
+++ b/web/js/interiors/layout.js
@@ -0,0 +1,365 @@
+// PROCITY Lane C — the placer. Turns a recipe + room shell into a believable, non-overlapping,
+// walkable arrangement of fittings + wall decor, seeded from shop.seed.
+//
+// Guarantees (LANE_C acceptance):
+// • nothing overlaps — every fitting reserves cells in an occupancy grid before placing
+// • wall art/signs don't clash — the thriftgod shuffled wall-slot system draws from one pool
+// • door → counter is walkable — flood-fill verifies connectivity; blockers are pulled until it is
+// • density varies by shop — count per fitting scales by recipe.clutter × area × seed
+//
+// Coordinates match shell.js: +Z = door/street side, −Z = back. Origin = room centre, floor y=0.
+
+import { buildFitting } from './fittings.js';
+import * as stock from './stock.js';
+import { frange, shuffle } from './context.js';
+
+const CELL = 0.4; // occupancy grid resolution (m)
+const PAD = 0.12; // clearance padding around each fitting (m)
+
+// ── grid helpers ─────────────────────────────────────────────────────────────────────
+function makeGrid(W, D) {
+ const cols = Math.max(3, Math.round(W / CELL));
+ const rows = Math.max(3, Math.round(D / CELL));
+ const cw = W / cols, cd = D / rows;
+ const occ = new Uint8Array(cols * rows); // 0 free, 1 fitting, 2 wall(border)
+ const reserved = new Uint8Array(cols * rows); // walkable but no placement (door corridor, till front)
+ for (let cx = 0; cx < cols; cx++) for (let cz = 0; cz < rows; cz++)
+ if (cx === 0 || cz === 0 || cx === cols - 1 || cz === rows - 1) occ[cz * cols + cx] = 2;
+ const cellOf = (x, z) => [
+ Math.min(cols - 1, Math.max(0, Math.floor((x + W / 2) / cw))),
+ Math.min(rows - 1, Math.max(0, Math.floor((z + D / 2) / cd))),
+ ];
+ return { cols, rows, cw, cd, W, D, occ, reserved, cellOf };
+}
+
+// Rotated-AABB half extents of a footprint at yaw ry.
+function halfExtents(fw, fd, ry) {
+ const c = Math.abs(Math.cos(ry)), s = Math.abs(Math.sin(ry));
+ return [(c * fw + s * fd) / 2 + PAD, (s * fw + c * fd) / 2 + PAD];
+}
+function rectCells(grid, x, z, hw, hd) {
+ const a = grid.cellOf(x - hw, z - hd), b = grid.cellOf(x + hw, z + hd);
+ return { cx0: a[0], cz0: a[1], cx1: b[0], cz1: b[1] };
+}
+// Is the padded footprint clear? (wall cells value 2 are allowed — shelves hug walls.)
+function areaFree(grid, rect) {
+ for (let cz = rect.cz0; cz <= rect.cz1; cz++)
+ for (let cx = rect.cx0; cx <= rect.cx1; cx++) {
+ const v = grid.occ[cz * grid.cols + cx];
+ if (v === 1) return false;
+ if (grid.reserved[cz * grid.cols + cx]) return false;
+ }
+ return true;
+}
+function stampRect(grid, rect) {
+ for (let cz = rect.cz0; cz <= rect.cz1; cz++)
+ for (let cx = rect.cx0; cx <= rect.cx1; cx++)
+ if (grid.occ[cz * grid.cols + cx] === 0) grid.occ[cz * grid.cols + cx] = 1;
+}
+function rebuildOcc(grid, placed) {
+ for (let i = 0; i < grid.occ.length; i++) if (grid.occ[i] === 1) grid.occ[i] = 0;
+ // Skip noStamp (wall-mounted) fittings — placeAtWall never stamped them at placement, so re-stamping
+ // here would inject phantom floor obstacles beside a wall the player can actually walk past, and
+ // could trigger needless fitting-pulls or a carve when the path was fine.
+ for (const p of placed) if (!p.noStamp) stampRect(grid, p.rect);
+}
+function reserveRect(grid, x0, z0, x1, z1) {
+ const a = grid.cellOf(x0, z0), b = grid.cellOf(x1, z1);
+ for (let cz = a[1]; cz <= b[1]; cz++) for (let cx = a[0]; cx <= b[0]; cx++)
+ grid.reserved[cz * grid.cols + cx] = 1;
+}
+
+// 4-connected flood fill over walkable cells (occ==0). Returns Set of reachable indices.
+function floodFill(grid, startIdx) {
+ const seen = new Uint8Array(grid.cols * grid.rows);
+ const stack = [startIdx]; seen[startIdx] = 1;
+ const out = new Set([startIdx]);
+ while (stack.length) {
+ const i = stack.pop(), cx = i % grid.cols, cz = (i / grid.cols) | 0;
+ for (const [dx, dz] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
+ const nx = cx + dx, nz = cz + dz;
+ if (nx < 0 || nz < 0 || nx >= grid.cols || nz >= grid.rows) continue;
+ const ni = nz * grid.cols + nx;
+ if (seen[ni] || grid.occ[ni] !== 0) continue;
+ seen[ni] = 1; out.add(ni); stack.push(ni);
+ }
+ }
+ return out;
+}
+
+// ── counter placement per recipe.counterPos ──────────────────────────────────────────
+function placeCounter(ctx, recipe, W, D, r) {
+ const pos = recipe.counterPos || 'corner';
+ const cw = Math.min(2.2, Math.max(1.4, W * 0.42));
+ let x, z, ry, front;
+ if (pos === 'door') { // milk bar: faces the door, front-left
+ x = -W * 0.12; z = D / 2 - 1.8; ry = 0; front = { x, z: z + 0.9 };
+ } else if (pos === 'forward') { // pawn/stall: counter up front, goods behind
+ x = -W * 0.05; z = D / 2 - 2.4; ry = 0; front = { x, z: z + 0.95 };
+ } else if (pos === 'back') { // along the back wall, facing in
+ x = -W * 0.1; z = -D / 2 + 0.7; ry = Math.PI; front = { x, z: z + 0.95 };
+ } else { // 'corner' — back-east, facing into the room
+ x = W / 2 - 0.55; z = -D / 4; ry = -Math.PI / 2; front = { x: x - 0.95, z };
+ }
+ const f = buildFitting('counter', ctx, { w: cw }, r);
+ f.group.position.set(x, 0, z); f.group.rotation.y = ry;
+ const [hw, hd] = halfExtents(f.footprint.w, f.footprint.d, ry);
+ return { fitting: f, x, z, ry, hw, hd, front, priority: 9, kind: 'counter' };
+}
+
+// ── wall-slot system (shuffled pool → art + signs + wall fittings never overlap) ──────
+function makeWallSlots(W, D, r) {
+ const slots = [];
+ const nZ = Math.max(2, Math.floor((D - 3.2) / 1.7));
+ for (let i = 0; i < nZ; i++) {
+ const z = -D / 2 + 1.6 + i * ((D - 3.4) / Math.max(1, nZ - 1));
+ slots.push({ wall: 'west', x: -W / 2, z, ry: Math.PI / 2, used: false });
+ slots.push({ wall: 'east', x: W / 2, z, ry: -Math.PI / 2, used: false });
+ }
+ const nX = Math.max(1, Math.floor((W - 3.0) / 1.7));
+ for (let i = 0; i < nX; i++) {
+ const x = -W / 2 + 1.5 + i * ((W - 3.0) / Math.max(1, nX - 1));
+ slots.push({ wall: 'north', x, z: -D / 2, ry: 0, used: false });
+ }
+ shuffle(r, slots);
+ return slots;
+}
+
+// ── free-standing candidate positions per zone ────────────────────────────────────────
+function zoneCandidates(zone, W, D, doorCx, r) {
+ const out = [];
+ if (zone === 'window') {
+ const z = D / 2 - 1.4;
+ for (let x = -W / 2 + 1.1; x <= doorCx - 1.2; x += 1.3) out.push({ x, z, ry: Math.PI });
+ } else if (zone === 'aisle') {
+ for (let z = D / 2 - 3.0; z >= -D / 2 + 1.6; z -= 1.7)
+ for (let x = -W / 2 + 1.3; x <= W / 2 - 1.3; x += 1.9) out.push({ x, z, ry: 0 });
+ } else if (zone === 'centre') {
+ for (let z = D / 2 - 3.5; z >= -D / 2 + 2.2; z -= 2.0)
+ for (let x = -W / 4; x <= W / 4; x += 1.8) out.push({ x, z, ry: 0 });
+ }
+ return shuffle(r, out);
+}
+
+// ── main entry ────────────────────────────────────────────────────────────────────────
+export function layout(ctx, { recipe, dims, shell, adapter, shop }) {
+ const { W, D } = dims;
+ const doorCx = shell.door.x;
+ const grid = makeGrid(W, D);
+ const rCount = ctx.stream('lay-count'), rPlace = ctx.stream('lay-place'), rWall = ctx.stream('lay-wall');
+
+ // Reserve the door corridor (walkable, no fittings) and the spawn cell.
+ reserveRect(grid, doorCx - 0.7, D / 2 - 2.0, doorCx + 0.7, D / 2 - 0.4);
+
+ const placed = [];
+ const roomGroup = shell.group;
+
+ // 1) COUNTER first (always present, reachability target).
+ const counter = placeCounter(ctx, recipe, W, D, rCount);
+ counter.rect = rectCells(grid, counter.x, counter.z, counter.hw, counter.hd);
+ stampRect(grid, counter.rect);
+ placed.push(counter);
+ // reserve the strip in front of the till so nothing blocks service
+ reserveRect(grid, counter.front.x - 0.5, counter.front.z - 0.5, counter.front.x + 0.5, counter.front.z + 0.5);
+
+ // 2) FITTINGS from the recipe mix (skip the counter entry — already placed).
+ const area = W * D;
+ const wallSlots = makeWallSlots(W, D, rWall);
+ for (const spec of recipe.fittings) {
+ if (spec.kind === 'counter') continue;
+ // count scales with clutter × area, clamped to [min,max]
+ const density = recipe.clutter * (area / 70);
+ let n = Math.round(frange(rCount, spec.min, spec.max + 0.99) * Math.min(1.3, density));
+ n = Math.max(spec.min, Math.min(spec.max, n));
+ for (let i = 0; i < n; i++) {
+ const f = buildFitting(spec.kind, ctx, {}, ctx.stream(`fit-${spec.kind}-${i}`));
+ let ok = false;
+ if (spec.zone === 'wall' || f.wallMounted) {
+ ok = placeAtWall(ctx, grid, f, wallSlots, rPlace, placed, spec);
+ } else if (spec.zone === 'counter') {
+ ok = placeNearCounter(ctx, grid, f, counter, rPlace, placed, spec);
+ } else {
+ ok = placeFree(ctx, grid, f, spec.zone, W, D, doorCx, rPlace, placed, spec);
+ }
+ if (!ok) { /* no room — density self-caps; drop the fitting (its resources free at room end) */ }
+ }
+ }
+
+ // 3) PATH GUARANTEE: door → counter. Pull low-priority blockers until reachable; carve if we must.
+ const spawnCell = idxOf(grid, shell.spawn.x, shell.spawn.z);
+ const targetCell = idxOf(grid, counter.front.x, counter.front.z);
+ let pathOK = reaches(grid, spawnCell, targetCell);
+ let carved = false;
+ const removable = () => placed
+ .filter(p => p.priority < 8 && p.kind !== 'counter' && !p.noStamp) // wall-mounted don't block the floor
+ .sort((a, b) => a.priority - b.priority);
+ while (!pathOK) {
+ const pool = removable();
+ if (!pool.length) break;
+ const victim = pool[0];
+ placed.splice(placed.indexOf(victim), 1);
+ if (victim.fitting.group.parent) victim.fitting.group.parent.remove(victim.fitting.group);
+ victim.removed = true;
+ rebuildOcc(grid, placed);
+ pathOK = reaches(grid, spawnCell, targetCell);
+ }
+ if (!pathOK) { carveCorridor(grid, spawnCell, targetCell); carved = true; pathOK = reaches(grid, spawnCell, targetCell); }
+
+ // 4) add SURVIVORS to the room + fill them with visual stock. Collect `places` here (NOT at
+ // placement time) so interactables belong only to fittings that actually made it into the room —
+ // a fitting pulled by the path loop must not leave a phantom interactable in the returned list.
+ const places = [...(shell.places || [])];
+ for (const p of placed) {
+ if (p.removed) continue;
+ if (!p.fitting.group.parent) roomGroup.add(p.fitting.group);
+ stock.fill(ctx, p.fitting, { shop, recipe, stockKind: recipe.stockKind, adapter }, ctx.stream(`stk-${p.priority}-${p.x.toFixed(2)}-${p.z.toFixed(2)}`));
+ places.push(...(p.fitting.places || []));
+ }
+
+ // 5) WALL DECOR — art frames + inside signage on leftover wall slots (shuffled, never overlap).
+ const decor = placeWallDecor(ctx, roomGroup, recipe, wallSlots, W, D, rWall);
+
+ // placement summary for the determinism deep-equal test
+ const placement = placed.filter(p => !p.removed).map(p => ({
+ kind: p.kind || 'fitting', kindName: p.fitting.group.userData.kind,
+ x: round(p.x), z: round(p.z), ry: round(p.ry), fw: round(p.fitting.footprint.w), fd: round(p.fitting.footprint.d),
+ })).concat(decor.summary);
+
+ return {
+ placed: placed.filter(p => !p.removed),
+ places, decor: decor.meshes, grid, pathOK, carved,
+ spawnCell, targetCell, placement,
+ };
+}
+
+// ── placement strategies ──────────────────────────────────────────────────────────────
+function placeFree(ctx, grid, f, zone, W, D, doorCx, r, placed, spec) {
+ const cands = zoneCandidates(zone, W, D, doorCx, r);
+ for (const c of cands) {
+ const ry = c.ry + (r() - 0.5) * 0.4;
+ const [hw, hd] = halfExtents(f.footprint.w, f.footprint.d, ry);
+ const rect = rectCells(grid, c.x, c.z, hw, hd);
+ if (!areaFree(grid, rect)) continue;
+ f.group.position.set(c.x, 0, c.z); f.group.rotation.y = ry;
+ stampRect(grid, rect);
+ placed.push({ fitting: f, x: c.x, z: c.z, ry, hw, hd, rect, priority: spec.priority, kind: spec.kind });
+ return true;
+ }
+ return false;
+}
+
+function placeAtWall(ctx, grid, f, wallSlots, r, placed, spec) {
+ const backOff = f.wallMounted ? 0.06 : f.footprint.d / 2 + 0.05;
+ for (const slot of wallSlots) {
+ if (slot.used) continue;
+ const ry = slot.ry;
+ let x = slot.x, z = slot.z;
+ if (slot.wall === 'west') x += backOff; else if (slot.wall === 'east') x -= backOff; else z += backOff;
+ const y = f.wallMounted ? (f.mountY || 1.4) : 0;
+ const [hw, hd] = halfExtents(f.footprint.w, f.footprint.d, ry);
+ const rect = rectCells(grid, x, z, hw, hd);
+ if (!f.wallMounted && !areaFree(grid, rect)) continue;
+ f.group.position.set(x, y, z); f.group.rotation.y = ry;
+ if (!f.wallMounted) stampRect(grid, rect);
+ slot.used = true;
+ // noStamp: wall-mounted fittings hang above head height and don't block the floor — keep rebuildOcc
+ // consistent with this placement (see rebuildOcc).
+ placed.push({ fitting: f, x, z, ry, hw, hd, rect, priority: spec.priority, kind: spec.kind, noStamp: f.wallMounted });
+ return true;
+ }
+ return false;
+}
+
+function placeNearCounter(ctx, grid, f, counter, r, placed, spec) {
+ // treasures / cases beside the till, on the goods side of the counter
+ const side = counter.ry === 0 ? { x: counter.x + (r() < 0.5 ? -1.3 : 1.3), z: counter.z } : { x: counter.x, z: counter.z + (r() < 0.5 ? -1.3 : 1.3) };
+ const ry = counter.ry;
+ const [hw, hd] = halfExtents(f.footprint.w, f.footprint.d, ry);
+ const rect = rectCells(grid, side.x, side.z, hw, hd);
+ if (!areaFree(grid, rect)) return false;
+ f.group.position.set(side.x, 0, side.z); f.group.rotation.y = ry;
+ stampRect(grid, rect);
+ placed.push({ fitting: f, x: side.x, z: side.z, ry, hw, hd, rect, priority: spec.priority, kind: spec.kind });
+ return true;
+}
+
+// ── wall decor (art + signage) ─────────────────────────────────────────────────────────
+function placeWallDecor(ctx, roomGroup, recipe, wallSlots, W, D, r) {
+ const meshes = [], summary = [];
+ const free = wallSlots.filter(s => !s.used);
+ const nArt = Math.min(free.length, 2 + (r() * 3 | 0));
+ const nSign = Math.min(Math.max(0, free.length - nArt), 1 + (r() * 2 | 0));
+ let fi = 0;
+ for (let i = 0; i < nArt && fi < free.length; i++, fi++) {
+ const slot = free[fi];
+ const sz = 0.7 + r() * 0.5;
+ const artCol = new ctx.THREE.Color().setHSL(r(), 0.32, 0.42);
+ const frame = ctx.box(sz + 0.08, sz + 0.08, 0.04, ctx.mat('#4a3a28', 0.6), 0, 0, 0, roomGroup);
+ const canvas = ctx.plane(sz, sz, ctx.mat(artCol, 0.75), roomGroup);
+ // inward normal per wall so the canvas sits a hair in front of its frame (no z-fight)
+ const nx = slot.wall === 'west' ? 1 : slot.wall === 'east' ? -1 : 0;
+ const nz = slot.wall === 'north' ? 1 : 0;
+ const y = 1.5 + r() * 0.3, tilt = (r() - 0.5) * 0.05;
+ const baseX = slot.wall === 'west' ? -W / 2 + 0.04 : slot.wall === 'east' ? W / 2 - 0.04 : slot.x;
+ const baseZ = slot.wall === 'north' ? -D / 2 + 0.04 : slot.z;
+ frame.position.set(baseX, y, baseZ); frame.rotation.y = slot.ry; frame.rotation.z = tilt;
+ canvas.position.set(baseX + nx * 0.03, y, baseZ + nz * 0.03); canvas.rotation.y = slot.ry; canvas.rotation.z = tilt;
+ meshes.push(frame, canvas);
+ summary.push({ kind: 'art', wall: slot.wall, x: round(slot.x), z: round(slot.z) });
+ }
+ const signs = recipe.signFlavour || [];
+ for (let i = 0; i < nSign && fi < free.length; i++, fi++) {
+ const slot = free[fi];
+ const txt = signs[(r() * signs.length) | 0] || 'SALE';
+ const mesh = makeSignPlane(ctx, txt);
+ const off = 0.05;
+ const x = slot.wall === 'west' ? -W / 2 + off : slot.wall === 'east' ? W / 2 - off : slot.x;
+ const z = slot.wall === 'north' ? -D / 2 + off : slot.z;
+ mesh.position.set(x, 2.05, z); mesh.rotation.y = slot.ry;
+ roomGroup.add(mesh);
+ meshes.push(mesh);
+ summary.push({ kind: 'sign', wall: slot.wall, text: txt, x: round(slot.x), z: round(slot.z) });
+ }
+ return { meshes, summary };
+}
+
+function makeSignPlane(ctx, text) {
+ const cv = document.createElement('canvas'); cv.width = 256; cv.height = 64;
+ const x = cv.getContext('2d');
+ x.fillStyle = '#f5f2ea'; x.fillRect(0, 0, 256, 64);
+ x.strokeStyle = '#333'; x.lineWidth = 3; x.strokeRect(3, 3, 250, 58);
+ x.fillStyle = '#c1121f'; x.font = 'bold 30px Arial'; x.textAlign = 'center'; x.textBaseline = 'middle';
+ x.fillText(String(text).slice(0, 18), 128, 34);
+ const tex = ctx.canvasTexture(cv);
+ const m = ctx.mat('#ffffff', 0.7); m.map = tex; m.needsUpdate = true;
+ const mesh = ctx.plane(1.3, 0.32, m);
+ return mesh;
+}
+
+// ── small utils ─────────────────────────────────────────────────────────────────────
+function idxOf(grid, x, z) { const [cx, cz] = grid.cellOf(x, z); return cz * grid.cols + cx; }
+function reaches(grid, startIdx, targetIdx) {
+ if (grid.occ[startIdx] !== 0) { const s = nearestFree(grid, startIdx); if (s < 0) return false; startIdx = s; }
+ if (grid.occ[targetIdx] !== 0) { const t = nearestFree(grid, targetIdx); if (t < 0) return false; targetIdx = t; }
+ return floodFill(grid, startIdx).has(targetIdx);
+}
+function nearestFree(grid, idx) {
+ const cx0 = idx % grid.cols, cz0 = (idx / grid.cols) | 0;
+ for (let rad = 1; rad < 6; rad++)
+ for (let dz = -rad; dz <= rad; dz++) for (let dx = -rad; dx <= rad; dx++) {
+ const nx = cx0 + dx, nz = cz0 + dz;
+ if (nx < 1 || nz < 1 || nx >= grid.cols - 1 || nz >= grid.rows - 1) continue;
+ if (grid.occ[nz * grid.cols + nx] === 0) return nz * grid.cols + nx;
+ }
+ return -1;
+}
+// Clear a Manhattan corridor between two cells (last-resort guarantee).
+function carveCorridor(grid, startIdx, targetIdx) {
+ let cx = startIdx % grid.cols, cz = (startIdx / grid.cols) | 0;
+ const tx = targetIdx % grid.cols, tz = (targetIdx / grid.cols) | 0;
+ const clear = (x, z) => { if (x > 0 && z > 0 && x < grid.cols - 1 && z < grid.rows - 1) grid.occ[z * grid.cols + x] = 0; };
+ while (cz !== tz) { clear(cx, cz); cz += Math.sign(tz - cz); }
+ while (cx !== tx) { clear(cx, cz); cx += Math.sign(tx - cx); }
+ clear(tx, tz);
+}
+const round = (v) => Math.round(v * 1000) / 1000;
diff --git a/web/js/interiors/shell.js b/web/js/interiors/shell.js
new file mode 100644
index 0000000..a83e118
--- /dev/null
+++ b/web/js/interiors/shell.js
@@ -0,0 +1,213 @@
+// PROCITY Lane C — the room shell: floor / walls / ceiling / glazed shopfront / lights.
+//
+// Sized from the shop's lot (lot.w × lot.d) adapted to one of the 5 thriftgod archetypes
+// (cosy / gallery / wide / hall / pokey). Ceiling height 3.2–4.5m by storeys. Materials are seeded
+// picks of wall-*.jpg wallpapers + tex-carpet/lino/boards floors (all already in web/assets/gen/,
+// all with flat-colour fallbacks). The shopfront (south, +Z) has real glazing you can see a hint of
+// street through — a bright backdrop plane beyond the glass sells the inside-looking-out view. The
+// back wall (north, −Z) has a blocked doorway (v1). Door + exit live on the shopfront.
+//
+// Coordinate convention (shared with layout.js):
+// +Z = south = the street side (door + glazing). −Z = north = back of shop.
+// Player spawns just inside the door facing −Z (into the shop). Origin = room centre, floor y=0.
+
+import { pick, frange } from './context.js';
+
+// The 5 archetypes: [wMin,wMax] × [dMin,dMax] footprint ranges + base ceiling height (m).
+export const ARCHETYPES = {
+ cosy: { w: [7, 9], d: [8, 10], h: 3.4 }, // cosy squarish
+ gallery: { w: [6, 7], d: [11, 14], h: 3.4 }, // narrow deep gallery
+ wide: { w: [10, 12], d: [7, 9], h: 3.6 }, // wide shallow
+ hall: { w: [9, 11], d: [10, 13], h: 4.0 }, // big high-ceilinged hall
+ pokey: { w: [6, 7], d: [7, 8], h: 3.2 }, // pokey little room
+};
+export const ARCHETYPE_KEYS = Object.keys(ARCHETYPES);
+
+// Floor names live under web/assets/gen/tex-.jpg ; wallpapers under wall-.jpg.
+const FLOOR_FALLBACK = { // seeded flat colour beneath each floor texture
+ 'carpet-swirl': '#6b5b45', 'carpet-mustard': '#8a7038', 'carpet-greygreen': '#5d6b56',
+ 'lino-check': '#b9b2a0', 'lino-cork': '#9c7a52', 'boards-polished': '#7a5533',
+};
+const WALL_FALLBACK = {
+ 'floral-cream': '#d8cdb4', 'floral-gold': '#c9b18a', 'damask-mauve': '#b9a6b4',
+ 'stripe-sage': '#bfc8b2', 'geo-orange': '#caa06a', 'diamond-green': '#a9c0a4',
+ 'trellis-blue': '#aebfcf', 'woodchip-white': '#d7d2c6',
+};
+
+// Choose an archetype from a recipe's weighted bias (or honour an explicit override).
+export function chooseArchetype(recipe, rArch, override) {
+ if (override && ARCHETYPES[override]) return override;
+ // fall back to a uniform bias if the recipe's is missing OR empty (e.g. an authored registry patch)
+ const bias = (recipe.archetypeBias && recipe.archetypeBias.length)
+ ? recipe.archetypeBias : ARCHETYPE_KEYS.map(k => [k, 1]);
+ const total = bias.reduce((s, [, w]) => s + w, 0);
+ let t = rArch() * total;
+ for (const [k, w] of bias) { t -= w; if (t <= 0) return k; }
+ return bias[0][0];
+}
+
+// Compute interior dims {W,D,H} from archetype + lot + storeys, all seeded/deterministic.
+export function computeDims(ctx, recipe, archetype, lot, storeys) {
+ const a = ARCHETYPES[archetype] || ARCHETYPES.cosy;
+ const rw = ctx.stream('dim-w'), rd = ctx.stream('dim-d');
+ let W = frange(rw, a.w[0], a.w[1]);
+ let D = frange(rd, a.d[0], a.d[1]);
+ if (lot && lot.w > 0 && lot.d > 0) { // adapt to the real lot: never exceed it
+ const availW = Math.max(4, lot.w - 0.6); // 0.3m wall thickness each side
+ const availD = Math.max(4, lot.d - 0.6);
+ W = Math.min(W, availW);
+ D = Math.min(D, availD);
+ }
+ W = Math.round(W * 20) / 20; D = Math.round(D * 20) / 20; // 5cm grid → stable deep-equal
+ const st = Math.max(1, storeys || 1);
+ let H = a.h + (st - 1) * 0.4;
+ H = Math.max(3.2, Math.min(4.5, H));
+ return { W, D, H };
+}
+
+// Build the shell into a fresh Group. Returns geometry + spawn/exit metadata for the placer.
+export function buildShell(ctx, { recipe, archetype, dims }) {
+ const { W, D, H } = dims;
+ const THREE = ctx.THREE;
+ const group = new THREE.Group();
+ group.userData = { kind: 'interior', archetype, type: recipe.key };
+
+ const rMat = ctx.stream('shell-mat');
+ const floorName = pick(rMat, recipe.floorBias);
+ const wallName = pick(rMat, recipe.wallpaperBias);
+
+ // ── floor ──
+ const floorMat = ctx.mat(FLOOR_FALLBACK[floorName] || '#8a7a60', 0.95);
+ ctx.skin(floorMat, `assets/gen/tex-${floorName}.jpg`, { repeat: [Math.ceil(W / 3), Math.ceil(D / 3)] });
+ const floor = ctx.plane(W, D, floorMat, group);
+ floor.rotation.x = -Math.PI / 2;
+ floor.receiveShadow = true;
+
+ // ── ceiling ──
+ const ceil = ctx.plane(W, D, ctx.mat('#d9d4c8', 1.0), group);
+ ceil.rotation.x = Math.PI / 2; ceil.position.y = H;
+
+ // ── side + back walls (west −X, east +X, north −Z) ──
+ const wallColor = WALL_FALLBACK[wallName] || '#cfc4ae';
+ const wallDefs = [
+ { x: -W / 2, z: 0, ry: Math.PI / 2, w: D, name: 'west' },
+ { x: W / 2, z: 0, ry: -Math.PI / 2, w: D, name: 'east' },
+ { x: 0, z: -D / 2, ry: 0, w: W, name: 'north' },
+ ];
+ const walls = {};
+ for (const def of wallDefs) {
+ const m = ctx.mat(wallColor, 0.9);
+ ctx.skin(m, `assets/gen/wall-${wallName}.jpg`, { repeat: [def.w / 2.5, H / 2.5] });
+ const me = ctx.plane(def.w, H, m, group);
+ me.position.set(def.x, H / 2, def.z);
+ me.rotation.y = def.ry;
+ walls[def.name] = me;
+ }
+
+ // Back doorway (blocked v1): a dark recessed frame on the north wall.
+ const doorwayW = 1.1, doorwayH = 2.2;
+ const backX = (ctx.rand('backdoor') - 0.5) * (W - 3);
+ ctx.box(doorwayW + 0.2, doorwayH + 0.2, 0.08, ctx.mat('#3a3128', 0.8), backX, doorwayH / 2, -D / 2 + 0.05, group); // frame
+ ctx.box(doorwayW, doorwayH, 0.04, ctx.mat('#15110c', 0.9), backX, doorwayH / 2, -D / 2 + 0.10, group); // dark void
+
+ // ── shopfront (south, +Z): glazing + door opening + street backdrop ──
+ const front = buildShopfront(ctx, group, { W, H, D, wallColor, wallName });
+
+ // ── lighting (travels with the group) ──
+ group.add(new THREE.AmbientLight(0xfff4de, 0.75));
+ const lightXs = W > 9 ? [-W / 3, 0, W / 3] : [-W / 4, W / 4];
+ const lightZs = D > 11 ? [-D / 3, 0, D / 3] : [-D / 4, D / 4];
+ for (const lx of lightXs) for (const lz of lightZs) {
+ const l = new THREE.PointLight(0xfff0d0, 14, 12, 1.6);
+ l.position.set(lx, H - 0.3, lz);
+ group.add(l);
+ ctx.box(0.7, 0.06, 0.18, ctx.mat('#f5f2ea', 0.4, { emissive: new THREE.Color('#4a4636') }), lx, H - 0.06, lz, group); // fixture
+ }
+
+ return {
+ group, dims, archetype, floorName, wallName, walls,
+ spawn: { x: front.doorCx, z: D / 2 - 1.5, ry: 0 }, // just inside, facing −Z into the shop
+ exits: [{ x: front.doorCx, z: D / 2, w: front.doorW, toStreet: true }],
+ door: { x: front.doorCx, w: front.doorW, z: D / 2 },
+ places: front.places,
+ };
+}
+
+// The glazed shopfront wall with a door gap and a bright street backdrop behind the glass.
+function buildShopfront(ctx, group, { W, H, D, wallColor }) {
+ const THREE = ctx.THREE;
+ const z = D / 2;
+ const th = 0.12;
+ const doorW = 1.3, doorH = 2.2;
+ const doorCx = W * 0.26; // door on the right quarter
+ const doorL = doorCx - doorW / 2, doorR = doorCx + doorW / 2;
+ const sill = 0.9, head = 2.35; // window band
+ const winL = -W / 2 + 0.12, winR = doorL - 0.18; // glazing fills left of the door
+ const wallMat = () => ctx.mat(wallColor, 0.9);
+
+ const solid = (w, h, x, y) => { if (w > 0.01 && h > 0.01) ctx.box(w, h, th, wallMat(), x, y, z, group); };
+ // left return pillar
+ solid(0.12, H, -W / 2 + 0.06, H / 2);
+ // sill (below window)
+ solid(winR - winL, sill, (winL + winR) / 2, sill / 2);
+ // head/spandrel (above window)
+ solid(winR - winL, H - head, (winL + winR) / 2, (head + H) / 2);
+ // pillar between window and door
+ solid(doorL - winR, H, (winR + doorL) / 2, H / 2);
+ // lintel above door
+ solid(doorW, H - doorH, doorCx, (doorH + H) / 2);
+ // right of door → east return
+ solid(W / 2 - doorR, H, (doorR + W / 2) / 2, H / 2);
+
+ // the glass pane
+ const glass = ctx.mat('#bfe0ea', 0.05, { transparent: true, opacity: 0.16 });
+ const pane = ctx.box(winR - winL, head - sill, 0.02, glass, (winL + winR) / 2, (sill + head) / 2, z - 0.02, group);
+ // a mullion or two
+ const mull = ctx.mat('#4a4038', 0.7);
+ ctx.box(0.05, head - sill, 0.05, mull, (winL + winR) / 2, (sill + head) / 2, z - 0.02, group);
+
+ // door frame trim + a glass door leaf (the exit)
+ ctx.box(doorW + 0.1, 0.08, 0.14, mull, doorCx, doorH, z, group);
+ ctx.box(0.08, doorH, 0.14, mull, doorL, doorH / 2, z, group);
+ ctx.box(0.08, doorH, 0.14, mull, doorR, doorH / 2, z, group);
+ const leaf = ctx.box(doorW - 0.16, doorH - 0.1, 0.03, ctx.mat('#bfe0ea', 0.05, { transparent: true, opacity: 0.2 }), doorCx, doorH / 2, z - 0.03, group);
+ leaf.userData = { kind: 'exit', interactable: true, toStreet: true };
+ // little EXIT plate above the door, facing inward
+ const plate = ctx.plane(0.6, 0.16, ctx.mat('#7a6a55', 0.5, { emissive: new THREE.Color('#2a2416') }), group);
+ plate.position.set(doorCx, doorH + 0.16, z - 0.06); plate.rotation.y = Math.PI;
+
+ // ── street backdrop beyond the glazing: an unlit gradient plane (sky → horizon → footpath) ──
+ const backdrop = makeStreetBackdrop(ctx, W);
+ backdrop.position.set(0, H / 2, z + 1.6);
+ backdrop.rotation.y = Math.PI; // face back into the shop
+ group.add(backdrop);
+
+ return { doorCx, doorW, places: [leaf] };
+}
+
+// Cheap inside-looking-out backdrop: a canvas gradient with a hint of buildings + footpath.
+// Unlit (MeshBasicMaterial) so it reads as bright daylight regardless of interior lights.
+function makeStreetBackdrop(ctx, W) {
+ const THREE = ctx.THREE;
+ const cv = document.createElement('canvas'); cv.width = 256; cv.height = 128;
+ const x = cv.getContext('2d');
+ const sky = x.createLinearGradient(0, 0, 0, 128);
+ sky.addColorStop(0, '#8fb6d8'); sky.addColorStop(0.55, '#cfe0ec'); sky.addColorStop(0.62, '#d8d2c4');
+ sky.addColorStop(1, '#9c968a');
+ x.fillStyle = sky; x.fillRect(0, 0, 256, 128);
+ // a row of muted facades across the horizon
+ const cols = ['#8a7a68', '#9a8a6a', '#7a8a80', '#a08a7a', '#7a7488'];
+ for (let i = 0; i < 8; i++) {
+ x.fillStyle = cols[i % cols.length];
+ const bw = 24 + (i * 37 % 20), bx = i * 33 - 8, bh = 30 + (i * 53 % 34);
+ x.fillRect(bx, 62 - bh, bw, bh);
+ }
+ x.fillStyle = '#6f6a60'; x.fillRect(0, 78, 256, 50); // footpath
+ x.fillStyle = '#5a5650'; x.fillRect(0, 74, 256, 4); // kerb line
+ const tex = ctx.canvasTexture(cv);
+ const mat = new THREE.MeshBasicMaterial({ map: tex });
+ ctx._materials.add(mat);
+ const w = Math.max(W, 6) * 1.4;
+ const geo = ctx.geom(new THREE.PlaneGeometry(w, w * 0.5));
+ return new THREE.Mesh(geo, mat);
+}
diff --git a/web/js/interiors/stock.js b/web/js/interiors/stock.js
new file mode 100644
index 0000000..6186b6a
--- /dev/null
+++ b/web/js/interiors/stock.js
@@ -0,0 +1,213 @@
+// PROCITY Lane C — visual stock (v1). Fills the fittings' `slots` with product silhouettes:
+// canvas-texture cardboard sleeves / spines / boxes / snacks / magazines with price stickers
+// (the dig.js sleeve trick — coloured cardboard + sticker). Real item data is the content phase;
+// this stays purely cosmetic and byte-identical per seed.
+//
+// stock.fill(ctx, fitting, { shop, recipe, stockKind, adapter }, r)
+// iterates fitting.slots and adds child meshes into fitting.group.
+//
+// stockAdapter hook (content phase): `adapter(shop, slotKind) => { texture } | { mesh } | null`.
+// If it returns a texture, that texture skins the item face; a mesh replaces the item entirely;
+// null (default) → procedural placeholder. BaseGod / GODVERSE data plugs in here without touching
+// layout or fittings code.
+//
+// PERF + LEAK SAFETY: face textures are POOLED per room (a small seeded set reused across many
+// items) → few CanvasTextures to build and dispose, fast builds, soak returns to baseline.
+
+import { pick } from './context.js';
+
+// Muted 90s stock palette (cardboard, plastic, faded card).
+const CARD = ['#b49b72', '#a6b472', '#72a6b4', '#b47298', '#8a72b4', '#b48a72', '#72b48a', '#b4a672'];
+const SPINE = ['#7a2a2a', '#2a4a7a', '#2a6a4a', '#7a5a2a', '#5a2a6a', '#2a5a6a', '#6a6a2a', '#7a3a5a'];
+const SNACK = ['#e04a3a', '#f0b428', '#3a9ae0', '#4ac04a', '#e04a9a', '#f07028', '#a04ae0'];
+
+// ── per-room face-texture pool ──────────────────────────────────────────────────────
+// A pooled canvas "face" for a stock kind: coloured card + a little price sticker + label bars.
+function makeFace(ctx, kind, r) {
+ const cv = document.createElement('canvas');
+ cv.width = 64; cv.height = kind === 'spine' ? 128 : 64;
+ const x = cv.getContext('2d');
+ const pal = kind === 'spine' ? SPINE : kind === 'snack' ? SNACK : CARD;
+ const base = pick(r, pal);
+ x.fillStyle = base; x.fillRect(0, 0, cv.width, cv.height);
+ // faded border
+ x.strokeStyle = 'rgba(0,0,0,0.18)'; x.lineWidth = 3; x.strokeRect(2, 2, cv.width - 4, cv.height - 4);
+ if (kind === 'spine') {
+ // title bars down the spine
+ x.fillStyle = 'rgba(255,255,255,0.82)';
+ for (let i = 0; i < 3; i++) x.fillRect(10, 18 + i * 30 + (r() * 6 | 0), cv.width - 20, 8);
+ x.fillStyle = 'rgba(0,0,0,0.3)'; x.fillRect(8, cv.height - 20, cv.width - 16, 10);
+ } else {
+ // a cover blob + label lines
+ x.fillStyle = 'rgba(255,255,255,0.16)';
+ x.beginPath(); x.arc(32 + (r() - 0.5) * 16, 26, 12 + r() * 6, 0, 6.3); x.fill();
+ x.fillStyle = 'rgba(255,255,255,0.75)';
+ x.fillRect(8, 44, 34, 5); x.fillRect(8, 52, 22, 4);
+ }
+ // the op-shop price sticker — a fluoro dot with a $ scrawl
+ const sx = cv.width - 18, sy = 14;
+ x.fillStyle = '#f4e11a'; x.beginPath(); x.arc(sx, sy, 9, 0, 6.3); x.fill();
+ x.fillStyle = '#c1121f'; x.font = 'bold 11px Arial'; x.textAlign = 'center'; x.textBaseline = 'middle';
+ x.fillText('$' + (1 + (r() * 9 | 0)), sx, sy + 1);
+ return ctx.canvasTexture(cv);
+}
+
+function facePool(ctx, kind, r, n = 6) {
+ const key = `_facePool_${kind}`;
+ if (!ctx[key]) { ctx[key] = []; for (let i = 0; i < n; i++) ctx[key].push(makeFace(ctx, kind, r)); }
+ return ctx[key];
+}
+
+// A garment silhouette (ported from 90sDJsim garmentCanvas), pooled per room.
+const GARMENTS = ['tee', 'hoodie', 'jacket', 'dress', 'pants', 'shirt'];
+function makeGarment(ctx, r) {
+ const shape = GARMENTS[(r() * GARMENTS.length) | 0];
+ const color = new ctx.THREE.Color().setHSL(r(), 0.45, 0.4 + r() * 0.2);
+ const cv = document.createElement('canvas'); cv.width = 128; cv.height = 160;
+ const x = cv.getContext('2d');
+ const col = '#' + color.getHexString();
+ const dark = '#' + color.clone().offsetHSL(0, 0.05, -0.14).getHexString();
+ x.fillStyle = col; x.strokeStyle = dark; x.lineWidth = 4; x.lineJoin = 'round';
+ const P = pts => { x.beginPath(); x.moveTo(pts[0][0], pts[0][1]); for (let i = 1; i < pts.length; i++) x.lineTo(pts[i][0], pts[i][1]); x.closePath(); x.fill(); x.stroke(); };
+ if (shape === 'pants') P([[40, 8], [88, 8], [90, 152], [70, 152], [64, 62], [58, 152], [38, 152]]);
+ else if (shape === 'dress') { P([[48, 20], [80, 20], [104, 152], [24, 152]]); x.fillStyle = dark; x.beginPath(); x.ellipse(64, 22, 11, 6, 0, 0, 6.3); x.fill(); }
+ else {
+ P([[16, 46], [46, 26], [46, 60], [22, 78]]); P([[112, 46], [82, 26], [82, 60], [106, 78]]);
+ P([[42, 24], [86, 24], [86, 142], [42, 142]]);
+ x.fillStyle = dark; x.beginPath(); x.ellipse(64, 26, 12, 7, 0, 0, 6.3); x.fill();
+ if (shape === 'hoodie') { x.fillStyle = col; x.beginPath(); x.arc(64, 24, 15, Math.PI, 0); x.fill(); x.stroke(); }
+ }
+ x.fillStyle = 'rgba(255,255,255,0.22)'; x.fillRect(55, 62, 18, 20);
+ return ctx.canvasTexture(cv);
+}
+function garmentPool(ctx, r, n = 8) {
+ if (!ctx._garmentPool) { ctx._garmentPool = []; for (let i = 0; i < n; i++) ctx._garmentPool.push(makeGarment(ctx, r)); }
+ return ctx._garmentPool;
+}
+
+// ── fill entry point ─────────────────────────────────────────────────────────────────
+export function fill(ctx, fitting, opts, r) {
+ for (const slot of fitting.slots || []) fillSlot(ctx, fitting.group, slot, opts, r);
+}
+
+function fillSlot(ctx, parent, slot, opts, r) {
+ switch (slot.kind) {
+ case 'sleeve': return binFan(ctx, parent, slot, opts, r);
+ case 'garment': return hang(ctx, parent, slot, opts, r);
+ case 'treasure': return treasures(ctx, parent, slot, opts, r);
+ default: return shelfLine(ctx, parent, slot, opts, r); // box / spine / snack / magazine
+ }
+}
+
+// Ask the adapter for a face texture (content phase); null → pooled procedural face.
+function faceTex(ctx, kind, opts, r) {
+ if (opts.adapter) {
+ const got = opts.adapter(opts.shop, kind);
+ if (got && got.texture) return got.texture;
+ }
+ return pick(r, facePool(ctx, kind === 'magazine' ? 'card' : kind, r));
+}
+
+// Packed leaning sleeves in a bin/crate; front few wear covers (dig.js look).
+function binFan(ctx, parent, slot, opts, r) {
+ const n = slot.count || 12;
+ const fw = slot.faceW || 0.3, h = slot.height || 0.3, run = slot.run || 0.5, tilt = slot.tilt ?? -0.4;
+ const step = run / Math.max(1, n);
+ const pool = facePool(ctx, 'card', r);
+ for (let i = 0; i < n; i++) {
+ const z = -run / 2 + (i + 0.5) * step;
+ const front = i >= n - 4; // front sleeves show a cover
+ const faceMat = front ? ctx.mat('#ffffff', 0.7) : ctx.mat(pick(r, CARD), 0.85);
+ if (front) { faceMat.map = pick(r, pool); faceMat.color.set('#ffffff'); faceMat.needsUpdate = true; }
+ const sl = ctx.box(fw, h, 0.012, faceMat, slot.x + (r() - 0.5) * 0.02, slot.y + h / 2, slot.z + z, parent);
+ sl.rotation.x = tilt + (r() - 0.5) * 0.05;
+ sl.rotation.z = (r() - 0.5) * 0.04;
+ }
+}
+
+// Neat row along local X. Handles box (upright), spine (edge-out), snack (small), magazine (leaning).
+function shelfLine(ctx, parent, slot, opts, r) {
+ const kind = slot.kind;
+ const run = slot.run || 0.8, depth = slot.depth || 0.3, clear = slot.height || 0.3;
+ const scatter = slot.scatter;
+ const n = Math.max(1, slot.count || 4);
+ const pool = kind === 'spine' ? facePool(ctx, 'spine', r) : facePool(ctx, kind === 'snack' ? 'snack' : 'card', r);
+
+ if (scatter) { // tables / pegboards: random small goods
+ for (let i = 0; i < n; i++) {
+ const s = 0.09 + r() * 0.11;
+ const m = ctx.mat(pick(r, CARD), 0.85);
+ if (r() < 0.5) { m.map = pick(r, pool); m.color.set('#ffffff'); m.needsUpdate = true; }
+ const b = ctx.box(s, slot.wall ? s * 1.4 : s, slot.wall ? 0.03 : s, m,
+ slot.x + (r() - 0.5) * (run - s), slot.y + (slot.wall ? (r() - 0.5) * (clear - s) : s / 2),
+ slot.z + (slot.wall ? 0.03 : (r() - 0.5) * (depth - s)), parent);
+ b.rotation.y = (r() - 0.5) * 0.6;
+ }
+ return;
+ }
+
+ if (kind === 'spine') { // books / VHS: thin spines edge-out
+ const th = Math.min(0.03, run / (n + 2));
+ const step = (run - 0.04) / n;
+ for (let i = 0; i < n; i++) {
+ const m = ctx.mat('#ffffff', 0.7); m.map = pick(r, pool); m.color.set('#ffffff'); m.needsUpdate = true;
+ const bh = clear * (0.8 + r() * 0.2);
+ const b = ctx.box(step * 0.86, bh, depth, m, slot.x - run / 2 + (i + 0.5) * step, slot.y + bh / 2, slot.z, parent);
+ if (slot.lean && i > n - 3) b.rotation.z = 0.18; // a couple flopped over
+ }
+ return;
+ }
+
+ if (kind === 'magazine') { // leaning mag faces
+ const step = run / n;
+ for (let i = 0; i < n; i++) {
+ const m = ctx.mat('#ffffff', 0.7); m.map = pick(r, pool); m.color.set('#ffffff'); m.needsUpdate = true;
+ const b = ctx.box(step * 0.9, clear * 0.9, 0.01, m, slot.x - run / 2 + (i + 0.5) * step, slot.y + clear * 0.45, slot.z, parent);
+ b.rotation.x = slot.tilt ?? -0.35;
+ }
+ return;
+ }
+
+ // default 'box' / 'snack': upright cartons in a row, faces forward (+Z)
+ const bw = Math.min((run - 0.02) / n, kind === 'snack' ? 0.12 : 0.28);
+ const step = run / n;
+ for (let i = 0; i < n; i++) {
+ const bh = clear * (kind === 'snack' ? 0.6 : 0.62 + r() * 0.28);
+ const bd = Math.min(depth, kind === 'snack' ? 0.1 : 0.26);
+ const m = ctx.mat('#ffffff', 0.7);
+ m.map = faceTex(ctx, kind, opts, r); m.color.set('#ffffff'); m.needsUpdate = true;
+ const side = ctx.mat(pick(r, kind === 'snack' ? SNACK : CARD), 0.85);
+ const g = ctx.boxGeo(bw * 0.88, bh, bd); // shared geometry
+ const mesh = new ctx.THREE.Mesh(g, [side, side, side, side, m, side]); // face (+Z) wears the cover
+ mesh.position.set(slot.x - run / 2 + (i + 0.5) * step, slot.y + bh / 2, slot.z + depth / 2 - bd / 2 - 0.01);
+ parent.add(mesh); // face `m` + `side` already tracked via ctx.mat; geometry via ctx.geom
+ }
+}
+
+// Garments hung along a rail.
+function hang(ctx, parent, slot, opts, r) {
+ const n = slot.count || 10, run = slot.run || 1.4, h = slot.height || 0.52;
+ const pool = garmentPool(ctx, r);
+ for (let i = 0; i < n; i++) {
+ const gx = slot.x - run / 2 + (run) * (i / (n - 1 || 1));
+ const m = ctx.mat('#ffffff', 0.92, { transparent: true, alphaTest: 0.4, side: ctx.THREE.DoubleSide });
+ m.map = pick(r, pool); m.color.set('#ffffff'); m.needsUpdate = true;
+ const g = ctx.planeGeo(0.34, h);
+ const p = new ctx.THREE.Mesh(g, m);
+ p.position.set(gx, slot.y - 0.05 - h / 2, slot.z + (r() - 0.5) * 0.03);
+ p.rotation.y = (r() - 0.5) * 0.15;
+ parent.add(p);
+ }
+}
+
+// Small treasures on a glass-case shelf.
+function treasures(ctx, parent, slot, opts, r) {
+ const n = slot.count || 4, run = slot.run || 1.0;
+ const cols = ['#c8a13a', '#b0b0b8', '#8a5a3a', '#3a6a8a', '#8a3a5a'];
+ for (let i = 0; i < n; i++) {
+ const s = 0.06 + r() * 0.06;
+ const b = ctx.box(s * (1 + r()), s, s, ctx.mat(pick(r, cols), 0.4, { metalness: 0.3 }),
+ slot.x - run / 2 + (run) * ((i + 0.5) / n), slot.y + s / 2, slot.z + (r() - 0.5) * (slot.depth || 0.2) * 0.5, parent);
+ b.rotation.y = r() * Math.PI;
+ }
+}
diff --git a/web/js/interiors/theme.js b/web/js/interiors/theme.js
new file mode 100644
index 0000000..14d54f0
--- /dev/null
+++ b/web/js/interiors/theme.js
@@ -0,0 +1,236 @@
+// PROCITY Lane C — type theming. Every CITY_SPEC shop type gets a distinct interior recipe so a
+// `record` shop instantly reads different from a `toy` shop the moment the door opens.
+//
+// LANE_C says "import SHOP_TYPES from core/registry.js" — Lane A hasn't landed registry.js yet, so
+// this lane stays FULLY STANDALONE: the canonical recipes live here. When registry.js exists, Lane F
+// can call `mergeRegistry(SHOP_TYPES)` to let authored registry data override any field. No hard
+// dependency, so the test page runs with zero other lanes.
+
+// Fitting `kind`s referenced below must exist in fittings.js FITTINGS registry.
+// Zones the layout placer understands: 'window' (front, by glazing), 'aisle' (centre rows),
+// 'wall' (perimeter, uses wall-slots), 'centre' (free-standing island), 'counter' (till corner).
+//
+// Recipe fields:
+// archetypeBias : weighted room-shape preference (keys of shell.ARCHETYPES) — 'auto' picks from here
+// wallpaperBias : biased pick from web/assets/gen/wall-*.jpg (names without prefix/suffix)
+// floorBias : biased pick from carpet/lino/board floor names
+// clutter : 0..1 density multiplier (some shops crammed, some sparse)
+// counterPos : 'door' | 'forward' | 'corner' | 'back' — where the till lives
+// fittings : the mix. Each {kind, zone, min, max, priority}. Counts scale with clutter+area.
+// priority: higher = more essential; flood-fill removes low-priority first if the
+// door→counter path is ever blocked. Counter is always highest.
+// stockKind : what stock.js renders into this shop's carriers
+// signFlavour : seeded inside signage strings, themed
+
+export const ARCH = { cosy: 'cosy', gallery: 'gallery', wide: 'wide', hall: 'hall', pokey: 'pokey' };
+
+const RECIPES = {
+ // ── RECORD ─────────────────────────────────────────────────────────────────────
+ record: {
+ label: 'Record Store',
+ archetypeBias: [['cosy', 3], ['gallery', 2], ['pokey', 1]],
+ wallpaperBias: ['woodchip-white', 'stripe-sage', 'geo-orange'],
+ floorBias: ['boards-polished', 'carpet-greygreen', 'lino-cork'],
+ clutter: 0.85,
+ counterPos: 'corner',
+ fittings: [
+ { kind: 'recordBin', zone: 'aisle', min: 2, max: 5, priority: 5 },
+ { kind: 'crate', zone: 'centre', min: 1, max: 3, priority: 3 },
+ { kind: 'wallShelf', zone: 'wall', min: 1, max: 3, priority: 3 },
+ { kind: 'listeningCorner', zone: 'window', min: 1, max: 1, priority: 4 },
+ { kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 9 },
+ ],
+ stockKind: 'sleeves',
+ signFlavour: ['NEW ARRIVALS', 'VINYL $5 EA', 'SORRY NO REFUNDS', '2ND HAND CDs', 'JAZZ · SOUL · FUNK'],
+ },
+
+ // ── OP SHOP ────────────────────────────────────────────────────────────────────
+ opshop: {
+ label: 'Op Shop',
+ archetypeBias: [['cosy', 3], ['wide', 2], ['hall', 1]],
+ wallpaperBias: ['floral-cream', 'damask-mauve', 'trellis-blue', 'floral-gold'],
+ floorBias: ['carpet-swirl', 'carpet-mustard', 'lino-check'],
+ clutter: 1.0,
+ counterPos: 'corner',
+ fittings: [
+ { kind: 'clothesRack', zone: 'centre', min: 2, max: 5, priority: 5 },
+ { kind: 'metalShelf', zone: 'wall', min: 1, max: 3, priority: 4 },
+ { kind: 'bookshelf', zone: 'wall', min: 1, max: 2, priority: 3 },
+ { kind: 'trestleTable', zone: 'aisle', min: 1, max: 2, priority: 3 },
+ { kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 9 },
+ ],
+ stockKind: 'garments',
+ signFlavour: ['ALL CLOTHES $2', 'BAG SALE SAT', 'BRIC-A-BRAC', 'PROCEEDS TO CHARITY', 'HALF PRICE'],
+ },
+
+ // ── TOY ────────────────────────────────────────────────────────────────────────
+ toy: {
+ label: 'Toy Shop',
+ archetypeBias: [['wide', 3], ['cosy', 2], ['hall', 1]],
+ wallpaperBias: ['geo-orange', 'diamond-green', 'trellis-blue'],
+ floorBias: ['lino-check', 'carpet-greygreen', 'lino-cork'],
+ clutter: 0.75,
+ counterPos: 'corner',
+ fittings: [
+ { kind: 'cubeShelf', zone: 'wall', min: 2, max: 4, priority: 5 },
+ { kind: 'trestleTable', zone: 'aisle', min: 1, max: 3, priority: 3 },
+ { kind: 'glassCase', zone: 'counter', min: 1, max: 1, priority: 4 },
+ { kind: 'metalShelf', zone: 'aisle', min: 1, max: 2, priority: 3 },
+ { kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 9 },
+ ],
+ stockKind: 'boxes',
+ signFlavour: ['NEW! ', 'AS SEEN ON TV', 'PLAY & SAVE', 'BATTERIES EXTRA', 'LAYBY WELCOME'],
+ },
+
+ // ── BOOK ───────────────────────────────────────────────────────────────────────
+ book: {
+ label: 'Book Barn',
+ archetypeBias: [['hall', 3], ['gallery', 2], ['cosy', 1]],
+ wallpaperBias: ['damask-mauve', 'woodchip-white', 'floral-gold'],
+ floorBias: ['carpet-greygreen', 'boards-polished', 'carpet-swirl'],
+ clutter: 0.9,
+ counterPos: 'corner',
+ fittings: [
+ { kind: 'bookshelf', zone: 'aisle', min: 3, max: 5, priority: 5 },
+ { kind: 'bookshelf', zone: 'wall', min: 2, max: 3, priority: 4 },
+ { kind: 'spinnerRack', zone: 'centre', min: 1, max: 2, priority: 3 },
+ { kind: 'armchair', zone: 'window', min: 1, max: 1, priority: 2 },
+ { kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 9 },
+ ],
+ stockKind: 'spines',
+ signFlavour: ['ALL BOOKS $2', 'FICTION A–M', 'RARE & COLLECTABLE', 'NO HAGGLING (haggle at till)', 'POETRY ↑'],
+ },
+
+ // ── VIDEO ──────────────────────────────────────────────────────────────────────
+ video: {
+ label: 'Video Rental',
+ archetypeBias: [['gallery', 3], ['cosy', 2], ['wide', 1]],
+ wallpaperBias: ['diamond-green', 'geo-orange', 'stripe-sage'],
+ floorBias: ['carpet-swirl', 'carpet-greygreen', 'lino-cork'],
+ clutter: 0.85,
+ counterPos: 'door',
+ fittings: [
+ { kind: 'vhsAisle', zone: 'aisle', min: 2, max: 4, priority: 5 },
+ { kind: 'vhsAisle', zone: 'wall', min: 1, max: 2, priority: 4 },
+ { kind: 'returnsSlot', zone: 'counter', min: 1, max: 1, priority: 4 },
+ { kind: 'pegboard', zone: 'wall', min: 1, max: 2, priority: 2 },
+ { kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 9 },
+ ],
+ stockKind: 'spines',
+ signFlavour: ['NEW RELEASES', 'OVERNIGHT $3', 'BE KIND REWIND', 'WEEKLY $2', 'DROP RETURNS HERE'],
+ },
+
+ // ── PAWN ───────────────────────────────────────────────────────────────────────
+ pawn: {
+ label: 'Pawnbroker',
+ archetypeBias: [['pokey', 3], ['cosy', 2], ['gallery', 1]],
+ wallpaperBias: ['woodchip-white', 'stripe-sage', 'damask-mauve'],
+ floorBias: ['lino-cork', 'boards-polished', 'lino-check'],
+ clutter: 0.7,
+ counterPos: 'forward', // counter is right up front; goods behind it
+ fittings: [
+ { kind: 'glassCase', zone: 'counter', min: 1, max: 3, priority: 6 },
+ { kind: 'pegboard', zone: 'wall', min: 1, max: 3, priority: 4 }, // wall hooks
+ { kind: 'metalShelf', zone: 'wall', min: 1, max: 2, priority: 3 },
+ { kind: 'barredWindow', zone: 'window', min: 1, max: 1, priority: 3 },
+ { kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 9 },
+ ],
+ stockKind: 'mixed',
+ signFlavour: ['WE BUY ANYTHING', 'CASH LOANS', 'GOLD · TOOLS · GEAR', 'ID REQUIRED', 'UNREDEEMED PLEDGES'],
+ },
+
+ // ── MILK BAR ─────────────────────────────────────────────────────────────────────
+ milkbar: {
+ label: 'Milk Bar',
+ archetypeBias: [['pokey', 3], ['cosy', 2]],
+ wallpaperBias: ['diamond-green', 'geo-orange', 'stripe-sage'],
+ floorBias: ['lino-check', 'lino-cork', 'carpet-mustard'],
+ clutter: 0.8,
+ counterPos: 'door', // counter faces the door — you're served on the way in
+ fittings: [
+ { kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 9 },
+ { kind: 'fridge', zone: 'wall', min: 1, max: 3, priority: 6 },
+ { kind: 'magazineRack', zone: 'window', min: 1, max: 2, priority: 4 },
+ { kind: 'metalShelf', zone: 'wall', min: 1, max: 3, priority: 4 },
+ { kind: 'cubeShelf', zone: 'aisle', min: 0, max: 1, priority: 2 },
+ ],
+ stockKind: 'snacks',
+ signFlavour: ['MILK · BREAD · SMOKES', 'PIES HOT', 'PAPERS IN', 'PHONECARDS', 'NO SCHOOL KIDS AT LUNCH'],
+ },
+
+ // ── DEPT (anchor) ──────────────────────────────────────────────────────────────
+ dept: {
+ label: 'Department Store',
+ archetypeBias: [['hall', 4], ['wide', 1]],
+ wallpaperBias: ['floral-gold', 'damask-mauve', 'trellis-blue'],
+ floorBias: ['carpet-greygreen', 'carpet-swirl', 'boards-polished'],
+ clutter: 0.7,
+ counterPos: 'corner',
+ fittings: [
+ { kind: 'cubeShelf', zone: 'aisle', min: 2, max: 4, priority: 4 },
+ { kind: 'clothesRack', zone: 'centre', min: 2, max: 4, priority: 4 },
+ { kind: 'glassCase', zone: 'aisle', min: 1, max: 2, priority: 3 },
+ { kind: 'metalShelf', zone: 'wall', min: 2, max: 4, priority: 3 },
+ { kind: 'escalator', zone: 'centre', min: 1, max: 1, priority: 5 }, // the grand-hall centrepiece
+ { kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 9 },
+ ],
+ stockKind: 'mixed',
+ signFlavour: ['MANCHESTER ↑', 'MENSWEAR', 'HABERDASHERY', 'GROUND FLOOR', 'STAFF ONLY'],
+ },
+
+ // ── STALL (market) ───────────────────────────────────────────────────────────────
+ stall: {
+ label: 'Market Stall',
+ archetypeBias: [['wide', 3], ['hall', 1]],
+ wallpaperBias: ['woodchip-white', 'stripe-sage'], // barely walled; open feel
+ floorBias: ['lino-cork', 'boards-polished'],
+ clutter: 0.95,
+ counterPos: 'forward',
+ fittings: [
+ { kind: 'trestleTable', zone: 'aisle', min: 3, max: 6, priority: 5 },
+ { kind: 'crate', zone: 'centre', min: 2, max: 5, priority: 3 },
+ { kind: 'clothesRack', zone: 'wall', min: 0, max: 2, priority: 2 },
+ { kind: 'counter', zone: 'counter', min: 1, max: 1, priority: 8 },
+ ],
+ stockKind: 'boxes',
+ signFlavour: ['$1 EACH', 'CASH ONLY', 'FIRST IN BEST DRESSED', 'MAKE ME AN OFFER', 'FRESH TODAY'],
+ },
+};
+
+// Aliases: accept thriftgod / Overpass-style type names and map to canonical recipes.
+const ALIAS = {
+ music: 'record', records: 'record',
+ charity: 'opshop', second_hand: 'opshop', secondhand: 'opshop', antiques: 'opshop', op_shop: 'opshop',
+ toys: 'toy', toyshop: 'toy',
+ books: 'book', bookshop: 'book', bookbarn: 'book',
+ video_games: 'video', games: 'video', vhs: 'video', rental: 'video',
+ pawnbroker: 'pawn', pawnshop: 'pawn',
+ milk_bar: 'milkbar', deli: 'milkbar', corner_store: 'milkbar',
+ department: 'dept', anchor: 'dept',
+ market: 'stall', stalls: 'stall',
+};
+
+export const SHOP_TYPES = Object.keys(RECIPES);
+
+export function canonicalType(type) {
+ if (!type) return 'opshop';
+ const t = String(type).toLowerCase();
+ if (RECIPES[t]) return t;
+ if (ALIAS[t]) return ALIAS[t];
+ return 'opshop'; // sensible default: an op shop takes anything
+}
+
+export function getRecipe(type) {
+ const key = canonicalType(type);
+ return { key, ...RECIPES[key] };
+}
+
+// Lane F hook: let an authored registry override recipe fields without editing this lane.
+// registry: { [typeKey]: partialRecipe }. Shallow-merges over the built-in recipe.
+export function mergeRegistry(registry) {
+ if (!registry) return;
+ for (const [k, patch] of Object.entries(registry)) {
+ const key = canonicalType(k);
+ if (RECIPES[key]) Object.assign(RECIPES[key], patch);
+ }
+}