PROCITY/web/js/interiors/layout.js
m3ultra 42b11e717d Lane C R16 (v3.1): stage.backline[] amp unification, drop primitive ampStacks (ledger #5, C half)
C exposes room.stage.backline[] — 2 seeded up-stage amp slots {x,z,ry,y} (pure data) — and
drops its primitive ampStacks from placeStage. Lane D plants E's guitar_amp GLB at backline[0]
(D's primitive fallback under ?noassets): one unified backline, GLB when present.

- backline[0] = (stage.x + stage.w*0.36, deckY, stage.z - stage.d*0.22) == D's R15-verified amp
  pose exactly, so plumbing D's plant to backline[0] moves nothing on screen. backline[1] = spare
  stage-left mirror. ry=pi (audience-facing), y=deckY.
- Verified fresh (seed 20261990): slots up-stage, on-deck, clear of all 4 bandPoses (nearest-bass
  0.87 pub / 0.70 band_room / 0.92 rsl) and all watchPoints (>=1.95 m); determinism byte-identical;
  0 leftover ampStack groups; drawSweep venues 122/120/131 (was 126/124/135, -~4 draws each) <=350.
- Determinism/golden-safe: dropping the amp loop only drops draws on the 'stage' sub-stream (nothing
  downstream reads it); placement snapshot excludes ampStacks; interiors aren't hashed; ?classic has
  no venues. Disjoint from F's flip. LANE_C_PUB §3 amended (v3.1 marker).

C->D handshake: backline shape live. Verify with D once the GLB is wired at backline[0].

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 12:16:38 +10:00

592 lines
33 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 — 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 };
}
const f = buildFitting('counter', ctx, { w: cw }, 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 ─────────────────────────
// 13 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) {
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 === '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 }) {
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);
for (const spec of recipe.fittings) {
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++) {
const f = buildFitting(spec.kind, ctx, {}, 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);
} else {
ok = placeFree(ctx, grid, f, spec.zone, 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);
while (!pathOK) {
const pool = removable();
if (!pool.length) break;
const victim = pool[0];
placed.splice(placed.indexOf(victim), 1);
if (victim.fitting.group.parent) victim.fitting.group.parent.remove(victim.fitting.group);
victim.removed = true;
rebuildOcc(grid, placed);
pathOK = reaches(grid, spawnCell, targetCell);
}
if (!pathOK) { carveCorridor(grid, spawnCell, targetCell); carved = true; pathOK = reaches(grid, spawnCell, targetCell); }
// 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)}`));
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) — 13 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,
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 ──────────────────────────────────────────────────────────────
function placeFree(ctx, grid, f, zone, W, D, doorCx, r, placed, spec) {
const cands = zoneCandidates(zone, W, D, doorCx, r);
for (const c of cands) {
const ry = c.ry + (r() - 0.5) * 0.4;
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;
}
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) {
// 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;
const cands = counter.ry === 0
? [{ x: counter.x - 1.4, z: counter.z }, { x: counter.x + 1.4, z: counter.z }, { x: counter.x - 1.4, z: counter.z - 0.7 }, { x: counter.x + 1.4, z: counter.z - 0.7 }]
: [{ x: counter.x, z: counter.z - 1.4 }, { x: counter.x, z: counter.z + 1.4 }, { x: counter.x - 0.7, z: counter.z - 1.4 }, { x: counter.x - 0.7, z: counter.z + 1.4 }];
shuffle(r, cands);
const [hw, hd] = halfExtents(f.footprint.w, f.footprint.d, ry);
for (const side of cands) {
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 = ry;
stampRect(grid, rect);
placed.push({ fitting: f, x: side.x, z: side.z, ry, 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);
}
// ── 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;