@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
@@ -2,6 +2,7 @@ import { applyWheelSurfaceState } from '../vehicle/wheelSurface.js';
2
2
  import { normalizePitCrewStats } from './pitState.js';
3
3
  import { beginPitPenaltyService } from './pitPenaltyService.js';
4
4
  import { beginTireService } from './pitTireService.js';
5
+ import { syncPitCarKinematics } from './pitKinematics.js';
5
6
  import { getPitBoxRaceDistance } from './pitOccupancy.js';
6
7
 
7
8
  export { normalizePitCrewStats };
@@ -32,6 +33,7 @@ export function beginPitService(sim, car, box) {
32
33
  car.steeringAngle = 0;
33
34
  car.yawRate = 0;
34
35
  car.turnRadius = Infinity;
36
+ syncPitCarKinematics(car, { physicsMode: sim.physicsMode });
35
37
  applyWheelSurfaceState(car, sim.track);
36
38
  car.progress = car.trackState.distance;
37
39
  car.raceDistance = getPitBoxRaceDistance(sim, stop, box);
@@ -6,6 +6,7 @@ import {
6
6
  pitMainLanePointAt,
7
7
  routePoint,
8
8
  } from './pitRouting.js';
9
+ import { syncPitCarKinematics } from './pitKinematics.js';
9
10
  import { getPitBoxRaceDistance, getPitStopBox } from './pitOccupancy.js';
10
11
  import { calculatePitServiceProfile } from './pitServiceProfile.js';
11
12
  import { PIT_BOX_APPROACH_DISTANCE, PIT_EXIT_RELEASE_SPEED_KPH } from './pitServiceConstants.js';
@@ -79,6 +80,7 @@ export function finishPitExit(sim, car) {
79
80
  car.speed = Math.max(car.speed, kphToSimSpeed(PIT_EXIT_RELEASE_SPEED_KPH));
80
81
  car.throttle = 0.55;
81
82
  car.brake = 0;
83
+ syncPitCarKinematics(car, { physicsMode: sim.physicsMode });
82
84
  applyWheelSurfaceState(car, sim.track);
83
85
  car.progress = car.trackState.distance;
84
86
  car.raceDistance = Math.max(car.raceDistance ?? 0, stop.routeEndRaceDistance ?? car.raceDistance ?? 0);
@@ -2,6 +2,7 @@ import { offsetTrackPoint, pointAt } from '../track/trackModel.js';
2
2
  import { applyWheelSurfaceState } from '../vehicle/wheelSurface.js';
3
3
  import { clamp } from '../simMath.js';
4
4
  import { nearestTrackStateForCar } from '../track/trackStatePolicy.js';
5
+ import { freezeVehicleMotion } from '../vehicle/vehicleKinematics.js';
5
6
 
6
7
  export function setPitLaneOpenState(sim, open) {
7
8
  const next = Boolean(open);
@@ -58,12 +59,7 @@ export function holdGridCars(sim) {
58
59
  car.x = position.x;
59
60
  car.y = position.y;
60
61
  car.heading = gridPoint.heading;
61
- car.speed = 0;
62
- car.throttle = 0;
63
- car.brake = 1;
64
- car.steeringAngle = 0;
65
- car.yawRate = 0;
66
- car.turnRadius = Infinity;
62
+ freezeVehicleMotion(car, { physicsMode: sim.physicsMode });
67
63
  car.progress = gridPoint.distance;
68
64
  car.raceDistance = car.gridDistance;
69
65
  applyWheelSurfaceState(car, sim.track);
@@ -14,13 +14,18 @@ import {
14
14
  } from '../timing/raceTiming.js';
15
15
  import { progressDelta } from './raceDistance.js';
16
16
  import { isRaceDnf } from './retirements.js';
17
+ import { freezeVehicleMotion } from '../vehicle/vehicleKinematics.js';
17
18
 
18
19
  export function recalculateRaceStateForSimulation(sim, { updateDrs = true } = {}) {
19
20
  sim.cars.forEach((car) => {
20
21
  const previousRaceDistance = car.raceDistance;
21
22
  if (car.destroyed || car.outOfRace) {
22
23
  applyWheelSurfaceState(car, sim.track);
23
- car.speed = 0;
24
+ freezeVehicleMotion(car, {
25
+ clearManualControls: true,
26
+ physicsMode: sim.physicsMode,
27
+ stabilityState: car.destroyed ? 'destroyed' : car.stabilityState,
28
+ });
24
29
  car.canAttack = false;
25
30
  car.drsEligible = false;
26
31
  car.drsActive = false;
@@ -29,7 +29,6 @@ export function initializeRaceSimulation(simulation, {
29
29
  track = null,
30
30
  trackSeed = null,
31
31
  trackGeneration = {},
32
- trackQueryIndex = true,
33
32
  physicsMode = 'arcade',
34
33
  participantInteractions = {},
35
34
  replayGhosts = [],
@@ -43,11 +42,8 @@ export function initializeRaceSimulation(simulation, {
43
42
  ...builtTrack,
44
43
  pitLane: clonePitLaneModel(builtTrack.pitLane),
45
44
  };
46
- const useTrackQueryIndex = trackQueryIndex !== false;
47
- if (useTrackQueryIndex) {
48
- const forkedCachedIndex = forkTrackQueryIndex(builtTrack.queryIndex);
49
- attachTrackQueryIndex(simulation.track, forkedCachedIndex ?? createTrackQueryIndex(simulation.track));
50
- }
45
+ const forkedCachedIndex = forkTrackQueryIndex(builtTrack.queryIndex, simulation.track);
46
+ attachTrackQueryIndex(simulation.track, forkedCachedIndex ?? createTrackQueryIndex(simulation.track));
51
47
  simulation.track.timingLines = createTimingLines(simulation.track);
52
48
  simulation.trackSeed = simulation.track.seed ?? trackSeed;
53
49
  simulation.rules = normalizeRaceRules(rules);
@@ -40,7 +40,7 @@ export function runRaceStep(simulation, dt) {
40
40
  ];
41
41
  driveCars.forEach((car) => {
42
42
  if (car.destroyed || car.outOfRace) {
43
- freezeRetiredCar(car);
43
+ freezeRetiredCar(car, { physicsMode: simulation.physicsMode });
44
44
  return;
45
45
  }
46
46
  const orderIndex = orderedIndexById.get(car.id);
@@ -59,7 +59,7 @@ export function runRaceStep(simulation, dt) {
59
59
  throttle: controls.throttle ?? 0,
60
60
  brake: controls.brake ?? 0,
61
61
  };
62
- if (simulation.physicsMode === 'simulator') {
62
+ if (simulation.physicsMode === 'advanced') {
63
63
  applyWheelSurfaceState(car, simulation.track);
64
64
  }
65
65
  integrateVehiclePhysics(car, controls, delta, {
@@ -1,11 +1,11 @@
1
+ import { freezeVehicleMotion } from '../vehicle/vehicleKinematics.js';
2
+
1
3
  export function applyRedFlagHoldForSimulation(sim) {
2
4
  sim.cars.forEach((car) => {
3
5
  car.previousX = car.x;
4
6
  car.previousY = car.y;
5
7
  car.previousHeading = car.heading;
6
- car.speed = 0;
7
- car.throttle = 0;
8
- car.brake = 1;
8
+ freezeVehicleMotion(car, { physicsMode: sim.physicsMode });
9
9
  car.drsActive = false;
10
10
  car.drsEligible = false;
11
11
  car.drsZoneId = null;
@@ -1,5 +1,6 @@
1
1
  import { pointAt } from '../track/trackModel.js';
2
2
  import { kphToSimSpeed } from '../units.js';
3
+ import { freezeVehicleMotion, syncVehicleVelocityToHeading } from '../vehicle/vehicleKinematics.js';
3
4
 
4
5
  export function setSafetyCarState(sim, deployed) {
5
6
  const next = Boolean(deployed);
@@ -60,9 +61,7 @@ export function setRedFlagState(sim, deployed) {
60
61
  sim.raceControl.mode = 'red-flag';
61
62
  sim.raceControl.frozenOrder = sim.orderedCars().map((car) => car.id);
62
63
  sim.cars.forEach((car) => {
63
- car.speed = 0;
64
- car.throttle = 0;
65
- car.brake = 1;
64
+ freezeVehicleMotion(car, { physicsMode: sim.physicsMode });
66
65
  car.drsActive = false;
67
66
  car.drsEligible = false;
68
67
  car.drsZoneId = null;
@@ -75,8 +74,15 @@ export function setRedFlagState(sim, deployed) {
75
74
  ? sim.orderedCars().map((car) => car.id)
76
75
  : null;
77
76
  sim.cars.forEach((car) => {
78
- if (!car.finished) {
77
+ if (car.destroyed || car.outOfRace) {
78
+ freezeVehicleMotion(car, {
79
+ clearManualControls: true,
80
+ physicsMode: sim.physicsMode,
81
+ stabilityState: car.destroyed ? 'destroyed' : car.stabilityState,
82
+ });
83
+ } else if (!car.finished) {
79
84
  car.speed = Math.max(car.speed, kphToSimSpeed(60));
85
+ if (sim.physicsMode === 'advanced') syncVehicleVelocityToHeading(car);
80
86
  car.brake = 0;
81
87
  car.throttle = Math.max(car.throttle ?? 0, 0.35);
82
88
  }
@@ -1,5 +1,6 @@
1
1
  import { markCarDnf } from './retirements.js';
2
2
  import { simSpeedToKph } from '../units.js';
3
+ import { freezeVehicleMotion } from '../vehicle/vehicleKinematics.js';
3
4
 
4
5
  const LEGAL_STALL_SURFACES = new Set(['track', 'kerb', 'pit-entry', 'pit-lane', 'pit-exit', 'pit-box']);
5
6
 
@@ -48,7 +49,7 @@ function retireStalledCar(sim, car) {
48
49
  if (car.finished || car.destroyed || car.outOfRace) return;
49
50
  car.outOfRace = true;
50
51
  markCarDnf(sim, car, { reason: 'stalled-off-track' });
51
- freezeRetiredCar(car);
52
+ freezeRetiredCar(car, { physicsMode: sim.physicsMode });
52
53
  sim.events.unshift({
53
54
  type: 'car-dnf',
54
55
  at: sim.time,
@@ -58,27 +59,17 @@ function retireStalledCar(sim, car) {
58
59
  });
59
60
  }
60
61
 
61
- export function freezeRetiredCar(car) {
62
- car.speed = 0;
63
- car.velocityX = 0;
64
- car.velocityY = 0;
65
- car.throttle = 0;
66
- car.brake = 1;
67
- car.appliedControls = { steering: 0, throttle: 0, brake: 1 };
68
- car.manualControls = null;
62
+ export function freezeRetiredCar(car, { physicsMode = 'arcade' } = {}) {
63
+ freezeVehicleMotion(car, {
64
+ clearManualControls: true,
65
+ physicsMode,
66
+ stabilityState: 'destroyed',
67
+ });
69
68
  car.canAttack = false;
70
69
  car.drsActive = false;
71
70
  car.drsEligible = false;
72
71
  car.drsZoneId = null;
73
72
  car.drsZoneEnabled = false;
74
- car.steeringAngle = 0;
75
- car.yawRate = 0;
76
- car.longitudinalAcceleration = 0;
77
- car.lateralAcceleration = 0;
78
- car.longitudinalG = 0;
79
- car.lateralG = 0;
80
- car.tractionLimited = false;
81
- car.stabilityState = 'destroyed';
82
73
  }
83
74
 
84
75
  function resetStallTimer(car) {
@@ -152,6 +152,7 @@ export class F1RaceSimulation {
152
152
  cars: this.cars,
153
153
  computeLap: (raceDistance) => this.computeLap(raceDistance),
154
154
  nearestDistanceOnRoute,
155
+ physicsMode: this.physicsMode,
155
156
  progressDelta,
156
157
  raceControl: this.raceControl,
157
158
  releaseRaceStart: () => this.releaseRaceStart(),
@@ -11,6 +11,9 @@ function forwardVector(car) {
11
11
  }
12
12
 
13
13
  function velocityVector(car) {
14
+ if (Number.isFinite(car.velocityX) && Number.isFinite(car.velocityY)) {
15
+ return { x: car.velocityX, y: car.velocityY };
16
+ }
14
17
  const forward = forwardVector(car);
15
18
  return { x: forward.x * car.speed, y: forward.y * car.speed };
16
19
  }
@@ -47,6 +47,7 @@ export function createVehicleSnapshotDependencies(sim) {
47
47
  simSpeedToKph,
48
48
  simUnitsToMeters,
49
49
  VEHICLE_LIMITS,
50
+ physicsMode: sim.physicsMode,
50
51
  };
51
52
  }
52
53
 
@@ -33,9 +33,10 @@ function getLapTelemetryPosition(track, raceDistance, totalLaps = Infinity) {
33
33
  };
34
34
  }
35
35
 
36
- function createSectorProgress(position, currentSectors = null) {
36
+ function createSectorProgress(position, currentSectors = null, { preserveCompletedSectors = false } = {}) {
37
37
  const activeIndex = clamp((position.currentSector ?? 1) - 1, 0, TELEMETRY_SECTOR_COUNT - 1);
38
38
  return createEmptySectorTimes().map((_, index) => {
39
+ if (preserveCompletedSectors && Number.isFinite(currentSectors?.[index])) return 1;
39
40
  if (index < activeIndex && Number.isFinite(currentSectors?.[index])) return 1;
40
41
  if (index === activeIndex) return clamp(position.currentSectorProgress ?? 0, 0, 1);
41
42
  return 0;
@@ -99,11 +100,23 @@ export function updateLapTelemetry(car, previousRaceDistance, currentTime, track
99
100
  const previousUpdateTime = Number.isFinite(telemetry.lastUpdatedAt) ? telemetry.lastUpdatedAt : currentTime;
100
101
  const elapsedTime = Math.max(0, currentTime - previousUpdateTime);
101
102
 
102
- if (!Number.isFinite(previousDistance) || !Number.isFinite(currentDistance) || travelled < -1e-3 || travelled > track.length / 2) {
103
+ if (
104
+ !Number.isFinite(previousDistance) ||
105
+ !Number.isFinite(currentDistance) ||
106
+ Math.abs(travelled) > track.length / 2
107
+ ) {
103
108
  resetLapTelemetry(car, currentTime, track, totalLaps);
104
109
  return;
105
110
  }
106
111
 
112
+ if (travelled < -1e-3) {
113
+ syncLapTelemetryPosition(telemetry, currentTime, currentRaceDistance, track, totalLaps, {
114
+ preserveCompletedSectors: true,
115
+ });
116
+ telemetry.lastUpdatedAt = currentTime;
117
+ return;
118
+ }
119
+
107
120
  const sectorLength = getSectorLength(track);
108
121
  if (travelled > 1e-6) {
109
122
  const firstBoundary = Math.floor(previousDistance / sectorLength) + 1;
@@ -155,28 +168,34 @@ function clearFutureSectorTelemetry(telemetry) {
155
168
  }
156
169
  }
157
170
 
158
- function syncLiveSectorTelemetry(telemetry) {
171
+ function syncLiveSectorTelemetry(telemetry, { preserveCompletedSectors = false } = {}) {
159
172
  const activeIndex = clamp((telemetry.currentSector ?? 1) - 1, 0, TELEMETRY_SECTOR_COUNT - 1);
160
173
 
161
174
  telemetry.liveSectors = createEmptySectorTimes();
162
175
  telemetry.currentSectors.forEach((time, index) => {
163
- if (index >= activeIndex) return;
176
+ if (!preserveCompletedSectors && index >= activeIndex) return;
164
177
  if (!Number.isFinite(time)) return;
165
178
  telemetry.liveSectors[index] = time;
166
179
  });
167
- telemetry.liveSectors[activeIndex] = telemetry.currentSectorElapsed;
180
+ if (!Number.isFinite(telemetry.liveSectors[activeIndex])) {
181
+ telemetry.liveSectors[activeIndex] = telemetry.currentSectorElapsed;
182
+ }
168
183
  }
169
184
 
170
- function syncLapTelemetryPosition(telemetry, currentTime, currentRaceDistance, track, totalLaps) {
185
+ function syncLapTelemetryPosition(telemetry, currentTime, currentRaceDistance, track, totalLaps, {
186
+ preserveCompletedSectors = false,
187
+ } = {}) {
171
188
  const position = getLapTelemetryPosition(track, currentRaceDistance, totalLaps);
172
189
  telemetry.completedLaps = position.completedLaps;
173
190
  telemetry.currentLap = position.currentLap;
174
191
  telemetry.currentSector = position.currentSector;
175
192
  telemetry.currentSectorProgress = position.currentSectorProgress;
176
- clearFutureSectorTelemetry(telemetry);
177
- telemetry.sectorProgress = createSectorProgress(position, telemetry.currentSectors);
193
+ if (!preserveCompletedSectors) clearFutureSectorTelemetry(telemetry);
194
+ telemetry.sectorProgress = createSectorProgress(position, telemetry.currentSectors, {
195
+ preserveCompletedSectors,
196
+ });
178
197
  telemetry.completedLaps = Math.max(telemetry.completedLaps, Math.min(position.completedLaps, totalLaps));
179
198
  telemetry.currentLapTime = Math.max(0, currentTime - telemetry.currentLapStartedAt);
180
199
  telemetry.currentSectorElapsed = Math.max(0, currentTime - telemetry.currentSectorStartedAt);
181
- syncLiveSectorTelemetry(telemetry);
200
+ syncLiveSectorTelemetry(telemetry, { preserveCompletedSectors });
182
201
  }
@@ -1,9 +1,9 @@
1
1
  import { clamp } from '../simMath.js';
2
2
  import { metersToSimUnits } from '../units.js';
3
3
  import { WORLD, PIT_BOX_COUNT, PIT_BOX_DEPTH, PIT_BOX_LENGTH, PIT_BOX_PAIR_GAP, PIT_BOX_TO_LANE_GAP, PIT_ENTRY_SEARCH_AFTER, PIT_ENTRY_SEARCH_BEFORE, PIT_EXIT_SEARCH_AFTER, PIT_EXIT_SEARCH_BEFORE, PIT_LANE_EDGE_GAP, PIT_LANE_ENTRY_BUFFER, PIT_LANE_EXIT_BUFFER, PIT_LANE_FINISH_RATIO, PIT_LANE_OFFSET_SEARCH_STEP, PIT_LANE_WIDTH, PIT_SERVICE_AREA_DEPTH, PIT_SERVICE_AREA_LENGTH, PIT_SERVICE_QUEUE_GAP, PIT_TEAM_COUNT, PIT_TEAM_GAP, PIT_BOXES_PER_TEAM, PIT_WORKING_LANE_GAP, PIT_WORKING_LANE_WIDTH, PIT_ACCESS_MAX_LENGTH, PIT_ACCESS_MIN_LENGTH, PIT_ACCESS_TANGENT_RATIO, PIT_TRACK_CLEARANCE_MARGIN } from './trackConstants.js';
4
- import { clonePoint, distance, headingVector, interpolatePitPoint, normalizeVector, pointWorldClearance, projectPitPoint, createPointBounds } from './trackMath.js';
4
+ import { clonePoint, distance, headingVector, interpolatePitPoint, nearestPointOnPolyline, normalizeVector, pointWorldClearance, projectPitPoint, createPointBounds } from './trackMath.js';
5
5
  import { findPitAccessConnection, createPitAccessRoadCenterline } from './pitLaneAccess.js';
6
- import { nearestTrackState, pointAt } from './spatialQueries.js';
6
+ import { pointAt } from './spatialQueries.js';
7
7
 
8
8
  export function createStartStraightBasis(track) {
9
9
  const finish = pointAt(track, 0);
@@ -90,7 +90,7 @@ export function scorePitLanePlacement(track, side, laneOffset, layout) {
90
90
  minimumClearance = Math.min(minimumClearance, pointWorldClearance(lanePoint), pointWorldClearance(boxPoint));
91
91
  minimumTrackClearance = Math.min(
92
92
  minimumTrackClearance,
93
- nearestTrackState(track, lanePoint).crossTrackError -
93
+ nearestMainTrackProjection(track, lanePoint).crossTrackError -
94
94
  (track.width / 2 + (track.kerbWidth ?? 0) + PIT_TRACK_CLEARANCE_MARGIN),
95
95
  );
96
96
  }
@@ -105,6 +105,10 @@ export function scorePitLanePlacement(track, side, laneOffset, layout) {
105
105
  };
106
106
  }
107
107
 
108
+ function nearestMainTrackProjection(track, position) {
109
+ return nearestPointOnPolyline(track.samples, position) ?? { crossTrackError: Infinity };
110
+ }
111
+
108
112
  export function choosePitLanePlacement(track, baseLaneOffset, layout) {
109
113
  const candidates = [];
110
114
  for (let offsetStep = 0; offsetStep <= 9; offsetStep += 1) {
@@ -1,5 +1,5 @@
1
1
  import { clamp, normalizeAngle, wrapDistance } from '../simMath.js';
2
- import { NEAREST_HINT_WINDOW_SAMPLES, PIT_LANE_WIDTH } from './trackConstants.js';
2
+ import { PIT_LANE_WIDTH } from './trackConstants.js';
3
3
  import { nearestPitLaneState } from './pitLaneState.js';
4
4
  import { queryNearestTrackProjection } from './trackQueryIndex.js';
5
5
 
@@ -91,13 +91,10 @@ export function createTrackState(track, position, best) {
91
91
 
92
92
  export function nearestTrackState(track, position, progressHint = null, options = {}) {
93
93
  const allowPitOverride = options.allowPitOverride !== false;
94
- const best = options.indexMode === 'legacy'
95
- ? nearestTrackSampleLegacy(track, position, progressHint)
96
- : queryNearestTrackProjection(track, position, progressHint, {
97
- indexMode: options.indexMode,
98
- hintMaxDistance: options.hintMaxDistance,
99
- }) ??
100
- nearestTrackSampleLegacy(track, position, progressHint);
94
+ const best = queryNearestTrackProjection(track, position, progressHint);
95
+ if (!best) {
96
+ throw new Error('nearestTrackState requires a finite position and an indexed track model. Build tracks with buildTrackModel() or createRaceSimulation().');
97
+ }
101
98
  const trackState = createTrackState(track, position, best);
102
99
  if (!allowPitOverride) return trackState;
103
100
  const pitState = nearestPitLaneState(track, position);
@@ -125,26 +122,6 @@ export function nearestTrackState(track, position, progressHint = null, options
125
122
  };
126
123
  }
127
124
 
128
- function nearestTrackSampleLegacy(track, position, progressHint = null) {
129
- let nearest = null;
130
-
131
- if (Number.isFinite(progressHint)) {
132
- const centerIndex = sampleIndexAtDistance(track, progressHint);
133
- nearest = nearestSampleInRange(
134
- track,
135
- position,
136
- centerIndex - NEAREST_HINT_WINDOW_SAMPLES,
137
- centerIndex + NEAREST_HINT_WINDOW_SAMPLES,
138
- );
139
- const fallbackDistance = track.width / 2 + (track.kerbWidth ?? 0) + track.gravelWidth + track.runoffWidth + 180;
140
- if (!nearest.best || nearest.bestDistance > fallbackDistance * fallbackDistance) {
141
- nearest = null;
142
- }
143
- }
144
-
145
- return nearest?.best ?? nearestSampleGlobal(track, position).best;
146
- }
147
-
148
125
  export function offsetTrackPoint(point, offset) {
149
126
  return {
150
127
  x: point.x + point.normalX * offset,
@@ -0,0 +1,94 @@
1
+ import { clamp } from '../simMath.js';
2
+ import { ensureQueryScratch, ensureScratchArray, nextScratchEpoch } from './trackQueryScratch.js';
3
+
4
+ export function createSpatialGrid(bounds, cellSize) {
5
+ const columns = Math.max(1, Math.ceil((bounds.maxX - bounds.minX) / cellSize));
6
+ const rows = Math.max(1, Math.ceil((bounds.maxY - bounds.minY) / cellSize));
7
+ return {
8
+ bounds,
9
+ cellSize,
10
+ columns,
11
+ rows,
12
+ cells: new Array(columns * rows),
13
+ };
14
+ }
15
+
16
+ export function insertIdIntoGridBounds(grid, id, bounds) {
17
+ const minCell = gridCellForPoint(grid, { x: bounds.minX, y: bounds.minY }, true);
18
+ const maxCell = gridCellForPoint(grid, { x: bounds.maxX, y: bounds.maxY }, true);
19
+ for (let row = minCell.row; row <= maxCell.row; row += 1) {
20
+ for (let column = minCell.column; column <= maxCell.column; column += 1) {
21
+ const cellIndex = row * grid.columns + column;
22
+ const cell = grid.cells[cellIndex];
23
+ if (cell) cell.push(id);
24
+ else grid.cells[cellIndex] = [id];
25
+ }
26
+ }
27
+ }
28
+
29
+ export function candidateIdsFromGrid(index, grid, position, neighborLimit) {
30
+ const center = gridCellForPoint(grid, position, false);
31
+ if (!center) return [];
32
+ const scratch = ensureQueryScratch(index);
33
+ const maxId = Math.max(index.centerline.segmentCount, index.pit?.roadSegments?.length ?? 0, index.pit?.boxCandidates?.length ?? 0);
34
+ const idMarks = ensureScratchArray(scratch, 'candidateMarks', maxId);
35
+ const ids = [];
36
+ for (let radius = 0; radius <= neighborLimit; radius += 1) {
37
+ const candidateEpoch = nextScratchEpoch(scratch, 'candidateEpoch', idMarks);
38
+ ids.length = 0;
39
+ for (let row = center.row - radius; row <= center.row + radius; row += 1) {
40
+ for (let column = center.column - radius; column <= center.column + radius; column += 1) {
41
+ if (column < 0 || row < 0 || column >= grid.columns || row >= grid.rows) continue;
42
+ const cell = grid.cells[row * grid.columns + column];
43
+ if (!cell) continue;
44
+ for (const id of cell) {
45
+ if (idMarks[id] === candidateEpoch) continue;
46
+ idMarks[id] = candidateEpoch;
47
+ ids.push(id);
48
+ }
49
+ }
50
+ }
51
+ if (ids.length) return ids.slice();
52
+ }
53
+ return [];
54
+ }
55
+
56
+ export function candidateIdsFromGridBounds(index, grid, bounds) {
57
+ if (
58
+ bounds.maxX < grid.bounds.minX ||
59
+ bounds.minX > grid.bounds.maxX ||
60
+ bounds.maxY < grid.bounds.minY ||
61
+ bounds.minY > grid.bounds.maxY
62
+ ) return [];
63
+ const minPoint = { x: bounds.minX, y: bounds.minY };
64
+ const maxPoint = { x: bounds.maxX, y: bounds.maxY };
65
+ const minCell = gridCellForPoint(grid, minPoint, true);
66
+ const maxCell = gridCellForPoint(grid, maxPoint, true);
67
+ const scratch = ensureQueryScratch(index);
68
+ const maxId = Math.max(index.centerline.segmentCount, index.pit?.roadSegments?.length ?? 0, index.pit?.boxCandidates?.length ?? 0);
69
+ const idMarks = ensureScratchArray(scratch, 'candidateMarks', maxId);
70
+ const candidateEpoch = nextScratchEpoch(scratch, 'candidateEpoch', idMarks);
71
+ const ids = [];
72
+ for (let row = minCell.row; row <= maxCell.row; row += 1) {
73
+ for (let column = minCell.column; column <= maxCell.column; column += 1) {
74
+ const cell = grid.cells[row * grid.columns + column];
75
+ if (!cell) continue;
76
+ for (const id of cell) {
77
+ if (idMarks[id] === candidateEpoch) continue;
78
+ idMarks[id] = candidateEpoch;
79
+ ids.push(id);
80
+ }
81
+ }
82
+ }
83
+ return ids;
84
+ }
85
+
86
+ export function gridCellForPoint(grid, point, clampToGrid) {
87
+ const column = Math.floor((point.x - grid.bounds.minX) / grid.cellSize);
88
+ const row = Math.floor((point.y - grid.bounds.minY) / grid.cellSize);
89
+ if (!clampToGrid && (column < 0 || row < 0 || column >= grid.columns || row >= grid.rows)) return null;
90
+ return {
91
+ column: clampToGrid ? clamp(column, 0, grid.columns - 1) : column,
92
+ row: clampToGrid ? clamp(row, 0, grid.rows - 1) : row,
93
+ };
94
+ }