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,377 @@
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
+ // Pads get a bucket grid too. A world has tens of junctions and the lattice asks this
118
+ // function ~1.7 million times for a cold cook: scanning every pad per call is 40 million
119
+ // distance tests nobody needs, and it showed up as a 25x cost over the bare terrain.
120
+ function padIndex(pads, cell) {
121
+ const buckets = new Map();
122
+ pads.forEach((p, id) => {
123
+ const reach = (p.halfX != null ? Math.hypot(p.halfX, p.halfZ) : (p.radius ?? 0))
124
+ + Math.max(p.fade ?? 0, p.maxEarthwork ?? 120);
125
+ const i0 = Math.floor((p.x - reach) / cell), i1 = Math.floor((p.x + reach) / cell);
126
+ const j0 = Math.floor((p.z - reach) / cell), j1 = Math.floor((p.z + reach) / cell);
127
+ for (let i = i0; i <= i1; i++) for (let j = j0; j <= j1; j++) {
128
+ const k = `${i},${j}`;
129
+ if (!buckets.has(k)) buckets.set(k, []);
130
+ buckets.get(k).push(id);
131
+ }
132
+ });
133
+ return (x, z) => buckets.get(`${Math.floor(x / cell)},${Math.floor(z / cell)}`) ?? null;
134
+ }
135
+
136
+ export function makeRoadTerrain(baseHeightAt, { pads = [], corridors = [], surfaces = [], cell = 32, clearance = 0.08 } = {}) {
137
+ const index = corridors.length ? segmentIndex(corridors, cell) : null;
138
+ const nearPads = pads.length ? padIndex(pads, cell) : null;
139
+
140
+ // WHERE THE DRIVEABLE SURFACE IS. The ground is deliberately cut a little below the road so
141
+ // the two do not fight for the same pixels, which means anything that drives — the player's
142
+ // car, traffic, a pedestrian on a bridge — must ask for the road, not the dirt.
143
+ // Returns null away from any road, so the caller falls back to the ground.
144
+ heightAt.road = function roadAt(x, z, preferY = null) {
145
+ const found = [];
146
+ // pads are driveable too: a junction's deck is where its own roads sit
147
+ for (const pid of nearPads?.(x, z) ?? []) {
148
+ const p = pads[pid];
149
+ if (padDistance(p, x, z) <= 0) found.push(p.y);
150
+ }
151
+ if (index) {
152
+ const ids = index.near(x, z);
153
+ for (const id of ids ?? []) {
154
+ const s = index.segs[id];
155
+ const dx = s.bx - s.ax, dz = s.bz - s.az;
156
+ const len2 = dx * dx + dz * dz || 1e-6;
157
+ const t = Math.min(1, Math.max(0, ((x - s.ax) * dx + (z - s.az) * dz) / len2));
158
+ if (Math.hypot(x - (s.ax + dx * t), z - (s.az + dz * t)) > s.half) continue;
159
+ found.push(s.ay + (s.by - s.ay) * t);
160
+ }
161
+ }
162
+ if (!found.length) return null;
163
+ // MULTI-LEVEL: where a bridge crosses a cutting, or a link overlaps an interchange's
164
+ // trench, several surfaces cover the same spot. Answer with the one nearest the height
165
+ // the caller is already at — you stay on the deck you are driving on, you do not fall
166
+ // through it onto the road below.
167
+ if (preferY == null) return Math.max(...found);
168
+ // 'low' / 'high' ask for a level outright. (A non-finite preference cannot do this by
169
+ // arithmetic: |y - -Infinity| is Infinity for EVERY candidate, so the nearest-wins
170
+ // comparison is always false and the first deck found is returned regardless.)
171
+ if (preferY === 'low' || preferY === -Infinity) return Math.min(...found);
172
+ if (preferY === 'high' || preferY === Infinity) return Math.max(...found);
173
+ return found.reduce((best, y) => (Math.abs(y - preferY) < Math.abs(best - preferY) ? y : best));
174
+ };
175
+
176
+ // JUST THE CARRIAGEWAYS — no pads. A pad is a levelled area a junction stands on, not a
177
+ // road deck: anything asking "is this ground hidden under tarmac?" must not be told yes
178
+ // for the whole footprint of an interchange, or it will remove ground you can plainly see.
179
+ // `shrink` narrows the corridor before answering. A corridor's bed is deliberately wider
180
+ // than its tarmac (the road needs level ground beside it), so anything using this to decide
181
+ // "is the ground hidden here?" must ask about the ROAD, not the bed — or it cuts away a
182
+ // strip of terrain along both edges that nothing is covering, leaving a gap at the kerb.
183
+ heightAt.deck = function deckAt(x, z, shrink = 0) {
184
+ if (!index) return null;
185
+ const ids = index.near(x, z);
186
+ if (!ids) return null;
187
+ let lowest = null;
188
+ for (const id of ids) {
189
+ const s = index.segs[id];
190
+ const dx = s.bx - s.ax, dz = s.bz - s.az;
191
+ const len2 = dx * dx + dz * dz || 1e-6;
192
+ const t = Math.min(1, Math.max(0, ((x - s.ax) * dx + (z - s.az) * dz) / len2));
193
+ if (Math.hypot(x - (s.ax + dx * t), z - (s.az + dz * t)) > s.half - shrink) continue;
194
+ const y = s.ay + (s.by - s.ay) * t;
195
+ if (lowest == null || y < lowest) lowest = y;
196
+ }
197
+ return lowest;
198
+ };
199
+
200
+ function heightAt(x, z) {
201
+ let h = baseHeightAt(x, z);
202
+
203
+ // ── junction pads: level, because a prefab junction cannot bend ──
204
+ for (const pid of nearPads?.(x, z) ?? []) {
205
+ const p = pads[pid];
206
+ const d = padDistance(p, x, z);
207
+ // sit the dirt a little under the deck, or the two surfaces fight for the same pixels
208
+ const y = p.y - (p.drop ?? 0);
209
+ if (d <= 0) { h = y; continue; } // inside the footprint: level
210
+ // A FADE, NOT A CUT. An embankment batter is right for a road crossing a valley and
211
+ // wrong for the ground around a junction: it leaves a visible rim, and the eye reads
212
+ // the whole pad as a platform someone dropped on the landscape. Kiwi's answer, and
213
+ // the right one, is to ease back to the ORIGINAL height over a generous distance —
214
+ // far enough that you cannot tell the ground was levelled at all.
215
+ const fade = p.fade ?? 0;
216
+ if (fade > 0) {
217
+ if (d >= fade) continue; // beyond the fade: natural ground
218
+ const t = smoothstep(0, 1, d / fade);
219
+ h = y + (h - y) * t;
220
+ } else {
221
+ const reach = d * (p.batter ?? 0.5); // the old earthwork law
222
+ h = Math.min(Math.max(h, y - reach), y + reach);
223
+ }
224
+ }
225
+
226
+ // ── piece decks: cut the plateau open wherever a deck runs BELOW it ──
227
+ // The pad above gives a junction the level ground it needs. This pass then digs out the
228
+ // parts that live under grade — the motorway through an overpass, the slip roads
229
+ // descending to it — so those are open to the sky and the deck above spans a real gap.
230
+ // It only ever lowers: raising here would make the ground follow every kerb and ramp,
231
+ // which turns a junction into a lumpy mess.
232
+ for (const r of surfaces) {
233
+ const hit = rasterAt(r, x, z);
234
+ if (!hit) continue;
235
+ const y = hit.y - r.drop;
236
+ // retaining-wall gradient out of the cut, so it stays tight to the road below
237
+ h = Math.min(h, y + hit.d * (r.wall ?? 3));
238
+ }
239
+
240
+ // ── link corridors: cut/fill the ground to the road's own profile ──
241
+ if (index) {
242
+ const ids = index.near(x, z);
243
+ if (ids) {
244
+ let best = null;
245
+ for (const id of ids) {
246
+ const s = index.segs[id];
247
+ const dx = s.bx - s.ax, dz = s.bz - s.az;
248
+ const len2 = dx * dx + dz * dz || 1e-6;
249
+ const t = Math.min(1, Math.max(0, ((x - s.ax) * dx + (z - s.az) * dz) / len2));
250
+ const px = s.ax + dx * t, pz = s.az + dz * t;
251
+ const d = Math.hypot(x - px, z - pz);
252
+ if (!best || d < best.d) best = { d, s, y: s.ay + (s.by - s.ay) * t };
253
+ }
254
+ if (best) {
255
+ const road = best.y - best.s.drop;
256
+ if (best.d <= best.s.half) h = road; // the bed itself
257
+ else {
258
+ // EMBANKMENT / CUTTING: outside the bed the ground may only climb away from the
259
+ // road at the batter slope (1:2 by default). Fill rises to meet a road above the
260
+ // valley, cutting falls away from a road below the hill, and natural ground
261
+ // further out is left alone — no fixed blend distance to get wrong.
262
+ const reach = (best.d - best.s.half) * best.s.batter;
263
+ h = Math.min(Math.max(h, road - reach), road + reach);
264
+ }
265
+ }
266
+ }
267
+ }
268
+
269
+ // THE GUARANTEE: ground never sits above a road. The clearance is a hair — just enough
270
+ // that the two surfaces do not fight for the same pixels. It was much larger while this
271
+ // clamp was silently broken, and that showed as a step along every road edge: the ground
272
+ // beside a kerb sitting half a metre below the tarmac it should meet. Corridor and pad bookkeeping alone
273
+ // cannot promise it — a terrain lattice is metres wide, so a vertex just outside a
274
+ // road's flat bed drags its triangle up across the carriageway and lays a wedge of dirt
275
+ // over the tarmac. Every consumer of this function (the mesh, the cook, collision,
276
+ // grass) gets the same promise, in every game, without having to know the rule exists.
277
+ const deck = heightAt.road(x, z, 'low');
278
+ if (deck != null && h > deck - clearance) h = deck - clearance;
279
+ return h;
280
+ }
281
+
282
+ return heightAt;
283
+ }
284
+
285
+ // The height a junction should sit at: the average ground across its footprint, so it is
286
+ // neither buried on one side nor stilted on the other. `spread` is how much the ground
287
+ // varies underneath — a site law: junctions go on the flats.
288
+ export function seatHeight(heightAt, x, z, radius, rings = 3, spokes = 8) {
289
+ // `radius` may be a footprint {halfX, halfZ, yaw}; sample its own extent in that case
290
+ if (typeof radius === 'object' && radius) {
291
+ const { halfX, halfZ, yaw = 0 } = radius;
292
+ const c = Math.cos(yaw), sn = Math.sin(yaw);
293
+ let sum = 0, n = 0, lo = Infinity, hi = -Infinity;
294
+ for (let i = -rings; i <= rings; i++) {
295
+ for (let j = -rings; j <= rings; j++) {
296
+ const lx = (i / rings) * halfX, lz = (j / rings) * halfZ;
297
+ const h = heightAt(x + lx * c + lz * sn, z - lx * sn + lz * c);
298
+ sum += h; n++;
299
+ if (h < lo) lo = h;
300
+ if (h > hi) hi = h;
301
+ }
302
+ }
303
+ return { y: sum / n, spread: hi - lo };
304
+ }
305
+ let sum = heightAt(x, z), n = 1, lo = Infinity, hi = -Infinity;
306
+ for (let r = 1; r <= rings; r++) {
307
+ const rad = (radius * r) / rings;
308
+ for (let a = 0; a < spokes; a++) {
309
+ const th = (a / spokes) * Math.PI * 2;
310
+ const h = heightAt(x + Math.cos(th) * rad, z + Math.sin(th) * rad);
311
+ sum += h; n++;
312
+ if (h < lo) lo = h;
313
+ if (h > hi) hi = h;
314
+ }
315
+ }
316
+ return { y: sum / n, spread: hi - lo };
317
+ }
318
+
319
+ // Where should a junction go? Sample the map, keep the flat spots, and take the pair that is
320
+ // both well separated and interestingly different in height — the "sites go on the flats"
321
+ // law from the road plan, applied automatically.
322
+ // Several sites at once, for a whole map: flat enough to stand a junction on, spread out
323
+ // enough to be separate places. Greedy farthest-point selection keeps them from clumping.
324
+ export function pickManySites(heightAt, { count = 5, half = 600, step = 90, radius = 60, minGap = 300, maxGrade = 0 } = {}) {
325
+ const cands = [];
326
+ for (let x = -half; x <= half; x += step) {
327
+ for (let z = -half; z <= half; z += step) {
328
+ const seat = seatHeight(heightAt, x, z, radius);
329
+ if (seat.spread > radius * 0.3) continue;
330
+ cands.push({ x, z, y: seat.y, spread: seat.spread });
331
+ }
332
+ }
333
+ if (!cands.length) return [];
334
+ cands.sort((a, b) => a.spread - b.spread); // flattest first
335
+ const chosen = [cands[0]];
336
+ for (const c of cands) {
337
+ if (chosen.length >= count) break;
338
+ const ok = chosen.every((s) => {
339
+ const gap = Math.hypot(s.x - c.x, s.z - c.z);
340
+ if (gap < minGap) return false;
341
+ // AND A ROAD MUST BE ABLE TO REACH IT. Picking the flattest spots regardless of each
342
+ // other's height cheerfully pairs a hilltop with a valley floor, and no road between
343
+ // them can be built at a sane grade — the link then quietly exceeds it.
344
+ if (maxGrade > 0 && Math.abs(s.y - c.y) > gap * maxGrade * 0.55) return false;
345
+ return true;
346
+ });
347
+ if (ok) chosen.push(c);
348
+ }
349
+ return chosen;
350
+ }
351
+
352
+ export function pickSites(heightAt, { half = 600, step = 90, radius = 60, minGap = 340, maxGap = 760, maxGrade = 0.07 } = {}) {
353
+ const cands = [];
354
+ for (let x = -half; x <= half; x += step) {
355
+ for (let z = -half; z <= half; z += step) {
356
+ const seat = seatHeight(heightAt, x, z, radius);
357
+ if (seat.spread > radius * 0.28) continue; // too lumpy to stand a junction on
358
+ cands.push({ x, z, y: seat.y, spread: seat.spread });
359
+ }
360
+ }
361
+ let best = null;
362
+ for (let i = 0; i < cands.length; i++) {
363
+ for (let j = i + 1; j < cands.length; j++) {
364
+ const a = cands[i], b = cands[j];
365
+ const gap = Math.hypot(a.x - b.x, a.z - b.z);
366
+ if (gap < minGap || gap > maxGap) continue;
367
+ // interesting relief, but only as much as a road can actually climb between them:
368
+ // pairing the highest peak with the deepest valley just guarantees an unbuildable link
369
+ const dy = Math.abs(a.y - b.y);
370
+ const climbable = gap * maxGrade * 0.55;
371
+ const relief = dy <= climbable ? dy : climbable - (dy - climbable) * 3;
372
+ const score = relief * 2 - (a.spread + b.spread) + gap * 0.02;
373
+ if (!best || score > best.score) best = { a, b, gap, score };
374
+ }
375
+ }
376
+ return best;
377
+ }
@@ -0,0 +1,200 @@
1
+ // ROADS AS A WORLD-BUILD STEP.
2
+ //
3
+ // The pieces exist — a kit to read, a network to solve, links to generate, earthworks to cut.
4
+ // This is the one call that runs them in the right order and hands back everything a world
5
+ // needs, so a game does not have to know that order (and cannot get it wrong):
6
+ //
7
+ // const roads = await buildRoadWorld({ sites, kit, candidates, ground, placePiece });
8
+ // const heightAt = roads.heightAt; // ← give THIS to buildHeightfield
9
+ // scene.add(...roads.meshes); // ← and stamp these BEFORE the BVH bake
10
+ // for (const m of roads.colliders) bvhSources.push(m);
11
+ //
12
+ // THE ORDER IS THE POINT, and it is not obvious:
13
+ // 1. site the junctions on flat ground — before anything is placed
14
+ // 2. seat each piece at ITS grade level — multi-level pieces are authored high
15
+ // 3. wrap the ground with the pads — so links can follow a padded surface
16
+ // 4. solve and generate the links — they read that padded ground
17
+ // 5. wrap again with pads AND corridors — the final heightAt the world uses
18
+ // Do 4 before 3 and links follow raw terrain into the sides of junctions; do 5 before 4 and
19
+ // there are no corridors to add yet.
20
+ //
21
+ // `placePiece(name, { x, y, z, yaw })` is the game's: it loads the assembly, adds it to the
22
+ // scene and returns `{ object, sockets, deckPoints }`. The engine never loads assets.
23
+ import { makeRoadTerrain, seatHeight, pickManySites } from './roadTerrain.js';
24
+ import { solveNetwork, planLoop, loopTangents } from './roadNetwork.js';
25
+ import { buildRoadLink, buildDeck } from './roadLink.js';
26
+ import { seatY, gradeLevel } from './roadKit.js';
27
+
28
+ const footprintOf = (piece, margin = 6) => ({
29
+ halfX: (piece.bbox.max[0] - piece.bbox.min[0]) / 2 + margin,
30
+ halfZ: (piece.bbox.max[2] - piece.bbox.min[2]) / 2 + margin,
31
+ cx: (piece.bbox.max[0] + piece.bbox.min[0]) / 2,
32
+ cz: (piece.bbox.max[2] + piece.bbox.min[2]) / 2,
33
+ });
34
+
35
+ export async function buildRoadWorld({
36
+ kit, // from makeRoadKit
37
+ ground, // the game's natural heightAt
38
+ placePiece, // (name, transform) => { object, sockets, deckPoints }
39
+ sites = null, // explicit sites, or let pickManySites choose
40
+ candidates = [], // junction piece names the solver may use
41
+ count = 6, // how many places, when choosing them
42
+ half = 600, minGap = 420, // where they may go
43
+ loop = false, // a closed circuit rather than a network
44
+ maxGrade = 0.07,
45
+ fade = 90, // how far a junction pad eases back into the landscape
46
+ drop = 0.12, // ground below a deck: a hair, so they do not z-fight
47
+ linkStep = 3,
48
+ handleScale = 0.4,
49
+ } = {}) {
50
+ const pieceOf = (n) => (kit.piece ? kit.piece(n) : kit.pieces?.[n]);
51
+ const pad = Math.max(...candidates.map((n) => pieceOf(n)?.size?.[0] ?? 100)) * 0.55;
52
+
53
+ // 1. WHERE THE PLACES GO — flat enough to stand a junction on, far enough apart to be
54
+ // separate places
55
+ const chosen = sites ?? pickManySites(ground, { count, half, step: 70, radius: pad, minGap, maxGrade });
56
+ if (chosen.length < 2) return { heightAt: ground, meshes: [], colliders: [], network: null, sites: chosen };
57
+
58
+ // 2. THE NETWORK — who joins whom, and what junction each place needs
59
+ const order = loop ? planLoop(chosen) : null;
60
+ const facing = loop ? loopTangents(chosen, order) : null;
61
+ const net = loop ? null : solveNetwork({ sites: chosen, kit, candidates, kinds: ['road'] });
62
+
63
+ // 3. PLACE EACH JUNCTION, seated at its own grade level
64
+ const placed = new Map();
65
+ const pads = [];
66
+ const placeOne = async (index, name, yaw) => {
67
+ const piece = pieceOf(name);
68
+ if (!piece) return;
69
+ const st = chosen[index];
70
+ const p = await placePiece(name, { x: st.x, y: seatY(piece, st.y), z: st.z, yaw });
71
+ if (!p) return;
72
+ const foot = footprintOf(piece);
73
+ const c = Math.cos(yaw), s = Math.sin(yaw);
74
+ pads.push({
75
+ x: st.x + foot.cx * c + foot.cz * s, z: st.z - foot.cx * s + foot.cz * c,
76
+ y: st.y, yaw, halfX: foot.halfX, halfZ: foot.halfZ, drop, fade,
77
+ });
78
+ placed.set(index, { ...p, site: st, yaw, name, piece });
79
+ };
80
+ if (loop) for (const { index, yaw } of facing) await placeOne(index, candidates[0], yaw);
81
+ else for (const node of net.nodes) if (node.name) await placeOne(node.index, node.name, node.yaw);
82
+
83
+ // 4. THE LINKS — generated over ground that already knows about the pads, so a road
84
+ // approaches a junction over its own levelled apron rather than raw hillside
85
+ const padded = makeRoadTerrain(ground, { pads });
86
+ const kinds = loop ? ['motorway'] : ['road'];
87
+ const facingSocket = (p, tx, tz) => (p.sockets ?? [])
88
+ .filter((s) => kinds.includes(s.kind ?? 'road'))
89
+ .reduce((best, s) => {
90
+ const v = [tx - s.pos[0], tz - s.pos[2]];
91
+ const len = Math.hypot(...v) || 1;
92
+ const sc = (s.dir[0] * v[0] + s.dir[1] * v[1]) / len;
93
+ return !best || sc > best.sc ? { s, sc } : best;
94
+ }, null)?.s ?? null;
95
+
96
+ // IN A LOOP THE TWO ENDS ARE ASSIGNED, not chosen independently. A through-piece has
97
+ // exactly two motorway sockets and exactly two neighbours; picking the best-facing socket
98
+ // for each neighbour separately lets both pick the SAME end on a turn, and the second road
99
+ // is then dropped for want of an arm — a circuit with holes in it.
100
+ const assigned = new Map();
101
+ if (loop) {
102
+ for (let k = 0; k < order.length; k++) {
103
+ const me = placed.get(order[k]);
104
+ if (!me) continue;
105
+ const prev = placed.get(order[(k - 1 + order.length) % order.length]);
106
+ const next = placed.get(order[(k + 1) % order.length]);
107
+ const mw = (me.sockets ?? []).filter((sk) => sk.kind === 'motorway');
108
+ if (mw.length < 2 || !prev || !next) continue;
109
+ const toward = (sk, t) => {
110
+ const v = [t.site.x - sk.pos[0], t.site.z - sk.pos[2]];
111
+ const len = Math.hypot(...v) || 1;
112
+ return (sk.dir[0] * v[0] + sk.dir[1] * v[1]) / len;
113
+ };
114
+ // take whichever pairing serves both neighbours better overall
115
+ const straight = toward(mw[0], prev) + toward(mw[1], next);
116
+ const swapped = toward(mw[1], prev) + toward(mw[0], next);
117
+ assigned.set(order[k], straight >= swapped
118
+ ? { prev: mw[0], next: mw[1] }
119
+ : { prev: mw[1], next: mw[0] });
120
+ }
121
+ }
122
+
123
+ const links = [];
124
+ const pairs = loop
125
+ ? order.map((a, k) => [a, order[(k + 1) % order.length]])
126
+ : net.edges.map((e) => [e.a, e.b]);
127
+ const used = new Set();
128
+ for (const [ia, ib] of pairs) {
129
+ const A = placed.get(ia), B = placed.get(ib);
130
+ if (!A || !B) continue;
131
+ const from = assigned.get(ia)?.next ?? facingSocket(A, B.site.x, B.site.z);
132
+ const to = assigned.get(ib)?.prev ?? facingSocket(B, A.site.x, A.site.z);
133
+ if (!from || !to) continue;
134
+ const ka = `${ia}:${from.id}`, kb = `${ib}:${to.id}`;
135
+ if (used.has(ka) || used.has(kb)) continue; // one road per arm
136
+ used.add(ka); used.add(kb);
137
+ links.push({
138
+ a: ia, b: ib, from, to,
139
+ link: buildRoadLink({
140
+ from, to, groundAt: (x, z) => padded(x, z) + 0.15,
141
+ handleScale, step: linkStep, maxGrade,
142
+ }),
143
+ });
144
+ }
145
+
146
+ // 5. THE EARTHWORKS the world actually uses: pads AND the corridors those links carry,
147
+ // plus a trench for anything a piece keeps below its own grade
148
+ const corridors = links.map(({ link, from }) => ({
149
+ centreline: link.centreline, halfWidth: from.width / 2 + 5, drop, batter: 0.5,
150
+ }));
151
+ for (const [, p] of placed) {
152
+ const mw = (p.sockets ?? []).filter((s) => s.kind === 'motorway');
153
+ if (mw.length < 2 || !p.deckPoints?.length) continue;
154
+ const a = mw[0].pos, b = mw[1].pos;
155
+ const ax = [b[0] - a[0], b[2] - a[2]];
156
+ const al = Math.hypot(...ax) || 1;
157
+ ax[0] /= al; ax[1] /= al;
158
+ const perp = [-ax[1], ax[0]];
159
+ const grade = p.site.y;
160
+ let wide = 0, lowest = Infinity;
161
+ for (const q of p.deckPoints) {
162
+ if (q[1] > grade - 1.5) continue;
163
+ const lat = Math.abs((q[0] - p.site.x) * perp[0] + (q[2] - p.site.z) * perp[1]);
164
+ if (lat > wide) wide = lat;
165
+ if (q[1] < lowest) lowest = q[1];
166
+ }
167
+ if (!(wide > 0)) continue;
168
+ // KEEP THE TRENCH TIGHT TO THE CARRIAGEWAY. Sizing it from everything below grade lets
169
+ // the slip roads — which sweep far out to either side as they climb — widen the cut until
170
+ // it swallows the whole junction, dropping the entire pad to motorway level and leaving
171
+ // every piece standing 10 m above its own ground.
172
+ const bed = Math.min(wide, Math.max(mw[0].width, mw[1].width) / 2 + 18) + 5;
173
+ corridors.push({
174
+ centreline: [
175
+ [a[0] - ax[0] * 30, Math.min(a[1], lowest), a[2] - ax[1] * 30],
176
+ [b[0] + ax[0] * 30, Math.min(b[1], lowest), b[2] + ax[1] * 30],
177
+ ],
178
+ halfWidth: bed, drop, batter: 4,
179
+ });
180
+ }
181
+
182
+ return {
183
+ heightAt: makeRoadTerrain(ground, { pads, corridors }),
184
+ links,
185
+ pads,
186
+ corridors,
187
+ placed,
188
+ sites: chosen,
189
+ network: net,
190
+ order,
191
+ // the median strip through each junction: its two carriageways are separate meshes, and
192
+ // the ground would otherwise be visible dropping away between them
193
+ medianDecks: [...placed.values()].flatMap((p) => {
194
+ const mw = (p.sockets ?? []).filter((s) => s.kind === 'motorway');
195
+ if (mw.length < 2) return [];
196
+ const inner = (mw[0].section?.lines ?? []).map((b) => Math.abs(b.at)).sort((x, y) => x - y)[0] ?? 1.1;
197
+ return [buildDeck({ from: mw[0].pos, to: mw[1].pos, width: inner * 2 + 0.6 })];
198
+ }),
199
+ };
200
+ }
@@ -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
+ }