@inventure71/paddockjs 2.0.0 → 3.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 (110) hide show
  1. package/CHANGELOG.md +37 -1
  2. package/INSTALL_AND_UPDATE.md +10 -1
  3. package/README.md +92 -17
  4. package/docs/architecture.md +26 -16
  5. package/docs/component_inventory.md +72 -0
  6. package/docs/concepts.md +7 -5
  7. package/docs/custom_model_controller.md +2 -2
  8. package/docs/data_contract.md +121 -33
  9. package/docs/data_model.md +1 -1
  10. package/docs/index.md +10 -0
  11. package/docs/learnings.md +6 -6
  12. package/docs/rules.md +39 -9
  13. package/docs/sense_contract.md +59 -0
  14. package/docs/system_specs.md +76 -20
  15. package/docs/training.md +27 -20
  16. package/package.json +1 -1
  17. package/src/api/PaddockSimulatorController.js +75 -36
  18. package/src/app/BrowserExpertAdapter.js +9 -300
  19. package/src/app/BrowserExpertExternalRenderer.js +303 -0
  20. package/src/app/F1SimulatorApp.js +190 -68
  21. package/src/app/banners/raceDataBannerController.js +71 -36
  22. package/src/app/banners/stewardMessageController.js +5 -4
  23. package/src/app/camera/cameraConstants.js +2 -0
  24. package/src/app/camera/cameraController.js +86 -29
  25. package/src/app/camera/cameraGeometry.js +12 -1
  26. package/src/app/domBindings.js +89 -9
  27. package/src/app/layoutScheduler.js +27 -0
  28. package/src/app/layoutSupport.js +165 -0
  29. package/src/app/raceOverlayClearanceSupport.js +158 -0
  30. package/src/app/readouts/carOverviewRenderer.js +16 -8
  31. package/src/app/readouts/controlStateRenderer.js +1 -1
  32. package/src/app/readouts/raceStatusRenderer.js +30 -21
  33. package/src/app/readouts/readoutFormatters.js +2 -0
  34. package/src/app/readouts/telemetryRenderer.js +17 -8
  35. package/src/app/readouts/timingTowerRenderer.js +62 -10
  36. package/src/app/runtime/runtimeVisibility.js +66 -5
  37. package/src/config/cssValues.js +16 -0
  38. package/src/config/defaultOptions.js +51 -38
  39. package/src/config/restartOptions.js +34 -0
  40. package/src/config/themeOptions.js +825 -0
  41. package/src/config/timingGapMode.js +13 -0
  42. package/src/config/urlOptions.js +25 -0
  43. package/src/data/championship.js +14 -9
  44. package/src/environment/controllerLoop.js +34 -5
  45. package/src/environment/episode.js +14 -2
  46. package/src/environment/index.d.ts +17 -4
  47. package/src/environment/observationVector.js +200 -0
  48. package/src/environment/observations.js +58 -285
  49. package/src/environment/options.js +1 -1
  50. package/src/environment/recorder.js +13 -6
  51. package/src/environment/rewards.js +13 -2
  52. package/src/environment/runtime.js +65 -25
  53. package/src/environment/scenarios.js +22 -3
  54. package/src/environment/sensors/index.js +3 -3
  55. package/src/environment/sensors/opponentRadar.js +13 -3
  56. package/src/environment/sensors/sensorTargets.js +8 -2
  57. package/src/environment/sensors/surfaceRays.js +84 -12
  58. package/src/environment/sensors/trackRays.js +173 -15
  59. package/src/environment/specs.js +78 -40
  60. package/src/index.d.ts +112 -9
  61. package/src/index.js +26 -33
  62. package/src/simulation/driver/driverController.js +1 -1
  63. package/src/simulation/driver/edgeRecovery.js +1 -1
  64. package/src/simulation/driver/racingControls.js +2 -2
  65. package/src/simulation/driver/rejoinControls.js +2 -2
  66. package/src/simulation/pit/pitFlow.js +5 -0
  67. package/src/simulation/pit/pitKinematics.js +24 -0
  68. package/src/simulation/pit/pitQueue.js +5 -0
  69. package/src/simulation/pit/pitRouteMovement.js +7 -0
  70. package/src/simulation/pit/pitRouting.js +2 -4
  71. package/src/simulation/pit/pitService.js +2 -0
  72. package/src/simulation/pit/pitTireService.js +2 -0
  73. package/src/simulation/race/raceLifecycle.js +2 -6
  74. package/src/simulation/race/raceProgress.js +6 -1
  75. package/src/simulation/race/raceSetup.js +2 -6
  76. package/src/simulation/race/raceStep.js +2 -2
  77. package/src/simulation/race/redFlag.js +3 -3
  78. package/src/simulation/race/safetyCar.js +10 -4
  79. package/src/simulation/race/stalledDnf.js +8 -17
  80. package/src/simulation/raceSimulation.js +1 -0
  81. package/src/simulation/rules/rulesReview.js +3 -0
  82. package/src/simulation/snapshots/raceSnapshots.js +1 -0
  83. package/src/simulation/timing/lapTelemetry.js +28 -9
  84. package/src/simulation/track/pitLaneLayout.js +7 -3
  85. package/src/simulation/track/spatialQueries.js +5 -28
  86. package/src/simulation/track/trackQueryGrid.js +94 -0
  87. package/src/simulation/track/trackQueryIndex.js +193 -434
  88. package/src/simulation/track/trackQueryPitIndex.js +140 -0
  89. package/src/simulation/track/trackQueryScratch.js +46 -0
  90. package/src/simulation/track/trackQueryStats.js +28 -0
  91. package/src/simulation/track/trackStatePolicy.js +0 -1
  92. package/src/simulation/vehicle/contactResolution.js +67 -2
  93. package/src/simulation/vehicle/runoffResponse.js +11 -12
  94. package/src/simulation/vehicle/vehicleKinematics.js +37 -0
  95. package/src/simulation/vehicle/vehiclePhysics.js +5 -4
  96. package/src/simulation/vehicle/vehicleSnapshots.js +22 -0
  97. package/src/simulation/vehicle/vehicleState.js +36 -2
  98. package/src/simulation/vehicle/wheelSurface.js +3 -1
  99. package/src/simulation/warmup/runtimeWarmup.js +0 -1
  100. package/src/styles.css +1185 -136
  101. package/src/ui/bannerTemplates.js +38 -28
  102. package/src/ui/cameraControlsTemplate.js +16 -5
  103. package/src/ui/carOverviewTemplate.js +12 -5
  104. package/src/ui/raceCanvasTemplate.js +31 -8
  105. package/src/ui/raceControlsTemplate.js +27 -10
  106. package/src/ui/shellTemplate.js +3 -1
  107. package/src/ui/telemetryDrawerTemplate.js +23 -10
  108. package/src/ui/telemetryTemplates.js +58 -32
  109. package/src/ui/templateUtils.js +48 -0
  110. package/src/ui/timingTowerTemplate.js +29 -12
