PROCITY/web/js/interiors/fittings.js
m3ultra 293a82c5e8 R42 THE LOOK: five lane-runs — the pub is dressed, the decks read, and the town is cast
John looked at R41's proof shots and named three faults. All three are fixed, at zero draw
cost, and every lane corrected somebody's numbers on the way — including its own.

E-LOOK (§42.1+§42.2a) — THE PREMISE WAS WRONG. "The pub props have no texture" (my brief,
quoting E's own R41 note) is false: all 37 carry COLOR_0 and three.js renders it. The colour
was BLEACHED — jukebox at saturation 0.02, 15 assets under 0.05; what the source library
called colour was baked AO. So the fix was a palette imposed on colour already present, in
place: 46 assets, draw delta +0 and tri delta +0 on every one, proved the honest way — the
concatenated POSITION/NORMAL/TEXCOORD/index bytes are sha1-identical to R41, so footprints
and origins provably did not move. (A first-pass bbox check ignored node rotations and
"found" 3 phantom violations; thrown out for the byte comparison.) The banquette was 1990s
office teal and went burgundy; the beer umbrella was white and went brewery green.
THE DECK, diagnosed before being touched: 7 greys, chassis 0.05 luminance on a 0.033 bench =
1.21:1, and the one high-contrast feature was INSIDE-OUT (white ring around a dark disc; a
turntable is a dark record on a light platter). Now Technics silver at 6.74:1, black vinyl,
red start/stop. Node contract re-read from the shipped GLB: 4/4 + 1/1 PASS. Filed to R43,
not fixed: deck_1200_rigged has NO PLINTH (bottom 43mm of 119mm lost to R41 join damage).

E-CAST (§42.3) — 19 of 24 ship. Rejected: comical_luchador_01 (Mixamo Ch43, purple mask
reading EL CHUPACABRA, identified from the file's own material name), a 3-heads-tall cartoon
child, a robed wuxia elder, a WW2 infantryman — and dj_phrtt_01, AN UNCLOTHED BODY whose only
texture is a bare-skin atlas, which was in the DEFAULT crowd and live on the depot. It is the
R41 deny-list's failure class through a different door: an innocent name from another
pipeline. Only rendering all 24 and LOOKING found it.
Ruling 2 (prefer CC0) was REFUSED ON MEASUREMENT: Quaternius is 25 nodes / 23 joints and ZERO
of 64 canonical tracks bind — it would have loaded silently and never animated. Replacements
came from Mixamo's Ch## pool, filtered to the contract-clean 65-bone family (candidates ran
65..99 bones; named characters carry facial/hair bones).
THE LICENCE GAP WAS UNREAD, NOT MISSING: every source FBX carries ApplicationVendor "Mixamo,
Inc." internally. Adobe royalty-free, no attribution owed. B's amber rows are now real.

B (§42.5) — MY ASSIGNED FIX WAS USELESS AND B PROVED IT. document.fonts.ready shipped, then
measured: the atlas canvases are already byte-identical across boots, the game ships NO
webfonts (document.fonts.size === 0), and ready fires 50ms BEFORE the first fillText. The
real cause of unreproducible bookmarks is the wall-clock CROWD — 983 changed px of 921,600,
ALL in the pedestrian band; 0 px with the crowd out. New DBG.freeze() takes stable bookmarks
2/5 -> 5/5. Also found: ?pop=0 boots 140 citizens (0 is falsy against the default).
THE BENCH: D's filing correct on both counts. Front-vs-road 90.0deg -> 0.0deg, min=med=max
over 60/60 instances; the streetlight arm now reaches 1.05m over the road where it reached
0.00. Both used a base yaw that pointed +Z down the street while their own comments claimed
otherwise — the side term was already right, which is why it survived 41 rounds of reading.
Also: on any box with CDN access 9 of 60 benches are the depot GLB, 90deg out from the house
law — normalised at the geometry, once, at load. benchStops(plan) exported as one truth.

C (§42.2b) — 122 vs 123 WAS THE CAMERA ASPECT, and nobody was wrong. Draw counts are
post-frustum-cull and the sweep camera inherits the window aspect: 1.25->116, 1.60->122 (C's
harness), 1.778->123 (F's), 2.37->124. Pinned MEASURE_ASPECT=16/9 with a 1.25 control.
E's "keep the bench dark" advice was measured and declined with reason (deck-vs-bench was
already 4.73:1, over threshold). "Reads as a black box" is a claim about pixels, so C measured
pixels: near-black in the booth crop 57.5% -> 26.7%, distinct colours 228 -> 389. Bench
rebuilt 3->7 boxes with an open record bay; headphones on the mixer; faceDoor aims booths at
the player's entry — alignment within 45deg went 49% -> 92%. Two bugs found by looking: a
fitting could be planted INSIDE A WALL (0.77m of a 0.86m half-depth in plaster), and every
booth part sat 25mm sunk into its own worktop.

D (§42.4) — five swaps IN PLACE at the same index (pickRig indexes these arrays and pedIndex
is in the identity signature and the impostor atlas; removal would renumber the town).
Identity signature pre- vs post-swap: 0 differing lines of 150 — who each citizen is did not
move, only which body they wear. dj_phrtt_01 verified gone AT THE WIRE, not from source: a
fresh default boot fetches 32 ped GLBs, none retired.
THE CONTROL ARM FOUND A 41-ROUND-OLD BUG: the ped being RETIRED, comical_boy_01, binds only
3,132 of 3,460 tracks — six thumb bones lack the mixamorig prefix. It had been silently
dropping six tracks per clip since it shipped. Testing the thing being thrown away found it.
Imported B's benchStops mid-round: sitters now 0.0deg to the road, signatures byte-identical
across the rewire. Corrected F's filing: the browse smoke was flaky-by-construction (a 16-25s
race against a live sim), not deterministically red.

