sindicate 0.13.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.
@@ -0,0 +1,270 @@
1
+ // ROAD LINK — the road BETWEEN two junctions.
2
+ //
3
+ // Junctions are fixed prefab pieces (instanced, never deformed). The road joining two of
4
+ // their sockets has to curve, climb and match each socket's width exactly, so it is
5
+ // generated: a cubic Bezier through both socket tangents, a smooth vertical profile, and
6
+ // the same cross-section the junction arms are built from (asphalt + edge lines + centre
7
+ // dashes on a palette atlas). This is how Kiwi joined its junctions too — its
8
+ // Generated/RoadMeshes tree is thousands of exactly these.
9
+ //
10
+ // const link = buildRoadLink({ from: socketA, to: socketB, heights: [0, 12, 30, 22, 38] })
11
+ // scene.add(new THREE.Mesh(link.geometry, roadMaterial))
12
+ //
13
+ // `from`/`to` are kit sockets: { pos:[x,y,z], dir:[dx,dz], width } with dir pointing OUT of
14
+ // its junction. The link leaves `from` along +dir and arrives at `to` against its +dir, so
15
+ // both ends meet their arms tangentially — no kink at the seam.
16
+ import * as THREE from 'three';
17
+
18
+ const PALETTE = { // texels in the Simpligon atlas (flat sampling, no tiling)
19
+ asphalt: [0.2871, 0.0361],
20
+ line: [0.2871, 0.1105],
21
+ };
22
+ const MARK_Y = 0.02; // paint sits this far above the asphalt
23
+ const EDGE_W = 0.1; // edge-line width
24
+ const DASH_W = 0.1; // centre-line width
25
+ const DASH_ON = 2.5, DASH_OFF = 2.5;
26
+
27
+ const catmull = (p, t) => {
28
+ // smooth interpolation through the given heights (duplicated ends = flat entry/exit)
29
+ const n = p.length;
30
+ if (n === 1) return p[0];
31
+ const x = t * (n - 1);
32
+ const i = Math.min(Math.floor(x), n - 2);
33
+ const f = x - i;
34
+ const p0 = p[Math.max(i - 1, 0)], p1 = p[i], p2 = p[i + 1], p3 = p[Math.min(i + 2, n - 1)];
35
+ return 0.5 * ((2 * p1) + (-p0 + p2) * f + (2 * p0 - 5 * p1 + 4 * p2 - p3) * f * f + (-p0 + 3 * p1 - 3 * p2 + p3) * f * f * f);
36
+ };
37
+
38
+ // A road may only climb so fast. Whatever profile is asked for, squeeze it into the envelope
39
+ // that is actually reachable from both ends at `g`, then relax the kinks out of it — the same
40
+ // idea as the rail solver's Lipschitz envelope + vertical curves, which is why Sindicate can
41
+ // do grades at all (Kiwi could only bench them into the ground).
42
+ function limitGrade(samples, gWanted, yStart, yEnd) {
43
+ const n = samples.length;
44
+ if (n < 3 || !(gWanted > 0)) return { grade: gWanted, feasible: true };
45
+ const s = samples.map((sm) => sm.s);
46
+ const L = s[n - 1] || 1;
47
+ // CONNECTING IS NON-NEGOTIABLE. If the two sockets simply cannot be joined at the wanted
48
+ // grade, the road still meets both ends and reports the grade it actually needed — a
49
+ // steep road is a fact the caller can act on; a road that stops in mid-air is a bug.
50
+ const needed = Math.abs(yEnd - yStart) / L;
51
+ const g = Math.max(gWanted, needed * 1.0001);
52
+ const y = samples.map((sm) => sm.p.y);
53
+ y[0] = yStart; y[n - 1] = yEnd;
54
+ for (let i = 1; i < n - 1; i++) {
55
+ const hi = Math.min(yStart + g * s[i], yEnd + g * (L - s[i]));
56
+ const lo = Math.max(yStart - g * s[i], yEnd - g * (L - s[i]));
57
+ y[i] = Math.min(Math.max(y[i], Math.min(lo, hi)), Math.max(lo, hi));
58
+ }
59
+ // vertical curves: average neighbours, re-clamping so the envelope still holds
60
+ for (let pass = 0; pass < 24; pass++) {
61
+ for (let i = 1; i < n - 1; i++) {
62
+ const avg = (y[i - 1] + y[i + 1]) / 2;
63
+ const step = y[i] + (avg - y[i]) * 0.5;
64
+ const hi = Math.min(yStart + g * s[i], yEnd + g * (L - s[i]));
65
+ const lo = Math.max(yStart - g * s[i], yEnd - g * (L - s[i]));
66
+ y[i] = Math.min(Math.max(step, Math.min(lo, hi)), Math.max(lo, hi));
67
+ }
68
+ }
69
+ // final safety: no single step may exceed the grade — interior points only, so the pass
70
+ // can never drag the far end off its socket
71
+ for (let i = 1; i < n - 1; i++) {
72
+ const ds = s[i] - s[i - 1] || 1e-3;
73
+ y[i] = Math.min(Math.max(y[i], y[i - 1] - g * ds), y[i - 1] + g * ds);
74
+ }
75
+ const dsLast = s[n - 1] - s[n - 2] || 1e-3;
76
+ y[n - 2] = Math.min(Math.max(y[n - 2], yEnd - g * dsLast), yEnd + g * dsLast);
77
+ y[n - 1] = yEnd;
78
+ for (let i = 0; i < n; i++) samples[i].p.y = y[i];
79
+ return { grade: g, feasible: g <= gWanted * 1.001 };
80
+ }
81
+
82
+ export function buildRoadLink({
83
+ from, to,
84
+ heights = null, // extra height control points between the two sockets
85
+ groundAt = null, // sample the terrain instead: the road follows the land it crosses
86
+ step = 3, // metres between cross-sections
87
+ handleScale = 0.42, // Bezier handle length as a fraction of the gap — how gentle the curve is
88
+ maxGrade = 0.08, // 8% — steep but drivable; motorways want ~0.06
89
+ skirt = 0.5, // vertical apron under the road edges (0 = paper-thin)
90
+ palette = PALETTE,
91
+ } = {}) {
92
+ const P0 = new THREE.Vector3(...from.pos);
93
+ const P3 = new THREE.Vector3(...to.pos);
94
+ const gap = P0.distanceTo(P3);
95
+ const k = gap * handleScale;
96
+ const P1 = P0.clone().add(new THREE.Vector3(from.dir[0], 0, from.dir[1]).multiplyScalar(k));
97
+ const P2 = P3.clone().add(new THREE.Vector3(to.dir[0], 0, to.dir[1]).multiplyScalar(k));
98
+
99
+ const at = (t) => {
100
+ const u = 1 - t;
101
+ return new THREE.Vector3()
102
+ .addScaledVector(P0, u * u * u).addScaledVector(P1, 3 * u * u * t)
103
+ .addScaledVector(P2, 3 * u * t * t).addScaledVector(P3, t * t * t);
104
+ };
105
+ // arc length first, so cross-sections are evenly spaced no matter how the handles bunch
106
+ const probe = 200;
107
+ const cum = [0];
108
+ let prev = at(0);
109
+ for (let i = 1; i <= probe; i++) { const p = at(i / probe); cum.push(cum[i - 1] + p.distanceTo(prev)); prev = p; }
110
+ const length = cum[probe];
111
+ const tAt = (s) => {
112
+ let i = 1;
113
+ while (i < probe && cum[i] < s) i++;
114
+ const span = cum[i] - cum[i - 1] || 1;
115
+ return (i - 1 + (s - cum[i - 1]) / span) / probe;
116
+ };
117
+
118
+ const rows = Math.max(2, Math.round(length / step) + 1);
119
+ const halfA = from.width / 2, halfB = to.width / 2;
120
+ // where the joined arms carry their edge lines — usually inset from the asphalt edge by
121
+ // the verge, so painting at the deck edge would kink the lines at the seam
122
+ const paintA = from.paint?.half ?? halfA - EDGE_W;
123
+ const paintB = to.paint?.half ?? halfB - EDGE_W;
124
+ const lineW = from.paint?.lineWidth ?? EDGE_W;
125
+ const pos = [], nrm = [], uv = [], idx = [];
126
+ const vert = (p, n, t) => { pos.push(p.x, p.y, p.z); nrm.push(n[0], n[1], n[2]); uv.push(t[0], t[1]); return pos.length / 3 - 1; };
127
+ const UP = [0, 1, 0];
128
+
129
+ // one cross-section anywhere along the road: position, right vector, half-width
130
+ const profile = heights?.length ? [from.pos[1], ...heights, to.pos[1]] : null;
131
+ const sampleAt = (s) => {
132
+ const f = Math.min(Math.max(s / length, 0), 1);
133
+ const t = tAt(s);
134
+ const p = at(t);
135
+ const fwd = at(Math.min(t + 0.001, 1)).sub(at(Math.max(t - 0.001, 0)));
136
+ fwd.y = 0; fwd.normalize();
137
+ // vertical profile: follow the land if we were given it, else the sockets' own heights
138
+ // plus any crests asked for between them. Either way limitGrade has the last word.
139
+ if (groundAt) p.y = groundAt(p.x, p.z);
140
+ else if (profile) p.y = catmull(profile, f);
141
+ return {
142
+ p, right: new THREE.Vector3(-fwd.z, 0, fwd.x),
143
+ half: halfA + (halfB - halfA) * f,
144
+ paintHalf: paintA + (paintB - paintA) * f,
145
+ s,
146
+ };
147
+ };
148
+ const samples = [];
149
+ for (let i = 0; i < rows; i++) samples.push(sampleAt((i / (rows - 1)) * length));
150
+ const gradeFit = limitGrade(samples, maxGrade, from.pos[1], to.pos[1]);
151
+ // EVERYTHING after this point must read the road from `samples`, never from sampleAt():
152
+ // the deck follows the grade-limited profile, and paint that re-sampled the raw curve
153
+ // would float off the ribbon wherever the limiter did its work.
154
+ const along = (s) => {
155
+ const f = Math.min(Math.max(s / length, 0), 1) * (rows - 1);
156
+ const i = Math.min(Math.floor(f), rows - 2);
157
+ const t = f - i;
158
+ const a = samples[i], b = samples[i + 1];
159
+ return {
160
+ p: a.p.clone().lerp(b.p, t),
161
+ right: a.right.clone().lerp(b.right, t).normalize(),
162
+ half: a.half + (b.half - a.half) * t,
163
+ paintHalf: a.paintHalf + (b.paintHalf - a.paintHalf) * t,
164
+ s,
165
+ };
166
+ };
167
+
168
+ // ── asphalt deck ──
169
+ const deck = [];
170
+ for (const { p, right, half } of samples) {
171
+ const l = p.clone().addScaledVector(right, -half);
172
+ const r = p.clone().addScaledVector(right, half);
173
+ deck.push([vert(l, UP, palette.asphalt), vert(r, UP, palette.asphalt)]);
174
+ }
175
+ for (let i = 0; i < deck.length - 1; i++) {
176
+ const [a, b] = deck[i], [c, d] = deck[i + 1];
177
+ idx.push(a, b, c, b, d, c);
178
+ }
179
+
180
+ // ── painted strips: a run of cross-sections at fixed lateral offsets ──
181
+ const paint = (rowsIn, offsetFn, texel) => {
182
+ const row = rowsIn.map((sm) => {
183
+ const [o0, o1] = offsetFn(sm);
184
+ const y = sm.p.clone(); y.y += MARK_Y;
185
+ return [
186
+ vert(y.clone().addScaledVector(sm.right, o0), UP, texel),
187
+ vert(y.clone().addScaledVector(sm.right, o1), UP, texel),
188
+ ];
189
+ });
190
+ for (let i = 0; i < row.length - 1; i++) {
191
+ const [a, b] = row[i], [c, d] = row[i + 1];
192
+ idx.push(a, b, c, b, d, c);
193
+ }
194
+ };
195
+ // A MOTORWAY carries its own measured paint — two carriageways, a median and however many
196
+ // lane divisions the piece was generated with — so a link between two motorway sockets is
197
+ // marked out exactly like the interchange it leaves. Anything else gets the plain
198
+ // two-way treatment: an edge line each side and a dashed middle.
199
+ const section = from.section ?? null;
200
+ if (section?.lines?.length) {
201
+ const lerpBands = (a, b) => (b?.length === a.length
202
+ ? a.map((band, i) => ({ at0: band.at, at1: b[i].at, width: Math.max(band.width, b[i].width, 0.12) }))
203
+ : a.map((band) => ({ at0: band.at, at1: band.at, width: Math.max(band.width, 0.12) })));
204
+ for (const b of lerpBands(section.lines, to.section?.lines)) {
205
+ paint(samples, (sm) => {
206
+ const at = b.at0 + (b.at1 - b.at0) * (sm.s / length);
207
+ return [at - b.width / 2, at + b.width / 2];
208
+ }, palette.line);
209
+ }
210
+ for (const b of lerpBands(section.dashes ?? [], to.section?.dashes)) {
211
+ for (let s = DASH_OFF / 2; s + DASH_ON < length; s += DASH_ON + DASH_OFF) {
212
+ const dash = [along(s), along(s + DASH_ON * 0.5), along(s + DASH_ON)];
213
+ paint(dash, (sm) => {
214
+ const at = b.at0 + (b.at1 - b.at0) * (sm.s / length);
215
+ return [at - b.width / 2, at + b.width / 2];
216
+ }, palette.line);
217
+ }
218
+ }
219
+ } else {
220
+ paint(samples, (sm) => [-sm.paintHalf, -sm.paintHalf + lineW], palette.line); // left edge line
221
+ paint(samples, (sm) => [sm.paintHalf - lineW, sm.paintHalf], palette.line); // right edge line
222
+ // centre dashes walk their own cycle so they stay crisp regardless of `step`
223
+ const centreOff = from.paint?.centre ?? 0;
224
+ for (let s = DASH_OFF / 2; s + DASH_ON < length; s += DASH_ON + DASH_OFF) {
225
+ const dash = [along(s), along(s + DASH_ON * 0.5), along(s + DASH_ON)];
226
+ paint(dash, () => [centreOff - DASH_W / 2, centreOff + DASH_W / 2], palette.line);
227
+ }
228
+ }
229
+
230
+ // ── side apron so an elevated road is not a floating sheet ──
231
+ if (skirt > 0) {
232
+ for (const side of [-1, 1]) {
233
+ const wall = [];
234
+ for (const { p, right, half } of samples) {
235
+ const top = p.clone().addScaledVector(right, side * half);
236
+ const bot = top.clone(); bot.y -= skirt;
237
+ const n = [right.x * side, 0, right.z * side];
238
+ wall.push([vert(top, n, palette.asphalt), vert(bot, n, palette.asphalt)]);
239
+ }
240
+ for (let i = 0; i < wall.length - 1; i++) {
241
+ const [a, b] = wall[i], [c, d] = wall[i + 1];
242
+ if (side > 0) idx.push(a, b, c, b, d, c);
243
+ else idx.push(a, c, b, b, c, d);
244
+ }
245
+ }
246
+ }
247
+
248
+ const geometry = new THREE.BufferGeometry();
249
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
250
+ geometry.setAttribute('normal', new THREE.Float32BufferAttribute(nrm, 3));
251
+ geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2));
252
+ geometry.setIndex(idx);
253
+ geometry.computeBoundingSphere();
254
+
255
+ // the centreline is what traffic, benching and the minimap all consume
256
+ const centreline = samples.map(({ p }) => [+p.x.toFixed(3), +p.y.toFixed(3), +p.z.toFixed(3)]);
257
+ const grades = [];
258
+ for (let i = 1; i < samples.length; i++) {
259
+ const dy = samples[i].p.y - samples[i - 1].p.y;
260
+ const dxz = Math.hypot(samples[i].p.x - samples[i - 1].p.x, samples[i].p.z - samples[i - 1].p.z) || 1;
261
+ grades.push(dy / dxz);
262
+ }
263
+ return {
264
+ geometry, centreline, length,
265
+ maxGrade: grades.length ? Math.max(...grades.map(Math.abs)) : 0,
266
+ // false when the two sockets could not be joined within the requested grade: the road
267
+ // still connects, but the caller should re-site, re-pair, or accept the climb
268
+ feasible: gradeFit?.feasible ?? true,
269
+ };
270
+ }
@@ -0,0 +1,118 @@
1
+ // ROAD NETWORK — turning a handful of places into a road system.
2
+ //
3
+ // The map is authored as SITES (towns, cities, interchanges) and the network between them is
4
+ // solved: which places are joined, what junction each one needs, and which way it faces.
5
+ //
6
+ // const net = solveNetwork({ sites, kit, pieces: { 4: '...', 3: '...' } });
7
+ // for (const node of net.nodes) place(node.piece, node); // junctions
8
+ // for (const edge of net.edges) buildRoadLink(edge.from, edge.to); // roads between them
9
+ //
10
+ // Kiwi resolved a junction from its link count and snapped the piece to 90°; the same idea
11
+ // generalises here — pick the piece whose arms best match the bearings a site actually needs,
12
+ // then rotate it to face them.
13
+
14
+ // Every site joined to the network at least once (a spanning tree), plus a few extra short
15
+ // links so the map has loops in it — a pure tree gives a road system with no alternate
16
+ // routes, which reads as a diagram rather than a country.
17
+ export function planEdges(sites, { extra = 0.35, maxLength = Infinity } = {}) {
18
+ const n = sites.length;
19
+ if (n < 2) return [];
20
+ const dist = (a, b) => Math.hypot(sites[a].x - sites[b].x, sites[a].z - sites[b].z);
21
+ const inTree = new Set([0]);
22
+ const edges = [];
23
+ while (inTree.size < n) {
24
+ let best = null;
25
+ for (const a of inTree) {
26
+ for (let b = 0; b < n; b++) {
27
+ if (inTree.has(b)) continue;
28
+ const d = dist(a, b);
29
+ if (!best || d < best.d) best = { a, b, d };
30
+ }
31
+ }
32
+ if (!best) break;
33
+ inTree.add(best.b);
34
+ edges.push({ a: best.a, b: best.b, length: best.d });
35
+ }
36
+ // candidate extras, shortest first, skipping pairs already joined
37
+ const have = new Set(edges.map((e) => `${Math.min(e.a, e.b)}-${Math.max(e.a, e.b)}`));
38
+ const cands = [];
39
+ for (let a = 0; a < n; a++) {
40
+ for (let b = a + 1; b < n; b++) {
41
+ const key = `${a}-${b}`;
42
+ if (have.has(key)) continue;
43
+ const d = dist(a, b);
44
+ if (d > maxLength) continue;
45
+ cands.push({ a, b, length: d });
46
+ }
47
+ }
48
+ cands.sort((p, q) => p.length - q.length);
49
+ for (const c of cands.slice(0, Math.round(edges.length * extra))) edges.push(c);
50
+ return edges;
51
+ }
52
+
53
+ const TAU = Math.PI * 2;
54
+ const wrap = (a) => ((a % TAU) + TAU) % TAU;
55
+ const angleGap = (a, b) => { const d = Math.abs(wrap(a) - wrap(b)); return Math.min(d, TAU - d); };
56
+
57
+ // How well a piece, turned by `yaw`, presents an arm to each bearing it must serve.
58
+ // Returns the total mismatch in radians, and which socket answers which bearing.
59
+ export function fitJunction(piece, bearings, yaw, { kinds = null } = {}) {
60
+ const sockets = (piece.sockets ?? []).filter((s) => !kinds || kinds.includes(s.kind ?? 'road'));
61
+ if (sockets.length < bearings.length) return null;
62
+ const used = new Set();
63
+ let cost = 0;
64
+ const assign = [];
65
+ for (const want of bearings) {
66
+ let best = null;
67
+ for (let i = 0; i < sockets.length; i++) {
68
+ if (used.has(i)) continue;
69
+ const have = Math.atan2(sockets[i].dir[0], sockets[i].dir[1]) + yaw;
70
+ const gap = angleGap(want, have);
71
+ if (!best || gap < best.gap) best = { i, gap };
72
+ }
73
+ if (!best) return null;
74
+ used.add(best.i);
75
+ cost += best.gap;
76
+ assign.push({ socket: sockets[best.i], bearing: want, gap: best.gap });
77
+ }
78
+ return { cost, assign };
79
+ }
80
+
81
+ // Choose the piece and rotation that best serves a site's bearings. `candidates` is a list of
82
+ // piece names; the kit supplies their sockets.
83
+ export function chooseJunction(kit, candidates, bearings, { steps = 72, kinds = null } = {}) {
84
+ let best = null;
85
+ for (const name of candidates) {
86
+ const piece = kit.piece ? kit.piece(name) : kit.pieces?.[name];
87
+ if (!piece) continue;
88
+ const arms = (piece.sockets ?? []).filter((s) => !kinds || kinds.includes(s.kind ?? 'road')).length;
89
+ if (arms < bearings.length) continue;
90
+ for (let k = 0; k < steps; k++) {
91
+ const yaw = (k / steps) * TAU;
92
+ const fit = fitJunction(piece, bearings, yaw, { kinds });
93
+ if (!fit) continue;
94
+ // prefer a piece with no arms left over — a crossroads standing in for a corner
95
+ // leaves two stubs pointing at nothing
96
+ const score = fit.cost + (arms - bearings.length) * 0.25;
97
+ if (!best || score < best.score) best = { name, piece, yaw, score, assign: fit.assign };
98
+ }
99
+ }
100
+ return best;
101
+ }
102
+
103
+ // The whole plan: which sites join to which, and what junction each site needs.
104
+ export function solveNetwork({ sites, kit, candidates, extra = 0.35, maxLength = Infinity, kinds = null }) {
105
+ const edges = planEdges(sites, { extra, maxLength });
106
+ const bearingsOf = (i) => edges
107
+ .filter((e) => e.a === i || e.b === i)
108
+ .map((e) => {
109
+ const o = sites[e.a === i ? e.b : e.a];
110
+ return Math.atan2(o.x - sites[i].x, o.z - sites[i].z);
111
+ });
112
+ const nodes = sites.map((s, i) => {
113
+ const bearings = bearingsOf(i);
114
+ const choice = chooseJunction(kit, candidates, bearings, { kinds });
115
+ return { ...s, index: i, degree: bearings.length, bearings, ...(choice ?? {}) };
116
+ });
117
+ return { nodes, edges };
118
+ }
@@ -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
+ }