@jgengine/core 0.18.0 → 0.18.1
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 +114 -2
- package/dist/ai/decisionGraph.d.ts +52 -0
- package/dist/ai/decisionGraph.js +82 -0
- package/dist/ai/mobBrainGraph.d.ts +3 -0
- package/dist/ai/mobBrainGraph.js +25 -0
- package/dist/ai/tacticalQueries.d.ts +30 -0
- package/dist/ai/tacticalQueries.js +45 -0
- package/dist/anim/animGraph.d.ts +144 -0
- package/dist/anim/animGraph.js +286 -0
- package/dist/anim/ikSolver.d.ts +51 -0
- package/dist/anim/ikSolver.js +124 -0
- package/dist/anim/locomotionGraph.d.ts +26 -0
- package/dist/anim/locomotionGraph.js +33 -0
- package/dist/assets/spriteAtlas.d.ts +21 -0
- package/dist/assets/spriteAtlas.js +103 -0
- package/dist/audio/audioFalloff.d.ts +10 -0
- package/dist/economy/currency.d.ts +16 -2
- package/dist/economy/currency.js +55 -14
- package/dist/economy/wallet.d.ts +5 -4
- package/dist/economy/wallet.js +8 -4
- package/dist/editor/commandApply.js +1 -0
- package/dist/editor/commands.d.ts +4 -1
- package/dist/editor/document.d.ts +2 -1
- package/dist/editor/document.js +89 -0
- package/dist/editor/environment.d.ts +3 -0
- package/dist/editor/environment.js +16 -0
- package/dist/editor/index.d.ts +4 -2
- package/dist/editor/index.js +1 -1
- package/dist/editor/types.d.ts +16 -0
- package/dist/game/cameraConfig.d.ts +8 -0
- package/dist/game/chat.d.ts +22 -1
- package/dist/game/chat.js +48 -19
- package/dist/game/controlGate.d.ts +6 -0
- package/dist/game/controlGate.js +30 -1
- package/dist/game/defineGame.d.ts +10 -1
- package/dist/game/events.d.ts +8 -1
- package/dist/game/pixelPerfect.d.ts +25 -0
- package/dist/game/pixelPerfect.js +29 -0
- package/dist/game/playableGame.d.ts +97 -3
- package/dist/game/systemRuntime.js +6 -1
- package/dist/input/actionBindings.d.ts +2 -1
- package/dist/input/actionBindings.js +10 -8
- package/dist/input/actionContexts.d.ts +21 -0
- package/dist/input/actionContexts.js +56 -0
- package/dist/input/gamepadModel.d.ts +45 -0
- package/dist/input/gamepadModel.js +69 -0
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +85 -1
- package/dist/movement/characterController.d.ts +76 -0
- package/dist/movement/characterController.js +216 -0
- package/dist/movement/freeFlight.d.ts +119 -0
- package/dist/movement/freeFlight.js +294 -0
- package/dist/movement/playerMovement.d.ts +10 -2
- package/dist/movement/playerMovement.js +218 -4
- package/dist/movement/serverStep.d.ts +5 -0
- package/dist/movement/serverStep.js +6 -0
- package/dist/movement/solidObstacles.d.ts +30 -7
- package/dist/movement/solidObstacles.js +27 -4
- package/dist/multiplayer/poseSyncGate.d.ts +3 -1
- package/dist/multiplayer/poseSyncGate.js +9 -1
- package/dist/multiplayer/presenceContract.d.ts +6 -0
- package/dist/multiplayer/presenceModel.d.ts +2 -0
- package/dist/multiplayer/presenceModel.js +11 -0
- package/dist/nav/navMesh.d.ts +33 -0
- package/dist/nav/navMesh.js +164 -0
- package/dist/physics/flightDynamics.d.ts +8 -0
- package/dist/physics/kinematicVehicle.d.ts +6 -1
- package/dist/physics/physicsBackend.d.ts +191 -0
- package/dist/physics/physicsBackend.js +49 -0
- package/dist/physics/physicsBackendConformance.d.ts +27 -0
- package/dist/physics/physicsBackendConformance.js +117 -0
- package/dist/physics/physicsWorldBackend.d.ts +44 -0
- package/dist/physics/physicsWorldBackend.js +485 -0
- package/dist/physics/worldColliders.d.ts +13 -0
- package/dist/physics/worldColliders.js +96 -0
- package/dist/random/rng.d.ts +12 -1
- package/dist/random/rng.js +20 -3
- package/dist/render/environment.d.ts +23 -0
- package/dist/render/environment.js +1 -0
- package/dist/render/lookPreset.d.ts +11 -5
- package/dist/render/lookPreset.js +29 -4
- package/dist/render/postProcessing.d.ts +10 -1
- package/dist/render/sprite2d.d.ts +38 -0
- package/dist/render/sprite2d.js +40 -0
- package/dist/runtime/adapter.d.ts +20 -1
- package/dist/runtime/adapter.js +8 -1
- package/dist/runtime/commandInput.d.ts +15 -0
- package/dist/runtime/commandInput.js +13 -0
- package/dist/runtime/commandRunner.d.ts +12 -7
- package/dist/runtime/commandRunner.js +7 -5
- package/dist/runtime/gameContext.js +11 -1
- package/dist/runtime/gameContextTypes.d.ts +14 -4
- package/dist/runtime/gameRuntime.d.ts +20 -4
- package/dist/runtime/gameRuntime.js +17 -4
- package/dist/runtime/headlessRunner.d.ts +10 -0
- package/dist/runtime/headlessRunner.js +26 -8
- package/dist/runtime/hostPolicy.d.ts +3 -2
- package/dist/runtime/hostPolicy.js +8 -10
- package/dist/runtime/hostedGameRunner.js +34 -6
- package/dist/runtime/hostedWorldSession.d.ts +12 -16
- package/dist/runtime/hostedWorldSession.js +29 -6
- package/dist/runtime/hostedWorldStore.d.ts +20 -0
- package/dist/runtime/hostedWorldStore.js +1 -0
- package/dist/runtime/inputRecorder.d.ts +30 -0
- package/dist/runtime/inputRecorder.js +59 -0
- package/dist/runtime/inputSnapshot.d.ts +8 -0
- package/dist/runtime/inputSnapshot.js +1 -0
- package/dist/runtime/poseInterpolation.d.ts +36 -0
- package/dist/runtime/poseInterpolation.js +91 -0
- package/dist/runtime/prediction.d.ts +45 -0
- package/dist/runtime/prediction.js +62 -0
- package/dist/runtime/simContext.d.ts +45 -0
- package/dist/runtime/simContext.js +65 -0
- package/dist/runtime/simLoop.d.ts +52 -0
- package/dist/runtime/simLoop.js +72 -0
- package/dist/runtime/simSnapshot.d.ts +32 -0
- package/dist/runtime/simSnapshot.js +60 -0
- package/dist/runtime/snapshot.d.ts +8 -0
- package/dist/runtime/snapshotBuffer.d.ts +33 -0
- package/dist/runtime/snapshotBuffer.js +58 -0
- package/dist/runtime/transport.d.ts +32 -2
- package/dist/runtime/worldChunks.d.ts +2 -0
- package/dist/runtime/worldChunks.js +14 -0
- package/dist/runtime/worldProjection.d.ts +9 -0
- package/dist/runtime/worldProjection.js +17 -1
- package/dist/runtime/worldSnapshot.d.ts +6 -0
- package/dist/runtime/worldSnapshot.js +3 -0
- package/dist/scene/behaviorRuntime.d.ts +19 -1
- package/dist/scene/behaviorRuntime.js +55 -4
- package/dist/scene/behaviors.d.ts +8 -1
- package/dist/scene/builtinSceneKinds.js +10 -0
- package/dist/sensor/perception.d.ts +60 -0
- package/dist/sensor/perception.js +142 -0
- package/dist/settings/graphicsProfile.d.ts +19 -0
- package/dist/settings/graphicsProfile.js +36 -0
- package/dist/time/accrueSince.d.ts +10 -0
- package/dist/time/accrueSince.js +9 -0
- package/dist/time/rateWindow.d.ts +16 -0
- package/dist/time/rateWindow.js +14 -0
- package/dist/time/serverTick.d.ts +21 -3
- package/dist/time/serverTick.js +14 -13
- package/dist/vfx/particleDirector.d.ts +68 -0
- package/dist/vfx/particleDirector.js +81 -0
- package/dist/world/buildings.d.ts +12 -0
- package/dist/world/buildings.js +43 -3
- package/dist/world/cityContent.js +103 -25
- package/dist/world/cityKind.d.ts +132 -0
- package/dist/world/cityKind.js +315 -15
- package/dist/world/features.d.ts +27 -1
- package/dist/world/features.js +7 -2
- package/dist/world/placement.d.ts +7 -0
- package/dist/world/placement.js +2 -0
- package/dist/world/placementController.d.ts +1 -1
- package/dist/world/placementController.js +7 -0
- package/dist/world/raceCircuit.d.ts +168 -0
- package/dist/world/raceCircuit.js +797 -0
- package/dist/world/terrain.d.ts +5 -1
- package/dist/world/terrain.js +5 -0
- package/dist/world/territory.d.ts +97 -0
- package/dist/world/territory.js +182 -0
- package/dist/world.d.ts +5 -3
- package/dist/world.js +4 -2
- package/package.json +1 -1
- package/skills/ce-handoff/SKILL.md +1 -1
- package/skills/game-design/SKILL.md +11 -1
- package/skills/jgengine/SKILL.md +2 -0
- package/skills/jgengine/api.md +94 -19
- package/skills/jgengine/capabilities.md +28 -0
- package/skills/jgengine-gameplay/SKILL.md +10 -0
- package/skills/jgengine-gameplay/api.md +40 -18
- package/skills/jgengine-gameplay/capabilities.md +12 -0
- package/skills/jgengine-world/SKILL.md +6 -0
- package/skills/jgengine-world/api.md +312 -12
- package/skills/jgengine-world/capabilities.md +102 -0
|
@@ -0,0 +1,797 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A race circuit is a SECTION OF A CITY, blocked off — not a separate shape. This module lifts a
|
|
3
|
+
* closed lap out of an existing {@link StreetNetwork}: pick a cycle through the streets that are
|
|
4
|
+
* already there, then SEAL every side street leaving it. That is how a street race works in an open
|
|
5
|
+
* world (drive the city freely, then an instanced event fences a few blocks into a lap) and how a
|
|
6
|
+
* real street circuit works (Monaco, Detroit, Baku are public roads with barriers dropped in).
|
|
7
|
+
*
|
|
8
|
+
* The route is derived, never invented: `centerline`, `widths`, and `heights` are read off the same
|
|
9
|
+
* {@link StreetEdge}s the open world drives on, so the lap and the city are byte-for-byte the same
|
|
10
|
+
* asphalt. Everything a race needs comes with it — {@link CircuitSeal}s (the barriers that make it an
|
|
11
|
+
* instance), ordered {@link CircuitGate}s that hand straight to `raceTrack({ checkpoints })`,
|
|
12
|
+
* start/finish, a staggered grid, corner analysis, kerbs, and timing sectors.
|
|
13
|
+
*
|
|
14
|
+
* Works on either topology family the generator produces. On a `net` (a city) it searches the graph
|
|
15
|
+
* for the cycle that best matches the requested lap length and arterial bias. On a `circuit` (the
|
|
16
|
+
* generator's closed-loop mode) the loop edges ARE the cycle, so the same call returns the whole lap
|
|
17
|
+
* — one API, both slider extremes, no second code path.
|
|
18
|
+
*
|
|
19
|
+
* Volume-local XZ coords throughout, matching {@link StreetNetwork}; the caller rotates/translates
|
|
20
|
+
* into world space. Deterministic: same network + rules ⇒ identical route, with bounded search work.
|
|
21
|
+
*
|
|
22
|
+
* @capability race-circuit lift a closed, sealed-off race lap out of an existing city street network
|
|
23
|
+
*/
|
|
24
|
+
import { seededRng } from "../random/rng.js";
|
|
25
|
+
/** Road hierarchy rank — higher is wider/more arterial. Shared with the fabric's ordering. */
|
|
26
|
+
const LEVEL_RANK = { lane: 0, street: 1, avenue: 2, boulevard: 3 };
|
|
27
|
+
/** Sharpest turn a lap takes at a junction (radians). Beyond this it doubles back on itself. */
|
|
28
|
+
const MAX_LAP_TURN = (120 * Math.PI) / 180;
|
|
29
|
+
/**
|
|
30
|
+
* Worst self-approach of a closed lap, as `gap − required` over every non-adjacent pair of points;
|
|
31
|
+
* negative means two stretches of the lap are close enough that their road surfaces overlap. The
|
|
32
|
+
* generator holds the same invariant for its own circuits — an extracted lap needs it too, or a
|
|
33
|
+
* junction fold hands back a "lap" that drives through itself.
|
|
34
|
+
*/
|
|
35
|
+
function selfClearance(points, widths) {
|
|
36
|
+
const n = points.length;
|
|
37
|
+
const { at, total } = arcLengths(points);
|
|
38
|
+
let worst = Infinity;
|
|
39
|
+
for (let i = 0; i < n; i += 1) {
|
|
40
|
+
const a = points[i];
|
|
41
|
+
for (let j = i + 1; j < n; j += 1) {
|
|
42
|
+
const required = ((widths[i] ?? 9) + (widths[j] ?? 9)) * 0.5 * 1.15;
|
|
43
|
+
// Adjacency is measured along the lap, not in indices: two points a whole corner apart may
|
|
44
|
+
// still be one short edge apart by index, and that pair is not an overlap.
|
|
45
|
+
const along = at[j] - at[i];
|
|
46
|
+
if (Math.min(along, total - along) < required * 2.5)
|
|
47
|
+
continue;
|
|
48
|
+
const b = points[j];
|
|
49
|
+
const gap = Math.hypot(a[0] - b[0], a[1] - b[1]) - required;
|
|
50
|
+
if (gap < worst)
|
|
51
|
+
worst = gap;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return worst;
|
|
55
|
+
}
|
|
56
|
+
/** Defaults: a ~2.4 km arterial street lap with 12 gates, 3 laps, and barriered side streets. */
|
|
57
|
+
export const CIRCUIT_ROUTE_DEFAULTS = {
|
|
58
|
+
seed: "circuit",
|
|
59
|
+
lapLength: 2400,
|
|
60
|
+
arterialBias: 0.7,
|
|
61
|
+
minCornerRadius: 9,
|
|
62
|
+
checkpoints: 12,
|
|
63
|
+
laps: 3,
|
|
64
|
+
gateHeight: 6,
|
|
65
|
+
gridSlots: 12,
|
|
66
|
+
sealKind: "barrier",
|
|
67
|
+
sectors: 3,
|
|
68
|
+
sampleStep: 2.5,
|
|
69
|
+
cornerCut: 0.42,
|
|
70
|
+
};
|
|
71
|
+
function edgeLength(points) {
|
|
72
|
+
let total = 0;
|
|
73
|
+
for (let i = 0; i + 1 < points.length; i += 1) {
|
|
74
|
+
const a = points[i];
|
|
75
|
+
const b = points[i + 1];
|
|
76
|
+
total += Math.hypot(b[0] - a[0], b[1] - a[1]);
|
|
77
|
+
}
|
|
78
|
+
return total;
|
|
79
|
+
}
|
|
80
|
+
function buildAdjacency(network) {
|
|
81
|
+
const links = new Map();
|
|
82
|
+
const edgeById = new Map();
|
|
83
|
+
const nodeAt = new Map();
|
|
84
|
+
for (const node of network.nodes)
|
|
85
|
+
nodeAt.set(node.id, { x: node.x, z: node.z });
|
|
86
|
+
for (const edge of network.edges) {
|
|
87
|
+
if (edge.a === edge.b)
|
|
88
|
+
continue;
|
|
89
|
+
edgeById.set(edge.id, edge);
|
|
90
|
+
const forward = links.get(edge.a) ?? [];
|
|
91
|
+
forward.push({ edge: edge.id, other: edge.b });
|
|
92
|
+
links.set(edge.a, forward);
|
|
93
|
+
const back = links.get(edge.b) ?? [];
|
|
94
|
+
back.push({ edge: edge.id, other: edge.a });
|
|
95
|
+
links.set(edge.b, back);
|
|
96
|
+
}
|
|
97
|
+
return { links, edgeById, nodeAt };
|
|
98
|
+
}
|
|
99
|
+
/** Direction (radians) leaving `node` along `edge`, read off the edge's first sampled span. */
|
|
100
|
+
function departureHeading(edge, node) {
|
|
101
|
+
const points = edge.points;
|
|
102
|
+
if (points.length < 2)
|
|
103
|
+
return 0;
|
|
104
|
+
const forward = edge.a === node;
|
|
105
|
+
const from = forward ? points[0] : points[points.length - 1];
|
|
106
|
+
const to = forward ? points[1] : points[points.length - 2];
|
|
107
|
+
return Math.atan2(to[0] - from[0], to[1] - from[1]);
|
|
108
|
+
}
|
|
109
|
+
function angleDelta(a, b) {
|
|
110
|
+
let d = b - a;
|
|
111
|
+
while (d > Math.PI)
|
|
112
|
+
d -= Math.PI * 2;
|
|
113
|
+
while (d < -Math.PI)
|
|
114
|
+
d += Math.PI * 2;
|
|
115
|
+
return d;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Chain the lap's edges into one continuous centerline, carrying per-point width and height. Each
|
|
119
|
+
* edge is walked in its traversal direction and its shared endpoint dropped, so the polyline closes
|
|
120
|
+
* without a duplicated seam vertex.
|
|
121
|
+
*/
|
|
122
|
+
function assembleCenterline(nodes, edges, adjacency) {
|
|
123
|
+
const centerline = [];
|
|
124
|
+
const widths = [];
|
|
125
|
+
const heights = [];
|
|
126
|
+
let anyHeights = false;
|
|
127
|
+
for (let i = 0; i < edges.length; i += 1) {
|
|
128
|
+
const edge = adjacency.edgeById.get(edges[i]);
|
|
129
|
+
if (edge === undefined)
|
|
130
|
+
continue;
|
|
131
|
+
const from = nodes[i];
|
|
132
|
+
const forward = edge.a === from;
|
|
133
|
+
const points = forward ? edge.points : [...edge.points].reverse();
|
|
134
|
+
const edgeHeights = edge.heights === undefined ? null : forward ? edge.heights : [...edge.heights].reverse();
|
|
135
|
+
if (edgeHeights !== null)
|
|
136
|
+
anyHeights = true;
|
|
137
|
+
// Drop the shared endpoint so consecutive edges weld into one polyline.
|
|
138
|
+
for (let p = 0; p + 1 < points.length; p += 1) {
|
|
139
|
+
centerline.push(points[p]);
|
|
140
|
+
widths.push(edge.width);
|
|
141
|
+
heights.push(edgeHeights?.[p] ?? 0);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return { centerline, widths, heights: anyHeights ? heights : null };
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Resample a closed lap to a fixed arc-length step, interpolating width and height. City street
|
|
148
|
+
* polylines are emitted at whatever resolution the road ribbon needed (often 15 m between vertices);
|
|
149
|
+
* a racing surface, corner analysis, and kerbs all need a dense, evenly spaced line.
|
|
150
|
+
*/
|
|
151
|
+
function resampleClosed(points, widths, heights, step) {
|
|
152
|
+
const n = points.length;
|
|
153
|
+
// Cumulative arc length at the START of each of the n closed segments, so the wrap-around segment
|
|
154
|
+
// from the last vertex back to the first is sampled like any other.
|
|
155
|
+
const starts = new Array(n + 1).fill(0);
|
|
156
|
+
for (let i = 0; i < n; i += 1) {
|
|
157
|
+
const a = points[i];
|
|
158
|
+
const b = points[(i + 1) % n];
|
|
159
|
+
starts[i + 1] = starts[i] + Math.hypot(b[0] - a[0], b[1] - a[1]);
|
|
160
|
+
}
|
|
161
|
+
const total = starts[n];
|
|
162
|
+
if (total < 1e-6)
|
|
163
|
+
return { points: [...points], widths: [...widths], heights: heights === null ? null : [...heights] };
|
|
164
|
+
const count = Math.max(12, Math.min(4000, Math.round(total / step)));
|
|
165
|
+
const spacing = total / count;
|
|
166
|
+
const out = [];
|
|
167
|
+
const outWidths = [];
|
|
168
|
+
const outHeights = heights === null ? null : [];
|
|
169
|
+
let segment = 0;
|
|
170
|
+
for (let i = 0; i < count; i += 1) {
|
|
171
|
+
const distance = i * spacing;
|
|
172
|
+
while (segment + 1 < n && starts[segment + 1] <= distance)
|
|
173
|
+
segment += 1;
|
|
174
|
+
const spanStart = starts[segment];
|
|
175
|
+
const spanLength = starts[segment + 1] - spanStart;
|
|
176
|
+
const t = spanLength < 1e-9 ? 0 : Math.max(0, Math.min(1, (distance - spanStart) / spanLength));
|
|
177
|
+
const a = points[segment];
|
|
178
|
+
const b = points[(segment + 1) % n];
|
|
179
|
+
out.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]);
|
|
180
|
+
const wa = widths[segment] ?? 9;
|
|
181
|
+
const wb = widths[(segment + 1) % widths.length] ?? wa;
|
|
182
|
+
outWidths.push(wa + (wb - wa) * t);
|
|
183
|
+
if (outHeights !== null && heights !== null) {
|
|
184
|
+
const ha = heights[segment] ?? 0;
|
|
185
|
+
const hb = heights[(segment + 1) % heights.length] ?? ha;
|
|
186
|
+
outHeights.push(ha + (hb - ha) * t);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return { points: out, widths: outWidths, heights: outHeights };
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Round the lap's junction corners into a drivable racing line. A city lap turning from one street
|
|
193
|
+
* onto another inherits the junction's hard ~90° kink; a real street circuit takes that corner on a
|
|
194
|
+
* curve. Relax every point tighter than `minRadius` toward its neighbours, but cap how far any point
|
|
195
|
+
* may leave the street centerline so the line never drives off the asphalt it inherited.
|
|
196
|
+
*/
|
|
197
|
+
function roundCorners(points, widths, minRadius, cut) {
|
|
198
|
+
const n = points.length;
|
|
199
|
+
if (n < 8 || cut <= 0)
|
|
200
|
+
return [...points];
|
|
201
|
+
const origin = points.map((point) => [point[0], point[1]]);
|
|
202
|
+
const working = origin.map((point) => [point[0], point[1]]);
|
|
203
|
+
const budget = widths.map((width) => Math.max(0, width * 0.5 * cut));
|
|
204
|
+
const span = Math.max(minRadius * 0.5, polylineLength(points, true) / n * 3);
|
|
205
|
+
for (let pass = 0; pass < 24; pass += 1) {
|
|
206
|
+
const radii = curvatureProfile(working, span);
|
|
207
|
+
let moved = 0;
|
|
208
|
+
for (let i = 0; i < n; i += 1) {
|
|
209
|
+
if (radii[i] >= minRadius)
|
|
210
|
+
continue;
|
|
211
|
+
const prev = working[(i - 1 + n) % n];
|
|
212
|
+
const next = working[(i + 1) % n];
|
|
213
|
+
const targetX = (prev[0] + next[0]) * 0.5;
|
|
214
|
+
const targetZ = (prev[1] + next[1]) * 0.5;
|
|
215
|
+
const here = working[i];
|
|
216
|
+
let x = here[0] + (targetX - here[0]) * 0.5;
|
|
217
|
+
let z = here[1] + (targetZ - here[1]) * 0.5;
|
|
218
|
+
// Clamp back inside the road: the racing line may cut a corner, never leave the street.
|
|
219
|
+
const base = origin[i];
|
|
220
|
+
const driftX = x - base[0];
|
|
221
|
+
const driftZ = z - base[1];
|
|
222
|
+
const drift = Math.hypot(driftX, driftZ);
|
|
223
|
+
const limit = budget[i];
|
|
224
|
+
if (drift > limit && drift > 1e-9) {
|
|
225
|
+
x = base[0] + (driftX / drift) * limit;
|
|
226
|
+
z = base[1] + (driftZ / drift) * limit;
|
|
227
|
+
}
|
|
228
|
+
moved += Math.hypot(x - here[0], z - here[1]);
|
|
229
|
+
here[0] = x;
|
|
230
|
+
here[1] = z;
|
|
231
|
+
}
|
|
232
|
+
if (moved < 0.01)
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
return working.map((point) => [point[0], point[1]]);
|
|
236
|
+
}
|
|
237
|
+
function polylineLength(points, closed) {
|
|
238
|
+
let total = 0;
|
|
239
|
+
const last = closed ? points.length : points.length - 1;
|
|
240
|
+
for (let i = 0; i < last; i += 1) {
|
|
241
|
+
const a = points[i];
|
|
242
|
+
const b = points[(i + 1) % points.length];
|
|
243
|
+
total += Math.hypot(b[0] - a[0], b[1] - a[1]);
|
|
244
|
+
}
|
|
245
|
+
return total;
|
|
246
|
+
}
|
|
247
|
+
/** Radius of the circle through three points; Infinity when they are collinear. */
|
|
248
|
+
function circumRadius(a, b, c) {
|
|
249
|
+
const abx = b[0] - a[0];
|
|
250
|
+
const abz = b[1] - a[1];
|
|
251
|
+
const cbx = b[0] - c[0];
|
|
252
|
+
const cbz = b[1] - c[1];
|
|
253
|
+
const cross = abx * cbz - abz * cbx;
|
|
254
|
+
if (Math.abs(cross) < 1e-9)
|
|
255
|
+
return Infinity;
|
|
256
|
+
const ab = Math.hypot(abx, abz);
|
|
257
|
+
const bc = Math.hypot(cbx, cbz);
|
|
258
|
+
const ca = Math.hypot(c[0] - a[0], c[1] - a[1]);
|
|
259
|
+
return (ab * bc * ca) / (2 * Math.abs(cross));
|
|
260
|
+
}
|
|
261
|
+
/** Per-point radius of curvature over a closed polyline, measured across a ~`span` meter chord. */
|
|
262
|
+
function curvatureProfile(points, span) {
|
|
263
|
+
const n = points.length;
|
|
264
|
+
const radii = new Array(n).fill(Infinity);
|
|
265
|
+
if (n < 5)
|
|
266
|
+
return radii;
|
|
267
|
+
const perPoint = polylineLength(points, true) / n;
|
|
268
|
+
const step = Math.max(1, Math.round(span / Math.max(0.5, perPoint)));
|
|
269
|
+
for (let i = 0; i < n; i += 1) {
|
|
270
|
+
const prev = points[(i - step + n * 2) % n];
|
|
271
|
+
const here = points[i];
|
|
272
|
+
const next = points[(i + step) % n];
|
|
273
|
+
radii[i] = circumRadius(prev, here, next);
|
|
274
|
+
}
|
|
275
|
+
return radii;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Group the sub-threshold stretches of a curvature profile into discrete corners. The profile is
|
|
279
|
+
* smoothed and short stretches dropped first: raw per-sample curvature on a dense line flickers
|
|
280
|
+
* across any threshold, which would otherwise report one sweeper as a dozen "corners".
|
|
281
|
+
*/
|
|
282
|
+
function analyzeCorners(points, radii, threshold, minArc, spacing) {
|
|
283
|
+
const n = points.length;
|
|
284
|
+
const corners = [];
|
|
285
|
+
if (n < 8)
|
|
286
|
+
return corners;
|
|
287
|
+
const smoothed = radii.map((_, i) => {
|
|
288
|
+
let sum = 0;
|
|
289
|
+
for (let k = -2; k <= 2; k += 1)
|
|
290
|
+
sum += radii[(i + k + n * 3) % n];
|
|
291
|
+
return sum / 5;
|
|
292
|
+
});
|
|
293
|
+
const tight = smoothed.map((r) => r < threshold);
|
|
294
|
+
// Bridge tiny straights so one flowing corner is not split by a sample of relief.
|
|
295
|
+
const bridge = Math.max(1, Math.round(minArc / Math.max(0.5, spacing) / 2));
|
|
296
|
+
for (let i = 0; i < n; i += 1) {
|
|
297
|
+
if (tight[i])
|
|
298
|
+
continue;
|
|
299
|
+
let gap = 0;
|
|
300
|
+
while (gap < bridge && !tight[(i + gap) % n])
|
|
301
|
+
gap += 1;
|
|
302
|
+
if (gap < bridge && tight[(i - 1 + n) % n] && tight[(i + gap) % n]) {
|
|
303
|
+
for (let k = 0; k < gap; k += 1)
|
|
304
|
+
tight[(i + k) % n] = true;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
const minSpan = Math.max(3, Math.round(minArc / Math.max(0.5, spacing)));
|
|
308
|
+
// Rotate the scan so it never starts mid-corner, otherwise one corner splits across the seam.
|
|
309
|
+
let origin = tight.findIndex((value) => !value);
|
|
310
|
+
if (origin < 0)
|
|
311
|
+
origin = 0;
|
|
312
|
+
let index = 0;
|
|
313
|
+
while (index < n) {
|
|
314
|
+
const at = (origin + index) % n;
|
|
315
|
+
if (!tight[at]) {
|
|
316
|
+
index += 1;
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
let span = 0;
|
|
320
|
+
let apex = at;
|
|
321
|
+
let best = smoothed[at];
|
|
322
|
+
while (span < n && tight[(origin + index + span) % n]) {
|
|
323
|
+
const cursor = (origin + index + span) % n;
|
|
324
|
+
if (smoothed[cursor] < best) {
|
|
325
|
+
best = smoothed[cursor];
|
|
326
|
+
apex = cursor;
|
|
327
|
+
}
|
|
328
|
+
span += 1;
|
|
329
|
+
}
|
|
330
|
+
if (span < minSpan) {
|
|
331
|
+
index += span;
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
const entry = at;
|
|
335
|
+
const exit = (origin + index + span - 1) % n;
|
|
336
|
+
const before = points[(entry - 1 + n) % n];
|
|
337
|
+
const atPoint = points[entry];
|
|
338
|
+
const afterPoint = points[exit];
|
|
339
|
+
const after = points[(exit + 1) % n];
|
|
340
|
+
const headingIn = Math.atan2(atPoint[0] - before[0], atPoint[1] - before[1]);
|
|
341
|
+
const headingOut = Math.atan2(after[0] - afterPoint[0], after[1] - afterPoint[1]);
|
|
342
|
+
const turn = angleDelta(headingIn, headingOut);
|
|
343
|
+
corners.push({
|
|
344
|
+
apex,
|
|
345
|
+
radius: best,
|
|
346
|
+
angle: Math.abs(turn),
|
|
347
|
+
direction: turn >= 0 ? 1 : -1,
|
|
348
|
+
entry,
|
|
349
|
+
exit,
|
|
350
|
+
});
|
|
351
|
+
index += span;
|
|
352
|
+
}
|
|
353
|
+
return corners;
|
|
354
|
+
}
|
|
355
|
+
/** Cumulative arc length at each point of a closed polyline, plus the total. */
|
|
356
|
+
function arcLengths(points) {
|
|
357
|
+
const at = new Array(points.length).fill(0);
|
|
358
|
+
let total = 0;
|
|
359
|
+
for (let i = 0; i < points.length; i += 1) {
|
|
360
|
+
at[i] = total;
|
|
361
|
+
const a = points[i];
|
|
362
|
+
const b = points[(i + 1) % points.length];
|
|
363
|
+
total += Math.hypot(b[0] - a[0], b[1] - a[1]);
|
|
364
|
+
}
|
|
365
|
+
return { at, total };
|
|
366
|
+
}
|
|
367
|
+
/** Index whose arc length is closest to `distance` around the lap. */
|
|
368
|
+
function indexAtDistance(at, total, distance) {
|
|
369
|
+
const target = ((distance % total) + total) % total;
|
|
370
|
+
let lo = 0;
|
|
371
|
+
let hi = at.length - 1;
|
|
372
|
+
while (lo < hi) {
|
|
373
|
+
const mid = (lo + hi) >> 1;
|
|
374
|
+
if (at[mid] < target)
|
|
375
|
+
lo = mid + 1;
|
|
376
|
+
else
|
|
377
|
+
hi = mid;
|
|
378
|
+
}
|
|
379
|
+
return lo;
|
|
380
|
+
}
|
|
381
|
+
function headingAt(points, index) {
|
|
382
|
+
const n = points.length;
|
|
383
|
+
const a = points[index];
|
|
384
|
+
const b = points[(index + 1) % n];
|
|
385
|
+
return Math.atan2(b[0] - a[0], b[1] - a[1]);
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Locate the longest low-curvature run — the start/finish straight. Returns the centerline index
|
|
389
|
+
* two-thirds along it, so the grid has room behind the line and the first corner room ahead.
|
|
390
|
+
*/
|
|
391
|
+
function findStartIndex(radii, at, total, straightRadius) {
|
|
392
|
+
const n = radii.length;
|
|
393
|
+
let bestStart = 0;
|
|
394
|
+
let bestLength = -1;
|
|
395
|
+
let runStart = -1;
|
|
396
|
+
// Two passes so a run wrapping the seam is measured whole.
|
|
397
|
+
for (let i = 0; i < n * 2; i += 1) {
|
|
398
|
+
const cursor = i % n;
|
|
399
|
+
const straight = radii[cursor] >= straightRadius;
|
|
400
|
+
if (straight && runStart < 0)
|
|
401
|
+
runStart = i;
|
|
402
|
+
if (!straight && runStart >= 0) {
|
|
403
|
+
const spanStart = at[runStart % n];
|
|
404
|
+
const spanEnd = at[(i - 1) % n];
|
|
405
|
+
const length = spanEnd >= spanStart ? spanEnd - spanStart : total - spanStart + spanEnd;
|
|
406
|
+
if (length > bestLength) {
|
|
407
|
+
bestLength = length;
|
|
408
|
+
bestStart = runStart % n;
|
|
409
|
+
}
|
|
410
|
+
runStart = -1;
|
|
411
|
+
if (i >= n)
|
|
412
|
+
break;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
if (bestLength <= 0)
|
|
416
|
+
return 0;
|
|
417
|
+
return indexAtDistance(at, total, at[bestStart] + bestLength * 0.66);
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Randomized cycle search over the street graph. Each attempt walks node to node without revisiting,
|
|
421
|
+
* biased toward arterial roads and toward carrying straight on, and closes the loop once the walk is
|
|
422
|
+
* long enough to reach the target and an edge back to the start exists. Bounded attempts and bounded
|
|
423
|
+
* walk length keep the work fixed however large the network.
|
|
424
|
+
*/
|
|
425
|
+
function searchCycle(adjacency, rules) {
|
|
426
|
+
const rng = seededRng(`raceCircuit:${rules.seed}`);
|
|
427
|
+
// Only nodes with two or more ways out can sit on a cycle; peel the trees off first.
|
|
428
|
+
const degree = new Map();
|
|
429
|
+
for (const [node, links] of adjacency.links)
|
|
430
|
+
degree.set(node, links.length);
|
|
431
|
+
let peeled = true;
|
|
432
|
+
while (peeled) {
|
|
433
|
+
peeled = false;
|
|
434
|
+
for (const [node, count] of degree) {
|
|
435
|
+
if (count > 1 || count === 0)
|
|
436
|
+
continue;
|
|
437
|
+
degree.set(node, 0);
|
|
438
|
+
for (const link of adjacency.links.get(node) ?? []) {
|
|
439
|
+
const other = degree.get(link.other);
|
|
440
|
+
if (other !== undefined && other > 0)
|
|
441
|
+
degree.set(link.other, other - 1);
|
|
442
|
+
}
|
|
443
|
+
peeled = true;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
const cyclic = [...degree.entries()].filter(([, count]) => count >= 2).map(([node]) => node);
|
|
447
|
+
if (cyclic.length < 3)
|
|
448
|
+
return null;
|
|
449
|
+
const arterialOf = (edge) => (LEVEL_RANK[edge.level] >= LEVEL_RANK.avenue ? 1 : 0);
|
|
450
|
+
const maxSteps = Math.min(400, Math.max(24, Math.ceil((rules.lapLength / 40) * 4)));
|
|
451
|
+
const attempts = 240;
|
|
452
|
+
let best = null;
|
|
453
|
+
let bestScore = -Infinity;
|
|
454
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
455
|
+
const startNode = cyclic[Math.floor(rng() * cyclic.length)];
|
|
456
|
+
const nodes = [startNode];
|
|
457
|
+
const edges = [];
|
|
458
|
+
const visited = new Set([startNode]);
|
|
459
|
+
let length = 0;
|
|
460
|
+
let arterial = 0;
|
|
461
|
+
let heading = null;
|
|
462
|
+
let closed = false;
|
|
463
|
+
for (let step = 0; step < maxSteps; step += 1) {
|
|
464
|
+
const current = nodes[nodes.length - 1];
|
|
465
|
+
const links = (adjacency.links.get(current) ?? []).filter((link) => {
|
|
466
|
+
if (degree.get(link.other) === 0)
|
|
467
|
+
return false;
|
|
468
|
+
if (edges.length > 0 && link.edge === edges[edges.length - 1])
|
|
469
|
+
return false;
|
|
470
|
+
return true;
|
|
471
|
+
});
|
|
472
|
+
if (links.length === 0)
|
|
473
|
+
break;
|
|
474
|
+
// Close as soon as the lap is long enough and the start is one edge away.
|
|
475
|
+
if (length >= rules.lapLength * 0.7 && nodes.length >= 3) {
|
|
476
|
+
const home = links.find((link) => link.other === startNode);
|
|
477
|
+
if (home !== undefined) {
|
|
478
|
+
const edge = adjacency.edgeById.get(home.edge);
|
|
479
|
+
edges.push(home.edge);
|
|
480
|
+
length += edgeLength(edge.points);
|
|
481
|
+
arterial += arterialOf(edge) * edgeLength(edge.points);
|
|
482
|
+
closed = true;
|
|
483
|
+
break;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
const fresh = links.filter((link) => !visited.has(link.other));
|
|
487
|
+
// A lap that doubles back down the street it arrived on is not raceable; drop reversals
|
|
488
|
+
// unless the walk would otherwise have nowhere to go.
|
|
489
|
+
const forward = fresh.filter((link) => {
|
|
490
|
+
if (heading === null)
|
|
491
|
+
return true;
|
|
492
|
+
const edge = adjacency.edgeById.get(link.edge);
|
|
493
|
+
return Math.abs(angleDelta(heading, departureHeading(edge, current))) <= MAX_LAP_TURN;
|
|
494
|
+
});
|
|
495
|
+
const options = forward.length > 0 ? forward : fresh;
|
|
496
|
+
if (options.length === 0)
|
|
497
|
+
break;
|
|
498
|
+
let total = 0;
|
|
499
|
+
const weights = options.map((link) => {
|
|
500
|
+
const edge = adjacency.edgeById.get(link.edge);
|
|
501
|
+
const rank = LEVEL_RANK[edge.level] / LEVEL_RANK.boulevard;
|
|
502
|
+
let weight = 0.15 + rank * rules.arterialBias * 2.5;
|
|
503
|
+
if (heading !== null) {
|
|
504
|
+
// Prefer carrying straight on: a lap that zig-zags every junction drives like a maze.
|
|
505
|
+
const turn = Math.abs(angleDelta(heading, departureHeading(edge, current)));
|
|
506
|
+
weight *= 1.25 - (turn / Math.PI) * 0.85;
|
|
507
|
+
}
|
|
508
|
+
// Once long enough, pull back toward the start so the walk can actually close.
|
|
509
|
+
if (length > rules.lapLength * 0.55) {
|
|
510
|
+
const home = adjacency.nodeAt.get(startNode);
|
|
511
|
+
const here = adjacency.nodeAt.get(current);
|
|
512
|
+
const there = adjacency.nodeAt.get(link.other);
|
|
513
|
+
const closer = Math.hypot(home.x - there.x, home.z - there.z) < Math.hypot(home.x - here.x, home.z - here.z);
|
|
514
|
+
weight *= closer ? 2.4 : 0.5;
|
|
515
|
+
}
|
|
516
|
+
weight = Math.max(0.01, weight);
|
|
517
|
+
total += weight;
|
|
518
|
+
return weight;
|
|
519
|
+
});
|
|
520
|
+
let roll = rng() * total;
|
|
521
|
+
let chosen = options.length - 1;
|
|
522
|
+
for (let i = 0; i < weights.length; i += 1) {
|
|
523
|
+
roll -= weights[i];
|
|
524
|
+
if (roll <= 0) {
|
|
525
|
+
chosen = i;
|
|
526
|
+
break;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
const link = options[chosen];
|
|
530
|
+
const edge = adjacency.edgeById.get(link.edge);
|
|
531
|
+
const span = edgeLength(edge.points);
|
|
532
|
+
nodes.push(link.other);
|
|
533
|
+
edges.push(link.edge);
|
|
534
|
+
visited.add(link.other);
|
|
535
|
+
length += span;
|
|
536
|
+
arterial += arterialOf(edge) * span;
|
|
537
|
+
heading = departureHeading(edge, link.other) + Math.PI;
|
|
538
|
+
}
|
|
539
|
+
if (!closed || length <= 0)
|
|
540
|
+
continue;
|
|
541
|
+
const arterialShare = arterial / length;
|
|
542
|
+
const lengthError = Math.abs(length - rules.lapLength) / rules.lapLength;
|
|
543
|
+
const score = arterialShare * rules.arterialBias * 1.5 - lengthError * 2 + Math.min(nodes.length, 24) * 0.01;
|
|
544
|
+
if (score <= bestScore)
|
|
545
|
+
continue;
|
|
546
|
+
const assembled = assembleCenterline(nodes, edges, adjacency);
|
|
547
|
+
if (assembled.centerline.length < 8)
|
|
548
|
+
continue;
|
|
549
|
+
if (selfClearance(assembled.centerline, assembled.widths) < 0)
|
|
550
|
+
continue;
|
|
551
|
+
bestScore = score;
|
|
552
|
+
best = { nodes, edges, length, arterial: arterialShare };
|
|
553
|
+
}
|
|
554
|
+
return best;
|
|
555
|
+
}
|
|
556
|
+
/** Order the generator's own `loop` edges into a traversal — the circuit-mode fast path. */
|
|
557
|
+
function loopCandidate(network) {
|
|
558
|
+
const loopEdges = network.edges.filter((edge) => edge.loop && edge.a !== edge.b);
|
|
559
|
+
if (loopEdges.length < 3)
|
|
560
|
+
return null;
|
|
561
|
+
const byNode = new Map();
|
|
562
|
+
for (const edge of loopEdges) {
|
|
563
|
+
for (const node of [edge.a, edge.b]) {
|
|
564
|
+
const bucket = byNode.get(node) ?? [];
|
|
565
|
+
bucket.push(edge);
|
|
566
|
+
byNode.set(node, bucket);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
const first = loopEdges[0];
|
|
570
|
+
const nodes = [first.a];
|
|
571
|
+
const edges = [];
|
|
572
|
+
const used = new Set();
|
|
573
|
+
let current = first.a;
|
|
574
|
+
let length = 0;
|
|
575
|
+
let arterial = 0;
|
|
576
|
+
for (let guard = 0; guard < loopEdges.length + 2; guard += 1) {
|
|
577
|
+
const next = (byNode.get(current) ?? []).find((edge) => !used.has(edge.id));
|
|
578
|
+
if (next === undefined)
|
|
579
|
+
break;
|
|
580
|
+
used.add(next.id);
|
|
581
|
+
const other = next.a === current ? next.b : next.a;
|
|
582
|
+
const span = edgeLength(next.points);
|
|
583
|
+
edges.push(next.id);
|
|
584
|
+
length += span;
|
|
585
|
+
if (LEVEL_RANK[next.level] >= LEVEL_RANK.avenue)
|
|
586
|
+
arterial += span;
|
|
587
|
+
if (other === nodes[0])
|
|
588
|
+
break;
|
|
589
|
+
nodes.push(other);
|
|
590
|
+
current = other;
|
|
591
|
+
}
|
|
592
|
+
if (edges.length !== nodes.length || edges.length < 3)
|
|
593
|
+
return null;
|
|
594
|
+
return { nodes, edges, length, arterial: length > 0 ? arterial / length : 0 };
|
|
595
|
+
}
|
|
596
|
+
/** Barriers on every street that leaves the lap and is not part of it. */
|
|
597
|
+
function buildSeals(nodes, edges, adjacency, kind) {
|
|
598
|
+
const onRoute = new Set(edges);
|
|
599
|
+
const seals = [];
|
|
600
|
+
// The widest lap road meeting each node sets how far back a barrier must stand.
|
|
601
|
+
const lapHalfWidth = new Map();
|
|
602
|
+
for (const id of edges) {
|
|
603
|
+
const edge = adjacency.edgeById.get(id);
|
|
604
|
+
if (edge === undefined)
|
|
605
|
+
continue;
|
|
606
|
+
for (const node of [edge.a, edge.b]) {
|
|
607
|
+
lapHalfWidth.set(node, Math.max(lapHalfWidth.get(node) ?? 0, edge.width * 0.5));
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
for (const node of nodes) {
|
|
611
|
+
const at = adjacency.nodeAt.get(node);
|
|
612
|
+
if (at === undefined)
|
|
613
|
+
continue;
|
|
614
|
+
for (const link of adjacency.links.get(node) ?? []) {
|
|
615
|
+
if (onRoute.has(link.edge))
|
|
616
|
+
continue;
|
|
617
|
+
const edge = adjacency.edgeById.get(link.edge);
|
|
618
|
+
if (edge === undefined)
|
|
619
|
+
continue;
|
|
620
|
+
const heading = departureHeading(edge, node);
|
|
621
|
+
// Stand the barrier past the junction mouth so it never overlaps the racing surface.
|
|
622
|
+
const offset = (lapHalfWidth.get(node) ?? edge.width * 0.5) + edge.width * 0.5 + 4;
|
|
623
|
+
seals.push({
|
|
624
|
+
x: at.x + Math.sin(heading) * offset,
|
|
625
|
+
z: at.z + Math.cos(heading) * offset,
|
|
626
|
+
heading,
|
|
627
|
+
width: edge.width,
|
|
628
|
+
kind,
|
|
629
|
+
node,
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
return seals;
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Red/white kerb strips hugging both road edges through each corner. Returns the polylines and
|
|
637
|
+
* stripe pitch as data; the renderer owns the mesh and the colors.
|
|
638
|
+
*/
|
|
639
|
+
export function circuitKerbs(route, options = {}) {
|
|
640
|
+
const width = options.width ?? 1.1;
|
|
641
|
+
const stripe = options.stripe ?? 2.2;
|
|
642
|
+
const maxRadius = options.maxRadius ?? Infinity;
|
|
643
|
+
const points = route.centerline;
|
|
644
|
+
const n = points.length;
|
|
645
|
+
const kerbs = [];
|
|
646
|
+
route.corners.forEach((corner, index) => {
|
|
647
|
+
if (corner.radius > maxRadius)
|
|
648
|
+
return;
|
|
649
|
+
const span = [];
|
|
650
|
+
let cursor = corner.entry;
|
|
651
|
+
for (let guard = 0; guard < n; guard += 1) {
|
|
652
|
+
span.push(cursor);
|
|
653
|
+
if (cursor === corner.exit)
|
|
654
|
+
break;
|
|
655
|
+
cursor = (cursor + 1) % n;
|
|
656
|
+
}
|
|
657
|
+
if (span.length < 3)
|
|
658
|
+
return;
|
|
659
|
+
for (const side of [-1, 1]) {
|
|
660
|
+
const run = [];
|
|
661
|
+
for (const at of span) {
|
|
662
|
+
const here = points[at];
|
|
663
|
+
// Mitre on the neighbour-to-neighbour tangent: a per-segment normal fans the strip open on
|
|
664
|
+
// the outside of a corner, which is exactly where a kerb must stay flush to the road edge.
|
|
665
|
+
const prev = points[(at - 1 + n) % n];
|
|
666
|
+
const next = points[(at + 1) % n];
|
|
667
|
+
const dx = next[0] - prev[0];
|
|
668
|
+
const dz = next[1] - prev[1];
|
|
669
|
+
const len = Math.hypot(dx, dz);
|
|
670
|
+
if (len < 1e-6)
|
|
671
|
+
continue;
|
|
672
|
+
const half = (route.widths[at] ?? 8) * 0.5;
|
|
673
|
+
run.push([here[0] + (dz / len) * half * side, here[1] - (dx / len) * half * side]);
|
|
674
|
+
}
|
|
675
|
+
if (run.length >= 2)
|
|
676
|
+
kerbs.push({ points: run, width, side, stripe, corner: index });
|
|
677
|
+
}
|
|
678
|
+
});
|
|
679
|
+
return kerbs;
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* Lift a closed, sealed-off race lap out of an existing street network. Returns `null` when the
|
|
683
|
+
* network has no drivable cycle (a pure tree of dead-end streets), so callers can fall back or warn
|
|
684
|
+
* rather than render a broken race.
|
|
685
|
+
*
|
|
686
|
+
* On a `circuit`-mode network the generated loop is used directly. On a `net` (a city) the graph is
|
|
687
|
+
* searched for the cycle closest to `lapLength` that respects `arterialBias` and `minCornerRadius`.
|
|
688
|
+
*/
|
|
689
|
+
export function extractCircuitRoute(network, rules = {}) {
|
|
690
|
+
const resolved = {
|
|
691
|
+
...CIRCUIT_ROUTE_DEFAULTS,
|
|
692
|
+
...rules,
|
|
693
|
+
lapLength: Math.max(80, rules.lapLength ?? CIRCUIT_ROUTE_DEFAULTS.lapLength),
|
|
694
|
+
arterialBias: Math.max(0, Math.min(1, rules.arterialBias ?? CIRCUIT_ROUTE_DEFAULTS.arterialBias)),
|
|
695
|
+
minCornerRadius: Math.max(3, rules.minCornerRadius ?? CIRCUIT_ROUTE_DEFAULTS.minCornerRadius),
|
|
696
|
+
checkpoints: Math.max(2, Math.floor(rules.checkpoints ?? CIRCUIT_ROUTE_DEFAULTS.checkpoints)),
|
|
697
|
+
laps: Math.max(1, Math.floor(rules.laps ?? CIRCUIT_ROUTE_DEFAULTS.laps)),
|
|
698
|
+
gridSlots: Math.max(0, Math.floor(rules.gridSlots ?? CIRCUIT_ROUTE_DEFAULTS.gridSlots)),
|
|
699
|
+
sectors: Math.max(1, Math.floor(rules.sectors ?? CIRCUIT_ROUTE_DEFAULTS.sectors)),
|
|
700
|
+
sampleStep: Math.max(0.5, rules.sampleStep ?? CIRCUIT_ROUTE_DEFAULTS.sampleStep),
|
|
701
|
+
cornerCut: Math.max(0, Math.min(0.9, rules.cornerCut ?? CIRCUIT_ROUTE_DEFAULTS.cornerCut)),
|
|
702
|
+
};
|
|
703
|
+
const adjacency = buildAdjacency(network);
|
|
704
|
+
const candidate = network.mode === "circuit"
|
|
705
|
+
? loopCandidate(network) ?? searchCycle(adjacency, resolved)
|
|
706
|
+
: searchCycle(adjacency, resolved) ?? loopCandidate(network);
|
|
707
|
+
if (candidate === null)
|
|
708
|
+
return null;
|
|
709
|
+
const assembled = assembleCenterline(candidate.nodes, candidate.edges, adjacency);
|
|
710
|
+
if (assembled.centerline.length < 4)
|
|
711
|
+
return null;
|
|
712
|
+
// Resample to a racing-surface resolution, then round the junction kinks the city streets carry
|
|
713
|
+
// into a line a car can actually take — still the same asphalt, just driven properly.
|
|
714
|
+
const dense = resampleClosed(assembled.centerline, assembled.widths, assembled.heights, resolved.sampleStep);
|
|
715
|
+
const centerline = roundCorners(dense.points, dense.widths, resolved.minCornerRadius, resolved.cornerCut);
|
|
716
|
+
const widths = dense.widths;
|
|
717
|
+
const heights = dense.heights;
|
|
718
|
+
if (centerline.length < 8)
|
|
719
|
+
return null;
|
|
720
|
+
const { at, total } = arcLengths(centerline);
|
|
721
|
+
const radii = curvatureProfile(centerline, Math.max(6, resolved.minCornerRadius));
|
|
722
|
+
let tightestRadius = Infinity;
|
|
723
|
+
for (const radius of radii)
|
|
724
|
+
if (radius < tightestRadius)
|
|
725
|
+
tightestRadius = radius;
|
|
726
|
+
const cornerThreshold = Math.max(resolved.minCornerRadius * 4, 40);
|
|
727
|
+
const spacing = total / centerline.length;
|
|
728
|
+
const corners = analyzeCorners(centerline, radii, cornerThreshold, Math.max(8, resolved.minCornerRadius), spacing);
|
|
729
|
+
const straightRadius = Math.max(cornerThreshold * 3, 200);
|
|
730
|
+
const startIndex = findStartIndex(radii, at, total, straightRadius);
|
|
731
|
+
const startPoint = centerline[startIndex];
|
|
732
|
+
const startHeading = headingAt(centerline, startIndex);
|
|
733
|
+
const startWidth = widths[startIndex] ?? 10;
|
|
734
|
+
const across = startHeading + Math.PI / 2;
|
|
735
|
+
const start = {
|
|
736
|
+
x: startPoint[0],
|
|
737
|
+
z: startPoint[1],
|
|
738
|
+
heading: startHeading,
|
|
739
|
+
line: [
|
|
740
|
+
[startPoint[0] + Math.sin(across) * startWidth * 0.5, startPoint[1] + Math.cos(across) * startWidth * 0.5],
|
|
741
|
+
[startPoint[0] - Math.sin(across) * startWidth * 0.5, startPoint[1] - Math.cos(across) * startWidth * 0.5],
|
|
742
|
+
],
|
|
743
|
+
distance: at[startIndex],
|
|
744
|
+
width: startWidth,
|
|
745
|
+
};
|
|
746
|
+
const grid = [];
|
|
747
|
+
for (let slot = 0; slot < resolved.gridSlots; slot += 1) {
|
|
748
|
+
const row = Math.floor(slot / 2);
|
|
749
|
+
const column = slot % 2 === 0 ? -1 : 1;
|
|
750
|
+
const back = 8 + row * 9;
|
|
751
|
+
const index = indexAtDistance(at, total, at[startIndex] - back);
|
|
752
|
+
const point = centerline[index];
|
|
753
|
+
const heading = headingAt(centerline, index);
|
|
754
|
+
const lateral = heading + Math.PI / 2;
|
|
755
|
+
const offset = (widths[index] ?? startWidth) * 0.22 * column;
|
|
756
|
+
grid.push({
|
|
757
|
+
x: point[0] + Math.sin(lateral) * offset,
|
|
758
|
+
z: point[1] + Math.cos(lateral) * offset,
|
|
759
|
+
heading,
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
// Gates are evenly spaced around the lap with the finish line LAST, so `raceTrack` treats the
|
|
763
|
+
// start/finish crossing as the lap boundary.
|
|
764
|
+
const checkpoints = [];
|
|
765
|
+
const gateSpacing = total / resolved.checkpoints;
|
|
766
|
+
for (let gate = 1; gate <= resolved.checkpoints; gate += 1) {
|
|
767
|
+
const index = indexAtDistance(at, total, at[startIndex] + gateSpacing * gate);
|
|
768
|
+
const point = centerline[index];
|
|
769
|
+
const height = heights?.[index] ?? 0;
|
|
770
|
+
const halfWidth = (widths[index] ?? startWidth) * 0.5 + 1.5;
|
|
771
|
+
checkpoints.push({
|
|
772
|
+
id: gate === resolved.checkpoints ? "finish" : `cp${gate}`,
|
|
773
|
+
center: [point[0], height + resolved.gateHeight * 0.5, point[1]],
|
|
774
|
+
half: [halfWidth, resolved.gateHeight * 0.5, 2.5],
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
const sectors = [];
|
|
778
|
+
for (let sector = 0; sector < resolved.sectors; sector += 1)
|
|
779
|
+
sectors.push(sector / resolved.sectors);
|
|
780
|
+
return {
|
|
781
|
+
centerline,
|
|
782
|
+
widths,
|
|
783
|
+
...(heights === null ? {} : { heights }),
|
|
784
|
+
length: total,
|
|
785
|
+
edges: candidate.edges,
|
|
786
|
+
nodes: candidate.nodes,
|
|
787
|
+
seals: buildSeals(candidate.nodes, candidate.edges, adjacency, resolved.sealKind),
|
|
788
|
+
corners,
|
|
789
|
+
checkpoints,
|
|
790
|
+
laps: resolved.laps,
|
|
791
|
+
start,
|
|
792
|
+
grid,
|
|
793
|
+
sectors,
|
|
794
|
+
arterialShare: candidate.arterial,
|
|
795
|
+
tightestRadius,
|
|
796
|
+
};
|
|
797
|
+
}
|