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.
- package/CHANGELOG.md +69 -0
- package/docs/plans/kiwi-road-recon.md +97 -0
- package/docs/plans/road-system.md +142 -0
- package/docs/plans/three-cities.md +43 -0
- package/package.json +3 -2
- package/src/world/design.js +72 -0
- package/src/world/heightfield.js +146 -0
- package/src/world/kit.js +924 -0
- package/src/world/permanentWay.js +161 -0
- package/src/world/placement.js +309 -0
- package/src/world/roadKit.js +110 -0
- package/src/world/roadLink.js +270 -0
- package/src/world/roadNetwork.js +118 -0
- package/src/world/roadTerrain.js +263 -0
- package/src/world/waterLevels.js +95 -0
- package/src/world/waterMeshes.js +168 -0
- package/src/world/worldBase.js +493 -0
- package/tools/ExportKiwiRoadMaterials.cs +145 -0
- package/tools/ExportKiwiRoadRefs.cs +94 -0
- package/tools/buildKiwiRoadsPack.mjs +36 -0
- package/tools/designer-mcp.mjs +103 -0
- package/tools/designer.js +316 -0
- package/tools/devPlugin.js +57 -0
- package/tools/fbxToGlb.mjs +115 -0
- package/tools/glbWrite.mjs +62 -0
- package/tools/rebuildSimpleRoundabouts.mjs +211 -0
- package/tools/roadKitAnalyze.mjs +366 -0
- package/tools/truthToAssemblies.mjs +160 -0
- package/tools/unityMeshToGlb.mjs +95 -0
- package/tools/unityPrefabToAssembly.mjs +276 -0
|
@@ -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
|
+
}
|