@inventure71/paddockjs 4.1.0 → 4.2.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 (38) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/INSTALL_AND_UPDATE.md +9 -2
  3. package/README.md +4 -2
  4. package/docs/architecture.md +2 -0
  5. package/docs/component_inventory.md +1 -1
  6. package/docs/getting-started.md +3 -0
  7. package/docs/index.md +4 -2
  8. package/docs/loading.md +141 -0
  9. package/docs/system_specs.md +4 -1
  10. package/docs/testing-reliability-guide.md +62 -0
  11. package/package.json +5 -7
  12. package/src/data/index.d.ts +64 -1
  13. package/src/environment/index.d.ts +75 -12
  14. package/src/index.d.ts +72 -6
  15. package/src/simulation/collisionGeometry.js +7 -9
  16. package/src/simulation/driver/driverController.js +2 -2
  17. package/src/simulation/driver/edgeRecovery.js +2 -2
  18. package/src/simulation/driver/racingControls.js +35 -25
  19. package/src/simulation/driver/racingLinePlan.js +6 -5
  20. package/src/simulation/driver/rejoinControls.js +3 -3
  21. package/src/simulation/driver/trafficScan.js +48 -22
  22. package/src/simulation/participants/participantInteractions.js +24 -15
  23. package/src/simulation/race/raceStep.js +19 -10
  24. package/src/simulation/race/safetyCar.js +2 -1
  25. package/src/simulation/rules/pitLaneSpeedingSteward.js +1 -1
  26. package/src/simulation/rules/rulesReview.js +19 -9
  27. package/src/simulation/rules/trackLimitsSteward.js +2 -2
  28. package/src/simulation/timing/sectorPerformance.js +11 -3
  29. package/src/simulation/track/spatialQueries.js +80 -11
  30. package/src/simulation/track/trackModel.js +10 -1
  31. package/src/simulation/vehicle/contactResolution.js +4 -4
  32. package/src/simulation/vehicle/vehicleGeometry.js +87 -49
  33. package/src/simulation/vehicle/vehiclePhysics.js +7 -2
  34. package/src/simulation/vehicle/vehicleSnapshots.js +5 -2
  35. package/src/simulation/vehicle/vehicleState.js +3 -2
  36. package/src/ui/raceCanvasTemplate.js +3 -2
  37. package/examples/train-basic-policy.mjs +0 -276
  38. package/examples/trainingData.mjs +0 -133
@@ -1,7 +1,7 @@
1
1
  import { decideDriverControls } from '../driverController.js';
2
2
  import { clamp } from '../simMath.js';
3
3
  import { updateReplayGhosts } from '../replay/replayGhosts.js';
4
- import { integrateVehiclePhysics } from '../vehicle/vehiclePhysics.js';
4
+ import { integrateVehiclePhysics, isSimulatorPhysicsMode } from '../vehicle/vehiclePhysics.js';
5
5
  import { applyWheelSurfaceState } from '../vehicle/wheelSurface.js';
6
6
  import { applyRedFlagHoldForSimulation } from './redFlag.js';
7
7
  import { freezeRetiredCar, updateStalledDnfForSimulation } from './stalledDnf.js';
