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>
370 lines
20 KiB
JavaScript
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;
|
|
}
|