// 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 ───────────────────────── // 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; } // ── 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) — 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')); // 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;