@inventure71/paddockjs 2.0.1 → 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 (80) hide show
  1. package/CHANGELOG.md +31 -1
  2. package/INSTALL_AND_UPDATE.md +10 -1
  3. package/README.md +65 -15
  4. package/docs/architecture.md +17 -13
  5. package/docs/component_inventory.md +72 -0
  6. package/docs/concepts.md +2 -2
  7. package/docs/custom_model_controller.md +2 -2
  8. package/docs/data_contract.md +96 -21
  9. package/docs/index.md +2 -1
  10. package/docs/learnings.md +6 -6
  11. package/docs/rules.md +7 -7
  12. package/docs/sense_contract.md +1 -1
  13. package/docs/system_specs.md +25 -18
  14. package/docs/training.md +13 -12
  15. package/package.json +1 -1
  16. package/src/api/PaddockSimulatorController.js +74 -4
  17. package/src/app/F1SimulatorApp.js +191 -28
  18. package/src/app/banners/raceDataBannerController.js +71 -36
  19. package/src/app/banners/stewardMessageController.js +5 -4
  20. package/src/app/camera/cameraConstants.js +2 -0
  21. package/src/app/camera/cameraController.js +86 -29
  22. package/src/app/camera/cameraGeometry.js +12 -1
  23. package/src/app/domBindings.js +89 -9
  24. package/src/app/layoutScheduler.js +27 -0
  25. package/src/app/layoutSupport.js +165 -0
  26. package/src/app/raceOverlayClearanceSupport.js +158 -0
  27. package/src/app/readouts/carOverviewRenderer.js +16 -8
  28. package/src/app/readouts/controlStateRenderer.js +1 -1
  29. package/src/app/readouts/raceStatusRenderer.js +30 -21
  30. package/src/app/readouts/readoutFormatters.js +2 -0
  31. package/src/app/readouts/telemetryRenderer.js +17 -8
  32. package/src/app/readouts/timingTowerRenderer.js +62 -10
  33. package/src/app/runtime/runtimeVisibility.js +66 -5
  34. package/src/config/cssValues.js +16 -0
  35. package/src/config/defaultOptions.js +51 -38
  36. package/src/config/themeOptions.js +825 -0
  37. package/src/config/timingGapMode.js +13 -0
  38. package/src/config/urlOptions.js +25 -0
  39. package/src/data/championship.js +14 -9
  40. package/src/environment/controllerLoop.js +17 -3
  41. package/src/environment/index.d.ts +1 -2
  42. package/src/environment/options.js +1 -1
  43. package/src/environment/runtime.js +3 -11
  44. package/src/environment/scenarios.js +1 -1
  45. package/src/environment/sensors/surfaceRays.js +41 -33
  46. package/src/environment/sensors/trackRays.js +44 -49
  47. package/src/index.d.ts +110 -9
  48. package/src/index.js +25 -1
  49. package/src/simulation/driver/driverController.js +1 -1
  50. package/src/simulation/driver/edgeRecovery.js +1 -1
  51. package/src/simulation/driver/racingControls.js +2 -2
  52. package/src/simulation/driver/rejoinControls.js +2 -2
  53. package/src/simulation/pit/pitKinematics.js +2 -2
  54. package/src/simulation/pit/pitRouting.js +2 -4
  55. package/src/simulation/race/raceSetup.js +2 -6
  56. package/src/simulation/race/raceStep.js +1 -1
  57. package/src/simulation/race/safetyCar.js +1 -1
  58. package/src/simulation/race/stalledDnf.js +1 -1
  59. package/src/simulation/timing/lapTelemetry.js +28 -9
  60. package/src/simulation/track/pitLaneLayout.js +7 -3
  61. package/src/simulation/track/spatialQueries.js +5 -28
  62. package/src/simulation/track/trackQueryIndex.js +180 -135
  63. package/src/simulation/track/trackStatePolicy.js +0 -1
  64. package/src/simulation/vehicle/contactResolution.js +3 -3
  65. package/src/simulation/vehicle/vehicleKinematics.js +2 -2
  66. package/src/simulation/vehicle/vehiclePhysics.js +3 -3
  67. package/src/simulation/vehicle/vehicleSnapshots.js +12 -1
  68. package/src/simulation/vehicle/vehicleState.js +1 -1
  69. package/src/simulation/warmup/runtimeWarmup.js +0 -1
  70. package/src/styles.css +1185 -136
  71. package/src/ui/bannerTemplates.js +38 -28
  72. package/src/ui/cameraControlsTemplate.js +16 -5
  73. package/src/ui/carOverviewTemplate.js +12 -5
  74. package/src/ui/raceCanvasTemplate.js +31 -8
  75. package/src/ui/raceControlsTemplate.js +27 -10
  76. package/src/ui/shellTemplate.js +3 -1
  77. package/src/ui/telemetryDrawerTemplate.js +23 -10
  78. package/src/ui/telemetryTemplates.js +58 -32
  79. package/src/ui/templateUtils.js +48 -0
  80. package/src/ui/timingTowerTemplate.js +29 -12
