// PROCITY Lane B — ground.js // Roads, footpaths, kerbs, the market plaza, and a base ground plane, built ONCE as merged strips // per material (not per-chunk streamed). Rationale: the street graph is a handful of edges; merged // flat quads are a few draw calls total and — unlike per-chunk road clipping — can never seam. // UV tiling is baked into the geometry so one shared ground material serves strips of any length. // (If Lane A ships a many-hundred-edge city, Lane F revisits streaming the ground.) import * as THREE from 'three'; import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js'; import { resolveEdges } from './planutil.js'; const FOOT = 3.5; // footpath width const TILE = 5; // metres per texture tile const OVERLAP = 1.5; // road segments overrun their nodes so intersections fill // Horizontal quad in XZ centred at (cx,cz), aligned to unit dir/perp, size len×wid, at height y. // UVs baked to tile every TILE metres. function hQuad(cx, cz, dir, perp, len, wid, y) { const hl = len / 2, hw = wid / 2; const ax = dir.x * hl, az = dir.z * hl; // along const bx = perp.x * hw, bz = perp.z * hw; // across const P = (sa, sb) => [cx + sa * ax + sb * bx, y, cz + sa * az + sb * bz]; const c0 = P(-1, -1), c1 = P(1, -1), c2 = P(1, 1), c3 = P(-1, 1); const repU = Math.max(1, len / TILE), repV = Math.max(1, wid / TILE); const g = new THREE.BufferGeometry(); // winding c0,c2,c1 / c0,c3,c2 so the geometric normal points +Y (up) — FrontSide stays visible. g.setAttribute('position', new THREE.BufferAttribute(new Float32Array([ ...c0, ...c2, ...c1, ...c0, ...c3, ...c2]), 3)); g.setAttribute('uv', new THREE.BufferAttribute(new Float32Array([ 0, 0, repU, repV, repU, 0, 0, 0, 0, repV, repU, repV]), 2)); g.setAttribute('normal', new THREE.BufferAttribute(new Float32Array([ 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0]), 3)); return g; } // Axis-aligned rectangle helper (plaza, base plane). function rectGeo(cx, cz, w, d, y, tile = TILE) { return hQuad(cx, cz, new THREE.Vector3(1, 0, 0), new THREE.Vector3(0, 0, 1), w, d, y); } export function buildGround(plan, skins) { const group = new THREE.Group(); group.name = 'ground'; const edges = resolveEdges(plan); const roadGeos = [], footGeos = [], kerbGeos = []; const dir = new THREE.Vector3(), perp = new THREE.Vector3(); for (const e of edges) { const dx = e.bx - e.ax, dz = e.bz - e.az; const len = Math.hypot(dx, dz) || 1; dir.set(dx / len, 0, dz / len); perp.set(-dir.z, 0, dir.x); const cx = (e.ax + e.bx) / 2, cz = (e.az + e.bz) / 2; const roadLen = len + OVERLAP * 2; roadGeos.push(hQuad(cx, cz, dir, perp, roadLen, e.width, 0.0)); // footpaths flank the road const off = e.width / 2 + FOOT / 2; footGeos.push(hQuad(cx + perp.x * off, cz + perp.z * off, dir, perp, roadLen, FOOT, 0.02)); footGeos.push(hQuad(cx - perp.x * off, cz - perp.z * off, dir, perp, roadLen, FOOT, 0.02)); // low kerbs between road and footpath const koff = e.width / 2 + 0.12; kerbGeos.push(hQuad(cx + perp.x * koff, cz + perp.z * koff, dir, perp, roadLen, 0.24, 0.06)); kerbGeos.push(hQuad(cx - perp.x * koff, cz - perp.z * koff, dir, perp, roadLen, 0.24, 0.06)); } // market plaza (brickpave) over the central square const sq = (plan.districts || []).find((d) => d.kind === 'market'); const plazaGeos = []; if (sq) plazaGeos.push(rectGeo(0, 0, 46, 46, 0.015)); // base ground plane under the whole town (grass/dirt), sized to the plan extent let minX = -60, maxX = 60, minZ = -60, maxZ = 60; for (const n of plan.streets.nodes) { minX = Math.min(minX, n.x); maxX = Math.max(maxX, n.x); minZ = Math.min(minZ, n.z); maxZ = Math.max(maxZ, n.z); } for (const l of plan.lots) { minX = Math.min(minX, l.x); maxX = Math.max(maxX, l.x); minZ = Math.min(minZ, l.z); maxZ = Math.max(maxZ, l.z); } const bw = (maxX - minX) + 120, bd = (maxZ - minZ) + 120; const bcx = (minX + maxX) / 2, bcz = (minZ + maxZ) / 2; const baseGeo = rectGeo(bcx, bcz, bw, bd, -0.03); const meshes = []; const add = (geos, material, receive = true) => { if (!geos.length) return; const merged = mergeGeometries(geos, false); geos.forEach((g) => g.dispose()); const m = new THREE.Mesh(merged, material); m.receiveShadow = receive; m.castShadow = false; group.add(m); meshes.push(m); }; const roadSkin = 'djsim-road', footSkin = 'djsim-footpath', grassSkin = 'grass', plazaSkin = 'brickpave'; add([baseGeo], skins.groundMat(grassSkin)); add(roadGeos, skins.groundMat(roadSkin)); add(footGeos, skins.groundMat(footSkin)); add(plazaGeos, skins.groundMat(plazaSkin)); const kerbMat = new THREE.MeshStandardMaterial({ color: 0x9a948c, roughness: 0.9 }); add(kerbGeos, kerbMat); function dispose() { for (const m of meshes) m.geometry.dispose(); kerbMat.dispose(); group.clear(); } return { group, dispose }; }