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