PROCITY/web/js/interiors/fittings.js
m3ultra 422f17263e Lane C R41 §41.4: the shops get their stock — and the measuring instrument was wrong
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>
2026-08-07 18:11:07 +10:00

780 lines
46 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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); // 23 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.280.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;
}