sindicate 0.12.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +11 -3
- package/src/core/assets.js +15 -0
- package/src/systems/missions.js +2 -2
- package/src/systems/shatter.js +2 -1
- package/src/ui/dialogue.js +2 -1
- package/src/ui/pauseMenu.js +18 -11
- package/src/ui/saveLoadScreen.js +13 -9
- package/src/ui/theme.js +34 -0
- package/src/world/bridgeSplice.js +176 -0
- package/src/world/design.js +72 -0
- package/src/world/geo.js +53 -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/railFormation.js +185 -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 +324 -0
- package/tools/fbxToGlb.mjs +115 -0
- package/tools/glbWrite.mjs +62 -0
- package/tools/rebuildSimpleRoundabouts.mjs +211 -0
- package/tools/registry.json +45 -0
- package/tools/roadKitAnalyze.mjs +366 -0
- package/tools/sindicate-cli.mjs +71 -0
- package/tools/truthToAssemblies.mjs +160 -0
- package/tools/unityMeshToGlb.mjs +95 -0
- package/tools/unityPrefabToAssembly.mjs +276 -0
|
@@ -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,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
|
+
}
|