sindicate 0.12.0 → 0.15.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/docs/plans/kiwi-road-recon.md +97 -0
  3. package/docs/plans/road-system.md +142 -0
  4. package/docs/plans/three-cities.md +43 -0
  5. package/package.json +11 -3
  6. package/src/core/assets.js +15 -0
  7. package/src/systems/missions.js +2 -2
  8. package/src/systems/shatter.js +2 -1
  9. package/src/ui/dialogue.js +2 -1
  10. package/src/ui/pauseMenu.js +18 -11
  11. package/src/ui/saveLoadScreen.js +13 -9
  12. package/src/ui/theme.js +34 -0
  13. package/src/world/bridgeSplice.js +176 -0
  14. package/src/world/design.js +72 -0
  15. package/src/world/geo.js +53 -0
  16. package/src/world/heightfield.js +146 -0
  17. package/src/world/kit.js +924 -0
  18. package/src/world/permanentWay.js +161 -0
  19. package/src/world/placement.js +309 -0
  20. package/src/world/railFormation.js +185 -0
  21. package/src/world/roadKit.js +110 -0
  22. package/src/world/roadLink.js +270 -0
  23. package/src/world/roadNetwork.js +118 -0
  24. package/src/world/roadTerrain.js +263 -0
  25. package/src/world/waterLevels.js +95 -0
  26. package/src/world/waterMeshes.js +168 -0
  27. package/src/world/worldBase.js +493 -0
  28. package/tools/ExportKiwiRoadMaterials.cs +145 -0
  29. package/tools/ExportKiwiRoadRefs.cs +94 -0
  30. package/tools/buildKiwiRoadsPack.mjs +36 -0
  31. package/tools/designer-mcp.mjs +103 -0
  32. package/tools/designer.js +316 -0
  33. package/tools/devPlugin.js +324 -0
  34. package/tools/fbxToGlb.mjs +115 -0
  35. package/tools/glbWrite.mjs +62 -0
  36. package/tools/rebuildSimpleRoundabouts.mjs +211 -0
  37. package/tools/registry.json +45 -0
  38. package/tools/roadKitAnalyze.mjs +366 -0
  39. package/tools/sindicate-cli.mjs +71 -0
  40. package/tools/truthToAssemblies.mjs +160 -0
  41. package/tools/unityMeshToGlb.mjs +95 -0
  42. package/tools/unityPrefabToAssembly.mjs +276 -0
