@inventure71/paddockjs 2.0.1 → 4.0.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 (136) hide show
  1. package/CHANGELOG.md +176 -1
  2. package/INSTALL_AND_UPDATE.md +24 -4
  3. package/README.md +84 -512
  4. package/docs/architecture.md +38 -24
  5. package/docs/component_inventory.md +72 -0
  6. package/docs/composable-layouts.md +77 -0
  7. package/docs/concepts.md +2 -2
  8. package/docs/custom_model_controller.md +6 -6
  9. package/docs/data-helpers.md +50 -0
  10. package/docs/data_contract.md +104 -27
  11. package/docs/getting-started.md +75 -0
  12. package/docs/headless-environment.md +68 -0
  13. package/docs/index.md +18 -4
  14. package/docs/learnings.md +6 -6
  15. package/docs/rules.md +12 -8
  16. package/docs/sense_contract.md +1 -1
  17. package/docs/system_specs.md +71 -21
  18. package/docs/theming.md +64 -0
  19. package/docs/training.md +26 -19
  20. package/docs/troubleshooting.md +76 -0
  21. package/docs/upgrading-hosts-to-3.md +42 -0
  22. package/examples/python/README.md +6 -0
  23. package/examples/python/base_policy_server.py +19 -0
  24. package/package.json +10 -3
  25. package/src/api/PaddockSimulatorController.js +150 -24
  26. package/src/api/runtimeTheme.js +59 -0
  27. package/src/app/F1SimulatorApp.js +213 -29
  28. package/src/app/banners/raceDataBannerController.js +71 -36
  29. package/src/app/banners/stewardMessageController.js +5 -4
  30. package/src/app/camera/cameraConstants.js +2 -0
  31. package/src/app/camera/cameraController.js +86 -29
  32. package/src/app/camera/cameraGeometry.js +12 -1
  33. package/src/app/domBindings.js +89 -9
  34. package/src/app/layoutScheduler.js +27 -0
  35. package/src/app/layoutSupport.js +165 -0
  36. package/src/app/raceOverlayClearanceSupport.js +158 -0
  37. package/src/app/readouts/carOverviewRenderer.js +16 -8
  38. package/src/app/readouts/controlStateRenderer.js +1 -1
  39. package/src/app/readouts/raceStatusRenderer.js +30 -21
  40. package/src/app/readouts/readoutFormatters.js +2 -0
  41. package/src/app/readouts/telemetryRenderer.js +17 -8
  42. package/src/app/readouts/timingTowerRenderer.js +262 -49
  43. package/src/app/runtime/frameLoop.js +5 -1
  44. package/src/app/runtime/runtimeVisibility.js +66 -5
  45. package/src/config/cssValues.js +16 -0
  46. package/src/config/defaultOptions.js +58 -38
  47. package/src/config/themeOptions.js +830 -0
  48. package/src/config/timingGapMode.js +13 -0
  49. package/src/config/urlOptions.js +25 -0
  50. package/src/data/championship.js +14 -9
  51. package/src/data/index.d.ts +212 -0
  52. package/src/data/index.js +14 -0
  53. package/src/environment/controllerLoop.js +17 -3
  54. package/src/environment/episode.js +11 -1
  55. package/src/environment/index.d.ts +1 -2
  56. package/src/environment/metrics.js +59 -16
  57. package/src/environment/observationVector.js +50 -33
  58. package/src/environment/observations.js +89 -20
  59. package/src/environment/options.js +1 -1
  60. package/src/environment/runtime.js +44 -11
  61. package/src/environment/scenarios.js +1 -1
  62. package/src/environment/sensors/carRays.js +48 -37
  63. package/src/environment/sensors/index.js +501 -97
  64. package/src/environment/sensors/indexedRayBands.js +134 -46
  65. package/src/environment/sensors/rayBandTrace.js +1171 -0
  66. package/src/environment/sensors/rayChannels.js +108 -0
  67. package/src/environment/sensors/rayConfig.js +71 -25
  68. package/src/environment/sensors/rayGeometry.js +23 -17
  69. package/src/environment/sensors/sensorTargets.js +67 -1
  70. package/src/environment/sensors/surfaceRays.js +167 -118
  71. package/src/environment/sensors/trackRays.js +157 -48
  72. package/src/index.d.ts +153 -9
  73. package/src/index.js +52 -3
  74. package/src/simulation/collisionGeometry.js +260 -96
  75. package/src/simulation/driver/driverController.js +1 -1
  76. package/src/simulation/driver/edgeRecovery.js +1 -1
  77. package/src/simulation/driver/racingControls.js +2 -2
  78. package/src/simulation/driver/rejoinControls.js +2 -2
  79. package/src/simulation/pit/pitFlow.js +21 -9
  80. package/src/simulation/pit/pitKinematics.js +2 -2
  81. package/src/simulation/pit/pitQueue.js +5 -3
  82. package/src/simulation/pit/pitRouteMovement.js +9 -4
  83. package/src/simulation/pit/pitRouting.js +79 -46
  84. package/src/simulation/pit/pitState.js +1 -0
  85. package/src/simulation/pit/pitTireService.js +7 -3
  86. package/src/simulation/race/raceFinish.js +25 -8
  87. package/src/simulation/race/raceOrder.js +189 -32
  88. package/src/simulation/race/raceProgress.js +129 -37
  89. package/src/simulation/race/raceSetup.js +4 -6
  90. package/src/simulation/race/raceStep.js +26 -14
  91. package/src/simulation/race/safetyCar.js +1 -1
  92. package/src/simulation/race/stalledDnf.js +1 -1
  93. package/src/simulation/raceSimulation.js +20 -17
  94. package/src/simulation/rules/collisionSteward.js +40 -23
  95. package/src/simulation/rules/rulesReview.js +77 -56
  96. package/src/simulation/snapshots/raceSnapshots.js +37 -0
  97. package/src/simulation/timing/drsTiming.js +143 -30
  98. package/src/simulation/timing/gapEstimation.js +25 -3
  99. package/src/simulation/timing/lapTelemetry.js +138 -40
  100. package/src/simulation/timing/raceTiming.js +5 -1
  101. package/src/simulation/timing/sectorPerformance.js +104 -26
  102. package/src/simulation/timing/timingHistory.js +229 -14
  103. package/src/simulation/timing/timingLines.js +65 -7
  104. package/src/simulation/track/pitLaneLayout.js +30 -3
  105. package/src/simulation/track/pitLaneState.js +218 -19
  106. package/src/simulation/track/spatialQueries.js +66 -33
  107. package/src/simulation/track/trackMath.js +91 -24
  108. package/src/simulation/track/trackModel.js +2 -0
  109. package/src/simulation/track/trackQueryIndex.js +1468 -189
  110. package/src/simulation/track/trackQueryPitIndex.js +138 -14
  111. package/src/simulation/track/trackQueryScratch.js +7 -0
  112. package/src/simulation/track/trackQueryStats.js +25 -0
  113. package/src/simulation/track/trackSnapshotJson.js +139 -0
  114. package/src/simulation/track/trackStatePolicy.js +249 -2
  115. package/src/simulation/vehicle/contactResolution.js +99 -62
  116. package/src/simulation/vehicle/mainTrackWheelSurface.js +73 -34
  117. package/src/simulation/vehicle/pitWheelSurface.js +81 -28
  118. package/src/simulation/vehicle/runoffResponse.js +54 -29
  119. package/src/simulation/vehicle/vehicleGeometry.js +245 -51
  120. package/src/simulation/vehicle/vehicleKinematics.js +2 -2
  121. package/src/simulation/vehicle/vehiclePhysics.js +45 -30
  122. package/src/simulation/vehicle/vehicleSnapshots.js +35 -1
  123. package/src/simulation/vehicle/vehicleState.js +7 -1
  124. package/src/simulation/vehicle/wheelSurface.js +807 -92
  125. package/src/simulation/warmup/runtimeWarmup.js +0 -1
  126. package/src/styles.css +1568 -519
  127. package/src/ui/bannerTemplates.js +38 -28
  128. package/src/ui/cameraControlsTemplate.js +16 -5
  129. package/src/ui/carOverviewTemplate.js +12 -5
  130. package/src/ui/raceCanvasTemplate.js +31 -8
  131. package/src/ui/raceControlsTemplate.js +27 -10
  132. package/src/ui/shellTemplate.js +3 -1
  133. package/src/ui/telemetryDrawerTemplate.js +23 -10
  134. package/src/ui/telemetryTemplates.js +58 -32
  135. package/src/ui/templateUtils.js +48 -0
  136. package/src/ui/timingTowerTemplate.js +29 -12
