@jgengine/core 0.26.0 → 0.28.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.
Files changed (143) hide show
  1. package/CHANGELOG.md +174 -0
  2. package/dist/ai/policeResponse.d.ts +253 -0
  3. package/dist/ai/policeResponse.js +421 -0
  4. package/dist/anim/actionLayer.d.ts +47 -0
  5. package/dist/anim/actionLayer.js +63 -0
  6. package/dist/anim/ikSolver.d.ts +14 -0
  7. package/dist/anim/ikSolver.js +37 -0
  8. package/dist/anim/locomotionGraph.d.ts +58 -3
  9. package/dist/anim/locomotionGraph.js +142 -15
  10. package/dist/anim/ragdollRig.d.ts +53 -0
  11. package/dist/anim/ragdollRig.js +132 -0
  12. package/dist/anim/skeletonStandard.d.ts +140 -0
  13. package/dist/anim/skeletonStandard.js +326 -0
  14. package/dist/combat/damageRuntime.d.ts +14 -1
  15. package/dist/combat/damageRuntime.js +62 -4
  16. package/dist/combat/death.js +7 -3
  17. package/dist/combat/deathReason.d.ts +7 -1
  18. package/dist/combat/effects.d.ts +25 -0
  19. package/dist/combat/effects.js +16 -0
  20. package/dist/combat/statusRuntime.d.ts +2 -0
  21. package/dist/combat/statusRuntime.js +1 -0
  22. package/dist/economy/loanBook.d.ts +182 -0
  23. package/dist/economy/loanBook.js +303 -0
  24. package/dist/economy/market.d.ts +161 -0
  25. package/dist/economy/market.js +248 -0
  26. package/dist/economy/transactionLedger.d.ts +162 -0
  27. package/dist/economy/transactionLedger.js +301 -0
  28. package/dist/editor/creatorStorage.js +3 -1
  29. package/dist/editor/simulation.d.ts +5 -2
  30. package/dist/editor/simulation.js +29 -19
  31. package/dist/game/cameraConfig.d.ts +14 -0
  32. package/dist/game/clipRoles.d.ts +5 -4
  33. package/dist/game/clipRoles.js +34 -4
  34. package/dist/game/events.d.ts +27 -0
  35. package/dist/game/gameSave.d.ts +142 -0
  36. package/dist/game/gameSave.js +174 -0
  37. package/dist/game/playableGame.d.ts +74 -6
  38. package/dist/game/saveSlotIndex.d.ts +54 -0
  39. package/dist/game/saveSlotIndex.js +71 -0
  40. package/dist/game/saveSlots.d.ts +2 -0
  41. package/dist/game/saveSlots.js +1 -1
  42. package/dist/game/saveStore.d.ts +8 -0
  43. package/dist/game/saveStore.js +16 -0
  44. package/dist/gameplay.d.ts +17 -2
  45. package/dist/gameplay.js +15 -0
  46. package/dist/inventory/carryLoad.d.ts +48 -0
  47. package/dist/inventory/carryLoad.js +44 -0
  48. package/dist/inventory/equipment.d.ts +95 -0
  49. package/dist/inventory/equipment.js +169 -0
  50. package/dist/inventory/inventoryModel.d.ts +15 -6
  51. package/dist/inventory/inventoryModel.js +54 -5
  52. package/dist/inventory/shapedGrid.d.ts +26 -0
  53. package/dist/inventory/shapedGrid.js +47 -0
  54. package/dist/loot/lootGen.d.ts +186 -0
  55. package/dist/loot/lootGen.js +437 -0
  56. package/dist/loot/lootWeapon.d.ts +35 -0
  57. package/dist/loot/lootWeapon.js +70 -0
  58. package/dist/meta/changelog.d.ts +1 -1
  59. package/dist/meta/changelog.js +156 -1
  60. package/dist/mission/compositions.d.ts +85 -0
  61. package/dist/mission/compositions.js +130 -0
  62. package/dist/mission/mission.d.ts +435 -0
  63. package/dist/mission/mission.js +770 -0
  64. package/dist/mission/triggers.d.ts +61 -0
  65. package/dist/mission/triggers.js +108 -0
  66. package/dist/nav/laneGraph.d.ts +178 -0
  67. package/dist/nav/laneGraph.js +671 -0
  68. package/dist/nav/traffic.d.ts +222 -0
  69. package/dist/nav/traffic.js +918 -0
  70. package/dist/physics/kinematicVehicle.d.ts +36 -0
  71. package/dist/physics/kinematicVehicle.js +15 -1
  72. package/dist/physics/vehicleDamage.d.ts +136 -0
  73. package/dist/physics/vehicleDamage.js +214 -0
  74. package/dist/physics/vehicleParts.d.ts +62 -0
  75. package/dist/physics/vehicleParts.js +65 -0
  76. package/dist/progression/gearScore.d.ts +56 -0
  77. package/dist/progression/gearScore.js +59 -0
  78. package/dist/progression/skillTree.d.ts +129 -0
  79. package/dist/progression/skillTree.js +265 -0
  80. package/dist/progression/xpCurve.d.ts +60 -0
  81. package/dist/progression/xpCurve.js +141 -0
  82. package/dist/runtime/context/combat.js +11 -0
  83. package/dist/scene/builtinSceneKinds.js +2 -0
  84. package/dist/settings/accessibilitySettings.d.ts +81 -0
  85. package/dist/settings/accessibilitySettings.js +152 -0
  86. package/dist/settings/graphicsProfile.d.ts +3 -0
  87. package/dist/settings/graphicsProfile.js +4 -0
  88. package/dist/settings/settingsModel.d.ts +1 -1
  89. package/dist/settings/settingsModel.js +1 -0
  90. package/dist/sim/nearAgents.d.ts +8 -1
  91. package/dist/sim/nearAgents.js +34 -17
  92. package/dist/sim/offScreen.d.ts +2 -1
  93. package/dist/sim/offScreen.js +14 -3
  94. package/dist/sim/population.d.ts +147 -0
  95. package/dist/sim/population.js +182 -0
  96. package/dist/sim/queue.d.ts +90 -0
  97. package/dist/sim/queue.js +171 -0
  98. package/dist/sim/rides.d.ts +287 -0
  99. package/dist/sim/rides.js +471 -0
  100. package/dist/sim/routine.js +19 -11
  101. package/dist/ui/loadingTips.d.ts +41 -0
  102. package/dist/ui/loadingTips.js +64 -0
  103. package/dist/ui/menuNav.d.ts +79 -0
  104. package/dist/ui/menuNav.js +147 -0
  105. package/dist/ui/menuTransition.d.ts +50 -0
  106. package/dist/ui/menuTransition.js +67 -0
  107. package/dist/ui.d.ts +2 -0
  108. package/dist/ui.js +2 -0
  109. package/dist/vfx/effectLibrary.d.ts +98 -0
  110. package/dist/vfx/effectLibrary.js +181 -0
  111. package/dist/vfx/particleBudget.d.ts +40 -0
  112. package/dist/vfx/particleBudget.js +39 -0
  113. package/dist/vfx/particleDirector.d.ts +16 -0
  114. package/dist/vfx/particleDirector.js +18 -0
  115. package/dist/world/authoredSimulation.js +7 -1
  116. package/dist/world/captureCamera.d.ts +30 -0
  117. package/dist/world/captureCamera.js +19 -0
  118. package/dist/world/decalKind.d.ts +48 -0
  119. package/dist/world/decalKind.js +86 -0
  120. package/dist/world/features.d.ts +18 -5
  121. package/dist/world/features.js +1 -0
  122. package/dist/world/grassKind.d.ts +5 -0
  123. package/dist/world/grassKind.js +3 -0
  124. package/dist/world/terraform.d.ts +8 -0
  125. package/dist/world/terrain.d.ts +1 -0
  126. package/dist/world/terrain.js +2 -0
  127. package/dist/world.d.ts +7 -2
  128. package/dist/world.js +5 -0
  129. package/package.json +1 -1
  130. package/skills/jgengine/api.md +2 -2
  131. package/skills/jgengine-combat/SKILL.md +9 -1
  132. package/skills/jgengine-combat/api.md +5 -4
  133. package/skills/jgengine-combat/reference.md +4 -0
  134. package/skills/jgengine-gameplay/SKILL.md +86 -0
  135. package/skills/jgengine-gameplay/api.md +485 -16
  136. package/skills/jgengine-gameplay/capabilities.md +131 -4
  137. package/skills/jgengine-gameplay/reference-systems.md +118 -0
  138. package/skills/jgengine-world/SKILL.md +8 -0
  139. package/skills/jgengine-world/api.md +243 -11
  140. package/skills/jgengine-world/capabilities.md +61 -1
  141. package/skills/jgengine-world/recipes/character-animation.md +25 -2
  142. package/skills/jgengine-world/recipes/vehicle-feel.md +5 -0
  143. package/skills/jgengine-world/reference.md +1 -1
