PROCITY/web/js/world/ground.js
m3ultra 47db478a51 Lane B R37 (v8 wave 0 §0.1 + §0.4): THE KERB IS FOOTPATH — +0 draws, tris DOWN; the chunk seam is subscribable at last
§0.1 — ground.js adopts Lane A's corridor law. The road quad narrows from the whole
corridor to the carriageway and the EXISTING merged footGeos class extends inward to
meet it, consuming `vergeBand(e)[0]` (not `roadWidth/2` — per A's mid-round correction,
so the geometry and A's corrected gate read the same number).

MEASURED, fresh boots, port-isolated no-store servers (:8178 = HEAD control, :8177 =
treatment), 2560x1440:
  synthetic     ground 5 draws / 224 tris -> 5 draws / 216 tris
                walked spine, 102 steps @ 8 m: worst 276 draws BOTH; sum of draws
                20,081 BOTH (identical at every step); 8 bookmarks identical draws
  katoomba_real ground 4 draws / 9,932 tris -> 4 draws / 9,644 tris
                walk 120 steps: worst 100 draws BOTH; sum 7,703 BOTH
+0 draws is proven, not asserted. Tris go DOWN (-8 / -288): the charter's "~2k tris" was
an estimate against a class that already existed. Honest number, stated loudly.

Surfaces verified by RAYCAST, not by reading the code (offset from centreline, topmost hit):
  main@28  road 0-4.75 | kerb 5 | footpath 5.25-17.5     (band [5,14])
  main@24  road 0-5    | footpath 5.25-15.25             (band [5,12], real)
  side@12  road 0-3    | footpath 3.25-9.5               (band [3,6] — 72% of the corpus)
  lane@8   road 0-4    | footpath 4.25-9   DEGENERATE band [4,4] => pre-ruling geometry
  lane@4   road 0-1.75 | kerb 2 | footpath 2.25-5.25     (synthetic, also unchanged)
  arcade   footpath 0-6.5, NO road quad, NO kerbs        (1,102 corpus edges)
  RESIDENTIAL side@14  road 0-2.75 | kerb 3 | GRASS 3.25-6.75 | footpath 7-10.5
The lane case branches on `outer <= inner`, never on a band literal.

Posters: 14 of 14 stood inside painted bitumen before; 0 of 14 after. Not one poster moved
— the ground did.

ONE DEFECT FOUND AND FIXED IN THE ROUND. The first cut painted every intersection shut:
a 12.5 m footpath crosses the perpendicular CARRIAGEWAY where a 3.5 m one only ever
clipped a far corner. Screenshotted, rejected, and fixed by putting the carriageway on
top (ROAD_Y 0 -> 0.04 under the flag): roads run through junctions, footpaths stop at the
kerb they meet. Verified on the real graph's worst 4.3-degree junction.

?verge=0 ships permanently — the gate's falsifiability control. Proven EXACT, not merely
similar: ground vertex+uv hashes identical to the HEAD build (c4c72181 / c111d968 /
1a80ebcd / 12c1241b / fd0d3211), and the same detector goes RED on the default boot.

CLASSIC IS FENCED, NOT AMENDED (the R31 dig-flip pattern). ?classic=1 forces verge off, so
the covenanted town's render does not move on a render-side ruling. Proven twice: identical
ground geometry hashes AND identical ground-only rendered pixels at three poses
(79df268c / 91564001 / a09d1e52), while the default boot differs at all three. If Fable
wants classic to take the footpath too, that is an amendment ruling and a one-token change.

§0.4 — the chunk lifecycle seam. The producer has fired since R3 and Lane D's consumer has
existed since R3, but nothing could connect them: createChunkManager never handed `ctx`
back, so a consumer holding only PROCITY.chunks had nowhere to attach (measured on HEAD:
`!!chunks.ctx === false`). Now `chunks.onChunkBuilt(fn) -> unsubscribe()` for many
consumers plus `chunks.ctx` for the original single slot. Proven live: 136 build / 144
dispose events over a 40-step walk; payload {cx,cz,buildings,furniture,data} with
key === `${cx},${cz}`; a second consumer does not clobber the first; the ctx slot fires;
unsubscribe silences; a THROWING subscriber does not take the streamer down.
ZERO behaviour change when nobody subscribes: a 102-step walk recording
[draws, tris, chunks] per step hashes ceb1ec66 on BOTH builds.

Files: web/js/world/ground.js, web/js/world/chunks.js, docs/shots/laneB_r37_kerb/*,
docs/LANES/LANE_B_NOTES.md, B-progress.md. sim.js untouched (Lane D's).
2026-08-03 16:40:43 +10:00

192 lines
11 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 — 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';
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);
}
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();
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);
};
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, verge: VERGE };
}