@@ -1,7 +1,8 @@
1
1
  import { clamp, wrapDistance } from '../simMath.js';
2
2
  import { PIT_LANE_WIDTH } from './trackConstants.js';
3
- import { nearestPointOnPolyline, pointInsideBounds, pointIsInsidePolygon } from './trackMath.js';
3
+ import { nearestPointOnPolyline, nearestPointOnPolylineInto, pointInsideBounds, pointIsInsidePolygon } from './trackMath.js';
4
4
  import { queryPitBoxCandidates, queryPitRoadSegmentCandidatesByRoute } from './trackQueryIndex.js';
5
+ import { ensureQueryScratch } from './trackQueryScratch.js';
5
6
 
6
7
  export function mapPitDistance(track, startDistance, endDistance, amount) {
7
8
  return wrapDistance(startDistance + (endDistance - startDistance) * clamp(amount, 0, 1), track.length);
@@ -15,9 +16,37 @@ export function createPitRoadState(track, position, pitLane, {
15
16
  startDistance,
16
17
  endDistance,
17
18
  segmentCandidates = null,
19
+ projectionTarget = null,
20
+ projectionScratch = null,
21
+ cumulativeDistances = null,
22
+ connectorProjectionStats = false,
23
+ connectorEndpointWindowProjection = false,
18
24
  }) {
25
+ if (!Array.isArray(points) || points.length < 2) return null;
19
26
  if (Array.isArray(segmentCandidates) && segmentCandidates.length === 0) return null;
20
- const projected = nearestPointOnPolyline(points, position, segmentCandidates);
27
+ if (connectorProjectionStats) {
28
+ const stats = track?.queryIndex?.stats;
29
+ if (stats) {
30
+ if (connectorEndpointWindowProjection && Array.isArray(segmentCandidates) && segmentCandidates.length > 0) {
31
+ stats.pitConnectorEndpointWindowProjectionCalls =
32
+ (stats.pitConnectorEndpointWindowProjectionCalls ?? 0) + 1;
33
+ } else if (!Array.isArray(segmentCandidates)) {
34
+ stats.pitConnectorFullRouteProjectionScans =
35
+ (stats.pitConnectorFullRouteProjectionScans ?? 0) + 1;
36
+ }
37
+ }
38
+ }
39
+ if (projectionScratch && Array.isArray(segmentCandidates) && segmentCandidates.length > 0 && !Array.isArray(cumulativeDistances)) {
40
+ const stats = track?.queryIndex?.stats;
41
+ if (stats) stats.pitRoadCumulativeDistanceRebuilds = (stats.pitRoadCumulativeDistanceRebuilds ?? 0) + 1;
42
+ }
43
+ const projected = projectionTarget
44
+ ? nearestPointOnPolylineInto(projectionTarget, points, position, {
45
+ segmentIndices: segmentCandidates,
46
+ scratch: projectionScratch,
47
+ cumulativeDistances,
48
+ })
49
+ : nearestPointOnPolyline(points, position, segmentCandidates);
21
50
  if (!projected || projected.crossTrackError > roadWidth / 2) return null;
22
51
  const amount = projected.totalLength > 0 ? projected.distanceAlong / projected.totalLength : 0;
23
52
  const distanceAlongTrack = mapPitDistance(track, startDistance, endDistance, amount);
@@ -94,21 +123,27 @@ export function createPitBoxState(track, position, pitLane, candidateBoxes = nul
94
123
  };
95
124
  }
96
125
 
97
- export function nearestPitLaneState(track, position) {
126
+ export function nearestPitLaneState(track, position, progressHint = null) {
98
127
  const pitLane = track.pitLane;
99
128
  if (!pitLane?.enabled) return null;
100
129
  if (!pointInsideBounds(position, pitLane.bounds)) return null;
130
+ const scratch = track?.queryIndex ? ensureQueryScratch(track.queryIndex) : null;
101
131
 
102
- const indexedBoxCandidates = queryPitBoxCandidates(track, position);
103
- const boxCandidates = indexedBoxCandidates ?? null;
104
- const boxState = createPitBoxState(track, position, pitLane, boxCandidates);
105
- if (boxState) return boxState;
132
+ if (!pitLane.boxBounds || pointInsideBounds(position, pitLane.boxBounds)) {
133
+ const indexedBoxCandidates = queryPitBoxCandidates(track, position, scratch);
134
+ const boxCandidates = indexedBoxCandidates ?? null;
135
+ const boxState = createPitBoxState(track, position, pitLane, boxCandidates);
136
+ if (boxState) return boxState;
137
+ }
106
138
 
107
139
  const laneEntryDistance = pitLane.layout?.entryDistance ?? pitLane.entry.distanceFromStart;
108
140
  const laneExitDistance = pitLane.layout?.exitDistance ?? pitLane.exit.distanceFromStart;
109
- const roadCandidatesByRoute = queryPitRoadSegmentCandidatesByRoute(track, position);
110
- const candidates = [
111
- createPitRoadState(track, position, pitLane, {
141
+ const roadCandidatesByRoute = queryPitRoadSegmentCandidatesByRoute(track, position, progressHint, scratch);
142
+ const pitRoutes = track?.queryIndex?.pit?.routes ?? {};
143
+ const projectionTarget = scratch ? (scratch.pitRoadProjection ??= { point: {} }) : null;
144
+ const projectionScratch = scratch ? (scratch.pitRoadProjectionScratch ??= {}) : null;
145
+ let bestState = null;
146
+ bestState = pickPitState(bestState, createPitRoadState(track, position, pitLane, {
112
147
  points: pitLane.entry.roadCenterline,
113
148
  surface: 'pit-entry',
114
149
  part: 'entry',
@@ -116,8 +151,11 @@ export function nearestPitLaneState(track, position) {
116
151
  startDistance: pitLane.entry.distanceFromStart,
117
152
  endDistance: laneEntryDistance,
118
153
  segmentCandidates: roadCandidatesByRoute?.entry ?? null,
119
- }),
120
- createPitRoadState(track, position, pitLane, {
154
+ projectionTarget,
155
+ projectionScratch,
156
+ cumulativeDistances: pitRoutes.entry?.cumulativeDistances ?? null,
157
+ }));
158
+ bestState = pickPitState(bestState, createPitRoadState(track, position, pitLane, {
121
159
  points: pitLane.mainLane.points,
122
160
  surface: 'pit-lane',
123
161
  part: 'fast-lane',
@@ -125,8 +163,11 @@ export function nearestPitLaneState(track, position) {
125
163
  startDistance: laneEntryDistance,
126
164
  endDistance: laneExitDistance,
127
165
  segmentCandidates: roadCandidatesByRoute?.main ?? null,
128
- }),
129
- createPitRoadState(track, position, pitLane, {
166
+ projectionTarget,
167
+ projectionScratch,
168
+ cumulativeDistances: pitRoutes.main?.cumulativeDistances ?? null,
169
+ }));
170
+ bestState = pickPitState(bestState, createPitRoadState(track, position, pitLane, {
130
171
  points: pitLane.workingLane?.points,
131
172
  surface: 'pit-lane',
132
173
  part: 'working-lane',
@@ -134,8 +175,11 @@ export function nearestPitLaneState(track, position) {
134
175
  startDistance: laneEntryDistance,
135
176
  endDistance: laneExitDistance,
136
177
  segmentCandidates: roadCandidatesByRoute?.working ?? null,
137
- }),
138
- createPitRoadState(track, position, pitLane, {
178
+ projectionTarget,
179
+ projectionScratch,
180
+ cumulativeDistances: pitRoutes.working?.cumulativeDistances ?? null,
181
+ }));
182
+ bestState = pickPitState(bestState, createPitRoadState(track, position, pitLane, {
139
183
  points: pitLane.exit.roadCenterline,
140
184
  surface: 'pit-exit',
141
185
  part: 'exit',
@@ -143,10 +187,165 @@ export function nearestPitLaneState(track, position) {
143
187
  startDistance: laneExitDistance,
144
188
  endDistance: pitLane.exit.distanceFromStart,
145
189
  segmentCandidates: roadCandidatesByRoute?.exit ?? null,
146
- }),
147
- ].filter(Boolean);
190
+ projectionTarget,
191
+ projectionScratch,
192
+ cumulativeDistances: pitRoutes.exit?.cumulativeDistances ?? null,
193
+ }));
148
194
 
149
- return candidates.sort((left, right) => left.crossTrackError - right.crossTrackError)[0] ?? null;
195
+ return bestState;
196
+ }
197
+
198
+ export function resolveDirectConnectorPitLaneState(track, position, progressHint = null) {
199
+ const pitLane = track?.pitLane;
200
+ if (!pitLane?.enabled) return undefined;
201
+ const inEntryConnector = pointInsideBounds(position, pitLane.connectorBounds?.entry);
202
+ const inExitConnector = pointInsideBounds(position, pitLane.connectorBounds?.exit);
203
+ const nearEntryProgress = Number.isFinite(progressHint) && progressHint >= pitLane.entry.trackDistance;
204
+ const nearExitProgress = Number.isFinite(progressHint) &&
205
+ progressHint >= 0 &&
206
+ progressHint <= pitLane.exit.trackDistance;
207
+ if (!inEntryConnector && !inExitConnector && !nearEntryProgress && !nearExitProgress) return undefined;
208
+
209
+ const laneEntryDistance = pitLane.layout?.entryDistance ?? pitLane.entry.distanceFromStart;
210
+ const laneExitDistance = pitLane.layout?.exitDistance ?? pitLane.exit.distanceFromStart;
211
+ const scratch = track?.queryIndex ? ensureQueryScratch(track.queryIndex) : null;
212
+ const pitRoutes = track?.queryIndex?.pit?.routes ?? {};
213
+ const projectionTarget = scratch ? (scratch.pitRoadProjection ??= { point: {} }) : null;
214
+ const projectionScratch = scratch ? (scratch.pitRoadProjectionScratch ??= {}) : null;
215
+ const useEntryEndpoint = inEntryConnector || nearEntryProgress;
216
+ const useExitEndpoint = inExitConnector || nearExitProgress;
217
+ const useIndexedConnectorCandidates = inEntryConnector || inExitConnector;
218
+ const roadCandidatesByRoute = (useIndexedConnectorCandidates || (!nearEntryProgress && !nearExitProgress))
219
+ ? queryPitRoadSegmentCandidatesByRoute(track, position, null, scratch, {
220
+ countQuery: false,
221
+ recordPathStats: !useIndexedConnectorCandidates,
222
+ })
223
+ : null;
224
+ let bestState = null;
225
+
226
+ if (useEntryEndpoint) {
227
+ const entrySegments = connectorRouteSegments(
228
+ roadCandidatesByRoute,
229
+ 'entry',
230
+ pitRoutes.entry?.startSegmentWindow ?? null,
231
+ inEntryConnector,
232
+ );
233
+ bestState = pickPitState(bestState, createPitRoadState(track, position, pitLane, {
234
+ points: pitLane.entry.roadCenterline,
235
+ surface: 'pit-entry',
236
+ part: 'entry',
237
+ roadWidth: pitLane.width,
238
+ startDistance: pitLane.entry.distanceFromStart,
239
+ endDistance: laneEntryDistance,
240
+ projectionTarget,
241
+ projectionScratch,
242
+ cumulativeDistances: pitRoutes.entry?.cumulativeDistances ?? null,
243
+ segmentCandidates: entrySegments.segmentCandidates,
244
+ connectorProjectionStats: true,
245
+ connectorEndpointWindowProjection: entrySegments.endpointWindow,
246
+ }));
247
+ }
248
+
249
+ if (useEntryEndpoint || useExitEndpoint) {
250
+ const mainSegments = connectorRouteSegments(
251
+ roadCandidatesByRoute,
252
+ 'main',
253
+ connectorRouteSegmentWindow(pitRoutes.main, useEntryEndpoint, useExitEndpoint),
254
+ useIndexedConnectorCandidates,
255
+ );
256
+ bestState = pickPitState(bestState, createPitRoadState(track, position, pitLane, {
257
+ points: pitLane.mainLane.points,
258
+ surface: 'pit-lane',
259
+ part: 'fast-lane',
260
+ roadWidth: pitLane.width,
261
+ startDistance: laneEntryDistance,
262
+ endDistance: laneExitDistance,
263
+ projectionTarget,
264
+ projectionScratch,
265
+ cumulativeDistances: pitRoutes.main?.cumulativeDistances ?? null,
266
+ segmentCandidates: mainSegments.segmentCandidates,
267
+ connectorProjectionStats: true,
268
+ connectorEndpointWindowProjection: mainSegments.endpointWindow,
269
+ }));
270
+ const workingSegments = connectorRouteSegments(
271
+ roadCandidatesByRoute,
272
+ 'working',
273
+ connectorRouteSegmentWindow(pitRoutes.working, useEntryEndpoint, useExitEndpoint),
274
+ useIndexedConnectorCandidates,
275
+ );
276
+ bestState = pickPitState(bestState, createPitRoadState(track, position, pitLane, {
277
+ points: pitLane.workingLane?.points,
278
+ surface: 'pit-lane',
279
+ part: 'working-lane',
280
+ roadWidth: pitLane.workingLane?.width ?? 0,
281
+ startDistance: laneEntryDistance,
282
+ endDistance: laneExitDistance,
283
+ projectionTarget,
284
+ projectionScratch,
285
+ cumulativeDistances: pitRoutes.working?.cumulativeDistances ?? null,
286
+ segmentCandidates: workingSegments.segmentCandidates,
287
+ connectorProjectionStats: true,
288
+ connectorEndpointWindowProjection: workingSegments.endpointWindow,
289
+ }));
290
+ }
291
+
292
+ if (useExitEndpoint) {
293
+ const exitSegments = connectorRouteSegments(
294
+ roadCandidatesByRoute,
295
+ 'exit',
296
+ pitRoutes.exit?.endSegmentWindow ?? null,
297
+ inExitConnector,
298
+ );
299
+ bestState = pickPitState(bestState, createPitRoadState(track, position, pitLane, {
300
+ points: pitLane.exit.roadCenterline,
301
+ surface: 'pit-exit',
302
+ part: 'exit',
303
+ roadWidth: pitLane.width,
304
+ startDistance: laneExitDistance,
305
+ endDistance: pitLane.exit.distanceFromStart,
306
+ projectionTarget,
307
+ projectionScratch,
308
+ cumulativeDistances: pitRoutes.exit?.cumulativeDistances ?? null,
309
+ segmentCandidates: exitSegments.segmentCandidates,
310
+ connectorProjectionStats: true,
311
+ connectorEndpointWindowProjection: exitSegments.endpointWindow,
312
+ }));
313
+ }
314
+
315
+ return bestState;
316
+ }
317
+
318
+ function connectorRouteSegmentWindow(route, useStart, useEnd) {
319
+ if (!route) return null;
320
+ if (useStart && useEnd) return route.endpointSegmentWindow ?? null;
321
+ if (useStart) return route.startSegmentWindow ?? null;
322
+ if (useEnd) return route.endSegmentWindow ?? null;
323
+ return null;
324
+ }
325
+
326
+ function connectorRouteSegments(candidatesByRoute, routeId, endpointWindow, preferIndexedCandidates) {
327
+ if (preferIndexedCandidates && candidatesByRoute) {
328
+ return {
329
+ segmentCandidates: candidatesByRoute[routeId] ?? [],
330
+ endpointWindow: false,
331
+ };
332
+ }
333
+ if (Array.isArray(endpointWindow)) {
334
+ return {
335
+ segmentCandidates: endpointWindow,
336
+ endpointWindow: endpointWindow.length > 0,
337
+ };
338
+ }
339
+ return {
340
+ segmentCandidates: candidatesByRoute?.[routeId] ?? null,
341
+ endpointWindow: false,
342
+ };
343
+ }
344
+
345
+ function pickPitState(currentBest, candidate) {
346
+ if (!candidate) return currentBest;
347
+ if (!currentBest) return candidate;
348
+ return candidate.crossTrackError < currentBest.crossTrackError ? candidate : currentBest;
150
349
  }
151
350
 
152
351
  function createLegacyPitBoxCandidates(pitLane) {
@@ -1,7 +1,9 @@
1
1
  import { clamp, normalizeAngle, wrapDistance } from '../simMath.js';
2
- import { NEAREST_HINT_WINDOW_SAMPLES, PIT_LANE_WIDTH } from './trackConstants.js';
3
- import { nearestPitLaneState } from './pitLaneState.js';
4
- import { queryNearestTrackProjection } from './trackQueryIndex.js';
2
+ import { PIT_LANE_WIDTH } from './trackConstants.js';
3
+ import { pointInsideBounds } from './trackMath.js';
4
+ import { nearestPitLaneState, resolveDirectConnectorPitLaneState } from './pitLaneState.js';
5
+ import { queryNearestTrackProjection, queryNearestTrackProjectionInto } from './trackQueryIndex.js';
6
+ import { recordStat } from './trackQueryStats.js';
5
7
 
6
8
  export function pointAt(track, distanceAlong) {
7
9
  const wrapped = wrapDistance(distanceAlong, track.length);
@@ -62,6 +64,10 @@ export function nearestSampleGlobal(track, position) {
62
64
  }
63
65
 
64
66
  export function createTrackState(track, position, best) {
67
+ return writeTrackState({}, track, position, best);
68
+ }
69
+
70
+ export function writeTrackState(target, track, position, best) {
65
71
  const dx = position.x - best.x;
66
72
  const dy = position.y - best.y;
67
73
  const signedOffset = dx * best.normalX + dy * best.normalY;
@@ -80,27 +86,47 @@ export function createTrackState(track, position, best) {
80
86
  ? 'grass'
81
87
  : 'barrier';
82
88
 
83
- return {
84
- ...best,
85
- signedOffset,
86
- crossTrackError,
87
- surface,
88
- onTrack: surface === 'track' || surface === 'kerb',
89
- };
89
+ target.segmentId = best.segmentId;
90
+ target.x = best.x;
91
+ target.y = best.y;
92
+ target.distance = best.distance;
93
+ target.heading = best.heading;
94
+ target.normalX = best.normalX;
95
+ target.normalY = best.normalY;
96
+ target.curvature = best.curvature;
97
+ target.signedOffset = signedOffset;
98
+ target.crossTrackError = crossTrackError;
99
+ target.surface = surface;
100
+ target.onTrack = surface === 'track' || surface === 'kerb';
101
+ target.distanceSquared = best.distanceSquared;
102
+ target.inPitLane = undefined;
103
+ target.pitLanePart = undefined;
104
+ target.pitBoxId = undefined;
105
+ target.mainTrackSignedOffset = undefined;
106
+ target.mainTrackCrossTrackError = undefined;
107
+ return target;
90
108
  }
91
109
 
92
110
  export function nearestTrackState(track, position, progressHint = null, options = {}) {
93
111
  const allowPitOverride = options.allowPitOverride !== false;
94
- const best = options.indexMode === 'legacy'
95
- ? nearestTrackSampleLegacy(track, position, progressHint)
96
- : queryNearestTrackProjection(track, position, progressHint, {
97
- indexMode: options.indexMode,
98
- hintMaxDistance: options.hintMaxDistance,
99
- }) ??
100
- nearestTrackSampleLegacy(track, position, progressHint);
112
+ const scratch = track?.queryIndex?.queryScratch ?? null;
113
+ const best = queryNearestTrackProjectionInto(
114
+ track,
115
+ position,
116
+ progressHint,
117
+ scratch ? (scratch.nearestProjection ??= {}) : null,
118
+ );
119
+ if (!best) {
120
+ throw new Error('nearestTrackState requires a finite position and an indexed track model. Build tracks with buildTrackModel() or createRaceSimulation().');
121
+ }
101
122
  const trackState = createTrackState(track, position, best);
102
123
  if (!allowPitOverride) return trackState;
103
- const pitState = nearestPitLaneState(track, position);
124
+ const pitState = queryPitOverrideState(track, position, progressHint, trackState);
125
+ if (!pitState) return trackState;
126
+ return resolveTrackStatePitOverride(track, trackState, pitState);
127
+ }
128
+
129
+ export function resolveTrackStatePitOverride(track, trackState, pitState) {
104
130
  if (!pitState) return trackState;
105
131
 
106
132
  const mainRoadEdge = track.width / 2 + (track.kerbWidth ?? 0);
@@ -125,24 +151,31 @@ export function nearestTrackState(track, position, progressHint = null, options
125
151
  };
126
152
  }
127
153
 
128
- function nearestTrackSampleLegacy(track, position, progressHint = null) {
129
- let nearest = null;
154
+ function queryPitOverrideState(track, position, progressHint, trackState) {
155
+ const pitLane = track?.pitLane;
156
+ if (!pitLane?.enabled) return null;
157
+ if (!pointInsideBounds(position, pitLane.bounds)) return null;
158
+ if (pitLane.boxBounds && pointInsideBounds(position, pitLane.boxBounds)) {
159
+ return nearestPitLaneState(track, position, progressHint);
160
+ }
130
161
 
131
- if (Number.isFinite(progressHint)) {
132
- const centerIndex = sampleIndexAtDistance(track, progressHint);
133
- nearest = nearestSampleInRange(
134
- track,
135
- position,
136
- centerIndex - NEAREST_HINT_WINDOW_SAMPLES,
137
- centerIndex + NEAREST_HINT_WINDOW_SAMPLES,
138
- );
139
- const fallbackDistance = track.width / 2 + (track.kerbWidth ?? 0) + track.gravelWidth + track.runoffWidth + 180;
140
- if (!nearest.best || nearest.bestDistance > fallbackDistance * fallbackDistance) {
141
- nearest = null;
162
+ const mainRoadEdge = track.width / 2 + (track.kerbWidth ?? 0);
163
+ if (trackState.crossTrackError <= mainRoadEdge) {
164
+ recordStat(track?.queryIndex, 'pitPaths', 'main-road-skip');
165
+ return null;
166
+ }
167
+ if ((trackState.signedOffset ?? 0) * pitLane.side > 0) {
168
+ const directConnectorState = resolveDirectConnectorPitLaneState(track, position, progressHint);
169
+ if (directConnectorState !== undefined) {
170
+ if (directConnectorState) {
171
+ recordStat(track?.queryIndex, 'pitPaths', 'connector-direct-state');
172
+ return directConnectorState;
173
+ }
174
+ recordStat(track?.queryIndex, 'pitPaths', 'connector-direct-skip');
175
+ return null;
142
176
  }
143
177
  }
144
-
145
- return nearest?.best ?? nearestSampleGlobal(track, position).best;
178
+ return nearestPitLaneState(track, position, progressHint);
146
179
  }
147
180
 
148
181
  export function offsetTrackPoint(point, offset) {
@@ -127,6 +127,16 @@ export function expandBounds(bounds, point) {
127
127
  };
128
128
  }
129
129
 
130
+ export function expandBoundsByPadding(bounds, padding = 0) {
131
+ const resolvedPadding = Number.isFinite(padding) ? Math.max(0, padding) : 0;
132
+ return {
133
+ minX: bounds.minX - resolvedPadding,
134
+ maxX: bounds.maxX + resolvedPadding,
135
+ minY: bounds.minY - resolvedPadding,
136
+ maxY: bounds.maxY + resolvedPadding,
137
+ };
138
+ }
139
+
130
140
  export function createPointBounds(points, padding = 0) {
131
141
  const bounds = points.filter(Boolean).reduce(
132
142
  (current, point) => expandBounds(current, point),
@@ -181,43 +191,100 @@ export function projectPointToSegment(position, start, end) {
181
191
  }
182
192
 
183
193
  export function nearestPointOnPolyline(points, position, segmentIndices = null) {
194
+ return nearestPointOnPolylineInto({}, points, position, { segmentIndices });
195
+ }
196
+
197
+ export function nearestPointOnPolylineInto(target, points, position, {
198
+ segmentIndices = null,
199
+ scratch = null,
200
+ cumulativeDistances: precomputedCumulativeDistances = null,
201
+ } = {}) {
184
202
  if (!Array.isArray(points) || points.length < 2) return null;
185
- const restrictedSegments = Array.isArray(segmentIndices) && segmentIndices.length > 0
186
- ? [...new Set(segmentIndices)].filter((index) => Number.isInteger(index) && index >= 0 && index < points.length - 1)
203
+ const output = target ?? {};
204
+ const outputPoint = output.point ?? {};
205
+ output.point = outputPoint;
206
+ const restrictedSegments = Array.isArray(segmentIndices) && segmentIndices.length > 0;
207
+ const cumulativeDistances = restrictedSegments
208
+ ? (Array.isArray(precomputedCumulativeDistances) && precomputedCumulativeDistances.length >= points.length
209
+ ? precomputedCumulativeDistances
210
+ : writePolylineCumulativeDistances(scratch?.cumulativeDistances ?? new Array(points.length), points))
187
211
  : null;
188
- const cumulativeDistances = restrictedSegments ? createPolylineCumulativeDistances(points) : null;
189
- let best = null;
212
+ if (scratch && restrictedSegments && cumulativeDistances !== precomputedCumulativeDistances) {
213
+ scratch.cumulativeDistances = cumulativeDistances;
214
+ }
215
+
216
+ let bestSegmentIndex = -1;
217
+ let bestAmount = 0;
218
+ let bestLength = 0;
219
+ let bestHeading = 0;
220
+ let bestNormalX = 0;
221
+ let bestNormalY = 1;
222
+ let bestSignedOffset = 0;
223
+ let bestCrossTrackError = Infinity;
224
+ let bestDistanceAlong = 0;
225
+ let bestX = 0;
226
+ let bestY = 0;
190
227
  let distanceBefore = 0;
191
228
  let totalLength = 0;
192
229
 
193
- const segmentCount = restrictedSegments?.length ?? (points.length - 1);
230
+ const segmentCount = restrictedSegments ? segmentIndices.length : (points.length - 1);
194
231
  for (let segmentOffset = 0; segmentOffset < segmentCount; segmentOffset += 1) {
195
- const index = restrictedSegments ? restrictedSegments[segmentOffset] : segmentOffset;
232
+ const index = restrictedSegments ? segmentIndices[segmentOffset] : segmentOffset;
233
+ if (!Number.isInteger(index) || index < 0 || index >= points.length - 1) continue;
196
234
  const current = points[index];
197
235
  const next = points[index + 1];
198
- const projection = projectPointToSegment(position, current, next);
199
- if (!best || projection.crossTrackError < best.crossTrackError) {
200
- best = {
201
- ...projection,
202
- segmentIndex: index,
203
- distanceAlong: (restrictedSegments ? cumulativeDistances[index] : distanceBefore) + projection.length * projection.amount,
204
- };
236
+ const dx = next.x - current.x;
237
+ const dy = next.y - current.y;
238
+ const lengthSquared = dx * dx + dy * dy;
239
+ const amount = lengthSquared > 0
240
+ ? clamp(((position.x - current.x) * dx + (position.y - current.y) * dy) / lengthSquared, 0, 1)
241
+ : 0;
242
+ const projectedX = current.x + dx * amount;
243
+ const projectedY = current.y + dy * amount;
244
+ const length = Math.sqrt(lengthSquared);
245
+ const heading = Math.atan2(dy, dx);
246
+ const normalX = length > 0 ? -dy / length : 0;
247
+ const normalY = length > 0 ? dx / length : 1;
248
+ const signedOffset = (position.x - projectedX) * normalX + (position.y - projectedY) * normalY;
249
+ const crossTrackError = Math.abs(signedOffset);
250
+ if (crossTrackError < bestCrossTrackError) {
251
+ bestSegmentIndex = index;
252
+ bestAmount = amount;
253
+ bestLength = length;
254
+ bestHeading = heading;
255
+ bestNormalX = normalX;
256
+ bestNormalY = normalY;
257
+ bestSignedOffset = signedOffset;
258
+ bestCrossTrackError = crossTrackError;
259
+ bestDistanceAlong = (restrictedSegments ? cumulativeDistances[index] : distanceBefore) + length * amount;
260
+ bestX = projectedX;
261
+ bestY = projectedY;
205
262
  }
206
- if (!restrictedSegments) distanceBefore += projection.length;
207
- totalLength += projection.length;
263
+ if (!restrictedSegments) distanceBefore += length;
264
+ totalLength += length;
208
265
  }
209
266
 
210
- if (!best) return null;
211
- return {
212
- ...best,
213
- totalLength: restrictedSegments
214
- ? cumulativeDistances[cumulativeDistances.length - 1]
215
- : totalLength,
216
- };
267
+ if (bestSegmentIndex < 0) return null;
268
+ outputPoint.x = bestX;
269
+ outputPoint.y = bestY;
270
+ output.amount = bestAmount;
271
+ output.length = bestLength;
272
+ output.heading = bestHeading;
273
+ output.normalX = bestNormalX;
274
+ output.normalY = bestNormalY;
275
+ output.signedOffset = bestSignedOffset;
276
+ output.crossTrackError = bestCrossTrackError;
277
+ output.segmentIndex = bestSegmentIndex;
278
+ output.distanceAlong = bestDistanceAlong;
279
+ output.totalLength = restrictedSegments
280
+ ? cumulativeDistances[cumulativeDistances.length - 1]
281
+ : totalLength;
282
+ return output;
217
283
  }
218
284
 
219
- function createPolylineCumulativeDistances(points) {
220
- const distances = new Array(points.length).fill(0);
285
+ function writePolylineCumulativeDistances(distances, points) {
286
+ distances.length = points.length;
287
+ distances[0] = 0;
221
288
  for (let index = 1; index < points.length; index += 1) {
222
289
  distances[index] = distances[index - 1] + distance(points[index - 1], points[index]);
223
290
  }
@@ -11,6 +11,7 @@ import { deriveDrsZones, normalizeDrsZone } from './drsZones.js';
11
11
  import { createPitLaneModel } from './pitLaneLayout.js';
12
12
  import { attachTrackQueryIndex, createTrackQueryIndex } from './trackQueryIndex.js';
13
13
  import { resolveProceduralTrackOptions } from './trackGenerationOptions.js';
14
+ import { attachTrackSnapshotJsonSerializer } from './trackSnapshotJson.js';
14
15
 
15
16
  export { WORLD, TRACK } from './trackConstants.js';
16
17
  export { isInDrsZone } from './drsZones.js';
@@ -56,6 +57,7 @@ export function buildTrackModel(track = TRACK) {
56
57
  ? null
57
58
  : createPitLaneModel(model);
58
59
  attachTrackQueryIndex(model, createTrackQueryIndex(model));
60
+ attachTrackSnapshotJsonSerializer(model);
59
61
  if (canReuseCachedModel) {
60
62
  TRACK_MODEL_CACHE.set(track, freezeTrackModel(model));
61
63
  }