§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).
192 lines
11 KiB
JavaScript
192 lines
11 KiB
JavaScript
// 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 };
|
||
}
|