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,110 @@
1
+ // ROAD KIT — reading the measured kit a pack ships (roadkit.json from tools/roadKitAnalyze).
2
+ //
3
+ // The kit stores geometry only. Which side of the road traffic drives on is a RUNTIME
4
+ // choice: a game may ship left-hand-drive, a designer may flip a map mid-edit to see how it
5
+ // reads, and neither should require re-analysing a single mesh. So every lane records the
6
+ // SIDE of the centre line it sits on ('L' or 'R', looking outward along its socket) and the
7
+ // direction of travel is derived here.
8
+ //
9
+ // const kit = await loadRoadKit('/assets/packs/KiwiRoads');
10
+ // kit.driveOnLeft = false; // flip the whole world, instantly
11
+ // for (const lane of kit.lanesOf(socket)) console.log(lane.flow); // 'out' | 'in'
12
+
13
+ export const laneFlow = (side, driveOnLeft) => ((side === 'L') === !!driveOnLeft ? 'out' : 'in');
14
+
15
+ export function makeRoadKit(data, { driveOnLeft = data.driveOnLeft ?? true } = {}) {
16
+ const kit = {
17
+ pieces: data.pieces ?? {},
18
+ laneWidth: data.laneWidth ?? 5,
19
+ driveOnLeft,
20
+ piece(name) { return kit.pieces[name] ?? null; },
21
+ // lanes with their direction of travel resolved against the current hand of the road
22
+ lanesOf(socket) {
23
+ return (socket?.lanes ?? []).map((l) => ({ ...l, flow: laneFlow(l.side, kit.driveOnLeft) }));
24
+ },
25
+ // the lanes leaving / arriving at a socket, in kerb-to-median order
26
+ outboundOf(socket) { return kit.lanesOf(socket).filter((l) => l.flow === 'out'); },
27
+ inboundOf(socket) { return kit.lanesOf(socket).filter((l) => l.flow === 'in'); },
28
+ // sockets a given socket may legally join: same kind, compatible width
29
+ compatible(a, b, tol = 0.25) {
30
+ if (!a || !b) return false;
31
+ if ((a.kind ?? 'road') !== (b.kind ?? 'road')) return false;
32
+ return Math.abs(a.width - b.width) <= Math.max(a.width, b.width) * tol;
33
+ },
34
+ // every socket in the kit of a given kind, as { piece, socket } pairs
35
+ socketsOfKind(kind = 'road') {
36
+ const out = [];
37
+ for (const [name, p] of Object.entries(kit.pieces)) {
38
+ for (const s of p.sockets ?? []) if ((s.kind ?? 'road') === kind) out.push({ piece: name, socket: s });
39
+ }
40
+ return out;
41
+ },
42
+ };
43
+ return kit;
44
+ }
45
+
46
+ export async function loadRoadKit(packDir, opts) {
47
+ const data = await fetch(`${packDir}/roadkit.json`, { cache: 'no-store' }).then((r) => r.json());
48
+ return makeRoadKit(data, opts);
49
+ }
50
+
51
+ // WHICH LEVEL OF A PIECE MEETS THE GROUND.
52
+ //
53
+ // Multi-level pieces are authored around their own origin, not around grade: an overpass
54
+ // interchange carries its local roads 10 m above origin and its motorway at origin; a
55
+ // dumbbell carries the motorway 20 m up. Seat either by its origin and the parts that should
56
+ // be at ground level hang in the air. Kiwi's answer was to sink the junction until the roads
57
+ // that meet the outside world are at grade — so the level to seat by is the level of the
58
+ // LOCAL road sockets when a piece has them, and the motorway's otherwise.
59
+ export function gradeLevel(piece) {
60
+ const sockets = piece?.sockets ?? [];
61
+ if (!sockets.length) return 0;
62
+ const local = sockets.filter((s) => (s.kind ?? 'road') === 'road');
63
+ const use = local.length ? local : sockets;
64
+ return use.reduce((a, s) => a + s.pos[1], 0) / use.length;
65
+ }
66
+
67
+ // The y to place a piece at so its grade level lands on the given ground height.
68
+ export const seatY = (piece, groundY) => groundY - gradeLevel(piece);
69
+
70
+ // Any part of a piece that sits BELOW its grade level needs a trench: the motorway running
71
+ // under an overpass roundabout, for instance. Returns corridors (in the piece's own frame)
72
+ // that carve it, ready to be transformed with the piece.
73
+ export function cuttings(piece, { margin = 3 } = {}) {
74
+ const grade = gradeLevel(piece);
75
+ const low = (piece?.sockets ?? []).filter((s) => s.pos[1] < grade - 1.5);
76
+ const out = [];
77
+ // pair opposite sockets: a motorway runs through, so its two ends define the alignment
78
+ for (let i = 0; i < low.length; i++) {
79
+ for (let j = i + 1; j < low.length; j++) {
80
+ const a = low[i], b = low[j];
81
+ if (a.dir[0] * b.dir[0] + a.dir[1] * b.dir[1] > -0.8) continue; // not opposite ends
82
+ out.push({
83
+ from: a.pos, to: b.pos,
84
+ halfWidth: Math.max(a.width, b.width) / 2 + margin,
85
+ });
86
+ }
87
+ }
88
+ return out;
89
+ }
90
+
91
+ // Place a piece in the world and get its sockets in world space. Junction pieces are rigid:
92
+ // they translate and yaw, never bend, so this is the whole transform.
93
+ export function placeSockets(piece, { x = 0, y = 0, z = 0, yaw = 0 } = {}) {
94
+ const cos = Math.cos(yaw), sin = Math.sin(yaw);
95
+ const xf = (p) => [x + p[0] * cos + p[2] * sin, y + p[1], z - p[0] * sin + p[2] * cos];
96
+ return (piece?.sockets ?? []).map((s) => ({
97
+ ...s,
98
+ pos: xf(s.pos),
99
+ dir: [s.dir[0] * cos + s.dir[1] * sin, -s.dir[0] * sin + s.dir[1] * cos],
100
+ lanes: (s.lanes ?? []).map((l) => ({ ...l, centre: xf(l.centre) })),
101
+ }));
102
+ }
103
+
104
+ // The yaw that turns a piece's socket to face a target point — how a junction is aimed at
105
+ // the thing it is about to be connected to.
106
+ export function yawToFace(socket, tx, tz, { x = 0, z = 0 } = {}) {
107
+ const want = Math.atan2(tx - x, tz - z);
108
+ const have = Math.atan2(socket.dir[0], socket.dir[1]);
109
+ return want - have;
110
+ }
@@ -0,0 +1,343 @@
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
+ // A PLAIN DECK between two points — no markings, no rails. A junction's two carriageways are
83
+ // separate meshes with a gap down the middle, so the ground shows through under the central
84
+ // reservation and drops away beneath the concrete. A generated link has no such gap because
85
+ // it is one deck across its whole width; this closes the same gap inside a junction.
86
+ export function buildDeck({ from, to, width, y = null, palette = PALETTE }) {
87
+ const ax = from[0], az = from[2], bx = to[0], bz = to[2];
88
+ const dx = bx - ax, dz = bz - az;
89
+ const len = Math.hypot(dx, dz) || 1;
90
+ const rx = -dz / len * (width / 2), rz = dx / len * (width / 2);
91
+ const ay = y ?? from[1], by = y ?? to[1];
92
+ const pos = new Float32Array([
93
+ ax - rx, ay, az - rz, ax + rx, ay, az + rz,
94
+ bx - rx, by, bz - rz, bx + rx, by, bz + rz,
95
+ ]);
96
+ const nrm = new Float32Array([0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0]);
97
+ const uv = new Float32Array([
98
+ palette.asphalt[0], palette.asphalt[1], palette.asphalt[0], palette.asphalt[1],
99
+ palette.asphalt[0], palette.asphalt[1], palette.asphalt[0], palette.asphalt[1],
100
+ ]);
101
+ const g = new THREE.BufferGeometry();
102
+ g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
103
+ g.setAttribute('normal', new THREE.BufferAttribute(nrm, 3));
104
+ g.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
105
+ g.setIndex([0, 1, 2, 1, 3, 2]);
106
+ g.computeBoundingSphere();
107
+ return g;
108
+ }
109
+
110
+ export function buildRoadLink({
111
+ from, to,
112
+ heights = null, // extra height control points between the two sockets
113
+ groundAt = null, // sample the terrain instead: the road follows the land it crosses
114
+ step = 3, // metres between cross-sections
115
+ handleScale = 0.42, // Bezier handle length as a fraction of the gap — how gentle the curve is
116
+ maxGrade = 0.08, // 8% — steep but drivable; motorways want ~0.06
117
+ skirt = 0.5, // vertical apron under the road edges (0 = paper-thin)
118
+ railStep = 5, // spacing of the shoulder rail sections
119
+ medianStep = 2, // spacing of the median's blocks
120
+ railInset = 0.55, // how far inside the deck edge the shoulder rail stands
121
+ palette = PALETTE,
122
+ } = {}) {
123
+ const P0 = new THREE.Vector3(...from.pos);
124
+ const P3 = new THREE.Vector3(...to.pos);
125
+ const gap = P0.distanceTo(P3);
126
+ const k = gap * handleScale;
127
+ const P1 = P0.clone().add(new THREE.Vector3(from.dir[0], 0, from.dir[1]).multiplyScalar(k));
128
+ const P2 = P3.clone().add(new THREE.Vector3(to.dir[0], 0, to.dir[1]).multiplyScalar(k));
129
+
130
+ const at = (t) => {
131
+ const u = 1 - t;
132
+ return new THREE.Vector3()
133
+ .addScaledVector(P0, u * u * u).addScaledVector(P1, 3 * u * u * t)
134
+ .addScaledVector(P2, 3 * u * t * t).addScaledVector(P3, t * t * t);
135
+ };
136
+ // arc length first, so cross-sections are evenly spaced no matter how the handles bunch
137
+ const probe = 200;
138
+ const cum = [0];
139
+ let prev = at(0);
140
+ for (let i = 1; i <= probe; i++) { const p = at(i / probe); cum.push(cum[i - 1] + p.distanceTo(prev)); prev = p; }
141
+ const length = cum[probe];
142
+ const tAt = (s) => {
143
+ let i = 1;
144
+ while (i < probe && cum[i] < s) i++;
145
+ const span = cum[i] - cum[i - 1] || 1;
146
+ return (i - 1 + (s - cum[i - 1]) / span) / probe;
147
+ };
148
+
149
+ const rows = Math.max(2, Math.round(length / step) + 1);
150
+ const halfA = from.width / 2, halfB = to.width / 2;
151
+ // where the joined arms carry their edge lines — usually inset from the asphalt edge by
152
+ // the verge, so painting at the deck edge would kink the lines at the seam
153
+ const paintA = from.paint?.half ?? halfA - EDGE_W;
154
+ const paintB = to.paint?.half ?? halfB - EDGE_W;
155
+ const lineW = from.paint?.lineWidth ?? EDGE_W;
156
+ const pos = [], nrm = [], uv = [], idx = [];
157
+ 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; };
158
+ const UP = [0, 1, 0];
159
+
160
+ // one cross-section anywhere along the road: position, right vector, half-width
161
+ const profile = heights?.length ? [from.pos[1], ...heights, to.pos[1]] : null;
162
+ const sampleAt = (s) => {
163
+ const f = Math.min(Math.max(s / length, 0), 1);
164
+ const t = tAt(s);
165
+ const p = at(t);
166
+ const fwd = at(Math.min(t + 0.001, 1)).sub(at(Math.max(t - 0.001, 0)));
167
+ fwd.y = 0; fwd.normalize();
168
+ // vertical profile: follow the land if we were given it, else the sockets' own heights
169
+ // plus any crests asked for between them. Either way limitGrade has the last word.
170
+ if (groundAt) p.y = groundAt(p.x, p.z);
171
+ else if (profile) p.y = catmull(profile, f);
172
+ return {
173
+ p, right: new THREE.Vector3(-fwd.z, 0, fwd.x),
174
+ half: halfA + (halfB - halfA) * f,
175
+ paintHalf: paintA + (paintB - paintA) * f,
176
+ s,
177
+ };
178
+ };
179
+ const samples = [];
180
+ for (let i = 0; i < rows; i++) samples.push(sampleAt((i / (rows - 1)) * length));
181
+ const gradeFit = limitGrade(samples, maxGrade, from.pos[1], to.pos[1]);
182
+ // EVERYTHING after this point must read the road from `samples`, never from sampleAt():
183
+ // the deck follows the grade-limited profile, and paint that re-sampled the raw curve
184
+ // would float off the ribbon wherever the limiter did its work.
185
+ const along = (s) => {
186
+ const f = Math.min(Math.max(s / length, 0), 1) * (rows - 1);
187
+ const i = Math.min(Math.floor(f), rows - 2);
188
+ const t = f - i;
189
+ const a = samples[i], b = samples[i + 1];
190
+ return {
191
+ p: a.p.clone().lerp(b.p, t),
192
+ right: a.right.clone().lerp(b.right, t).normalize(),
193
+ half: a.half + (b.half - a.half) * t,
194
+ paintHalf: a.paintHalf + (b.paintHalf - a.paintHalf) * t,
195
+ s,
196
+ };
197
+ };
198
+
199
+ // ── asphalt deck ──
200
+ const deck = [];
201
+ for (const { p, right, half } of samples) {
202
+ const l = p.clone().addScaledVector(right, -half);
203
+ const r = p.clone().addScaledVector(right, half);
204
+ deck.push([vert(l, UP, palette.asphalt), vert(r, UP, palette.asphalt)]);
205
+ }
206
+ for (let i = 0; i < deck.length - 1; i++) {
207
+ const [a, b] = deck[i], [c, d] = deck[i + 1];
208
+ idx.push(a, b, c, b, d, c);
209
+ }
210
+
211
+ // ── painted strips: a run of cross-sections at fixed lateral offsets ──
212
+ const paint = (rowsIn, offsetFn, texel) => {
213
+ const row = rowsIn.map((sm) => {
214
+ const [o0, o1] = offsetFn(sm);
215
+ const y = sm.p.clone(); y.y += MARK_Y;
216
+ return [
217
+ vert(y.clone().addScaledVector(sm.right, o0), UP, texel),
218
+ vert(y.clone().addScaledVector(sm.right, o1), UP, texel),
219
+ ];
220
+ });
221
+ for (let i = 0; i < row.length - 1; i++) {
222
+ const [a, b] = row[i], [c, d] = row[i + 1];
223
+ idx.push(a, b, c, b, d, c);
224
+ }
225
+ };
226
+ // A MOTORWAY carries its own measured paint — two carriageways, a median and however many
227
+ // lane divisions the piece was generated with — so a link between two motorway sockets is
228
+ // marked out exactly like the interchange it leaves. Anything else gets the plain
229
+ // two-way treatment: an edge line each side and a dashed middle.
230
+ const section = from.section ?? null;
231
+ if (section?.lines?.length) {
232
+ const lerpBands = (a, b) => (b?.length === a.length
233
+ ? a.map((band, i) => ({ at0: band.at, at1: b[i].at, width: Math.max(band.width, b[i].width, 0.12) }))
234
+ : a.map((band) => ({ at0: band.at, at1: band.at, width: Math.max(band.width, 0.12) })));
235
+ for (const b of lerpBands(section.lines, to.section?.lines)) {
236
+ paint(samples, (sm) => {
237
+ const at = b.at0 + (b.at1 - b.at0) * (sm.s / length);
238
+ return [at - b.width / 2, at + b.width / 2];
239
+ }, palette.line);
240
+ }
241
+ for (const b of lerpBands(section.dashes ?? [], to.section?.dashes)) {
242
+ for (let s = DASH_OFF / 2; s + DASH_ON < length; s += DASH_ON + DASH_OFF) {
243
+ const dash = [along(s), along(s + DASH_ON * 0.5), along(s + DASH_ON)];
244
+ paint(dash, (sm) => {
245
+ const at = b.at0 + (b.at1 - b.at0) * (sm.s / length);
246
+ return [at - b.width / 2, at + b.width / 2];
247
+ }, palette.line);
248
+ }
249
+ }
250
+ } else {
251
+ paint(samples, (sm) => [-sm.paintHalf, -sm.paintHalf + lineW], palette.line); // left edge line
252
+ paint(samples, (sm) => [sm.paintHalf - lineW, sm.paintHalf], palette.line); // right edge line
253
+ // centre dashes walk their own cycle so they stay crisp regardless of `step`
254
+ const centreOff = from.paint?.centre ?? 0;
255
+ for (let s = DASH_OFF / 2; s + DASH_ON < length; s += DASH_ON + DASH_OFF) {
256
+ const dash = [along(s), along(s + DASH_ON * 0.5), along(s + DASH_ON)];
257
+ paint(dash, () => [centreOff - DASH_W / 2, centreOff + DASH_W / 2], palette.line);
258
+ }
259
+ }
260
+
261
+ // ── side apron so an elevated road is not a floating sheet ──
262
+ if (skirt > 0) {
263
+ for (const side of [-1, 1]) {
264
+ const wall = [];
265
+ for (const { p, right, half } of samples) {
266
+ const top = p.clone().addScaledVector(right, side * half);
267
+ const bot = top.clone(); bot.y -= skirt;
268
+ const n = [right.x * side, 0, right.z * side];
269
+ wall.push([vert(top, n, palette.asphalt), vert(bot, n, palette.asphalt)]);
270
+ }
271
+ for (let i = 0; i < wall.length - 1; i++) {
272
+ const [a, b] = wall[i], [c, d] = wall[i + 1];
273
+ if (side > 0) idx.push(a, b, c, b, d, c);
274
+ else idx.push(a, c, b, b, c, d);
275
+ }
276
+ }
277
+ }
278
+
279
+ const geometry = new THREE.BufferGeometry();
280
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
281
+ geometry.setAttribute('normal', new THREE.Float32BufferAttribute(nrm, 3));
282
+ geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2));
283
+ geometry.setIndex(idx);
284
+ geometry.computeBoundingSphere();
285
+
286
+ // WHERE THE BARRIERS GO. A motorway's median is the gap between its two innermost solid
287
+ // lines; its shoulders are outside the outermost. Returned as posts a game can instance
288
+ // its own guardrail along — the generator lays road, not other people's props.
289
+ const railings = { median: [], left: [], right: [] };
290
+ if (section?.lines?.length >= 4) {
291
+ const byInner = section.lines.map((b) => b.at).sort((a, b) => Math.abs(a) - Math.abs(b));
292
+ const medianHalf = Math.abs(byInner[0]);
293
+ const outer = Math.max(...section.lines.map((b) => Math.abs(b.at)));
294
+ // each line is sampled at ITS OWN prop's length — a concrete median block is not a
295
+ // steel rail section, and posting them at the same spacing leaves gaps or overlaps
296
+ const lay = (into, spacing, offsetOf) => {
297
+ const out = [];
298
+ for (let s = spacing / 2; s <= length - spacing / 2; s += spacing) {
299
+ const sm = along(s);
300
+ const off = offsetOf(sm);
301
+ // PITCH. A rail section is a rigid bar: leave it level on a graded road and each
302
+ // one steps down from the last, which reads as a gap at every joint. Tilt it to the
303
+ // local slope and consecutive sections meet end to end.
304
+ const back = along(Math.max(s - spacing / 2, 0));
305
+ const fore = along(Math.min(s + spacing / 2, length));
306
+ const rise = fore.p.y - back.p.y;
307
+ const run = Math.hypot(fore.p.x - back.p.x, fore.p.z - back.p.z) || 1e-3;
308
+ out.push({
309
+ pos: [
310
+ +(sm.p.x + sm.right.x * off).toFixed(3), +sm.p.y.toFixed(3),
311
+ +(sm.p.z + sm.right.z * off).toFixed(3),
312
+ ],
313
+ // a prop modelled along its own X lies along the road at this yaw
314
+ yaw: Math.atan2(sm.right.x, sm.right.z) + (into ? Math.PI : 0),
315
+ pitch: +(Math.atan2(rise, run) * (into ? -1 : 1)).toFixed(5),
316
+ });
317
+ }
318
+ return out;
319
+ };
320
+ if (medianHalf > 0.5) railings.median = lay(false, medianStep, () => 0);
321
+ // ON THE SHOULDER, not off it. A rail set beyond the deck edge stands in the grass and
322
+ // wanders off the road wherever the carriageway narrows; it belongs on the grey strip
323
+ // outside the outer line, which is still road.
324
+ railings.left = lay(false, railStep, (sm) => -(sm.half - railInset));
325
+ railings.right = lay(true, railStep, (sm) => sm.half - railInset);
326
+ }
327
+
328
+ // the centreline is what traffic, benching and the minimap all consume
329
+ const centreline = samples.map(({ p }) => [+p.x.toFixed(3), +p.y.toFixed(3), +p.z.toFixed(3)]);
330
+ const grades = [];
331
+ for (let i = 1; i < samples.length; i++) {
332
+ const dy = samples[i].p.y - samples[i - 1].p.y;
333
+ const dxz = Math.hypot(samples[i].p.x - samples[i - 1].p.x, samples[i].p.z - samples[i - 1].p.z) || 1;
334
+ grades.push(dy / dxz);
335
+ }
336
+ return {
337
+ geometry, centreline, length, railings,
338
+ maxGrade: grades.length ? Math.max(...grades.map(Math.abs)) : 0,
339
+ // false when the two sockets could not be joined within the requested grade: the road
340
+ // still connects, but the caller should re-site, re-pair, or accept the climb
341
+ feasible: gradeFit?.feasible ?? true,
342
+ };
343
+ }
@@ -0,0 +1,158 @@
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
+ // A CLOSED CIRCUIT through every site — a ring road or an orbital motorway. Nearest-neighbour
54
+ // tour, then 2-opt until no crossing pair remains, which is enough to stop a small loop from
55
+ // doubling back on itself. Returns the sites in travel order.
56
+ export function planLoop(sites) {
57
+ const n = sites.length;
58
+ if (n < 3) return sites.map((_, i) => i);
59
+ const d = (a, b) => Math.hypot(sites[a].x - sites[b].x, sites[a].z - sites[b].z);
60
+ const tour = [0];
61
+ const left = new Set(sites.map((_, i) => i).slice(1));
62
+ while (left.size) {
63
+ const from = tour[tour.length - 1];
64
+ let best = null;
65
+ for (const i of left) { const dd = d(from, i); if (!best || dd < best.d) best = { i, d: dd }; }
66
+ tour.push(best.i);
67
+ left.delete(best.i);
68
+ }
69
+ const tourLen = (t) => t.reduce((a, _, i) => a + d(t[i], t[(i + 1) % n]), 0);
70
+ for (let pass = 0; pass < 40; pass++) {
71
+ let improved = false;
72
+ for (let i = 1; i < n - 1; i++) {
73
+ for (let j = i + 1; j < n; j++) {
74
+ const cand = [...tour.slice(0, i), ...tour.slice(i, j + 1).reverse(), ...tour.slice(j + 1)];
75
+ if (tourLen(cand) < tourLen(tour) - 1e-6) { tour.splice(0, n, ...cand); improved = true; }
76
+ }
77
+ }
78
+ if (!improved) break;
79
+ }
80
+ return tour;
81
+ }
82
+
83
+ // Where a piece must face to sit ON a loop: along the local tangent, so its through-
84
+ // carriageway continues the circuit rather than pointing across it.
85
+ export function loopTangents(sites, order) {
86
+ const n = order.length;
87
+ return order.map((idx, k) => {
88
+ const prev = sites[order[(k - 1 + n) % n]], next = sites[order[(k + 1) % n]];
89
+ return { index: idx, yaw: Math.atan2(next.x - prev.x, next.z - prev.z) };
90
+ });
91
+ }
92
+
93
+ const TAU = Math.PI * 2;
94
+ const wrap = (a) => ((a % TAU) + TAU) % TAU;
95
+ const angleGap = (a, b) => { const d = Math.abs(wrap(a) - wrap(b)); return Math.min(d, TAU - d); };
96
+
97
+ // How well a piece, turned by `yaw`, presents an arm to each bearing it must serve.
98
+ // Returns the total mismatch in radians, and which socket answers which bearing.
99
+ export function fitJunction(piece, bearings, yaw, { kinds = null } = {}) {
100
+ const sockets = (piece.sockets ?? []).filter((s) => !kinds || kinds.includes(s.kind ?? 'road'));
101
+ if (sockets.length < bearings.length) return null;
102
+ const used = new Set();
103
+ let cost = 0;
104
+ const assign = [];
105
+ for (const want of bearings) {
106
+ let best = null;
107
+ for (let i = 0; i < sockets.length; i++) {
108
+ if (used.has(i)) continue;
109
+ const have = Math.atan2(sockets[i].dir[0], sockets[i].dir[1]) + yaw;
110
+ const gap = angleGap(want, have);
111
+ if (!best || gap < best.gap) best = { i, gap };
112
+ }
113
+ if (!best) return null;
114
+ used.add(best.i);
115
+ cost += best.gap;
116
+ assign.push({ socket: sockets[best.i], bearing: want, gap: best.gap });
117
+ }
118
+ return { cost, assign };
119
+ }
120
+
121
+ // Choose the piece and rotation that best serves a site's bearings. `candidates` is a list of
122
+ // piece names; the kit supplies their sockets.
123
+ export function chooseJunction(kit, candidates, bearings, { steps = 72, kinds = null } = {}) {
124
+ let best = null;
125
+ for (const name of candidates) {
126
+ const piece = kit.piece ? kit.piece(name) : kit.pieces?.[name];
127
+ if (!piece) continue;
128
+ const arms = (piece.sockets ?? []).filter((s) => !kinds || kinds.includes(s.kind ?? 'road')).length;
129
+ if (arms < bearings.length) continue;
130
+ for (let k = 0; k < steps; k++) {
131
+ const yaw = (k / steps) * TAU;
132
+ const fit = fitJunction(piece, bearings, yaw, { kinds });
133
+ if (!fit) continue;
134
+ // prefer a piece with no arms left over — a crossroads standing in for a corner
135
+ // leaves two stubs pointing at nothing
136
+ const score = fit.cost + (arms - bearings.length) * 0.25;
137
+ if (!best || score < best.score) best = { name, piece, yaw, score, assign: fit.assign };
138
+ }
139
+ }
140
+ return best;
141
+ }
142
+
143
+ // The whole plan: which sites join to which, and what junction each site needs.
144
+ export function solveNetwork({ sites, kit, candidates, extra = 0.35, maxLength = Infinity, kinds = null }) {
145
+ const edges = planEdges(sites, { extra, maxLength });
146
+ const bearingsOf = (i) => edges
147
+ .filter((e) => e.a === i || e.b === i)
148
+ .map((e) => {
149
+ const o = sites[e.a === i ? e.b : e.a];
150
+ return Math.atan2(o.x - sites[i].x, o.z - sites[i].z);
151
+ });
152
+ const nodes = sites.map((s, i) => {
153
+ const bearings = bearingsOf(i);
154
+ const choice = chooseJunction(kit, candidates, bearings, { kinds });
155
+ return { ...s, index: i, degree: bearings.length, bearings, ...(choice ?? {}) };
156
+ });
157
+ return { nodes, edges };
158
+ }