@@ -0,0 +1,671 @@
1
+ const EPSILON = 1e-6;
2
+ const DEFAULT_LANE_WIDTH = 3.4;
3
+ const DEFAULT_SPEED = 13;
4
+ const DEFAULT_STOP_LINE = 6;
5
+ const DEFAULT_JOIN_RADIUS = 3;
6
+ const DEFAULT_SPEED_BY_LEVEL = {
7
+ boulevard: 18,
8
+ avenue: 16,
9
+ street: 13,
10
+ lane: 9,
11
+ };
12
+ function segmentLengths(points) {
13
+ const lengths = [];
14
+ for (let i = 0; i < points.length - 1; i += 1) {
15
+ const a = points[i];
16
+ const b = points[i + 1];
17
+ lengths.push(Math.hypot(b[0] - a[0], b[1] - a[1], b[2] - a[2]));
18
+ }
19
+ return lengths;
20
+ }
21
+ function totalLength(lengths) {
22
+ let total = 0;
23
+ for (const length of lengths)
24
+ total += length;
25
+ return total;
26
+ }
27
+ /** Interpolated world point and heading at `arc` along a polyline. */
28
+ function samplePolyline(points, lengths, arc) {
29
+ const clamped = Math.max(0, Math.min(arc, totalLength(lengths)));
30
+ let remaining = clamped;
31
+ for (let i = 0; i < lengths.length; i += 1) {
32
+ const length = lengths[i];
33
+ const a = points[i];
34
+ const b = points[i + 1];
35
+ if (remaining <= length || i === lengths.length - 1) {
36
+ const t = length <= EPSILON ? 0 : Math.min(1, remaining / length);
37
+ return {
38
+ at: [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t],
39
+ heading: Math.atan2(b[0] - a[0], b[2] - a[2]),
40
+ };
41
+ }
42
+ remaining -= length;
43
+ }
44
+ const only = points[0] ?? [0, 0, 0];
45
+ return { at: only, heading: 0 };
46
+ }
47
+ /** Fallback heading for a degenerate polyline, from the span's declared endpoints. */
48
+ function endpointHeading(a, b) {
49
+ return Math.atan2(b[0] - a[0], b[2] - a[2]);
50
+ }
51
+ /**
52
+ * Write the world pose at `arc` along a lane into a caller-owned record — the allocation-free form
53
+ * {@link LaneGraph.pose} wraps, for a runtime that poses hundreds of cars per tick.
54
+ * @internal
55
+ */
56
+ export function lanePoseInto(lane, arc, lateral, out) {
57
+ const sample = samplePolyline(lane.points, lane.segmentLengths, arc);
58
+ const forwardX = Math.sin(sample.heading);
59
+ const forwardZ = Math.cos(sample.heading);
60
+ out.x = sample.at[0] + forwardZ * lateral;
61
+ out.y = sample.at[1];
62
+ out.z = sample.at[2] - forwardX * lateral;
63
+ out.heading = sample.heading;
64
+ }
65
+ function nodeKeyAt(point) {
66
+ return `@${point[0].toFixed(1)},${point[2].toFixed(1)}`;
67
+ }
68
+ function joinNodes(lanes, explicit, joinRadius) {
69
+ const ends = [];
70
+ for (const lane of lanes) {
71
+ ends.push({ point: lane.fromPoint, ...(lane.from === undefined ? {} : { declared: lane.from }) });
72
+ ends.push({ point: lane.toPoint, ...(lane.to === undefined ? {} : { declared: lane.to }) });
73
+ }
74
+ for (const [id, at] of explicit)
75
+ ends.push({ point: at, declared: id });
76
+ const parent = ends.map((_, index) => index);
77
+ const find = (index) => {
78
+ let root = index;
79
+ while (parent[root] !== root)
80
+ root = parent[root];
81
+ let walk = index;
82
+ while (parent[walk] !== root) {
83
+ const next = parent[walk];
84
+ parent[walk] = root;
85
+ walk = next;
86
+ }
87
+ return root;
88
+ };
89
+ const union = (a, b) => {
90
+ const ra = find(a);
91
+ const rb = find(b);
92
+ if (ra === rb)
93
+ return;
94
+ parent[Math.max(ra, rb)] = Math.min(ra, rb);
95
+ };
96
+ const joins = joinRadius * joinRadius;
97
+ for (let i = 0; i < ends.length; i += 1) {
98
+ for (let j = i + 1; j < ends.length; j += 1) {
99
+ const a = ends[i];
100
+ const b = ends[j];
101
+ // An endpoint pinned to a junction id joins only that junction; two different declared
102
+ // junctions stay distinct even when they sit close together.
103
+ if (a.declared !== undefined && b.declared !== undefined && a.declared !== b.declared)
104
+ continue;
105
+ const dx = a.point[0] - b.point[0];
106
+ const dz = a.point[2] - b.point[2];
107
+ if (dx * dx + dz * dz <= joins)
108
+ union(i, j);
109
+ }
110
+ }
111
+ const nodes = new Map();
112
+ const idOfRoot = new Map();
113
+ const membersOfRoot = new Map();
114
+ for (let i = 0; i < ends.length; i += 1) {
115
+ const root = find(i);
116
+ const group = membersOfRoot.get(root);
117
+ if (group === undefined)
118
+ membersOfRoot.set(root, [ends[i]]);
119
+ else
120
+ group.push(ends[i]);
121
+ }
122
+ const taken = new Set(explicit.keys());
123
+ for (const [root, members] of membersOfRoot) {
124
+ const declared = members.find((member) => member.declared !== undefined)?.declared;
125
+ let id = declared;
126
+ if (id === undefined) {
127
+ id = nodeKeyAt(members[0].point);
128
+ if (taken.has(id)) {
129
+ let suffix = 2;
130
+ while (taken.has(`${id}#${suffix}`))
131
+ suffix += 1;
132
+ id = `${id}#${suffix}`;
133
+ }
134
+ }
135
+ taken.add(id);
136
+ idOfRoot.set(root, id);
137
+ nodes.set(id, { id, at: members[0].point });
138
+ }
139
+ const fromIds = new Array(lanes.length);
140
+ const toIds = new Array(lanes.length);
141
+ for (const lane of lanes) {
142
+ fromIds[lane.index] = idOfRoot.get(find(lane.index * 2));
143
+ toIds[lane.index] = idOfRoot.get(find(lane.index * 2 + 1));
144
+ }
145
+ return { from: fromIds, to: toIds, nodes };
146
+ }
147
+ /**
148
+ * Resolve authored spans into lane topology. Endpoints join by declared node id, else by an explicit
149
+ * node within `joinRadius`, else by shared position; a junction with two or more inbound lanes becomes
150
+ * an intersection so its right-of-way is resolved once here rather than per car.
151
+ */
152
+ function resolveLaneGraph(config) {
153
+ const joinRadius = Math.max(0.05, config.joinRadius ?? DEFAULT_JOIN_RADIUS);
154
+ const explicitNodes = new Map();
155
+ for (const node of config.nodes ?? []) {
156
+ if (explicitNodes.has(node.id))
157
+ throw new Error(`duplicate lane node: ${node.id}`);
158
+ explicitNodes.set(node.id, node.at);
159
+ }
160
+ const seenLaneIds = new Set();
161
+ const raw = [];
162
+ const lanes = [];
163
+ config.lanes.forEach((lane, index) => {
164
+ if (seenLaneIds.has(lane.id))
165
+ throw new Error(`duplicate lane: ${lane.id}`);
166
+ seenLaneIds.add(lane.id);
167
+ if (lane.points.length < 2)
168
+ throw new Error(`lane "${lane.id}" needs at least two points`);
169
+ const lengths = segmentLengths(lane.points);
170
+ const length = totalLength(lengths);
171
+ if (!(length > EPSILON))
172
+ throw new Error(`lane "${lane.id}" has zero length`);
173
+ lanes.push({
174
+ id: lane.id,
175
+ from: "",
176
+ to: "",
177
+ points: lane.points,
178
+ segmentLengths: lengths,
179
+ length,
180
+ speed: lane.speed ?? DEFAULT_SPEED,
181
+ width: lane.width ?? DEFAULT_LANE_WIDTH,
182
+ left: lane.left ?? null,
183
+ right: lane.right ?? null,
184
+ parking: (lane.parking ?? []).map((arc) => Math.max(0, Math.min(arc, length))),
185
+ entry: lane.entry ?? false,
186
+ exit: lane.exit ?? false,
187
+ });
188
+ raw.push({
189
+ index,
190
+ fromPoint: lane.points[0],
191
+ toPoint: lane.points[lane.points.length - 1],
192
+ from: lane.from,
193
+ to: lane.to,
194
+ });
195
+ });
196
+ const joined = joinNodes(raw, explicitNodes, joinRadius);
197
+ const laneOf = new Map();
198
+ lanes.forEach((lane, index) => {
199
+ lane.from = joined.from[index];
200
+ lane.to = joined.to[index];
201
+ laneOf.set(lane.id, lane);
202
+ });
203
+ const outByNode = new Map();
204
+ const inByNode = new Map();
205
+ for (const lane of lanes) {
206
+ const out = outByNode.get(lane.from) ?? [];
207
+ out.push(lane.id);
208
+ outByNode.set(lane.from, out);
209
+ const inbound = inByNode.get(lane.to) ?? [];
210
+ inbound.push(lane.id);
211
+ inByNode.set(lane.to, inbound);
212
+ }
213
+ const nodeIds = [...joined.nodes.keys()].sort();
214
+ for (const id of nodeIds) {
215
+ outByNode.get(id)?.sort();
216
+ inByNode.get(id)?.sort();
217
+ }
218
+ const intersections = new Map();
219
+ const addIntersection = (next) => {
220
+ intersections.set(next.node, next);
221
+ };
222
+ for (const lane of lanes) {
223
+ for (const [field, declared] of [
224
+ ["left", lane.left],
225
+ ["right", lane.right],
226
+ ]) {
227
+ if (declared !== null && !laneOf.has(declared)) {
228
+ throw new Error(`lane "${lane.id}" ${field} names unknown lane "${declared}"`);
229
+ }
230
+ }
231
+ }
232
+ const explicitIntersections = new Map();
233
+ for (const entry of config.intersections ?? []) {
234
+ if (!joined.nodes.has(entry.node))
235
+ throw new Error(`intersection names unknown node "${entry.node}"`);
236
+ explicitIntersections.set(entry.node, entry);
237
+ }
238
+ const buildIntersection = (nodeId, entry) => {
239
+ const inbound = inByNode.get(nodeId) ?? [];
240
+ const outbound = outByNode.get(nodeId) ?? [];
241
+ const signal = entry?.signal ?? null;
242
+ if (signal !== null) {
243
+ const phases = signal.phases ?? inbound.map((id) => [id]);
244
+ for (const phase of phases) {
245
+ for (const laneId of phase) {
246
+ if (!inbound.includes(laneId)) {
247
+ throw new Error(`signal phase at node "${nodeId}" names lane "${laneId}", which does not enter it`);
248
+ }
249
+ }
250
+ }
251
+ if (phases.length === 0)
252
+ throw new Error(`signal at node "${nodeId}" has no phases`);
253
+ }
254
+ const shortestInbound = inbound.reduce((shortest, laneId) => {
255
+ const lane = laneOf.get(laneId);
256
+ return Math.min(shortest, lane.length);
257
+ }, Number.POSITIVE_INFINITY);
258
+ const requested = Math.max(0, entry?.stopLine ?? DEFAULT_STOP_LINE);
259
+ const stopLine = Number.isFinite(shortestInbound) ? Math.min(requested, shortestInbound * 0.5) : requested;
260
+ return {
261
+ node: nodeId,
262
+ at: joined.nodes.get(nodeId)?.at ?? [0, 0, 0],
263
+ kind: entry?.kind ?? (signal !== null ? "signal" : "priority"),
264
+ inLanes: inbound,
265
+ outLanes: outbound,
266
+ stopLine,
267
+ signal,
268
+ offsetSeconds: entry?.offsetSeconds ?? 0,
269
+ };
270
+ };
271
+ for (const nodeId of nodeIds) {
272
+ const inbound = inByNode.get(nodeId) ?? [];
273
+ const outbound = outByNode.get(nodeId) ?? [];
274
+ if (inbound.length === 0 || outbound.length === 0)
275
+ continue;
276
+ const entry = explicitIntersections.get(nodeId);
277
+ if (entry === undefined && inbound.length < 2)
278
+ continue;
279
+ addIntersection(buildIntersection(nodeId, entry));
280
+ }
281
+ const spots = [];
282
+ const seenSpotIds = new Set();
283
+ const addSpot = (spot) => {
284
+ if (seenSpotIds.has(spot.id))
285
+ throw new Error(`duplicate parking spot: ${spot.id}`);
286
+ seenSpotIds.add(spot.id);
287
+ const lane = laneOf.get(spot.lane);
288
+ if (lane === undefined)
289
+ throw new Error(`parking spot "${spot.id}" names unknown lane "${spot.lane}"`);
290
+ spots.push({ id: spot.id, lane: spot.lane, arc: Math.max(0, Math.min(spot.arc, lane.length)) });
291
+ };
292
+ for (const spot of config.spots ?? [])
293
+ addSpot(spot);
294
+ for (const lane of lanes) {
295
+ lane.parking.forEach((arc, index) => addSpot({ id: `${lane.id}:parking:${index}`, lane: lane.id, arc }));
296
+ }
297
+ const spotOf = new Map(spots.map((spot) => [spot.id, spot]));
298
+ const entries = [];
299
+ const exits = [];
300
+ for (const lane of lanes) {
301
+ const inboundAtStart = inByNode.get(lane.from) ?? [];
302
+ if (lane.entry || inboundAtStart.length === 0)
303
+ entries.push(lane.id);
304
+ const outboundAtEnd = outByNode.get(lane.to) ?? [];
305
+ if (lane.exit || outboundAtEnd.length === 0)
306
+ exits.push(lane.id);
307
+ }
308
+ entries.sort();
309
+ exits.sort();
310
+ return { nodeIds, nodes: joined.nodes, laneIds: lanes.map((lane) => lane.id), laneOf, outByNode, inByNode, intersections, spots, spotOf, entries, exits };
311
+ }
312
+ /**
313
+ * Read handle over the graph resolved once at construction. The build mutates its working set; this
314
+ * object never changes, so a save system rebuilds it from the scene document instead of serializing it.
315
+ *
316
+ * @capability lane-graph directed lane topology with junctions, signals and parking for vehicle traffic
317
+ */
318
+ export function createLaneGraph(config) {
319
+ const resolved = resolveLaneGraph(config);
320
+ const { nodeIds, laneIds, laneOf, outByNode, inByNode, intersections, spots, spotOf, entries, exits } = resolved;
321
+ const nodes = resolved.nodes;
322
+ return {
323
+ node: (id) => {
324
+ const node = nodes.get(id);
325
+ return node === undefined ? null : { id: node.id, at: node.at };
326
+ },
327
+ lane: (id) => laneOf.get(id) ?? null,
328
+ laneIds: () => laneIds.slice(),
329
+ nodeIds: () => nodeIds.slice(),
330
+ outLanes: (nodeId) => outByNode.get(nodeId) ?? [],
331
+ inLanes: (nodeId) => inByNode.get(nodeId) ?? [],
332
+ intersection: (nodeId) => intersections.get(nodeId) ?? null,
333
+ intersections: () => [...intersections.values()],
334
+ spots: () => spots.slice(),
335
+ spot: (id) => spotOf.get(id) ?? null,
336
+ entries: () => entries.slice(),
337
+ exits: () => exits.slice(),
338
+ pose(laneId, arc, lateral = 0) {
339
+ const lane = laneOf.get(laneId);
340
+ if (lane === undefined)
341
+ return null;
342
+ const out = { x: 0, y: 0, z: 0, heading: 0 };
343
+ lanePoseInto(lane, arc, lateral, out);
344
+ return { laneId, arc, position: [out.x, out.y, out.z], heading: out.heading };
345
+ },
346
+ project(laneId, x, z) {
347
+ const lane = laneOf.get(laneId);
348
+ if (lane === undefined)
349
+ return 0;
350
+ let bestArc = 0;
351
+ let bestSq = Number.POSITIVE_INFINITY;
352
+ let travelled = 0;
353
+ for (let i = 0; i < lane.segmentLengths.length; i += 1) {
354
+ const a = lane.points[i];
355
+ const b = lane.points[i + 1];
356
+ const dx = b[0] - a[0];
357
+ const dz = b[2] - a[2];
358
+ const lengthSq = dx * dx + dz * dz;
359
+ const t = lengthSq <= EPSILON ? 0 : Math.max(0, Math.min(1, ((x - a[0]) * dx + (z - a[2]) * dz) / lengthSq));
360
+ const px = a[0] + dx * t;
361
+ const pz = a[2] + dz * t;
362
+ const distSq = (x - px) * (x - px) + (z - pz) * (z - pz);
363
+ if (distSq < bestSq) {
364
+ bestSq = distSq;
365
+ bestArc = travelled + lane.segmentLengths[i] * t;
366
+ }
367
+ travelled += lane.segmentLengths[i];
368
+ }
369
+ return bestArc;
370
+ },
371
+ heading(laneId, arc) {
372
+ const lane = laneOf.get(laneId);
373
+ if (lane === undefined)
374
+ return 0;
375
+ if (lane.segmentLengths.length === 0) {
376
+ return endpointHeading(lane.points[0], lane.points[lane.points.length - 1]);
377
+ }
378
+ return samplePolyline(lane.points, lane.segmentLengths, arc).heading;
379
+ },
380
+ };
381
+ }
382
+ function metaNumber(meta, key) {
383
+ const value = meta?.[key];
384
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
385
+ }
386
+ function metaBoolean(meta, key) {
387
+ const value = meta?.[key];
388
+ return typeof value === "boolean" ? value : undefined;
389
+ }
390
+ function pathSpeed(path, options) {
391
+ const declared = metaNumber(path.meta, "speed");
392
+ if (declared !== undefined)
393
+ return declared;
394
+ const level = path.meta?.level;
395
+ const byLevel = options.speedByLevel ?? DEFAULT_SPEED_BY_LEVEL;
396
+ if (typeof level === "string" && byLevel[level] !== undefined)
397
+ return byLevel[level];
398
+ return options.defaultSpeed ?? DEFAULT_SPEED;
399
+ }
400
+ function toWaypoints(points) {
401
+ return points.map((point) => [point.x, point.y, point.z]);
402
+ }
403
+ /** Bend a centerline `lateral` metres to its left, keeping both endpoints pinned to the junctions. */
404
+ function offsetCenterline(points, lateral) {
405
+ if (points.length < 2 || lateral === 0)
406
+ return points.map((point) => [point[0], point[1], point[2]]);
407
+ const out = [];
408
+ for (let i = 0; i < points.length; i += 1) {
409
+ const point = points[i];
410
+ if (i === 0 || i === points.length - 1) {
411
+ out.push([point[0], point[1], point[2]]);
412
+ continue;
413
+ }
414
+ const previous = points[i - 1];
415
+ const next = points[i + 1];
416
+ const heading = Math.atan2(next[0] - previous[0], next[2] - previous[2]);
417
+ out.push([point[0] + Math.cos(heading) * lateral, point[1], point[2] - Math.sin(heading) * lateral]);
418
+ }
419
+ return out;
420
+ }
421
+ function cumulativeArcs(points) {
422
+ const arcs = [0];
423
+ for (let i = 1; i < points.length; i += 1) {
424
+ const previous = points[i - 1];
425
+ const point = points[i];
426
+ arcs.push(arcs[i - 1] + Math.hypot(point[0] - previous[0], point[1] - previous[1], point[2] - previous[2]));
427
+ }
428
+ return arcs;
429
+ }
430
+ function indexCenterlines(paths, radius) {
431
+ const buckets = new Map();
432
+ const key = (cx, cz) => `${cx},${cz}`;
433
+ for (const path of paths) {
434
+ for (const point of path.points) {
435
+ const cx = Math.floor(point[0] / radius);
436
+ const cz = Math.floor(point[2] / radius);
437
+ const bucket = buckets.get(key(cx, cz));
438
+ const entry = { pathId: path.id, x: point[0], z: point[2] };
439
+ if (bucket === undefined)
440
+ buckets.set(key(cx, cz), [entry]);
441
+ else
442
+ bucket.push(entry);
443
+ }
444
+ }
445
+ return { cell: radius, buckets };
446
+ }
447
+ /**
448
+ * Interior vertices that another centerline passes through: those are junctions the road crosses, and
449
+ * splitting there is what turns a street centerline into lane spans that meet at a crossing.
450
+ */
451
+ function junctionCuts(index, pathId, points) {
452
+ const radiusSq = index.cell * index.cell;
453
+ const cuts = [];
454
+ for (let i = 1; i < points.length - 1; i += 1) {
455
+ const point = points[i];
456
+ const cx = Math.floor(point[0] / index.cell);
457
+ const cz = Math.floor(point[2] / index.cell);
458
+ let shared = false;
459
+ for (let dx = -1; dx <= 1 && !shared; dx += 1) {
460
+ for (let dz = -1; dz <= 1 && !shared; dz += 1) {
461
+ for (const other of index.buckets.get(`${cx + dx},${cz + dz}`) ?? []) {
462
+ if (other.pathId === pathId)
463
+ continue;
464
+ const ddx = other.x - point[0];
465
+ const ddz = other.z - point[2];
466
+ if (ddx * ddx + ddz * ddz <= radiusSq) {
467
+ shared = true;
468
+ break;
469
+ }
470
+ }
471
+ }
472
+ }
473
+ if (shared)
474
+ cuts.push(i);
475
+ }
476
+ return cuts;
477
+ }
478
+ function nearestLaneAt(graph, x, z, limit) {
479
+ let best = null;
480
+ for (const laneId of graph.laneIds()) {
481
+ const arc = graph.project(laneId, x, z);
482
+ const pose = graph.pose(laneId, arc);
483
+ if (pose === null)
484
+ continue;
485
+ const distance = Math.hypot(pose.position[0] - x, pose.position[2] - z);
486
+ if (distance > limit)
487
+ continue;
488
+ if (best === null || distance < best.distance)
489
+ best = { lane: laneId, arc, distance };
490
+ }
491
+ return best;
492
+ }
493
+ function signalFromMeta(meta) {
494
+ const raw = meta?.signal;
495
+ if (raw === undefined || raw === null || typeof raw !== "object")
496
+ return null;
497
+ const record = raw;
498
+ const green = metaNumber(record, "greenSeconds") ?? metaNumber(record, "green") ?? 12;
499
+ const yellow = metaNumber(record, "yellowSeconds") ?? metaNumber(record, "yellow") ?? 3;
500
+ const allRed = metaNumber(record, "allRedSeconds") ?? metaNumber(record, "allRed");
501
+ const rawPhases = record.phases;
502
+ let phases;
503
+ if (Array.isArray(rawPhases)) {
504
+ phases = rawPhases.map((phase) => (Array.isArray(phase) ? phase.filter((entry) => typeof entry === "string") : []));
505
+ }
506
+ return {
507
+ greenSeconds: green,
508
+ yellowSeconds: yellow,
509
+ ...(allRed === undefined ? {} : { allRedSeconds: allRed }),
510
+ ...(phases === undefined ? {} : { phases }),
511
+ };
512
+ }
513
+ /**
514
+ * Read a lane graph out of an authored scene document. `traffic_lane` paths are one directed lane each;
515
+ * `road` paths (the editor's street bake) become a lane per direction, offset onto their own side of
516
+ * the road; `traffic_intersection` markers carry a signal program and right-of-way in `meta`;
517
+ * `traffic_parking` markers place parking spots. Runtime positions come from the paths, so editing a
518
+ * road moves its traffic instead of leaving a duplicate lane behind.
519
+ *
520
+ * @capability lane-graph-from-scene read an authored lane graph, signals and parking out of the scene document
521
+ */
522
+ export function laneGraphFromDocument(doc, options = {}) {
523
+ const laneKinds = new Set(options.laneKinds ?? ["traffic_lane"]);
524
+ const roadKinds = new Set(options.roadKinds ?? ["road"]);
525
+ const intersectionKinds = new Set(options.intersectionKinds ?? ["traffic_intersection", "traffic_signal"]);
526
+ const parkingKinds = new Set(options.parkingKinds ?? ["traffic_parking"]);
527
+ const laneWidth = DEFAULT_LANE_WIDTH;
528
+ const joinRadius = Math.max(0.5, options.joinRadius ?? DEFAULT_JOIN_RADIUS);
529
+ const lanes = [];
530
+ const consumed = [];
531
+ for (const path of doc.paths) {
532
+ if (path.points.length < 2)
533
+ continue;
534
+ if (!laneKinds.has(path.kind) && !roadKinds.has(path.kind))
535
+ continue;
536
+ consumed.push({ path, points: toWaypoints(path.points) });
537
+ }
538
+ const centerlines = indexCenterlines(consumed.map((entry) => ({ id: entry.path.id, points: entry.points })), joinRadius);
539
+ /** Cut a centerline into the runs between junctions, as `[first,last]` point index pairs. */
540
+ const runsOf = (pathId, points) => {
541
+ const cuts = junctionCuts(centerlines, pathId, points);
542
+ const runs = [];
543
+ let start = 0;
544
+ for (const cut of cuts) {
545
+ if (cut > start)
546
+ runs.push([start, cut]);
547
+ start = cut;
548
+ }
549
+ if (start < points.length - 1)
550
+ runs.push([start, points.length - 1]);
551
+ return runs;
552
+ };
553
+ /** Parking arcs authored along the centerline, moved into one run's own arc space. */
554
+ const parkingForRun = (arcs, arcsAt, runStart, runEnd, pathLength, reverse) => {
555
+ const from = reverse ? pathLength - arcsAt[runEnd] : arcsAt[runStart];
556
+ const to = reverse ? pathLength - arcsAt[runStart] : arcsAt[runEnd];
557
+ const out = [];
558
+ for (const arc of arcs) {
559
+ const position = reverse ? pathLength - arc : arc;
560
+ if (position < from - 1e-6 || position > to + 1e-6)
561
+ continue;
562
+ out.push(position - from);
563
+ }
564
+ return out;
565
+ };
566
+ for (const { path, points } of consumed) {
567
+ const width = path.width ?? laneWidth;
568
+ const speed = pathSpeed(path, options);
569
+ const entry = metaBoolean(path.meta, "entry") ?? false;
570
+ const exit = metaBoolean(path.meta, "exit") ?? false;
571
+ const authoredParking = Array.isArray(path.meta?.parking)
572
+ ? path.meta.parking.filter((arc) => typeof arc === "number" && Number.isFinite(arc))
573
+ : [];
574
+ const arcsAt = cumulativeArcs(points);
575
+ const pathLength = arcsAt[arcsAt.length - 1];
576
+ const runs = runsOf(path.id, points);
577
+ const split = runs.length > 1;
578
+ const push = (baseId, run) => {
579
+ lanes.push({
580
+ id: baseId,
581
+ points: points.slice(run[0], run[1] + 1),
582
+ speed,
583
+ width,
584
+ parking: parkingForRun(authoredParking, arcsAt, run[0], run[1], pathLength, false),
585
+ entry: entry && run[0] === 0,
586
+ exit: exit && run[1] === points.length - 1,
587
+ });
588
+ };
589
+ if (laneKinds.has(path.kind)) {
590
+ runs.forEach((run, index) => {
591
+ push(index === 0 ? path.id : `${path.id}#${index + 1}`, run);
592
+ if (split)
593
+ return;
594
+ const lane = lanes[lanes.length - 1];
595
+ lane.left = typeof path.meta?.left === "string" ? path.meta.left : undefined;
596
+ lane.right = typeof path.meta?.right === "string" ? path.meta.right : undefined;
597
+ });
598
+ continue;
599
+ }
600
+ const half = Math.max(0, width / 2 - laneWidth / 2);
601
+ runs.forEach((run, index) => {
602
+ const forwardId = index === 0 ? `${path.id}:f` : `${path.id}:f#${index + 1}`;
603
+ const reverseId = index === 0 ? `${path.id}:r` : `${path.id}:r#${index + 1}`;
604
+ const slice = points.slice(run[0], run[1] + 1);
605
+ lanes.push({
606
+ id: forwardId,
607
+ points: offsetCenterline(slice, half),
608
+ speed,
609
+ width: laneWidth,
610
+ left: reverseId,
611
+ parking: parkingForRun(authoredParking, arcsAt, run[0], run[1], pathLength, false),
612
+ entry: entry && run[0] === 0,
613
+ exit: exit && run[1] === points.length - 1,
614
+ });
615
+ lanes.push({
616
+ id: reverseId,
617
+ points: offsetCenterline([...slice].reverse(), half),
618
+ speed,
619
+ width: laneWidth,
620
+ left: forwardId,
621
+ parking: parkingForRun(authoredParking, arcsAt, run[0], run[1], pathLength, true),
622
+ entry: entry && run[0] === 0,
623
+ exit: exit && run[1] === points.length - 1,
624
+ });
625
+ });
626
+ }
627
+ const intersectionMarkers = doc.markers.filter((marker) => intersectionKinds.has(marker.kind));
628
+ const nodes = intersectionMarkers.map((marker) => ({
629
+ id: marker.id,
630
+ at: [marker.position.x, marker.position.y, marker.position.z],
631
+ }));
632
+ const preliminary = createLaneGraph({ lanes, joinRadius, nodes });
633
+ const intersections = [];
634
+ for (const marker of intersectionMarkers) {
635
+ const hit = nearestNode(preliminary, marker, joinRadius * 1.5);
636
+ if (hit === null)
637
+ continue;
638
+ const signal = signalFromMeta(marker.meta) ?? (marker.kind === "traffic_signal" ? { greenSeconds: 12, yellowSeconds: 3 } : null);
639
+ const declaredKind = marker.meta?.rightOfWay;
640
+ const stopLine = metaNumber(marker.meta, "stopLine");
641
+ const offsetSeconds = metaNumber(marker.meta, "offsetSeconds");
642
+ intersections.push({
643
+ node: hit,
644
+ kind: declaredKind === "signal" || declaredKind === "priority" ? declaredKind : signal === null ? "priority" : "signal",
645
+ ...(signal === null ? {} : { signal }),
646
+ ...(stopLine === undefined ? {} : { stopLine }),
647
+ ...(offsetSeconds === undefined ? {} : { offsetSeconds }),
648
+ });
649
+ }
650
+ const spots = [];
651
+ for (const marker of doc.markers.filter((entry) => parkingKinds.has(entry.kind))) {
652
+ const hit = nearestLaneAt(preliminary, marker.position.x, marker.position.z, joinRadius * 2);
653
+ if (hit === null)
654
+ continue;
655
+ spots.push({ id: marker.id, lane: hit.lane, arc: hit.arc });
656
+ }
657
+ return createLaneGraph({ lanes, joinRadius, intersections, spots, nodes });
658
+ }
659
+ function nearestNode(graph, marker, limit) {
660
+ let best = null;
661
+ let bestDistance = limit;
662
+ for (const nodeId of graph.nodeIds()) {
663
+ const node = graph.node(nodeId);
664
+ const distance = Math.hypot(node.at[0] - marker.position.x, node.at[2] - marker.position.z);
665
+ if (distance <= bestDistance) {
666
+ bestDistance = distance;
667
+ best = nodeId;
668
+ }
669
+ }
670
+ return best;
671
+ }