// 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'; import { vergeBand } from '../core/registry.js'; import { townCharacter } from './character.js'; // [R38 §1.2] the per-town ground palette const FOOT = 3.5; // footpath width OUTBOARD of the corridor (unchanged since v1) const TILE = 5; // metres per texture tile const OVERLAP = 1.5; // road segments overrun their nodes so intersections fill // ── THE KERB IS FOOTPATH (v8 ruling 1, ROUND37 §0.1) ─────────────────────────────── // `edge.width` is the whole CORRIDOR — carriageway + verge — and this file painted all of it as // bitumen from v1 to v7, which put the band registry.js calls "verandah'd footpath" (and that // buildings.js already builds an awning over) under road texture: 14 gig posters, every bench and // every ped lane stood in the road on the boot every player gets. Lane A's published corridor law // (core/registry.js) is now the single source of truth for where the bitumen stops: the road quad // narrows to the carriageway and the EXISTING merged `footGeos` class extends inward to meet it. // Same merged meshes, same quad count ⇒ measured +0 draws (identical at all 102 steps of the walked // spine) and tris that go DOWN, not up: −8 on the synthetic town, −288 on katoomba_real, because the // only quads this adds are the ones the arcade case removes. The charter's "~2 k tris" was an // estimate against a class that already existed; the honest number is a re-parameterisation. // // We consume `vergeBand(e)[0]`, NOT `roadWidth(e)/2`. Both `roadWidth` and `edge.width` are full // widths — one of the carriageway, one of the corridor — and A names that collision as the reason // this bug existed at all; `vergeBand`'s inner bound is the number A's own corrected gate reads, so // the gate and the geometry cannot drift apart (Lane A, R37, verified over 31,039 corpus edges). // The band is NOT one number: 9 m is synthetic-only (main @28 m). A real main is 24 m ⇒ 7 m, and the // corpus's dominant edge is `side` @12 m — 22,415 of 31,039 edges (72%) — ⇒ 3 m. Never branch on a // literal band; branch on the degenerate test (`outer <= inner`), which is exactly the class of bug // this whole item exists to retire. // // `?verge=0` reverts to the pre-ruling geometry, permanently — it is Lane F's falsifiability // control for the poster-clearance gate (the pre-change failure is known and counted at exactly 14). // `?classic=1` forces it off (the R31 dig-flip pattern): the covenanted town's render does not move // on a render-side ruling without an amendment ruling from Fable. const VERGE = (() => { try { const p = new URLSearchParams(location.search); const classic = p.has('classic') && p.get('classic') !== '0'; return !classic && p.get('verge') !== '0'; } catch (e) { return false; } // unparseable search ⇒ pre-ruling geometry, never a throw })(); // Edges that keep a GRASS VERGE instead of pavement: the nature strip is period-correct in front of // houses and nowhere else. An edge is residential if a house/yard lot fronts it, or if its fronting // lots sit in a `residential` district. Real towns are 100% shop/mainstreet today ⇒ empty set ⇒ they // pave, which is the intent. function residentialEdges(plan) { const set = new Set(); const districtOfBlock = new Map((plan.blocks || []).map((b) => [b.id, b.district])); const kindOfDistrict = new Map((plan.districts || []).map((d) => [d.id, d.kind])); for (const l of plan.lots || []) { if (l.frontEdge == null) continue; if (l.use === 'house' || l.use === 'yard' || kindOfDistrict.get(districtOfBlock.get(l.block)) === 'residential') { set.add(l.frontEdge); } } return set; } // 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); } // [R38 §1.2 — THE PER-TOWN CHARACTER VECTOR] `townKey` is the ?town= key the shell resolved; the // plan deliberately does NOT carry it (plan_osm.js:515 — "town key stays OFF the plan (keeps the // Melbourne golden frozen)"), so it arrives as an option. `townCharacter(null)` returns the frozen // v1 literals, which is what the synthetic default and ?classic=1 both get — so this whole item is // +0 draws / +0 tris AND byte-identical on the covenanted boot BY CONSTRUCTION, with no flag test // anywhere in this file. `add()` merges each surface class into ONE town-wide mesh regardless of // town size (measured: exactly 5 meshes, synthetic AND katoomba_real), so swapping which skin/tint // that one shared material carries cannot cost a draw at any N. export function buildGround(plan, skins, { townKey = null } = {}) { const group = new THREE.Group(); group.name = 'ground'; const edges = resolveEdges(plan); const character = townCharacter(townKey); const roadGeos = [], footGeos = [], kerbGeos = []; const dir = new THREE.Vector3(), perp = new THREE.Vector3(); const grassy = VERGE ? residentialEdges(plan) : null; // Surface heights are a painter's order, not elevations — the whole stack is 9 cm thick and every // class is a flat quad, so what these numbers decide is who wins where two strips CROSS. // Pre-ruling the footpath (0.02) sat above the road (0.0) and that was invisible: a 3.5 m path // outboard of the corridor only ever crossed the far corner of a junction. At 12.5 m it crosses // the whole of the perpendicular CARRIAGEWAY, and the first cut of this change painted every // intersection shut — four roads arriving at a pedestrian plaza, screenshotted and rejected. // So under the ruling the carriageway goes on top: roads run through junctions, footpaths stop at // the kerb they meet, which is what an intersection actually looks like. Road-on-road overlap in // the junction box is unchanged and still invisible (one skin, one y, uniform asphalt — R20). const ROAD_Y = VERGE ? 0.04 : 0.0; 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; const half = e.width / 2; // corridor half-width (the v1 kerb line) // A's law, as A publishes it: [kerb, corridor edge] measured from the centreline. const [bandInner, bandOuter] = vergeBand(e); // Where the kerb goes. `min(…, half)` is the inversion guard: no edge in the 31,039-edge corpus // has a carriageway wider than its corridor, but a `side` under 6 m would, and an inverted band // would flip a footpath quad inside out. A DEGENERATE band (`bandOuter <= bandInner` — all // carriageway, no verge: 56 real `lane` edges, 2 synthetic ones, and NOT the [2,2] literal the // brief carried) then lands on `bandInner === half`, i.e. the pre-ruling geometry, by arithmetic // rather than by a special case. const kerbHalf = VERGE ? Math.min(bandInner, half) : half; // ARCADE (1,102 corpus edges): a covered pedestrian lane has no carriageway at all, so a road // quad here is zero-area and the two kerbs land on top of each other at the centreline. Emit // neither, and pave the whole corridor as ONE footpath quad (no centreline seam). if (VERGE && kerbHalf <= 0) { footGeos.push(hQuad(cx, cz, dir, perp, roadLen, e.width + FOOT * 2, 0.02)); continue; } roadGeos.push(hQuad(cx, cz, dir, perp, roadLen, kerbHalf * 2, ROAD_Y)); // Footpaths flank the road: from the kerb line out to the corridor edge + FOOT. Two bands keep // their v1 outboard-only pavement — a DEGENERATE band (no verge exists to pave: the laneway // case, tested, never assumed from a width) and a RESIDENTIAL edge, where the verge stays grass // because a nature strip is what is period-correct in front of a house. const inner = (bandOuter <= bandInner || (grassy && grassy.has(e.id))) ? half : kerbHalf; const fw = half + FOOT - inner; const off = inner + fw / 2; footGeos.push(hQuad(cx + perp.x * off, cz + perp.z * off, dir, perp, roadLen, fw, 0.02)); footGeos.push(hQuad(cx - perp.x * off, cz - perp.z * off, dir, perp, roadLen, fw, 0.02)); // low kerbs between road and footpath const koff = kerbHalf + 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); }; // [R38 §1.2] Five classes, five town-wide meshes, five shared materials — exactly as before. The // only thing the character vector changes is WHICH skin name and WHICH tint each of those five // asks for. An unmatched town key returns the same literals a null key does (and says so in // `character.matched`), so a new town cache appearing in E's index before this table knows about // it degrades to today's palette instead of to a default branch nobody printed. const g = character.ground, tint = character.tint; add([baseGeo], skins.groundMat(g.base, tint.base)); add(roadGeos, skins.groundMat(g.road, tint.road)); add(footGeos, skins.groundMat(g.foot, tint.foot)); add(plazaGeos, skins.groundMat(g.plaza, tint.plaza)); const kerbMat = new THREE.MeshStandardMaterial({ color: character.kerb, roughness: 0.9 }); add(kerbGeos, kerbMat); function dispose() { for (const m of meshes) m.geometry.dispose(); kerbMat.dispose(); group.clear(); } return { group, dispose, verge: VERGE, character, get meshCount() { return meshes.length; } }; }