sindicate 0.13.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,161 @@
1
+ // THE PERMANENT WAY's MACHINERY (world splits, step 8) — the generic half of laying
2
+ // track as geometry, moved verbatim from western's railway.js. The GAME keeps the
3
+ // railway itself: which castings, which gauge mates to which, where the line runs,
4
+ // portal art direction, station areas. The engine keeps the laws:
5
+ //
6
+ // · THE PERF LAW: a hundred track pieces as a hundred meshes is not acceptable.
7
+ // Panels batch per file into an InstancedMesh whose instance matrix carries the full
8
+ // transform — position, YAW, PITCH and a Z-SCALE — which is what makes the adaptive
9
+ // panel length free: a 9.6 m panel on a curve and a 10.0 m panel on the straight
10
+ // ride in the same batch.
11
+ // · YOU MUST BE ABLE TO WALK OVER IT: panel batches are named `scatterDeco:` (visual
12
+ // only) — track is 20 cm of rail, not a wall; the formation under it is terrain and
13
+ // the terrain is already in the BVH.
14
+ // · a deck is A FLOOR, NOT A PICTURE OF ONE: the trestle builder hands its deck batch
15
+ // back as an instance group for a side BVH.
16
+ import * as THREE from 'three/webgpu';
17
+ import { loadModel, loadTexture, resolveModelUrl } from '../core/assets.js';
18
+ import { flattenToGeometry } from './scatter.js';
19
+
20
+ // A raw FBX carries the default UV flip; a .glb twin carries glTF-convention UVs
21
+ // (flipY:false). Kits whose castings HAVE twins genuinely need two textures — mixing
22
+ // them is the white-roofs bug, and at a junction it paints the turnout in somebody
23
+ // else's colours.
24
+ export async function railMaterial(texUrl, modelUrl) {
25
+ const resolved = await resolveModelUrl(modelUrl, { texture: texUrl });
26
+ const tex = await loadTexture(texUrl, /\.glb$/i.test(resolved) ? { flipY: false } : {});
27
+ return new THREE.MeshStandardMaterial({ map: tex, roughness: 0.95, metalness: 0 });
28
+ }
29
+
30
+ // THE FOUNDRY MIRRORS THE PATTERN. A casting that throws its diverging road to its own
31
+ // LEFT when the yard is on the RIGHT is reflected in x. A negative scale would do it in
32
+ // one line and get it wrong in three ways (winding reversed → the mesh renders
33
+ // inside-out; normals still pointing the old way → lit from the wrong side; and three's
34
+ // frustum/shadow maths quietly disagreeing about the determinant). So the reflection is
35
+ // baked into the geometry properly: negate x on the positions, negate x on the normals,
36
+ // and reverse the winding to put the faces back the right way out.
37
+ export function mirrorX(src) {
38
+ const g = src.clone();
39
+ const p = g.attributes.position, n = g.attributes.normal;
40
+ for (let i = 0; i < p.count; i++) p.setX(i, -p.getX(i));
41
+ if (n) for (let i = 0; i < n.count; i++) n.setX(i, -n.getX(i));
42
+ if (g.index) {
43
+ const a = g.index.array;
44
+ for (let i = 0; i < a.length; i += 3) { const t = a[i]; a[i] = a[i + 2]; a[i + 2] = t; }
45
+ g.index.needsUpdate = true;
46
+ } else {
47
+ // non-indexed: swap the 1st and 3rd vertex of every triangle, across every attribute
48
+ for (const key of Object.keys(g.attributes)) {
49
+ const at = g.attributes[key], w = at.itemSize, arr = at.array;
50
+ for (let t = 0; t < at.count; t += 3) {
51
+ for (let c = 0; c < w; c++) {
52
+ const i = (t + 0) * w + c, j = (t + 2) * w + c;
53
+ const tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp;
54
+ }
55
+ }
56
+ at.needsUpdate = true;
57
+ }
58
+ }
59
+ p.needsUpdate = true;
60
+ if (n) n.needsUpdate = true;
61
+ return g;
62
+ }
63
+
64
+ // Euler YXZ: yaw first, then pitch about the piece's OWN lateral axis. Applied to the
65
+ // piece's local +Z it gives (cosθ·sin ry, sinθ, cosθ·cos ry) — a chord with heading `ry`
66
+ // climbing at θ — which is exactly the chord a panel is cut to. Any other order tilts
67
+ // the sleepers. Returns a fresh matrixFor with its own scratch objects.
68
+ export function makeChordMatrix() {
69
+ const _e = new THREE.Euler();
70
+ const _q = new THREE.Quaternion();
71
+ const _p = new THREE.Vector3();
72
+ const _s = new THREE.Vector3();
73
+ const matrixFor = (p) => {
74
+ _e.set(p.pitch, p.ry, 0, 'YXZ');
75
+ _q.setFromEuler(_e);
76
+ _p.set(p.x, p.y, p.z);
77
+ _s.set(1, 1, p.sz ?? 1); // the chord's own length over the casting's native length
78
+ return new THREE.Matrix4().compose(_p, _q, _s);
79
+ };
80
+ matrixFor.yawOnly = (p) => {
81
+ _e.set(0, p.ry, 0, 'YXZ');
82
+ _q.setFromEuler(_e);
83
+ return new THREE.Matrix4().compose(_p.set(p.x, p.y, p.z), _q, _s.set(1, 1, 1));
84
+ };
85
+ return matrixFor;
86
+ }
87
+
88
+ // One InstancedMesh per (file) panel batch. `scatterDeco:` by default — see the header;
89
+ // pass a `scatter:` name yourself when the batch is genuinely SOLID (a ridge of rock).
90
+ // frustumCulled false: a kilometres-long line has a county-sized bounding sphere, so
91
+ // culling on it is a coin toss that always comes up "visible" — not worth the test.
92
+ export function panelBatch(scene, geo, material, mats, file) {
93
+ if (!geo || !mats.length) return null;
94
+ const mesh = new THREE.InstancedMesh(geo, material, mats.length);
95
+ mats.forEach((m, i) => mesh.setMatrixAt(i, m));
96
+ mesh.instanceMatrix.needsUpdate = true;
97
+ mesh.castShadow = false; // hundreds of low bars of rail cast nothing worth a shadow pass
98
+ mesh.receiveShadow = true;
99
+ mesh.name = `scatterDeco:${file}`;
100
+ mesh.matrixAutoUpdate = false;
101
+ mesh.updateMatrix();
102
+ mesh.frustumCulled = false;
103
+ scene.add(mesh);
104
+ return mesh;
105
+ }
106
+
107
+ // One-off casting placer: turnouts, buffer stops, portals. Position + YXZ yaw/pitch +
108
+ // uniform scale, optional mirrorX. `scatterDeco:` — a buffer stop you cannot walk past
109
+ // is a buffer stop that traps you at the end of the spur; give the mesh real collision
110
+ // yourself (buildingObjects push) when the casting is genuinely solid.
111
+ export async function placeCasting(scene, { url, tex, place, name, mirror = false }) {
112
+ try {
113
+ const m = await loadModel(url, { texture: tex });
114
+ const f = flattenToGeometry(m);
115
+ if (!f) throw new Error('no geometry');
116
+ const geo = mirror ? mirrorX(f.geometry) : f.geometry;
117
+ const mesh = new THREE.Mesh(geo, await railMaterial(tex, url));
118
+ mesh.position.set(place.x, place.y, place.z);
119
+ mesh.rotation.order = 'YXZ';
120
+ mesh.rotation.set(place.pitch, place.ry, 0);
121
+ mesh.scale.setScalar(place.scale);
122
+ mesh.castShadow = true;
123
+ mesh.receiveShadow = true;
124
+ mesh.name = `scatterDeco:${name}`;
125
+ scene.add(mesh);
126
+ return mesh;
127
+ } catch (err) { console.error(`[railway] ${name} failed`, err); }
128
+ }
129
+
130
+ // Seeded LCG — an unseeded Math.random reshuffles a dressed ridge every boot: one boot
131
+ // frames the portal beautifully, the next drops a boulder in front of it. Same rocks
132
+ // every time.
133
+ export function lcgRng(seed = 0xC0FFEE) {
134
+ let _seed = seed >>> 0;
135
+ return () => { _seed = (_seed * 1664525 + 1013904223) >>> 0; return _seed / 4294967296; };
136
+ }
137
+
138
+ // THE TRESTLE, BUILT. Where the line flies it rides timber, not air: a flying panel
139
+ // KEEPS its rails and gains the kit's deck casting UNDER them (deck-only planking, same
140
+ // chord convention); the ballast is skipped by the caller (dirt does not float), and a
141
+ // bent goes under every deck joint, standing on (and into) whatever is below. `seat`
142
+ // lifts the deck so its top plane meets the panel origin — the plane the sleepers rest
143
+ // on — measured off the casting. Returns { deckMesh, bentCount, walkable } — the deck
144
+ // IS A FLOOR: bake `walkable` into a side BVH or someone rides onto the trestle and
145
+ // falls straight through the planks.
146
+ export async function buildTrestle(scene, { deckPanels, deckUrl, bentUrl, tex, material, seat = 0, matrixFor }) {
147
+ if (!deckPanels.length) return null;
148
+ const deckMaster = await loadModel(deckUrl, { texture: tex });
149
+ const bentMaster = await loadModel(bentUrl, { texture: tex });
150
+ const deckFlat = flattenToGeometry(deckMaster);
151
+ const bentFlat = flattenToGeometry(bentMaster);
152
+ const deckMats = [], bentMats = [];
153
+ for (const p of deckPanels) {
154
+ deckMats.push(matrixFor({ ...p, y: p.y + seat }));
155
+ bentMats.push(matrixFor.yawOnly({ x: p.x, y: p.y + seat, z: p.z, ry: p.ry })); // square to the track
156
+ }
157
+ const deckMesh = panelBatch(scene, deckFlat.geometry, material, deckMats, deckUrl.split('/').pop());
158
+ panelBatch(scene, bentFlat.geometry, material, bentMats, bentUrl.split('/').pop());
159
+ const walkable = deckMesh ? { geometry: deckMesh.geometry, count: deckMesh.count, matrices: deckMesh.instanceMatrix.array } : null;
160
+ return { deckMesh, bentCount: bentMats.length, walkable };
161
+ }
@@ -0,0 +1,309 @@
1
+ // THE PLACEMENT MACHINERY (world splits, step 6) — the deterministic scatter samplers, the
2
+ // placement-manifest COOK, the resident-spec wrappers and the grass OCCUPANCY grid, moved
3
+ // verbatim from western's zones.js. The game keeps its manifest (species rows, counts,
4
+ // biome accepts, crop tables) and calls these; the engine keeps the laws:
5
+ //
6
+ // · the whole placement stream is deterministic — a seeded rng with space-hashed accepts
7
+ // — so it round-trips to ONE binary file per quality preset. On a cook hit the
8
+ // generators return recorded slices and never touch the rng OR heightAt.
9
+ // · slice arrays are the editor-override address space (slice:index), applied AFTER the
10
+ // pristine cook save and BEFORE anything consumes them.
11
+ // · collision-by-name: circle colliders register per tile WRITE (never at ensure()), so
12
+ // a failed asset fetch can't leave an invisible blocker; ground dressing declares a
13
+ // `scatterGround:` collider name and is skipped by the tile BVH bake; humps feed the
14
+ // analytic walk lift identically on cooked and computed paths.
15
+ import * as THREE from 'three/webgpu';
16
+ import { scatterResident } from './scatter.js';
17
+ import { registerHumps } from './walkHumps.js';
18
+ import { colliders } from './kit.js';
19
+
20
+ // ── the placement context — the game's terrain and sampling extent ──
21
+ let PC = { heightAt: () => 0, seaLevel: -Infinity, scatterSize: 2000, saveRoute: '/__save-cookbin', cookRoute: '/assets/cook' };
22
+ export function setPlacementContext(c = {}) { Object.assign(PC, c); }
23
+
24
+ // generic FNV table fingerprint — hash any nested table of numbers/booleans into the cook
25
+ // filename, so a tuned table with an unbumped version counter renames the bin and re-cooks
26
+ // (the only outcome that was ever correct)
27
+ export function fnvSig(table) {
28
+ let h = 2166136261 >>> 0;
29
+ const walk = (v) => {
30
+ if (typeof v === 'number') { h = Math.imul((h ^ (Math.round(v * 1000) >>> 0)) >>> 0, 16777619) >>> 0; }
31
+ else if (typeof v === 'boolean') walk(v ? 1 : 0);
32
+ else if (v && typeof v === 'object') for (const k of Object.keys(v).sort()) walk(v[k]);
33
+ };
34
+ walk(table);
35
+ return (h >>> 0).toString(16).padStart(8, '0');
36
+ }
37
+
38
+ // ── the determinism probe + the editor-override address space ──
39
+ let _plHash = 0, _plCount = 0;
40
+ let _sliceRefs = []; // every slice array in stream order — the editor-override address space
41
+ const _hashPl = (out) => {
42
+ for (const p of out) {
43
+ for (const v of [p.x, p.y, p.z, p.ry, p.s]) {
44
+ _plHash = (_plHash ^ ((Math.round((v ?? 0) * 1000)) >>> 0)) >>> 0;
45
+ _plHash = Math.imul(_plHash, 16777619) >>> 0;
46
+ }
47
+ _plCount++;
48
+ }
49
+ _sliceRefs.push(out);
50
+ return out;
51
+ };
52
+ export const placementStats = () => ({ hash: _plHash >>> 0, count: _plCount });
53
+ export const sliceRefs = () => _sliceRefs;
54
+
55
+ // ── the cook state machine ──
56
+ let _cookSlices = null, _cookAt = 0, _cookRec = null;
57
+ const _rec = (out) => { if (_cookRec) _cookRec.push(out); return out; };
58
+ const _cookNext = () => {
59
+ const a = _cookSlices[_cookAt++];
60
+ if (!a) { throw new Error('[manifest] cook has fewer slices than the code has generators — bump the zones cook version or delete the cook dir'); }
61
+ const out = new Array(a.length / 6);
62
+ for (let i = 0; i < out.length; i++) {
63
+ const o = i * 6;
64
+ out[i] = { x: a[o], y: a[o + 1], z: a[o + 2], ry: a[o + 3], s: a[o + 4], cr: a[o + 5] };
65
+ }
66
+ return _rec(_hashPl(out)); // the determinism probe still tallies cooked placements
67
+ };
68
+
69
+ // Try the recorded placement stream. The bin is only trusted if it was cooked for THIS
70
+ // county at THIS scale — a scale mismatch is not a cosmetic problem: it is a whole world of
71
+ // props replayed at the wrong coordinates. Resets the probe + override address space for
72
+ // the build either way; on a miss, record mode arms.
73
+ export async function loadPlacementCook({ name, ver, scale }) {
74
+ _plHash = 2166136261 >>> 0; _plCount = 0;
75
+ _cookSlices = null; _cookAt = 0; _cookRec = null; _sliceRefs = [];
76
+ try {
77
+ const r = await fetch(`${PC.cookRoute}/${name}`);
78
+ if (r.ok) {
79
+ const buf = await r.arrayBuffer();
80
+ const hlen = new DataView(buf).getUint32(0, true);
81
+ const head = JSON.parse(new TextDecoder().decode(new Uint8Array(buf, 4, hlen)));
82
+ if (head.ver !== ver || head.scale !== scale) {
83
+ console.warn(`[manifest] ${name} is v${head.ver}/x${head.scale}, this build wants v${ver}/x${scale} — IGNORING it and re-placing from scratch`);
84
+ } else {
85
+ const f32 = new Float32Array(buf, 4 + hlen);
86
+ const slices = []; let off = 0;
87
+ for (const len of head.lens) { slices.push(f32.subarray(off, off + len * 6)); off += len * 6; }
88
+ _cookSlices = slices;
89
+ console.log(`[manifest] COOKED: ${head.count} placements from ${name} — placement compute skipped`);
90
+ }
91
+ }
92
+ } catch (e) { console.warn('[zones] cook bin missing/unreadable — falling back to the SLOW compute path (first boot after a VER bump is expected; repeated boots are not)', e?.message ?? e); }
93
+ if (!_cookSlices) _cookRec = [];
94
+ return !!_cookSlices;
95
+ }
96
+
97
+ // record mode: persist the whole placement stream for the next boot (dev middleware writes
98
+ // the cook dir — the same self-writing scheme as the VAT bins). On a cooked boot, checks
99
+ // the slice count instead (a generator added/removed without a version bump is loud).
100
+ export function savePlacementCook({ name, ver, scale, quality }) {
101
+ if (_cookRec) {
102
+ try {
103
+ const lens = _cookRec.map((a) => a.length);
104
+ const total = lens.reduce((s, l) => s + l, 0);
105
+ const f32 = new Float32Array(total * 6);
106
+ let o = 0;
107
+ for (const arr of _cookRec) for (const p of arr) { f32[o++] = p.x; f32[o++] = p.y; f32[o++] = p.z; f32[o++] = p.ry; f32[o++] = p.s; f32[o++] = p.cr ?? 0; }
108
+ const srcs = _cookRec.map((a) => a._src ?? null); // slice source models — the editor renders real meshes from these
109
+ let head = JSON.stringify({ ver, scale, quality, hash: (_plHash >>> 0).toString(16), count: _plCount, lens, srcs });
110
+ while ((head.length + 4) % 4) head += ' '; // pad so the Float32Array view lands 4-byte aligned
111
+ const hb = new TextEncoder().encode(head);
112
+ const out = new Uint8Array(4 + hb.length + f32.byteLength);
113
+ new DataView(out.buffer).setUint32(0, hb.length, true);
114
+ out.set(hb, 4);
115
+ out.set(new Uint8Array(f32.buffer), 4 + hb.length);
116
+ fetch(`${PC.saveRoute}?name=${name}`, { method: 'POST', body: out });
117
+ console.log(`[manifest] cooked → ${name} (${(out.length / 1024).toFixed(0)}KB, ${lens.length} slices)`);
118
+ } catch (e) { console.warn('[manifest] cook save failed', e); }
119
+ _cookRec = null;
120
+ } else if (_cookSlices && _cookAt !== _cookSlices.length) {
121
+ console.warn(`[manifest] cook slice-count mismatch (${_cookAt} used of ${_cookSlices.length}) — a generator was added/removed; bump the zones cook version`);
122
+ }
123
+ }
124
+
125
+ // MAP-EDITOR OVERRIDES: hand edits addressed as slice:index against the PRISTINE stream,
126
+ // applied to the shared pl arrays AFTER the cook save (the recorded bin stays pristine) and
127
+ // BEFORE the grass occupancy/TileField consume them. Same behaviour cooked/computed.
128
+ export async function applyPlacementOverrides({ url = '/assets/cook/overrides.json', ver }) {
129
+ try {
130
+ const r = await fetch(url);
131
+ if (r.ok) {
132
+ const ov = await r.json();
133
+ if (ov.ver != null && ov.ver !== ver) {
134
+ console.warn(`[manifest] editor overrides were recorded against zones v${ov.ver} (now v${ver}) — indices would corrupt; SKIPPED.`);
135
+ throw 0; // into the catch — pristine world
136
+ }
137
+ let applied = 0;
138
+ for (const [k, m] of Object.entries(ov.move ?? {})) {
139
+ const [si, i] = k.split(':').map(Number);
140
+ const p = _sliceRefs[si]?.[i];
141
+ if (p) { p.x = m[0]; p.y = m[1]; p.z = m[2]; p.ry = m[3]; p.s = m[4]; applied++; }
142
+ }
143
+ const dels = (ov.del ?? []).map((k) => k.split(':').map(Number)).sort((a, b) => b[1] - a[1]); // splice high→low keeps indices coherent
144
+ for (const [si, i] of dels) { if (_sliceRefs[si]?.[i] !== undefined) { _sliceRefs[si].splice(i, 1); applied++; } }
145
+ for (const a of ov.add ?? []) {
146
+ const s = _sliceRefs[a.slice];
147
+ if (s) { s.push({ x: a.x, y: a.y ?? PC.heightAt(a.x, a.z) - 0.08, z: a.z, ry: a.ry ?? 0, s: a.s ?? 1, cr: a.cr ?? 0 }); applied++; }
148
+ }
149
+ if (applied) console.log(`[manifest] ${applied} editor overrides applied`);
150
+ }
151
+ } catch (e) { /* no overrides — pristine world */ }
152
+ }
153
+
154
+ // ── the samplers ──
155
+ export function placementField(rng, count, accept, { sMin = 0.85, sMax = 1.3, sPow = 1, collider = 0, bvhOwned = false, box = null, fitY = 0 } = {}) {
156
+ if (_cookSlices) return _cookNext(); // cooked boot: recorded placements, zero rng/heightAt
157
+ const out = [];
158
+ let guard = 0;
159
+ while (out.length < count && guard++ < count * 40) {
160
+ const x = box ? box[0] + rng() * (box[2] - box[0]) : (rng() - 0.5) * PC.scatterSize * 0.94;
161
+ const z = box ? box[1] + rng() * (box[3] - box[1]) : (rng() - 0.5) * PC.scatterSize * 0.94;
162
+ if (!accept(x, z)) continue;
163
+ const y = PC.heightAt(x, z);
164
+ // sPow > 1 biases sizes small (many modest plants, few giants)
165
+ let s = sMin + Math.pow(rng(), sPow) * (sMax - sMin);
166
+ let py = y - 0.08;
167
+ if (fitY) { // span from a sunk base up toward nearby high ground (cliff-fit)
168
+ let hi = y;
169
+ for (const [dx, dz] of [[16, 0], [-16, 0], [0, 16], [0, -16], [12, 12], [-12, -12], [12, -12], [-12, 12]]) { const hh = PC.heightAt(x + dx, z + dz); if (hh > hi) hi = hh; }
170
+ py = PC.seaLevel - 3;
171
+ s = Math.max(0.4, Math.min(2.6, (hi - py) / fitY));
172
+ }
173
+ const p = { x, y: py, z, ry: rng() * Math.PI * 2, s };
174
+ // circle colliders are registered into `colliders` ONLY once the mesh's tile actually
175
+ // writes (see residentSpec), so a failed asset fetch can't leave an invisible blocker
176
+ p.cr = (collider && !bvhOwned) ? collider : 0;
177
+ out.push(p);
178
+ }
179
+ return _rec(_hashPl(out));
180
+ }
181
+
182
+ // A generator that is NOT rejection-sampled. A crop row walks a GRID and a fence panel walks
183
+ // a PERIMETER; throwing darts at a rectangle to find them would be absurd. Same cook contract
184
+ // as placementField, and it must be: on a cook hit it returns the recorded slice and never
185
+ // runs the builder. It consumes no rng, which is fine — the seeded stream's order is only
186
+ // required to be the same on two COLD boots, and this generator is the same no-op on both.
187
+ export function listField(build) {
188
+ if (_cookSlices) return _cookNext();
189
+ return _rec(_hashPl(build()));
190
+ }
191
+
192
+ // ── the resident kit — per-build wrappers encoding the collision-by-name contract ──
193
+ // Tree/cactus circle colliders are registered into `colliders` ONLY after a tile writes —
194
+ // a failed asset fetch must not leave an invisible blocker. `occluders` records the same
195
+ // circles synchronously so the grass occupancy grid can still dodge trunks.
196
+ export function makeResidentKit() {
197
+ const occluders = [];
198
+ const noteOcc = (pl) => { for (const p of pl) if (p.cr) occluders.push(p); };
199
+ const regCol = (pl, sink) => { for (const p of pl) if (p.cr) { const c = { x: p.x, z: p.z, r: p.cr * p.s }; colliders.push(c); if (sink) sink(c); } };
200
+ // Each helper returns a RESIDENT SPEC: the game collects these into a MANIFEST and the
201
+ // TileField streams the world from it. ensure(cap) builds the spec's resident mesh set ONCE
202
+ // (empty, capacity-sized); write(k, sub, sink)/free(k) stream one tile's placements in/out.
203
+ // CONTRACT (collision determinism): ensure() must NOT register circle colliders — regCol
204
+ // runs per WRITE, so circles register with their tile and are spliced back out on dispose.
205
+ const residentSpec = (pl, makeResident) => {
206
+ const spec = {
207
+ pl,
208
+ ensure: (cap) => (spec._hp ||= makeResident(cap)
209
+ .then((h) => (spec._h = h))
210
+ .catch((e) => { console.warn('[resident] spec build failed', e); return (spec._h = null); })),
211
+ write: async (key, sub, sink) => {
212
+ const h = spec._hp ? await spec._hp : null; // resolved after boot — a runtime write never suspends
213
+ if (!h) return null;
214
+ const res = h.write(key, sub);
215
+ if (res) regCol(sub, sink);
216
+ return res;
217
+ },
218
+ free: (key) => { spec._h?.free(key); },
219
+ handle: () => spec._h,
220
+ };
221
+ return spec;
222
+ };
223
+ // tag(): stamp each slice array with its source model+atlas — the cook header ships
224
+ // these names so the map editor can render REAL meshes (not marker cones)
225
+ const tag = (pl, url, tex) => { pl._src = { url, tex }; return pl; };
226
+ // res(): SIMPLE resident scatter — one mesh set per species, no impostor/LOD tiers
227
+ const res = (dir, atlas, file, pl, opts = {}) => { noteOcc(tag(pl, `${dir}/${file}.fbx`, atlas)); return residentSpec(pl, (cap) => scatterResident(`${dir}/${file}.fbx`, atlas, cap, pl, opts)); };
228
+ // resFlat(): the same, but the instances NEVER collide. Collision membership is decided by
229
+ // NAME — tileManager/the world facade drop any collider whose name matches /fence|flower|
230
+ // path|brick|ground|floor|grass/i — so ground dressing declares itself with a
231
+ // `scatterGround:` collider name and is skipped by the tile BVH bake. You walk OVER flat
232
+ // ground dressing; a 20cm pebble that stops a horse is a bug, not a feature.
233
+ const resFlat = (dir, atlas, file, pl, opts = {}) => {
234
+ noteOcc(tag(pl, `${dir}/${file}.fbx`, atlas));
235
+ return residentSpec(pl, async (cap) => {
236
+ const h = await scatterResident(`${dir}/${file}.fbx`, atlas, cap, pl, { castShadow: false, ...opts });
237
+ h.colliderName = `scatterGround:${file}.fbx`;
238
+ h.group.name = h.colliderName; // and out of the `^scatter:` BVH scan too
239
+ return h;
240
+ });
241
+ };
242
+ // resHump(): resFlat for dressing TALL enough to read as ground. Same no-BVH rule (a lump
243
+ // of dirt must never STOP anything), but the placements ALSO feed walkHumps' analytic
244
+ // lift, so everything that walks rides OVER the mound instead of clipping through it.
245
+ // Registration happens here, at declaration, identically on the cooked and computed paths
246
+ // (`pl` is the final rows on both), so collision truth can never drift from the renderer.
247
+ const resHump = (dir, atlas, file, pl, opts = {}) => {
248
+ registerHumps(file, pl);
249
+ return resFlat(dir, atlas, file, pl, opts);
250
+ };
251
+ return { occluders, residentSpec, tag, res, resFlat, resHump };
252
+ }
253
+
254
+ // ── the grass OCCUPANCY GRID — every cell covered by a placed object, O(1) per blade ──
255
+ export function makeOccupancy({ cell = 0.5 } = {}) {
256
+ const keyOf = (gx, gz) => gx * 100003 + gz;
257
+ const occ = new Set();
258
+ const mark = (x, z, r) => {
259
+ const r2 = r * r, gx = Math.round(x / cell), gz = Math.round(z / cell), c = Math.ceil(r / cell);
260
+ for (let i = -c; i <= c; i++) for (let j = -c; j <= c; j++) {
261
+ if ((i * cell) ** 2 + (j * cell) ** 2 <= r2) occ.add(keyOf(gx + i, gz + j)); // circle, not square
262
+ }
263
+ };
264
+ // Rectangular (rotated) footprint — buildings aren't round; marks cells inside the oriented box.
265
+ const markRect = (cx, cz, hx, hz, ang) => {
266
+ const ca = Math.cos(ang), sa = Math.sin(ang), gx = Math.round(cx / cell), gz = Math.round(cz / cell);
267
+ const c = Math.ceil(Math.hypot(hx, hz) / cell);
268
+ for (let i = -c; i <= c; i++) for (let j = -c; j <= c; j++) {
269
+ const wx = (gx + i) * cell - cx, wz = (gz + j) * cell - cz;
270
+ const lx = wx * ca + wz * sa, lz = -wx * sa + wz * ca; // into the box's local frame
271
+ if (Math.abs(lx) <= hx && Math.abs(lz) <= hz) occ.add(keyOf(gx + i, gz + j));
272
+ }
273
+ };
274
+ const rasterTri = (x0, z0, x1, z1, x2, z2) => {
275
+ const gx0 = Math.floor(Math.min(x0, x1, x2) / cell), gx1 = Math.ceil(Math.max(x0, x1, x2) / cell);
276
+ const gz0 = Math.floor(Math.min(z0, z1, z2) / cell), gz1 = Math.ceil(Math.max(z0, z1, z2) / cell);
277
+ for (let gx = gx0; gx <= gx1; gx++) for (let gz = gz0; gz <= gz1; gz++) {
278
+ const px = gx * cell, pz = gz * cell;
279
+ const d1 = (px - x1) * (z0 - z1) - (x0 - x1) * (pz - z1);
280
+ const d2 = (px - x2) * (z1 - z2) - (x1 - x2) * (pz - z2);
281
+ const d3 = (px - x0) * (z2 - z0) - (x2 - x0) * (pz - z0);
282
+ if (!(((d1 < 0) || (d2 < 0) || (d3 < 0)) && ((d1 > 0) || (d2 > 0) || (d3 > 0)))) occ.add(keyOf(gx, gz));
283
+ }
284
+ };
285
+ // Buildings: stamp each one's REAL ground footprint (floor + low-wall faces) into the
286
+ // grid, so grass meets every wall flush — works for any building shape.
287
+ const _va = new THREE.Vector3(), _vb = new THREE.Vector3(), _vc = new THREE.Vector3();
288
+ const stampBuildings = (objects, wallBand = 1.5) => {
289
+ for (const obj of objects) {
290
+ obj.updateMatrixWorld(true);
291
+ const floorTop = new THREE.Box3().setFromObject(obj).min.y + wallBand; // floor slab + lower walls only
292
+ obj.traverse((o) => {
293
+ if (!o.isMesh || !o.geometry?.attributes.position) return;
294
+ const pos = o.geometry.attributes.position, idx = o.geometry.index, mw = o.matrixWorld;
295
+ const stamp = (i0, i1, i2) => {
296
+ _va.fromBufferAttribute(pos, i0).applyMatrix4(mw);
297
+ _vb.fromBufferAttribute(pos, i1).applyMatrix4(mw);
298
+ _vc.fromBufferAttribute(pos, i2).applyMatrix4(mw);
299
+ if (_va.y > floorTop && _vb.y > floorTop && _vc.y > floorTop) return; // skip roof/upper walls
300
+ rasterTri(_va.x, _va.z, _vb.x, _vb.z, _vc.x, _vc.z);
301
+ };
302
+ if (idx) for (let i = 0; i < idx.count; i += 3) stamp(idx.getX(i), idx.getX(i + 1), idx.getX(i + 2));
303
+ else for (let i = 0; i < pos.count; i += 3) stamp(i, i + 1, i + 2);
304
+ });
305
+ }
306
+ };
307
+ const occupied = (x, z) => occ.has(keyOf(Math.round(x / cell), Math.round(z / cell)));
308
+ return { mark, markRect, rasterTri, stampBuildings, occupied };
309
+ }
@@ -0,0 +1,125 @@
1
+ // WHAT MUST BE TRUE OF A ROAD WORLD.
2
+ //
3
+ // Every fault in this file was found by a person driving into it, one at a time, over an
4
+ // afternoon. Each was silent: nothing threw, nothing logged, the world simply looked wrong in
5
+ // one particular place. That is the argument for checking them at build — a road system has
6
+ // no natural failure mode, so it needs assertions or it needs somebody's evening.
7
+ //
8
+ // const report = auditRoadWorld(roads, { ground });
9
+ // if (report.failures.length) console.warn(report.summary);
10
+ //
11
+ // Cheap by design: it samples rather than proves, so a game can afford to run it on every
12
+ // cold build and still boot.
13
+ const NEAR = (a, b, tol) => Math.abs(a - b) <= tol;
14
+
15
+ export function auditRoadWorld(roads, { ground, samples = 24, tolerance = 0.06 } = {}) {
16
+ const checks = [];
17
+ const fail = (rule, detail) => checks.push({ rule, ok: false, detail });
18
+ const pass = (rule, detail) => checks.push({ rule, ok: true, detail });
19
+ const { heightAt, links = [], placed = new Map() } = roads ?? {};
20
+ if (!heightAt || !links.length) return { failures: [], checks, summary: 'no roads to audit' };
21
+
22
+ // 1. NO DIRT ABOVE TARMAC. The ground is cut below every deck; if it is not, a wedge of
23
+ // landscape lies across the carriageway.
24
+ let poke = 0, worstPoke = 0, pokeAt = null, n1 = 0;
25
+ for (const { link, from } of links) {
26
+ const half = from.width / 2;
27
+ const line = link.centreline;
28
+ for (let i = 2; i < line.length - 2; i += Math.max(1, Math.floor(line.length / samples))) {
29
+ const p = line[i], q = line[i + 1];
30
+ const fx = q[0] - p[0], fz = q[2] - p[2], L = Math.hypot(fx, fz) || 1;
31
+ const rx = -fz / L, rz = fx / L;
32
+ for (const off of [-half * 0.95, 0, half * 0.95]) {
33
+ const x = p[0] + rx * off, z = p[2] + rz * off;
34
+ n1++;
35
+ const over = heightAt(x, z) - p[1];
36
+ if (over > tolerance) {
37
+ poke++;
38
+ if (over > worstPoke) { worstPoke = over; pokeAt = [Math.round(x), Math.round(z)]; }
39
+ }
40
+ }
41
+ }
42
+ }
43
+ poke ? fail('no ground above a road', `${poke}/${n1} samples, worst ${worstPoke.toFixed(2)} m at ${pokeAt}`)
44
+ : pass('no ground above a road', `${n1} samples clean`);
45
+
46
+ // 2. NO FLOATING ROADS. Beside the tarmac the ground must come up to meet it — a road with
47
+ // a gap of air under its edge exists nowhere in the world.
48
+ let float = 0, worstFloat = 0, floatAt = null, n2 = 0;
49
+ for (const { link, from } of links) {
50
+ const half = from.width / 2;
51
+ const line = link.centreline;
52
+ for (let i = 2; i < line.length - 2; i += Math.max(1, Math.floor(line.length / samples))) {
53
+ const p = line[i], q = line[i + 1];
54
+ const fx = q[0] - p[0], fz = q[2] - p[2], L = Math.hypot(fx, fz) || 1;
55
+ const rx = -fz / L, rz = fx / L;
56
+ for (const off of [-half - 1.5, half + 1.5]) {
57
+ const x = p[0] + rx * off, z = p[2] + rz * off;
58
+ // A CUTTING IS NOT A FLOATING ROAD. Where a lower carriageway runs alongside — the
59
+ // motorway beneath an interchange, say — the ground beside this road drops to meet
60
+ // it, held by a retaining wall. That is a road ON something, not over nothing.
61
+ // look a little wider than the sample itself: the wall of a cutting stands OUTSIDE
62
+ // the lower carriageway's bed, so asking only at this exact point misses it
63
+ let inCutting = false;
64
+ for (const probe of [0, 6, 12]) {
65
+ const b = heightAt.deck?.(x + rx * Math.sign(off) * probe, z + rz * Math.sign(off) * probe);
66
+ if (b != null && b < p[1] - 2) { inCutting = true; break; }
67
+ }
68
+ if (inCutting) continue;
69
+ n2++;
70
+ const under = p[1] - heightAt(x, z);
71
+ if (under > 1.0) {
72
+ float++;
73
+ if (under > worstFloat) { worstFloat = under; floatAt = [Math.round(x), Math.round(z)]; }
74
+ }
75
+ }
76
+ }
77
+ }
78
+ float ? fail('no road floating over a gap', `${float}/${n2} edge samples, worst ${worstFloat.toFixed(2)} m at ${floatAt}`)
79
+ : pass('no road floating over a gap', `${n2} edge samples clean`);
80
+
81
+ // 3. EVERY LINK MEETS ITS SOCKETS. A road that stops short of the junction it was built to
82
+ // reach is the one fault a player cannot drive around.
83
+ let gaps = 0;
84
+ for (const { link, from, to } of links) {
85
+ const a = link.centreline[0], b = link.centreline[link.centreline.length - 1];
86
+ if (!NEAR(a[0], from.pos[0], 0.1) || !NEAR(a[2], from.pos[2], 0.1) || !NEAR(a[1], from.pos[1], 0.1)) gaps++;
87
+ if (!NEAR(b[0], to.pos[0], 0.1) || !NEAR(b[2], to.pos[2], 0.1) || !NEAR(b[1], to.pos[1], 0.1)) gaps++;
88
+ }
89
+ gaps ? fail('every link meets both sockets', `${gaps} loose ends`)
90
+ : pass('every link meets both sockets', `${links.length * 2} ends joined`);
91
+
92
+ // 4. NOTHING UNDRIVABLE. A link whose grade had to exceed what was asked for is a fact the
93
+ // world should know about, not a surprise on the hill.
94
+ const steep = links.filter(({ link }) => link.feasible === false);
95
+ steep.length ? fail('every link is drivable at its grade', `${steep.length} link(s) needed more grade than allowed`)
96
+ : pass('every link is drivable at its grade', `${links.length} links within grade`);
97
+
98
+ // 5. JUNCTIONS SIT ON THEIR OWN GROUND. A piece seated at the wrong level is the difference
99
+ // between a slip road and a diving board.
100
+ let hanging = 0;
101
+ for (const [, p] of placed) {
102
+ if (!p.site) continue;
103
+ // sample ACROSS the footprint and take the median: a multi-level piece has a motorway
104
+ // running through its middle in a trench, so the ground at its exact centre is
105
+ // legitimately metres below grade. What matters is that the piece as a whole is seated.
106
+ const r = (p.piece?.size?.[0] ?? 60) * 0.35;
107
+ const around = [];
108
+ for (let k = 0; k < 8; k++) {
109
+ const th = (k / 8) * Math.PI * 2;
110
+ around.push(heightAt(p.site.x + Math.cos(th) * r, p.site.z + Math.sin(th) * r));
111
+ }
112
+ around.sort((x, y) => x - y);
113
+ const median = around[Math.floor(around.length / 2)];
114
+ if (Math.abs(median - p.site.y) > 1.5) hanging++;
115
+ }
116
+ hanging ? fail('junctions seated on their own ground', `${hanging} piece(s) more than 1.5 m off`)
117
+ : pass('junctions seated on their own ground', `${placed.size} pieces seated`);
118
+
119
+ const failures = checks.filter((c) => !c.ok);
120
+ const summary = [
121
+ `road audit: ${checks.length - failures.length}/${checks.length} passed`,
122
+ ...checks.map((c) => ` ${c.ok ? '✓' : '✗'} ${c.rule} — ${c.detail}`),
123
+ ].join('\n');
124
+ return { failures, checks, summary };
125
+ }