@inventure71/paddockjs 3.0.0 → 4.1.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 (97) hide show
  1. package/CHANGELOG.md +152 -1
  2. package/INSTALL_AND_UPDATE.md +21 -4
  3. package/README.md +103 -551
  4. package/docs/architecture.md +28 -18
  5. package/docs/component_inventory.md +5 -4
  6. package/docs/composable-layouts.md +79 -0
  7. package/docs/custom_model_controller.md +4 -4
  8. package/docs/data-helpers.md +50 -0
  9. package/docs/data_contract.md +13 -7
  10. package/docs/getting-started.md +105 -0
  11. package/docs/headless-environment.md +68 -0
  12. package/docs/index.md +17 -4
  13. package/docs/rules.md +6 -2
  14. package/docs/system_specs.md +60 -7
  15. package/docs/theming.md +64 -0
  16. package/docs/training.md +20 -14
  17. package/docs/troubleshooting.md +76 -0
  18. package/docs/upgrading-hosts-to-3.md +42 -0
  19. package/examples/python/README.md +6 -0
  20. package/examples/python/base_policy_server.py +19 -0
  21. package/package.json +21 -5
  22. package/src/api/PaddockSimulatorController.js +78 -22
  23. package/src/api/runtimeTheme.js +59 -0
  24. package/src/app/F1SimulatorApp.js +24 -3
  25. package/src/app/readouts/timingTowerRenderer.js +203 -42
  26. package/src/app/runtime/frameLoop.js +5 -1
  27. package/src/config/defaultOptions.js +8 -1
  28. package/src/config/themeOptions.js +5 -0
  29. package/src/data/index.d.ts +212 -0
  30. package/src/data/index.js +14 -0
  31. package/src/environment/episode.js +11 -1
  32. package/src/environment/metrics.js +59 -16
  33. package/src/environment/observationVector.js +50 -33
  34. package/src/environment/observations.js +89 -20
  35. package/src/environment/runtime.js +41 -0
  36. package/src/environment/sensors/carRays.js +48 -37
  37. package/src/environment/sensors/index.js +501 -97
  38. package/src/environment/sensors/indexedRayBands.js +134 -46
  39. package/src/environment/sensors/rayBandTrace.js +1171 -0
  40. package/src/environment/sensors/rayChannels.js +108 -0
  41. package/src/environment/sensors/rayConfig.js +71 -25
  42. package/src/environment/sensors/rayGeometry.js +23 -17
  43. package/src/environment/sensors/sensorTargets.js +67 -1
  44. package/src/environment/sensors/surfaceRays.js +130 -89
  45. package/src/environment/sensors/trackRays.js +115 -1
  46. package/src/index.d.ts +43 -0
  47. package/src/index.js +27 -2
  48. package/src/placeholder/index.d.ts +11 -0
  49. package/src/placeholder/index.js +61 -0
  50. package/src/placeholder.css +72 -0
  51. package/src/simulation/collisionGeometry.js +260 -96
  52. package/src/simulation/pit/pitFlow.js +21 -9
  53. package/src/simulation/pit/pitQueue.js +5 -3
  54. package/src/simulation/pit/pitRouteMovement.js +9 -4
  55. package/src/simulation/pit/pitRouting.js +77 -42
  56. package/src/simulation/pit/pitState.js +1 -0
  57. package/src/simulation/pit/pitTireService.js +7 -3
  58. package/src/simulation/race/raceFinish.js +25 -8
  59. package/src/simulation/race/raceOrder.js +189 -32
  60. package/src/simulation/race/raceProgress.js +129 -37
  61. package/src/simulation/race/raceSetup.js +2 -0
  62. package/src/simulation/race/raceStep.js +25 -13
  63. package/src/simulation/raceSimulation.js +20 -17
  64. package/src/simulation/rules/collisionSteward.js +40 -23
  65. package/src/simulation/rules/rulesReview.js +77 -56
  66. package/src/simulation/snapshots/raceSnapshots.js +37 -0
  67. package/src/simulation/timing/drsTiming.js +143 -30
  68. package/src/simulation/timing/gapEstimation.js +25 -3
  69. package/src/simulation/timing/lapTelemetry.js +115 -36
  70. package/src/simulation/timing/raceTiming.js +5 -1
  71. package/src/simulation/timing/sectorPerformance.js +104 -26
  72. package/src/simulation/timing/timingHistory.js +229 -14
  73. package/src/simulation/timing/timingLines.js +65 -7
  74. package/src/simulation/track/pitLaneLayout.js +23 -0
  75. package/src/simulation/track/pitLaneState.js +218 -19
  76. package/src/simulation/track/spatialQueries.js +67 -11
  77. package/src/simulation/track/trackMath.js +91 -24
  78. package/src/simulation/track/trackModel.js +2 -0
  79. package/src/simulation/track/trackQueryIndex.js +1334 -100
  80. package/src/simulation/track/trackQueryPitIndex.js +138 -14
  81. package/src/simulation/track/trackQueryScratch.js +7 -0
  82. package/src/simulation/track/trackQueryStats.js +25 -0
  83. package/src/simulation/track/trackSnapshotJson.js +139 -0
  84. package/src/simulation/track/trackStatePolicy.js +249 -1
  85. package/src/simulation/vehicle/contactResolution.js +96 -59
  86. package/src/simulation/vehicle/mainTrackWheelSurface.js +73 -34
  87. package/src/simulation/vehicle/pitWheelSurface.js +81 -28
  88. package/src/simulation/vehicle/runoffResponse.js +54 -29
  89. package/src/simulation/vehicle/vehicleGeometry.js +245 -51
  90. package/src/simulation/vehicle/vehiclePhysics.js +42 -27
  91. package/src/simulation/vehicle/vehicleSnapshots.js +23 -0
  92. package/src/simulation/vehicle/vehicleState.js +6 -0
  93. package/src/simulation/vehicle/wheelSurface.js +807 -92
  94. package/src/styles.css +497 -497
  95. package/src/ui/raceControlsTemplate.js +1 -0
  96. package/src/ui/telemetryDrawerTemplate.js +1 -0
  97. package/src/ui/telemetryTemplates.js +1 -0