@@ -38,19 +38,28 @@ export function runRaceStep(simulation, dt) {
38
38
 
39
39
  const orderedCars = simulation.orderedCars();
40
40
  const raceContext = simulation.driverRaceContext(orderedCars);
41
- const orderedIndexById = new Map(orderedCars.map((car, index) => [car.id, index]));
42
- const driveCars = orderedCars.length === simulation.cars.length
43
- ? orderedCars
44
- : [
45
- ...orderedCars,
46
- ...simulation.cars.filter((car) => !orderedIndexById.has(car.id)),
47
- ];
41
+ for (let index = 0; index < simulation.cars.length; index += 1) {
42
+ simulation.cars[index]._driveOrderIndex = -1;
43
+ }
44
+ for (let index = 0; index < orderedCars.length; index += 1) {
45
+ orderedCars[index]._driveOrderIndex = index;
46
+ }
47
+ let driveCars = orderedCars;
48
+ if (orderedCars.length !== simulation.cars.length) {
49
+ driveCars = simulation._driveCarsScratch ??= [];
50
+ driveCars.length = 0;
51
+ driveCars.push(...orderedCars);
52
+ for (let index = 0; index < simulation.cars.length; index += 1) {
53
+ const car = simulation.cars[index];
54
+ if (car._driveOrderIndex === -1) driveCars.push(car);
55
+ }
56
+ }
48
57
  driveCars.forEach((car) => {
49
58
  if (car.destroyed || car.outOfRace) {
50
59
  freezeRetiredCar(car, { physicsMode: simulation.physicsMode });
51
60
  return;
52
61
  }
53
- const orderIndex = orderedIndexById.get(car.id);
62
+ const orderIndex = car._driveOrderIndex >= 0 ? car._driveOrderIndex : undefined;
54
63
  car.previousX = car.x;
55
64
  car.previousY = car.y;
56
65
  car.previousHeading = car.heading;
@@ -66,7 +75,7 @@ export function runRaceStep(simulation, dt) {
66
75
  throttle: controls.throttle ?? 0,
67
76
  brake: controls.brake ?? 0,
68
77
  };
69
- if (simulation.physicsMode === 'advanced') {
78
+ if (isSimulatorPhysicsMode(simulation.physicsMode)) {
70
79
  measureRuntimePhase(simulation, 'prePhysicsWheelSurface', () => {
71
80
  applyWheelSurfaceState(car, simulation.track);
72
81
  });
@@ -1,6 +1,7 @@
1
1
  import { pointAt } from '../track/trackModel.js';
2
2
  import { kphToSimSpeed } from '../units.js';
3
3
  import { freezeVehicleMotion, syncVehicleVelocityToHeading } from '../vehicle/vehicleKinematics.js';
4
+ import { isSimulatorPhysicsMode } from '../vehicle/vehiclePhysics.js';
4
5
 
5
6
  export function setSafetyCarState(sim, deployed) {
6
7
  const next = Boolean(deployed);
@@ -82,7 +83,7 @@ export function setRedFlagState(sim, deployed) {
82
83
  });
83
84
  } else if (!car.finished) {
84
85
  car.speed = Math.max(car.speed, kphToSimSpeed(60));
85
- if (sim.physicsMode === 'advanced') syncVehicleVelocityToHeading(car);
86
+ if (isSimulatorPhysicsMode(sim.physicsMode)) syncVehicleVelocityToHeading(car);
86
87
  car.brake = 0;
87
88
  car.throttle = Math.max(car.throttle ?? 0, 0.35);
88
89
  }
@@ -20,7 +20,7 @@ export function calculatePitLaneSpeedingReview({ car, rule, stewardState }) {
20
20
 
21
21
  if (!isViolation) {
22
22
  return {
23
- nextState: { ...current, active: false },
23
+ nextState: current.active === false ? current : { ...current, active: false },
24
24
  event: null,
25
25
  penalty: null,
26
26
  };
@@ -129,7 +129,9 @@ export function reviewTrackLimitsForSimulation(sim) {
129
129
  sim.cars.forEach((car) => {
130
130
  if (isLegallyInsidePitLaneForTrackLimits(car)) {
131
131
  const current = sim.stewardState.trackLimits[car.id];
132
- sim.stewardState.trackLimits[car.id] = { ...(current ?? { violations: 0 }), active: false };
132
+ if (!current || current.active !== false) {
133
+ sim.stewardState.trackLimits[car.id] = { ...(current ?? { violations: 0 }), active: false };
134
+ }
133
135
  return;
134
136
  }
135
137
 
@@ -146,18 +148,26 @@ export function reviewTrackLimitsForSimulation(sim) {
146
148
  });
147
149
  }
148
150
 
151
+ const PIT_SPEED_REVIEW_SCRATCH = {
152
+ car: { id: null, speedKph: 0, trackState: null, pitLanePart: null },
153
+ rule: null,
154
+ stewardState: null,
155
+ };
156
+
149
157
  export function reviewPitLaneSpeedingForSimulation(sim) {
150
158
  const rule = getPenaltyRule(sim.rules, 'pitLaneSpeeding');
151
159
  sim.cars.forEach((car) => {
152
160
  const currentState = sim.stewardState.pitLaneSpeeding[car.id];
153
- const review = calculatePitLaneSpeedingReview({
154
- car: {
155
- ...car,
156
- speedKph: simSpeedToKph(car.speed),
157
- },
158
- rule,
159
- stewardState: currentState,
160
- });
161
+ // The steward only reads the speed and pit-lane location, so feed it a
162
+ // pooled view instead of spreading the whole car every step.
163
+ const reviewCar = PIT_SPEED_REVIEW_SCRATCH.car;
164
+ reviewCar.id = car.id;
165
+ reviewCar.speedKph = simSpeedToKph(car.speed);
166
+ reviewCar.trackState = car.trackState;
167
+ reviewCar.pitLanePart = car.pitLanePart;
168
+ PIT_SPEED_REVIEW_SCRATCH.rule = rule;
169
+ PIT_SPEED_REVIEW_SCRATCH.stewardState = currentState;
170
+ const review = calculatePitLaneSpeedingReview(PIT_SPEED_REVIEW_SCRATCH);
161
171
  sim.stewardState.pitLaneSpeeding[car.id] = review.nextState;
162
172
  if (review.event) sim.events.unshift({ ...review.event, at: sim.time });
163
173
  if (review.penalty) sim.recordPenalty(review.penalty);
@@ -22,7 +22,7 @@ export function calculateTrackLimitReview({ car, rule, track, stewardState }) {
22
22
  const wheelStates = wheelSurfaceState.wheels;
23
23
  if (wheelStates.some((wheelState) => wheelState.inPitLane)) {
24
24
  return {
25
- nextState: { ...current, active: false },
25
+ nextState: current.active === false ? current : { ...current, active: false },
26
26
  event: null,
27
27
  penalty: null,
28
28
  };
@@ -34,7 +34,7 @@ export function calculateTrackLimitReview({ car, rule, track, stewardState }) {
34
34
 
35
35
  if (!isViolation) {
36
36
  return {
37
- nextState: { ...current, active: false },
37
+ nextState: current.active === false ? current : { ...current, active: false },
38
38
  event: null,
39
39
  penalty: null,
40
40
  };
@@ -5,7 +5,7 @@ const OVERALL_BEST_1 = '_sectorPerformanceOverallBest1';
5
5
  const OVERALL_BEST_2 = '_sectorPerformanceOverallBest2';
6
6
 
7
7
  export function createEmptySectorTimes() {
8
- return Array.from({ length: TELEMETRY_SECTOR_COUNT }, () => null);
8
+ return new Array(TELEMETRY_SECTOR_COUNT).fill(null);
9
9
  }
10
10
 
11
11
  export function createEmptySectorPerformance() {
@@ -17,11 +17,19 @@ export function createEmptySectorPerformance() {
17
17
  }
18
18
 
19
19
  export function serializeSectorTimes(values) {
20
- return createEmptySectorTimes().map((_, index) => finiteOrNull(values?.[index]));
20
+ const result = new Array(TELEMETRY_SECTOR_COUNT);
21
+ for (let index = 0; index < TELEMETRY_SECTOR_COUNT; index += 1) {
22
+ result[index] = finiteOrNull(values?.[index]);
23
+ }
24
+ return result;
21
25
  }
22
26
 
23
27
  export function serializeSectorPerformance(values) {
24
- return createEmptySectorTimes().map((_, index) => values?.[index] ?? null);
28
+ const result = new Array(TELEMETRY_SECTOR_COUNT);
29
+ for (let index = 0; index < TELEMETRY_SECTOR_COUNT; index += 1) {
30
+ result[index] = values?.[index] ?? null;
31
+ }
32
+ return result;
25
33
  }
26
34
 
27
35
  export function updateSectorPerformance(cars, stats = null) {
@@ -6,27 +6,59 @@ import { queryNearestTrackProjection, queryNearestTrackProjectionInto } from './
6
6
  import { recordStat } from './trackQueryStats.js';
7
7
 
8
8
  export function pointAt(track, distanceAlong) {
9
+ return pointAtInto(track, distanceAlong, {});
10
+ }
11
+
12
+ export function pointAtInto(track, distanceAlong, target) {
9
13
  const wrapped = wrapDistance(distanceAlong, track.length);
10
- const low = sampleIndexAtDistance(track, wrapped);
14
+ const low = sampleIndexAtDistanceUnwrapped(track, wrapped);
11
15
 
12
16
  const next = track.samples[low] ?? track.samples[0];
13
17
  const previous = track.samples[Math.max(0, low - 1)] ?? next;
14
18
  const span = Math.max(1, next.distance - previous.distance);
15
19
  const amount = clamp((wrapped - previous.distance) / span, 0, 1);
16
20
 
17
- return {
18
- x: previous.x + (next.x - previous.x) * amount,
19
- y: previous.y + (next.y - previous.y) * amount,
20
- heading: previous.heading + normalizeAngle(next.heading - previous.heading) * amount,
21
- normalX: previous.normalX + (next.normalX - previous.normalX) * amount,
22
- normalY: previous.normalY + (next.normalY - previous.normalY) * amount,
23
- curvature: previous.curvature + (next.curvature - previous.curvature) * amount,
24
- distance: wrapped,
25
- };
21
+ target.x = previous.x + (next.x - previous.x) * amount;
22
+ target.y = previous.y + (next.y - previous.y) * amount;
23
+ target.heading = previous.heading + normalizeAngle(next.heading - previous.heading) * amount;
24
+ target.normalX = previous.normalX + (next.normalX - previous.normalX) * amount;
25
+ target.normalY = previous.normalY + (next.normalY - previous.normalY) * amount;
26
+ target.curvature = previous.curvature + (next.curvature - previous.curvature) * amount;
27
+ target.distance = wrapped;
28
+ return target;
26
29
  }
27
30
 
28
- export function sampleIndexAtDistance(track, distanceAlong) {
31
+ export function sampleHeadingCurvatureAtInto(track, distanceAlong, target) {
29
32
  const wrapped = wrapDistance(distanceAlong, track.length);
33
+ const low = sampleIndexAtDistanceUnwrapped(track, wrapped);
34
+
35
+ const next = track.samples[low] ?? track.samples[0];
36
+ const previous = track.samples[Math.max(0, low - 1)] ?? next;
37
+ const span = Math.max(1, next.distance - previous.distance);
38
+ const amount = clamp((wrapped - previous.distance) / span, 0, 1);
39
+
40
+ target.heading = previous.heading + normalizeAngle(next.heading - previous.heading) * amount;
41
+ target.curvature = previous.curvature + (next.curvature - previous.curvature) * amount;
42
+ return target;
43
+ }
44
+
45
+ export function sampleHeadingAt(track, distanceAlong) {
46
+ const wrapped = wrapDistance(distanceAlong, track.length);
47
+ const low = sampleIndexAtDistanceUnwrapped(track, wrapped);
48
+
49
+ const next = track.samples[low] ?? track.samples[0];
50
+ const previous = track.samples[Math.max(0, low - 1)] ?? next;
51
+ const span = Math.max(1, next.distance - previous.distance);
52
+ const amount = clamp((wrapped - previous.distance) / span, 0, 1);
53
+
54
+ return previous.heading + normalizeAngle(next.heading - previous.heading) * amount;
55
+ }
56
+
57
+ export function sampleIndexAtDistance(track, distanceAlong) {
58
+ return binarySampleIndexAtDistance(track, wrapDistance(distanceAlong, track.length));
59
+ }
60
+
61
+ function binarySampleIndexAtDistance(track, wrapped) {
30
62
  let low = 0;
31
63
  let high = track.samples.length - 1;
32
64
 
@@ -39,6 +71,43 @@ export function sampleIndexAtDistance(track, distanceAlong) {
39
71
  return low;
40
72
  }
41
73
 
74
+ // Uniform-bin index over sample distances: table[bin] holds the smallest
75
+ // sample index whose distance reaches the bin start, so a lookup plus a short
76
+ // forward walk returns exactly what the binary search would, in O(1). Built
77
+ // eagerly when the track query index is attached; lookups on sample arrays
78
+ // without a prepared table fall back to the binary search.
79
+ const SAMPLE_INDEX_LUTS = new WeakMap();
80
+
81
+ export function prepareSampleIndexLut(track) {
82
+ const samples = track?.samples;
83
+ if (!Array.isArray(samples) || samples.length < 2 || !Number.isFinite(track.length) || track.length <= 0) return;
84
+ if (SAMPLE_INDEX_LUTS.has(samples)) return;
85
+ const lastIndex = samples.length - 1;
86
+ const bins = Math.max(1, lastIndex * 2);
87
+ const table = new Int32Array(bins);
88
+ const binSize = track.length / bins;
89
+ let index = 0;
90
+ for (let bin = 0; bin < bins; bin += 1) {
91
+ const start = bin * binSize;
92
+ while (index < lastIndex && samples[index].distance < start) index += 1;
93
+ table[bin] = index;
94
+ }
95
+ SAMPLE_INDEX_LUTS.set(samples, { bins, binSize, table });
96
+ }
97
+
98
+ function sampleIndexAtDistanceUnwrapped(track, wrapped) {
99
+ const samples = track.samples;
100
+ const lut = Number.isFinite(wrapped) ? SAMPLE_INDEX_LUTS.get(samples) : null;
101
+ if (!lut) return binarySampleIndexAtDistance(track, wrapped);
102
+ const lastIndex = samples.length - 1;
103
+ let bin = Math.floor(wrapped / lut.binSize);
104
+ if (bin < 0) bin = 0;
105
+ else if (bin >= lut.bins) bin = lut.bins - 1;
106
+ let index = lut.table[bin];
107
+ while (index < lastIndex && samples[index].distance < wrapped) index += 1;
108
+ return index;
109
+ }
110
+
42
111
  export function nearestSampleInRange(track, position, startIndex, endIndex) {
43
112
  const sampleCount = track.samples.length - 1;
44
113
  let best = null;
@@ -6,6 +6,7 @@ import { generateCenterlineControls, generateFallbackCenterlineControls, generat
6
6
  import { isValidProceduralTrackModel } from './trackValidation.js';
7
7
  import { recalculateSampleGeometry } from './sampleGeometry.js';
8
8
  import { rotateSamplesToStandingStart, straightenStandingStartSamples } from './startStraight.js';
9
+ import { prepareSampleIndexLut } from './spatialQueries.js';
9
10
  import { createTrackSectors } from './trackSectors.js';
10
11
  import { deriveDrsZones, normalizeDrsZone } from './drsZones.js';
11
12
  import { createPitLaneModel } from './pitLaneLayout.js';
@@ -15,7 +16,14 @@ import { attachTrackSnapshotJsonSerializer } from './trackSnapshotJson.js';
15
16
 
16
17
  export { WORLD, TRACK } from './trackConstants.js';
17
18
  export { isInDrsZone } from './drsZones.js';
18
- export { nearestTrackState, offsetTrackPoint, pointAt } from './spatialQueries.js';
19
+ export {
20
+ nearestTrackState,
21
+ offsetTrackPoint,
22
+ pointAt,
23
+ pointAtInto,
24
+ sampleHeadingAt,
25
+ sampleHeadingCurvatureAtInto,
26
+ } from './spatialQueries.js';
19
27
 
20
28
  const PROCEDURAL_TRACK_CACHE = new Map();
21
29
  const TRACK_MODEL_CACHE = new WeakMap();
@@ -57,6 +65,7 @@ export function buildTrackModel(track = TRACK) {
57
65
  ? null
58
66
  : createPitLaneModel(model);
59
67
  attachTrackQueryIndex(model, createTrackQueryIndex(model));
68
+ prepareSampleIndexLut(model);
60
69
  attachTrackSnapshotJsonSerializer(model);
61
70
  if (canReuseCachedModel) {
62
71
  TRACK_MODEL_CACHE.set(track, freezeTrackModel(model));
@@ -6,7 +6,7 @@ import {
6
6
  } from '../collisionGeometry.js';
7
7
  import { canCollide, isCollidable } from '../participants/participantInteractions.js';
8
8
  import { clamp, normalizeAngle } from '../simMath.js';
9
- import { VEHICLE_LIMITS } from './vehiclePhysics.js';
9
+ import { VEHICLE_LIMITS, isSimulatorPhysicsMode } from './vehiclePhysics.js';
10
10
  import { shiftPreviousRenderPose } from '../pit/pitRouting.js';
11
11
 
12
12
  const MAX_COLLISION_CORRECTION = 4.5;
@@ -114,8 +114,8 @@ export function resolveCollisionsForSimulation(sim) {
114
114
  }
115
115
 
116
116
  if (oneCarFixed) {
117
- if (!firstPitControlled) dampPitContactVelocity(first, 0.985, sim.physicsMode === 'advanced');
118
- if (!secondPitControlled) dampPitContactVelocity(second, 0.985, sim.physicsMode === 'advanced');
117
+ if (!firstPitControlled) dampPitContactVelocity(first, 0.985, isSimulatorPhysicsMode(sim.physicsMode));
118
+ if (!secondPitControlled) dampPitContactVelocity(second, 0.985, isSimulatorPhysicsMode(sim.physicsMode));
119
119
  } else {
120
120
  applyContactVelocityResponse(sim, first, second, collision.axis);
121
121
  }
@@ -167,7 +167,7 @@ export function resolveCollisionsForSimulation(sim) {
167
167
  export function applyContactVelocityResponse(sim, first, second, axis, options = {}) {
168
168
  recordContactVelocityResponse(options.stats);
169
169
  if (
170
- sim.physicsMode === 'advanced' ||
170
+ isSimulatorPhysicsMode(sim.physicsMode) ||
171
171
  Number.isFinite(first.velocityX) ||
172
172
  Number.isFinite(second.velocityX)
173
173
  ) {
@@ -274,33 +274,22 @@ function sweptPoseSignature(pose) {
274
274
  ].join(':');
275
275
  }
276
276
 
277
- function geometryPose(car) {
278
- const { x, y, heading } = currentPoseState(car);
279
- return {
280
- x,
281
- y,
282
- heading,
283
- previousX: finiteOr(car.previousX, x),
284
- previousY: finiteOr(car.previousY, y),
285
- previousHeading: normalizeAngle(finiteOr(car.previousHeading, heading)),
286
- };
287
- }
288
-
289
277
  function currentGeometryStateMatches(car, state) {
290
278
  const pose = state?.pose;
291
279
  if (!pose) return false;
292
- const currentPose = currentPoseState(car);
293
280
  return (
294
- pose.x === currentPose.x &&
295
- pose.y === currentPose.y &&
296
- pose.heading === currentPose.heading
281
+ pose.x === finiteOr(car.x, 0) &&
282
+ pose.y === finiteOr(car.y, 0) &&
283
+ pose.heading === normalizeAngle(finiteOr(car.heading, 0))
297
284
  );
298
285
  }
299
286
 
300
287
  function geometryStateMatches(car, state) {
301
288
  const pose = state?.pose;
302
289
  if (!pose) return false;
303
- const { x, y, heading } = currentPoseState(car);
290
+ const x = finiteOr(car.x, 0);
291
+ const y = finiteOr(car.y, 0);
292
+ const heading = normalizeAngle(finiteOr(car.heading, 0));
304
293
  return (
305
294
  pose.x === x &&
306
295
  pose.y === y &&
@@ -312,6 +301,10 @@ function geometryStateMatches(car, state) {
312
301
  }
313
302
 
314
303
  export function createVehicleShapeAabb(shape) {
304
+ return writeVehicleShapeAabb({}, shape);
305
+ }
306
+
307
+ function writeVehicleShapeAabb(target, shape) {
315
308
  let minX = Infinity;
316
309
  let maxX = -Infinity;
317
310
  let minY = Infinity;
@@ -323,12 +316,11 @@ export function createVehicleShapeAabb(shape) {
323
316
  if (corner.y < minY) minY = corner.y;
324
317
  if (corner.y > maxY) maxY = corner.y;
325
318
  }
326
- return {
327
- minX,
328
- maxX,
329
- minY,
330
- maxY,
331
- };
319
+ target.minX = minX;
320
+ target.maxX = maxX;
321
+ target.minY = minY;
322
+ target.maxY = maxY;
323
+ return target;
332
324
  }
333
325
 
334
326
  export function mergeAabbs(aabbs) {
@@ -346,32 +338,73 @@ export function mergeAabbs(aabbs) {
346
338
  return { minX, maxX, minY, maxY };
347
339
  }
348
340
 
349
- export function createVehicleGeometryState(car) {
350
- const pose = geometryPose(car);
351
- const current = writeVehicleGeometry(null, pose);
352
- const previous = writeVehicleGeometry(null, {
353
- x: pose.previousX,
354
- y: pose.previousY,
355
- heading: pose.previousHeading,
356
- });
357
- const bodyAabb = createVehicleShapeAabb(current.body);
358
- const previousBodyAabb = createVehicleShapeAabb(previous.body);
359
- const sweptBodyAabb = mergeAabbs([previousBodyAabb, bodyAabb]);
341
+ function writeGeometryPose(target, car) {
342
+ const pose = target ?? {};
343
+ const x = finiteOr(car.x, 0);
344
+ const y = finiteOr(car.y, 0);
345
+ const heading = normalizeAngle(finiteOr(car.heading, 0));
346
+ pose.x = x;
347
+ pose.y = y;
348
+ pose.heading = heading;
349
+ pose.previousX = finiteOr(car.previousX, x);
350
+ pose.previousY = finiteOr(car.previousY, y);
351
+ pose.previousHeading = normalizeAngle(finiteOr(car.previousHeading, heading));
352
+ return pose;
353
+ }
360
354
 
361
- return {
362
- pose,
363
- currentSignature: currentPoseSignature(pose),
364
- sweptSignature: sweptPoseSignature(pose),
365
- signature: sweptPoseSignature(pose),
366
- current,
367
- previous,
368
- body: current.body,
369
- wheels: current.wheels,
370
- contactPatches: current.contactPatches,
371
- bodyAabb,
372
- previousBodyAabb,
373
- sweptBodyAabb,
355
+ function createGeometryStateShell() {
356
+ const state = {
357
+ pose: null,
358
+ current: null,
359
+ previous: null,
360
+ body: null,
361
+ wheels: null,
362
+ contactPatches: null,
363
+ bodyAabb: {},
364
+ previousBodyAabb: {},
365
+ sweptBodyAabb: {},
374
366
  };
367
+ // Signatures are derived lazily from the pose: nothing on the hot path reads
368
+ // them, so the per-step string formatting cost must not be paid eagerly.
369
+ Object.defineProperties(state, {
370
+ currentSignature: {
371
+ get() { return currentPoseSignature(this.pose); },
372
+ enumerable: true,
373
+ },
374
+ sweptSignature: {
375
+ get() { return sweptPoseSignature(this.pose); },
376
+ enumerable: true,
377
+ },
378
+ signature: {
379
+ get() { return sweptPoseSignature(this.pose); },
380
+ enumerable: true,
381
+ },
382
+ });
383
+ return state;
384
+ }
385
+
386
+ const PREVIOUS_GEOMETRY_POSE_SCRATCH = { x: 0, y: 0, heading: 0 };
387
+
388
+ export function createVehicleGeometryState(car, target = null) {
389
+ const state = target ?? createGeometryStateShell();
390
+ const pose = state.pose = writeGeometryPose(state.pose, car);
391
+ const current = state.current = writeVehicleGeometry(state.current, pose);
392
+ PREVIOUS_GEOMETRY_POSE_SCRATCH.x = pose.previousX;
393
+ PREVIOUS_GEOMETRY_POSE_SCRATCH.y = pose.previousY;
394
+ PREVIOUS_GEOMETRY_POSE_SCRATCH.heading = pose.previousHeading;
395
+ const previous = state.previous = writeVehicleGeometry(state.previous, PREVIOUS_GEOMETRY_POSE_SCRATCH);
396
+ const bodyAabb = writeVehicleShapeAabb(state.bodyAabb, current.body);
397
+ const previousBodyAabb = writeVehicleShapeAabb(state.previousBodyAabb, previous.body);
398
+ const sweptBodyAabb = state.sweptBodyAabb;
399
+ sweptBodyAabb.minX = Math.min(bodyAabb.minX, previousBodyAabb.minX);
400
+ sweptBodyAabb.maxX = Math.max(bodyAabb.maxX, previousBodyAabb.maxX);
401
+ sweptBodyAabb.minY = Math.min(bodyAabb.minY, previousBodyAabb.minY);
402
+ sweptBodyAabb.maxY = Math.max(bodyAabb.maxY, previousBodyAabb.maxY);
403
+
404
+ state.body = current.body;
405
+ state.wheels = current.wheels;
406
+ state.contactPatches = current.contactPatches;
407
+ return state;
375
408
  }
376
409
 
377
410
  export function createCurrentVehicleGeometryState(car, target = null) {
@@ -388,8 +421,13 @@ export function createCurrentVehicleGeometryState(car, target = null) {
388
421
 
389
422
  export function getVehicleGeometryState(car) {
390
423
  if (geometryStateMatches(car, car.geometryState)) return car.geometryState;
391
- car.geometryState = createVehicleGeometryState(car);
392
- return car.geometryState;
424
+ // Double-buffer pooled states: consumers may still hold the state returned
425
+ // for the previous pose within a step, so alternate between two reusable
426
+ // shells instead of allocating a fresh geometry tree on every pose change.
427
+ const next = createVehicleGeometryState(car, car._geometryStateSpare ?? null);
428
+ car._geometryStateSpare = car.geometryState ?? null;
429
+ car.geometryState = next;
430
+ return next;
393
431
  }
394
432
 
395
433
  export function getCurrentVehicleGeometryState(car) {
@@ -50,7 +50,11 @@ const SIMULATOR_SURFACE_MODEL = {
50
50
  };
51
51
 
52
52
  export function normalizePhysicsMode(value) {
53
- return value === 'advanced' ? 'advanced' : DEFAULT_PHYSICS_MODE;
53
+ return PHYSICS_MODES.includes(value) ? value : DEFAULT_PHYSICS_MODE;
54
+ }
55
+
56
+ export function isSimulatorPhysicsMode(value) {
57
+ return value === 'advanced';
54
58
  }
55
59
 
56
60
  function accelerationLimit(speed, surfaceGrip) {
@@ -120,7 +124,8 @@ function setPhysicsTelemetry(car, {
120
124
  }
121
125
 
122
126
  export function integrateVehiclePhysics(car, controls, dt, options = {}) {
123
- if (normalizePhysicsMode(options.physicsMode) === 'advanced') {
127
+ const mode = normalizePhysicsMode(options.physicsMode);
128
+ if (mode === 'advanced') {
124
129
  return integrateSimulatorVehiclePhysics(car, controls, dt, options);
125
130
  }
126
131
  return integrateArcadeVehiclePhysics(car, controls, dt, options);
@@ -1,4 +1,7 @@
1
- import { serializeParticipantInteraction } from '../participants/participantInteractions.js';
1
+ import {
2
+ serializeParticipantInteraction,
3
+ serializeParticipantInteractionCached,
4
+ } from '../participants/participantInteractions.js';
2
5
 
3
6
  export function finiteOrNull(value) {
4
7
  return Number.isFinite(value) ? value : null;
@@ -185,7 +188,7 @@ export function serializeRenderCarInto(target, car, dependencies) {
185
188
  target.dnfOrder = car.dnfOrder ?? null;
186
189
  target.outOfRace = Boolean(car.outOfRace || car.destroyed);
187
190
  target.pitStop = serializeRenderPitStop(car.pitStop);
188
- target.interaction = serializeParticipantInteraction(car.interaction);
191
+ target.interaction = serializeParticipantInteractionCached(car);
189
192
  return target;
190
193
  }
191
194
 
@@ -2,7 +2,7 @@ import { buildDriverPersonality } from '../driverController.js';
2
2
  import { clamp, normalizeAngle, seededRange } from '../simMath.js';
3
3
  import { offsetTrackPoint, pointAt } from '../track/trackModel.js';
4
4
  import { kphToSimSpeed, metersToSimUnits } from '../units.js';
5
- import { VEHICLE_LIMITS } from './vehiclePhysics.js';
5
+ import { VEHICLE_LIMITS, isSimulatorPhysicsMode } from './vehiclePhysics.js';
6
6
  import { applyWheelSurfaceState } from './wheelSurface.js';
7
7
  import { nearestTrackStateForCar } from '../track/trackStatePolicy.js';
8
8
  import { clearCarDnf } from '../race/retirements.js';
@@ -73,6 +73,7 @@ export function createCar(driver, index, random, track, { standingStart = false,
73
73
  downforceCoefficient: vehicle.downforceCoefficient ?? 6.1 + seededRange(random, -0.18, 0.18),
74
74
  tireGrip: vehicle.tireGrip ?? 2.22 + racecraft * 0.28 + seededRange(random, -0.03, 0.03),
75
75
  tireCare: vehicle.tireCare ?? 1,
76
+ driverModel: driver.driverModel ?? vehicle.driverModel ?? null,
76
77
  pace,
77
78
  racecraft,
78
79
  personality,
@@ -170,7 +171,7 @@ export function applyExternalCarState(car, partial, context) {
170
171
  }
171
172
  car.speed = clamp(Number.isFinite(car.speed) ? car.speed : 0, 0, VEHICLE_LIMITS.maxSpeed);
172
173
  car.heading = normalizeAngle(Number.isFinite(car.heading) ? car.heading : 0);
173
- syncExternalVelocityState(car, nextPartial, physicsMode === 'advanced');
174
+ syncExternalVelocityState(car, nextPartial, isSimulatorPhysicsMode(physicsMode));
174
175
  const centerState = nearestTrackStateForCar(track, car, car, car.progress ?? car.raceDistance);
175
176
  applyWheelSurfaceState(car, track, { centerState });
176
177
  if (
@@ -39,8 +39,9 @@ export function createRaceCanvasMarkup({
39
39
  }
40
40
  const timingPanelId = includeTimingTower ? createTimingPanelId() : '';
41
41
  const showPhysicsModeIndicator = debug.physicsModeIndicator === true;
42
- const physicsModeLabel = physicsMode === 'advanced' ? 'Advanced physics mode' : 'Arcade physics mode';
43
- const physicsModeClass = physicsMode === 'advanced' ? 'advanced' : 'arcade';
42
+ const simulatorPhysics = physicsMode === 'advanced';
43
+ const physicsModeLabel = `${simulatorPhysics ? 'Advanced' : 'Arcade'} physics mode`;
44
+ const physicsModeClass = simulatorPhysics ? 'advanced' : 'arcade';
44
45
 
45
46
  const body = `
46
47
  <div class="track-canvas" data-track-canvas></div>