PROCITY/web/js/interiors/layout.js
m3ultra 5ae635a1e9 Lane C round-9 C1: seeded browse points (room.browsePoints) for Lane D rigs
layout.js placeBrowsePoints(): 1–3 deterministic floor poses in front of
browsable floor fittings, facing the goods. Guarantees (all asserted):
walkable (occ-free, not reserved), reachable from spawn (door→counter
flood-fill), ≥0.6m apart + clear of keeper stand, every archetype (rich
candidate ring — 4 sides×2 gaps + diagonals — plus a nearest-free-cell
fallback so tight/mismatched rooms still yield ≥1). Pure data: no meshes,
no draws; ≤350 law untouched. Exposed as room.browsePoints[]
{x,z,ry,atKind,slotIndex}, same coord frame + ry convention as
counter.stand (contract in LANE_C_NOTES).

Validated headless: 180 type×archetype×seed combos → 0 empties (177×3,
3×1 via fallback), determinism 0 fails, min-sep 0.7m, 0 keeper clashes.
Test page: cyan browse-post markers on the 'path' overlay + HUD count.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 09:36:26 +10:00

490 lines
26 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;
}
// ── 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);
// 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'));
// 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,
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;