PROCITY/web/js/interiors/layout.js
m3ultra c88d8906a2 Lane C R6 C2: map the 3 orphaned fittings (glass_case/magazine_rack/crate_stack)
Published R5, unconsumed until now:
- glb.js KIND_TO_GLB: glassCase->glass_case, magazineRack->magazine_rack,
  and a NEW crateStack primitive (fittings.js — a stack of crates) ->
  crate_stack. yaw 0 (validated on-screen).
- theme.js: crateStack in record + opshop (back-room dressing); glassCase
  added to milkbar; magazineRack added to pawn; milkbar magazineRack moved
  window->wall (dodges the door-counter congestion).
- layout.js: fixed placeNearCounter — it picked one random side and gave up,
  so counter-zone fittings (the glass case) rarely landed. Now it tries both
  sides + offsets, then falls back to a free aisle spot, so a min>=1 fitting
  reliably lands.

Validated (GLB on, baked depot): all 3 load + render planted/scaled/facing
right. Appearance glassCase 12/12 (toy/pawn/milkbar), magazineRack 12/12
(milkbar/pawn), crateStack ~11/12 (record/opshop). drawSweep still <=350
with them on (worst 177 GLB-on / 157 off). Determinism 0 fails, leak-free,
dig (?dig=1) still riffles batched bins (C3 re-verify). Contact sheet
docs/shots/laneC/glb_orphans_r6.jpg. qa.sh --strict GREEN.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 23:19:48 +10:00

400 lines
21 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)) };
}
// ── 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);
// 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);
// 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,
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;