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>
762 lines
46 KiB
JavaScript
762 lines
46 KiB
JavaScript
// PROCITY Lane C — the placer. Turns a recipe + room shell into a believable, non-overlapping,
|
||
// walkable arrangement of fittings + wall decor, seeded from shop.seed.
|
||
//
|
||
// Guarantees (LANE_C acceptance):
|
||
// • nothing overlaps — every fitting reserves cells in an occupancy grid before placing
|
||
// • wall art/signs don't clash — the thriftgod shuffled wall-slot system draws from one pool
|
||
// • door → counter is walkable — flood-fill verifies connectivity; blockers are pulled until it is
|
||
// • density varies by shop — count per fitting scales by recipe.clutter × area × seed
|
||
//
|
||
// Coordinates match shell.js: +Z = door/street side, −Z = back. Origin = room centre, floor y=0.
|
||
|
||
import { buildFitting } from './fittings.js';
|
||
import * as stock from './stock.js';
|
||
import { frange, shuffle } from './context.js';
|
||
|
||
const CELL = 0.4; // occupancy grid resolution (m)
|
||
const PAD = 0.12; // clearance padding around each fitting (m)
|
||
|
||
// ── grid helpers ─────────────────────────────────────────────────────────────────────
|
||
function makeGrid(W, D) {
|
||
const cols = Math.max(3, Math.round(W / CELL));
|
||
const rows = Math.max(3, Math.round(D / CELL));
|
||
const cw = W / cols, cd = D / rows;
|
||
const occ = new Uint8Array(cols * rows); // 0 free, 1 fitting, 2 wall(border)
|
||
const reserved = new Uint8Array(cols * rows); // walkable but no placement (door corridor, till front)
|
||
for (let cx = 0; cx < cols; cx++) for (let cz = 0; cz < rows; cz++)
|
||
if (cx === 0 || cz === 0 || cx === cols - 1 || cz === rows - 1) occ[cz * cols + cx] = 2;
|
||
const cellOf = (x, z) => [
|
||
Math.min(cols - 1, Math.max(0, Math.floor((x + W / 2) / cw))),
|
||
Math.min(rows - 1, Math.max(0, Math.floor((z + D / 2) / cd))),
|
||
];
|
||
return { cols, rows, cw, cd, W, D, occ, reserved, cellOf };
|
||
}
|
||
|
||
// Rotated-AABB half extents of a footprint at yaw ry.
|
||
function halfExtents(fw, fd, ry) {
|
||
const c = Math.abs(Math.cos(ry)), s = Math.abs(Math.sin(ry));
|
||
return [(c * fw + s * fd) / 2 + PAD, (s * fw + c * fd) / 2 + PAD];
|
||
}
|
||
function rectCells(grid, x, z, hw, hd) {
|
||
const a = grid.cellOf(x - hw, z - hd), b = grid.cellOf(x + hw, z + hd);
|
||
return { cx0: a[0], cz0: a[1], cx1: b[0], cz1: b[1] };
|
||
}
|
||
// Is the padded footprint clear? (wall cells value 2 are allowed — shelves hug walls.)
|
||
function areaFree(grid, rect) {
|
||
for (let cz = rect.cz0; cz <= rect.cz1; cz++)
|
||
for (let cx = rect.cx0; cx <= rect.cx1; cx++) {
|
||
const v = grid.occ[cz * grid.cols + cx];
|
||
if (v === 1) return false;
|
||
if (grid.reserved[cz * grid.cols + cx]) return false;
|
||
}
|
||
return true;
|
||
}
|
||
function stampRect(grid, rect) {
|
||
for (let cz = rect.cz0; cz <= rect.cz1; cz++)
|
||
for (let cx = rect.cx0; cx <= rect.cx1; cx++)
|
||
if (grid.occ[cz * grid.cols + cx] === 0) grid.occ[cz * grid.cols + cx] = 1;
|
||
}
|
||
function rebuildOcc(grid, placed) {
|
||
for (let i = 0; i < grid.occ.length; i++) if (grid.occ[i] === 1) grid.occ[i] = 0;
|
||
// Skip noStamp (wall-mounted) fittings — placeAtWall never stamped them at placement, so re-stamping
|
||
// here would inject phantom floor obstacles beside a wall the player can actually walk past, and
|
||
// could trigger needless fitting-pulls or a carve when the path was fine.
|
||
for (const p of placed) if (!p.noStamp) stampRect(grid, p.rect);
|
||
}
|
||
function reserveRect(grid, x0, z0, x1, z1) {
|
||
const a = grid.cellOf(x0, z0), b = grid.cellOf(x1, z1);
|
||
for (let cz = a[1]; cz <= b[1]; cz++) for (let cx = a[0]; cx <= b[0]; cx++)
|
||
grid.reserved[cz * grid.cols + cx] = 1;
|
||
}
|
||
|
||
// 4-connected flood fill over walkable cells (occ==0). Returns Set of reachable indices.
|
||
function floodFill(grid, startIdx) {
|
||
const seen = new Uint8Array(grid.cols * grid.rows);
|
||
const stack = [startIdx]; seen[startIdx] = 1;
|
||
const out = new Set([startIdx]);
|
||
while (stack.length) {
|
||
const i = stack.pop(), cx = i % grid.cols, cz = (i / grid.cols) | 0;
|
||
for (const [dx, dz] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
|
||
const nx = cx + dx, nz = cz + dz;
|
||
if (nx < 0 || nz < 0 || nx >= grid.cols || nz >= grid.rows) continue;
|
||
const ni = nz * grid.cols + nx;
|
||
if (seen[ni] || grid.occ[ni] !== 0) continue;
|
||
seen[ni] = 1; out.add(ni); stack.push(ni);
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// ── counter placement per recipe.counterPos ──────────────────────────────────────────
|
||
function placeCounter(ctx, recipe, W, D, r) {
|
||
const pos = recipe.counterPos || 'corner';
|
||
const cw = Math.min(2.2, Math.max(1.4, W * 0.42));
|
||
let x, z, ry, front;
|
||
if (pos === 'door') { // milk bar: faces the door, front-left
|
||
x = -W * 0.12; z = D / 2 - 1.8; ry = 0; front = { x, z: z + 0.9 };
|
||
} else if (pos === 'forward') { // pawn/stall: counter up front, goods behind
|
||
x = -W * 0.05; z = D / 2 - 2.4; ry = 0; front = { x, z: z + 0.95 };
|
||
} else if (pos === 'back') { // along the back wall, facing in
|
||
x = -W * 0.1; z = -D / 2 + 0.7; ry = Math.PI; front = { x, z: z + 0.95 };
|
||
} else { // 'corner' — back-east, facing into the room
|
||
x = W / 2 - 0.55; z = -D / 4; ry = -Math.PI / 2; front = { x: x - 0.95, z };
|
||
}
|
||
// [R41] `recipe.counterGlb` overrides the counter's GLB swap for one type (the venues' `plywood_bar`).
|
||
// Pure data — it reads no stream, so a recipe without it is byte-identical to pre-R41.
|
||
const f = buildFitting('counter', ctx, { w: cw, glbId: recipe.counterGlb }, r);
|
||
f.group.position.set(x, 0, z); f.group.rotation.y = ry;
|
||
const [hw, hd] = halfExtents(f.footprint.w, f.footprint.d, ry);
|
||
const stand = keeperStand(x, z, front, W, D);
|
||
return { fitting: f, x, z, ry, hw, hd, front, stand, priority: 9, kind: 'counter' };
|
||
}
|
||
|
||
// The pose a shopkeeper (Lane D) stands at: behind the bench on the service side (opposite the
|
||
// customer-facing `front`), facing the customer. Clamped inside the room so a corner/back-wall
|
||
// counter keeper never clips through a wall. `ry` uses the rig-front = local −Z convention that
|
||
// keepers.js expects (spawn's ry is "the counter's outward facing"). Deterministic — a pure
|
||
// function of the (already-seeded) counter pose.
|
||
function keeperStand(cx, cz, front, W, D) {
|
||
let dx = front.x - cx, dz = front.z - cz; // counter → customer
|
||
const len = Math.hypot(dx, dz) || 1; dx /= len; dz /= len;
|
||
const off = 0.5; // stand this far back from the bench centre
|
||
let x = cx - dx * off, z = cz - dz * off; // opposite the customer side (behind the bench)
|
||
const mx = W / 2 - 0.45, mz = D / 2 - 0.45; // keep clear of the walls
|
||
x = Math.max(-mx, Math.min(mx, x));
|
||
z = Math.max(-mz, Math.min(mz, z));
|
||
return { x: round(x), z: round(z), ry: round(Math.atan2(-dx, -dz)) };
|
||
}
|
||
|
||
// ── browse points (round 9): where Lane D stands browser rigs ─────────────────────────
|
||
// 1–3 seeded floor poses, aisle-adjacent, in front of a browsable fitting, facing the goods. Pure
|
||
// data (no meshes, no draws) — mirrors counter.stand so keepers.js/fleet clones drop straight in.
|
||
// Every point is walkable (occ-free, not reserved), reachable from spawn (same flood-fill as
|
||
// door→counter), ≥MIN_SEP from each other AND the keeper stand. Deterministic per shop.seed.
|
||
const MIN_SEP = 0.6; // min spacing between rigs (m)
|
||
const STAND_GAP = 0.55; // how far in front of a fitting a browser stands (m)
|
||
function placeBrowsePoints(grid, placed, counterStand, spawnCell, r) {
|
||
// reachable floor cells from spawn (adjust off an occupied spawn cell like reaches() does)
|
||
let start = spawnCell;
|
||
if (grid.occ[start] !== 0) { const s = nearestFree(grid, start); if (s < 0) return []; start = s; }
|
||
const reach = floodFill(grid, start);
|
||
|
||
// browsable = surviving floor fittings (not the counter, not wall-mounted). Prefer ones that actually
|
||
// carry interactable stock (bins/cases have `places`) so browsers cluster at the goods, then bigger
|
||
// fittings, then a seeded tiebreak — all pure functions of seed/geometry (determinism holds).
|
||
const cand = placed
|
||
.filter(p => !p.removed && !p.noStamp && p.kind !== 'counter')
|
||
.map(p => ({ p, jitter: r() })) // draw seeded keys up front so ordering is seed-stable
|
||
.sort((a, b) => {
|
||
const ap = (a.p.fitting.places || []).length ? 1 : 0, bp = (b.p.fitting.places || []).length ? 1 : 0;
|
||
if (ap !== bp) return bp - ap;
|
||
const aa = a.p.hw * a.p.hd, ba = b.p.hw * b.p.hd;
|
||
if (Math.abs(aa - ba) > 0.05) return ba - aa;
|
||
return a.jitter - b.jitter;
|
||
})
|
||
.map(o => o.p);
|
||
|
||
const pts = [];
|
||
const near = (x, z, list, d) => list.some(q => Math.hypot(q.x - x, q.z - z) < d);
|
||
// Is (x,z) a valid, unclaimed stand? (walkable free floor · reachable · clear of walls/keeper/other rigs)
|
||
const validStand = (x, z) => {
|
||
const [cx, cz] = grid.cellOf(x, z);
|
||
if (cx < 1 || cz < 1 || cx >= grid.cols - 1 || cz >= grid.rows - 1) return false;
|
||
const idx = cz * grid.cols + cx;
|
||
if (grid.occ[idx] !== 0 || grid.reserved[idx]) return false;
|
||
if (!reach.has(idx)) return false;
|
||
if (counterStand && Math.hypot(counterStand.x - x, counterStand.z - z) < MIN_SEP) return false;
|
||
if (near(x, z, pts, MIN_SEP)) return false;
|
||
return true;
|
||
};
|
||
const push = (x, z, p) => {
|
||
const dx = p.x - x, dz = p.z - z; // stand → fitting; face it (counter.stand ry convention)
|
||
pts.push({ x: round(x), z: round(z), ry: round(Math.atan2(-dx, -dz)), atKind: p.kind, slotIndex: pts.length });
|
||
};
|
||
// Stand candidates around a fitting: 4 sides at two gaps + 4 diagonals, tried toward-room-centre
|
||
// first (the open aisle) so a browser faces OUT of a wall-hugging shelf, never into the wall. Richer
|
||
// than a single ring so a valid cell survives even in a tight/pokey room where one side is blocked.
|
||
const standsAround = (p) => {
|
||
const tX = p.x > 0 ? -1 : 1, gW = p.hw + STAND_GAP, gW2 = p.hw + STAND_GAP + 0.35, gD = p.hd + STAND_GAP;
|
||
return [
|
||
{ x: p.x + tX * gW, z: p.z }, { x: p.x, z: p.z + gD },
|
||
{ x: p.x + tX * gW, z: p.z + 0.5 }, { x: p.x + tX * gW, z: p.z - 0.5 },
|
||
{ x: p.x + tX * gW2, z: p.z }, { x: p.x, z: p.z - gD },
|
||
{ x: p.x - tX * gW, z: p.z }, { x: p.x - tX * gW, z: p.z + 0.5 },
|
||
];
|
||
};
|
||
for (const p of cand) {
|
||
if (pts.length >= 3) break;
|
||
for (const s of standsAround(p)) {
|
||
if (!validStand(s.x, s.z)) continue;
|
||
push(s.x, s.z, p); break; // one point per fitting
|
||
}
|
||
}
|
||
// Fallback: a room with browsable goods but no point yet (all fitting sides boxed in — tight pokey/
|
||
// mismatched archetypes) still deserves ≥1 browser. Scan the reachable free floor for the cell nearest
|
||
// a browsable fitting and stand there, facing that fitting. Deterministic (grid + cand order are seeded).
|
||
if (pts.length === 0 && cand.length) {
|
||
let best = null;
|
||
for (const idx of reach) {
|
||
if (grid.occ[idx] !== 0 || grid.reserved[idx]) continue;
|
||
const cx = idx % grid.cols, cz = (idx / grid.cols) | 0;
|
||
const x = (cx + 0.5) * grid.cw - grid.W / 2, z = (cz + 0.5) * grid.cd - grid.D / 2;
|
||
if (counterStand && Math.hypot(counterStand.x - x, counterStand.z - z) < MIN_SEP) continue;
|
||
for (const p of cand) {
|
||
const d = Math.hypot(p.x - x, p.z - z);
|
||
if (d < 0.5) continue; // not on top of the fitting
|
||
if (!best || d < best.d) best = { x, z, p, d };
|
||
}
|
||
}
|
||
if (best) push(best.x, best.z, best.p);
|
||
}
|
||
return pts;
|
||
}
|
||
|
||
// ── VENUE stage (v3, ?gigs) ───────────────────────────────────────────────────────────
|
||
// Places a low stage centred on the back wall + PA cabinets flanking it on the floor (both into `placed` at
|
||
// priority 8 so the door→counter path loop never pulls them). Returns the stage frame the crowd faces, the
|
||
// 4 band poses on the deck, and `backline[]` — up-stage amp slots (v3.1, ledger #5) where Lane D plants E's
|
||
// guitar_amp GLB. C no longer plants its own primitive amps: one unified backline, GLB when present, D's
|
||
// primitive fallback under ?noassets. Pure pose data; Lane D drops the rigs + amp in.
|
||
function placeStage(ctx, grid, W, D, roomGroup, r, spec = {}) {
|
||
const wf = spec.deckWidthFrac || 0.58, deckH = spec.deckH || 0.32; // per-kind (round 13): band_room
|
||
const sw = Math.min(W - 1.4, Math.max(3.0, W * wf)), sd = spec.deckDepth || 2.0; // barely a riser, rsl bigger
|
||
const cx = 0, cz = -D / 2 + sd / 2 + 0.2; // back wall, centred
|
||
const f = buildFitting('stage', ctx, { w: sw, d: sd, h: deckH }, r);
|
||
f.group.position.set(cx, 0, cz); roomGroup.add(f.group);
|
||
const [hw, hd] = halfExtents(f.footprint.w, f.footprint.d, 0);
|
||
const rect = rectCells(grid, cx, cz, hw, hd); stampRect(grid, rect);
|
||
const out = [{ fitting: f, x: cx, z: cz, ry: 0, hw, hd, rect, priority: 8, kind: 'stage' }];
|
||
|
||
for (const sgn of [-1, 1]) { // PA cabinets at the stage front corners, pulled
|
||
const px = sgn * (sw / 2 - 0.3), pz = cz + sd / 2 + 0.35; // in + just off the deck lip
|
||
const [phw, phd] = halfExtents(0.6, 0.55, 0); // paSpeaker footprint (fixed)
|
||
const prect = rectCells(grid, px, pz, phw, phd);
|
||
if (!areaFree(grid, prect)) continue; // never stamp over the counter's service strip / a wall
|
||
const pf = buildFitting('paSpeaker', ctx, {}, r);
|
||
pf.group.position.set(px, 0, pz); roomGroup.add(pf.group);
|
||
stampRect(grid, prect);
|
||
out.push({ fitting: pf, x: px, z: pz, ry: 0, hw: phw, hd: phd, rect: prect, priority: 8, kind: 'prop' });
|
||
}
|
||
|
||
const deckY = f.deckY || deckH; // deck top surface (band + backline stand height)
|
||
|
||
// 4-PIECE BAND (round 13, debt #3): front-line trio across the deck lip + a drummer up-stage on the
|
||
// riser. ry=π faces the audience (+Z) under the rig-front=−Z house convention (see LANE_C_PUB.md). Each
|
||
// pose carries its own stand height `y` — the front line on the deck (deckY), the drummer on the drum
|
||
// riser (deckY+0.16, the fittings.js stage riser). D lifts each member to pose.y (was: stage.deckY).
|
||
const riserY = round(deckY + 0.16); // drum riser top surface (fittings.js: 0.16 m box)
|
||
const front = [-sw * 0.28, 0, sw * 0.28].map((bx, i) => ({
|
||
x: round(cx + bx), z: round(cz + sd * 0.12), ry: round(Math.PI), role: ['guitar', 'vocal', 'bass'][i], y: round(deckY),
|
||
}));
|
||
const drums = { x: round(cx), z: round(cz - sd / 4), ry: round(Math.PI), role: 'drums', y: riserY, seated: true };
|
||
const bandPoses = [...front, drums];
|
||
|
||
// BACKLINE (v3.1, ledger #5): up-stage amp/spare-cab slots — the unified backline that replaces C's old
|
||
// primitive ampStacks. Pure data (no meshes): Lane D plants E's guitar_amp GLB here (primitive fallback
|
||
// under ?noassets). Up-stage of the front line, flanking the drum riser, clear of every bandPose AND all
|
||
// watchPoints (which sit downstage of frontZ), on-deck. slot[0] = stage-right (primary), slot[1] = spare
|
||
// stage-left. ry=π faces the audience (+Z), same −Z convention as the band; y = deckY. Deterministic.
|
||
// slot[0] == D's R15-verified guitar_amp pose (stage.w·0.36, deckY, stage.z − stage.d·0.22, 0.87 m bass
|
||
// clearance), so plumbing D's plant to backline[0] moves nothing on screen.
|
||
const backline = [sw * 0.36, -sw * 0.36].map(bx => ({
|
||
x: round(cx + bx), z: round(cz - sd * 0.22), ry: round(Math.PI), y: round(deckY),
|
||
}));
|
||
return { placed: out, stage: { x: cx, z: cz, w: sw, d: sd, deckY, riserY, frontZ: round(cz + sd / 2), bandPoses, backline } };
|
||
}
|
||
|
||
// ── WATCH POINTS (venue) — seeded audience floor poses facing the stage ──
|
||
// Same pure-data pattern as browse points: walkable free cells, reachable from spawn, clear of the keeper
|
||
// stand + browse points + stage, spaced for a crowd. `dance` seeded ~1/3 true (John: "a bit of both").
|
||
// `watchMax` = the crowd cap per kind (round 13): pub/band_room 8, RSL 12 (the crowd-cap stress case).
|
||
// The room fills what geometry allows up to the cap; the count IS the crowd cap (F asserts crowd ≤ it).
|
||
function placeWatchPoints(grid, stage, spawnCell, counterStand, browsePoints, r, watchMax = 8) {
|
||
let start = spawnCell;
|
||
if (grid.occ[start] !== 0) { const s = nearestFree(grid, start); if (s < 0) return []; start = s; }
|
||
const reach = floodFill(grid, start);
|
||
const avoid = [counterStand, ...(browsePoints || [])].filter(Boolean);
|
||
const near = (x, z, list, d) => list.some(q => Math.hypot(q.x - x, q.z - z) < d);
|
||
const cands = [];
|
||
for (let z = stage.frontZ + 0.6; z <= grid.D / 2 - 1.6; z += 0.7) // between stage lip and the door zone
|
||
for (let x = -grid.W / 2 + 0.8; x <= grid.W / 2 - 0.8; x += 0.8) cands.push({ x, z });
|
||
for (const c of cands) c.key = r(); // seed the visiting order up front (determinism)
|
||
cands.sort((a, b) => a.key - b.key);
|
||
const pts = [];
|
||
const valid = (x, z) => {
|
||
const [cx, cz] = grid.cellOf(x, z);
|
||
if (cx < 1 || cz < 1 || cx >= grid.cols - 1 || cz >= grid.rows - 1) return false;
|
||
const idx = cz * grid.cols + cx;
|
||
if (grid.occ[idx] !== 0 || grid.reserved[idx] || !reach.has(idx)) return false;
|
||
if (near(x, z, avoid, MIN_SEP) || near(x, z, pts, 0.7)) return false;
|
||
return true;
|
||
};
|
||
for (const c of cands) {
|
||
if (pts.length >= watchMax) break;
|
||
if (!valid(c.x, c.z)) continue;
|
||
const dx = stage.x - c.x, dz = stage.z - c.z; // face the stage centre (counter.stand ry convention)
|
||
pts.push({ x: round(c.x), z: round(c.z), ry: round(Math.atan2(-dx, -dz)), dance: r() < 0.34, slotIndex: pts.length });
|
||
}
|
||
return pts;
|
||
}
|
||
|
||
// ── wall-slot system (shuffled pool → art + signs + wall fittings never overlap) ──────
|
||
function makeWallSlots(W, D, r) {
|
||
const slots = [];
|
||
const nZ = Math.max(2, Math.floor((D - 3.2) / 1.7));
|
||
for (let i = 0; i < nZ; i++) {
|
||
const z = -D / 2 + 1.6 + i * ((D - 3.4) / Math.max(1, nZ - 1));
|
||
slots.push({ wall: 'west', x: -W / 2, z, ry: Math.PI / 2, used: false });
|
||
slots.push({ wall: 'east', x: W / 2, z, ry: -Math.PI / 2, used: false });
|
||
}
|
||
const nX = Math.max(1, Math.floor((W - 3.0) / 1.7));
|
||
for (let i = 0; i < nX; i++) {
|
||
const x = -W / 2 + 1.5 + i * ((W - 3.0) / Math.max(1, nX - 1));
|
||
slots.push({ wall: 'north', x, z: -D / 2, ry: 0, used: false });
|
||
}
|
||
shuffle(r, slots);
|
||
return slots;
|
||
}
|
||
|
||
// ── free-standing candidate positions per zone ────────────────────────────────────────
|
||
function zoneCandidates(zone, W, D, doorCx, r, stage) {
|
||
const out = [];
|
||
if (zone === 'window') {
|
||
const z = D / 2 - 1.4;
|
||
for (let x = -W / 2 + 1.1; x <= doorCx - 1.2; x += 1.3) out.push({ x, z, ry: Math.PI });
|
||
} else if (zone === 'stage') {
|
||
// [R41 §41.4] THE STAGE FLANKS — venue-only. Before this, a PA stack or a crowd barrier drawn from the
|
||
// venue pool went into `aisle`, whose candidate grid spans the whole room, so the pub's gear landed
|
||
// wherever there was space: a 1.97 m crowd barrier ended up across the doorway. Gear belongs BESIDE
|
||
// the stage. Deliberately NOT in front of it: the strip from `stage.frontZ + 0.6` to `D/2 − 1.6` is
|
||
// where placeWatchPoints stands the crowd, and furniture there is furniture instead of an audience.
|
||
// No stage (a non-venue recipe that asks for this zone) ⇒ no candidates ⇒ the spec's fallbacks run.
|
||
if (!stage) return out;
|
||
const fx = Math.min(W / 2 - 0.7, stage.w / 2 + 0.75);
|
||
for (const sx of [-1, 1])
|
||
for (let z = stage.z - stage.d / 2; z <= stage.frontZ + 0.9; z += 0.8)
|
||
out.push({ x: sx * fx, z, ry: sx > 0 ? -Math.PI / 2 : Math.PI / 2 });
|
||
} else if (zone === 'aisle') {
|
||
for (let z = D / 2 - 3.0; z >= -D / 2 + 1.6; z -= 1.7)
|
||
for (let x = -W / 2 + 1.3; x <= W / 2 - 1.3; x += 1.9) out.push({ x, z, ry: 0 });
|
||
} else if (zone === 'centre') {
|
||
for (let z = D / 2 - 3.5; z >= -D / 2 + 2.2; z -= 2.0)
|
||
for (let x = -W / 4; x <= W / 4; x += 1.8) out.push({ x, z, ry: 0 });
|
||
}
|
||
return shuffle(r, out);
|
||
}
|
||
|
||
// ── main entry ────────────────────────────────────────────────────────────────────────
|
||
export function layout(ctx, { recipe, dims, shell, adapter, shop, fittingOpts, noKit }) {
|
||
const { W, D } = dims;
|
||
const doorCx = shell.door.x;
|
||
const grid = makeGrid(W, D);
|
||
const rCount = ctx.stream('lay-count'), rPlace = ctx.stream('lay-place'), rWall = ctx.stream('lay-wall');
|
||
|
||
// Reserve the door corridor (walkable, no fittings) and the spawn cell.
|
||
reserveRect(grid, doorCx - 0.7, D / 2 - 2.0, doorCx + 0.7, D / 2 - 0.4);
|
||
|
||
const placed = [];
|
||
const roomGroup = shell.group;
|
||
|
||
// 1) COUNTER first (always present, reachability target).
|
||
const counter = placeCounter(ctx, recipe, W, D, rCount);
|
||
counter.rect = rectCells(grid, counter.x, counter.z, counter.hw, counter.hd);
|
||
stampRect(grid, counter.rect);
|
||
placed.push(counter);
|
||
// Surface the keeper stand pose on the counter's interactable so Lane D (keepers.js) can spawn a
|
||
// shopkeeper behind it without guessing which side is the service side. Also exposed top-level as
|
||
// room.counter.stand (see interiors.js). The counter is never path-pulled, so this always survives.
|
||
counter.fitting.group.userData.keeperStand = counter.stand;
|
||
// reserve the strip in front of the till so nothing blocks service
|
||
reserveRect(grid, counter.front.x - 0.5, counter.front.z - 0.5, counter.front.x + 0.5, counter.front.z + 0.5);
|
||
|
||
// 1b) VENUE STAGE (pub, round 12) — placed BEFORE the fitting mix so its footprint is reserved. Only
|
||
// for recipe.venue; other rooms never touch the 'stage'/'watch' streams → flags-off byte-identical.
|
||
let stageInfo = null, watchPoints = [];
|
||
if (recipe.venue) {
|
||
const st = placeStage(ctx, grid, W, D, roomGroup, ctx.stream('stage'), recipe.venueSpec);
|
||
stageInfo = st.stage;
|
||
for (const sp of st.placed) placed.push(sp);
|
||
}
|
||
|
||
// 2) FITTINGS from the recipe mix (skip the counter entry — already placed).
|
||
const area = W * D;
|
||
const wallSlots = makeWallSlots(W, D, rWall);
|
||
// [R41 §41.4] `opts.noKit` is the KIT'S OWN CONTROL ARM, and it is exact rather than approximate: every
|
||
// R41 spec is appended LAST in its recipe, so dropping them here leaves each surviving spec the same
|
||
// draws off the same `lay-count`/`lay-place` streams in the same order — a `noKit` room is byte-identical
|
||
// to the pre-R41 room, which is what makes "the kit cost N draws" a measurement instead of a claim.
|
||
// Diagnostics only (like opts.noBatch); nothing in the game sets it.
|
||
const specs = noKit ? recipe.fittings.filter(s => !isKitSpec(s)) : recipe.fittings;
|
||
for (const spec of specs) {
|
||
if (spec.kind === 'counter') continue;
|
||
// count scales with clutter × area, clamped to [min,max]
|
||
const density = recipe.clutter * (area / 70);
|
||
let n = Math.round(frange(rCount, spec.min, spec.max + 0.99) * Math.min(1.3, density));
|
||
n = Math.max(spec.min, Math.min(spec.max, n));
|
||
for (let i = 0; i < n; i++) {
|
||
// [R39] fitting opts: recipe-declared (`spec.o`) with a per-build override (`opts.fittingOpts`,
|
||
// keyed by fitting kind) on top. PURE DATA — reads no stream, so a spec that declares none is
|
||
// byte-identical to pre-R39 and an override can never shift another fitting's seeded pick.
|
||
const fo = fittingOpts && fittingOpts[spec.kind];
|
||
const o = spec.o || fo ? { ...(spec.o || {}), ...(fo || {}) } : {};
|
||
const f = buildFitting(spec.kind, ctx, o, ctx.stream(`fit-${spec.kind}-${i}`));
|
||
let ok = false;
|
||
if (spec.zone === 'wall' || f.wallMounted) {
|
||
ok = placeAtWall(ctx, grid, f, wallSlots, rPlace, placed, spec);
|
||
} else if (spec.zone === 'counter') {
|
||
ok = placeNearCounter(ctx, grid, f, counter, rPlace, placed, spec, W, D, doorCx, stageInfo);
|
||
} else {
|
||
ok = placeFree(ctx, grid, f, spec.zone, W, D, doorCx, rPlace, placed, spec, stageInfo);
|
||
}
|
||
// [R39] OPT-IN placement fallbacks, for a fitting a recipe promises will BE there (the op shop's
|
||
// rummage bin). Default is unchanged: no fallback list ⇒ a fitting that can't find its zone is
|
||
// dropped, exactly as before, and the density self-caps. Measured: the bin is appended last, so
|
||
// its zone is the fullest it will ever be — `aisle` alone landed it in only 11/24 archetype×seed
|
||
// rooms. The chain runs on the SAME `rPlace` stream and only ever fires for a spec that opted in
|
||
// AND already failed, so no other fitting's draws move.
|
||
if (!ok && spec.fallbackZones)
|
||
for (const z of spec.fallbackZones) {
|
||
// [R41] 'wall' in a fallback chain has to route to placeAtWall — the wall is a SLOT POOL, not a
|
||
// candidate grid, so `zoneCandidates('wall')` returns [] and a 'wall' fallback was a silent
|
||
// no-op. No pre-R41 spec lists it, so this only ever adds a recovery that used to be missing.
|
||
ok = z === 'wall'
|
||
? placeAtWall(ctx, grid, f, wallSlots, rPlace, placed, spec)
|
||
: placeFree(ctx, grid, f, z, W, D, doorCx, rPlace, placed, spec, stageInfo);
|
||
if (ok) break;
|
||
}
|
||
if (!ok && spec.anywhere) ok = placeAnywhere(ctx, grid, f, W, D, doorCx, rPlace, placed, spec);
|
||
if (!ok) { /* no room — density self-caps; drop the fitting (its resources free at room end) */ }
|
||
}
|
||
}
|
||
|
||
// 3) PATH GUARANTEE: door → counter. Pull low-priority blockers until reachable; carve if we must.
|
||
const spawnCell = idxOf(grid, shell.spawn.x, shell.spawn.z);
|
||
const targetCell = idxOf(grid, counter.front.x, counter.front.z);
|
||
let pathOK = reaches(grid, spawnCell, targetCell);
|
||
let carved = false;
|
||
const removable = () => placed
|
||
.filter(p => p.priority < 8 && p.kind !== 'counter' && !p.noStamp) // wall-mounted don't block the floor
|
||
.sort((a, b) => a.priority - b.priority);
|
||
const pulled = [];
|
||
while (!pathOK) {
|
||
const pool = removable();
|
||
if (!pool.length) break;
|
||
const victim = pool[0];
|
||
const at = placed.indexOf(victim);
|
||
placed.splice(at, 1);
|
||
if (victim.fitting.group.parent) victim.fitting.group.parent.remove(victim.fitting.group);
|
||
victim.removed = true;
|
||
pulled.push({ v: victim, at });
|
||
rebuildOcc(grid, placed);
|
||
pathOK = reaches(grid, spawnCell, targetCell);
|
||
}
|
||
if (!pathOK) { carveCorridor(grid, spawnCell, targetCell); carved = true; pathOK = reaches(grid, spawnCell, targetCell); }
|
||
|
||
// 3b) [R41 §41.4] PUT BACK EVERYTHING THAT WASN'T THE BLOCKER.
|
||
// The pull loop above takes the LOWEST-PRIORITY survivor each pass, not the one standing in the
|
||
// corridor — so it clears the cheap furniture first and only reaches the actual blocker by attrition.
|
||
// Measured: `pub/wide/4242` came back with a bar, a stage and NOTHING ELSE — no tables, no PA, no R41
|
||
// kit — because a wide pub puts the bar beside the stage and leaves one ~1 m gap to squeeze through,
|
||
// and restoring that gap cost every innocent fitting in the room. That was already true before this
|
||
// round; ten more fittings per pub just made it impossible to miss.
|
||
// The fix is additive and self-checking: walk the pulled list in reverse, put each fitting back at its
|
||
// original index, and keep it ONLY if door→counter still reaches. A room that pulled nothing does
|
||
// nothing here (byte-identical); a room that pulled N can only end with MORE fittings than before,
|
||
// never fewer, and the path guarantee is re-proved on every single restore rather than assumed.
|
||
let restored = 0;
|
||
if (pathOK && pulled.length) {
|
||
for (let i = pulled.length - 1; i >= 0; i--) {
|
||
const { v, at } = pulled[i];
|
||
placed.splice(Math.min(at, placed.length), 0, v);
|
||
v.removed = false;
|
||
rebuildOcc(grid, placed);
|
||
if (reaches(grid, spawnCell, targetCell)) { restored++; continue; }
|
||
placed.splice(placed.indexOf(v), 1);
|
||
v.removed = true;
|
||
rebuildOcc(grid, placed);
|
||
}
|
||
}
|
||
|
||
// 4) add SURVIVORS to the room + fill them with visual stock. Collect `places` here (NOT at
|
||
// placement time) so interactables belong only to fittings that actually made it into the room —
|
||
// a fitting pulled by the path loop must not leave a phantom interactable in the returned list.
|
||
const places = [...(shell.places || [])];
|
||
for (const p of placed) {
|
||
if (p.removed) continue;
|
||
if (!p.fitting.group.parent) roomGroup.add(p.fitting.group);
|
||
stock.fill(ctx, p.fitting, { shop, recipe, stockKind: recipe.stockKind, adapter }, ctx.stream(`stk-${p.priority}-${p.x.toFixed(2)}-${p.z.toFixed(2)}${ctx.stockSalt || ''}`)); // [Lane F R30 seam] day-salted stock rotation (§30.3)
|
||
places.push(...(p.fitting.places || []));
|
||
}
|
||
|
||
// 5) WALL DECOR — art frames + inside signage on leftover wall slots (shuffled, never overlap).
|
||
const decor = placeWallDecor(ctx, roomGroup, recipe, wallSlots, W, D, rWall);
|
||
|
||
// 6) BROWSE POINTS (round 9) — 1–3 seeded floor poses for Lane D's browser rigs, in front of the
|
||
// goods, reachable + non-blocking. Pure data (no geometry). Computed AFTER the path guarantee so
|
||
// every point sits on a survivor's open side and on a cell reachable from the door.
|
||
const browsePoints = placeBrowsePoints(grid, placed, counter.stand, spawnCell, ctx.stream('browse'));
|
||
|
||
// 6b) WATCH POINTS (venue, round 12) — audience floor poses facing the stage, computed after the path
|
||
// guarantee + browse points (so they avoid both), on cells reachable from the door.
|
||
if (stageInfo) watchPoints = placeWatchPoints(grid, stageInfo, spawnCell, counter.stand, browsePoints, ctx.stream('watch'), (recipe.venueSpec && recipe.venueSpec.watchMax) || 8);
|
||
|
||
// placement summary for the determinism deep-equal test
|
||
const placement = placed.filter(p => !p.removed).map(p => ({
|
||
kind: p.kind || 'fitting', kindName: p.fitting.group.userData.kind,
|
||
x: round(p.x), z: round(p.z), ry: round(p.ry), fw: round(p.fitting.footprint.w), fd: round(p.fitting.footprint.d),
|
||
})).concat(decor.summary);
|
||
|
||
return {
|
||
placed: placed.filter(p => !p.removed),
|
||
places, decor: decor.meshes, grid, pathOK, carved,
|
||
pulls: { pulled: pulled.length, restored, lost: pulled.length - restored }, // [R41] path-loop cost
|
||
spawnCell, targetCell, placement, browsePoints,
|
||
watchPoints, // venue: audience poses {x,z,ry,dance} facing the stage, cap by kind (pub/band_room 8, rsl 12); else []
|
||
stage: stageInfo, // venue: { x,z,w,d,deckY,frontZ,bandPoses } (else null)
|
||
counter: {
|
||
mesh: counter.fitting.group,
|
||
pose: { x: round(counter.x), z: round(counter.z), ry: round(counter.ry) }, // the bench itself
|
||
stand: counter.stand, // where the keeper stands
|
||
},
|
||
};
|
||
}
|
||
|
||
// ── placement strategies ──────────────────────────────────────────────────────────────
|
||
// [R42 §42.2b] `spec.faceDoor` — TURN THE FITTING'S FACE TOWARDS THE PLAYER.
|
||
// A fitting whose whole point is what is ON it (the DJ booth: two decks, a mixer, headphones, a bay of
|
||
// records — all on the +Z face and the top) is wasted if the room hands it a yaw that shows the player
|
||
// its end or its back. `placeNearCounter` copies the counter's yaw, and for a `corner` counter that is
|
||
// −π/2: the booth ends up square to the side wall, 90° off the door. Measured over 190 booth rooms
|
||
// (12 types × 6 archetypes × 8 seeds): only 94 (49%) presented their face within 45° of the player's
|
||
// entry line, 117 (62%) within 60°.
|
||
// The aim is the SPAWN POINT — where the player actually is when the door opens (door x, D/2 − 1.5) —
|
||
// not the door hole, and local +Z is the house "front" convention for every fitting.
|
||
// Draw discipline: the aimed yaw is TRIED FIRST and the zone's own yaw — UNJITTERED, because an
|
||
// axis-aligned box is the tightest rotated AABB there is — is the fallback, so a booth that cannot be
|
||
// angled still lands. Both branches consume the SAME single r() from the shared `rPlace` stream, so
|
||
// switching the flag on cannot move another fitting's placement.
|
||
const faceDoorYaw = (x, z, doorCx, D) => Math.atan2(doorCx - x, (D / 2 - 1.5) - z);
|
||
|
||
// [R42 §42.2b] …and a face pointed at the player is no use in a spot the player never looks at.
|
||
// Turning the booth alone MOVED THE PROBLEM: aiming it made its deep axis narrow, which made a
|
||
// counter-side candidate "fit" that never had before, and the record shop's hero landed 2.5 m to the
|
||
// player's LEFT at the door — square out of frame. So a `faceDoor` spec also gets its candidate list
|
||
// ORDERED by what the player can see from the spawn, lowest score first:
|
||
// · angle off the view axis (the spawn looks along −Z — shell.js: "just inside, facing −Z")
|
||
// · a penalty under 2.2 m (you walk into it before you can read it)
|
||
// · a penalty over 7 m (a 1.55 m bench at 9 m is a smudge)
|
||
// It is a pure function of geometry — no stream draws — and it is applied to the ALREADY-SHUFFLED
|
||
// list with a stable sort, so equal-scoring candidates keep their seeded order and determinism holds.
|
||
function doorViewScore(x, z, doorCx, D) {
|
||
const dx = x - doorCx, dz = z - (D / 2 - 1.5);
|
||
const dist = Math.hypot(dx, dz) || 1e-3;
|
||
const ang = Math.acos(Math.max(-1, Math.min(1, -dz / dist))) * 180 / Math.PI;
|
||
return ang + (dist < 2.2 ? (2.2 - dist) * 30 : 0) + (dist > 7 ? (dist - 7) * 8 : 0);
|
||
}
|
||
const byDoorView = (doorCx, D) => (a, b) => doorViewScore(a.x, a.z, doorCx, D) - doorViewScore(b.x, b.z, doorCx, D);
|
||
|
||
function placeFree(ctx, grid, f, zone, W, D, doorCx, r, placed, spec, stage) {
|
||
const cands = zoneCandidates(zone, W, D, doorCx, r, stage);
|
||
if (spec.faceDoor) cands.sort(byDoorView(doorCx, D));
|
||
for (const c of cands) {
|
||
const jit = r() - 0.5;
|
||
const base = c.ry + jit * 0.4;
|
||
// Fallback for a faceDoor spec is the zone's yaw UNJITTERED: axis-aligned is the tightest rotated
|
||
// AABB there is, so the fitting that could not be angled still gets the best chance of landing.
|
||
const tries = spec.faceDoor ? [faceDoorYaw(c.x, c.z, doorCx, D) + jit * 0.12, c.ry] : [base];
|
||
for (const ry of tries) {
|
||
const [hw, hd] = halfExtents(f.footprint.w, f.footprint.d, ry);
|
||
const rect = rectCells(grid, c.x, c.z, hw, hd);
|
||
if (!areaFree(grid, rect)) continue;
|
||
f.group.position.set(c.x, 0, c.z); f.group.rotation.y = ry;
|
||
stampRect(grid, rect);
|
||
placed.push({ fitting: f, x: c.x, z: c.z, ry, hw, hd, rect, priority: spec.priority, kind: spec.kind });
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// [R39] Last resort for an opt-in spec (`anywhere: true`): the first free rect on a seeded sweep of
|
||
// the open floor. Respects `reserved` through areaFree, so it can never land in the door corridor or
|
||
// the strip in front of the till. Deterministic (the cell list is built in grid order, then shuffled
|
||
// on `rPlace`). Never called unless the spec asked for it AND every zone already failed.
|
||
function placeAnywhere(ctx, grid, f, W, D, doorCx, r, placed, spec) {
|
||
const cells = [];
|
||
for (let cz = 1; cz < grid.rows - 1; cz++) for (let cx = 1; cx < grid.cols - 1; cx++) {
|
||
if (grid.occ[cz * grid.cols + cx] !== 0 || grid.reserved[cz * grid.cols + cx]) continue;
|
||
cells.push([(cx + 0.5) * grid.cw - W / 2, (cz + 0.5) * grid.cd - D / 2]);
|
||
}
|
||
shuffle(r, cells);
|
||
if (spec.faceDoor) cells.sort((a, b) => doorViewScore(a[0], a[1], doorCx, D) - doorViewScore(b[0], b[1], doorCx, D));
|
||
for (const [x, z] of cells) {
|
||
const jit = r() - 0.5; // [R42] see faceDoorYaw — same draw either way
|
||
const base = jit * 0.4;
|
||
const tries = spec.faceDoor ? [faceDoorYaw(x, z, doorCx, D) + jit * 0.12, 0] : [base];
|
||
for (const ry of tries) {
|
||
const [hw, hd] = halfExtents(f.footprint.w, f.footprint.d, ry);
|
||
const rect = rectCells(grid, x, z, hw, hd);
|
||
if (!areaFree(grid, rect)) continue;
|
||
f.group.position.set(x, 0, z); f.group.rotation.y = ry;
|
||
stampRect(grid, rect);
|
||
placed.push({ fitting: f, x, z, ry, hw, hd, rect, priority: spec.priority, kind: spec.kind });
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function placeAtWall(ctx, grid, f, wallSlots, r, placed, spec) {
|
||
const backOff = f.wallMounted ? 0.06 : f.footprint.d / 2 + 0.05;
|
||
for (const slot of wallSlots) {
|
||
if (slot.used) continue;
|
||
const ry = slot.ry;
|
||
let x = slot.x, z = slot.z;
|
||
if (slot.wall === 'west') x += backOff; else if (slot.wall === 'east') x -= backOff; else z += backOff;
|
||
const y = f.wallMounted ? (f.mountY || 1.4) : 0;
|
||
const [hw, hd] = halfExtents(f.footprint.w, f.footprint.d, ry);
|
||
const rect = rectCells(grid, x, z, hw, hd);
|
||
if (!f.wallMounted && !areaFree(grid, rect)) continue;
|
||
f.group.position.set(x, y, z); f.group.rotation.y = ry;
|
||
if (!f.wallMounted) stampRect(grid, rect);
|
||
slot.used = true;
|
||
// noStamp: wall-mounted fittings hang above head height and don't block the floor — keep rebuildOcc
|
||
// consistent with this placement (see rebuildOcc).
|
||
placed.push({ fitting: f, x, z, ry, hw, hd, rect, priority: spec.priority, kind: spec.kind, noStamp: f.wallMounted });
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function placeNearCounter(ctx, grid, f, counter, r, placed, spec, W, D, doorCx, stage) {
|
||
// cases beside the till, on the goods side of the counter — try BOTH sides + a couple offsets, then
|
||
// fall back to a free-standing aisle spot so a min≥1 fitting (e.g. a glass case) reliably lands.
|
||
const ry = counter.ry;
|
||
// [R41] `spec.gap` — how far along the bench to stand the neighbour. The hardcoded 1.4 m is the gap a
|
||
// glass case needs and is KEPT as the default, so every pre-R41 spec places byte-identically. It is far
|
||
// too small for a 1.55 m DJ booth beside a 2.2 m counter: their padded rects overlap by ~0.8 m, every
|
||
// candidate fails, and the record shop's hero prop landed in 1 room out of 15 (measured). An opt-in
|
||
// number, not a retuned constant — the fitting that needs more room asks for more room.
|
||
const gap = spec.gap || 1.4;
|
||
const cands = counter.ry === 0
|
||
? [{ x: counter.x - gap, z: counter.z }, { x: counter.x + gap, z: counter.z }, { x: counter.x - gap, z: counter.z - 0.7 }, { x: counter.x + gap, z: counter.z - 0.7 }]
|
||
: [{ x: counter.x, z: counter.z - gap }, { x: counter.x, z: counter.z + gap }, { x: counter.x - 0.7, z: counter.z - gap }, { x: counter.x - 0.7, z: counter.z + gap }];
|
||
shuffle(r, cands);
|
||
if (spec.faceDoor) cands.sort(byDoorView(doorCx, D)); // [R42] see doorViewScore
|
||
// [R42 §42.2b] A COUNTER-SIDE CANDIDATE MUST ACTUALLY BE IN THE ROOM.
|
||
// Every other placer derives its candidates from the room box (`zoneCandidates` starts 1.1–1.3 m in;
|
||
// `placeAtWall` backs off by half the footprint), but these four are `counter ± gap` with no bound —
|
||
// and `areaFree` deliberately allows the border cells (occ 2) because shelves hug walls, while
|
||
// `cellOf` CLAMPS anything past the edge back onto the border. So a candidate that overhangs the
|
||
// shell was accepted and the fitting was planted inside the wall. Measured on the record shop, seed
|
||
// 1990: the corner counter sits at z = −D/4, so `counter.z − 2.35` lands the 1.55 m booth at
|
||
// z = −4.35 in a room whose back wall is z = −4.0 — 0.77 m of a 0.86 m half-depth buried in plaster.
|
||
// R41 never saw it because at the counter's own yaw the booth was too deep to fit there at all and
|
||
// fell through to the aisle; aiming it at the door made the deep axis narrow enough to "fit".
|
||
// Pre-existing bug, exposed by this round, fixed here for every `zone: 'counter'` fitting — and a
|
||
// rejected candidate is not a lost fitting: this placer already falls back to the aisle.
|
||
const inRoom = (x, z, hw, hd) => Math.abs(x) + hw <= W / 2 && Math.abs(z) + hd <= D / 2;
|
||
for (const side of cands) {
|
||
// [R42 §42.2b] `spec.faceDoor` — try the door-facing yaw first, keep the counter's yaw as the
|
||
// fallback. This is the case the flag exists for: a `corner` counter is at ry −π/2, and copying it
|
||
// stands the booth square to the side wall with its face pointed across the room. Consumes no
|
||
// stream draws at all (unchanged from R41 — the candidate list is what was shuffled).
|
||
const tries = spec.faceDoor ? [faceDoorYaw(side.x, side.z, doorCx, D), ry] : [ry];
|
||
for (const yaw of tries) {
|
||
const [hw, hd] = halfExtents(f.footprint.w, f.footprint.d, yaw);
|
||
if (!inRoom(side.x, side.z, hw, hd)) continue;
|
||
const rect = rectCells(grid, side.x, side.z, hw, hd);
|
||
if (!areaFree(grid, rect)) continue;
|
||
f.group.position.set(side.x, 0, side.z); f.group.rotation.y = yaw;
|
||
stampRect(grid, rect);
|
||
placed.push({ fitting: f, x: side.x, z: side.z, ry: yaw, hw, hd, rect, priority: spec.priority, kind: spec.kind });
|
||
return true;
|
||
}
|
||
}
|
||
// counter is boxed in — put it in the aisle instead (still front-of-room-ish)
|
||
return placeFree(ctx, grid, f, 'aisle', W, D, doorCx, r, placed, spec, stage);
|
||
}
|
||
|
||
// ── wall decor (art + signage) ─────────────────────────────────────────────────────────
|
||
function placeWallDecor(ctx, roomGroup, recipe, wallSlots, W, D, r) {
|
||
const meshes = [], summary = [];
|
||
const free = wallSlots.filter(s => !s.used);
|
||
const nArt = Math.min(free.length, 2 + (r() * 3 | 0));
|
||
const nSign = Math.min(Math.max(0, free.length - nArt), 1 + (r() * 2 | 0));
|
||
let fi = 0;
|
||
for (let i = 0; i < nArt && fi < free.length; i++, fi++) {
|
||
const slot = free[fi];
|
||
const sz = 0.7 + r() * 0.5;
|
||
const artCol = new ctx.THREE.Color().setHSL(r(), 0.32, 0.42);
|
||
const frame = ctx.box(sz + 0.08, sz + 0.08, 0.04, ctx.mat('#4a3a28', 0.6), 0, 0, 0, roomGroup);
|
||
const canvas = ctx.plane(sz, sz, ctx.mat(artCol, 0.75), roomGroup);
|
||
// inward normal per wall so the canvas sits a hair in front of its frame (no z-fight)
|
||
const nx = slot.wall === 'west' ? 1 : slot.wall === 'east' ? -1 : 0;
|
||
const nz = slot.wall === 'north' ? 1 : 0;
|
||
const y = 1.5 + r() * 0.3, tilt = (r() - 0.5) * 0.05;
|
||
const baseX = slot.wall === 'west' ? -W / 2 + 0.04 : slot.wall === 'east' ? W / 2 - 0.04 : slot.x;
|
||
const baseZ = slot.wall === 'north' ? -D / 2 + 0.04 : slot.z;
|
||
frame.position.set(baseX, y, baseZ); frame.rotation.y = slot.ry; frame.rotation.z = tilt;
|
||
canvas.position.set(baseX + nx * 0.03, y, baseZ + nz * 0.03); canvas.rotation.y = slot.ry; canvas.rotation.z = tilt;
|
||
meshes.push(frame, canvas);
|
||
summary.push({ kind: 'art', wall: slot.wall, x: round(slot.x), z: round(slot.z) });
|
||
}
|
||
const signs = recipe.signFlavour || [];
|
||
for (let i = 0; i < nSign && fi < free.length; i++, fi++) {
|
||
const slot = free[fi];
|
||
const txt = signs[(r() * signs.length) | 0] || 'SALE';
|
||
const mesh = makeSignPlane(ctx, txt);
|
||
const off = 0.05;
|
||
const x = slot.wall === 'west' ? -W / 2 + off : slot.wall === 'east' ? W / 2 - off : slot.x;
|
||
const z = slot.wall === 'north' ? -D / 2 + off : slot.z;
|
||
mesh.position.set(x, 2.05, z); mesh.rotation.y = slot.ry;
|
||
roomGroup.add(mesh);
|
||
meshes.push(mesh);
|
||
summary.push({ kind: 'sign', wall: slot.wall, text: txt, x: round(slot.x), z: round(slot.z) });
|
||
}
|
||
return { meshes, summary };
|
||
}
|
||
|
||
function makeSignPlane(ctx, text) {
|
||
const cv = document.createElement('canvas'); cv.width = 256; cv.height = 64;
|
||
const x = cv.getContext('2d');
|
||
x.fillStyle = '#f5f2ea'; x.fillRect(0, 0, 256, 64);
|
||
x.strokeStyle = '#333'; x.lineWidth = 3; x.strokeRect(3, 3, 250, 58);
|
||
x.fillStyle = '#c1121f'; x.font = 'bold 30px Arial'; x.textAlign = 'center'; x.textBaseline = 'middle';
|
||
x.fillText(String(text).slice(0, 18), 128, 34);
|
||
const tex = ctx.canvasTexture(cv);
|
||
const m = ctx.mat('#ffffff', 0.7); m.map = tex; m.needsUpdate = true;
|
||
const mesh = ctx.plane(1.3, 0.32, m);
|
||
return mesh;
|
||
}
|
||
|
||
// ── small utils ─────────────────────────────────────────────────────────────────────
|
||
function idxOf(grid, x, z) { const [cx, cz] = grid.cellOf(x, z); return cz * grid.cols + cx; }
|
||
function reaches(grid, startIdx, targetIdx) {
|
||
if (grid.occ[startIdx] !== 0) { const s = nearestFree(grid, startIdx); if (s < 0) return false; startIdx = s; }
|
||
if (grid.occ[targetIdx] !== 0) { const t = nearestFree(grid, targetIdx); if (t < 0) return false; targetIdx = t; }
|
||
return floodFill(grid, startIdx).has(targetIdx);
|
||
}
|
||
function nearestFree(grid, idx) {
|
||
const cx0 = idx % grid.cols, cz0 = (idx / grid.cols) | 0;
|
||
for (let rad = 1; rad < 6; rad++)
|
||
for (let dz = -rad; dz <= rad; dz++) for (let dx = -rad; dx <= rad; dx++) {
|
||
const nx = cx0 + dx, nz = cz0 + dz;
|
||
if (nx < 1 || nz < 1 || nx >= grid.cols - 1 || nz >= grid.rows - 1) continue;
|
||
if (grid.occ[nz * grid.cols + nx] === 0) return nz * grid.cols + nx;
|
||
}
|
||
return -1;
|
||
}
|
||
// Clear a Manhattan corridor between two cells (last-resort guarantee).
|
||
function carveCorridor(grid, startIdx, targetIdx) {
|
||
let cx = startIdx % grid.cols, cz = (startIdx / grid.cols) | 0;
|
||
const tx = targetIdx % grid.cols, tz = (targetIdx / grid.cols) | 0;
|
||
const clear = (x, z) => { if (x > 0 && z > 0 && x < grid.cols - 1 && z < grid.rows - 1) grid.occ[z * grid.cols + x] = 0; };
|
||
while (cz !== tz) { clear(cx, cz); cz += Math.sign(tz - cz); }
|
||
while (cx !== tx) { clear(cx, cz); cx += Math.sign(tx - cx); }
|
||
clear(tx, tz);
|
||
}
|
||
const round = (v) => Math.round(v * 1000) / 1000;
|
||
// The R41 kit's specs, by construction: every one of them is a `kit*` fitting or a `djBooth`.
|
||
const isKitSpec = (s) => s.kind === 'djBooth' || /^kit[A-Z]/.test(s.kind);
|