// 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 N–S. // // 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; // Canonical CLOSING-TIME LAW (frozen v2 ruling; Lane D/F consume this — the "debt" made code). // Openness is HALF-OPEN: a shop is open for `open ≤ hour < close`, so a shop closing at 17 is shut // AT 17:00. New entry is refused when closed; occupants already inside finish their dwell and drain // (never popped); the player is never force-ejected. `hour` is a 0–24 float (Lane F's currentHour()). // Pass a shop (or any { hours:[open,close] }). No hours ⇒ treated as always open. export function isOpen(shopOrHours, hour) { const h = Array.isArray(shopOrHours) ? shopOrHours : (shopOrHours && shopOrHours.hours); if (!h) return true; return hour >= h[0] && hour < h[1]; } // 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; // ROUND27 THE FACADE FIX: aim the VISIBLE facade (local +Z — B's canon, what buildings.js draws) back // down the outward normal, at the street. The old `atan2(nx, nz)` put +Z along the OUTWARD normal, i.e. // facing away — this line's own comment has described the intended result since v1; only the sign was // wrong. It read correct because CITY_SPEC:71 wrote the SIM convention (−Z) into the lots contract. const ry = Math.atan2(-nx, -nz); // facade (+Z) 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 E–W 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). ROUND27 THE FACADE FIX: ry now // aims the VISIBLE facade (local +Z, B's canon) at the street, so a west-of-street lot wants +Z = +x ⇒ // ry = atan2(+1, 0) (= +π/2). It was atan2(−1, 0), which pointed +Z at −x — away from the spine, into // the market's back. Same one-sign error as marchStrip above, and the old comment described the // intended result ("the facade then faces −(outward) = +x") that the code did not produce. // 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 2–4 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 }; }