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

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

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

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

370 lines
20 KiB
JavaScript

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