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>
451 lines
26 KiB
JavaScript
451 lines
26 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 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); // 2–3 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;
|
||
}
|