diff --git a/docs/shots/laneB_r37_kerb/kerb_after_junction.jpg b/docs/shots/laneB_r37_kerb/kerb_after_junction.jpg new file mode 100644 index 0000000..713f0d7 Binary files /dev/null and b/docs/shots/laneB_r37_kerb/kerb_after_junction.jpg differ diff --git a/docs/shots/laneB_r37_kerb/kerb_after_katoomba_acute_4deg.jpg b/docs/shots/laneB_r37_kerb/kerb_after_katoomba_acute_4deg.jpg new file mode 100644 index 0000000..a2a8d5b Binary files /dev/null and b/docs/shots/laneB_r37_kerb/kerb_after_katoomba_acute_4deg.jpg differ diff --git a/docs/shots/laneB_r37_kerb/kerb_after_poster.jpg b/docs/shots/laneB_r37_kerb/kerb_after_poster.jpg new file mode 100644 index 0000000..3883b71 Binary files /dev/null and b/docs/shots/laneB_r37_kerb/kerb_after_poster.jpg differ diff --git a/docs/shots/laneB_r37_kerb/kerb_after_streetlevel.jpg b/docs/shots/laneB_r37_kerb/kerb_after_streetlevel.jpg new file mode 100644 index 0000000..2927539 Binary files /dev/null and b/docs/shots/laneB_r37_kerb/kerb_after_streetlevel.jpg differ diff --git a/docs/shots/laneB_r37_kerb/kerb_before_junction.jpg b/docs/shots/laneB_r37_kerb/kerb_before_junction.jpg new file mode 100644 index 0000000..1f2cc15 Binary files /dev/null and b/docs/shots/laneB_r37_kerb/kerb_before_junction.jpg differ diff --git a/docs/shots/laneB_r37_kerb/kerb_before_poster.jpg b/docs/shots/laneB_r37_kerb/kerb_before_poster.jpg new file mode 100644 index 0000000..89452a2 Binary files /dev/null and b/docs/shots/laneB_r37_kerb/kerb_before_poster.jpg differ diff --git a/web/js/world/chunks.js b/web/js/world/chunks.js index a490d08..c1ae6c0 100644 --- a/web/js/world/chunks.js +++ b/web/js/world/chunks.js @@ -25,6 +25,34 @@ export function createChunkManager(plan, scene, ctx) { const _colliders = []; // scratch, refilled each getColliders() + // ── the per-chunk lifecycle seam (ROUND37 §0.4) ────────────────────────────────────────────── + // The producer below has fired `ctx.onChunkBuilt` / `ctx.onChunkDisposed` since R3 and Lane D's + // sim has carried the matching `onChunkBuilt(key)` / `onChunkDisposed(key)` methods just as long — + // but nothing could ever connect them: `ctx` is the shell's local object and the manager never + // handed it back, so a consumer holding only the manager (which is all `PROCITY.chunks` is) had + // nowhere to attach. Two ways in now, both inert until someone uses them: + // • `chunks.onChunkBuilt(fn)` → returns an unsubscribe. MANY consumers, none clobbering another + // (the single `ctx` slot is last-writer-wins, and the v8 slate has several per-chunk systems). + // • `chunks.ctx.onChunkBuilt = fn` — the original single slot, still first to fire, and now + // reachable exactly as LANE_D_NOTES §35 documents it. + // Zero cost when nobody listens: the detail object is only allocated inside the guard, so a + // subscriber-free boot does the same two truthiness tests it has done since R3 and nothing else. + const builtSubs = [], disposedSubs = []; + const subscribe = (list, fn) => { + if (typeof fn !== 'function') return () => {}; + list.push(fn); + return () => { const i = list.indexOf(fn); if (i >= 0) list.splice(i, 1); }; + }; + // A throwing subscriber must never take the streamer down with it — a half-built chunk leaks its + // groups and the player walks into a void. Report and carry on. + const fire = (fn, key, detail, what) => { + try { fn(key, detail); } catch (err) { console.warn(`[procity] ${what} subscriber threw for ${key}`, err); } + }; + const emit = (slot, list, key, detail, what) => { + if (slot) fire(slot, key, detail, what); + for (let i = 0; i < list.length; i++) fire(list[i], key, detail, what); + }; + function buildChunk(key) { if (live.has(key)) return; const data = index.get(key); @@ -41,14 +69,19 @@ export function createChunkManager(plan, scene, ctx) { ? buildings.colliders.concat(furniture.colliders) : buildings.colliders; live.set(key, { buildings, furniture, colliders, cx, cz }); // optional lifecycle hook (Lane D/F per-chunk spawning, ambient, LOD — LANE_F_NOTES §8). - // Inert unless a consumer sets ctx.onChunkBuilt; citizens currently drive off plan.streets instead. - if (ctx.onChunkBuilt) ctx.onChunkBuilt(key, { cx, cz, buildings: buildings.group, furniture: furniture.group, data }); + if (ctx.onChunkBuilt || builtSubs.length) { + emit(ctx.onChunkBuilt, builtSubs, key, + { cx, cz, buildings: buildings.group, furniture: furniture.group, data }, 'onChunkBuilt'); + } } function disposeChunk(key) { const b = live.get(key); if (!b) return; - if (ctx.onChunkDisposed) ctx.onChunkDisposed(key, { cx: b.cx, cz: b.cz }); // before the groups are freed + // fires BEFORE the groups are freed, so a consumer can still read what it is losing + if (ctx.onChunkDisposed || disposedSubs.length) { + emit(ctx.onChunkDisposed, disposedSubs, key, { cx: b.cx, cz: b.cz }, 'onChunkDisposed'); + } scene.remove(b.buildings.group, b.furniture.group); if (b.buildings.doorMesh) { const i = doorMeshes.indexOf(b.buildings.doorMesh); @@ -128,6 +161,13 @@ export function createChunkManager(plan, scene, ctx) { return { update, warmup, getColliders, setNight, dispose, + // §0.4 — the lifecycle seam, subscribable. `onChunkBuilt(fn)` → unsubscribe(); `ctx` is handed + // back so the original single-slot form works too. Keys are planutil chunk keys ("cx,cz"), + // which sim.js derives identically (sim.js:52). + onChunkBuilt: (fn) => subscribe(builtSubs, fn), + onChunkDisposed: (fn) => subscribe(disposedSubs, fn), + get subscriberCount() { return builtSubs.length + disposedSubs.length; }, + ctx, getDoorMeshes: () => doorMeshes, get count() { return live.size; }, get pending() { return queue.length; }, diff --git a/web/js/world/ground.js b/web/js/world/ground.js index fee7daf..8090b9b 100644 --- a/web/js/world/ground.js +++ b/web/js/world/ground.js @@ -8,11 +8,62 @@ 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 +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) { @@ -46,6 +97,18 @@ export function buildGround(plan, skins) { 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; @@ -53,13 +116,37 @@ export function buildGround(plan, skins) { 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; - roadGeos.push(hQuad(cx, cz, dir, perp, roadLen, e.width, 0.0)); - // footpaths flank the road - const off = e.width / 2 + FOOT / 2; - footGeos.push(hQuad(cx + perp.x * off, cz + perp.z * off, dir, perp, roadLen, FOOT, 0.02)); - footGeos.push(hQuad(cx - perp.x * off, cz - perp.z * off, dir, perp, roadLen, FOOT, 0.02)); + 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 = e.width / 2 + 0.12; + 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)); } @@ -100,5 +187,5 @@ export function buildGround(plan, skins) { kerbMat.dispose(); group.clear(); } - return { group, dispose }; + return { group, dispose, verge: VERGE }; }