PROCITY/web/js/interiors/fittings.js
m3ultra 05e020e3b8 Lane C: round-12 pub venue — archetype + watchPoints + audio.gigKey (?gigs=1)
v3.0-alpha band room. New pub recipe (venue:true): stage + PA + amps at the back wall (new stage/paSpeaker/ampStack fittings, GLB slots stage/pa_speaker/amp for E), bar counter (keeper), sticky carpet, blocked back door = green room.

buildInterior returns for venues: room.watchPoints[] (6-8 seeded audience poses {x,z,ry,dance} facing the stage, dance ~1/3, counter.stand ry convention); room.stage {x,z,w,d,deckY,frontZ,bandPoses[3]} for Lane D's band; room.audio.gigKey ('gig-'+genreKey) set when Lane F passes opts.gig.

Flags-off byte-identical: venue code runs only for recipe.venue on its own seed sub-streams; non-pub rooms have watchPoints:[], stage:null, unchanged placement/audio.

Verified: 40-seed pub sweep (0 throws/carves/path-fails, 8 watchPoints, dance 0.38) + 60-room mixed soak (0 det/path fails, leak geo0/tex0, worst 19ms). Non-pub unchanged. Interface for D/F: docs/LANES/LANE_C_PUB.md. scaffold + manifest green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 18:59:48 +10:00

