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,263 @@
1
+ // ROAD TERRAIN — the ground has to agree with the roads.
2
+ //
3
+ // Two edits, both applied by wrapping a game's own heightAt:
4
+ // · PADS — a junction is a rigid prefab: it cannot follow a slope, so the ground under its
5
+ // footprint is levelled and blended back out to the natural terrain.
6
+ // · CORRIDORS — a link road carries its own solved profile; the ground is cut or filled to
7
+ // meet it across the carriageway, then blended out over the verge.
8
+ //
9
+ // const heightAt = makeRoadTerrain(naturalHeightAt, { pads, corridors })
10
+ //
11
+ // Wrapping rather than baking means the terrain cook, collision, grass occupancy and
12
+ // anything else that samples the ground all see the same edited surface for free.
13
+ import { smoothstep } from './geo.js';
14
+
15
+ // segment lookup on a uniform grid — a world's worth of road is far too many segments to
16
+ // walk per lattice vertex, and the lattice is ~1.7M vertices
17
+ function segmentIndex(corridors, cell) {
18
+ const buckets = new Map();
19
+ const key = (i, j) => `${i},${j}`;
20
+ const segs = [];
21
+ for (const c of corridors) {
22
+ const line = c.centreline;
23
+ // a batter can run a long way when the road crosses a deep valley, so the bucket reach
24
+ // has to cover the whole earthwork, not just the carriageway
25
+ const reach = (c.halfWidth ?? 5) + (c.maxEarthwork ?? 90);
26
+ for (let i = 0; i < line.length - 1; i++) {
27
+ const a = line[i], b = line[i + 1];
28
+ const s = {
29
+ ax: a[0], ay: a[1], az: a[2], bx: b[0], by: b[1], bz: b[2],
30
+ half: c.halfWidth ?? 5, drop: c.drop ?? 0, batter: c.batter ?? 0.5,
31
+ };
32
+ const id = segs.push(s) - 1;
33
+ const i0 = Math.floor((Math.min(a[0], b[0]) - reach) / cell), i1 = Math.floor((Math.max(a[0], b[0]) + reach) / cell);
34
+ const j0 = Math.floor((Math.min(a[2], b[2]) - reach) / cell), j1 = Math.floor((Math.max(a[2], b[2]) + reach) / cell);
35
+ for (let ii = i0; ii <= i1; ii++) for (let jj = j0; jj <= j1; jj++) {
36
+ const k = key(ii, jj);
37
+ if (!buckets.has(k)) buckets.set(k, []);
38
+ buckets.get(k).push(id);
39
+ }
40
+ }
41
+ }
42
+ return {
43
+ near(x, z) { return buckets.get(key(Math.floor(x / cell), Math.floor(z / cell))) ?? null; },
44
+ segs,
45
+ };
46
+ }
47
+
48
+ // How far outside a pad's footprint a point is (0 inside). A simple junction is a circle,
49
+ // but an interchange is a long rectangle with slip roads and spurs down one axis — flatten a
50
+ // circle under one of those and the arms stick out over thin air.
51
+ function padDistance(p, x, z) {
52
+ const dx = x - p.x, dz = z - p.z;
53
+ if (p.halfX == null) return Math.max(Math.hypot(dx, dz) - (p.radius ?? 0), 0);
54
+ const c = Math.cos(p.yaw ?? 0), s = Math.sin(p.yaw ?? 0);
55
+ const lx = Math.abs(dx * c - dz * s) - p.halfX; // into the pad's own frame
56
+ const lz = Math.abs(dx * s + dz * c) - p.halfZ;
57
+ return Math.hypot(Math.max(lx, 0), Math.max(lz, 0));
58
+ }
59
+
60
+ // THE GROUND UNDER A PIECE, TAKEN FROM THE PIECE ITSELF.
61
+ //
62
+ // The law: no road may fly with nothing under it — except a deck, which flies over the road
63
+ // it crosses. Both follow from one rule: the ground tracks the LOWEST DRIVEABLE SURFACE at
64
+ // each spot. Under a bridge the lowest surface is the carriageway below, so the ground is cut
65
+ // to it and the deck spans the gap; everywhere else the lowest surface is the only surface,
66
+ // so the ground comes up to meet it — slip roads included, wherever they curve.
67
+ //
68
+ // `points` must be DECK vertices only. Pillars, supports and retaining walls hang far below
69
+ // the road they carry; let those in and the ground is dug out from under the whole junction.
70
+ export function surfaceRaster(points, { x = 0, z = 0, yaw = 0, halfX, halfZ, res = 3, drop = 0.3, batter = 0.5 }) {
71
+ const nx = Math.max(1, Math.ceil((halfX * 2) / res));
72
+ const nz = Math.max(1, Math.ceil((halfZ * 2) / res));
73
+ const h = new Float32Array(nx * nz).fill(Infinity);
74
+ const c = Math.cos(yaw), s = Math.sin(yaw);
75
+ for (const p of points) {
76
+ const dx = p[0] - x, dz = p[2] - z;
77
+ const lx = dx * c - dz * s, lz = dx * s + dz * c; // into the piece's own frame
78
+ const i = Math.floor((lx + halfX) / res), j = Math.floor((lz + halfZ) / res);
79
+ if (i < 0 || j < 0 || i >= nx || j >= nz) continue;
80
+ const k = j * nx + i;
81
+ if (p[1] < h[k]) h[k] = p[1]; // LOWEST deck wins
82
+ }
83
+ // chamfer distance transform: every empty cell learns its nearest deck and how far away,
84
+ // so the batter can slope out of it in O(1) per query
85
+ const d = new Float32Array(nx * nz).fill(Infinity);
86
+ for (let k = 0; k < h.length; k++) if (h[k] < Infinity) d[k] = 0;
87
+ const relax = (k, k2, cost) => { if (d[k2] + cost < d[k]) { d[k] = d[k2] + cost; h[k] = h[k2]; } };
88
+ for (let j = 0; j < nz; j++) for (let i = 0; i < nx; i++) {
89
+ const k = j * nx + i;
90
+ if (i > 0) relax(k, k - 1, res);
91
+ if (j > 0) relax(k, k - nx, res);
92
+ if (i > 0 && j > 0) relax(k, k - nx - 1, res * 1.414);
93
+ if (i < nx - 1 && j > 0) relax(k, k - nx + 1, res * 1.414);
94
+ }
95
+ for (let j = nz - 1; j >= 0; j--) for (let i = nx - 1; i >= 0; i--) {
96
+ const k = j * nx + i;
97
+ if (i < nx - 1) relax(k, k + 1, res);
98
+ if (j < nz - 1) relax(k, k + nx, res);
99
+ if (i < nx - 1 && j < nz - 1) relax(k, k + nx + 1, res * 1.414);
100
+ if (i > 0 && j < nz - 1) relax(k, k + nx - 1, res * 1.414);
101
+ }
102
+ return { nx, nz, res, x, z, yaw, halfX, halfZ, h, d, drop, batter };
103
+ }
104
+
105
+ function rasterAt(r, x, z) {
106
+ const c = Math.cos(r.yaw), s = Math.sin(r.yaw);
107
+ const dx = x - r.x, dz = z - r.z;
108
+ const lx = dx * c - dz * s, lz = dx * s + dz * c;
109
+ const outX = Math.max(Math.abs(lx) - r.halfX, 0), outZ = Math.max(Math.abs(lz) - r.halfZ, 0);
110
+ const i = Math.min(Math.max(Math.floor((lx + r.halfX) / r.res), 0), r.nx - 1);
111
+ const j = Math.min(Math.max(Math.floor((lz + r.halfZ) / r.res), 0), r.nz - 1);
112
+ const k = j * r.nx + i;
113
+ if (!(r.h[k] < Infinity)) return null;
114
+ return { y: r.h[k], d: r.d[k] + Math.hypot(outX, outZ) };
115
+ }
116
+
117
+ export function makeRoadTerrain(baseHeightAt, { pads = [], corridors = [], surfaces = [], cell = 32 } = {}) {
118
+ const index = corridors.length ? segmentIndex(corridors, cell) : null;
119
+
120
+ return function heightAt(x, z) {
121
+ let h = baseHeightAt(x, z);
122
+
123
+ // ── junction pads: level, because a prefab junction cannot bend ──
124
+ for (const p of pads) {
125
+ const d = padDistance(p, x, z);
126
+ // sit the dirt a little under the deck, or the two surfaces fight for the same pixels
127
+ const y = p.y - (p.drop ?? 0);
128
+ if (d <= 0) { h = y; continue; } // inside the footprint: level
129
+ const reach = d * (p.batter ?? 0.5); // same earthwork law as a link
130
+ h = Math.min(Math.max(h, y - reach), y + reach);
131
+ }
132
+
133
+ // ── piece decks: cut the plateau open wherever a deck runs BELOW it ──
134
+ // The pad above gives a junction the level ground it needs. This pass then digs out the
135
+ // parts that live under grade — the motorway through an overpass, the slip roads
136
+ // descending to it — so those are open to the sky and the deck above spans a real gap.
137
+ // It only ever lowers: raising here would make the ground follow every kerb and ramp,
138
+ // which turns a junction into a lumpy mess.
139
+ for (const r of surfaces) {
140
+ const hit = rasterAt(r, x, z);
141
+ if (!hit) continue;
142
+ const y = hit.y - r.drop;
143
+ // retaining-wall gradient out of the cut, so it stays tight to the road below
144
+ h = Math.min(h, y + hit.d * (r.wall ?? 3));
145
+ }
146
+
147
+ // ── link corridors: cut/fill the ground to the road's own profile ──
148
+ if (index) {
149
+ const ids = index.near(x, z);
150
+ if (ids) {
151
+ let best = null;
152
+ for (const id of ids) {
153
+ const s = index.segs[id];
154
+ const dx = s.bx - s.ax, dz = s.bz - s.az;
155
+ const len2 = dx * dx + dz * dz || 1e-6;
156
+ const t = Math.min(1, Math.max(0, ((x - s.ax) * dx + (z - s.az) * dz) / len2));
157
+ const px = s.ax + dx * t, pz = s.az + dz * t;
158
+ const d = Math.hypot(x - px, z - pz);
159
+ if (!best || d < best.d) best = { d, s, y: s.ay + (s.by - s.ay) * t };
160
+ }
161
+ if (best) {
162
+ const road = best.y - best.s.drop;
163
+ if (best.d <= best.s.half) h = road; // the bed itself
164
+ else {
165
+ // EMBANKMENT / CUTTING: outside the bed the ground may only climb away from the
166
+ // road at the batter slope (1:2 by default). Fill rises to meet a road above the
167
+ // valley, cutting falls away from a road below the hill, and natural ground
168
+ // further out is left alone — no fixed blend distance to get wrong.
169
+ const reach = (best.d - best.s.half) * best.s.batter;
170
+ h = Math.min(Math.max(h, road - reach), road + reach);
171
+ }
172
+ }
173
+ }
174
+ }
175
+
176
+ return h;
177
+ };
178
+ }
179
+
180
+ // The height a junction should sit at: the average ground across its footprint, so it is
181
+ // neither buried on one side nor stilted on the other. `spread` is how much the ground
182
+ // varies underneath — a site law: junctions go on the flats.
183
+ export function seatHeight(heightAt, x, z, radius, rings = 3, spokes = 8) {
184
+ // `radius` may be a footprint {halfX, halfZ, yaw}; sample its own extent in that case
185
+ if (typeof radius === 'object' && radius) {
186
+ const { halfX, halfZ, yaw = 0 } = radius;
187
+ const c = Math.cos(yaw), sn = Math.sin(yaw);
188
+ let sum = 0, n = 0, lo = Infinity, hi = -Infinity;
189
+ for (let i = -rings; i <= rings; i++) {
190
+ for (let j = -rings; j <= rings; j++) {
191
+ const lx = (i / rings) * halfX, lz = (j / rings) * halfZ;
192
+ const h = heightAt(x + lx * c + lz * sn, z - lx * sn + lz * c);
193
+ sum += h; n++;
194
+ if (h < lo) lo = h;
195
+ if (h > hi) hi = h;
196
+ }
197
+ }
198
+ return { y: sum / n, spread: hi - lo };
199
+ }
200
+ let sum = heightAt(x, z), n = 1, lo = Infinity, hi = -Infinity;
201
+ for (let r = 1; r <= rings; r++) {
202
+ const rad = (radius * r) / rings;
203
+ for (let a = 0; a < spokes; a++) {
204
+ const th = (a / spokes) * Math.PI * 2;
205
+ const h = heightAt(x + Math.cos(th) * rad, z + Math.sin(th) * rad);
206
+ sum += h; n++;
207
+ if (h < lo) lo = h;
208
+ if (h > hi) hi = h;
209
+ }
210
+ }
211
+ return { y: sum / n, spread: hi - lo };
212
+ }
213
+
214
+ // Where should a junction go? Sample the map, keep the flat spots, and take the pair that is
215
+ // both well separated and interestingly different in height — the "sites go on the flats"
216
+ // law from the road plan, applied automatically.
217
+ // Several sites at once, for a whole map: flat enough to stand a junction on, spread out
218
+ // enough to be separate places. Greedy farthest-point selection keeps them from clumping.
219
+ export function pickManySites(heightAt, { count = 5, half = 600, step = 90, radius = 60, minGap = 300 } = {}) {
220
+ const cands = [];
221
+ for (let x = -half; x <= half; x += step) {
222
+ for (let z = -half; z <= half; z += step) {
223
+ const seat = seatHeight(heightAt, x, z, radius);
224
+ if (seat.spread > radius * 0.3) continue;
225
+ cands.push({ x, z, y: seat.y, spread: seat.spread });
226
+ }
227
+ }
228
+ if (!cands.length) return [];
229
+ cands.sort((a, b) => a.spread - b.spread); // flattest first
230
+ const chosen = [cands[0]];
231
+ for (const c of cands) {
232
+ if (chosen.length >= count) break;
233
+ if (chosen.every((s) => Math.hypot(s.x - c.x, s.z - c.z) >= minGap)) chosen.push(c);
234
+ }
235
+ return chosen;
236
+ }
237
+
238
+ export function pickSites(heightAt, { half = 600, step = 90, radius = 60, minGap = 340, maxGap = 760, maxGrade = 0.07 } = {}) {
239
+ const cands = [];
240
+ for (let x = -half; x <= half; x += step) {
241
+ for (let z = -half; z <= half; z += step) {
242
+ const seat = seatHeight(heightAt, x, z, radius);
243
+ if (seat.spread > radius * 0.28) continue; // too lumpy to stand a junction on
244
+ cands.push({ x, z, y: seat.y, spread: seat.spread });
245
+ }
246
+ }
247
+ let best = null;
248
+ for (let i = 0; i < cands.length; i++) {
249
+ for (let j = i + 1; j < cands.length; j++) {
250
+ const a = cands[i], b = cands[j];
251
+ const gap = Math.hypot(a.x - b.x, a.z - b.z);
252
+ if (gap < minGap || gap > maxGap) continue;
253
+ // interesting relief, but only as much as a road can actually climb between them:
254
+ // pairing the highest peak with the deepest valley just guarantees an unbuildable link
255
+ const dy = Math.abs(a.y - b.y);
256
+ const climbable = gap * maxGrade * 0.55;
257
+ const relief = dy <= climbable ? dy : climbable - (dy - climbable) * 3;
258
+ const score = relief * 2 - (a.spread + b.spread) + gap * 0.02;
259
+ if (!best || score > best.score) best = { a, b, gap, score };
260
+ }
261
+ }
262
+ return best;
263
+ }
@@ -0,0 +1,95 @@
1
+ // THE WATER-LEVEL BAKE (world splits, step 3b) — R.wy, the per-vertex water surface every
2
+ // carve, sheet, swimmer and bridge reads. Moved verbatim from the games (identical bodies;
3
+ // western adds PASS 0 + the R.bd gates, which subsume fable's ungated loops — an unsolved
4
+ // course simply runs the live passes).
5
+ //
6
+ // PASS 0 — THE SURVEY GOVERNS (solved courses, R.bd present): wy = solved bed + baked
7
+ // reach depth, source/terminus lake pins, then strictly monotone — the uphill
8
+ // forgiveness below does not apply to a solved course.
9
+ // PASS 1 — raw level = lowest of the ±ww cross-section on the river-free ground, −1 m
10
+ // (cross-section reach capped at 10 m — the wide-trunk lesson: ±26 m banks
11
+ // reached distant low ground and PASS 2 dragged whole reaches to sea level).
12
+ // PASS 2 — terminus pin (end lake surface −0.8 / parent trunk's level at the join / the
13
+ // open sea), monotone-downhill, floor at sea level. Trunks must precede their
14
+ // tributaries in the array so a join reads a FINALIZED parent level.
15
+ // PASS 3 — riffle relief: water rides within riffleCut of the river-free ground (highest
16
+ // of centreline AND both banks at ±(ww+4)) so the monotone floor cannot gouge a
17
+ // slot-gorge across a rise ('rivers underground').
18
+ // PASS 4 — estuary descent: sea-reaching trunks ease to sea level anchored at the
19
+ // SHORELINE node (tails run out to sea), pin seaward, re-monotone the reach.
20
+ //
21
+ // groundFn(x, z) is the game's river-free pristine ground — rawHeight(x, z, true) in both
22
+ // counties. Call this at the game's bake site (BELOW its rawHeight, per the TDZ law both
23
+ // files document). Headless by law.
24
+ import { lerp, smoothstep } from './geo.js';
25
+
26
+ function riverWyAt(R, p) { // baked level of R nearest p — for confluences
27
+ let best = Infinity, wy = R.wy[0];
28
+ for (let i = 0; i < R.pts.length; i++) {
29
+ const d = Math.hypot(R.pts[i].x - p.x, R.pts[i].z - p.z);
30
+ if (d < best) { best = d; wy = R.wy[i]; }
31
+ }
32
+ return wy;
33
+ }
34
+
35
+ export function bakeWaterLevels(rivers, groundFn, { seaLevel, coastZ, riffleCut = 1.6 } = {}) {
36
+ // PASS 0
37
+ for (const R of rivers) {
38
+ if (!R.bd) continue;
39
+ const n = R.pts.length;
40
+ R.wy = R.bd.map((b, i) => b + R.wdep[i]);
41
+ if (R.srcLake) R.wy[0] = R.srcLake.level - 0.3;
42
+ if (R.endLake) R.wy[n - 1] = R.endLake.level - 0.8;
43
+ for (let i = 1; i < n; i++) R.wy[i] = Math.min(R.wy[i], R.wy[i - 1]);
44
+ }
45
+ // PASS 1
46
+ for (const R of rivers) {
47
+ if (R.bd) continue;
48
+ R.wy = R.pts.map((p, i, a) => {
49
+ const pa = a[Math.max(0, i - 1)], pb = a[Math.min(a.length - 1, i + 1)];
50
+ let tx = pb.x - pa.x, tz = pb.z - pa.z; const tl = Math.hypot(tx, tz) || 1; const nx = -tz / tl, nz = tx / tl;
51
+ const w = Math.min(R.ww[i], 10);
52
+ return Math.min(groundFn(p.x, p.z), groundFn(p.x + nx * w, p.z + nz * w), groundFn(p.x - nx * w, p.z - nz * w)) - 1.0;
53
+ });
54
+ }
55
+ // PASS 2
56
+ for (const R of rivers) {
57
+ if (R.bd) continue;
58
+ const n = R.wy.length;
59
+ R.wy[n - 1] = R.endLake ? R.endLake.level - 0.8
60
+ : R.parent != null ? riverWyAt(rivers[R.parent], R.pts[n - 1])
61
+ : seaLevel;
62
+ for (let i = 1; i < n; i++) R.wy[i] = Math.min(R.wy[i], R.wy[i - 1]);
63
+ for (let i = 0; i < n; i++) R.wy[i] = Math.max(R.wy[i], seaLevel);
64
+ }
65
+ // PASS 3
66
+ for (const R of rivers) {
67
+ if (R.bd) continue;
68
+ for (let i = 0; i < R.wy.length; i++) {
69
+ const p2 = R.pts[i], pa2 = R.pts[Math.max(0, i - 1)], pb2 = R.pts[Math.min(R.pts.length - 1, i + 1)];
70
+ let tx2 = pb2.x - pa2.x, tz2 = pb2.z - pa2.z; const tl2 = Math.hypot(tx2, tz2) || 1;
71
+ const nx2 = -tz2 / tl2, nz2 = tx2 / tl2, off2 = R.ww[i] + 4;
72
+ const gRef = Math.max(
73
+ groundFn(p2.x, p2.z),
74
+ groundFn(p2.x + nx2 * off2, p2.z + nz2 * off2),
75
+ groundFn(p2.x - nx2 * off2, p2.z - nz2 * off2),
76
+ );
77
+ R.wy[i] = Math.max(R.wy[i], gRef - riffleCut);
78
+ }
79
+ }
80
+ // PASS 4
81
+ for (const R of rivers) {
82
+ if (R.bd || R.parent != null || R.endLake) continue;
83
+ const n = R.wy.length;
84
+ if (R.pts[n - 1].z < coastZ) continue;
85
+ let iShore = n - 1;
86
+ while (iShore > 0 && groundFn(R.pts[iShore].x, R.pts[iShore].z) < seaLevel + 0.3) iShore--;
87
+ const K = Math.min(14, iShore);
88
+ for (let i = iShore - K; i <= iShore; i++) {
89
+ const t = smoothstep(iShore - K, iShore, i);
90
+ R.wy[i] = Math.max(seaLevel, lerp(R.wy[i], seaLevel, t));
91
+ }
92
+ for (let i = iShore + 1; i < n; i++) R.wy[i] = seaLevel;
93
+ for (let i = Math.max(1, iShore - Math.min(24, iShore)); i < n; i++) R.wy[i] = Math.min(R.wy[i], R.wy[i - 1]);
94
+ }
95
+ }
@@ -0,0 +1,168 @@
1
+ // THE WATER MESHES (world splits, step 6) — river ribbons, lake sheets and the sea plane,
2
+ // moved verbatim from western's zones.js. All geometry + per-vertex hydraulic bakes; the
3
+ // MATERIALS stay the game's choice (one shared waterMaterial across lakes, one shared
4
+ // riverMaterial across courses — each distinct refracting material pays its own viewport
5
+ // copy per frame, so sharing collapses the copies however many bodies the county gains).
6
+ import * as THREE from 'three/webgpu';
7
+
8
+ // A river ribbon: walk the path in ~2m steps, emit left/right cross-section verts at ±w,
9
+ // bake the still-water depth (level − bed) + downstream UVs per vertex, strip the quads.
10
+ // Emitted as ~200 m CHUNKS (each its own geometry + bounding sphere) rather than one
11
+ // course-long mesh: a 3.6 km course has a county-sized bounding sphere, so frustumCulled
12
+ // never actually culled, and refraction pays a viewport copy per DRAW. Boundary
13
+ // cross-section rows are duplicated into both neighbouring chunks — identical baked values
14
+ // from identical inputs, so the strip has no seam.
15
+ export function buildRiverMesh(scene, R, mat, { heightAt }) {
16
+ const L = THREE.MathUtils.lerp, pts = [];
17
+ // Reference half-width for the flow bake: the course's own source→mouth hydraulic ramp,
18
+ // PER STATION — not one course-wide constant. The solved widths are that ramp ±25 %
19
+ // pool/riffle modulation, and it is the MODULATION continuity should answer to: against a
20
+ // constant reference, a narrow upstream pool computes faster than a wide downstream riffle
21
+ // (measured: pools averaged 0.50 m/s to the riffles' 0.44), which is backwards. Against
22
+ // the local ramp, wide-for-here runs slow and narrow-for-here runs quick, the whole course over.
23
+ const wEnd = R.pts.length - 1 || 1;
24
+ const wrefAt = (f) => L(R.wsrc ?? R.w, R.w, f);
25
+ for (let i = 0; i < R.pts.length - 1; i++) {
26
+ const a = R.pts[i], b = R.pts[i + 1], steps = Math.max(1, Math.round(Math.hypot(b.x - a.x, b.z - a.z) / 2));
27
+ for (let s = 0; s < steps; s++) { const t = s / steps; pts.push({ x: L(a.x, b.x, t), z: L(a.z, b.z, t), wy: L(R.wy[i], R.wy[i + 1], t), ww: L(R.ww[i], R.ww[i + 1], t), wr: wrefAt((i + t) / wEnd) }); }
28
+ }
29
+ const e = R.pts.length - 1; pts.push({ x: R.pts[e].x, z: R.pts[e].z, wy: R.wy[e], ww: R.ww[e], wr: wrefAt(1) });
30
+
31
+ // cross-section resolution scales with the river's width — ~2.4m across (odd → centreline)
32
+ const K = Math.max(9, Math.min(41, 2 * Math.round(Math.max(...R.ww) / 2.4) + 1));
33
+ const CHUNK_LEN = 200; // m of course per geometry
34
+ const meshes = [];
35
+ const newChunk = () => ({ verts: [], depths: [], uvs: [], flows: [], drops: [], bWy: [], bX: [], bZ: [], rows: 0 });
36
+ const pushRow = (c, row) => {
37
+ for (const [dst, src] of [[c.verts, row.verts], [c.depths, row.depths], [c.uvs, row.uvs],
38
+ [c.flows, row.flows], [c.drops, row.drops], [c.bWy, row.bWy], [c.bX, row.bX], [c.bZ, row.bZ]]) dst.push(...src);
39
+ c.rows++;
40
+ };
41
+ const flush = (c) => {
42
+ const idx = [];
43
+ for (let i = 0; i < c.rows - 1; i++) for (let k = 0; k < K - 1; k++) {
44
+ const a = i * K + k, b = a + 1, q = (i + 1) * K + k, d = q + 1;
45
+ idx.push(a, q, b, b, q, d);
46
+ }
47
+ const geo = new THREE.BufferGeometry();
48
+ geo.setAttribute('position', new THREE.Float32BufferAttribute(c.verts, 3));
49
+ geo.setAttribute('aDepth', new THREE.Float32BufferAttribute(c.depths, 1));
50
+ geo.setAttribute('uv', new THREE.Float32BufferAttribute(c.uvs, 2));
51
+ geo.setAttribute('aFlow', new THREE.Float32BufferAttribute(c.flows, 2));
52
+ geo.setAttribute('aDrop', new THREE.Float32BufferAttribute(c.drops, 1));
53
+ geo.setIndex(idx);
54
+ const mesh = new THREE.Mesh(geo, mat);
55
+ // per-chunk bake data — refreshRiverDepths already iterates N meshes, so each chunk
56
+ // re-derives its own aDepth after the terrain pads mutate heightAt
57
+ mesh.userData.riverVerts = { wy: Float32Array.from(c.bWy), x: Float32Array.from(c.bX), z: Float32Array.from(c.bZ) };
58
+ geo.computeBoundingSphere();
59
+ mesh.frustumCulled = true; // refraction pays a viewport copy per DRAW — never draw off-screen chunks
60
+ scene.add(mesh);
61
+ meshes.push(mesh);
62
+ };
63
+ let cur = newChunk(), chunkStart = 0, along = 0;
64
+ for (let i = 0; i < pts.length; i++) {
65
+ const p = pts[i], pa = pts[Math.max(0, i - 1)], pb = pts[Math.min(pts.length - 1, i + 1)];
66
+ let tx = pb.x - pa.x, tz = pb.z - pa.z; const tl = Math.hypot(tx, tz) || 1; tx /= tl; tz /= tl;
67
+ const nx = -tz, nz = tx; // perpendicular in the XZ plane
68
+ if (i > 0) along += Math.hypot(p.x - pts[i - 1].x, p.z - pts[i - 1].z);
69
+ // rapids strength: how fast the water level DROPS just downstream of here (per metre)
70
+ const pd = pts[Math.min(pts.length - 1, i + 3)];
71
+ const dDist = Math.hypot(pd.x - p.x, pd.z - p.z) || 1;
72
+ const drop = Math.max(0, (p.wy - pd.wy) / dDist);
73
+ const row = newChunk(); // one cross-section, built once…
74
+ for (let k = 0; k < K; k++) {
75
+ const side = (k / (K - 1)) * 2 - 1; // -1 (left bank) … 0 (centre) … 1 (right bank)
76
+ const vx = p.x + nx * p.ww * side, vz = p.z + nz * p.ww * side;
77
+ const g = heightAt(vx, vz);
78
+ // wet verts sit at water level; DRY verts dive a fixed metre BELOW the surface, so the
79
+ // sheet submerges and the waterline is the smooth surface/terrain intersection
80
+ row.verts.push(vx, g < p.wy ? p.wy : p.wy - 1.0, vz);
81
+ row.depths.push(p.wy - g); // ≤0 on dry verts → transparent + bank foam at the line
82
+ row.bWy.push(p.wy); row.bX.push(vx); row.bZ.push(vz);
83
+ row.uvs.push((side + 1) * 0.5, along * 0.1); // V runs down the WHOLE course — seamless across chunks
84
+ // downstream velocity (m/s), BAKED FROM THE HYDRAULICS instead of a universal
85
+ // constant (every reach of every course at the same speed — a pool as fast as a
86
+ // rapid). Slope sets the pace (drop is already per-metre, from the reach-baked wy
87
+ // three nodes downstream), width modulates it by continuity against the local ramp
88
+ // (wr above): the solved courses run ±25 % pool/riffle width, so a narrow riffle
89
+ // visibly quickens and a wide pool slows to a crawl.
90
+ const v = THREE.MathUtils.clamp(0.4 + 10 * drop, 0.3, 2.4) * (p.wr / p.ww);
91
+ row.flows.push(tx * v, tz * v);
92
+ row.drops.push(drop);
93
+ }
94
+ pushRow(cur, row);
95
+ if (along - chunkStart >= CHUNK_LEN && i < pts.length - 1) {
96
+ flush(cur);
97
+ cur = newChunk(); chunkStart = along;
98
+ pushRow(cur, row); // …and shared by both sides of the cut
99
+ }
100
+ }
101
+ flush(cur);
102
+ return meshes;
103
+ }
104
+
105
+ // A lake sheet: one plane per body at the survey level − drop, per-vertex aDepth bake, dry
106
+ // verts dived below the surface, per-vertex aRClip so ONE shared material keeps per-lake
107
+ // sprawl clip. The shared material is the game's (waterMaterial({ rClip: 'attribute' })).
108
+ export function buildLake(scene, Lk, mat, { heightAt, drop = 0.8 }) {
109
+ const rClip = Lk.rClip ?? Lk.r * 0.85;
110
+ const wr = rClip + 4;
111
+ const wseg = THREE.MathUtils.clamp(Math.round((wr * 2) / 1.8), 16, 64); // ~1.8m facets
112
+ const wgeo = new THREE.PlaneGeometry(wr * 2, wr * 2, wseg, wseg);
113
+ const waterY = Lk.level - drop;
114
+ const dp = wgeo.attributes.position, aDepth = new Float32Array(dp.count);
115
+ const aRClip = new Float32Array(dp.count).fill(rClip); // per-vertex so the shared shader keeps per-lake sprawl clip
116
+ for (let i = 0; i < dp.count; i++) {
117
+ const g = heightAt(Lk.x + dp.getX(i), Lk.z - dp.getY(i));
118
+ aDepth[i] = waterY - g;
119
+ if (g >= waterY) dp.setZ(i, -drop); // dry verts dive below the surface (plane local +z is world up)
120
+ }
121
+ wgeo.setAttribute('aDepth', new THREE.BufferAttribute(aDepth, 1));
122
+ wgeo.setAttribute('aRClip', new THREE.BufferAttribute(aRClip, 1));
123
+ const lake = new THREE.Mesh(wgeo, mat);
124
+ lake.rotation.x = -Math.PI / 2;
125
+ lake.position.set(Lk.x, waterY, Lk.z);
126
+ scene.add(lake);
127
+ return lake;
128
+ }
129
+
130
+ // The sea: a flat ocean plane at seaLevel, shown only where land dips below it past coastZ.
131
+ export function buildSea(scene, mat, { worldSize, seaLevel, coastZ, heightAt }) {
132
+ const SEG = 256;
133
+ const geo = new THREE.PlaneGeometry(worldSize, worldSize, SEG, SEG);
134
+ const pos = geo.attributes.position, aDepth = new Float32Array(pos.count);
135
+ for (let i = 0; i < pos.count; i++) {
136
+ const X = pos.getX(i), Z = -pos.getY(i);
137
+ aDepth[i] = Z > coastZ ? Math.max(0, seaLevel - heightAt(X, Z)) : 0;
138
+ }
139
+ geo.setAttribute('aDepth', new THREE.BufferAttribute(aDepth, 1));
140
+ const mesh = new THREE.Mesh(geo, mat);
141
+ mesh.rotation.x = -Math.PI / 2;
142
+ mesh.position.y = seaLevel;
143
+ mesh.frustumCulled = false;
144
+ scene.add(mesh);
145
+ return mesh;
146
+ }
147
+
148
+ // Rivers v2: re-derive every river vert's depth from the CURRENT terrain — pads that
149
+ // register during world.build mutate heightAt AFTER the ribbons bake, leaving stale aDepth.
150
+ // Called at the END of world.build; re-runnable whenever terrain changes.
151
+ export function refreshRiverDepths(waters, { heightAt }) {
152
+ let fixed = 0;
153
+ for (const m of waters ?? []) {
154
+ const rb = m.userData?.riverVerts;
155
+ if (!rb) continue;
156
+ const pos = m.geometry.attributes.position, aD = m.geometry.attributes.aDepth;
157
+ for (let i = 0; i < aD.count; i++) {
158
+ const g = heightAt(rb.x[i], rb.z[i]);
159
+ const d = rb.wy[i] - g;
160
+ if (Math.abs(d - aD.getX(i)) > 0.02) fixed++;
161
+ aD.setX(i, d);
162
+ pos.setY(i, g < rb.wy[i] ? rb.wy[i] : rb.wy[i] - 1.0);
163
+ }
164
+ aD.needsUpdate = true; pos.needsUpdate = true;
165
+ m.geometry.computeBoundingSphere();
166
+ }
167
+ if (fixed) console.log(`[rivers] depth refresh: ${fixed} verts re-derived after terrain pads`);
168
+ }