@inventure71/paddockjs 1.0.0 → 2.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 (190) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/INSTALL_AND_UPDATE.md +4 -3
  3. package/README.md +194 -12
  4. package/docs/architecture.md +119 -44
  5. package/docs/concepts.md +4 -2
  6. package/docs/custom_model_controller.md +329 -0
  7. package/docs/data_contract.md +299 -17
  8. package/docs/index.md +1 -0
  9. package/docs/learnings.md +22 -0
  10. package/docs/rules.md +37 -10
  11. package/docs/system_specs.md +23 -10
  12. package/docs/training.md +272 -30
  13. package/examples/python/README.md +62 -0
  14. package/examples/python/base_policy_server.py +191 -0
  15. package/examples/python/requirements.txt +2 -0
  16. package/package.json +12 -4
  17. package/src/api/PaddockSimulatorController.js +4 -0
  18. package/src/app/BrowserExpertAdapter.js +367 -9
  19. package/src/app/F1SimulatorApp.js +305 -1603
  20. package/src/app/banners/bannerFormatters.js +55 -0
  21. package/src/app/banners/raceDataBannerController.js +141 -0
  22. package/src/app/banners/stewardMessageController.js +99 -0
  23. package/src/app/camera/cameraConstants.js +21 -0
  24. package/src/app/camera/cameraController.js +265 -0
  25. package/src/app/camera/cameraGeometry.js +176 -0
  26. package/src/app/domBindings.js +4 -0
  27. package/src/app/readouts/carOverviewRenderer.js +109 -0
  28. package/src/app/readouts/controlStateRenderer.js +37 -0
  29. package/src/app/readouts/raceStatusRenderer.js +133 -0
  30. package/src/app/readouts/readoutFormatters.js +58 -0
  31. package/src/app/readouts/telemetryRenderer.js +113 -0
  32. package/src/app/readouts/timingTowerRenderer.js +177 -0
  33. package/src/app/rendering/appAssets.js +38 -0
  34. package/src/app/rendering/carRenderer.js +286 -0
  35. package/src/app/rendering/displayUtils.js +22 -0
  36. package/src/app/rendering/drsTrailRenderer.js +90 -0
  37. package/src/app/rendering/expertSensorRenderer.js +126 -0
  38. package/src/app/rendering/pitLaneStatusRenderer.js +50 -0
  39. package/src/app/rendering/replayGhostRenderer.js +124 -0
  40. package/src/app/rendering/trackRenderer.js +25 -0
  41. package/src/app/runtime/frameLoop.js +66 -0
  42. package/src/app/runtime/runtimeTiming.js +34 -0
  43. package/src/app/runtime/runtimeVisibility.js +45 -0
  44. package/src/config/defaultOptions.js +20 -0
  45. package/src/environment/actions.js +13 -5
  46. package/src/environment/controllerLoop.js +246 -0
  47. package/src/environment/episode.js +86 -2
  48. package/src/environment/evaluation.js +54 -32
  49. package/src/environment/index.d.ts +361 -9
  50. package/src/environment/index.js +1 -0
  51. package/src/environment/metrics.js +159 -0
  52. package/src/environment/observationOptions.js +22 -1
  53. package/src/environment/observations.js +325 -40
  54. package/src/environment/options.js +37 -5
  55. package/src/environment/runtime.js +222 -21
  56. package/src/environment/scenarios.js +37 -8
  57. package/src/environment/sensors/bodySenses.js +23 -0
  58. package/src/environment/sensors/boundarySenses.js +17 -0
  59. package/src/environment/sensors/carRays.js +75 -0
  60. package/src/environment/sensors/contactSenses.js +32 -0
  61. package/src/environment/sensors/index.js +157 -0
  62. package/src/environment/sensors/indexedRayBands.js +103 -0
  63. package/src/environment/sensors/nearbyCars.js +52 -0
  64. package/src/environment/sensors/opponentRadar.js +65 -0
  65. package/src/environment/sensors/rayConfig.js +110 -0
  66. package/src/environment/sensors/rayDefaults.js +6 -0
  67. package/src/environment/sensors/rayGeometry.js +60 -0
  68. package/src/environment/sensors/rayGuards.js +19 -0
  69. package/src/environment/sensors/sensorTargets.js +60 -0
  70. package/src/environment/sensors/surfaceRays.js +296 -0
  71. package/src/environment/sensors/trackRays.js +261 -0
  72. package/src/environment/sensors.js +1 -367
  73. package/src/environment/specs.js +100 -5
  74. package/src/environment/workerProtocol.js +13 -1
  75. package/src/index.d.ts +169 -6
  76. package/src/index.js +5 -0
  77. package/src/rendering/proceduralTrackAsset.js +20 -823
  78. package/src/rendering/renderSnapshot.js +38 -12
  79. package/src/rendering/track/finishLineRenderer.js +34 -0
  80. package/src/rendering/track/gridRenderer.js +47 -0
  81. package/src/rendering/track/offsetStrokeSafety.js +136 -0
  82. package/src/rendering/track/pitLaneRenderer.js +248 -0
  83. package/src/rendering/track/trackMaterialRenderer.js +326 -0
  84. package/src/rendering/track/trackRenderConstants.js +38 -0
  85. package/src/rendering/track/trackRenderGeometry.js +94 -0
  86. package/src/simulation/collisionGeometry.js +1 -1
  87. package/src/simulation/driver/driverControlConstants.js +28 -0
  88. package/src/simulation/driver/driverController.js +47 -0
  89. package/src/simulation/driver/driverInput.js +28 -0
  90. package/src/simulation/driver/driverMath.js +6 -0
  91. package/src/simulation/driver/driverPersonality.js +25 -0
  92. package/src/simulation/driver/edgeRecovery.js +48 -0
  93. package/src/simulation/driver/racingControls.js +399 -0
  94. package/src/simulation/driver/racingLinePlan.js +166 -0
  95. package/src/simulation/driver/recoveryDynamics.js +38 -0
  96. package/src/simulation/driver/rejoinControls.js +213 -0
  97. package/src/simulation/driver/safetyCarControls.js +35 -0
  98. package/src/simulation/driver/trafficScan.js +51 -0
  99. package/src/simulation/driverController.js +4 -619
  100. package/src/simulation/participants/participantInteractions.js +120 -0
  101. package/src/simulation/pit/pitFlow.js +234 -0
  102. package/src/simulation/pit/pitIntent.js +92 -0
  103. package/src/simulation/pit/pitOccupancy.js +47 -0
  104. package/src/simulation/pit/pitPenaltyService.js +71 -0
  105. package/src/simulation/pit/pitQueue.js +55 -0
  106. package/src/simulation/pit/pitRouteMovement.js +160 -0
  107. package/src/simulation/pit/pitRouting.js +319 -0
  108. package/src/simulation/pit/pitService.js +41 -0
  109. package/src/simulation/pit/pitServiceConstants.js +21 -0
  110. package/src/simulation/pit/pitServiceProfile.js +45 -0
  111. package/src/simulation/pit/pitSnapshots.js +61 -0
  112. package/src/simulation/pit/pitState.js +113 -0
  113. package/src/simulation/pit/pitTireService.js +92 -0
  114. package/src/simulation/race/classification.js +104 -0
  115. package/src/simulation/race/gridPenalties.js +37 -0
  116. package/src/simulation/race/raceControlState.js +38 -0
  117. package/src/simulation/race/raceDistance.js +32 -0
  118. package/src/simulation/race/raceFinish.js +65 -0
  119. package/src/simulation/race/raceLifecycle.js +71 -0
  120. package/src/simulation/race/raceOrder.js +113 -0
  121. package/src/simulation/race/raceProgress.js +124 -0
  122. package/src/simulation/race/raceSetup.js +93 -0
  123. package/src/simulation/race/raceStep.js +80 -0
  124. package/src/simulation/race/redFlag.js +15 -0
  125. package/src/simulation/race/retirements.js +44 -0
  126. package/src/simulation/race/safetyCar.js +86 -0
  127. package/src/simulation/race/stalledDnf.js +86 -0
  128. package/src/simulation/raceSimulation.js +230 -2817
  129. package/src/simulation/replay/replayGhosts.js +128 -0
  130. package/src/simulation/rules/moduleConfig.js +72 -0
  131. package/src/simulation/rules/penaltyConfig.js +99 -0
  132. package/src/simulation/rules/penaltyEvents.js +18 -0
  133. package/src/simulation/rules/penaltyStats.js +26 -0
  134. package/src/simulation/rules/ruleConfigMerge.js +41 -0
  135. package/src/simulation/rules/ruleDefaults.js +85 -0
  136. package/src/simulation/rules/rulesReview.js +141 -0
  137. package/src/simulation/rules/rulesetPresets.js +47 -0
  138. package/src/simulation/rules/trackLimitsSteward.js +1 -1
  139. package/src/simulation/rulesConfig.js +5 -323
  140. package/src/simulation/snapshots/raceSnapshots.js +192 -0
  141. package/src/simulation/timing/drsTiming.js +87 -0
  142. package/src/simulation/timing/gapEstimation.js +38 -0
  143. package/src/simulation/timing/lapTelemetry.js +182 -0
  144. package/src/simulation/timing/raceTiming.js +15 -0
  145. package/src/simulation/timing/sectorPerformance.js +60 -0
  146. package/src/simulation/timing/timingConstants.js +5 -0
  147. package/src/simulation/timing/timingHistory.js +46 -0
  148. package/src/simulation/timing/timingLines.js +79 -0
  149. package/src/simulation/track/centerlineSpline.js +59 -0
  150. package/src/simulation/track/drsZones.js +67 -0
  151. package/src/simulation/track/pitLaneAccess.js +103 -0
  152. package/src/simulation/track/pitLaneLayout.js +395 -0
  153. package/src/simulation/track/pitLaneState.js +160 -0
  154. package/src/simulation/track/proceduralCenterline.js +237 -0
  155. package/src/simulation/track/sampleGeometry.js +49 -0
  156. package/src/simulation/track/spatialQueries.js +154 -0
  157. package/src/simulation/track/startStraight.js +141 -0
  158. package/src/simulation/track/trackConstants.js +109 -0
  159. package/src/simulation/track/trackGenerationOptions.js +283 -0
  160. package/src/simulation/track/trackMath.js +241 -0
  161. package/src/simulation/track/trackModel.js +240 -0
  162. package/src/simulation/track/trackQueryIndex.js +945 -0
  163. package/src/simulation/track/trackSectors.js +19 -0
  164. package/src/simulation/track/trackStatePolicy.js +24 -0
  165. package/src/simulation/track/trackValidation.js +98 -0
  166. package/src/simulation/trackModel.js +1 -1706
  167. package/src/simulation/vehicle/contactResolution.js +116 -0
  168. package/src/simulation/vehicle/mainTrackWheelSurface.js +82 -0
  169. package/src/simulation/vehicle/pitWheelSurface.js +69 -0
  170. package/src/simulation/vehicle/runoffResponse.js +88 -0
  171. package/src/simulation/vehicle/surfacePriority.js +28 -0
  172. package/src/simulation/vehicle/vehicleGeometry.js +247 -0
  173. package/src/simulation/vehicle/vehiclePhysics.js +363 -0
  174. package/src/simulation/vehicle/vehicleSnapshots.js +316 -0
  175. package/src/simulation/vehicle/vehicleState.js +186 -0
  176. package/src/simulation/vehicle/wheelSurface.js +150 -0
  177. package/src/simulation/vehicleGeometry.js +1 -247
  178. package/src/simulation/vehiclePhysics.js +1 -136
  179. package/src/simulation/warmup/runtimeWarmup.js +224 -0
  180. package/src/simulation/wheelSurface.js +1 -338
  181. package/src/styles.css +42 -0
  182. package/src/ui/cameraControlsTemplate.js +20 -0
  183. package/src/ui/carOverviewTemplate.js +33 -0
  184. package/src/ui/componentTemplates.js +14 -360
  185. package/src/ui/raceCanvasTemplate.js +69 -0
  186. package/src/ui/raceControlsTemplate.js +36 -0
  187. package/src/ui/shellTemplate.js +5 -4
  188. package/src/ui/telemetryDrawerTemplate.js +55 -0
  189. package/src/ui/telemetryTemplates.js +136 -0
  190. package/src/ui/timingTowerTemplate.js +34 -0
