Round-4 §Lane C — map the 4 new hero props + cash_register (manifest now
14 fittings), validate, measure, clean up.
- glb.js: map listening_booth→listeningCorner (record), drinks_fridge→fridge
(milkbar), arcade_cabinet→new arcadeCabinet primitive (video focal prop).
New attachCounterTop() places cash_register on every counter's till slot
(hides the primitive till) + milkshake_mixer on the milk-bar benchtop;
counter primitive + keeper stand untouched. All yaw 0 (no correction).
- fittings.js: new arcadeCabinet primitive (footprint/height matched to the
GLB); counter exposes counterTop attach points + tags the primitive till.
- theme.js: arcadeCabinet in the video recipe; the 3 focal props (arcade,
listeningCorner already, +) raised to priority 6 + placed first so the
door-counter path loop can't pull them → 24/24 appearance.
- interiors.js: thread recipe.key to upgradeRoom (gates milkshake_mixer to
milk bars).
- interior_test.html: load GLBs via the live depot: path (drop the deleted
_local_glb localBase shim; ?localdepot=1 still works offline).
Validated (all via live depot): primitive sweep 810 {throws 0, pathFail 0,
detFail 0}; GLB-on soak {throws 0, path 0, det 0, leakGeo 0, leakTex 0,
worst 7.3ms}; all 5 new props render planted/scaled/facing right (contact
sheet docs/shots/laneC/glb_heroprops_r4.jpg). Room tris measured: milk-bar
GLB-on ~468k (drinks_fridge 77k + milkshake_mixer 73k) — reported to Lane E
as the #1 bake-pass target (not fixed, per integrator decision #2; interiors
render one-at-a-time so gate 3 is unaffected). record_crate still broken +
counter still oversized — both await Lane E re-export (C task 5). Deleted the
stale web/assets/_local_glb/ validation copy. qa.sh --strict GREEN.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
396 lines
22 KiB
JavaScript
396 lines
22 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: [] };
|
|
}
|
|
|
|
// 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 };
|
|
}
|
|
|
|
export const FITTINGS = {
|
|
recordBin, crate, listeningCorner,
|
|
wallShelf, metalShelf, cubeShelf, bookshelf, vhsAisle, spinnerRack, arcadeCabinet,
|
|
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;
|
|
}
|