@@ -3,6 +3,53 @@ import { metersToSimUnits, simUnitsToMeters } from '../units.js';
3
3
  import { pointAt } from '../track/trackModel.js';
4
4
  import { TIMING_LINE_HISTORY_LAPS, TIMING_LINE_TARGET_SPACING_METERS } from './timingConstants.js';
5
5
 
6
+ function setTimingLineCrossingBounds(car, firstLine, lastLine) {
7
+ car.timingLineFirstStored = Number.isInteger(firstLine) ? firstLine : null;
8
+ car.timingLineLastStored = Number.isInteger(lastLine) ? lastLine : null;
9
+ }
10
+
11
+ function ensureTimingLineCrossingBounds(car) {
12
+ if (Number.isInteger(car.timingLineFirstStored) && Number.isInteger(car.timingLineLastStored)) {
13
+ return {
14
+ firstLine: car.timingLineFirstStored,
15
+ lastLine: car.timingLineLastStored,
16
+ };
17
+ }
18
+ const lineNumbers = Object.keys(car.timingLineCrossings)
19
+ .map((key) => Number(key))
20
+ .filter((value) => Number.isFinite(value))
21
+ .sort((left, right) => left - right);
22
+ const firstLine = lineNumbers[0];
23
+ const lastLine = lineNumbers[lineNumbers.length - 1];
24
+ setTimingLineCrossingBounds(car, firstLine, lastLine);
25
+ return {
26
+ firstLine: car.timingLineFirstStored,
27
+ lastLine: car.timingLineLastStored,
28
+ };
29
+ }
30
+
31
+ function setTimingLineCrossing(car, lineNumber, crossingTime) {
32
+ car.timingLineCrossings[lineNumber] = crossingTime;
33
+ const firstLine = car.timingLineFirstStored;
34
+ const lastLine = car.timingLineLastStored;
35
+ if (!Number.isInteger(firstLine) || lineNumber < firstLine) car.timingLineFirstStored = lineNumber;
36
+ if (!Number.isInteger(lastLine) || lineNumber > lastLine) car.timingLineLastStored = lineNumber;
37
+ }
38
+
39
+ function pruneTimingLineCrossings(car, cutoffLine) {
40
+ const { firstLine, lastLine } = ensureTimingLineCrossingBounds(car);
41
+ if (!Number.isInteger(firstLine) || !Number.isInteger(lastLine) || cutoffLine <= firstLine) return;
42
+ const pruneEnd = Math.min(lastLine, cutoffLine - 1);
43
+ for (let lineNumber = firstLine; lineNumber <= pruneEnd; lineNumber += 1) {
44
+ delete car.timingLineCrossings[lineNumber];
45
+ }
46
+ if (cutoffLine > lastLine) {
47
+ setTimingLineCrossingBounds(car, null, null);
48
+ return;
49
+ }
50
+ setTimingLineCrossingBounds(car, cutoffLine, lastLine);
51
+ }
52
+
6
53
  export function createTimingLines(track) {
7
54
  const targetSpacing = metersToSimUnits(TIMING_LINE_TARGET_SPACING_METERS);
8
55
  const count = Math.max(1, Math.round(track.length / targetSpacing));
@@ -36,6 +83,8 @@ export function resetTimingLineCrossings(car, currentTime) {
36
83
  car.timingLineCrossings = Object.create(null);
37
84
  car.timingLineLastUpdatedAt = currentTime;
38
85
  car.previousRaceDistanceForTiming = car.raceDistance;
86
+ setTimingLineCrossingBounds(car, null, null);
87
+ car.timingLinePruneCutoff = null;
39
88
  }
40
89
 
41
90
  export function recordTimingLineCrossings(car, previousRaceDistance, currentTime, track) {
@@ -51,6 +100,18 @@ export function recordTimingLineCrossings(car, previousRaceDistance, currentTime
51
100
  const travelled = currentDistance - previousDistance;
52
101
  const previousTime = Number.isFinite(car.timingLineLastUpdatedAt) ? car.timingLineLastUpdatedAt : currentTime;
53
102
  const elapsed = Math.max(0, currentTime - previousTime);
103
+ const latestLine = getTimingLineNumber(track, currentDistance);
104
+ const cutoff = latestLine != null
105
+ ? latestLine - (track.timingLines.count * TIMING_LINE_HISTORY_LAPS)
106
+ : null;
107
+
108
+ if (Math.abs(travelled) <= 1e-6) {
109
+ car.timingLineLastUpdatedAt = currentTime;
110
+ if (!Number.isFinite(cutoff) || cutoff <= 0 || cutoff === car.timingLinePruneCutoff) return;
111
+ pruneTimingLineCrossings(car, cutoff);
112
+ car.timingLinePruneCutoff = cutoff;
113
+ return;
114
+ }
54
115
 
55
116
  if (travelled > 1e-6 && travelled < track.length / 2) {
56
117
  const firstLine = Math.floor(Math.max(0, previousDistance) / spacing) + 1;
@@ -59,17 +120,14 @@ export function recordTimingLineCrossings(car, previousRaceDistance, currentTime
59
120
  const lineDistance = lineNumber * spacing;
60
121
  if (lineDistance <= previousDistance + 1e-6 || lineDistance > currentDistance + 1e-6) continue;
61
122
  const ratio = clamp((lineDistance - previousDistance) / travelled, 0, 1);
62
- car.timingLineCrossings[lineNumber] = previousTime + ratio * elapsed;
123
+ setTimingLineCrossing(car, lineNumber, previousTime + ratio * elapsed);
63
124
  }
64
125
  }
65
126
 
66
127
  car.timingLineLastUpdatedAt = currentTime;
67
- const latestLine = getTimingLineNumber(track, currentDistance);
68
- if (latestLine != null) {
69
- const cutoff = latestLine - (track.timingLines.count * TIMING_LINE_HISTORY_LAPS);
70
- Object.keys(car.timingLineCrossings).forEach((key) => {
71
- if (Number(key) < cutoff) delete car.timingLineCrossings[key];
72
- });
128
+ if (Number.isFinite(cutoff) && cutoff > 0 && cutoff !== car.timingLinePruneCutoff) {
129
+ pruneTimingLineCrossings(car, cutoff);
130
+ car.timingLinePruneCutoff = cutoff;
73
131
  }
74
132
  }
75
133
 
@@ -274,6 +274,27 @@ export function createPitLaneBounds(pitLane) {
274
274
  return createPointBounds(points, padding);
275
275
  }
276
276
 
277
+ export function createPitBoxBounds(pitLane) {
278
+ const points = [
279
+ ...(pitLane.boxes ?? []).flatMap((box) => box.corners ?? []),
280
+ ...(pitLane.serviceAreas ?? []).flatMap((area) => [
281
+ ...(area.corners ?? []),
282
+ ...(area.queueCorners ?? []),
283
+ ]),
284
+ ];
285
+ if (!points.length) return null;
286
+ const padding = Math.max(PIT_BOX_DEPTH, PIT_SERVICE_AREA_DEPTH) / 2 + metersToSimUnits(3);
287
+ return createPointBounds(points, padding);
288
+ }
289
+
290
+ export function createPitConnectorBounds(pitLane) {
291
+ const connectorPadding = metersToSimUnits(18);
292
+ return {
293
+ entry: createPointBounds(pitLane.entry?.connector ?? [], connectorPadding),
294
+ exit: createPointBounds(pitLane.exit?.connector ?? [], connectorPadding),
295
+ };
296
+ }
297
+
277
298
  export function createPitLaneModel(track) {
278
299
  const layout = createPitLaneLayout();
279
300
  const laneOffset = track.width / 2 + (track.kerbWidth ?? 0) + PIT_LANE_EDGE_GAP + PIT_LANE_WIDTH / 2;
@@ -395,5 +416,7 @@ export function createPitLaneModel(track) {
395
416
  return {
396
417
  ...pitLane,
397
418
  bounds: createPitLaneBounds(pitLane),
419
+ boxBounds: createPitBoxBounds(pitLane),
420
+ connectorBounds: createPitConnectorBounds(pitLane),
398
421
  };
399
422
  }
@@ -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
2
  import { PIT_LANE_WIDTH } from './trackConstants.js';
3
- import { nearestPitLaneState } from './pitLaneState.js';
4
- import { queryNearestTrackProjection } from './trackQueryIndex.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,24 +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 = queryNearestTrackProjection(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
+ );
95
119
  if (!best) {
96
120
  throw new Error('nearestTrackState requires a finite position and an indexed track model. Build tracks with buildTrackModel() or createRaceSimulation().');
97
121
  }
98
122
  const trackState = createTrackState(track, position, best);
99
123
  if (!allowPitOverride) return trackState;
100
- 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) {
101
130
  if (!pitState) return trackState;
102
131
 
103
132
  const mainRoadEdge = track.width / 2 + (track.kerbWidth ?? 0);
@@ -122,6 +151,33 @@ export function nearestTrackState(track, position, progressHint = null, options
122
151
  };
123
152
  }
124
153
 
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
+ }
161
+
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;
176
+ }
177
+ }
178
+ return nearestPitLaneState(track, position, progressHint);
179
+ }
180
+
125
181
  export function offsetTrackPoint(point, offset) {
126
182
  return {
127
183
  x: point.x + point.normalX * offset,