// PROCITY Lane B — planutil.js // Pure plan/geometry helpers over a CityPlan (no THREE). Chunk math (CITY_SPEC: 64m cells), // node/lot/shop indices, edge resolution, and the oriented-rectangle collider for a lot. // If Lane A later ships an official chunkIndex(plan) we swap the import; the shapes match. export const CHUNK = 64; // metres (CITY_SPEC law) export const chunkCoord = (v) => Math.floor(v / CHUNK); export const chunkKey = (cx, cz) => `${cx},${cz}`; export const chunkOf = (x, z) => ({ cx: chunkCoord(x), cz: chunkCoord(z) }); export const parseKey = (k) => { const [cx, cz] = k.split(',').map(Number); return { cx, cz }; }; // Fast lookup maps built once per plan. export function indexPlan(plan) { const nodes = new Map(plan.streets.nodes.map((n) => [n.id, n])); const lots = new Map(plan.lots.map((l) => [l.id, l])); const shopByLot = new Map(plan.shops.map((s) => [s.lot, s])); const shopsById = new Map(plan.shops.map((s) => [s.id, s])); return { nodes, lots, shopByLot, shopsById }; } // Resolve street edges to world segments with widths. export function resolveEdges(plan) { const nodes = new Map(plan.streets.nodes.map((n) => [n.id, n])); return plan.streets.edges.map((e) => { const a = nodes.get(e.a), b = nodes.get(e.b); return { id: e.id, kind: e.kind, width: e.width, ax: a.x, az: a.z, bx: b.x, bz: b.z }; }); } /** * chunkIndex(plan) → Map * A lot belongs to the chunk containing its centre. An edge is registered to every chunk its * segment passes through (rasterised at CHUNK/4 steps) so the streamer can draw partial roads * if it ever needs to. Shops ride along with their lot. */ export function chunkIndex(plan) { const { shopByLot } = indexPlan(plan); const map = new Map(); const cell = (cx, cz) => { const k = chunkKey(cx, cz); let c = map.get(k); if (!c) { c = { lots: [], shops: [], edges: [] }; map.set(k, c); } return c; }; for (const lot of plan.lots) { const { cx, cz } = chunkOf(lot.x, lot.z); const c = cell(cx, cz); c.lots.push(lot); const s = shopByLot.get(lot.id); if (s) c.shops.push(s); } const nodes = new Map(plan.streets.nodes.map((n) => [n.id, n])); for (const e of plan.streets.edges) { const a = nodes.get(e.a), b = nodes.get(e.b); const dx = b.x - a.x, dz = b.z - a.z; const len = Math.hypot(dx, dz) || 1; const ux = dx / len, uz = dz / len, px = -uz, pz = ux; // along + perpendicular units // Register every chunk the road BAND touches — not just the centreline — so furniture placed // at a perpendicular offset (footpath/verge) can never fall in an unbuilt chunk and vanish. const band = e.width / 2 + 10; const along = Math.max(1, Math.ceil(len / (CHUNK / 4))); const across = Math.max(1, Math.ceil((band * 2) / (CHUNK / 4))); const seen = new Set(); for (let i = 0; i <= along; i++) { const sx = a.x + dx * (i / along), sz = a.z + dz * (i / along); for (let j = 0; j <= across; j++) { const o = -band + (2 * band) * (j / across); const { cx, cz } = chunkOf(sx + px * o, sz + pz * o); const k = chunkKey(cx, cz); if (!seen.has(k)) { seen.add(k); cell(cx, cz).edges.push(e.id); } } } } return map; } // Oriented-rectangle collider for a lot (local X = frontage w, local Z = depth d, rotated ry). // Yards/houses get a fatter keep-out so you can't walk through private front gardens. export function lotCollider(lot) { const yard = lot.use === 'house' || lot.use === 'yard'; const pad = yard ? 2.5 : 0; // private-yard buffer in front of houses return { x: lot.x, z: lot.z, ry: lot.ry || 0, hw: lot.w / 2, hd: lot.d / 2 + pad / 2, use: lot.use, }; } // Test/point-push a circle of radius r at (px,pz) out of an oriented rect collider. // Returns [nx, nz] corrected position (or the same point if no overlap). Reused every frame — // pass scratch out-array to avoid allocation. export function pushOutRect(px, pz, r, col, out) { // Convention must match buildings.toWorld (local→world = RotY(ry)): dx = lx·cos + lz·sin, // dz = −lx·sin + lz·cos. So world→local is the inverse (RotY(−ry)) below, and the push-out // back-transform is RotY(ry). (Earlier this used the wrong sign and only happened to work for // axis-aligned ry ∈ {0,±π/2,π}; arbitrary ry from generatePlan needs the correct inverse.) const cos = Math.cos(col.ry), sin = Math.sin(col.ry); const dx = px - col.x, dz = pz - col.z; const lx = dx * cos - dz * sin; // world→local (RotY(−ry)) const lz = dx * sin + dz * cos; const ex = col.hw + r, ez = col.hd + r; if (lx > -ex && lx < ex && lz > -ez && lz < ez) { // overlapping — push along axis of least penetration const penX = ex - Math.abs(lx); const penZ = ez - Math.abs(lz); let nlx = lx, nlz = lz; if (penX < penZ) nlx = lx < 0 ? -ex : ex; else nlz = lz < 0 ? -ez : ez; out[0] = col.x + (nlx * cos + nlz * sin); // local→world (RotY(ry)), inverse of the above out[1] = col.z + (-nlx * sin + nlz * cos); return true; } out[0] = px; out[1] = pz; return false; }