@@ -0,0 +1,945 @@
1
+ import { clamp, wrapDistance } from '../simMath.js';
2
+ import { metersToSimUnits } from '../units.js';
3
+
4
+ const DEFAULT_GRID_CELL_SIZE = metersToSimUnits(32);
5
+ const GRID_NEIGHBOR_LIMIT = 2;
6
+ const ARC_BUCKET_COUNT = 512;
7
+ const AMBIGUOUS_DISTANCE_EPSILON = 1e-6;
8
+ const RAY_TRACE_BUCKET_DEGREES = 10;
9
+
10
+ export function createTrackQueryIndex(track) {
11
+ const samples = Array.isArray(track.samples) ? track.samples : [];
12
+ const segmentCount = Math.max(0, samples.length - 1);
13
+ const bands = createTrackBands(track);
14
+ const expansion = bands.runoffEdge + (track.barrierWidth ?? 0) + metersToSimUnits(64);
15
+ const bounds = expandedSampleBounds(samples, expansion);
16
+ const centerline = createCenterlineSegments(samples, segmentCount);
17
+ const grid = createSpatialGrid(bounds, DEFAULT_GRID_CELL_SIZE);
18
+ const segmentGrid = createSpatialGrid(bounds, DEFAULT_GRID_CELL_SIZE);
19
+ const arcBuckets = createArcBuckets(track.length, ARC_BUCKET_COUNT);
20
+
21
+ for (let segmentId = 0; segmentId < segmentCount; segmentId += 1) {
22
+ insertSegmentIntoGrid(grid, centerline, segmentId, expansion);
23
+ insertSegmentIntoGrid(segmentGrid, centerline, segmentId, 0);
24
+ insertSegmentIntoArcBuckets(arcBuckets, centerline, segmentId);
25
+ }
26
+
27
+ const index = {
28
+ version: 1,
29
+ bands,
30
+ centerline,
31
+ grid,
32
+ segmentGrid,
33
+ arcBuckets,
34
+ pit: createPitQueryIndex(track, grid.cellSize),
35
+ stats: createQueryStats(),
36
+ };
37
+ index.queryScratch = createQueryScratch(index);
38
+ return index;
39
+ }
40
+
41
+ export function queryNearestTrackProjection(track, position, progressHint = null, options = {}) {
42
+ const index = track?.queryIndex;
43
+ if (!index?.centerline?.segmentCount) {
44
+ recordFallback(index, 'nearestFallbackReasons', 'missing-index');
45
+ return null;
46
+ }
47
+ if (!finitePoint(position)) {
48
+ recordFallback(index, 'nearestFallbackReasons', 'invalid-position');
49
+ return null;
50
+ }
51
+ index.stats.nearestQueries += 1;
52
+
53
+ const mode = options.indexMode === 'sample' ? 'sample' : 'projection';
54
+ const { projection, path, reason } = bestNearestFromIndex(track, index, position, progressHint, mode, options);
55
+ if (!projection) {
56
+ recordFallback(index, 'nearestFallbackReasons', reason ?? 'unknown');
57
+ return null;
58
+ }
59
+
60
+ recordStat(index, 'nearestPaths', path);
61
+ return projection;
62
+ }
63
+
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) {
71
+ const hintedCandidateIds = Number.isFinite(progressHint)
72
+ ? candidateIdsFromArcBuckets(index, progressHint, 2)
73
+ : [];
74
+ if (hintedCandidateIds.length) {
75
+ 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
+ };
82
+ }
83
+ recordStat(index, 'nearestPaths', hinted.projection ? 'arc-hint-rejected-distance' : `arc-hint-${hinted.reason}`);
84
+ }
85
+
86
+ const gridCandidateIds = candidateIdsFromGrid(index, index.grid, position, GRID_NEIGHBOR_LIMIT);
87
+ 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',
92
+ }
93
+ : { projection: null, reason: `spatial-grid-${grid.reason}` };
94
+ }
95
+
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) {
107
+ return {
108
+ projection: hinted.sample,
109
+ path: hinted.reason === 'tie-resolved' ? 'arc-hint-tie-resolved' : 'arc-hint',
110
+ };
111
+ }
112
+ recordStat(index, 'nearestPaths', hinted.sample ? 'arc-hint-rejected-distance' : `arc-hint-${hinted.reason}`);
113
+ }
114
+
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
118
+ ? {
119
+ projection: grid.sample,
120
+ path: grid.reason === 'tie-resolved' ? 'spatial-grid-tie-resolved' : 'spatial-grid',
121
+ }
122
+ : { projection: null, reason: `spatial-grid-${grid.reason}` };
123
+ }
124
+
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);
129
+ }
130
+
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);
136
+
137
+ let bestSample = null;
138
+ let bestDistanceSquared = Infinity;
139
+ let bestTieScore = Infinity;
140
+ let secondBestDistance = Infinity;
141
+ 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
+
175
+ if (!bestSample) return { sample: null, reason: 'no-best' };
176
+
177
+ 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
+ };
184
+ }
185
+
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;
217
+ }
218
+ } else if (distanceSquared < secondBestDistance) {
219
+ secondBestDistance = distanceSquared;
220
+ }
221
+ return { bestSample, bestDistanceSquared, bestTieScore, secondBestDistance, tieResolved };
222
+ }
223
+
224
+ function sampleTieScore(sample, preferredDistance) {
225
+ if (!Number.isFinite(preferredDistance)) return sample.distance;
226
+ return Math.abs(sample.distance - preferredDistance);
227
+ }
228
+
229
+ function bestProjectionFromCandidates(centerline, candidateIds, position, preferredDistance = null) {
230
+ if (!candidateIds.length) return { projection: null, reason: 'no-candidates' };
231
+ let best = null;
232
+ let bestTieScore = Infinity;
233
+ let secondBestDistance = Infinity;
234
+ let tieResolved = false;
235
+ for (const segmentId of candidateIds) {
236
+ const projection = projectIndexedSegment(centerline, segmentId, position);
237
+ const tieScore = projectionTieScore(projection, preferredDistance);
238
+ if (!best || projection.distanceSquared < best.distanceSquared - AMBIGUOUS_DISTANCE_EPSILON) {
239
+ secondBestDistance = best?.distanceSquared ?? Infinity;
240
+ best = projection;
241
+ bestTieScore = tieScore;
242
+ } else if (Math.abs(projection.distanceSquared - best.distanceSquared) <= AMBIGUOUS_DISTANCE_EPSILON) {
243
+ tieResolved = true;
244
+ secondBestDistance = Math.min(secondBestDistance, projection.distanceSquared);
245
+ if (tieScore < bestTieScore) {
246
+ best = projection;
247
+ bestTieScore = tieScore;
248
+ }
249
+ } else if (projection.distanceSquared < secondBestDistance) {
250
+ secondBestDistance = projection.distanceSquared;
251
+ }
252
+ }
253
+
254
+ if (!best) return { projection: null, reason: 'no-best' };
255
+
256
+ const ambiguous = Number.isFinite(secondBestDistance) &&
257
+ Math.abs(secondBestDistance - best.distanceSquared) <= AMBIGUOUS_DISTANCE_EPSILON;
258
+ return { projection: best, reason: ambiguous || tieResolved ? 'tie-resolved' : 'ok' };
259
+ }
260
+
261
+ function projectionTieScore(projection, preferredDistance) {
262
+ if (!Number.isFinite(preferredDistance)) return projection.segmentId;
263
+ return Math.abs(projection.distance - preferredDistance);
264
+ }
265
+
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 [];
282
+ }
283
+ recordStat(index, 'pitPaths', 'box-grid-hit');
284
+ return ids.map((id) => pit.boxCandidates[id]).filter(Boolean);
285
+ }
286
+
287
+ export function queryPitRoadSegmentCandidates(track, routeId, position) {
288
+ const candidatesByRoute = queryPitRoadSegmentCandidatesByRoute(track, position);
289
+ return candidatesByRoute?.[routeId] ?? candidatesByRoute;
290
+ }
291
+
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;
317
+ }
318
+
319
+ export function queryNearbyTrackProjections(track, position, { neighborLimit = GRID_NEIGHBOR_LIMIT } = {}) {
320
+ const index = track?.queryIndex;
321
+ if (!index?.centerline?.segmentCount || !finitePoint(position)) return null;
322
+ const ids = candidateIdsFromGrid(index, index.grid, position, neighborLimit);
323
+ if (!ids.length) return [];
324
+ return ids.map((segmentId) => projectIndexedSegment(index.centerline, segmentId, position));
325
+ }
326
+
327
+ export function queryTrackSegmentsInBounds(track, bounds) {
328
+ const index = track?.queryIndex;
329
+ const grid = index?.segmentGrid ?? index?.grid;
330
+ if (!index?.centerline?.segmentCount || !grid || !finiteBounds(bounds)) return null;
331
+ const ids = candidateIdsFromGridBounds(index, grid, bounds);
332
+ if (!ids.length) return [];
333
+ return ids.map((segmentId) => segmentFromIndex(index.centerline, segmentId));
334
+ }
335
+
336
+ export function queryTrackSegmentsAlongRay(track, origin, vector, maxDistance, margin = 0) {
337
+ const index = track?.queryIndex;
338
+ if (
339
+ !index?.centerline?.segmentCount ||
340
+ !finitePoint(origin) ||
341
+ !Number.isFinite(vector?.x) ||
342
+ !Number.isFinite(vector?.y) ||
343
+ !Number.isFinite(maxDistance) ||
344
+ maxDistance < 0
345
+ ) return null;
346
+
347
+ const grid = index.segmentGrid ?? index.grid;
348
+ const scratch = ensureQueryScratch(index);
349
+ const segmentMarks = ensureScratchArray(scratch, 'raySegmentMarks', index.centerline.segmentCount);
350
+ const cellMarks = ensureScratchArray(scratch, 'rayCellMarks', grid.columns * grid.rows);
351
+ const segmentEpoch = nextScratchEpoch(scratch, 'raySegmentEpoch', segmentMarks);
352
+ const cellEpoch = nextScratchEpoch(scratch, 'rayCellEpoch', cellMarks);
353
+ const ids = scratch.raySegmentIds;
354
+ ids.length = 0;
355
+ const radius = Math.max(0, Math.ceil(Math.max(0, margin) / grid.cellSize));
356
+ const step = Math.max(grid.cellSize * 0.5, 1);
357
+ const sampleCount = Math.max(1, Math.ceil(maxDistance / step));
358
+ const visitedCells = scratch.rayVisitedCells;
359
+ visitedCells.length = 0;
360
+
361
+ const seed = seedRayTraceFromNearbyCache(index, grid, origin, vector, radius, maxDistance, segmentMarks, segmentEpoch, cellMarks, cellEpoch, ids);
362
+
363
+ for (let sample = 0; sample <= sampleCount; sample += 1) {
364
+ const distance = Math.min(maxDistance, sample * step);
365
+ const point = {
366
+ x: origin.x + vector.x * distance,
367
+ y: origin.y + vector.y * distance,
368
+ };
369
+ const center = gridCellForPoint(grid, point, false);
370
+ if (!center) continue;
371
+ for (let row = center.row - radius; row <= center.row + radius; row += 1) {
372
+ for (let column = center.column - radius; column <= center.column + radius; column += 1) {
373
+ if (column < 0 || row < 0 || column >= grid.columns || row >= grid.rows) continue;
374
+ const cellIndex = row * grid.columns + column;
375
+ if (cellMarks[cellIndex] === cellEpoch) continue;
376
+ cellMarks[cellIndex] = cellEpoch;
377
+ visitedCells.push(cellIndex);
378
+ const cell = grid.cells[cellIndex];
379
+ if (!cell) continue;
380
+ for (const segmentId of cell) {
381
+ if (segmentMarks[segmentId] === segmentEpoch) continue;
382
+ segmentMarks[segmentId] = segmentEpoch;
383
+ ids.push(segmentId);
384
+ }
385
+ }
386
+ }
387
+ }
388
+
389
+ storeRayTraceCache(index, grid, origin, vector, radius, maxDistance, ids, visitedCells, seed);
390
+ if (!ids.length) return [];
391
+ return ids.map((segmentId) => segmentFromIndex(index.centerline, segmentId));
392
+ }
393
+
394
+ export function attachTrackQueryIndex(track, queryIndex) {
395
+ Object.defineProperty(track, 'queryIndex', {
396
+ configurable: true,
397
+ enumerable: false,
398
+ value: queryIndex,
399
+ writable: false,
400
+ });
401
+ return track;
402
+ }
403
+
404
+ export function forkTrackQueryIndex(sourceIndex) {
405
+ if (!sourceIndex || typeof sourceIndex !== 'object') return null;
406
+ return {
407
+ ...sourceIndex,
408
+ stats: createQueryStats(),
409
+ queryScratch: createQueryScratch(sourceIndex),
410
+ };
411
+ }
412
+
413
+ export function resetTrackQueryStats(track) {
414
+ const stats = track?.queryIndex?.stats;
415
+ if (!stats) return null;
416
+ Object.assign(stats, createQueryStats());
417
+ return stats;
418
+ }
419
+
420
+ export function snapshotTrackQueryStats(track) {
421
+ const stats = track?.queryIndex?.stats;
422
+ return stats ? JSON.parse(JSON.stringify(stats)) : null;
423
+ }
424
+
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
+ function createTrackBands(track) {
455
+ const trackEdge = track.width / 2;
456
+ const kerbEdge = trackEdge + (track.kerbWidth ?? 0);
457
+ const gravelEdge = kerbEdge + (track.gravelWidth ?? 0);
458
+ const runoffEdge = gravelEdge + (track.runoffWidth ?? 0);
459
+ const barrierInnerFace = runoffEdge - (track.barrierWidth ?? 0) / 2;
460
+ return {
461
+ trackEdge,
462
+ kerbEdge,
463
+ gravelEdge,
464
+ runoffEdge,
465
+ barrierInnerFace,
466
+ };
467
+ }
468
+
469
+ function createCenterlineSegments(samples, segmentCount) {
470
+ const startX = new Float64Array(segmentCount);
471
+ const startY = new Float64Array(segmentCount);
472
+ const endX = new Float64Array(segmentCount);
473
+ const endY = new Float64Array(segmentCount);
474
+ const startDistance = new Float64Array(segmentCount);
475
+ const endDistance = new Float64Array(segmentCount);
476
+ const heading = new Float64Array(segmentCount);
477
+ const normalX = new Float64Array(segmentCount);
478
+ const normalY = new Float64Array(segmentCount);
479
+ const endNormalX = new Float64Array(segmentCount);
480
+ const endNormalY = new Float64Array(segmentCount);
481
+ const curvature = new Float64Array(segmentCount);
482
+ const minX = new Float64Array(segmentCount);
483
+ const maxX = new Float64Array(segmentCount);
484
+ const minY = new Float64Array(segmentCount);
485
+ const maxY = new Float64Array(segmentCount);
486
+
487
+ for (let id = 0; id < segmentCount; id += 1) {
488
+ const start = samples[id];
489
+ const end = samples[id + 1];
490
+ startX[id] = start.x;
491
+ startY[id] = start.y;
492
+ endX[id] = end.x;
493
+ endY[id] = end.y;
494
+ startDistance[id] = start.distance;
495
+ endDistance[id] = end.distance;
496
+ heading[id] = start.heading;
497
+ normalX[id] = start.normalX;
498
+ normalY[id] = start.normalY;
499
+ endNormalX[id] = end.normalX;
500
+ endNormalY[id] = end.normalY;
501
+ curvature[id] = start.curvature ?? 0;
502
+ minX[id] = Math.min(start.x, end.x);
503
+ maxX[id] = Math.max(start.x, end.x);
504
+ minY[id] = Math.min(start.y, end.y);
505
+ maxY[id] = Math.max(start.y, end.y);
506
+ }
507
+
508
+ return {
509
+ segmentCount,
510
+ startX,
511
+ startY,
512
+ endX,
513
+ endY,
514
+ startDistance,
515
+ endDistance,
516
+ heading,
517
+ normalX,
518
+ normalY,
519
+ endNormalX,
520
+ endNormalY,
521
+ curvature,
522
+ minX,
523
+ maxX,
524
+ minY,
525
+ maxY,
526
+ };
527
+ }
528
+
529
+ function createArcBuckets(totalLength, count) {
530
+ return {
531
+ count,
532
+ totalLength,
533
+ bucketLength: totalLength / count,
534
+ buckets: Array.from({ length: count }, () => []),
535
+ };
536
+ }
537
+
538
+ function insertSegmentIntoArcBuckets(arcBuckets, centerline, segmentId) {
539
+ if (!Number.isFinite(arcBuckets.bucketLength) || arcBuckets.bucketLength <= 0) return;
540
+ const start = Math.floor(centerline.startDistance[segmentId] / arcBuckets.bucketLength);
541
+ const end = Math.floor(centerline.endDistance[segmentId] / arcBuckets.bucketLength);
542
+ for (let bucket = start; bucket <= end; bucket += 1) {
543
+ arcBuckets.buckets[((bucket % arcBuckets.count) + arcBuckets.count) % arcBuckets.count].push(segmentId);
544
+ }
545
+ }
546
+
547
+ function expandedSampleBounds(samples, expansion) {
548
+ const bounds = samples.reduce((current, point) => ({
549
+ minX: Math.min(current.minX, point.x),
550
+ maxX: Math.max(current.maxX, point.x),
551
+ minY: Math.min(current.minY, point.y),
552
+ maxY: Math.max(current.maxY, point.y),
553
+ }), { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity });
554
+ return {
555
+ minX: bounds.minX - expansion,
556
+ maxX: bounds.maxX + expansion,
557
+ minY: bounds.minY - expansion,
558
+ maxY: bounds.maxY + expansion,
559
+ };
560
+ }
561
+
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
+ function insertSegmentIntoGrid(grid, centerline, segmentId, expansion) {
575
+ insertIdIntoGridBounds(grid, segmentId, {
576
+ minX: centerline.minX[segmentId] - expansion,
577
+ maxX: centerline.maxX[segmentId] + expansion,
578
+ minY: centerline.minY[segmentId] - expansion,
579
+ maxY: centerline.maxY[segmentId] + expansion,
580
+ });
581
+ }
582
+
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
+ function candidateIdsFromArcBuckets(index, distanceAlong, radius) {
597
+ const arcBuckets = index?.arcBuckets;
598
+ if (!arcBuckets?.count || !Number.isFinite(distanceAlong)) return [];
599
+ const scratch = ensureQueryScratch(index);
600
+ const segmentMarks = ensureScratchArray(scratch, 'candidateMarks', index.centerline.segmentCount);
601
+ const candidateEpoch = nextScratchEpoch(scratch, 'candidateEpoch', segmentMarks);
602
+ const wrapped = wrapDistance(distanceAlong, arcBuckets.totalLength);
603
+ const center = Math.floor(wrapped / arcBuckets.bucketLength);
604
+ const ids = [];
605
+ for (let offset = -radius; offset <= radius; offset += 1) {
606
+ const bucket = ((center + offset) % arcBuckets.count + arcBuckets.count) % arcBuckets.count;
607
+ for (const segmentId of arcBuckets.buckets[bucket]) {
608
+ if (segmentMarks[segmentId] === candidateEpoch) continue;
609
+ segmentMarks[segmentId] = candidateEpoch;
610
+ ids.push(segmentId);
611
+ }
612
+ }
613
+ return ids;
614
+ }
615
+
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
+ function projectIndexedSegment(centerline, segmentId, position) {
684
+ const ax = centerline.startX[segmentId];
685
+ const ay = centerline.startY[segmentId];
686
+ const bx = centerline.endX[segmentId];
687
+ const by = centerline.endY[segmentId];
688
+ const dx = bx - ax;
689
+ const dy = by - ay;
690
+ const lengthSquared = dx * dx + dy * dy;
691
+ const amount = lengthSquared > 0
692
+ ? clamp(((position.x - ax) * dx + (position.y - ay) * dy) / lengthSquared, 0, 1)
693
+ : 0;
694
+ const x = ax + dx * amount;
695
+ const y = ay + dy * amount;
696
+ const distance = centerline.startDistance[segmentId] +
697
+ (centerline.endDistance[segmentId] - centerline.startDistance[segmentId]) * amount;
698
+ const heading = centerline.heading[segmentId];
699
+ const normalX = centerline.normalX[segmentId];
700
+ const normalY = centerline.normalY[segmentId];
701
+ const px = position.x - x;
702
+ const py = position.y - y;
703
+ const signedOffset = px * normalX + py * normalY;
704
+
705
+ return {
706
+ segmentId,
707
+ x,
708
+ y,
709
+ distance,
710
+ heading,
711
+ normalX,
712
+ normalY,
713
+ curvature: centerline.curvature[segmentId],
714
+ signedOffset,
715
+ crossTrackError: Math.abs(signedOffset),
716
+ distanceSquared: px * px + py * py,
717
+ };
718
+ }
719
+
720
+ function segmentFromIndex(centerline, segmentId) {
721
+ return {
722
+ segmentId,
723
+ startX: centerline.startX[segmentId],
724
+ startY: centerline.startY[segmentId],
725
+ endX: centerline.endX[segmentId],
726
+ endY: centerline.endY[segmentId],
727
+ startDistance: centerline.startDistance[segmentId],
728
+ endDistance: centerline.endDistance[segmentId],
729
+ normalX: centerline.normalX[segmentId],
730
+ normalY: centerline.normalY[segmentId],
731
+ endNormalX: centerline.endNormalX[segmentId],
732
+ endNormalY: centerline.endNormalY[segmentId],
733
+ };
734
+ }
735
+
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
+ function seedRayTraceFromNearbyCache(
865
+ index,
866
+ grid,
867
+ origin,
868
+ vector,
869
+ radius,
870
+ maxDistance,
871
+ segmentMarks,
872
+ segmentEpoch,
873
+ cellMarks,
874
+ cellEpoch,
875
+ ids,
876
+ ) {
877
+ const scratch = ensureQueryScratch(index);
878
+ const traceCache = scratch.rayTraceCache;
879
+ const originCell = gridCellForPoint(grid, origin, false);
880
+ if (!originCell) return null;
881
+ const bucket = rayTraceBucket(vector);
882
+ const base = rayTraceBaseKey(grid, originCell, radius);
883
+ const reuseCandidates = [bucket, bucket - 1, bucket + 1]
884
+ .map((candidateBucket) => traceCache.get(rayTraceKey(base, candidateBucket)))
885
+ .filter(Boolean)
886
+ .filter((entry) => entry.maxDistance >= maxDistance * 0.85)
887
+ .sort((a, b) => b.maxDistance - a.maxDistance);
888
+ const seed = reuseCandidates[0];
889
+ if (!seed) return { base, bucket };
890
+
891
+ for (const segmentId of seed.segmentIds) {
892
+ if (segmentMarks[segmentId] === segmentEpoch) continue;
893
+ segmentMarks[segmentId] = segmentEpoch;
894
+ ids.push(segmentId);
895
+ }
896
+ for (const cellId of seed.cellIds) {
897
+ cellMarks[cellId] = cellEpoch;
898
+ }
899
+ return { base, bucket };
900
+ }
901
+
902
+ function storeRayTraceCache(index, grid, origin, vector, radius, maxDistance, segmentIds, cellIds, seed) {
903
+ const scratch = ensureQueryScratch(index);
904
+ const traceCache = scratch.rayTraceCache;
905
+ const originCell = gridCellForPoint(grid, origin, false);
906
+ if (!originCell) return;
907
+ const bucket = seed?.bucket ?? rayTraceBucket(vector);
908
+ const base = seed?.base ?? rayTraceBaseKey(grid, originCell, radius);
909
+ const key = rayTraceKey(base, bucket);
910
+ traceCache.set(key, {
911
+ maxDistance,
912
+ segmentIds: segmentIds.slice(),
913
+ cellIds: cellIds.slice(),
914
+ });
915
+ if (traceCache.size > 96) {
916
+ const oldestKey = traceCache.keys().next().value;
917
+ if (oldestKey) traceCache.delete(oldestKey);
918
+ }
919
+ }
920
+
921
+ function rayTraceBaseKey(grid, center, radius) {
922
+ return `${center.column}:${center.row}:${radius}:${grid.columns}:${grid.rows}`;
923
+ }
924
+
925
+ function rayTraceKey(base, bucket) {
926
+ return `${base}:${bucket}`;
927
+ }
928
+
929
+ function rayTraceBucket(vector) {
930
+ const angleDegrees = Math.atan2(vector.y, vector.x) * 180 / Math.PI;
931
+ return Math.round(angleDegrees / RAY_TRACE_BUCKET_DEGREES);
932
+ }
933
+
934
+ function finitePoint(point) {
935
+ return Number.isFinite(point?.x) && Number.isFinite(point?.y);
936
+ }
937
+
938
+ function finiteBounds(bounds) {
939
+ return Number.isFinite(bounds?.minX) &&
940
+ Number.isFinite(bounds?.maxX) &&
941
+ Number.isFinite(bounds?.minY) &&
942
+ Number.isFinite(bounds?.maxY) &&
943
+ bounds.minX <= bounds.maxX &&
944
+ bounds.minY <= bounds.maxY;
945
+ }