110 fittings placed across 12 shop types via new kit41.js (96-row catalogue, 67 ids in pools) and ONE generic kitProp builder; recipes name pools, never asset ids. glb.js now honours seeded per-fitting variants, multi-GLB booths, and a MEASURED autoYaw instead of a 110-row facing table. THE INSTRUMENT WAS WRONG. drawSweep cleared every interior EXCEPT current, so every historical interior number carried a phantom second room — 'toy', untouched this round, 'gained' 4 draws. R39/R40's celebrated 'worst 188, margin 162' was inflated. Corrected, and with 110 new fittings placed: worst room in the game is 122 @ dept/auto, MARGIN 228. Per type: record 61->78, pub 45->59, band_room 39->50, stall 71->82, book 51->61, pawn 60->70, video 54->63, rsl 52->60, dept 116->122, opshop 102->105, milkbar 68->69, toy 86->86 (+0, the control). The pub finally has a bar (plywood_bar), banquettes, pokies, a stage flank. Milk bars get a bain-marie and pie warmer. The pawn shop gets an 808 and an SP-1200 on the bench. Record shops get a rigged DJ booth. Market stalls get 2-4 diggable bins each, closing R39's measured 44%-no-crate hole to 0/40. WARDROBE, route R5: 257.5 KB total (246 KB atlas + 17 KB index), ONE fetch, one material, one draw per fitting — 52 layers picked BY NAME, alpha-cropped, shelf-packed, 3.7% of source bytes, lazy behind ?stock=real. LICENCE CALL MADE EXPLICITLY: AUDIT.md says no real brands ship and the source library is prompted FROM real labels — the adidas jacket renders a legible trefoil. Screened on a contact sheet, then relabelled in parody voice (Paddy's Bootleg, Kaymart, Coogee Knits, Mombo, Stoosh). FIVE DEFECTS FOUND AND FIXED, one long-standing: buyMesh has been INVISIBLE on every GLB fitting since R9 (hiding every buyable item under ?stock=real) · DJ booths were nondeterministic (parts attached in network order) · booth gear floated at 0.92 m · batch.js dropped userData sub-tags on merge, so any post-batch flag read was already a no-op · the path guarantee could empty a room (pub/wide/4242 returned a bar, a stage and nothing else) — new restore pass, 129 pulled -> 101 restored over 480 rooms, path re-proved on every restore. Gates: 288-room sweep x 4 arms all pass; soak 60 rooms avg 3.83 ms worst 10.3 ms, leaks 0; 960-room corpus 0 path-fails 0 carves; determinism byte-equal across fresh contexts (sha256 c26b4848); ?noassets=1 60 rooms with 0 GLB / 0 manifest / 0 wardrobe / 0 stock fetches. DJ control nodes recorded on fitting.controlNodes for a future driver (glTF drops the limit constraints — a driver must clamp travel itself). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
780 lines
46 KiB
JavaScript
780 lines
46 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, rummage bin (the op shop's own floor bin, R39).
|
||
//
|
||
// 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: [] };
|
||
}
|
||
|
||
// ───────────────────────────────────────────────────────────────────────────────────
|
||
// OP SHOP
|
||
//
|
||
// [R39 — THE OP SHOP CAN'T BE DUG] Until this round `kind:'bin'` — the thing the dig fires on — was
|
||
// minted in exactly two places (recordBin, crate) and used by exactly two recipes (record, stall).
|
||
// The op shop, the type this game is built out of, had clothes racks, shelves, a trestle and a
|
||
// crateStack its own comment calls back-room dressing: **no floor bin at all.** This is the op shop's
|
||
// own bin, and it is deliberately NOT a record crate recoloured:
|
||
// • WIDER THAN DEEP and shallow (0.95 × 0.70 × 0.28 tub) — a dump bin, not a sleeve bin. `crate` is
|
||
// 0.55 × 0.45 and deep, because it is sized to stand LPs up in; nothing stands up in this.
|
||
// • RIM AT 0.76 m — you rummage standing with your elbows in, not crouched over a floor crate.
|
||
// • FLARED SIDES on a castored steel frame — the church-hall / charity-shop dump tub silhouette.
|
||
// • A HAND-LETTERED CARD taped to the front panel ("EVERYTHING $2") — the one thing no record bin
|
||
// in this game has, and the thing that makes it read op shop from across the room.
|
||
// CONTENTS ARE NOT DECIDED HERE. `o.dig` (default **false**) is what mints `kind:'bin'`; with it off
|
||
// this is honest op-shop furniture and the game's economy is untouched. See LANE_C_PUB §10: what a
|
||
// dig in an op shop yields is a John taste call, and turning it on ships "op shops dig RECORDS".
|
||
const RUMMAGE_CARD = ['EVERYTHING $2', 'BRIC-A-BRAC 50¢', '$2 EACH', 'FILL A BAG $5', 'ANY 3 FOR $5'];
|
||
const RUMMAGE_TUB = ['#8d949a', '#6e7f86', '#7c8a6a', '#9a8f7a']; // galvo, faded teal, sage, cream-grey
|
||
function rummageBin(ctx, o = {}, r) {
|
||
const g = new ctx.THREE.Group();
|
||
const w = o.w || 0.95, d = o.d || 0.70;
|
||
const legH = 0.48, tubH = 0.28, th = 0.02, flare = 0.24; // rim = legH + tubH = 0.76 m
|
||
const tub = ctx.mat(RUMMAGE_TUB[(r() * RUMMAGE_TUB.length) | 0], 0.6, { metalness: 0.25 });
|
||
const frame = MAT.metal(ctx);
|
||
// castored steel frame: four legs + a lower cross rail (the rail is where the card is wired on)
|
||
[[-1, -1], [1, -1], [-1, 1], [1, 1]].forEach(([sx, sz]) => {
|
||
ctx.box(0.04, legH, 0.04, frame, sx * (w / 2 - 0.07), legH / 2, sz * (d / 2 - 0.07), g);
|
||
ctx.cyl(0.035, 0.03, frame, sx * (w / 2 - 0.07), 0.015, sz * (d / 2 - 0.07), g); // castor
|
||
});
|
||
ctx.box(w - 0.1, 0.03, 0.03, frame, 0, 0.2, d / 2 - 0.07, g);
|
||
ctx.box(w - 0.1, 0.03, 0.03, frame, 0, 0.2, -(d / 2 - 0.07), g);
|
||
// the tub: outer floor + four panels flared out at the rim, plus a raised inner plate so the goods
|
||
// ride up and crest the rim (the same trick `crate` uses for its sleeves — a full bin, not a hole)
|
||
ctx.box(w, th, d, tub, 0, legH, 0, g);
|
||
ctx.box(w - 0.06, th, d - 0.06, tub, 0, legH + 0.10, 0, g);
|
||
const side = (bw, bh, bd, x, y, z, rx, rz) => {
|
||
const m = ctx.box(bw, bh, bd, tub, x, y, z, g); m.rotation.x = rx; m.rotation.z = rz; return m;
|
||
};
|
||
side(w, tubH, th, 0, legH + tubH / 2, d / 2, flare, 0);
|
||
side(w, tubH, th, 0, legH + tubH / 2, -d / 2, -flare, 0);
|
||
side(th, tubH, d, -w / 2, legH + tubH / 2, 0, 0, -flare);
|
||
side(th, tubH, d, w / 2, legH + tubH / 2, 0, 0, flare);
|
||
// the hand-lettered card, taped to the front panel — the op-shop tell. Sits ON the flared panel's
|
||
// own face: rotating a +Z plane by `flare` about X tilts its normal to (0, −sin, cos), so the card
|
||
// is offset along that normal rather than along +Z, or it z-fights the panel it is taped to.
|
||
const card = ctx.plane(0.52, 0.20, cardMat(ctx, RUMMAGE_CARD[(r() * RUMMAGE_CARD.length) | 0]), g);
|
||
card.position.set(0, legH + tubH / 2 - Math.sin(flare) * 0.018, d / 2 + Math.cos(flare) * 0.018);
|
||
card.rotation.x = flare;
|
||
// mixed goods, tipped in — the trestle-table scatter, not a fan of sleeves. `pool` keeps every face
|
||
// on the room's shared pooled textures, so a full tub is ~0 extra draws (see stock.js shelfLine).
|
||
const slots = [{ kind: 'box', x: 0, y: legH + 0.11, z: 0, ry: 0, run: w - 0.16, depth: d - 0.16,
|
||
height: 0.22, count: o.items ?? 12, lean: false, scatter: true, pool: true }];
|
||
// `dig` is the CONTENTS gate, not a geometry switch — off ⇒ kind 'prop' ⇒ no bin, no dig prompt,
|
||
// no `places` entry, and the room is byte-identical to a room with any other prop in this slot.
|
||
const diggable = o.dig === true;
|
||
g.userData = diggable ? { kind: 'bin', interactable: true } : { kind: 'prop' };
|
||
return { group: g, footprint: { w: w + 0.16, d: d + 0.16 }, height: legH + tubH,
|
||
slots, places: diggable ? [g] : [] };
|
||
}
|
||
|
||
// A biro-on-cardboard price card (the op shop's own signage voice — not the shop's printed signs).
|
||
// The font SHRINKS TO FIT: a fixed size clipped "EVERYTHING $2" to "VERYTHING $" on screen, which is
|
||
// the one thing on this prop the player can actually read.
|
||
function cardMat(ctx, text) {
|
||
const W = 192, H = 72, PAD = 14;
|
||
const cv = document.createElement('canvas'); cv.width = W; cv.height = H;
|
||
const x = cv.getContext('2d');
|
||
x.fillStyle = '#d9c9a4'; x.fillRect(0, 0, W, H); // cardboard
|
||
x.strokeStyle = 'rgba(90,70,40,.45)'; x.lineWidth = 2; x.strokeRect(4, 4, W - 8, H - 8);
|
||
const s = String(text);
|
||
let px = 30;
|
||
do { x.font = `bold ${px}px "Comic Sans MS",cursive,Arial`; px -= 1; }
|
||
while (px > 9 && x.measureText(s).width > W - PAD * 2);
|
||
x.fillStyle = '#1c2f6a'; x.textAlign = 'center'; x.textBaseline = 'middle';
|
||
x.fillText(s, W / 2, H / 2 + 1);
|
||
const m = ctx.mat('#ffffff', 0.85); m.map = ctx.canvasTexture(cv); m.needsUpdate = true;
|
||
return m;
|
||
}
|
||
|
||
// 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 } };
|
||
// [R41] `o.glbId` overrides the default `counter_till` swap for one recipe — the pub's bar is Lane E's
|
||
// `plywood_bar` (1.51 × 0.82 × 1.05), which is what a band-room bar actually looks like. Recipe field
|
||
// `counterGlb`; absent ⇒ counter_till exactly as before.
|
||
return { group: g, footprint: { w: w + 0.1, d: d + 0.1 }, height: h + 0.3, slots: [], places: [g], counterTop,
|
||
glbId: o.glbId || null };
|
||
}
|
||
|
||
// 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: [] };
|
||
}
|
||
|
||
// ── R41 §41.4 — THE KIT (Lane E's 110 new interior GLBs) ─────────────────────────────────
|
||
//
|
||
// ONE builder covers all 110. It picks an id from a pool (`kit41.js POOLS`) on the fitting's own seeded
|
||
// stream, reads that id's REAL footprint/height out of the catalogue, and builds a cheap primitive
|
||
// silhouette at exactly those metres. Two things follow, and they are the whole reason it is shaped this
|
||
// way:
|
||
// • `?noassets=1` gets a real, correctly-sized, walkable shop floor with ZERO fetches (the catalogue is
|
||
// a hardcoded table, and buildInterior is synchronous — see kit41.js's header on why not "read the
|
||
// manifest");
|
||
// • the occupancy grid reserves the GLB's own footprint, so the swap in glb.js needs no rescale and the
|
||
// path/browse guarantees computed before the depot answers stay true after it does.
|
||
//
|
||
// The chosen id rides out on `fitting.glbId`, which `glb.js upgradeFitting` prefers over the legacy
|
||
// per-kind `KIND_TO_GLB` map. Draw cost when the GLB is live: ONE, for every id in the catalogue.
|
||
import { KIT, POOLS, BOOTHS, CONTROL_NODES } from './kit41.js';
|
||
|
||
// Seeded muted palettes per form family — the placeholder should read as the right KIND of object even
|
||
// before (or without) its GLB, which is what makes ?noassets a playable build rather than a grey box.
|
||
const KITCOL = {
|
||
tub: ['#2c2f36', '#d8d5cc', '#2f5aa8', '#7a5a3a', '#5a6a55'],
|
||
rack: ['#6d4a30', '#8a7a5a', '#4a4f57', '#9c8d76'],
|
||
stack: ['#3a3f47', '#6d4a30', '#7a6a55'],
|
||
board: ['#9c8d76', '#c9c2b2', '#6d4a30'],
|
||
unit: ['#b8bcc4', '#8a8d94', '#5a4632', '#c8c2b4'],
|
||
cab: ['#2a2028', '#3a2a30', '#242a34'],
|
||
table: ['#6d4a30', '#4a3524', '#8a7a5a'],
|
||
seat: ['#6a2a2a', '#2a3a5a', '#4a3a2a', '#3a4a3a'],
|
||
barrel: ['#8a8d94', '#3a4a3a', '#6a5a3a', '#b0b4ba'],
|
||
post: ['#4a4f57', '#8a3a2a', '#8a8d94'],
|
||
slab: ['#22242a', '#3a3f47', '#6d5a3a'],
|
||
speaker: ['#16161a', '#1e1e24'],
|
||
deck: ['#26262c', '#3a3a42', '#b8bcc4'],
|
||
panel: ['#7a6a4a', '#4a4f57', '#c9c2b2'],
|
||
column: ['#c9c2b2', '#a8a49a', '#8a8d94'],
|
||
trolley: ['#8a8d94', '#4a4f57'],
|
||
};
|
||
|
||
// Primitive silhouettes. Each draws into `g` at base y=0, footprint centred on (0,0), "front" = +Z.
|
||
const KITFORM = {
|
||
tub(ctx, g, w, d, h, m, r) {
|
||
const th = 0.022, lip = h * 0.78;
|
||
ctx.box(w, th, d, m, 0, th / 2, 0, g); // floor
|
||
ctx.box(th, h, d, m, -w / 2 + th / 2, h / 2, 0, g); ctx.box(th, h, d, m, w / 2 - th / 2, h / 2, 0, g);
|
||
ctx.box(w, h, th, m, 0, h / 2, -d / 2 + th / 2, g); ctx.box(w, h * 0.86, th, m, 0, h * 0.43, d / 2 - th / 2, g);
|
||
ctx.box(w - 0.05, th, d - 0.05, m, 0, lip, 0, g); // goods ride up and crest the rim
|
||
},
|
||
rack(ctx, g, w, d, h, m, r) {
|
||
const leg = ctx.mat('#4a4038', 0.7);
|
||
const tiers = h > 0.95 ? 3 : 2;
|
||
[[-1, -1], [1, -1], [-1, 1], [1, 1]].forEach(([sx, sz]) =>
|
||
ctx.box(0.05, h, 0.05, leg, sx * (w / 2 - 0.04), h / 2, sz * (d / 2 - 0.04), g));
|
||
for (let i = 0; i < tiers; i++) ctx.box(w, 0.035, d, m, 0, 0.14 + i * ((h - 0.16) / Math.max(1, tiers - 1 || 1)), 0, g);
|
||
ctx.box(w, h * 0.5, 0.03, m, 0, h * 0.25, -d / 2 + 0.015, g); // back panel
|
||
},
|
||
stack(ctx, g, w, d, h, m, r) {
|
||
const n = 4, ch = h / n, th = 0.02;
|
||
for (let i = 0; i < n; i++) {
|
||
const dd = d * (1 - i * 0.14);
|
||
ctx.box(w, th, dd, m, 0, i * ch + th / 2, (d - dd) / 2 * -0.5, g);
|
||
ctx.box(w, ch * 0.9, th, m, 0, i * ch + ch * 0.45, -dd / 2, g);
|
||
ctx.box(th, ch * 0.9, dd, m, -w / 2, i * ch + ch * 0.45, (d - dd) / 2 * -0.5, g);
|
||
ctx.box(th, ch * 0.9, dd, m, w / 2, i * ch + ch * 0.45, (d - dd) / 2 * -0.5, g);
|
||
}
|
||
},
|
||
board(ctx, g, w, d, h, m, r) {
|
||
const t = Math.max(0.03, Math.min(h, 0.05));
|
||
ctx.box(w, t, Math.max(d, 0.22), m, 0, t / 2, 0.08, g); // the plank
|
||
if (h > 0.4) { ctx.box(w, h, 0.03, m, 0, h / 2, -0.02, g); ctx.box(w, t, Math.max(d, 0.22), m, 0, h - t / 2, 0.08, g); }
|
||
[-1, 1].forEach(s => ctx.box(0.04, Math.min(h, 0.24), 0.2, ctx.mat('#4a4038', 0.7), s * (w / 2 - 0.08), -0.06, 0.02, g));
|
||
},
|
||
unit(ctx, g, w, d, h, m, r) {
|
||
ctx.box(w, h, d, m, 0, h / 2, 0, g);
|
||
ctx.box(w + 0.03, 0.04, d + 0.03, ctx.mat('#3a3730', 0.7), 0, h, 0, g); // capping
|
||
ctx.box(w * 0.8, h * 0.5, 0.02, ctx.mat('#2a2a30', 0.6), 0, h * 0.42, d / 2 + 0.01, g);
|
||
},
|
||
cab(ctx, g, w, d, h, m, r) {
|
||
ctx.box(w, h, d, m, 0, h / 2, 0, g);
|
||
const lit = ctx.mat('#e8c05a', 0.4, { emissive: new ctx.THREE.Color('#4a3a10') });
|
||
ctx.box(w * 0.86, h * 0.34, 0.03, lit, 0, h * 0.62, d / 2 + 0.015, g); // lit glass
|
||
ctx.box(w * 0.7, 0.1, 0.05, ctx.mat('#1a1a1e', 0.5), 0, h * 0.34, d / 2 + 0.02, g);
|
||
},
|
||
table(ctx, g, w, d, h, m, r) {
|
||
ctx.box(w, 0.05, d, m, 0, h, 0, g);
|
||
const leg = ctx.mat('#4a4038', 0.7);
|
||
if (Math.abs(w - d) < 0.25) ctx.cyl(0.05, h, leg, 0, h / 2, 0, g); // round bistro: centre post
|
||
else [[-1, -1], [1, -1], [-1, 1], [1, 1]].forEach(([sx, sz]) =>
|
||
ctx.box(0.06, h, 0.06, leg, sx * (w / 2 - 0.1), h / 2, sz * (d / 2 - 0.1), g));
|
||
if (Math.abs(w - d) < 0.25) ctx.cyl(w * 0.28, 0.04, leg, 0, 0.02, 0, g); // and a foot
|
||
},
|
||
seat(ctx, g, w, d, h, m, r) {
|
||
ctx.box(w, h * 0.5, d, m, 0, h * 0.25, 0, g); // base
|
||
ctx.box(w, h * 0.55, d * 0.22, m, 0, h * 0.62, -d / 2 + d * 0.11, g); // back
|
||
ctx.box(w, 0.1, d * 0.8, m, 0, h * 0.5, d * 0.06, g); // cushion
|
||
},
|
||
barrel(ctx, g, w, d, h, m, r) {
|
||
ctx.cyl(Math.min(w, d) / 2, h, m, 0, h / 2, 0, g);
|
||
ctx.cyl(Math.min(w, d) / 2 * 0.94, 0.03, ctx.mat('#3a3730', 0.6), 0, h, 0, g);
|
||
},
|
||
post(ctx, g, w, d, h, m, r) {
|
||
ctx.cyl(Math.min(0.06, Math.min(w, d) / 2), h, m, 0, h / 2, 0, g);
|
||
ctx.cyl(Math.min(w, d) / 2, h * 0.16, m, 0, h - h * 0.08, 0, g); // head
|
||
ctx.cyl(Math.min(w, d) / 2 * 0.9, 0.03, m, 0, 0.015, 0, g); // foot
|
||
},
|
||
slab(ctx, g, w, d, h, m, r) {
|
||
ctx.box(w, h, d, m, 0, h / 2, 0, g);
|
||
ctx.box(w + 0.02, 0.03, d + 0.02, ctx.mat('#b8bcc4', 0.4, { metalness: 0.6 }), 0, h * 0.7, 0, g);
|
||
},
|
||
speaker(ctx, g, w, d, h, m, r) {
|
||
ctx.box(w, h, d, m, 0, h / 2, 0, g);
|
||
const gr = ctx.mat('#0c0c10', 0.6);
|
||
ctx.box(w - 0.08, h * 0.3, 0.02, gr, 0, h * 0.3, d / 2, g);
|
||
ctx.box(w - 0.12, h * 0.14, 0.02, gr, 0, h * 0.78, d / 2, g);
|
||
},
|
||
deck(ctx, g, w, d, h, m, r) {
|
||
ctx.box(w, h, d, m, 0, h / 2, 0, g);
|
||
ctx.cyl(Math.min(w, d) * 0.32, 0.012, ctx.mat('#b8bcc4', 0.3, { metalness: 0.7 }), -w * 0.06, h + 0.006, 0, g);
|
||
ctx.box(w * 0.1, 0.012, d * 0.5, ctx.mat('#1a1a1e', 0.5), w * 0.36, h + 0.006, 0, g);
|
||
},
|
||
panel(ctx, g, w, d, h, m, r) {
|
||
ctx.box(w, h, Math.max(d, 0.03), m, 0, h / 2, 0, g);
|
||
ctx.box(w * 0.86, h * 0.7, 0.01, ctx.mat('#d9cfae', 0.8), 0, h * 0.52, Math.max(d, 0.03) / 2 + 0.006, g);
|
||
},
|
||
column(ctx, g, w, d, h, m, r) {
|
||
ctx.box(w, h, d, m, 0, h / 2, 0, g);
|
||
ctx.box(w + 0.08, 0.1, d + 0.08, m, 0, 0.05, 0, g);
|
||
ctx.box(w + 0.08, 0.1, d + 0.08, m, 0, h - 0.05, 0, g);
|
||
},
|
||
trolley(ctx, g, w, d, h, m, r) {
|
||
for (let i = 0; i < 3; i++) ctx.box(w, 0.03, d, m, 0, 0.14 + i * ((h - 0.16) / 2), 0, g);
|
||
[[-1, -1], [1, -1], [-1, 1], [1, 1]].forEach(([sx, sz]) => {
|
||
ctx.box(0.04, h, 0.04, m, sx * (w / 2 - 0.03), h / 2, sz * (d / 2 - 0.03), g);
|
||
ctx.cyl(0.05, 0.03, ctx.mat('#2a2a30', 0.5), sx * (w / 2 - 0.03), 0.05, sz * (d / 2 - 0.03), g);
|
||
});
|
||
},
|
||
};
|
||
|
||
// Where stock sits on each form (local coords), so a kit fitting is FULL, not an empty shell. Every slot
|
||
// is `pool:true` — the R39 trick: pooled white faces merge into the room's existing stock buckets, so a
|
||
// heaped tub costs ~0 extra draws (an un-pooled tinted fill cost one draw per item).
|
||
function kitSlots(form, w, d, h, kind, count) {
|
||
if (!kind) return [];
|
||
// A 12" SLEEVE IS 0.31 m SQUARE — a constant of the physical world, not a fraction of whatever tub it
|
||
// is standing in. Deriving it from the tub height (h * 1.3) put 0.42 m "records" in a 0.32 m crate and
|
||
// they read as posters; `recordBin`/`crate` have used 0.28–0.32 since v1 and this now matches them.
|
||
const SLEEVE_H = 0.30;
|
||
if (form === 'tub')
|
||
return [kind === 'sleeve'
|
||
? { kind: 'sleeve', x: 0, y: h * 0.72, z: 0, faceW: Math.min(0.31, w * 0.86), run: d * 0.84, height: SLEEVE_H, count, tilt: -0.32 }
|
||
: { kind: 'box', x: 0, y: h * 0.78, z: 0, ry: 0, run: w - 0.06, depth: d - 0.06, height: h * 0.8, count, lean: false, scatter: true, pool: true }];
|
||
if (form === 'rack' || form === 'trolley') {
|
||
// TIER HEIGHTS ARE FRACTIONS OF h, NOT AN EVEN LADDER, AND THEY SIT *IN* THE TUBS. Most of the rack
|
||
// pool is `3dstore` TUB RACKS — open tubs on a frame — so goods parked at y = h stand ON TOP of the
|
||
// tub rims and the tub itself reads as an empty wireframe box (it is thin-walled and open; at a
|
||
// grazing angle you see straight through it). 0.82h puts the sleeves inside the upper tub with the
|
||
// top third cresting the rim, which is what a record tub actually looks like, and it fills the hole.
|
||
const ys = form === 'trolley' ? [h * 0.92, h * 0.55, h * 0.18]
|
||
: (h > 0.95 ? [h * 0.86, h * 0.55, h * 0.22] : [h * 0.82, h * 0.28]);
|
||
return ys.map(y => (kind === 'sleeve'
|
||
? { kind: 'sleeve', x: 0, y, z: 0, faceW: Math.min(0.31, w * 0.9), run: d * 0.78, height: SLEEVE_H, count, tilt: -0.3 }
|
||
: { kind, x: 0, y: y + 0.02, z: 0, ry: 0, run: w - 0.1, depth: d * 0.68, height: 0.26, count, lean: kind === 'spine', pool: true }));
|
||
}
|
||
if (form === 'stack') {
|
||
const out = [];
|
||
for (let i = 0; i < 4; i++) out.push({ kind: 'sleeve', x: 0, y: (h / 4) * i + h / 4 * 0.62, z: 0, faceW: Math.min(0.31, w * 0.86), run: d * 0.6, height: SLEEVE_H * 0.85, count: Math.max(3, (count / 2) | 0), tilt: -0.28 });
|
||
return out;
|
||
}
|
||
if (form === 'board')
|
||
return [{ kind: kind === 'sleeve' ? 'box' : kind, x: 0, y: 0.06, z: 0.06, ry: 0, run: w - 0.14, depth: 0.2, height: 0.28, count, lean: false, pool: true }];
|
||
return [];
|
||
}
|
||
|
||
// Build one kit fitting. `o.pool` = an id list from kit41 POOLS (required — no pool ⇒ a plain shelf).
|
||
// o.dig true ⇒ userData.kind 'bin' (the dig fires on it). DEFAULT FALSE: R39 held "what an op shop
|
||
// yields" for John, so only recipes that already dig (record, stall) ever pass it.
|
||
// o.stock 'sleeve'|'box'|'spine'|'garment' — what stock.js fills it with (null ⇒ empty prop)
|
||
// o.wall true ⇒ wall-mounted (no floor footprint stamped); o.mountY = base height
|
||
// o.maxRun clamp: ids whose longest floor dimension exceeds this are filtered out of the pool BEFORE
|
||
// the seeded pick (TRAP 3, generalised — a 4.6 m board in a 6 m room).
|
||
function kitProp(ctx, o = {}, r) {
|
||
const g = new ctx.THREE.Group();
|
||
const pool = (o.pool || []).filter(id => KIT[id] && (!o.maxRun || Math.max(KIT[id][0], KIT[id][1]) <= o.maxRun));
|
||
const id = pool.length ? pool[(r() * pool.length) | 0] : null;
|
||
const row = id ? KIT[id] : [0.6, 0.4, 0.6, 'unit'];
|
||
// WALL FITTINGS ARE X-MAJOR BY HOUSE CONVENTION (pegboard/barredWindow/artFrame all are, and
|
||
// placeAtWall's slot.ry is what turns local +X along the wall). Several kit ids are authored Z-major
|
||
// (`shelf_board_short` is 0.48 × 1.95), so a wall row is transposed here — and glb.js's autoYaw then
|
||
// reads the same swapped footprint and gives the GLB the matching quarter turn. Floor props are NOT
|
||
// transposed: the placer's own ry orients them.
|
||
const swap = !!o.wall && row[1] > row[0];
|
||
const [w, d, h, form] = swap ? [row[1], row[0], row[2], row[3]] : row;
|
||
const pal = KITCOL[form] || KITCOL.unit;
|
||
const m = ctx.mat(pal[(r() * pal.length) | 0], form === 'speaker' || form === 'deck' ? 0.6 : 0.82,
|
||
form === 'barrel' || form === 'post' ? { metalness: 0.25 } : {});
|
||
(KITFORM[form] || KITFORM.unit)(ctx, g, w, d, h, m, r);
|
||
const slots = kitSlots(form, w, d, h, o.stock, o.items ?? 8);
|
||
const diggable = o.dig === true && (form === 'tub' || form === 'rack' || form === 'stack');
|
||
g.userData = diggable ? { kind: 'bin', interactable: true } : { kind: o.uk || 'prop' };
|
||
const fit = {
|
||
group: g, footprint: { w: w + 0.08, d: d + 0.08 }, height: h, slots,
|
||
places: diggable ? [g] : [], glbId: id,
|
||
};
|
||
if (o.wall) { fit.wallMounted = true; fit.mountY = o.mountY ?? 1.45; }
|
||
return fit;
|
||
}
|
||
|
||
// A DJ booth: a primitive bench with several GLBs standing ON it (`fitting.glbParts`, the one multi-GLB
|
||
// shape in the lane — DJ gear is desktop kit, a 1200 is 0.11 m tall). R41 PLACES the rigged models and
|
||
// records their control-node names; NOTHING DRIVES THEM this round (that is D/F's later work), and this
|
||
// builder deliberately ships no animation hook so nobody mistakes a placed node for a live one.
|
||
function djBooth(ctx, o = {}, r) {
|
||
const spec = BOOTHS[o.booth] || BOOTHS.venue;
|
||
const g = new ctx.THREE.Group();
|
||
const { w, d, h } = spec;
|
||
const body = ctx.mat(r() < 0.5 ? '#3a3128' : '#2a2a30', 0.8);
|
||
ctx.box(w, h, d, body, 0, h / 2, 0, g); // the bench
|
||
ctx.box(w + 0.06, 0.05, d + 0.06, ctx.mat('#1c1a18', 0.7), 0, h, 0, g); // bench top edge
|
||
ctx.box(w - 0.1, 0.28, d * 0.55, ctx.mat('#1a1a1e', 0.6), 0, h * 0.32, -d * 0.1, g); // rack space under
|
||
const nodes = {};
|
||
// primitive stand-ins on the bench top, drawn straight into `g` on ONE shared material so batch.js
|
||
// merges them (a child Group per part would have escaped the batcher and cost a draw each).
|
||
const gear = ctx.mat('#26262c', 0.6), plate = ctx.mat('#b8bcc4', 0.3, { metalness: 0.7 });
|
||
for (const p of spec.parts) {
|
||
const [pw, pd, ph] = KIT[p.id] || [0.3, 0.3, 0.1];
|
||
// `boothGear` marks the stand-in that a GLB part REPLACES. The bench underneath is deliberately not
|
||
// marked: a booth's GLBs are the gear only, so hiding the whole primitive (what a single-GLB swap
|
||
// does) left a 1200 and a mixer hovering at 0.92 m over bare floorboards. Measured on screen, R41.
|
||
ctx.box(pw, ph, pd, gear, p.x, h + ph / 2, p.z, g).userData.boothGear = true;
|
||
ctx.cyl(Math.min(pw, pd) * 0.3, 0.012, plate, p.x - pw * 0.06, h + ph + 0.006, p.z, g).userData.boothGear = true;
|
||
if (CONTROL_NODES[p.id]) nodes[p.id] = CONTROL_NODES[p.id];
|
||
}
|
||
g.userData = { kind: 'prop', djBooth: o.booth || 'venue', controlNodes: nodes };
|
||
return {
|
||
group: g, footprint: { w: w + 0.12, d: d + 0.12 }, height: h + 0.25, slots: [], places: [],
|
||
// consumed by glb.js: one GLB per part, planted on the bench top (y = h).
|
||
glbParts: spec.parts.map(p => ({ id: p.id, x: p.x, y: h, z: p.z, ry: p.ry || 0 })),
|
||
controlNodes: nodes,
|
||
};
|
||
}
|
||
export const FITTINGS = {
|
||
recordBin, crate, crateStack, listeningCorner, rummageBin,
|
||
wallShelf, metalShelf, cubeShelf, bookshelf, vhsAisle, spinnerRack, arcadeCabinet,
|
||
clothesRack,
|
||
trestleTable, counter, glassCase,
|
||
pegboard, fridge, magazineRack, armchair, barredWindow, returnsSlot, escalator, artFrame,
|
||
stage, paSpeaker, ampStack,
|
||
// [R41 §41.4] the kit. Registered under several NAMES that all build the same way, because
|
||
// layout.js keys each spec's seeded stream on `fit-<kind>-<i>` — two specs sharing a kind in one recipe
|
||
// would share a stream and therefore pick the SAME id. One name per pool per recipe keeps them distinct.
|
||
kitBin: kitProp, kitRack: kitProp, kitWall: kitProp, kitWindow: kitProp, kitSleeve: kitProp,
|
||
kitTrolley: kitProp, kitColumn: kitProp,
|
||
kitSeat: kitProp, kitFloor: kitProp, kitVenue: kitProp, kitHot: kitProp, kitGear: kitProp,
|
||
djBooth,
|
||
};
|
||
|
||
// 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;
|
||
}
|