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