PROCITY/web/js/world/ground.js
m3ultra 4514cb94b5 Lane B R38 (v8 WAVE 1, INHABITATION): all four runtime items — the whole round costs +1 walked draw
The town is inhabited: 23 towns finally differ, 181 back yards have a Hills Hoist with washing on it,
a magpie owns one street for six weeks of the year, and six point sources put a life off-screen.

MEASURED, walked, not bookmarked — two port-isolated no-store servers (:8472 = `git archive HEAD`,
:8471 = treatment, assets symlinked so E's mid-round wave is identical on both sides), 102 8-metre
spine steps at yaw 0, each frame fully streamed, through the shell's own composer:

  worst draws  224 -> 225   (+1)
  worst tris   89,502 -> 93,298   (+3,796 of ~75k spare)
  sum draws over 102 frames  16,676 -> 16,663   (-13)

The citizen sim and the tram are hidden identically on both sides, and that IS the measurement: with
them live the same step read control 346 vs treatment 232, because peds spawn on wall-clock time.
`?yards=0` reproduces HEAD exactly (224 / 89,502 / 16,676). Isolated at a fixed pose: washing +1 draw
/ +512 tris, magpie +1 draw / +182 tris, every yard box prop +0 draws.

§1.2 THE CHARACTER VECTOR — +0 draws, +0 tris, classic byte-identical BY CONSTRUCTION.
`townCharacter(null)` returns the frozen v1 literals and a null tint keeps the SAME material cache key,
so classic gets the same object, not a copy of it: default-boot ground hashes identical to HEAD
mesh-for-mesh, `?classic=1` hashes unmoved from R37. There is no `if (classic)` in the ground path.
The sky feeds the BASE skyName, never `setSky` (a one-way latch that would have killed R32's dawn dome
on all 23 towns). The vacuous arm is per-town: `newtown_godverse` / `redhill_godverse` carry `state: ''`,
so a per-corpus arm would have dropped exactly those two silently. 21 distinct palettes across 23 towns.
`gravel` and `reddust` were not unwired but UNWIREABLE — their `use` values named no slot ground.js had;
making the slot addressable fixed it at the root, and both are selected now. E's three new grounds
landed mid-round and the upgrade was a name change in one table, which is what procedural-first is for.

§1.4 BACKYARD AUSTRALIA — inside boxMats, +0 draws. Houses inset off their boundaries so there is a
yard; 36 `yard` lots that had rendered as blank shopfront sheds since v1 are now fenced blocks with a
hoist, a shed and often a tank. Collision byte-identical (the lot rect is where the fence stands).

THE WASHING SHIPS, and I wrote the inverted weight rather than dropping it. The shipped top-weighted
mode moves a hem 0.10 cm calm / 0.67 cm gusty; `pegged` gives 4.5 cm / 31 cm — ~52x, and at the hem
instead of the pegs. The `canopy` string is byte-for-byte unchanged, so the gum-tree covenant argument
is untouched. It is ONE TOWN-WIDE InstancedMesh (1 draw at any N, cap 256, proximity-first off the R37
chunk seam) rather than per-chunk, which would have been +1 x 31 live chunks = 4x the whole margin.
Zero on every real town (no hoists ⇒ no mesh) and zero at night.

§1.3 THE MAGPIE — +1 draw on screen, exactly 0 otherwise; 182 tris of 900. Territory scales with the
road graph: 6 on the 6.78 km synthetic town, 157 on bendigo's 188.6 km / 2,593 edges, nearest 120 m
from spawn. The latch sets, the day roll clears it, localStorage gains no key and the save never
mentions it. `swoopPos()` is exported and pure. The hat stays cut.

MY OWN SEASON MECHANISM WAS VACUOUS AND THE SWEEP CAUGHT IT: keyed on citySeed alone it produced a
CONSTANT, because the town key is deliberately off the plan — 24 of 24 towns were 214 days from
swooping season, and it would have gated green because `?magpie=1` forces it. Re-keyed on
(citySeed, townKey): `newtown_real` is in season on arrival on the default seed, marrickville is 8 days
out, and 12.9% of 1,000 seeds put the synthetic default in season against a design ratio of 11.5%.

§1.1 THE AUDIO LAYER — six new mixer layers, zero draws, zero tris, seeded anchors, distance falloff,
AND A StereoPannerNode PER LAYER, so direction is real (measured spread -0.71..+0.85) instead of a
limitation written into the notes. Day-of-week derived locally. All six anchored on the default boot;
3 of 6 report `no-anchor` on real towns rather than being faked onto shopfronts. The falsifiability
control — one manifest key renamed away, served from a third port — makes that source report
`no-entry`, 0 plays, never `audible`, with the other five unchanged and zero console errors.

TWO DEFECTS MY OWN GREEN NUMBERS COULD NOT SEE, both caught by a screenshot: the washing shipped at 256
instances / +1 draw / cap respected and was INVISIBLE behind its own back fence (per-instance peg height,
and a fence taller than the hoist), and the magpie perched on THIN AIR over the road. R37's lesson
relearned at full price. Fence 1.60 / hoist 2.15 measured against the object; territories now snap to
furniture.js's own streetlight rule.

LANE E'S PROPS, ADJUDICATED: hoist + aerial permanently primitive (E's spike killed them, and primitive
is also the +0-draw mechanism). `paling_fence` at 1,121 tris x 905 runs = 1.01M triangles — no. The
`magpie` GLB is HELD FOR THE TINT: it reads as a crow, and a magpie seen for 1.2 s in peripheral vision
IS its white bar. My 182-tri bird already carries it, at a fifth of the triangles and no fetch.
furniture.js's "novelty_record is 26.5k tris" was stale by six epochs — corrected to 8,000.

Zero fetch delta: `?noassets=1` fetches the same 23 assets, same list, on HEAD and on R38.
0 console errors on 10 boots (default, classic, yards=0, washing=0&magpie=0&ambient=0, noassets,
magpie=1, darwin, hobart, bendigo, the patched-manifest control) beyond the known PointerLock ones.

Shots: docs/shots/laneB_r38/. Detail + every number: LANE_B_NOTES §38.
2026-08-03 18:53:26 +10:00

207 lines
12 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';
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; } };
}