451 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 stack of empty crates — back-room dressing for record/op shops (GLB `crate_stack` upgrades it).
function crateStack(ctx, o = {}, r) {
const g = new ctx.THREE.Group();
const w = o.w || 0.66, d = o.d || 0.66, ch = 0.3, th = 0.02;
const n = 2 + (r() * 2 | 0); // 23 crates
for (let i = 0; i < n; i++) {
const m = ctx.mat(i % 2 ? '#4a5a6a' : '#8a6a45', 0.8);
const ox = (r() - 0.5) * 0.06, oz = (r() - 0.5) * 0.06, y = i * ch;
ctx.box(w, th, d, m, ox, y + th / 2, oz, g); // floor
ctx.box(th, ch, d, m, ox - w / 2, y + ch / 2, oz, g); ctx.box(th, ch, d, m, ox + w / 2, y + ch / 2, oz, g);
ctx.box(w, ch, th, m, ox, y + ch / 2, oz - d / 2, g); ctx.box(w, ch, th, m, ox, y + ch / 2, oz + d / 2, g);
}
g.userData = { kind: 'prop' };
return { group: g, footprint: { w: w + 0.1, d: d + 0.1 }, height: n * ch, slots: [], places: [] };
}
// 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: [] };
}
// Upright arcade cabinet — the video-store focal prop (GLB `arcade_cabinet` upgrades it).
// Built facing +Z (screen toward the room), footprint/height matched to the manifest so the GLB drops in.
function arcadeCabinet(ctx, o = {}, r) {
const g = new ctx.THREE.Group();
const w = o.w || 0.72, h = o.h || 1.7, d = o.d || 0.82;
const body = ctx.mat(['#7a2a3a', '#2a3a7a', '#2a6a4a', '#6a3a2a'][(r() * 4) | 0], 0.6);
const black = MAT.black(ctx);
ctx.box(w, h, d * 0.55, body, 0, h / 2, -d * 0.2, g); // tall body (set back)
ctx.box(w, h * 0.5, d * 0.45, body, 0, h * 0.25, d * 0.05, g); // lower front (kickplate)
ctx.box(w + 0.04, 0.2, 0.14, ctx.mat('#f0e070', 0.4, { emissive: new ctx.THREE.Color('#403a10') }), 0, h - 0.1, 0.02, g); // lit marquee
const scr = ctx.box(w - 0.14, 0.52, 0.04, ctx.mat('#0a0e14', 0.25, { emissive: new ctx.THREE.Color('#0c1626') }), 0, h * 0.7, 0.16, g);
scr.rotation.x = 0.22; // slanted dark screen, faces +Z
const cp = ctx.box(w - 0.06, 0.34, 0.05, black, 0, h * 0.46, d * 0.22, g); cp.rotation.x = -1.0; // control panel
ctx.cyl(0.018, 0.13, MAT.chrome(ctx), -0.15, h * 0.5, d * 0.24, g);
ctx.cyl(0.018, 0.13, MAT.chrome(ctx), 0.15, h * 0.5, d * 0.24, g);
[['#e04a3a', -0.02], ['#f0b428', 0.05], ['#4ac04a', 0.12]].forEach(([c, bx]) => ctx.cyl(0.02, 0.03, ctx.mat(c, 0.4), bx, h * 0.51, d * 0.27, g));
ctx.box(0.22, 0.16, 0.02, ctx.mat('#c8a13a', 0.4), 0, h * 0.14, d * 0.28, g); // coin door
g.userData = { kind: 'prop', interactable: true };
return { group: g, footprint: { w, d }, height: h, slots: [], places: [g] };
}
// 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
const till = 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;
till.userData = { tillPrimitive: true }; // glb.js hides these when cash_register lands
scr.userData = { tillPrimitive: true };
body.userData = { kind: 'counter', interactable: true };
g.userData = { kind: 'counter', interactable: true };
// Counter-top attach points (local space, on the benchtop top surface) for GLB counter-top props
// (cash_register on the till spot; milkshake_mixer on the free left spot). y = benchtop top.
const counterTop = { y: h + 0.03, till: { x: w / 2 - 0.35, z: 0 }, appliance: { x: -w / 2 + 0.4, z: -0.02 } };
return { group: g, footprint: { w: w + 0.1, d: d + 0.1 }, height: h + 0.3, slots: [], places: [g], counterTop };
}
// 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 };
}
// ── VENUE (pub, round 12 · behind ?gigs=1) ───────────────────────────────────────────
// Low band stage — a timber deck with a dark backdrop + a drum riser, against the back wall. The band
// (Lane D) stands on the deck (`deckY` = top surface). GLB slot: `stage` (E's when present, else this).
function stage(ctx, o = {}, r) {
const g = new ctx.THREE.Group();
const w = o.w || 3.4, d = o.d || 2.0, h = o.h || 0.32;
const deck = ctx.mat('#3a2a1e', 0.85), riser = ctx.mat('#2a2018', 0.85);
ctx.box(w, h, d, deck, 0, h / 2, 0, g); // deck
ctx.box(w + 0.06, 0.06, d + 0.06, ctx.mat('#1c130d', 0.9), 0, h, 0, g); // deck edge trim
ctx.box(w, 2.2, 0.06, ctx.mat('#161016', 0.95), 0, h + 1.1, -d / 2 + 0.05, g); // backdrop curtain
ctx.box(1.0, 0.16, 0.9, riser, 0, h + 0.08, -d / 4, g); // drum riser, up-stage centre
g.userData = { kind: 'stage' };
return { group: g, footprint: { w: w + 0.1, d: d + 0.1 }, height: h + 2.3, slots: [], places: [g], deckY: h };
}
// PA speaker stack — a tall black cabinet flanking the stage. GLB slot: `pa_speaker`.
function paSpeaker(ctx, o = {}, r) {
const g = new ctx.THREE.Group();
const w = 0.6, d = 0.55, h = o.h || 1.7;
ctx.box(w, h, d, ctx.mat('#1a1a1e', 0.7), 0, h / 2, 0, g); // cabinet
const grille = ctx.mat('#0c0c10', 0.6);
ctx.box(w - 0.1, 0.5, 0.02, grille, 0, h * 0.32, d / 2, g); // woofer face
ctx.box(w - 0.14, 0.24, 0.02, grille, 0, h - 0.32, d / 2, g); // horn face
g.userData = { kind: 'prop' };
return { group: g, footprint: { w, d }, height: h, slots: [], places: [] };
}
// Guitar/bass combo amp — sits ON the stage deck behind the band. GLB slot: `amp`.
function ampStack(ctx, o = {}, r) {
const g = new ctx.THREE.Group();
const w = 0.62, d = 0.36, h = 0.5;
ctx.box(w, h, d, ctx.mat(r() < 0.5 ? '#2a2320' : '#20242a', 0.8), 0, h / 2, 0, g);
ctx.box(w - 0.06, 0.3, 0.02, ctx.mat('#100f10', 0.6), 0, h * 0.55, d / 2, g); // grille cloth
g.userData = { kind: 'prop' };
return { group: g, footprint: { w, d }, height: h, slots: [], places: [] };
}
export const FITTINGS = {
recordBin, crate, crateStack, listeningCorner,
wallShelf, metalShelf, cubeShelf, bookshelf, vhsAisle, spinnerRack, arcadeCabinet,
clothesRack,
trestleTable, counter, glassCase,
pegboard, fridge, magazineRack, armchair, barredWindow, returnsSlot, escalator, artFrame,
stage, paSpeaker, ampStack,
};
// 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;
}