F (§42.6) — qa.sh --strict 24 passed / 0 failed / 0 warn / 0 skipped. New Gate 11: period law
(0/5 banned; CONTROL: R41's roster trips all five) and roster licence (24/24, 0 unverified;
CONTROL: an amber row turns 20 red). r41_shots.py promoted warn -> strict.
R41'S BYTE-HASH BAN IS REVERSED AND ITS STATED REASON WITHDRAWN AS WRONG. A gate may pin a
hash under five in-run assertions (classic+flags boot, freeze().stillLive all-false, a second
freeze removing 0 draws, settle until DBG.info() stops changing, hash with the dbg panel
hidden): 5/5 bookmarks now pin to one sha256 over 3 fresh boots. Two findings got it there —
the chunk streamer (a fixed settle is a bet on load) and the fps readout, which with
everything else frozen was the last thing moving at 53-62 px.
?pop=0 fixed (Number.isFinite) — a no-op for 35 rounds. Three red flags_check gates fixed.

Street: noon 282 · NIGHT 291/300 — the nine-draw margin INTACT, every R41 row reproducing to
the digit. Interior at pinned 16:9: 123 @ dept/auto, margin 227. selfcheck 157,647/157,647,
0x5f76e76 unmoved. No tag — John's playtest rules the epoch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 23:49:49 +10:00