@@ -43,6 +43,7 @@ export function createTrackQueryIndex(track) {
43
43
  const index = {
44
44
  version: 1,
45
45
  bands,
46
+ nearestGridExpansion: expansion,
46
47
  centerline,
47
48
  grid,
48
49
  segmentGrid,
@@ -54,7 +55,7 @@ export function createTrackQueryIndex(track) {
54
55
  return index;
55
56
  }
56
57
 
57
- export function queryNearestTrackProjection(track, position, progressHint = null, options = {}) {
58
+ export function queryNearestTrackProjection(track, position, progressHint = null) {
58
59
  const index = track?.queryIndex;
59
60
  if (!index?.centerline?.segmentCount) {
60
61
  recordFallback(index, 'nearestFallbackReasons', 'missing-index');
@@ -66,8 +67,7 @@ export function queryNearestTrackProjection(track, position, progressHint = null
66
67
  }
67
68
  index.stats.nearestQueries += 1;
68
69
 
69
- const mode = options.indexMode === 'sample' ? 'sample' : 'projection';
70
- const { projection, path, reason } = bestNearestFromIndex(track, index, position, progressHint, mode, options);
70
+ const { projection, path, reason } = bestProjectionFromIndex(index, position, progressHint);
71
71
  if (!projection) {
72
72
  recordFallback(index, 'nearestFallbackReasons', reason ?? 'unknown');
73
73
  return null;
@@ -77,169 +77,173 @@ export function queryNearestTrackProjection(track, position, progressHint = null
77
77
  return projection;
78
78
  }
79
79
 
80
- function bestNearestFromIndex(track, index, position, progressHint, mode, options) {
81
- return mode === 'sample'
82
- ? bestSampleFromIndex(track, index, position, progressHint, options)
83
- : bestProjectionFromIndex(index, position, progressHint, options);
84
- }
85
-
86
- function bestProjectionFromIndex(index, position, progressHint, options) {
80
+ function bestProjectionFromIndex(index, position, progressHint) {
87
81
  const hintedCandidateIds = Number.isFinite(progressHint)
88
82
  ? candidateIdsFromArcBuckets(index, progressHint, 2)
89
83
  : [];
90
84
  if (hintedCandidateIds.length) {
91
85
  const hinted = bestProjectionFromCandidates(index.centerline, hintedCandidateIds, position, progressHint);
92
- const maxHintDistance = resolveHintMaxDistance(index, options);
93
- if (hinted.projection && hinted.projection.distanceSquared <= maxHintDistance * maxHintDistance) {
94
- return {
95
- projection: hinted.projection,
96
- path: hinted.reason === 'tie-resolved' ? 'arc-hint-tie-resolved' : 'arc-hint',
97
- };
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
+ }
98
97
  }
99
- recordStat(index, 'nearestPaths', hinted.projection ? 'arc-hint-rejected-distance' : `arc-hint-${hinted.reason}`);
100
98
  }
101
99
 
100
+ let local = { projection: null, path: null };
102
101
  const gridCandidateIds = candidateIdsFromGrid(index, index.grid, position, GRID_NEIGHBOR_LIMIT);
103
102
  const grid = bestProjectionFromCandidates(index.centerline, gridCandidateIds, position, progressHint);
104
- return grid.projection
105
- ? {
106
- projection: grid.projection,
107
- 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;
108
107
  }
109
- : { projection: null, reason: `spatial-grid-${grid.reason}` };
110
- }
108
+ }
111
109
 
112
- function bestSampleFromIndex(track, index, position, progressHint, options) {
113
- const samples = track?.samples;
114
- const sampleCount = Math.max(0, samples?.length - 1);
115
- if (sampleCount <= 0) return { projection: null, reason: 'missing-samples' };
116
- const hintedCandidateIds = Number.isFinite(progressHint)
117
- ? candidateIdsFromArcBuckets(index, progressHint, 2)
118
- : [];
119
- if (hintedCandidateIds.length) {
120
- const hinted = bestSampleFromCandidates(index, samples, sampleCount, hintedCandidateIds, position, progressHint);
121
- const maxHintDistance = resolveHintMaxDistance(index, options);
122
- 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`;
123
116
  return {
124
- projection: hinted.sample,
125
- 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,
126
119
  };
127
120
  }
128
- 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
+ };
129
126
  }
130
127
 
131
- const gridCandidateIds = candidateIdsFromGrid(index, index.grid, position, GRID_NEIGHBOR_LIMIT);
132
- const grid = bestSampleFromCandidates(index, samples, sampleCount, gridCandidateIds, position, progressHint);
133
- 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
134
131
  ? {
135
- projection: grid.sample,
136
- 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'),
137
136
  }
138
- : { projection: null, reason: `spatial-grid-${grid.reason}` };
137
+ : { projection: null, reason: `global-index-${global.reason}` };
139
138
  }
140
139
 
141
- function resolveHintMaxDistance(index, options = {}) {
142
- if (options.hintMaxDistance == null) return index.bands.runoffEdge + metersToSimUnits(24);
143
- if (!Number.isFinite(options.hintMaxDistance)) return Infinity;
144
- 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;
145
155
  }
146
156
 
147
- function bestSampleFromCandidates(index, samples, sampleCount, candidateSegmentIds, position, preferredDistance = null) {
148
- if (!candidateSegmentIds.length || sampleCount <= 0) return { sample: null, reason: 'no-candidates' };
149
- const scratch = ensureQueryScratch(index);
150
- const sampleMarks = ensureScratchArray(scratch, 'sampleMarks', sampleCount);
151
- 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);
152
160
 
153
- let bestSample = null;
154
- let bestDistanceSquared = Infinity;
155
- 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;
156
169
  let secondBestDistance = Infinity;
157
170
  let tieResolved = false;
158
- for (const segmentId of candidateSegmentIds) {
159
- const normalized = ((segmentId % sampleCount) + sampleCount) % sampleCount;
160
- const adjacent = (normalized + 1) % sampleCount;
161
- if (sampleMarks[normalized] !== sampleEpoch) {
162
- sampleMarks[normalized] = sampleEpoch;
163
- ({ bestSample, bestDistanceSquared, bestTieScore, secondBestDistance, tieResolved } = considerSampleCandidate({
164
- samples,
165
- sampleId: normalized,
166
- position,
167
- preferredDistance,
168
- bestSample,
169
- bestDistanceSquared,
170
- bestTieScore,
171
- secondBestDistance,
172
- tieResolved,
173
- }));
174
- }
175
- if (sampleMarks[adjacent] !== sampleEpoch) {
176
- sampleMarks[adjacent] = sampleEpoch;
177
- ({ bestSample, bestDistanceSquared, bestTieScore, secondBestDistance, tieResolved } = considerSampleCandidate({
178
- samples,
179
- sampleId: adjacent,
180
- position,
181
- preferredDistance,
182
- bestSample,
183
- bestDistanceSquared,
184
- bestTieScore,
185
- secondBestDistance,
186
- tieResolved,
187
- }));
188
- }
189
- }
190
171
 
191
- if (!bestSample) return { sample: null, reason: 'no-best' };
172
+ for (let radius = 0; radius <= maxRadius; radius += 1) {
173
+ let ringCanImprove = !best;
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
+ }
192
208
 
209
+ if (!best) return { projection: null, reason: 'no-candidates' };
193
210
  const ambiguous = Number.isFinite(secondBestDistance) &&
194
- Math.abs(secondBestDistance - bestDistanceSquared) <= AMBIGUOUS_DISTANCE_EPSILON;
195
- return {
196
- sample: bestSample,
197
- distanceSquared: bestDistanceSquared,
198
- reason: ambiguous || tieResolved ? 'tie-resolved' : 'ok',
199
- };
211
+ Math.abs(secondBestDistance - best.distanceSquared) <= AMBIGUOUS_DISTANCE_EPSILON;
212
+ return { projection: best, reason: ambiguous || tieResolved ? 'tie-resolved' : 'ok' };
200
213
  }
201
214
 
202
- function considerSampleCandidate({
203
- samples,
204
- sampleId,
205
- position,
206
- preferredDistance,
207
- bestSample,
208
- bestDistanceSquared,
209
- bestTieScore,
210
- secondBestDistance,
211
- tieResolved,
212
- }) {
213
- const sample = samples[sampleId];
214
- if (!sample) {
215
- return { bestSample, bestDistanceSquared, bestTieScore, secondBestDistance, tieResolved };
216
- }
217
- const dx = position.x - sample.x;
218
- const dy = position.y - sample.y;
219
- const distanceSquared = dx * dx + dy * dy;
220
- const tieScore = sampleTieScore(sample, preferredDistance);
221
- if (!bestSample || distanceSquared < bestDistanceSquared - AMBIGUOUS_DISTANCE_EPSILON) {
222
- secondBestDistance = bestDistanceSquared;
223
- bestSample = sample;
224
- bestDistanceSquared = distanceSquared;
225
- bestTieScore = tieScore;
226
- } else if (Math.abs(distanceSquared - bestDistanceSquared) <= AMBIGUOUS_DISTANCE_EPSILON) {
227
- tieResolved = true;
228
- secondBestDistance = Math.min(secondBestDistance, distanceSquared);
229
- if (tieScore < bestTieScore) {
230
- bestSample = sample;
231
- bestDistanceSquared = distanceSquared;
232
- 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);
233
235
  }
234
- } else if (distanceSquared < secondBestDistance) {
235
- secondBestDistance = distanceSquared;
236
236
  }
237
- return { bestSample, bestDistanceSquared, bestTieScore, secondBestDistance, tieResolved };
238
237
  }
239
238
 
240
- function sampleTieScore(sample, preferredDistance) {
241
- if (!Number.isFinite(preferredDistance)) return sample.distance;
242
- 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;
243
247
  }
244
248
 
245
249
  function bestProjectionFromCandidates(centerline, candidateIds, position, preferredDistance = null) {
@@ -258,7 +262,7 @@ function bestProjectionFromCandidates(centerline, candidateIds, position, prefer
258
262
  } else if (Math.abs(projection.distanceSquared - best.distanceSquared) <= AMBIGUOUS_DISTANCE_EPSILON) {
259
263
  tieResolved = true;
260
264
  secondBestDistance = Math.min(secondBestDistance, projection.distanceSquared);
261
- if (tieScore < bestTieScore) {
265
+ if (projectionTieBeats(projection, tieScore, best, bestTieScore)) {
262
266
  best = projection;
263
267
  bestTieScore = tieScore;
264
268
  }
@@ -274,11 +278,50 @@ function bestProjectionFromCandidates(centerline, candidateIds, position, prefer
274
278
  return { projection: best, reason: ambiguous || tieResolved ? 'tie-resolved' : 'ok' };
275
279
  }
276
280
 
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;
288
+
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
+ }
306
+ }
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' };
312
+ }
313
+
277
314
  function projectionTieScore(projection, preferredDistance) {
278
315
  if (!Number.isFinite(preferredDistance)) return projection.segmentId;
279
316
  return Math.abs(projection.distance - preferredDistance);
280
317
  }
281
318
 
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;
323
+ }
324
+
282
325
  export function queryNearbyTrackProjections(track, position, { neighborLimit = GRID_NEIGHBOR_LIMIT } = {}) {
283
326
  const index = track?.queryIndex;
284
327
  if (!index?.centerline?.segmentCount || !finitePoint(position)) return null;
@@ -364,13 +407,15 @@ export function attachTrackQueryIndex(track, queryIndex) {
364
407
  return track;
365
408
  }
366
409
 
367
- export function forkTrackQueryIndex(sourceIndex) {
410
+ export function forkTrackQueryIndex(sourceIndex, runtimeTrack = null) {
368
411
  if (!sourceIndex || typeof sourceIndex !== 'object') return null;
369
- return {
412
+ const index = {
370
413
  ...sourceIndex,
414
+ pit: runtimeTrack ? createPitQueryIndex(runtimeTrack, sourceIndex.grid?.cellSize ?? DEFAULT_GRID_CELL_SIZE) : sourceIndex.pit,
371
415
  stats: createQueryStats(),
372
- queryScratch: createQueryScratch(sourceIndex),
373
416
  };
417
+ index.queryScratch = createQueryScratch(index);
418
+ return index;
374
419
  }
375
420
 
376
421
  export function resetTrackQueryStats(track) {
@@ -17,7 +17,6 @@ export function nearestTrackStateForCar(
17
17
  ) {
18
18
  const resolvedAllowPitOverride = options.allowPitOverride ?? pitOverrideAllowedForCar(car);
19
19
  return nearestTrackState(track, position, progressHint, {
20
- indexMode: options.indexMode ?? 'legacy',
21
20
  ...options,
22
21
  allowPitOverride: resolvedAllowPitOverride,
23
22
  });
@@ -79,8 +79,8 @@ export function resolveCollisionsForSimulation(sim) {
79
79
  }
80
80
 
81
81
  if (oneCarFixed) {
82
- if (!firstPitControlled) dampPitContactVelocity(first, 0.985, sim.physicsMode === 'simulator');
83
- if (!secondPitControlled) dampPitContactVelocity(second, 0.985, sim.physicsMode === 'simulator');
82
+ if (!firstPitControlled) dampPitContactVelocity(first, 0.985, sim.physicsMode === 'advanced');
83
+ if (!secondPitControlled) dampPitContactVelocity(second, 0.985, sim.physicsMode === 'advanced');
84
84
  } else {
85
85
  applyContactVelocityResponse(sim, first, second, collision.axis);
86
86
  }
@@ -122,7 +122,7 @@ export function resolveCollisionsForSimulation(sim) {
122
122
 
123
123
  export function applyContactVelocityResponse(sim, first, second, axis) {
124
124
  if (
125
- sim.physicsMode === 'simulator' ||
125
+ sim.physicsMode === 'advanced' ||
126
126
  Number.isFinite(first.velocityX) ||
127
127
  Number.isFinite(second.velocityX)
128
128
  ) {
@@ -1,11 +1,11 @@
1
1
  export function freezeVehicleMotion(car, {
2
2
  brake = 1,
3
3
  clearManualControls = false,
4
- physicsMode = 'simulator',
4
+ physicsMode = 'arcade',
5
5
  stabilityState = car.stabilityState ?? 'stable',
6
6
  } = {}) {
7
7
  car.speed = 0;
8
- if (physicsMode === 'simulator') {
8
+ if (physicsMode === 'advanced') {
9
9
  car.velocityX = 0;
10
10
  car.velocityY = 0;
11
11
  } else {
@@ -11,7 +11,7 @@ import { VEHICLE_GEOMETRY, getCarCorners, vehicleAxes } from './vehicleGeometry.
11
11
 
12
12
  const G = 9.80665;
13
13
 
14
- export const PHYSICS_MODES = ['arcade', 'simulator'];
14
+ export const PHYSICS_MODES = ['arcade', 'advanced'];
15
15
  export const DEFAULT_PHYSICS_MODE = 'arcade';
16
16
 
17
17
  export const VEHICLE_LIMITS = {
@@ -50,7 +50,7 @@ const SIMULATOR_SURFACE_MODEL = {
50
50
  };
51
51
 
52
52
  export function normalizePhysicsMode(value) {
53
- return value === 'simulator' ? 'simulator' : DEFAULT_PHYSICS_MODE;
53
+ return value === 'advanced' ? 'advanced' : DEFAULT_PHYSICS_MODE;
54
54
  }
55
55
 
56
56
  function accelerationLimit(speed, surfaceGrip) {
@@ -111,7 +111,7 @@ function setPhysicsTelemetry(car, {
111
111
  }
112
112
 
113
113
  export function integrateVehiclePhysics(car, controls, dt, options = {}) {
114
- if (normalizePhysicsMode(options.physicsMode) === 'simulator') {
114
+ if (normalizePhysicsMode(options.physicsMode) === 'advanced') {
115
115
  return integrateSimulatorVehiclePhysics(car, controls, dt, options);
116
116
  }
117
117
  return integrateArcadeVehiclePhysics(car, controls, dt, options);
@@ -17,7 +17,7 @@ function serializeAppliedControls(car, dependencies) {
17
17
  }
18
18
 
19
19
  function serializeVelocityComponent(car, component, dependencies) {
20
- if (dependencies.physicsMode !== 'simulator') return null;
20
+ if (dependencies.physicsMode !== 'advanced') return null;
21
21
  return finiteOrNull(car[component]);
22
22
  }
23
23
 
@@ -141,6 +141,17 @@ export function serializeCar(car, rank, penaltySeconds = 0, dependencies) {
141
141
  signedOffset: car.trackState?.signedOffset ?? 0,
142
142
  crossTrackError: car.trackState?.crossTrackError ?? 0,
143
143
  surface: car.trackState?.surface ?? 'track',
144
+ trackState: car.trackState ? {
145
+ distance: car.trackState.distance,
146
+ signedOffset: car.trackState.signedOffset,
147
+ crossTrackError: car.trackState.crossTrackError,
148
+ surface: car.trackState.surface,
149
+ inPitLane: Boolean(car.trackState.inPitLane),
150
+ pitLanePart: car.trackState.pitLanePart ?? null,
151
+ pitBoxId: car.trackState.pitBoxId ?? null,
152
+ curvature: car.trackState.curvature ?? 0,
153
+ heading: car.trackState.heading,
154
+ } : null,
144
155
  wheels: serializeWheels(car.wheelStates),
145
156
  inPitLane: Boolean(car.trackState?.inPitLane),
146
157
  pitLanePart: car.trackState?.pitLanePart ?? null,
@@ -164,7 +164,7 @@ export function applyExternalCarState(car, partial, context) {
164
164
  }
165
165
  car.speed = clamp(Number.isFinite(car.speed) ? car.speed : 0, 0, VEHICLE_LIMITS.maxSpeed);
166
166
  car.heading = normalizeAngle(Number.isFinite(car.heading) ? car.heading : 0);
167
- syncExternalVelocityState(car, nextPartial, physicsMode === 'simulator');
167
+ syncExternalVelocityState(car, nextPartial, physicsMode === 'advanced');
168
168
  const centerState = nearestTrackStateForCar(track, car, car, car.progress ?? car.raceDistance);
169
169
  applyWheelSurfaceState(car, track, { centerState });
170
170
  if (
@@ -116,7 +116,6 @@ function createWarmupFingerprint(options, { surface, steps }) {
116
116
  trackGeneration: options.trackGeneration ?? null,
117
117
  track: summarizeTrack(options.track),
118
118
  physicsMode: options.physicsMode ?? null,
119
- trackQueryIndex: options.trackQueryIndex !== false,
120
119
  rules: options.rules ?? null,
121
120
  participantInteractions: options.participantInteractions ?? null,
122
121
  replayGhosts: summarizeReplayGhosts(options.replayGhosts),