PROCITY/web/js/citygen/plan.js
m3ultra 5b8c806271 Lane A round 4: harden openLate contract (hours>=22 ⟺ openLate == the video), confirm F2
v1-tag round. Two confirmations + one hardening fix; qa.sh --strict GREEN (4/4).

A2 — openLate: F's night gate `hours[1] >= 22` matched 4–15 shops/seed (regular videos
jitter base-21 → 22), not the single landmark the shell assumes. Fixed in plan.js: regular
shops close by LATE_HOUR-1 (21:00); only the deterministically chosen openLate landmark
reaches ≥22, pick is now video-first. So `hours[1] >= 22` and the `openLate` field are the
same singleton, always the video rental (verified 15 seeds). Makes generation match
web/index.html:159-161 + soak.py:68 ("at night only the openLate shop is open"). selfcheck
asserts exactly-one / field⟺threshold / is-video. ALL GREEN 1301/1301.

A1 — Lane F's F2 permittedMax storeys scoping: confirmed 0 warnings across 7 seeds + a
false-negative test (injected video@2 in a scratch checker → caught every seed).

GOLDEN.hash 0x098eec2b → 0x3fa36874: intended A2 output change (a few videos close 21 not
22 for seed 20261990), NOT drift; F2 is tooling-only. Same lots/shops/types/names/skins.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:26:30 +10:00