@@ -0,0 +1,176 @@
1
+ // THE BRIDGE SPLICER (world splits, step 3) — every road x river crossing becomes a deck.
2
+ // Moved verbatim from the games (their copies were byte-identical except two spacing
3
+ // numbers, parameterized below): find crossings, drop hits an existing deck already
4
+ // carries, dedupe widest-first, then splice the road STRAIGHT across the channel on a
5
+ // perpendicular chord — clear-bank anchors, collinear approach legs, rejoin points walked
6
+ // along the old road, chord mids at chordStep, long legs re-densified under legGate. Runs
7
+ // to a FIXED POINT (a splice can create a new crossing), then pushes any surviving road
8
+ // vertex out of the water to the near bank.
9
+ //
10
+ // MUTATES `roads` IN PLACE — call it before anything snapshots them (chunk indices, road
11
+ // benches, registrations: the same import-order law both counties already live by).
12
+ // Returns the BRIDGE_SPOTS array: { ax, az, bx, bz, x, z, w } chord anchors + centre +
13
+ // local channel half-width; the games' bridge builders and deck holds read it from there.
14
+ //
15
+ // chordStep/legGate: western runs 28/30 (the travellers' ≤30 m route-leg law — see its
16
+ // verify-travellers note); fable runs 36/40 (its minimap's 74 m window). Inserted points
17
+ // are collinear on the chord, so roadDistance, the paint and the terrain are bit-identical
18
+ // under either spacing — only where the walkers get their waypoints changes.
19
+ // Headless by law: no imports at all.
20
+
21
+ function segInt(a, b, c, d) { // 2D segment intersection → {x,z,t (road param),u (river param)}
22
+ const rx = b.x - a.x, rz = b.z - a.z, sx = d.x - c.x, sz = d.z - c.z;
23
+ const denom = rx * sz - rz * sx;
24
+ if (Math.abs(denom) < 1e-9) return null;
25
+ const t = ((c.x - a.x) * sz - (c.z - a.z) * sx) / denom;
26
+ const u = ((c.x - a.x) * rz - (c.z - a.z) * rx) / denom;
27
+ if (t < 0 || t > 1 || u < 0 || u > 1) return null;
28
+ return { x: a.x + rx * t, z: a.z + rz * t, t, u };
29
+ }
30
+
31
+ function walkRoad(road, i, t, dist) { // walk `dist` m along the polyline → {x,z,seg,clamped}
32
+ let seg = i, tt = t, remaining = Math.abs(dist), clamped = false;
33
+ const dir = Math.sign(dist) || 1;
34
+ while (remaining > 0) {
35
+ const a = road[seg], b = road[seg + 1];
36
+ if (!a || !b) break;
37
+ const len = Math.hypot(b.x - a.x, b.z - a.z) || 1e-6;
38
+ const along = dir > 0 ? (1 - tt) * len : tt * len;
39
+ if (along >= remaining) { tt += dir * (remaining / len); break; }
40
+ remaining -= along;
41
+ seg += dir; tt = dir > 0 ? 0 : 1;
42
+ if (seg < 0 || seg >= road.length - 1) { seg = Math.max(0, Math.min(road.length - 2, seg)); tt = dir > 0 ? 1 : 0; clamped = true; break; }
43
+ }
44
+ const a = road[seg], b = road[seg + 1];
45
+ return { x: a.x + (b.x - a.x) * tt, z: a.z + (b.z - a.z) * tt, seg, clamped };
46
+ }
47
+
48
+ export function spliceBridges(roads, rivers, { chordStep = 28, legGate = 30, approach = 14, margin = 5, pushClear = 6 } = {}) {
49
+ // metres of dry margin OUTSIDE the nearest river channel (negative = inside the water).
50
+ const riverClearance = (x, z) => {
51
+ let best = Infinity;
52
+ for (const R of rivers) {
53
+ for (let i = 0; i < R.pts.length - 1; i++) {
54
+ const a = R.pts[i], b = R.pts[i + 1];
55
+ const dx = b.x - a.x, dz = b.z - a.z;
56
+ const len2 = dx * dx + dz * dz || 1e-9;
57
+ let t = ((x - a.x) * dx + (z - a.z) * dz) / len2;
58
+ t = t < 0 ? 0 : t > 1 ? 1 : t;
59
+ const d = Math.hypot(x - (a.x + dx * t), z - (a.z + dz * t));
60
+ const ww = R.ww[i] + (R.ww[Math.min(i + 1, R.ww.length - 1)] - R.ww[i]) * t;
61
+ best = Math.min(best, d - ww);
62
+ }
63
+ }
64
+ return best;
65
+ };
66
+
67
+ const BRIDGE_SPOTS = [];
68
+ const deckVerts = new Set();
69
+ for (let pass = 0; pass < 4; pass++) {
70
+ const hits = [];
71
+ roads.forEach((road, ri) => {
72
+ for (let i = 0; i < road.length - 1; i++)
73
+ for (const R of rivers)
74
+ for (let j = 0; j < R.pts.length - 1; j++) {
75
+ const h = segInt(road[i], road[i + 1], R.pts[j], R.pts[j + 1]);
76
+ if (h) hits.push({ ...h, ri, i, w: R.ww[j] + (R.ww[Math.min(j + 1, R.ww.length - 1)] - R.ww[j]) * h.u });
77
+ }
78
+ });
79
+ const carried = (h) => BRIDGE_SPOTS.some((s) => {
80
+ const dx = s.bx - s.ax, dz = s.bz - s.az, l2 = dx * dx + dz * dz || 1e-9;
81
+ let t = ((h.x - s.ax) * dx + (h.z - s.az) * dz) / l2; t = t < 0 ? 0 : t > 1 ? 1 : t;
82
+ return Math.hypot(h.x - (s.ax + dx * t), h.z - (s.az + dz * t)) < 8;
83
+ });
84
+ hits.sort((a, b) => b.w - a.w);
85
+ const keep = [];
86
+ for (const h of hits) if (!carried(h) && !keep.some((k) => Math.hypot(k.x - h.x, k.z - h.z) < Math.max(24, k.w * 1.6))) keep.push(h);
87
+ if (!keep.length) break; // fixed point — every crossing is bridged
88
+ keep.sort((a, b) => (b.ri - a.ri) || (b.i - a.i));
89
+ const splicedFrom = new Map();
90
+ for (const h of keep) {
91
+ const road = roads[h.ri];
92
+ const bound = splicedFrom.get(h.ri) ?? road.length;
93
+ if (h.i + 1 >= bound) continue;
94
+ let rv = { x: 1, z: 0 }, bd = Infinity;
95
+ for (const R of rivers) for (let i = 0; i < R.pts.length - 1; i++) {
96
+ const a = R.pts[i], b = R.pts[i + 1];
97
+ const dx = b.x - a.x, dz = b.z - a.z, len2 = dx * dx + dz * dz || 1e-9;
98
+ let t = ((h.x - a.x) * dx + (h.z - a.z) * dz) / len2; t = t < 0 ? 0 : t > 1 ? 1 : t;
99
+ const d = Math.hypot(h.x - (a.x + dx * t), h.z - (a.z + dz * t));
100
+ if (d < bd) { bd = d; const l = Math.hypot(dx, dz) || 1e-9; rv = { x: dx / l, z: dz / l }; }
101
+ }
102
+ let px = -rv.z, pz = rv.x;
103
+ const rd = { x: road[h.i + 1].x - road[h.i].x, z: road[h.i + 1].z - road[h.i].z };
104
+ if (px * rd.x + pz * rd.z < 0) { px = -px; pz = -pz; }
105
+ const clearOut = (sgn) => {
106
+ const cap = h.w + (h.w > 7 ? 80 : 24);
107
+ let d = h.w + margin;
108
+ while (d < cap && riverClearance(h.x + sgn * px * d, h.z + sgn * pz * d) < margin) d += 2;
109
+ return d < cap ? d : h.w + margin;
110
+ };
111
+ const dA = clearOut(-1), dB = clearOut(1);
112
+ const iA = { x: h.x - px * dA, z: h.z - pz * dA }, iB = { x: h.x + px * dB, z: h.z + pz * dB };
113
+ const aA = { x: iA.x - px * approach, z: iA.z - pz * approach };
114
+ const aB = { x: iB.x + px * approach, z: iB.z + pz * approach };
115
+ const rejoin = (sgn, ref) => {
116
+ const base = (sgn < 0 ? dA : dB) + approach + 12;
117
+ let P = null;
118
+ for (let d = base; d <= base + 60; d += 4) {
119
+ P = walkRoad(road, h.i, h.t, sgn * d);
120
+ if (P.clamped || ((P.x - ref.x) * px + (P.z - ref.z) * pz) * sgn >= 4) return P;
121
+ }
122
+ return P;
123
+ };
124
+ const A = rejoin(-1, aA), B = rejoin(1, aB);
125
+ if (A.clamped && B.clamped) continue;
126
+ let to = B.clamped ? road.length - 1 : B.seg;
127
+ const from = A.clamped ? 0 : A.seg + 1;
128
+ const joinB = to >= bound;
129
+ if (joinB) to = bound - 1;
130
+ if (to < from - 1) continue;
131
+ const start = A.clamped ? iA : aA, end = B.clamped ? iB : aB;
132
+ const ins = [];
133
+ if (!A.clamped && Math.hypot(A.x - start.x, A.z - start.z) > 0.01) ins.push({ x: A.x, z: A.z });
134
+ ins.push(start);
135
+ const runL = Math.hypot(end.x - start.x, end.z - start.z);
136
+ for (let d = chordStep; d < runL - 4; d += chordStep) {
137
+ const mid = { x: start.x + px * d, z: start.z + pz * d };
138
+ ins.push(mid); deckVerts.add(mid);
139
+ }
140
+ ins.push(end);
141
+ if (!B.clamped && !joinB && Math.hypot(B.x - end.x, B.z - end.z) > 0.01) ins.push({ x: B.x, z: B.z });
142
+ for (let k = ins.length - 1; k > 0; k--) {
143
+ const p = ins[k - 1], q = ins[k];
144
+ const L = Math.hypot(q.x - p.x, q.z - p.z);
145
+ if (L > legGate) {
146
+ const n = Math.ceil(L / chordStep), legMids = [];
147
+ for (let s = 1; s < n; s++) legMids.push({ x: p.x + (q.x - p.x) * s / n, z: p.z + (q.z - p.z) * s / n });
148
+ ins.splice(k, 0, ...legMids);
149
+ }
150
+ }
151
+ road.splice(from, to - from + 1, ...ins);
152
+ splicedFrom.set(h.ri, from);
153
+ deckVerts.add(start); deckVerts.add(end);
154
+ BRIDGE_SPOTS.push({ ax: iA.x, az: iA.z, bx: iB.x, bz: iB.z, x: (iA.x + iB.x) / 2, z: (iA.z + iB.z) / 2, w: h.w });
155
+ }
156
+ }
157
+ // Final pass: any road vertex still INSIDE a channel is pushed to the near bank.
158
+ for (const road of roads) for (const v of road) {
159
+ if (deckVerts.has(v)) continue;
160
+ let best = Infinity, nx = 0, nz = 0;
161
+ for (const R of rivers) for (let i = 0; i < R.pts.length - 1; i++) {
162
+ const a = R.pts[i], b = R.pts[i + 1];
163
+ const dx = b.x - a.x, dz = b.z - a.z, len2 = dx * dx + dz * dz || 1e-9;
164
+ let t = ((v.x - a.x) * dx + (v.z - a.z) * dz) / len2; t = t < 0 ? 0 : t > 1 ? 1 : t;
165
+ const cxp = a.x + dx * t, czp = a.z + dz * t;
166
+ const d = Math.hypot(v.x - cxp, v.z - czp);
167
+ const ww = R.ww[i] + (R.ww[Math.min(i + 1, R.ww.length - 1)] - R.ww[i]) * t;
168
+ if (d - ww < best) { best = d - ww; nx = cxp; nz = czp; }
169
+ }
170
+ if (best >= pushClear) continue;
171
+ const d = Math.hypot(v.x - nx, v.z - nz) || 1e-6;
172
+ const k = (pushClear - best) / d;
173
+ v.x += (v.x - nx) * k; v.z += (v.z - nz) * k;
174
+ }
175
+ return BRIDGE_SPOTS;
176
+ }
@@ -0,0 +1,72 @@
1
+ // THE DESIGN STAMPER (designer v3) — renders a World Designer layout (designs/<name>.json,
2
+ // authored in /designer.html by hand or by an MCP-driven session) into the live world.
3
+ // MACHINE-WRITTEN LAYOUTS ARE JSON: { name, prefabs: [{ file, dir, x, y?, z, ry?, s?, tex? }],
4
+ // roads: [[{x,z}, ...], ...] }.
5
+ //
6
+ // Same shape as the kit stamper's prop path: prefabs batch per (file|tex) into ONE
7
+ // InstancedMesh each, on the shared townMaterial pipeline (one material per atlas,
8
+ // lamp-lit). Placement seats on the game's ground through the heightAt option unless the
9
+ // entry pins its own y.
10
+ //
11
+ // COLLISION IS THE BAKE'S BUSINESS: batches are named `scatter:` (solid — the world BVH
12
+ // bake picks them up by name) or `scatterDeco:` (walk-through) via opts.solid. Stamping
13
+ // AFTER world.build's bake gives no collision until the next bake — fine for a dev-flag
14
+ // preview; the production path stamps designs before the bake, exactly like the towns.
15
+ //
16
+ // ROADS: not rendered here. A designer road is a ROUTE, and routes belong to the game's
17
+ // terrain survey (road networks bench the ground, paint the surface and re-cook) — the
18
+ // designer's road polylines feed that pipeline game-side; a painted-on decal would lie
19
+ // about where the carriageway really is.
20
+ import * as THREE from 'three/webgpu';
21
+ import { loadModel } from '../core/assets.js';
22
+ import { flattenToGeometry } from './scatter.js';
23
+ import { townMaterial } from './kit.js';
24
+
25
+ export async function buildDesign(scene, design, { heightAt = () => 0, solid = true, defaultTex = null } = {}) {
26
+ const byFile = new Map(); // `${dir}/${file}|${tex}` -> { url, tex, mats: [] }
27
+ const _p = new THREE.Vector3(), _q = new THREE.Quaternion(), _s = new THREE.Vector3();
28
+ const _up = new THREE.Vector3(0, 1, 0);
29
+ for (const e of design.prefabs ?? []) {
30
+ const bare = e.file.replace(/\.(fbx|glb)$/i, '');
31
+ const ext = e.ext ?? (/\.glb$/i.test(e.file) ? 'glb' : 'fbx');
32
+ const url = `${e.dir}/${bare}`;
33
+ e._url = `${url}.${ext}`;
34
+ const tex = e.tex ?? defaultTex;
35
+ const key = `${e._url}|${tex}`;
36
+ if (!byFile.has(key)) byFile.set(key, { url: e._url, tex, mats: [] });
37
+ byFile.get(key).mats.push(new THREE.Matrix4().compose(
38
+ _p.set(e.x, e.y ?? heightAt(e.x, e.z), e.z),
39
+ _q.setFromAxisAngle(_up, e.ry ?? 0),
40
+ _s.setScalar(e.s ?? 1),
41
+ ));
42
+ }
43
+ const meshes = [];
44
+ let count = 0, failed = 0;
45
+ for (const { url, tex, mats } of byFile.values()) {
46
+ try {
47
+ const master = await loadModel(url, tex ? { texture: tex } : {});
48
+ const flat = flattenToGeometry(master);
49
+ if (!flat) { failed++; continue; }
50
+ // textured prefabs ride the shared town pipeline (one material per atlas, lamp-lit);
51
+ // an untextured one gets a plain slate rather than crashing the whole batch pass
52
+ const mat = tex ? await townMaterial(tex, url) : new THREE.MeshStandardMaterial({ color: 0x9a938a, roughness: 0.9 });
53
+ const mesh = new THREE.InstancedMesh(flat.geometry, mat, mats.length);
54
+ mats.forEach((m, i) => mesh.setMatrixAt(i, m));
55
+ mesh.instanceMatrix.needsUpdate = true;
56
+ mesh.castShadow = mats.length < 12;
57
+ mesh.receiveShadow = true;
58
+ const file = url.split('/').pop();
59
+ mesh.name = `${solid ? 'scatter' : 'scatterDeco'}:${file}`;
60
+ mesh.matrixAutoUpdate = false;
61
+ mesh.updateMatrix();
62
+ scene.add(mesh);
63
+ meshes.push(mesh);
64
+ count += mats.length;
65
+ } catch (e) {
66
+ failed++;
67
+ console.warn(`[design] prefab batch ${url} failed`, e?.message ?? e);
68
+ }
69
+ }
70
+ console.log(`[design] '${design.name ?? 'unnamed'}': ${count} prefabs in ${meshes.length} batches${failed ? ` (${failed} files failed)` : ''}${design.roads?.length ? ` · ${design.roads.length} road route(s) carried for the survey` : ''}`);
71
+ return { meshes, count, roads: design.roads ?? [] };
72
+ }
@@ -0,0 +1,53 @@
1
+ // THE GEO TOOLKIT (world splits, step 1) — the terrain-math primitives both games carried
2
+ // as byte-identical private copies: the fbm/ridged noise kit (fixed ImprovedNoise
3
+ // permutation → deterministic, one shared instance is safe), the smoothstep/terrace
4
+ // shapers, and the point-to-segment distance the polyline indices are built on.
5
+ //
6
+ // HEADLESS BY LAW: this module imports ONLY three/addons math — never three/webgpu —
7
+ // because the games' terrainData files (its consumers) run in Node for offline solvers,
8
+ // verify scripts and height-equivalence probes. Keep it that way.
9
+ //
10
+ // EQUIVALENCE CONTRACT: these bodies moved verbatim from western/fable terrainData.js.
11
+ // Any numeric change here changes both counties' ground — prove a refactor with the
12
+ // height-grid probe (441 samples per game) before shipping it.
13
+ import { ImprovedNoise } from 'three/addons/math/ImprovedNoise.js';
14
+
15
+ const noise = new ImprovedNoise();
16
+
17
+ export function fbm(x, z, octaves = 4, lacunarity = 2, gain = 0.5, scale = 0.01) {
18
+ let amp = 1, freq = scale, sum = 0, norm = 0;
19
+ for (let i = 0; i < octaves; i++) {
20
+ sum += amp * noise.noise(x * freq, z * freq, 7.31 + i * 13.7);
21
+ norm += amp; amp *= gain; freq *= lacunarity;
22
+ }
23
+ return sum / norm;
24
+ }
25
+
26
+ export const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
27
+ export const lerp = (a, b, t) => a + (b - a) * t;
28
+ export function smoothstep(a, b, t) { const x = clamp((t - a) / (b - a), 0, 1); return x * x * (3 - 2 * x); }
29
+
30
+ // ~[0,1]: sharp crests, round valleys — mountain ranges and mesa fields
31
+ export function ridged(x, z, octaves, lac, gain, scale) {
32
+ let amp = 0.5, freq = scale, sum = 0, norm = 0, prev = 1;
33
+ for (let i = 0; i < octaves; i++) {
34
+ let n = 1 - Math.abs(noise.noise(x * freq, z * freq, 41.7 + i * 7.3));
35
+ n *= n; n *= prev; sum += n * amp; norm += amp; prev = n; amp *= gain; freq *= lac;
36
+ }
37
+ return sum / norm;
38
+ }
39
+
40
+ // quantise to benches with a sharp riser — mesa walls, cliff bands
41
+ export function terrace(h, step, sharp) {
42
+ const f = h / step, k = Math.floor(f), frac = f - k;
43
+ return (k + lerp(frac, smoothstep(0.5 - 0.5 * sharp, 0.5 + 0.5 * sharp, frac), sharp)) * step;
44
+ }
45
+
46
+ // distance from (px,pz) to segment a→b — the primitive under every polyline index
47
+ export function segDist(px, pz, ax, az, bx, bz) {
48
+ const dx = bx - ax, dz = bz - az;
49
+ const len2 = dx * dx + dz * dz;
50
+ let t = len2 ? ((px - ax) * dx + (pz - az) * dz) / len2 : 0;
51
+ t = clamp(t, 0, 1);
52
+ return Math.hypot(px - (ax + dx * t), pz - (az + dz * t));
53
+ }
@@ -0,0 +1,146 @@
1
+ // THE HEIGHTFIELD BUILDER (world splits, step 4) — the chunked terrain mesh + cook harness
2
+ // both games carried byte-identically around their own paint and material:
3
+ //
4
+ // · ONE height lattice (every vertex computed exactly once), chunk meshes aligned to it,
5
+ // normals from lattice CENTRAL DIFFERENCES so chunk borders cannot seam
6
+ // · THE TERRAIN COOK — the lattice fill is ~1.74M heightAt calls and the paint pass as
7
+ // many again, all deterministic: cooked to ONE self-writing bin
8
+ // [u32 hlen][JSON {ver,N} pad4][H f32 N²][sand u8 N²][paint u8 4/vert rgba-as-rgbs]
9
+ // fetched by NAME (the game bakes its content fingerprints into cookName, so stale
10
+ // bins 404 and re-cook), recorded back through the dev middleware on a cold boot
11
+ // · the SAND LATTICE (u8 N²) — the game's paint writes it, other systems (grass blade
12
+ // fields) query exactly what the ground shows; returned for the game's sandAt()
13
+ //
14
+ // The GAME supplies: heightAt, the cook name + ver, the node material, and makePaint —
15
+ // a factory receiving { hAt, step, N, sandLattice } and returning the per-vertex painter
16
+ // (x, z, h, li, lj, colors, sands, k), the exact signature both counties already use.
17
+ import * as THREE from 'three/webgpu';
18
+
19
+ export async function buildHeightfield({
20
+ worldSize, segments, chunkTarget = 360,
21
+ heightAt, cookName, cookVer,
22
+ material, makePaint,
23
+ saveRoute = '/__save-cookbin', cookRoute = '/assets/cook',
24
+ }) {
25
+ const CHUNKS = Math.max(1, Math.round(worldSize / chunkTarget));
26
+ if (segments % CHUNKS !== 0) throw new Error(`segments ${segments} not divisible by ${CHUNKS} chunks`);
27
+ const CSEG = segments / CHUNKS; // segments per chunk side
28
+ const CSIZE = worldSize / CHUNKS; // metres per chunk side
29
+ const STEP = worldSize / segments; // lattice spacing
30
+ const N = segments + 1; // lattice verts per side
31
+
32
+ let cook = null;
33
+ try {
34
+ const r = await fetch(`${cookRoute}/${cookName}`);
35
+ if (r.ok) {
36
+ const buf = await r.arrayBuffer();
37
+ const hlen = new DataView(buf).getUint32(0, true);
38
+ const head = JSON.parse(new TextDecoder().decode(new Uint8Array(buf, 4, hlen)));
39
+ if (head.N === N) {
40
+ let o = 4 + hlen;
41
+ cook = { H: new Float32Array(buf.slice(o, o + N * N * 4)) };
42
+ o += N * N * 4;
43
+ cook.sand = new Uint8Array(buf.slice(o, o + N * N));
44
+ o += N * N;
45
+ cook.paint = new Uint8Array(buf.slice(o));
46
+ console.log(`[terrain] COOKED lattice+paint from ${cookName} — heightAt/paint compute skipped`);
47
+ }
48
+ }
49
+ } catch (e) { console.warn('[terrain] 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); }
50
+
51
+ // ---- height lattice: every terrain vertex height, computed once ----
52
+ const H = cook ? cook.H : new Float32Array(N * N);
53
+ if (!cook) {
54
+ for (let j = 0; j < N; j++) {
55
+ const z = -worldSize / 2 + j * STEP;
56
+ for (let i = 0; i < N; i++) H[j * N + i] = heightAt(-worldSize / 2 + i * STEP, z);
57
+ }
58
+ }
59
+ const hAt = (i, j) => H[Math.min(N - 1, Math.max(0, j)) * N + Math.min(N - 1, Math.max(0, i))];
60
+ const sandLattice = cook ? cook.sand : new Uint8Array(N * N);
61
+ const paintVertex = cook ? null : makePaint({ hAt, step: STEP, N, sandLattice });
62
+
63
+ // ---- build the chunk grid ----
64
+ const group = new THREE.Group();
65
+ group.name = 'terrain';
66
+ let chunkIdx = 0, vertsPerChunk = 0;
67
+ const recChunks = cook ? null : [];
68
+ for (let cj = 0; cj < CHUNKS; cj++) {
69
+ for (let ci = 0; ci < CHUNKS; ci++) {
70
+ const geo = new THREE.PlaneGeometry(CSIZE, CSIZE, CSEG, CSEG);
71
+ geo.rotateX(-Math.PI / 2);
72
+ const cx = -worldSize / 2 + (ci + 0.5) * CSIZE; // chunk centre (mesh position)
73
+ const cz = -worldSize / 2 + (cj + 0.5) * CSIZE;
74
+ const pos = geo.attributes.position;
75
+ const colors = new Float32Array(pos.count * 3);
76
+ const sands = new Float32Array(pos.count);
77
+ const normals = geo.attributes.normal;
78
+ vertsPerChunk = pos.count;
79
+ for (let k = 0; k < pos.count; k++) {
80
+ const x = pos.getX(k) + cx, z = pos.getZ(k) + cz;
81
+ // lattice indices — chunk grids align exactly to the global lattice
82
+ const li = Math.round((x + worldSize / 2) / STEP);
83
+ const lj = Math.round((z + worldSize / 2) / STEP);
84
+ const h = hAt(li, lj);
85
+ pos.setY(k, h);
86
+ // normal from lattice central differences — identical on both sides of
87
+ // a chunk border (computeVertexNormals would seam: edge verts lack
88
+ // neighbour faces from the adjacent chunk). Cheap H arithmetic — always live.
89
+ const nx = -(hAt(li + 1, lj) - hAt(li - 1, lj)) / (2 * STEP);
90
+ const nz = -(hAt(li, lj + 1) - hAt(li, lj - 1)) / (2 * STEP);
91
+ const inv = 1 / Math.hypot(nx, 1, nz);
92
+ normals.setXYZ(k, nx * inv, inv, nz * inv);
93
+ if (cook) {
94
+ // cooked paint: 4 bytes/vert (r,g,b,sand) in chunk build order
95
+ const po = (chunkIdx * pos.count + k) * 4;
96
+ colors[k * 3] = cook.paint[po] / 255;
97
+ colors[k * 3 + 1] = cook.paint[po + 1] / 255;
98
+ colors[k * 3 + 2] = cook.paint[po + 2] / 255;
99
+ sands[k] = cook.paint[po + 3] / 255;
100
+ } else {
101
+ paintVertex(x, z, h, li, lj, colors, sands, k);
102
+ }
103
+ }
104
+ geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
105
+ geo.setAttribute('aSand', new THREE.BufferAttribute(sands, 1));
106
+ recChunks?.push({ colors, sands });
107
+ chunkIdx++;
108
+ const mesh = new THREE.Mesh(geo, material);
109
+ mesh.position.set(cx, 0, cz);
110
+ mesh.receiveShadow = true;
111
+ mesh.name = `terrain:${ci},${cj}`;
112
+ group.add(mesh);
113
+ }
114
+ }
115
+ console.log(`[terrain] ${CHUNKS}x${CHUNKS} chunks (${CSIZE}m, ${CSEG}x${CSEG} segs each) — frustum-culled${cook ? ' · cooked' : ''}`);
116
+
117
+ // record mode: persist lattice + paint for the next boot (dev middleware writes the file)
118
+ if (recChunks) {
119
+ try {
120
+ let head = JSON.stringify({ ver: cookVer, N });
121
+ while ((head.length + 4) % 4) head += ' '; // 4-byte-align the Float32 payload
122
+ const hb = new TextEncoder().encode(head);
123
+ const paint = new Uint8Array(chunkIdx * vertsPerChunk * 4);
124
+ recChunks.forEach(({ colors, sands }, cIdx) => {
125
+ for (let k = 0; k < sands.length; k++) {
126
+ const po = (cIdx * sands.length + k) * 4;
127
+ paint[po] = Math.min(255, Math.round(colors[k * 3] * 255));
128
+ paint[po + 1] = Math.min(255, Math.round(colors[k * 3 + 1] * 255));
129
+ paint[po + 2] = Math.min(255, Math.round(colors[k * 3 + 2] * 255));
130
+ paint[po + 3] = Math.min(255, Math.round(sands[k] * 255));
131
+ }
132
+ });
133
+ const out = new Uint8Array(4 + hb.length + H.byteLength + sandLattice.byteLength + paint.byteLength);
134
+ const dv = new DataView(out.buffer);
135
+ dv.setUint32(0, hb.length, true);
136
+ out.set(hb, 4);
137
+ let o = 4 + hb.length;
138
+ out.set(new Uint8Array(H.buffer), o); o += H.byteLength;
139
+ out.set(sandLattice, o); o += sandLattice.byteLength;
140
+ out.set(paint, o);
141
+ fetch(`${saveRoute}?name=${cookName}`, { method: 'POST', body: out });
142
+ console.log(`[terrain] cooked → ${cookName} (${(out.length / 1048576).toFixed(1)}MB)`);
143
+ } catch (e) { console.warn('[terrain] cook save failed', e); }
144
+ }
145
+ return { group, sandLattice, N, step: STEP, cooked: !!cook };
146
+ }