@@ -1,5 +1,21 @@
1
1
  import { clamp, wrapDistance } from '../simMath.js';
2
2
  import { metersToSimUnits } from '../units.js';
3
+ import {
4
+ candidateIdsFromGrid,
5
+ candidateIdsFromGridBounds,
6
+ createSpatialGrid,
7
+ gridCellForPoint,
8
+ insertIdIntoGridBounds,
9
+ } from './trackQueryGrid.js';
10
+ import { createPitQueryIndex } from './trackQueryPitIndex.js';
11
+ import { createQueryScratch, ensureQueryScratch, ensureScratchArray, nextScratchEpoch } from './trackQueryScratch.js';
12
+ import { createQueryStats, recordFallback, recordStat } from './trackQueryStats.js';
13
+
14
+ export {
15
+ queryPitBoxCandidates,
16
+ queryPitRoadSegmentCandidates,
17
+ queryPitRoadSegmentCandidatesByRoute,
18
+ } from './trackQueryPitIndex.js';
3
19
 
4
20
  const DEFAULT_GRID_CELL_SIZE = metersToSimUnits(32);
5
21
  const GRID_NEIGHBOR_LIMIT = 2;
@@ -27,6 +43,7 @@ export function createTrackQueryIndex(track) {
27
43
  const index = {
28
44
  version: 1,
29
45
  bands,
46
+ nearestGridExpansion: expansion,
30
47
  centerline,
31
48
  grid,
32
49
  segmentGrid,
@@ -38,7 +55,7 @@ export function createTrackQueryIndex(track) {
38
55
  return index;
39
56
  }
40
57
 
41
- export function queryNearestTrackProjection(track, position, progressHint = null, options = {}) {
58
+ export function queryNearestTrackProjection(track, position, progressHint = null) {
42
59
  const index = track?.queryIndex;
43
60
  if (!index?.centerline?.segmentCount) {
44
61
  recordFallback(index, 'nearestFallbackReasons', 'missing-index');
@@ -50,8 +67,7 @@ export function queryNearestTrackProjection(track, position, progressHint = null
50
67
  }
51
68
  index.stats.nearestQueries += 1;
52
69
 
53
- const mode = options.indexMode === 'sample' ? 'sample' : 'projection';
54
- const { projection, path, reason } = bestNearestFromIndex(track, index, position, progressHint, mode, options);
70
+ const { projection, path, reason } = bestProjectionFromIndex(index, position, progressHint);
55
71
  if (!projection) {
56
72
  recordFallback(index, 'nearestFallbackReasons', reason ?? 'unknown');
57
73
  return null;
@@ -61,169 +77,173 @@ export function queryNearestTrackProjection(track, position, progressHint = null
61
77
  return projection;
62
78
  }
63
79
 
64
- function bestNearestFromIndex(track, index, position, progressHint, mode, options) {
65
- return mode === 'sample'
66
- ? bestSampleFromIndex(track, index, position, progressHint, options)
67
- : bestProjectionFromIndex(index, position, progressHint, options);
68
- }
69
-
70
- function bestProjectionFromIndex(index, position, progressHint, options) {
80
+ function bestProjectionFromIndex(index, position, progressHint) {
71
81
  const hintedCandidateIds = Number.isFinite(progressHint)
72
82
  ? candidateIdsFromArcBuckets(index, progressHint, 2)
73
83
  : [];
74
84
  if (hintedCandidateIds.length) {
75
85
  const hinted = bestProjectionFromCandidates(index.centerline, hintedCandidateIds, position, progressHint);
76
- const maxHintDistance = resolveHintMaxDistance(index, options);
77
- if (hinted.projection && hinted.projection.distanceSquared <= maxHintDistance * maxHintDistance) {
78
- return {
79
- projection: hinted.projection,
80
- path: hinted.reason === 'tie-resolved' ? 'arc-hint-tie-resolved' : 'arc-hint',
81
- };
86
+ if (hinted.projection) {
87
+ const exact = bestProjectionFromCellSearch(index, position, progressHint, hinted.projection);
88
+ if (exact.projection) {
89
+ const path = projectionMatches(hinted.projection, exact.projection)
90
+ ? (hinted.reason === 'tie-resolved' ? 'arc-hint-tie-resolved' : 'arc-hint')
91
+ : 'arc-hint-refined';
92
+ return {
93
+ projection: exact.projection,
94
+ path: exact.reason === 'tie-resolved' && !path.endsWith('tie-resolved') ? `${path}-tie-resolved` : path,
95
+ };
96
+ }
82
97
  }
83
- recordStat(index, 'nearestPaths', hinted.projection ? 'arc-hint-rejected-distance' : `arc-hint-${hinted.reason}`);
84
98
  }
85
99
 
100
+ let local = { projection: null, path: null };
86
101
  const gridCandidateIds = candidateIdsFromGrid(index, index.grid, position, GRID_NEIGHBOR_LIMIT);
87
102
  const grid = bestProjectionFromCandidates(index.centerline, gridCandidateIds, position, progressHint);
88
- return grid.projection
89
- ? {
90
- projection: grid.projection,
91
- path: grid.reason === 'tie-resolved' ? 'spatial-grid-tie-resolved' : 'spatial-grid',
103
+ if (grid.projection) {
104
+ local = chooseBetterProjectionResult(local, grid, 'spatial-grid', progressHint);
105
+ if (grid.projection.distanceSquared <= index.nearestGridExpansion * index.nearestGridExpansion) {
106
+ return local;
92
107
  }
93
- : { projection: null, reason: `spatial-grid-${grid.reason}` };
94
- }
108
+ }
95
109
 
96
- function bestSampleFromIndex(track, index, position, progressHint, options) {
97
- const samples = track?.samples;
98
- const sampleCount = Math.max(0, samples?.length - 1);
99
- if (sampleCount <= 0) return { projection: null, reason: 'missing-samples' };
100
- const hintedCandidateIds = Number.isFinite(progressHint)
101
- ? candidateIdsFromArcBuckets(index, progressHint, 2)
102
- : [];
103
- if (hintedCandidateIds.length) {
104
- const hinted = bestSampleFromCandidates(index, samples, sampleCount, hintedCandidateIds, position, progressHint);
105
- const maxHintDistance = resolveHintMaxDistance(index, options);
106
- if (hinted.sample && hinted.distanceSquared <= maxHintDistance * maxHintDistance) {
110
+ if (local.projection) {
111
+ const exact = bestProjectionFromCellSearch(index, position, progressHint, local.projection);
112
+ if (exact.projection) {
113
+ const path = projectionMatches(local.projection, exact.projection)
114
+ ? local.path
115
+ : `${local.path}-refined`;
107
116
  return {
108
- projection: hinted.sample,
109
- path: hinted.reason === 'tie-resolved' ? 'arc-hint-tie-resolved' : 'arc-hint',
117
+ projection: exact.projection,
118
+ path: exact.reason === 'tie-resolved' && !path.endsWith('tie-resolved') ? `${path}-tie-resolved` : path,
110
119
  };
111
120
  }
112
- recordStat(index, 'nearestPaths', hinted.sample ? 'arc-hint-rejected-distance' : `arc-hint-${hinted.reason}`);
121
+
122
+ return {
123
+ projection: local.projection,
124
+ path: local.path,
125
+ };
113
126
  }
114
127
 
115
- const gridCandidateIds = candidateIdsFromGrid(index, index.grid, position, GRID_NEIGHBOR_LIMIT);
116
- const grid = bestSampleFromCandidates(index, samples, sampleCount, gridCandidateIds, position, progressHint);
117
- return grid.sample
128
+ const exact = bestProjectionFromCellSearch(index, position, progressHint);
129
+ const global = exact.projection ? exact : bestProjectionFromAllSegments(index.centerline, position, progressHint);
130
+ return global.projection
118
131
  ? {
119
- projection: grid.sample,
120
- path: grid.reason === 'tie-resolved' ? 'spatial-grid-tie-resolved' : 'spatial-grid',
132
+ projection: global.projection,
133
+ path: exact.projection
134
+ ? (global.reason === 'tie-resolved' ? 'exact-grid-tie-resolved' : 'exact-grid')
135
+ : (global.reason === 'tie-resolved' ? 'global-index-tie-resolved' : 'global-index'),
121
136
  }
122
- : { projection: null, reason: `spatial-grid-${grid.reason}` };
137
+ : { projection: null, reason: `global-index-${global.reason}` };
123
138
  }
124
139
 
125
- function resolveHintMaxDistance(index, options = {}) {
126
- if (options.hintMaxDistance == null) return index.bands.runoffEdge + metersToSimUnits(24);
127
- if (!Number.isFinite(options.hintMaxDistance)) return Infinity;
128
- return Math.max(0, options.hintMaxDistance);
140
+ function chooseBetterProjectionResult(current, candidate, source, preferredDistance) {
141
+ if (!candidate.projection) return current;
142
+ const path = candidate.reason === 'tie-resolved' ? `${source}-tie-resolved` : source;
143
+ if (!current.projection) return { projection: candidate.projection, path };
144
+ if (candidate.projection.distanceSquared < current.projection.distanceSquared - AMBIGUOUS_DISTANCE_EPSILON) {
145
+ return { projection: candidate.projection, path };
146
+ }
147
+ if (Math.abs(candidate.projection.distanceSquared - current.projection.distanceSquared) <= AMBIGUOUS_DISTANCE_EPSILON) {
148
+ const candidateTie = projectionTieScore(candidate.projection, preferredDistance);
149
+ const currentTie = projectionTieScore(current.projection, preferredDistance);
150
+ if (projectionTieBeats(candidate.projection, candidateTie, current.projection, currentTie)) {
151
+ return { projection: candidate.projection, path: `${path}-tie-resolved` };
152
+ }
153
+ }
154
+ return current;
129
155
  }
130
156
 
131
- function bestSampleFromCandidates(index, samples, sampleCount, candidateSegmentIds, position, preferredDistance = null) {
132
- if (!candidateSegmentIds.length || sampleCount <= 0) return { sample: null, reason: 'no-candidates' };
133
- const scratch = ensureQueryScratch(index);
134
- const sampleMarks = ensureScratchArray(scratch, 'sampleMarks', sampleCount);
135
- const sampleEpoch = nextScratchEpoch(scratch, 'sampleEpoch', sampleMarks);
157
+ function bestProjectionFromCellSearch(index, position, preferredDistance, initialProjection = null) {
158
+ const grid = index.segmentGrid ?? index.grid;
159
+ const center = gridCellForPoint(grid, position, true);
136
160
 
137
- let bestSample = null;
138
- let bestDistanceSquared = Infinity;
139
- let bestTieScore = Infinity;
161
+ const scratch = ensureQueryScratch(index);
162
+ const segmentMarks = ensureScratchArray(scratch, 'exactSegmentMarks', index.centerline.segmentCount);
163
+ const cellMarks = ensureScratchArray(scratch, 'exactCellMarks', grid.columns * grid.rows);
164
+ const segmentEpoch = nextScratchEpoch(scratch, 'exactSegmentEpoch', segmentMarks);
165
+ const cellEpoch = nextScratchEpoch(scratch, 'exactCellEpoch', cellMarks);
166
+ const maxRadius = Math.max(grid.columns, grid.rows);
167
+ let best = initialProjection;
168
+ let bestTieScore = best ? projectionTieScore(best, preferredDistance) : Infinity;
140
169
  let secondBestDistance = Infinity;
141
170
  let tieResolved = false;
142
- for (const segmentId of candidateSegmentIds) {
143
- const normalized = ((segmentId % sampleCount) + sampleCount) % sampleCount;
144
- const adjacent = (normalized + 1) % sampleCount;
145
- if (sampleMarks[normalized] !== sampleEpoch) {
146
- sampleMarks[normalized] = sampleEpoch;
147
- ({ bestSample, bestDistanceSquared, bestTieScore, secondBestDistance, tieResolved } = considerSampleCandidate({
148
- samples,
149
- sampleId: normalized,
150
- position,
151
- preferredDistance,
152
- bestSample,
153
- bestDistanceSquared,
154
- bestTieScore,
155
- secondBestDistance,
156
- tieResolved,
157
- }));
158
- }
159
- if (sampleMarks[adjacent] !== sampleEpoch) {
160
- sampleMarks[adjacent] = sampleEpoch;
161
- ({ bestSample, bestDistanceSquared, bestTieScore, secondBestDistance, tieResolved } = considerSampleCandidate({
162
- samples,
163
- sampleId: adjacent,
164
- position,
165
- preferredDistance,
166
- bestSample,
167
- bestDistanceSquared,
168
- bestTieScore,
169
- secondBestDistance,
170
- tieResolved,
171
- }));
172
- }
173
- }
174
171
 
175
- if (!bestSample) return { sample: null, reason: 'no-best' };
172
+ for (let radius = 0; radius <= maxRadius; radius += 1) {
173
+ let ringCanImprove = !best;
176
174
 
175
+ forEachRingCell(grid, center, radius, (cellIndex, row, column) => {
176
+ if (cellMarks[cellIndex] === cellEpoch) return;
177
+ cellMarks[cellIndex] = cellEpoch;
178
+ const lowerBound = cellDistanceSquared(grid, row, column, position);
179
+ if (best && lowerBound > best.distanceSquared + AMBIGUOUS_DISTANCE_EPSILON) {
180
+ return;
181
+ }
182
+ ringCanImprove = true;
183
+ const cell = grid.cells[cellIndex];
184
+ if (!cell) return;
185
+ for (const segmentId of cell) {
186
+ if (segmentMarks[segmentId] === segmentEpoch) continue;
187
+ segmentMarks[segmentId] = segmentEpoch;
188
+ const projection = projectIndexedSegment(index.centerline, segmentId, position);
189
+ const tieScore = projectionTieScore(projection, preferredDistance);
190
+ if (!best || projection.distanceSquared < best.distanceSquared - AMBIGUOUS_DISTANCE_EPSILON) {
191
+ secondBestDistance = best?.distanceSquared ?? Infinity;
192
+ best = projection;
193
+ bestTieScore = tieScore;
194
+ } else if (Math.abs(projection.distanceSquared - best.distanceSquared) <= AMBIGUOUS_DISTANCE_EPSILON) {
195
+ tieResolved = true;
196
+ secondBestDistance = Math.min(secondBestDistance, projection.distanceSquared);
197
+ if (projectionTieBeats(projection, tieScore, best, bestTieScore)) {
198
+ best = projection;
199
+ bestTieScore = tieScore;
200
+ }
201
+ } else if (projection.distanceSquared < secondBestDistance) {
202
+ secondBestDistance = projection.distanceSquared;
203
+ }
204
+ }
205
+ });
206
+ if (best && !ringCanImprove) break;
207
+ }
208
+
209
+ if (!best) return { projection: null, reason: 'no-candidates' };
177
210
  const ambiguous = Number.isFinite(secondBestDistance) &&
178
- Math.abs(secondBestDistance - bestDistanceSquared) <= AMBIGUOUS_DISTANCE_EPSILON;
179
- return {
180
- sample: bestSample,
181
- distanceSquared: bestDistanceSquared,
182
- reason: ambiguous || tieResolved ? 'tie-resolved' : 'ok',
183
- };
211
+ Math.abs(secondBestDistance - best.distanceSquared) <= AMBIGUOUS_DISTANCE_EPSILON;
212
+ return { projection: best, reason: ambiguous || tieResolved ? 'tie-resolved' : 'ok' };
184
213
  }
185
214
 
186
- function considerSampleCandidate({
187
- samples,
188
- sampleId,
189
- position,
190
- preferredDistance,
191
- bestSample,
192
- bestDistanceSquared,
193
- bestTieScore,
194
- secondBestDistance,
195
- tieResolved,
196
- }) {
197
- const sample = samples[sampleId];
198
- if (!sample) {
199
- return { bestSample, bestDistanceSquared, bestTieScore, secondBestDistance, tieResolved };
200
- }
201
- const dx = position.x - sample.x;
202
- const dy = position.y - sample.y;
203
- const distanceSquared = dx * dx + dy * dy;
204
- const tieScore = sampleTieScore(sample, preferredDistance);
205
- if (!bestSample || distanceSquared < bestDistanceSquared - AMBIGUOUS_DISTANCE_EPSILON) {
206
- secondBestDistance = bestDistanceSquared;
207
- bestSample = sample;
208
- bestDistanceSquared = distanceSquared;
209
- bestTieScore = tieScore;
210
- } else if (Math.abs(distanceSquared - bestDistanceSquared) <= AMBIGUOUS_DISTANCE_EPSILON) {
211
- tieResolved = true;
212
- secondBestDistance = Math.min(secondBestDistance, distanceSquared);
213
- if (tieScore < bestTieScore) {
214
- bestSample = sample;
215
- bestDistanceSquared = distanceSquared;
216
- bestTieScore = tieScore;
215
+ function projectionMatches(first, second) {
216
+ return first.segmentId === second.segmentId &&
217
+ Math.abs(first.distanceSquared - second.distanceSquared) <= AMBIGUOUS_DISTANCE_EPSILON;
218
+ }
219
+
220
+ function forEachRingCell(grid, center, radius, visitor) {
221
+ const minRow = Math.max(0, center.row - radius);
222
+ const maxRow = Math.min(grid.rows - 1, center.row + radius);
223
+ const minColumn = Math.max(0, center.column - radius);
224
+ const maxColumn = Math.min(grid.columns - 1, center.column + radius);
225
+ for (let row = minRow; row <= maxRow; row += 1) {
226
+ for (let column = minColumn; column <= maxColumn; column += 1) {
227
+ if (
228
+ radius > 0 &&
229
+ row !== minRow &&
230
+ row !== maxRow &&
231
+ column !== minColumn &&
232
+ column !== maxColumn
233
+ ) continue;
234
+ visitor(row * grid.columns + column, row, column);
217
235
  }
218
- } else if (distanceSquared < secondBestDistance) {
219
- secondBestDistance = distanceSquared;
220
236
  }
221
- return { bestSample, bestDistanceSquared, bestTieScore, secondBestDistance, tieResolved };
222
237
  }
223
238
 
224
- function sampleTieScore(sample, preferredDistance) {
225
- if (!Number.isFinite(preferredDistance)) return sample.distance;
226
- return Math.abs(sample.distance - preferredDistance);
239
+ function cellDistanceSquared(grid, row, column, position) {
240
+ const minX = grid.bounds.minX + column * grid.cellSize;
241
+ const minY = grid.bounds.minY + row * grid.cellSize;
242
+ const maxX = Math.min(grid.bounds.maxX, minX + grid.cellSize);
243
+ const maxY = Math.min(grid.bounds.maxY, minY + grid.cellSize);
244
+ const dx = position.x < minX ? minX - position.x : position.x > maxX ? position.x - maxX : 0;
245
+ const dy = position.y < minY ? minY - position.y : position.y > maxY ? position.y - maxY : 0;
246
+ return dx * dx + dy * dy;
227
247
  }
228
248
 
229
249
  function bestProjectionFromCandidates(centerline, candidateIds, position, preferredDistance = null) {
@@ -242,7 +262,7 @@ function bestProjectionFromCandidates(centerline, candidateIds, position, prefer
242
262
  } else if (Math.abs(projection.distanceSquared - best.distanceSquared) <= AMBIGUOUS_DISTANCE_EPSILON) {
243
263
  tieResolved = true;
244
264
  secondBestDistance = Math.min(secondBestDistance, projection.distanceSquared);
245
- if (tieScore < bestTieScore) {
265
+ if (projectionTieBeats(projection, tieScore, best, bestTieScore)) {
246
266
  best = projection;
247
267
  bestTieScore = tieScore;
248
268
  }
@@ -258,62 +278,48 @@ function bestProjectionFromCandidates(centerline, candidateIds, position, prefer
258
278
  return { projection: best, reason: ambiguous || tieResolved ? 'tie-resolved' : 'ok' };
259
279
  }
260
280
 
261
- function projectionTieScore(projection, preferredDistance) {
262
- if (!Number.isFinite(preferredDistance)) return projection.segmentId;
263
- return Math.abs(projection.distance - preferredDistance);
264
- }
281
+ function bestProjectionFromAllSegments(centerline, position, preferredDistance = null) {
282
+ const segmentCount = centerline?.segmentCount ?? 0;
283
+ if (segmentCount <= 0) return { projection: null, reason: 'no-candidates' };
284
+ let best = null;
285
+ let bestTieScore = Infinity;
286
+ let secondBestDistance = Infinity;
287
+ let tieResolved = false;
265
288
 
266
- export function queryPitBoxCandidates(track, position) {
267
- const index = track?.queryIndex;
268
- const pit = index?.pit;
269
- if (!pit?.boxGrid) {
270
- recordFallback(index, 'pitFallbackReasons', 'missing-box-index');
271
- return null;
272
- }
273
- if (!finitePoint(position)) {
274
- recordFallback(index, 'pitFallbackReasons', 'invalid-position');
275
- return null;
276
- }
277
- index.stats.pitQueries += 1;
278
- const ids = candidateIdsFromGrid(index, pit.boxGrid, position, 1);
279
- if (!ids.length) {
280
- recordStat(index, 'pitPaths', 'box-grid-miss');
281
- return [];
289
+ for (let segmentId = 0; segmentId < segmentCount; segmentId += 1) {
290
+ const projection = projectIndexedSegment(centerline, segmentId, position);
291
+ const tieScore = projectionTieScore(projection, preferredDistance);
292
+ if (!best || projection.distanceSquared < best.distanceSquared - AMBIGUOUS_DISTANCE_EPSILON) {
293
+ secondBestDistance = best?.distanceSquared ?? Infinity;
294
+ best = projection;
295
+ bestTieScore = tieScore;
296
+ } else if (Math.abs(projection.distanceSquared - best.distanceSquared) <= AMBIGUOUS_DISTANCE_EPSILON) {
297
+ tieResolved = true;
298
+ secondBestDistance = Math.min(secondBestDistance, projection.distanceSquared);
299
+ if (projectionTieBeats(projection, tieScore, best, bestTieScore)) {
300
+ best = projection;
301
+ bestTieScore = tieScore;
302
+ }
303
+ } else if (projection.distanceSquared < secondBestDistance) {
304
+ secondBestDistance = projection.distanceSquared;
305
+ }
282
306
  }
283
- recordStat(index, 'pitPaths', 'box-grid-hit');
284
- return ids.map((id) => pit.boxCandidates[id]).filter(Boolean);
307
+
308
+ if (!best) return { projection: null, reason: 'no-best' };
309
+ const ambiguous = Number.isFinite(secondBestDistance) &&
310
+ Math.abs(secondBestDistance - best.distanceSquared) <= AMBIGUOUS_DISTANCE_EPSILON;
311
+ return { projection: best, reason: ambiguous || tieResolved ? 'tie-resolved' : 'ok' };
285
312
  }
286
313
 
287
- export function queryPitRoadSegmentCandidates(track, routeId, position) {
288
- const candidatesByRoute = queryPitRoadSegmentCandidatesByRoute(track, position);
289
- return candidatesByRoute?.[routeId] ?? candidatesByRoute;
314
+ function projectionTieScore(projection, preferredDistance) {
315
+ if (!Number.isFinite(preferredDistance)) return projection.segmentId;
316
+ return Math.abs(projection.distance - preferredDistance);
290
317
  }
291
318
 
292
- export function queryPitRoadSegmentCandidatesByRoute(track, position) {
293
- const index = track?.queryIndex;
294
- const pit = index?.pit;
295
- if (!pit?.roadGrid) {
296
- recordFallback(index, 'pitFallbackReasons', 'missing-road-index');
297
- return null;
298
- }
299
- if (!finitePoint(position)) {
300
- recordFallback(index, 'pitFallbackReasons', 'invalid-position');
301
- return null;
302
- }
303
- index.stats.pitQueries += 1;
304
- const ids = candidateIdsFromGrid(index, pit.roadGrid, position, 1);
305
- if (!ids.length) {
306
- recordStat(index, 'pitPaths', 'road-grid-miss');
307
- return null;
308
- }
309
- const candidatesByRoute = { entry: [], main: [], working: [], exit: [] };
310
- ids.forEach((id) => {
311
- const segment = pit.roadSegments[id];
312
- if (!segment || !candidatesByRoute[segment.routeId]) return;
313
- candidatesByRoute[segment.routeId].push(segment.segmentIndex);
314
- });
315
- recordStat(index, 'pitPaths', 'road-grid-hit');
316
- return candidatesByRoute;
319
+ function projectionTieBeats(candidate, candidateTieScore, current, currentTieScore) {
320
+ if (candidateTieScore < currentTieScore - AMBIGUOUS_DISTANCE_EPSILON) return true;
321
+ if (Math.abs(candidateTieScore - currentTieScore) > AMBIGUOUS_DISTANCE_EPSILON) return false;
322
+ return candidate.segmentId < current.segmentId;
317
323
  }
318
324
 
319
325
  export function queryNearbyTrackProjections(track, position, { neighborLimit = GRID_NEIGHBOR_LIMIT } = {}) {
@@ -401,13 +407,15 @@ export function attachTrackQueryIndex(track, queryIndex) {
401
407
  return track;
402
408
  }
403
409
 
404
- export function forkTrackQueryIndex(sourceIndex) {
410
+ export function forkTrackQueryIndex(sourceIndex, runtimeTrack = null) {
405
411
  if (!sourceIndex || typeof sourceIndex !== 'object') return null;
406
- return {
412
+ const index = {
407
413
  ...sourceIndex,
414
+ pit: runtimeTrack ? createPitQueryIndex(runtimeTrack, sourceIndex.grid?.cellSize ?? DEFAULT_GRID_CELL_SIZE) : sourceIndex.pit,
408
415
  stats: createQueryStats(),
409
- queryScratch: createQueryScratch(sourceIndex),
410
416
  };
417
+ index.queryScratch = createQueryScratch(index);
418
+ return index;
411
419
  }
412
420
 
413
421
  export function resetTrackQueryStats(track) {
@@ -422,35 +430,6 @@ export function snapshotTrackQueryStats(track) {
422
430
  return stats ? JSON.parse(JSON.stringify(stats)) : null;
423
431
  }
424
432
 
425
- function createQueryStats() {
426
- return {
427
- nearestQueries: 0,
428
- nearestFallbacks: 0,
429
- nearestPaths: {},
430
- nearestFallbackReasons: {
431
- 'spatial-grid-no-candidates': 0,
432
- },
433
- pitQueries: 0,
434
- pitFallbacks: 0,
435
- pitPaths: {},
436
- pitFallbackReasons: {},
437
- };
438
- }
439
-
440
- function recordFallback(index, bucket, reason) {
441
- if (!index?.stats) return;
442
- if (bucket.startsWith('nearest')) index.stats.nearestFallbacks += 1;
443
- if (bucket.startsWith('pit')) index.stats.pitFallbacks += 1;
444
- recordStat(index, bucket, reason);
445
- }
446
-
447
- function recordStat(index, bucket, reason) {
448
- if (!index?.stats || !reason) return;
449
- const target = index.stats[bucket] ?? {};
450
- target[reason] = (target[reason] ?? 0) + 1;
451
- index.stats[bucket] = target;
452
- }
453
-
454
433
  function createTrackBands(track) {
455
434
  const trackEdge = track.width / 2;
456
435
  const kerbEdge = trackEdge + (track.kerbWidth ?? 0);
@@ -559,18 +538,6 @@ function expandedSampleBounds(samples, expansion) {
559
538
  };
560
539
  }
561
540
 
562
- function createSpatialGrid(bounds, cellSize) {
563
- const columns = Math.max(1, Math.ceil((bounds.maxX - bounds.minX) / cellSize));
564
- const rows = Math.max(1, Math.ceil((bounds.maxY - bounds.minY) / cellSize));
565
- return {
566
- bounds,
567
- cellSize,
568
- columns,
569
- rows,
570
- cells: new Array(columns * rows),
571
- };
572
- }
573
-
574
541
  function insertSegmentIntoGrid(grid, centerline, segmentId, expansion) {
575
542
  insertIdIntoGridBounds(grid, segmentId, {
576
543
  minX: centerline.minX[segmentId] - expansion,
@@ -580,19 +547,6 @@ function insertSegmentIntoGrid(grid, centerline, segmentId, expansion) {
580
547
  });
581
548
  }
582
549
 
583
- function insertIdIntoGridBounds(grid, id, bounds) {
584
- const minCell = gridCellForPoint(grid, { x: bounds.minX, y: bounds.minY }, true);
585
- const maxCell = gridCellForPoint(grid, { x: bounds.maxX, y: bounds.maxY }, true);
586
- for (let row = minCell.row; row <= maxCell.row; row += 1) {
587
- for (let column = minCell.column; column <= maxCell.column; column += 1) {
588
- const cellIndex = row * grid.columns + column;
589
- const cell = grid.cells[cellIndex];
590
- if (cell) cell.push(id);
591
- else grid.cells[cellIndex] = [id];
592
- }
593
- }
594
- }
595
-
596
550
  function candidateIdsFromArcBuckets(index, distanceAlong, radius) {
597
551
  const arcBuckets = index?.arcBuckets;
598
552
  if (!arcBuckets?.count || !Number.isFinite(distanceAlong)) return [];
@@ -613,73 +567,6 @@ function candidateIdsFromArcBuckets(index, distanceAlong, radius) {
613
567
  return ids;
614
568
  }
615
569
 
616
- function candidateIdsFromGrid(index, grid, position, neighborLimit) {
617
- const center = gridCellForPoint(grid, position, false);
618
- if (!center) return [];
619
- const scratch = ensureQueryScratch(index);
620
- const maxId = Math.max(index.centerline.segmentCount, index.pit?.roadSegments?.length ?? 0, index.pit?.boxCandidates?.length ?? 0);
621
- const idMarks = ensureScratchArray(scratch, 'candidateMarks', maxId);
622
- const ids = [];
623
- for (let radius = 0; radius <= neighborLimit; radius += 1) {
624
- const candidateEpoch = nextScratchEpoch(scratch, 'candidateEpoch', idMarks);
625
- ids.length = 0;
626
- for (let row = center.row - radius; row <= center.row + radius; row += 1) {
627
- for (let column = center.column - radius; column <= center.column + radius; column += 1) {
628
- if (column < 0 || row < 0 || column >= grid.columns || row >= grid.rows) continue;
629
- const cell = grid.cells[row * grid.columns + column];
630
- if (!cell) continue;
631
- for (const id of cell) {
632
- if (idMarks[id] === candidateEpoch) continue;
633
- idMarks[id] = candidateEpoch;
634
- ids.push(id);
635
- }
636
- }
637
- }
638
- if (ids.length) return ids.slice();
639
- }
640
- return [];
641
- }
642
-
643
- function candidateIdsFromGridBounds(index, grid, bounds) {
644
- if (
645
- bounds.maxX < grid.bounds.minX ||
646
- bounds.minX > grid.bounds.maxX ||
647
- bounds.maxY < grid.bounds.minY ||
648
- bounds.minY > grid.bounds.maxY
649
- ) return [];
650
- const minPoint = { x: bounds.minX, y: bounds.minY };
651
- const maxPoint = { x: bounds.maxX, y: bounds.maxY };
652
- const minCell = gridCellForPoint(grid, minPoint, true);
653
- const maxCell = gridCellForPoint(grid, maxPoint, true);
654
- const scratch = ensureQueryScratch(index);
655
- const maxId = Math.max(index.centerline.segmentCount, index.pit?.roadSegments?.length ?? 0, index.pit?.boxCandidates?.length ?? 0);
656
- const idMarks = ensureScratchArray(scratch, 'candidateMarks', maxId);
657
- const candidateEpoch = nextScratchEpoch(scratch, 'candidateEpoch', idMarks);
658
- const ids = [];
659
- for (let row = minCell.row; row <= maxCell.row; row += 1) {
660
- for (let column = minCell.column; column <= maxCell.column; column += 1) {
661
- const cell = grid.cells[row * grid.columns + column];
662
- if (!cell) continue;
663
- for (const id of cell) {
664
- if (idMarks[id] === candidateEpoch) continue;
665
- idMarks[id] = candidateEpoch;
666
- ids.push(id);
667
- }
668
- }
669
- }
670
- return ids;
671
- }
672
-
673
- function gridCellForPoint(grid, point, clampToGrid) {
674
- const column = Math.floor((point.x - grid.bounds.minX) / grid.cellSize);
675
- const row = Math.floor((point.y - grid.bounds.minY) / grid.cellSize);
676
- if (!clampToGrid && (column < 0 || row < 0 || column >= grid.columns || row >= grid.rows)) return null;
677
- return {
678
- column: clampToGrid ? clamp(column, 0, grid.columns - 1) : column,
679
- row: clampToGrid ? clamp(row, 0, grid.rows - 1) : row,
680
- };
681
- }
682
-
683
570
  function projectIndexedSegment(centerline, segmentId, position) {
684
571
  const ax = centerline.startX[segmentId];
685
572
  const ay = centerline.startY[segmentId];
@@ -733,134 +620,6 @@ function segmentFromIndex(centerline, segmentId) {
733
620
  };
734
621
  }
735
622
 
736
- function createPitQueryIndex(track, cellSize) {
737
- const pitLane = track.pitLane;
738
- if (!pitLane?.enabled) return null;
739
- const bounds = {
740
- minX: pitLane.bounds.minX - cellSize,
741
- maxX: pitLane.bounds.maxX + cellSize,
742
- minY: pitLane.bounds.minY - cellSize,
743
- maxY: pitLane.bounds.maxY + cellSize,
744
- };
745
- const roadGrid = createSpatialGrid(bounds, cellSize);
746
- const boxGrid = createSpatialGrid(bounds, cellSize);
747
- const roadSegments = [];
748
- const routes = {};
749
-
750
- [
751
- ['entry', pitLane.entry?.roadCenterline],
752
- ['main', pitLane.mainLane?.points],
753
- ['working', pitLane.workingLane?.points],
754
- ['exit', pitLane.exit?.roadCenterline],
755
- ].forEach(([routeId, points]) => {
756
- routes[routeId] = { start: roadSegments.length };
757
- indexPitRoute(roadGrid, roadSegments, routeId, points, cellSize);
758
- routes[routeId].end = roadSegments.length;
759
- });
760
-
761
- const boxCandidates = [];
762
- [
763
- ...(pitLane.serviceAreas ?? []).flatMap((area) => [
764
- { type: 'service-area', target: area, polygon: area.corners },
765
- { type: 'service-queue', target: area, polygon: area.queueCorners },
766
- ]),
767
- ...(pitLane.boxes ?? []).map((box) => ({ type: 'garage-box', target: box, polygon: box.corners })),
768
- ]
769
- .filter((candidate) => Array.isArray(candidate.polygon) && candidate.polygon.length >= 3)
770
- .forEach((candidate) => {
771
- const id = boxCandidates.length;
772
- boxCandidates.push(candidate);
773
- insertIdIntoGridBounds(boxGrid, id, polygonBounds(candidate.polygon, cellSize));
774
- });
775
-
776
- return {
777
- roadGrid,
778
- boxGrid,
779
- routes,
780
- roadSegments,
781
- boxCandidates,
782
- };
783
- }
784
-
785
- function indexPitRoute(grid, roadSegments, routeId, points, expansion) {
786
- if (!Array.isArray(points) || points.length < 2) return;
787
- for (let index = 0; index < points.length - 1; index += 1) {
788
- const start = points[index];
789
- const end = points[index + 1];
790
- const id = roadSegments.length;
791
- roadSegments.push({ routeId, segmentIndex: index });
792
- const bounds = {
793
- minX: Math.min(start.x, end.x) - expansion,
794
- maxX: Math.max(start.x, end.x) + expansion,
795
- minY: Math.min(start.y, end.y) - expansion,
796
- maxY: Math.max(start.y, end.y) + expansion,
797
- };
798
- insertIdIntoGridBounds(grid, id, bounds);
799
- }
800
- }
801
-
802
- function polygonBounds(polygon, expansion) {
803
- const bounds = polygon.reduce((current, point) => ({
804
- minX: Math.min(current.minX, point.x),
805
- maxX: Math.max(current.maxX, point.x),
806
- minY: Math.min(current.minY, point.y),
807
- maxY: Math.max(current.maxY, point.y),
808
- }), { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity });
809
- return {
810
- minX: bounds.minX - expansion,
811
- maxX: bounds.maxX + expansion,
812
- minY: bounds.minY - expansion,
813
- maxY: bounds.maxY + expansion,
814
- };
815
- }
816
-
817
- function createQueryScratch(index) {
818
- const segmentCount = Math.max(0, index?.centerline?.segmentCount ?? 0);
819
- const maxId = Math.max(
820
- segmentCount,
821
- index?.pit?.roadSegments?.length ?? 0,
822
- index?.pit?.boxCandidates?.length ?? 0,
823
- );
824
- const grid = index?.segmentGrid ?? index?.grid;
825
- const cellCount = Math.max(0, (grid?.columns ?? 0) * (grid?.rows ?? 0));
826
- return {
827
- candidateMarks: new Uint32Array(Math.max(1, maxId)),
828
- candidateEpoch: 1,
829
- sampleMarks: new Uint32Array(Math.max(1, segmentCount)),
830
- sampleEpoch: 1,
831
- raySegmentMarks: new Uint32Array(Math.max(1, segmentCount)),
832
- raySegmentEpoch: 1,
833
- rayCellMarks: new Uint32Array(Math.max(1, cellCount)),
834
- rayCellEpoch: 1,
835
- raySegmentIds: [],
836
- rayVisitedCells: [],
837
- rayTraceCache: new Map(),
838
- };
839
- }
840
-
841
- function ensureQueryScratch(index) {
842
- if (index.queryScratch) return index.queryScratch;
843
- index.queryScratch = createQueryScratch(index);
844
- return index.queryScratch;
845
- }
846
-
847
- function ensureScratchArray(scratch, key, minimumLength) {
848
- if (!scratch[key] || scratch[key].length < minimumLength) {
849
- scratch[key] = new Uint32Array(Math.max(1, minimumLength));
850
- }
851
- return scratch[key];
852
- }
853
-
854
- function nextScratchEpoch(scratch, key, marks) {
855
- let epoch = (scratch[key] ?? 0) + 1;
856
- if (epoch >= 0xFFFFFFFF) {
857
- marks.fill(0);
858
- epoch = 1;
859
- }
860
- scratch[key] = epoch;
861
- return epoch;
862
- }
863
-
864
623
  function seedRayTraceFromNearbyCache(
865
624
  index,
866
625
  grid,