449 lines
24 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 CityGen — Layer 1. generatePlan(citySeed) → CityPlan (pure JSON-serializable data).
// NO THREE import anywhere in this file (CITY_SPEC law). All randomness flows through core/prng.
// Deterministic: same citySeed ⇒ byte-identical plan, forever, in <100ms.
//
// Coordinate frame (CITY_SPEC): metres, +Y up, ground = XZ. Origin (0,0) = centre of the town.
// +x = east, -x = west, +z = north, -z = south. The main-street spine runs roughly NS.
//
// A lot's `ry` is the Y-rotation that makes its facade's outward normal point at its street edge
// (GLB convention: model faces -Z at ry=0). Lane B rotates the building shell by ry.
import { rng, seedFor, mulberry32, pick, frange, irange, shuffle } from '../core/prng.js';
import { SHOP_TYPES, pickWeightedType } from '../core/registry.js';
import { shopName, townName } from './names.js';
const r2 = v => Math.round(v * 100) / 100; // position/size precision (2dp metres)
const r4 = v => Math.round(v * 10000) / 10000; // angle precision (radians)
const clampHour = h => Math.max(0, Math.min(23, h | 0));
// The town has exactly ONE late-night landmark. Regular shops close by LATE_HOUR-1; only the
// deterministically-chosen `openLate` shop reaches ≥ LATE_HOUR — so `hours[1] >= LATE_HOUR`
// and the `openLate` field are equivalent, and both identify that single shop (Lane F night gate).
const LATE_HOUR = 22;
// World-space corners of a lot's rotated footprint. Convention matches selfcheck + Lane B:
// a lot faces its street via `ry`; corners are [±w/2, ±d/2] rotated by ry about the lot centre.
export function lotCorners(l) {
const cs = Math.cos(l.ry), sn = Math.sin(l.ry), hw = l.w / 2, hd = l.d / 2;
return [[-hw, -hd], [hw, -hd], [hw, hd], [-hw, hd]].map(([ox, oz]) => [
l.x + ox * cs + oz * sn, l.z - ox * sn + oz * cs,
]);
}
// Separating-Axis-Theorem overlap of two convex quads, tolerance 5cm (absorbs 2dp rounding at
// touching edges without masking real overlaps, which are metres). Axes are normalised so EPS is metric.
export function obbOverlap(a, b) {
const EPS = 0.05;
for (const quad of [a, b]) {
for (let i = 0; i < quad.length; i++) {
const p1 = quad[i], p2 = quad[(i + 1) % quad.length];
let ax = -(p2[1] - p1[1]), az = p2[0] - p1[0];
const m = Math.hypot(ax, az) || 1; ax /= m; az /= m;
let minA = Infinity, maxA = -Infinity, minB = Infinity, maxB = -Infinity;
for (const [x, z] of a) { const d = x * ax + z * az; if (d < minA) minA = d; if (d > maxA) maxA = d; }
for (const [x, z] of b) { const d = x * ax + z * az; if (d < minB) minB = d; if (d > maxB) maxB = d; }
if (maxA < minB + EPS || maxB < minA + EPS) return false;
}
}
return true;
}
export function generatePlan(citySeed) {
citySeed = citySeed >>> 0;
// ── plan accumulators + id-stamping factories ───────────────────────────────────
const nodes = [], edges = [], districts = [], blocks = [], lots = [], shops = [];
let nid = 0, eid = 0, did = 0, bid = 0, lid = 0, sid = 0;
const addNode = (x, z) => { const n = { id: nid++, x: r2(x), z: r2(z) }; nodes.push(n); return n; };
const addEdge = (a, b, width, kind) => { const id = eid++; edges.push({ id, a, b, width, kind }); return id; };
const addDistrict = (kind, cx, cz) => { const id = did++; districts.push({ id, kind, cx: r2(cx), cz: r2(cz) }); return id; };
const addBlock = (district, kind, poly) => {
const id = bid++;
blocks.push({ id, district, kind, poly: poly.map(p => [r2(p[0]), r2(p[1])]) });
return id;
};
const addLot = (block, x, z, w, d, ry, frontEdge, use) => {
const id = lid++;
lots.push({ id, block, x: r2(x), z: r2(z), w: r2(w), d: r2(d), ry: r4(ry), frontEdge, use });
return id;
};
// Create a shop record for a lot. `type` from registry; facade/storeys/hours seeded off id.
function createShop(lotId, type, opts = {}) {
const id = sid++;
const seed = seedFor(citySeed, 'shop', id);
const reg = SHOP_TYPES[type] || SHOP_TYPES.opshop;
const sr = mulberry32(seed);
const facadeSkin = pick(sr, reg.facades);
let storeys = reg.storeys[0] + ((sr() * (reg.storeys[1] - reg.storeys[0] + 1)) | 0);
// occasional 3-storey corner anchor — but only for types that already build tall (registry
// max ≥ 2); never make an inherently single-storey type (video/milkbar/stall) taller.
if (opts.cornerBoost && reg.storeys[1] >= 2 && sr() < 0.5) storeys = Math.min(3, storeys + 1);
let open = reg.hours.open, close = reg.hours.close;
if (sr() < 0.3) open = clampHour(open + (sr() < 0.5 ? -1 : 1));
if (sr() < 0.3) close = clampHour(close + (sr() < 0.5 ? -1 : 1));
if (close <= open) close = clampHour(open + 2);
close = Math.min(close, LATE_HOUR - 1); // regular shops close by 21:00 (only the openLate landmark is later)
// (the town's one guaranteed late-night shop is chosen deterministically after the whole
// shop set is final — see the `openLate` pass at the end of generatePlan.)
const { name, sign } = shopName(seed, type);
shops.push({ id, lot: lotId, type, name, sign, seed, facadeSkin, storeys, hours: [open, close] });
return id;
}
// March lots along one side of a street edge A→B. Lots are sequential intervals along the edge
// ⇒ never overlap within their block by construction. Returns { blockId, lots:[{lot,use,cx,cz}] }.
function marchStrip(streamKind, streamId, districtId, districtKind, edgeId, A, B, corridorW, side, band) {
const dx = B.x - A.x, dz = B.z - A.z, len = Math.hypot(dx, dz);
const out = { blockId: -1, lots: [] };
if (len < 1) return out;
const ux = dx / len, uz = dz / len;
const nx = side > 0 ? -uz : uz; // outward normal: street → lot
const nz = side > 0 ? ux : -ux;
const half = corridorW / 2;
const stripDepth = band.dmax + 2;
const c0 = [A.x + nx * half, A.z + nz * half];
const c1 = [B.x + nx * half, B.z + nz * half];
const c2 = [B.x + nx * (half + stripDepth), B.z + nz * (half + stripDepth)];
const c3 = [A.x + nx * (half + stripDepth), A.z + nz * (half + stripDepth)];
const blockId = addBlock(districtId, districtKind, [c0, c1, c2, c3]);
out.blockId = blockId;
const ry = Math.atan2(nx, nz); // facade faces back down the outward normal, to the street
const r = rng(citySeed, streamKind, streamId);
const iStart = band.insetStart ?? 0, iEnd = band.insetEnd ?? 0;
let t = iStart; const end = len - iEnd;
while (end - t >= band.fmin) {
let f = frange(r, band.fmin, band.fmax);
if (t + f > end) f = end - t;
if (f < band.fmin * 0.75) break;
const d = frange(r, band.dmin, band.dmax);
const along = t + f / 2, off = half + d / 2;
const cx = A.x + ux * along + nx * off;
const cz = A.z + uz * along + nz * off;
const lot = addLot(blockId, cx, cz, f, d, ry, edgeId, band.use);
out.lots.push({ lot, use: band.use, cx, cz });
t += f + (band.gap ?? 0);
}
return out;
}
// Assign shop types to a retail block's 'shop' lots, with same-type clustering (a record shop
// next to an opshop next to a bookshop is the vibe — bias each lot toward the block's lead type).
function assignRetail(blockId, districtKind, lotsArr) {
const r = rng(citySeed, 'shopmix', blockId);
const lead = pickWeightedType(r, districtKind);
lotsArr.forEach((L, i) => {
if (L.use !== 'shop') return;
let type = (r() < 0.55 && lead) ? lead : pickWeightedType(r, districtKind);
if (!type) type = 'opshop';
const corner = (i === 0 || i === lotsArr.length - 1);
createShop(L.lot, type, { cornerBoost: corner && districtKind === 'mainstreet' });
});
}
// ── districts (coarse area markers; every block belongs to one) ──────────────────
const dMain = addDistrict('mainstreet', 0, 0);
const dRes = addDistrict('residential', 0, 0);
// ── the spine: 7 stations S→N through the origin, x-jitter ±30m ───────────────────
const NST = 7, zTop = 400;
const spinePts = [];
for (let i = 0; i < NST; i++) {
const z = -zTop + (2 * zTop) * i / (NST - 1);
const rr = rng(citySeed, 'spine', i);
const x = (i === 3) ? frange(rr, -8, 8) : frange(rr, -30, 30); // keep origin on the spine
spinePts.push(addNode(x, z));
}
const spineEdges = [];
for (let i = 0; i < NST - 1; i++) spineEdges.push(addEdge(spinePts[i].id, spinePts[i + 1].id, 28, 'main'));
// ── cross-street rungs at stations 1,2,4,5 (central 2 = second high streets) ──────
const rungStations = [1, 2, 4, 5];
const rungs = [];
for (const st of rungStations) {
const base = spinePts[st];
const rr = rng(citySeed, 'rung', st);
const theta = frange(rr, -0.14, 0.14); // ±8° jitter off pure EW
const central = (st === 2 || st === 4);
const width = central ? 20 : 14;
const eLen = frange(rr, central ? 200 : 150, central ? 250 : 190);
const wLen = frange(rr, central ? 200 : 150, central ? 250 : 190);
const ex = Math.cos(theta), ez = Math.sin(theta);
const eastEnd = addNode(base.x + ex * eLen, base.z + ez * eLen);
const westEnd = addNode(base.x - ex * wLen, base.z - ez * wLen);
const eEdge = addEdge(base.id, eastEnd.id, width, 'side');
const wEdge = addEdge(westEnd.id, base.id, width, 'side');
const districtId = central ? dMain : addDistrict('backstreets', 0, base.z);
rungs.push({ st, base, eastEnd, westEnd, eEdge, wEdge, width, central, districtId });
}
// ── main-street retail strips along the spine (reserve origin: W=market, E=arcade) ─
for (let i = 0; i < NST - 1; i++) {
const A = spinePts[i], B = spinePts[i + 1];
const dz = B.z - A.z, len = Math.hypot(B.x - A.x, dz), uz = dz / len;
for (const side of [1, -1]) {
const west = (side > 0 ? -uz : uz) < 0; // outward normal's x-sign: <0 ⇒ this side faces west
if (west && (i === 2 || i === 3)) continue; // market square occupies west-of-origin
if (!west && i === 3) continue; // arcade cuts east through this mid-spine block
const band = { fmin: 6, fmax: 9, dmin: 12, dmax: 20, gap: 0, use: 'shop', insetStart: 12, insetEnd: 12 };
const res = marchStrip('spinelot', i * 2 + (side > 0 ? 0 : 1), dMain, 'mainstreet', spineEdges[i], A, B, 28, side, band);
assignRetail(res.blockId, 'mainstreet', res.lots);
}
}
// ── cross-street retail strips (both arms, both sides) ────────────────────────────
for (const rung of rungs) {
const districtKind = rung.central ? 'mainstreet' : 'backstreets';
const arms = [
{ edge: rung.eEdge, A: rung.base, B: rung.eastEnd, tag: 'e' },
{ edge: rung.wEdge, A: rung.westEnd, B: rung.base, tag: 'w' },
];
// Reserve the intersection corner: the near-spine end of each arm must clear not just the spine
// ROAD (half-corridor 14m) but the spine RETAIL STRIP's full depth (dmax 20m) so cross-street
// lots don't interpenetrate the spine lots. 14+20 = 34m. (A final resolution pass mops up any
// residual corner collisions from the ±8° rung jitter — see resolveOverlaps below.)
const SPINE_CLEAR = 34;
for (const arm of arms) {
for (const side of [1, -1]) {
const band = {
fmin: rung.central ? 6 : 7, fmax: rung.central ? 9 : 10,
dmin: 12, dmax: 18, gap: rung.central ? 0 : 2, use: 'shop',
insetStart: arm.tag === 'e' ? SPINE_CLEAR : 6, // near-spine end clears the spine strip depth
insetEnd: arm.tag === 'e' ? 6 : SPINE_CLEAR,
};
const streamId = rung.st * 100 + (arm.tag === 'e' ? 0 : 10) + (side > 0 ? 0 : 1);
const res = marchStrip('runglot', streamId, rung.districtId, districtKind, arm.edge, arm.A, arm.B, rung.width, side, band);
assignRetail(res.blockId, districtKind, res.lots);
}
}
}
// ── market square (west of origin): stalls in rows + the dept anchor fronting the spine ─
{
const spineX = spinePts[3].x;
const eastEdge = spineX - 14; // west kerb of the spine corridor
const marketW = 120, zLo = -110, zHi = 120;
const dMarket = addDistrict('market', eastEdge - marketW / 2, (zLo + zHi) / 2);
const poly = [[eastEdge, zLo], [eastEdge, zHi], [eastEdge - marketW, zHi], [eastEdge - marketW, zLo]];
const block = addBlock(dMarket, 'market', poly);
// Facades face +x (EAST, toward the spine — the market sits west of it). ry is the OUTWARD normal
// (street→lot); a west-of-street lot's outward normal is x, so ry = atan2(1, 0) (= −π/2). The
// facade then faces (outward) = +x, matching marchStrip's convention. frontEdge is the spine
// segment each lot actually fronts by z-band: stalls (z≈100..23) front segment 2; dept (z=55) → 3.
const ryEast = Math.atan2(-1, 0);
const stallEdge = spineEdges[2]; // spine segment spanning z∈[133,0] — the stalls' band
const deptEdge = spineEdges[3]; // spine segment spanning z∈[0,133] — the dept's band
const COLS = 5, ROWS = 8, pitch = 11, sSize = 4.5; // tidy rows in the southern ~2/3 of the square
for (let gx = 0; gx < COLS; gx++) {
for (let gz = 0; gz < ROWS; gz++) {
const x = eastEdge - 12 - gx * pitch;
const z = zLo + 10 + gz * pitch; // z ∈ [100, 23], all south of the dept strip (z≈41+)
addLot(block, x, z, sSize, sSize, ryEast, stallEdge, 'stall');
}
}
// shops for every stall lot (in id order for determinism)
for (const lot of lots) if (lot.block === block && lot.use === 'stall') createShop(lot.id, 'stall');
// dept anchor: big building on the square's north strip, fronting the spine
const deptW = 28, deptD = 26;
const deptId = addLot(block, eastEdge - deptD / 2, 55, deptW, deptD, ryEast, deptEdge, 'anchor');
createShop(deptId, 'dept', { cornerBoost: false });
}
// ── arcade: a covered pedestrian lane cutting east through a mid-spine block ───────
{
const midX = (spinePts[3].x + spinePts[4].x) / 2;
const midZ = (spinePts[3].z + spinePts[4].z) / 2;
const start = addNode(midX + 14, midZ);
const end = addNode(midX + 14 + 42, midZ);
const aEdge = addEdge(start.id, end.id, 5, 'arcade');
const dArcade = addDistrict('arcade', (start.x + end.x) / 2, midZ);
const band = { fmin: 3, fmax: 5, dmin: 5, dmax: 8, gap: 0.5, use: 'shop', insetStart: 2, insetEnd: 2 };
for (const side of [1, -1]) {
const res = marchStrip('arclot', side > 0 ? 0 : 1, dArcade, 'arcade', aEdge, start, end, 5, side, band);
assignRetail(res.blockId, 'arcade', res.lots);
}
}
// ── warehouse fringe: sparse big lots beyond one spine end (seeded N or S) ─────────
{
const rr = rng(citySeed, 'ware', 0);
const north = rr() < 0.5;
const z0 = north ? 480 : -480;
const A = addNode(-140, z0), B = addNode(140, z0);
const edge = addEdge(A.id, B.id, 12, 'side');
const dWare = addDistrict('warehouse', 0, z0);
// A→B runs +x (u=(1,0)); side>0 ⇒ normal (0,+1)=north, side<0 ⇒ (0,-1)=south. Face lots inward:
const useSide = north ? -1 : 1; // north fringe: lots to its south; south fringe: to its north
const band = { fmin: 20, fmax: 30, dmin: 24, dmax: 34, gap: 8, use: 'infill', insetStart: 8, insetEnd: 8 };
const res = marchStrip('warelot', 0, dWare, 'warehouse', edge, A, B, 12, useSide, band);
// sparsely activate a few as pawn/junk shops
for (const L of res.lots) {
const r3 = rng(citySeed, 'wareup', L.lot);
if (r3() < 0.3) { lots[L.lot].use = 'shop'; createShop(L.lot, 'pawn'); }
}
}
// ── residential collar: a loose ring road of houses, with 24 corner milk bars ────
{
const Wc = 430, Hc = 445;
const NW = addNode(-Wc, Hc), NE = addNode(Wc, Hc), SE = addNode(Wc, -Hc), SW = addNode(-Wc, -Hc);
const ringEdges = [
{ e: addEdge(NW.id, NE.id, 14, 'side'), A: NW, B: NE, inward: -1 }, // N: lots south (inward)
{ e: addEdge(NE.id, SE.id, 14, 'side'), A: NE, B: SE, inward: 1 }, // E: lots west (inward)
{ e: addEdge(SE.id, SW.id, 14, 'side'), A: SE, B: SW, inward: -1 }, // S: lots north (inward)
{ e: addEdge(SW.id, NW.id, 14, 'side'), A: SW, B: NW, inward: 1 }, // W: lots east (inward)
];
const cornerLots = [];
ringEdges.forEach((R, k) => {
const band = { fmin: 14, fmax: 20, dmin: 12, dmax: 18, gap: 5, use: 'house', insetStart: 24, insetEnd: 24 };
const res = marchStrip('houselot', k, dRes, 'residential', R.e, R.A, R.B, 14, R.inward, band);
if (res.lots.length) { cornerLots.push(res.lots[0], res.lots[res.lots.length - 1]); }
});
const shuffled = shuffle(rng(citySeed, 'milkbar', 0), cornerLots.slice());
const k = irange(rng(citySeed, 'milkbarN', 0), 2, 4);
for (let i = 0; i < Math.min(k, shuffled.length); i++) {
lots[shuffled[i].lot].use = 'shop';
createShop(shuffled[i].lot, 'milkbar');
}
}
// ── laneways behind the central main-street blocks (back-door flavour + yard lots) ─
{
const spineX = spinePts[3].x;
const eLaneX = spineX + 36, wLaneX = spineX - 36;
const laneA_E = addNode(eLaneX, -140), laneB_E = addNode(eLaneX, 140);
const laneA_W = addNode(wLaneX, -140), laneB_W = addNode(wLaneX, 140);
const eLane = addEdge(laneA_E.id, laneB_E.id, 4, 'lane');
const wLane = addEdge(laneA_W.id, laneB_W.id, 4, 'lane');
const band = { fmin: 8, fmax: 12, dmin: 8, dmax: 12, gap: 4, use: 'yard', insetStart: 12, insetEnd: 12 };
marchStrip('lanelot', 0, dMain, 'mainstreet', eLane, laneA_E, laneB_E, 4, -1, band); // yards on far (east) side
marchStrip('lanelot', 1, dMain, 'mainstreet', wLane, laneA_W, laneB_W, 4, 1, band); // yards on far (west) side
}
// ── corner de-confliction: guarantee no two BUILDING lots overlap across block boundaries ──
// Strips are non-overlapping within a block by construction, but perpendicular strips can still
// interpenetrate at street corners. Broad-phase over a spatial grid, then OBB/SAT; when two solid
// lots from different blocks collide, demote the higher-id one to 'infill' (dropping its shop).
// Deterministic: lots are visited in id order, so the earlier-built lot always wins the corner.
{
const SOLID = new Set(['shop', 'anchor', 'house', 'stall']);
const GCELL = 24; // broad-phase cell; correctness is independent of size (we register+query full AABB)
const grid = new Map();
const gkey = (cx, cz) => `${cx},${cz}`;
const demoted = new Set();
for (const lot of lots) {
if (!SOLID.has(lot.use)) continue;
const q = lotCorners(lot);
let minx = Infinity, maxx = -Infinity, minz = Infinity, maxz = -Infinity;
for (const [x, z] of q) { if (x < minx) minx = x; if (x > maxx) maxx = x; if (z < minz) minz = z; if (z > maxz) maxz = z; }
const cx0 = Math.floor(minx / GCELL), cx1 = Math.floor(maxx / GCELL);
const cz0 = Math.floor(minz / GCELL), cz1 = Math.floor(maxz / GCELL);
let hit = false;
for (let cx = cx0; cx <= cx1 && !hit; cx++) for (let cz = cz0; cz <= cz1 && !hit; cz++) {
const bucket = grid.get(gkey(cx, cz)); if (!bucket) continue;
for (const oid of bucket) {
if (lots[oid].block === lot.block) continue; // within-block is disjoint by construction
if (obbOverlap(q, lotCorners(lots[oid]))) { hit = true; break; }
}
}
if (hit) { demoted.add(lot.id); continue; } // don't index a demoted lot (it becomes non-solid)
for (let cx = cx0; cx <= cx1; cx++) for (let cz = cz0; cz <= cz1; cz++) {
const k = gkey(cx, cz); let b = grid.get(k); if (!b) grid.set(k, b = []); b.push(lot.id);
}
}
if (demoted.size) {
for (const id of demoted) lots[id].use = 'infill';
for (let i = shops.length - 1; i >= 0; i--) if (demoted.has(shops[i].lot)) shops.splice(i, 1);
}
}
// ── exactly one late-night landmark: the town's designated "open till late" shop ──────
// The brief calls for one weirdo trading late. Make it a deterministic, explicitly-flagged
// landmark (not a per-shop dice, which gave 0..many and often none past 22:00) so Lane B can
// light it after dark and Lane F can gate on a known field instead of a magic hours threshold.
// The video rental is the archetypal 90s late-night shop, so pick a video first; fall back to a
// milk bar, then any non-stall, only if a town somehow has none. Market stalls (morning-only)
// are never it. Runs AFTER corner de-confliction so the chosen shop can't then be spliced out.
if (shops.length) {
const videos = shops.filter(s => s.type === 'video');
const milkbars = shops.filter(s => s.type === 'milkbar');
const pool = videos.length ? videos : (milkbars.length ? milkbars : shops.filter(s => s.type !== 'stall'));
if (pool.length) {
const lr = rng(citySeed, 'openlate', 0);
const chosen = pick(lr, pool);
chosen.openLate = true;
chosen.hours = [chosen.hours[0], LATE_HOUR + ((lr() * 2) | 0)]; // 22:00 or 23:00 — the town's only shop ≥ LATE_HOUR
}
}
return {
version: 1,
citySeed,
name: townName(citySeed),
size: { w: 1024, d: 1024 },
districts,
streets: { nodes, edges },
blocks,
lots,
shops,
};
}
// ── chunk index: chunk key "cx,cz" → { lots, shops, edges } touching that 64m cell ──────
export const CHUNK = 64;
export const chunkKey = (cx, cz) => `${cx},${cz}`;
export function chunkIndex(plan, chunkSize = CHUNK) {
const cell = v => Math.floor(v / chunkSize);
const chunks = {};
const bucket = k => (chunks[k] ||= { lots: [], shops: [], edges: [] });
// each lot → the chunks its (rotated) footprint AABB covers
const lotKeys = new Map();
for (const lot of plan.lots) {
const cs = Math.cos(lot.ry), sn = Math.sin(lot.ry), hw = lot.w / 2, hd = lot.d / 2;
let minx = Infinity, maxx = -Infinity, minz = Infinity, maxz = -Infinity;
for (const [ox, oz] of [[-hw, -hd], [hw, -hd], [hw, hd], [-hw, hd]]) {
const wx = lot.x + ox * cs + oz * sn, wz = lot.z - ox * sn + oz * cs;
if (wx < minx) minx = wx; if (wx > maxx) maxx = wx;
if (wz < minz) minz = wz; if (wz > maxz) maxz = wz;
}
const keys = [];
for (let cx = cell(minx); cx <= cell(maxx); cx++)
for (let cz = cell(minz); cz <= cell(maxz); cz++) {
const k = chunkKey(cx, cz); bucket(k).lots.push(lot.id); keys.push(k);
}
lotKeys.set(lot.id, keys);
}
// shops ride along with their lot's chunks
for (const shop of plan.shops)
for (const k of (lotKeys.get(shop.lot) || [])) bucket(k).shops.push(shop.id);
// edges → every chunk the road CORRIDOR touches (centreline ± width/2 — the road + verge band
// where Lane B drops verandah posts, trees and street furniture). Each edge is split into
// ≤chunkSize pieces and every cell of each piece's corridor-rectangle AABB is bucketed. This is
// gap-free by construction: every point of the corridor lies inside some piece's AABB, so a prop
// placed anywhere in the verge always resolves to a chunk that lists its edge — closing the
// furniture-drop class Lane B hit against the old centreline-only sampling. (selfcheck asserts it.)
const addEdgeCell = (id, cx, cz) => { const bk = bucket(chunkKey(cx, cz)); if (!bk.edges.includes(id)) bk.edges.push(id); };
const nodeById = new Map(plan.streets.nodes.map(n => [n.id, n]));
for (const e of plan.streets.edges) {
const a = nodeById.get(e.a), b = nodeById.get(e.b); if (!a || !b) continue;
const dx = b.x - a.x, dz = b.z - a.z, len = Math.hypot(dx, dz) || 1;
const hw = (e.width || 0) / 2, px = -dz / len * hw, pz = dx / len * hw; // perpendicular half-corridor
const pieces = Math.max(1, Math.ceil(len / chunkSize));
for (let s = 0; s < pieces; s++) {
const x0 = a.x + dx * s / pieces, z0 = a.z + dz * s / pieces;
const x1 = a.x + dx * (s + 1) / pieces, z1 = a.z + dz * (s + 1) / pieces;
let minx = Infinity, maxx = -Infinity, minz = Infinity, maxz = -Infinity; // AABB of this piece's corridor rect
for (const [qx, qz] of [[x0 + px, z0 + pz], [x0 - px, z0 - pz], [x1 + px, z1 + pz], [x1 - px, z1 - pz]]) {
if (qx < minx) minx = qx; if (qx > maxx) maxx = qx; if (qz < minz) minz = qz; if (qz > maxz) maxz = qz;
}
for (let cx = cell(minx); cx <= cell(maxx); cx++)
for (let cz = cell(minz); cz <= cell(maxz); cz++) addEdgeCell(e.id, cx, cz);
}
}
return { chunkSize, chunks };
}