849 lines
52 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;
}
// ── [R42 §42.2b] THE BOOTH PALETTE, and why it is not "make the bench lighter" ─────────────────
// R42's fault report is "the DJ booth reads as a plain black box". Lane E's §42.2a repaint fixed the
// asset half: the chassis is now Technics silver #b9bdc2 (WCAG relative luminance 0.506). Measured
// here independently, against the R41 bench body #3a3128 (0.0325) that is 6.74:1 — E's number,
// reproduced exactly — and against this round's timber carcass #5a4630 (0.0677) it is 4.73:1.
// BOTH are far above the 3:1 WCAG threshold for a graphical object, so deck-to-bench contrast was
// never what was left broken. What was left broken is a claim about PIXELS, so it was measured in
// pixels — six booths rendered at eye height 2 m off the bench face, counting inside the booth's own
// screen rect: **57.5% of the booth below relative luminance 0.05, 61.8% of the bench mass below it,
// 228 distinct 4-bit colours.** A big unbroken rectangle of near-black. That is the "plain black box".
// After this section: **26.7% / 21.1% / 389 colours** (18.0% / 11.8% / 431 excluding one pub room
// where a 1.8 m speaker_stack stands between the camera and the bench and darkens the crop).
// So: the deck's own SEAT — the worktop it stands on — stays near-black #1c1a18 (9.19:1, the
// strongest contrast in the composition and the one that actually frames the deck), and the MASS
// underneath becomes shop furniture: timber carcass, recessed plinth, and an open record bay.
const BOOTH_TIMBER = ['#5a4630', '#6b4526']; // stained pine / red-brown — 1990s Australian shopfit
const BOOTH_WORKTOP = '#1c1a18'; // the deck's seat. KEEP IT NEAR-BLACK (9.19:1).
const BOOTH_SHADOW = '#211d19'; // recessed plinth + the record bay's back panel
// 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 (R42 ruling 5 restates it: no driver this round
// either), and this builder deliberately ships no animation hook so nobody mistakes a placed node for a
// live one.
//
// [R42 §42.2b] The bench is now seven boxes instead of three, because a DJ booth has to read as a DJ
// booth in a glance from the door and a slab cannot: recessed plinth · two end cheeks · an apron under
// and an apron over an OPEN RECORD BAY across the front · the bay's back panel · a dark worktop.
// FOOTPRINT AND HEIGHT ARE UNCHANGED (w+0.12 × d+0.12,
// h+0.25) on purpose — every one of these is inside the volume R41 already reserved, so not one room's
// occupancy grid, path guarantee or placement moves because of the new geometry.
function djBooth(ctx, o = {}, r) {
const spec = BOOTHS[o.booth] || BOOTHS.venue;
const g = new ctx.THREE.Group();
const { w, d, h } = spec;
const timber = ctx.mat(BOOTH_TIMBER[(r() * BOOTH_TIMBER.length) | 0], 0.85);
const dark = ctx.mat(BOOTH_WORKTOP, 0.7);
const shadow = ctx.mat(BOOTH_SHADOW, 0.9);
// The bay is sized off a 12" sleeve (0.31 m square — a constant of the physical world, the same one
// kitSlots uses), not off a fraction of the bench, so it holds records in every booth size.
const PL = 0.09; // recessed plinth: the bench SITS instead of being a block
const CHEEK = 0.12; // end cheeks — the face frame around the bay
const BAY_H = Math.min(0.38, h - PL - 0.30); // clear height in the bay (a sleeve is 0.30)
const BAY_Y = PL + 0.20; // the bay's sill
const BAY_W = w - 2 * CHEEK;
// The bay is an OPENING, not a box: the cheeks (x), the two aprons (y) and a back panel (Z) enclose
// it and the +Z face is left open. Building it as a solid dark box instead — the first take — put an
// opaque front plane in front of the records and the bench came back with an empty black slot in it.
ctx.box(w - 0.18, PL, d - 0.12, shadow, 0, PL / 2, 0, g); // recessed plinth
ctx.box(CHEEK, h - PL, d, timber, -(w - CHEEK) / 2, PL + (h - PL) / 2, 0, g); // end cheek (left)
ctx.box(CHEEK, h - PL, d, timber, (w - CHEEK) / 2, PL + (h - PL) / 2, 0, g); // end cheek (right)
ctx.box(BAY_W, BAY_Y - PL, d, timber, 0, PL + (BAY_Y - PL) / 2, 0, g); // apron under the bay = its shelf
ctx.box(BAY_W, h - BAY_Y - BAY_H, d, timber, 0, (BAY_Y + BAY_H + h) / 2, 0, g); // apron over the bay
ctx.box(BAY_W, BAY_H, 0.06, shadow, 0, BAY_Y + BAY_H / 2, -d / 2 + 0.03, g); // the bay's back panel
ctx.box(w + 0.06, 0.05, d + 0.06, dark, 0, h, 0, g); // worktop = deck seat
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 });
// [R42 §42.2b] THE GEAR STANDS ON THE WORKTOP'S SURFACE, NOT ON ITS CENTRE LINE. The worktop is a
// 0.05 m slab CENTRED at y = h, so its top face is at h + 0.025 — and R41 planted every part at y = h,
// i.e. 25 mm sunk into it. Invisible while nothing else moved; it stops being invisible the moment a
// part is lowered on purpose (the plinth workaround below buried the whole chassis in the slab and
// left a black disc floating on the bench, which is how this was found — by looking at the render).
const TOP = h + 0.025;
// A part may STACK on another (headphones resting on the mixer). Resolved off the KIT table so the
// stand-in and the GLB agree without a second hardcoded height.
const yOff = (p) => (p.stackOn && KIT[p.stackOn] ? KIT[p.stackOn][2] : 0);
for (const p of spec.parts) {
const [pw, pd, ph, form] = KIT[p.id] || [0.3, 0.3, 0.1, 'deck'];
const base = TOP + yOff(p);
// `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, base + ph / 2, p.z, g).userData.boothGear = true;
// the platter disc only belongs on a deck-form part — a pair of headphones has no platter.
if (form === 'deck')
ctx.cyl(Math.min(pw, pd) * 0.3, 0.012, plate, p.x - pw * 0.06, base + 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 };
// [R42 §42.2b] THE BAY'S STOCK — records face-out in the bench front. This is the single strongest
// "this is a record shop's DJ booth" signal available and it is nearly free: `pool: true` puts the
// whole row on THREE shared pooled face materials (see stock.js binFan), so batch.js collapses it to
// at most three meshes — and in a record shop those are buckets the room's own bins already have, so
// it merges to zero. Measured cost of the whole §42.2b booth: record 81 → 82 draws. Under ?stock=real
// the record pack serves the 'sleeve' slot and the bay becomes real covers on the shared atlas
// material (one draw). Under ?noassets it is the pooled canvas, like every other slot.
const bay = spec.bay || null;
const slots = bay ? [{
kind: bay.stock, axis: 'x', pool: true,
x: 0, y: BAY_Y + 0.005, z: d / 2 - 0.11,
faceW: 0.30, run: BAY_W - 0.10, depth: 0.16, height: 0.30, count: bay.items, tilt: -0.10,
}] : [];
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) — plus `stackOn` (a part
// riding another part) and `dy` (the R43 plinth workaround, GLB-only: see kit41.BOOTH_PLINTH_FIX).
glbParts: spec.parts.map(p => ({ id: p.id, x: p.x, y: TOP + yOff(p) + (p.dy || 0), 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;
}