PROCITY/web/js/world/planutil.js
m3ultra 5313402e43 Lane B (Streetscape): chunk-streamed walkable town shell
First-person shell: chunk streaming, instanced buildings + facade texture atlas
(22 facade materials -> 1; 5 skin materials city-wide), signs, awnings, furniture,
day/night lighting, minimap, pixel-accurate collision (incl. arbitrary-angle lots),
door raycast -> procity:enterShop, DBG harness, all-fallback mode. Renders Lane A's
full generated town (seed 20261990 'Boolarra Heads', 493 shops); worst continuous-
walk view ~261 draws (<=300 gate). 6 adversarial-review bugs fixed (collision sign,
house doors, awning skin, sign atlas, furniture seed hash, shot-mode restore).

index.html and skins.js include Lane F's inline integration seam edits (marked
'[Lane F integration]'): interior/keeper/citizen wiring + facade filename fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 14:28:47 +10:00

117 lines
5.1 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 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<chunkKey, { lots:[lot], shops:[shop], edges:[edgeId] }>
* 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;
}