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.
- package/CHANGELOG.md +118 -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/systems/train.js +16 -6
- package/src/vehicle/car.js +321 -0
- package/src/vehicle/carAudio.js +124 -0
- 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/roadAssert.js +125 -0
- package/src/world/roadKit.js +110 -0
- package/src/world/roadLink.js +343 -0
- package/src/world/roadNetwork.js +158 -0
- package/src/world/roadTerrain.js +377 -0
- package/src/world/roadWorld.js +200 -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/unityPackageExtract.mjs +56 -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,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
|
+
}
|