@inventure71/paddockjs 3.0.0 → 4.1.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 (97) hide show
  1. package/CHANGELOG.md +152 -1
  2. package/INSTALL_AND_UPDATE.md +21 -4
  3. package/README.md +103 -551
  4. package/docs/architecture.md +28 -18
  5. package/docs/component_inventory.md +5 -4
  6. package/docs/composable-layouts.md +79 -0
  7. package/docs/custom_model_controller.md +4 -4
  8. package/docs/data-helpers.md +50 -0
  9. package/docs/data_contract.md +13 -7
  10. package/docs/getting-started.md +105 -0
  11. package/docs/headless-environment.md +68 -0
  12. package/docs/index.md +17 -4
  13. package/docs/rules.md +6 -2
  14. package/docs/system_specs.md +60 -7
  15. package/docs/theming.md +64 -0
  16. package/docs/training.md +20 -14
  17. package/docs/troubleshooting.md +76 -0
  18. package/docs/upgrading-hosts-to-3.md +42 -0
  19. package/examples/python/README.md +6 -0
  20. package/examples/python/base_policy_server.py +19 -0
  21. package/package.json +21 -5
  22. package/src/api/PaddockSimulatorController.js +78 -22
  23. package/src/api/runtimeTheme.js +59 -0
  24. package/src/app/F1SimulatorApp.js +24 -3
  25. package/src/app/readouts/timingTowerRenderer.js +203 -42
  26. package/src/app/runtime/frameLoop.js +5 -1
  27. package/src/config/defaultOptions.js +8 -1
  28. package/src/config/themeOptions.js +5 -0
  29. package/src/data/index.d.ts +212 -0
  30. package/src/data/index.js +14 -0
  31. package/src/environment/episode.js +11 -1
  32. package/src/environment/metrics.js +59 -16
  33. package/src/environment/observationVector.js +50 -33
  34. package/src/environment/observations.js +89 -20
  35. package/src/environment/runtime.js +41 -0
  36. package/src/environment/sensors/carRays.js +48 -37
  37. package/src/environment/sensors/index.js +501 -97
  38. package/src/environment/sensors/indexedRayBands.js +134 -46
  39. package/src/environment/sensors/rayBandTrace.js +1171 -0
  40. package/src/environment/sensors/rayChannels.js +108 -0
  41. package/src/environment/sensors/rayConfig.js +71 -25
  42. package/src/environment/sensors/rayGeometry.js +23 -17
  43. package/src/environment/sensors/sensorTargets.js +67 -1
  44. package/src/environment/sensors/surfaceRays.js +130 -89
  45. package/src/environment/sensors/trackRays.js +115 -1
  46. package/src/index.d.ts +43 -0
  47. package/src/index.js +27 -2
  48. package/src/placeholder/index.d.ts +11 -0
  49. package/src/placeholder/index.js +61 -0
  50. package/src/placeholder.css +72 -0
  51. package/src/simulation/collisionGeometry.js +260 -96
  52. package/src/simulation/pit/pitFlow.js +21 -9
  53. package/src/simulation/pit/pitQueue.js +5 -3
  54. package/src/simulation/pit/pitRouteMovement.js +9 -4
  55. package/src/simulation/pit/pitRouting.js +77 -42
  56. package/src/simulation/pit/pitState.js +1 -0
  57. package/src/simulation/pit/pitTireService.js +7 -3
  58. package/src/simulation/race/raceFinish.js +25 -8
  59. package/src/simulation/race/raceOrder.js +189 -32
  60. package/src/simulation/race/raceProgress.js +129 -37
  61. package/src/simulation/race/raceSetup.js +2 -0
  62. package/src/simulation/race/raceStep.js +25 -13
  63. package/src/simulation/raceSimulation.js +20 -17
  64. package/src/simulation/rules/collisionSteward.js +40 -23
  65. package/src/simulation/rules/rulesReview.js +77 -56
  66. package/src/simulation/snapshots/raceSnapshots.js +37 -0
  67. package/src/simulation/timing/drsTiming.js +143 -30
  68. package/src/simulation/timing/gapEstimation.js +25 -3
  69. package/src/simulation/timing/lapTelemetry.js +115 -36
  70. package/src/simulation/timing/raceTiming.js +5 -1
  71. package/src/simulation/timing/sectorPerformance.js +104 -26
  72. package/src/simulation/timing/timingHistory.js +229 -14
  73. package/src/simulation/timing/timingLines.js +65 -7
  74. package/src/simulation/track/pitLaneLayout.js +23 -0
  75. package/src/simulation/track/pitLaneState.js +218 -19
  76. package/src/simulation/track/spatialQueries.js +67 -11
  77. package/src/simulation/track/trackMath.js +91 -24
  78. package/src/simulation/track/trackModel.js +2 -0
  79. package/src/simulation/track/trackQueryIndex.js +1334 -100
  80. package/src/simulation/track/trackQueryPitIndex.js +138 -14
  81. package/src/simulation/track/trackQueryScratch.js +7 -0
  82. package/src/simulation/track/trackQueryStats.js +25 -0
  83. package/src/simulation/track/trackSnapshotJson.js +139 -0
  84. package/src/simulation/track/trackStatePolicy.js +249 -1
  85. package/src/simulation/vehicle/contactResolution.js +96 -59
  86. package/src/simulation/vehicle/mainTrackWheelSurface.js +73 -34
  87. package/src/simulation/vehicle/pitWheelSurface.js +81 -28
  88. package/src/simulation/vehicle/runoffResponse.js +54 -29
  89. package/src/simulation/vehicle/vehicleGeometry.js +245 -51
  90. package/src/simulation/vehicle/vehiclePhysics.js +42 -27
  91. package/src/simulation/vehicle/vehicleSnapshots.js +23 -0
  92. package/src/simulation/vehicle/vehicleState.js +6 -0
  93. package/src/simulation/vehicle/wheelSurface.js +807 -92
  94. package/src/styles.css +497 -497
  95. package/src/ui/raceControlsTemplate.js +1 -0
  96. package/src/ui/telemetryDrawerTemplate.js +1 -0
  97. package/src/ui/telemetryTemplates.js +1 -0
@@ -12,11 +12,21 @@ import { canUseIndexedRecoveryRayApproximation } from './rayGuards.js';
12
12
  import { pointOnRay } from './rayGeometry.js';
13
13
  import { findIndexedTrackBandBoundaries } from './indexedRayBands.js';
14
14
 
15
- const LEGAL_SURFACES = new Set(['track', 'kerb', 'pit-entry', 'pit-lane', 'pit-exit', 'pit-box']);
16
- const SURFACE_CHANNELS = new Set(['kerb', 'illegalSurface']);
17
-
18
15
  export function requestedSurfaceChannels(channels = []) {
19
- return channels.filter((channel) => SURFACE_CHANNELS.has(channel));
16
+ const requested = [];
17
+ let hasKerb = false;
18
+ let hasIllegalSurface = false;
19
+ for (let index = 0; index < channels.length; index += 1) {
20
+ const channel = channels[index];
21
+ if (channel === 'kerb' && !hasKerb) {
22
+ requested.push(channel);
23
+ hasKerb = true;
24
+ } else if (channel === 'illegalSurface' && !hasIllegalSurface) {
25
+ requested.push(channel);
26
+ hasIllegalSurface = true;
27
+ }
28
+ }
29
+ return requested;
20
30
  }
21
31
 
22
32
  export function createSurfaceMiss(lengthMeters) {
@@ -38,15 +48,15 @@ export function estimateSurfaceHits(
38
48
  { precision = 'driver', sharedRayQuery = null } = {},
39
49
  ) {
40
50
  const requested = requestedSurfaceChannels(channels);
41
- const misses = Object.fromEntries(requested.map((channel) => [channel, createSurfaceMiss(ray.lengthMeters)]));
51
+ const hits = prepareSurfaceHitContainer(sharedRayQuery, ray.lengthMeters);
42
52
  if (!requested.length || !Array.isArray(snapshot.track?.samples) || snapshot.track.samples.length === 0) {
43
- return misses;
53
+ return hits;
44
54
  }
45
55
 
46
56
  const originState = context?.precision === precision
47
57
  ? context.originState
48
58
  : nearestRayTrackState(snapshot.track, car, origin, car.progress, precision);
49
- const originHits = surfaceHitsAtOrigin(requested, originState);
59
+ writeSurfaceHitsAtOrigin(hits, requested, originState);
50
60
  const indexedHits = estimateIndexedSurfaceHits({
51
61
  car,
52
62
  track: snapshot.track,
@@ -57,8 +67,9 @@ export function estimateSurfaceHits(
57
67
  originState,
58
68
  precision,
59
69
  sharedRayQuery,
70
+ hits,
60
71
  });
61
- if (indexedHits) return { ...misses, ...indexedHits, ...originHits };
72
+ if (indexedHits) return indexedHits;
62
73
  const analyticHits = estimateAnalyticSurfaceHits({
63
74
  car,
64
75
  track: snapshot.track,
@@ -66,15 +77,13 @@ export function estimateSurfaceHits(
66
77
  vector,
67
78
  requested,
68
79
  originState,
80
+ hits,
69
81
  });
70
- if (analyticHits) return { ...misses, ...analyticHits, ...originHits };
82
+ if (analyticHits) return analyticHits;
71
83
 
72
- const pending = new Set(requested);
73
- Object.keys(originHits).forEach((channel) => {
74
- misses[channel] = originHits[channel];
75
- pending.delete(channel);
76
- });
77
- if (pending.size === 0) return misses;
84
+ let pendingKerb = requestedIncludes(requested, 'kerb') && !matchesSurfaceChannel('kerb', originState);
85
+ let pendingIllegalSurface = requestedIncludes(requested, 'illegalSurface') && !matchesSurfaceChannel('illegalSurface', originState);
86
+ if (!pendingKerb && !pendingIllegalSurface) return hits;
78
87
  const maxDistance = metersToSimUnits(ray.lengthMeters);
79
88
  const step = metersToSimUnits(TRACK_RAY_STEP_METERS);
80
89
  let previousDistance = 0;
@@ -82,60 +91,78 @@ export function estimateSurfaceHits(
82
91
 
83
92
  for (let distance = 0; distance <= maxDistance; distance += step) {
84
93
  const state = nearestRayTrackState(snapshot.track, car, pointOnRay(origin, vector, distance), car.progress, precision);
85
- for (const channel of [...pending]) {
86
- if (!matchesSurfaceChannel(channel, state)) continue;
94
+ if (pendingKerb && matchesSurfaceChannel('kerb', state)) {
87
95
  const hitDistance = previousState
88
- ? refineSurfaceTransition(snapshot.track, car, origin, vector, car.progress, previousDistance, distance, channel, refineStepsForPrecision(precision))
96
+ ? refineSurfaceTransition(snapshot.track, car, origin, vector, car.progress, previousDistance, distance, 'kerb', refineStepsForPrecision(precision))
89
97
  : distance;
90
- misses[channel] = {
91
- hit: true,
92
- distanceMeters: simUnitsToMeters(hitDistance),
93
- surface: state.surface ?? null,
94
- };
95
- pending.delete(channel);
98
+ writeSurfaceHit(hits.kerb, simUnitsToMeters(hitDistance), state.surface ?? null);
99
+ pendingKerb = false;
96
100
  }
97
- if (pending.size === 0) break;
101
+ if (pendingIllegalSurface && matchesSurfaceChannel('illegalSurface', state)) {
102
+ const hitDistance = previousState
103
+ ? refineSurfaceTransition(snapshot.track, car, origin, vector, car.progress, previousDistance, distance, 'illegalSurface', refineStepsForPrecision(precision))
104
+ : distance;
105
+ writeSurfaceHit(hits.illegalSurface, simUnitsToMeters(hitDistance), state.surface ?? null);
106
+ pendingIllegalSurface = false;
107
+ }
108
+ if (!pendingKerb && !pendingIllegalSurface) break;
98
109
  previousDistance = distance;
99
110
  previousState = state;
100
111
  }
101
112
 
102
- return misses;
113
+ return hits;
103
114
  }
104
115
 
105
- function surfaceHitsAtOrigin(requested, originState) {
106
- const hits = {};
107
- requested.forEach((channel) => {
108
- if (!matchesSurfaceChannel(channel, originState)) return;
109
- hits[channel] = {
110
- hit: true,
111
- distanceMeters: 0,
112
- surface: originState.surface ?? null,
113
- };
114
- });
116
+ function prepareSurfaceHitContainer(cache, lengthMeters) {
117
+ const hits = cache
118
+ ? (cache.surfaceHits ?? {})
119
+ : {};
120
+ if (cache) cache.surfaceHits = hits;
121
+ hits.kerb = writeSurfaceMiss(hits.kerb ?? {}, lengthMeters);
122
+ hits.illegalSurface = writeSurfaceMiss(hits.illegalSurface ?? {}, lengthMeters);
115
123
  return hits;
116
124
  }
117
125
 
118
- function estimateAnalyticSurfaceHits({ car, track, ray, vector, requested, originState }) {
126
+ function writeSurfaceMiss(target, lengthMeters) {
127
+ target.hit = false;
128
+ target.distanceMeters = lengthMeters;
129
+ target.surface = null;
130
+ return target;
131
+ }
132
+
133
+ function writeSurfaceHit(target, distanceMeters, surface) {
134
+ target.hit = true;
135
+ target.distanceMeters = distanceMeters;
136
+ target.surface = surface ?? null;
137
+ return target;
138
+ }
139
+
140
+ function writeSurfaceHitsAtOrigin(hits, requested, originState) {
141
+ for (let index = 0; index < requested.length; index += 1) {
142
+ const channel = requested[index];
143
+ if (!matchesSurfaceChannel(channel, originState)) continue;
144
+ writeSurfaceHit(hits[channel], 0, originState.surface ?? null);
145
+ }
146
+ }
147
+
148
+ function estimateAnalyticSurfaceHits({ car, track, ray, vector, requested, originState, hits }) {
119
149
  if (!originState || car.inPitLane || originState.inPitLane) return null;
120
150
  if (!usesMainTrackOnlyRays(car) && isNearPitConnector(track, originState)) return null;
121
151
  if (!canUseIndexedRecoveryRayApproximation(track, originState)) return null;
122
152
  if (Math.abs(originState.curvature ?? 0) > ANALYTIC_TRACK_RAY_MAX_CURVATURE) return null;
123
153
 
124
154
  const lateral = vector.x * originState.normalX + vector.y * originState.normalY;
125
- if (Math.abs(lateral) < 0.08) return Object.fromEntries(requested.map((channel) => [
126
- channel,
127
- createSurfaceMiss(ray.lengthMeters),
128
- ]));
155
+ if (Math.abs(lateral) < 0.08) return hits;
129
156
 
130
157
  const trackHalfWidth = track.width / 2;
131
158
  const kerbOuter = trackHalfWidth + (track.kerbWidth ?? 0);
132
159
  const maxDistance = metersToSimUnits(ray.lengthMeters);
133
160
  const offset = originState.signedOffset ?? car.signedOffset ?? 0;
134
- const hits = {};
135
161
 
136
- requested.forEach((channel) => {
162
+ for (let index = 0; index < requested.length; index += 1) {
163
+ const channel = requested[index];
137
164
  if (channel === 'kerb') {
138
- hits[channel] = hitAbsOffsetBand({
165
+ hitAbsOffsetBand(hits.kerb, {
139
166
  offset,
140
167
  lateral,
141
168
  minAbsOffset: trackHalfWidth,
@@ -145,7 +172,7 @@ function estimateAnalyticSurfaceHits({ car, track, ray, vector, requested, origi
145
172
  surface: 'kerb',
146
173
  });
147
174
  } else if (channel === 'illegalSurface') {
148
- hits[channel] = hitAbsOffsetBand({
175
+ hitAbsOffsetBand(hits.illegalSurface, {
149
176
  offset,
150
177
  lateral,
151
178
  minAbsOffset: kerbOuter,
@@ -155,12 +182,12 @@ function estimateAnalyticSurfaceHits({ car, track, ray, vector, requested, origi
155
182
  surface: surfaceBeyondKerb(track, offset, lateral, maxDistance),
156
183
  });
157
184
  }
158
- });
185
+ }
159
186
 
160
187
  return hits;
161
188
  }
162
189
 
163
- function estimateIndexedSurfaceHits({ car, track, ray, origin, vector, requested, originState, precision, sharedRayQuery }) {
190
+ function estimateIndexedSurfaceHits({ car, track, ray, origin, vector, requested, originState, precision, sharedRayQuery, hits }) {
164
191
  if (!originState || car.inPitLane || originState.inPitLane) return null;
165
192
  if (!usesMainTrackOnlyRays(car) && isNearPitConnector(track, originState)) return null;
166
193
 
@@ -177,24 +204,24 @@ function estimateIndexedSurfaceHits({ car, track, ray, origin, vector, requested
177
204
  sharedRayQuery,
178
205
  );
179
206
  if (!boundaries.available) return null;
180
- const hits = {};
181
207
 
182
- for (const channel of requested) {
208
+ for (let index = 0; index < requested.length; index += 1) {
209
+ const channel = requested[index];
183
210
  if (matchesSurfaceChannel(channel, originState)) {
184
- hits[channel] = { hit: true, distanceMeters: 0, surface: originState.surface ?? null };
211
+ writeSurfaceHit(hits[channel], 0, originState.surface ?? null);
185
212
  continue;
186
213
  }
187
214
 
188
215
  const boundaryDistance = channel === 'kerb'
189
- ? minFinite(boundaries.trackEdgeDistance, boundaries.kerbOuterDistance)
216
+ ? minFinite2(boundaries.trackEdgeDistance, boundaries.kerbOuterDistance)
190
217
  : boundaries.kerbOuterDistance;
191
218
  if (boundaryDistance == null) {
192
219
  if (usesMainTrackOnlyRays(car) && channel === 'illegalSurface') {
193
- hits[channel] = createSurfaceMiss(ray.lengthMeters);
220
+ writeSurfaceMiss(hits[channel], ray.lengthMeters);
194
221
  continue;
195
222
  }
196
223
  if (canSampleRecoverySurface(track, originState, vector)) return null;
197
- hits[channel] = createSurfaceMiss(ray.lengthMeters);
224
+ writeSurfaceMiss(hits[channel], ray.lengthMeters);
198
225
  continue;
199
226
  }
200
227
  const hitDistance = usesMainTrackOnlyRays(car) || refineStepsForPrecision(precision) <= 0 || precision === 'debug'
@@ -211,13 +238,13 @@ function estimateIndexedSurfaceHits({ car, track, ray, origin, vector, requested
211
238
  precision,
212
239
  });
213
240
  if (hitDistance == null) return null;
214
- hits[channel] = {
215
- hit: true,
216
- distanceMeters: simUnitsToMeters(hitDistance),
217
- surface: channel === 'kerb'
241
+ writeSurfaceHit(
242
+ hits[channel],
243
+ simUnitsToMeters(hitDistance),
244
+ channel === 'kerb'
218
245
  ? 'kerb'
219
246
  : surfaceBeyondKerb(track, originState.signedOffset ?? 0, lateral, metersToSimUnits(ray.lengthMeters)),
220
- };
247
+ );
221
248
  }
222
249
 
223
250
  return hits;
@@ -249,35 +276,28 @@ function usesMainTrackOnlyRays(car) {
249
276
  return car?.interaction?.profile === 'batch-training';
250
277
  }
251
278
 
252
- function hitAbsOffsetBand({ offset, lateral, minAbsOffset, maxAbsOffset, maxDistance, lengthMeters, surface }) {
279
+ function hitAbsOffsetBand(target, { offset, lateral, minAbsOffset, maxAbsOffset, maxDistance, lengthMeters, surface }) {
253
280
  const currentAbs = Math.abs(offset);
254
281
  if (currentAbs >= minAbsOffset && currentAbs <= maxAbsOffset) {
255
- return { hit: true, distanceMeters: 0, surface };
282
+ return writeSurfaceHit(target, 0, surface);
256
283
  }
257
284
 
258
- const candidates = [
259
- distanceToOffset(minAbsOffset, offset, lateral),
260
- distanceToOffset(-minAbsOffset, offset, lateral),
261
- ].filter((distance) => Number.isFinite(distance) && distance >= 0 && distance <= maxDistance);
262
-
285
+ let distance = Infinity;
286
+ distance = nearestValidBandDistance(distance, distanceToOffset(minAbsOffset, offset, lateral), offset, lateral, minAbsOffset, maxAbsOffset, maxDistance);
287
+ distance = nearestValidBandDistance(distance, distanceToOffset(-minAbsOffset, offset, lateral), offset, lateral, minAbsOffset, maxAbsOffset, maxDistance);
263
288
  if (Number.isFinite(maxAbsOffset)) {
264
- candidates.push(
265
- ...[
266
- distanceToOffset(maxAbsOffset, offset, lateral),
267
- distanceToOffset(-maxAbsOffset, offset, lateral),
268
- ].filter((distance) => Number.isFinite(distance) && distance >= 0 && distance <= maxDistance),
269
- );
289
+ distance = nearestValidBandDistance(distance, distanceToOffset(maxAbsOffset, offset, lateral), offset, lateral, minAbsOffset, maxAbsOffset, maxDistance);
290
+ distance = nearestValidBandDistance(distance, distanceToOffset(-maxAbsOffset, offset, lateral), offset, lateral, minAbsOffset, maxAbsOffset, maxDistance);
270
291
  }
271
292
 
272
- const distance = candidates
273
- .filter((candidate) => {
274
- const nextAbs = Math.abs(offset + lateral * candidate);
275
- return nextAbs >= minAbsOffset - 1e-6 && nextAbs <= maxAbsOffset + 1e-6;
276
- })
277
- .sort((a, b) => a - b)[0];
293
+ if (!Number.isFinite(distance)) return writeSurfaceMiss(target, lengthMeters);
294
+ return writeSurfaceHit(target, simUnitsToMeters(distance), surface);
295
+ }
278
296
 
279
- if (!Number.isFinite(distance)) return createSurfaceMiss(lengthMeters);
280
- return { hit: true, distanceMeters: simUnitsToMeters(distance), surface };
297
+ function nearestValidBandDistance(current, candidate, offset, lateral, minAbsOffset, maxAbsOffset, maxDistance) {
298
+ if (!Number.isFinite(candidate) || candidate < 0 || candidate > maxDistance || candidate >= current) return current;
299
+ const nextAbs = Math.abs(offset + lateral * candidate);
300
+ return nextAbs >= minAbsOffset - 1e-6 && nextAbs <= maxAbsOffset + 1e-6 ? candidate : current;
281
301
  }
282
302
 
283
303
  function distanceToOffset(targetOffset, offset, lateral) {
@@ -286,12 +306,16 @@ function distanceToOffset(targetOffset, offset, lateral) {
286
306
 
287
307
  function surfaceBeyondKerb(track, offset, lateral, maxDistance) {
288
308
  const gravelOuter = track.width / 2 + (track.kerbWidth ?? 0) + (track.gravelWidth ?? 0);
289
- const hitDistance = Math.min(
290
- ...[
291
- distanceToOffset(track.width / 2 + (track.kerbWidth ?? 0), offset, lateral),
292
- distanceToOffset(-track.width / 2 - (track.kerbWidth ?? 0), offset, lateral),
293
- ].filter((distance) => Number.isFinite(distance) && distance >= 0 && distance <= maxDistance),
294
- );
309
+ const kerbOuter = track.width / 2 + (track.kerbWidth ?? 0);
310
+ const positiveDistance = distanceToOffset(kerbOuter, offset, lateral);
311
+ const negativeDistance = distanceToOffset(-kerbOuter, offset, lateral);
312
+ let hitDistance = Infinity;
313
+ if (Number.isFinite(positiveDistance) && positiveDistance >= 0 && positiveDistance <= maxDistance) {
314
+ hitDistance = positiveDistance;
315
+ }
316
+ if (Number.isFinite(negativeDistance) && negativeDistance >= 0 && negativeDistance <= maxDistance && negativeDistance < hitDistance) {
317
+ hitDistance = negativeDistance;
318
+ }
295
319
  const hitAbs = Math.abs(offset + lateral * hitDistance);
296
320
  return hitAbs <= gravelOuter ? 'gravel' : 'grass';
297
321
  }
@@ -342,10 +366,26 @@ function refineStepsForPrecision(precision) {
342
366
 
343
367
  function matchesSurfaceChannel(channel, state) {
344
368
  if (channel === 'kerb') return state.surface === 'kerb';
345
- if (channel === 'illegalSurface') return !LEGAL_SURFACES.has(state.surface) && state.surface !== 'barrier';
369
+ if (channel === 'illegalSurface') return !isLegalSurface(state.surface) && state.surface !== 'barrier';
370
+ return false;
371
+ }
372
+
373
+ function requestedIncludes(requested, channel) {
374
+ for (let index = 0; index < requested.length; index += 1) {
375
+ if (requested[index] === channel) return true;
376
+ }
346
377
  return false;
347
378
  }
348
379
 
380
+ function isLegalSurface(surface) {
381
+ return surface === 'track' ||
382
+ surface === 'kerb' ||
383
+ surface === 'pit-entry' ||
384
+ surface === 'pit-lane' ||
385
+ surface === 'pit-exit' ||
386
+ surface === 'pit-box';
387
+ }
388
+
349
389
  function canSampleRecoverySurface(track, originState, vector) {
350
390
  if (!canUseIndexedRecoveryRayApproximation(track, originState)) return false;
351
391
  const kerbOuter = track.width / 2 + (track.kerbWidth ?? 0);
@@ -362,7 +402,8 @@ function nearestRayTrackState(track, car, point, progressHint, precision = 'debu
362
402
  });
363
403
  }
364
404
 
365
- function minFinite(...values) {
366
- const minimum = Math.min(...values.filter((value) => Number.isFinite(value)));
367
- return Number.isFinite(minimum) ? minimum : null;
405
+ function minFinite2(first, second) {
406
+ if (!Number.isFinite(first)) return Number.isFinite(second) ? second : null;
407
+ if (!Number.isFinite(second)) return first;
408
+ return first <= second ? first : second;
368
409
  }
@@ -1,4 +1,6 @@
1
1
  import { nearestTrackState } from '../../simulation/track/trackModel.js';
2
+ import { normalizeAngle } from '../../simulation/simMath.js';
3
+ import { offsetTrackPoint, pointAt, sampleIndexAtDistance } from '../../simulation/track/spatialQueries.js';
2
4
  import { pitOverrideAllowedForCar } from '../../simulation/track/trackStatePolicy.js';
3
5
  import { metersToSimUnits, simUnitsToMeters } from '../../simulation/units.js';
4
6
  import {
@@ -12,13 +14,18 @@ import { canUseIndexedRecoveryRayApproximation } from './rayGuards.js';
12
14
  import { degreesToRadians, getCarRayOrigin, getCarRayVector, pointOnRay } from './rayGeometry.js';
13
15
  import { findIndexedTrackBandBoundaries } from './indexedRayBands.js';
14
16
 
17
+ const RAY_TRACK_STATE_REUSE_POSITION_TOLERANCE = metersToSimUnits(1);
18
+ const RAY_TRACK_STATE_REUSE_PROGRESS_TOLERANCE = metersToSimUnits(0.25);
19
+ const RAY_TRACK_STATE_REUSE_HEADING_TOLERANCE = 0.02;
20
+
15
21
  export function createTrackRayContext(car, snapshot, origin, precision = 'debug') {
16
22
  if (!Array.isArray(snapshot.track?.samples) || snapshot.track.samples.length === 0) {
17
23
  return { origin, originState: null, precision };
18
24
  }
25
+ const reusedOriginState = reusableRayOriginState(snapshot.track, car, origin);
19
26
  return {
20
27
  origin,
21
- originState: nearestRayTrackState(snapshot.track, car, origin, car.progress, precision),
28
+ originState: reusedOriginState ?? nearestRayTrackState(snapshot.track, car, origin, car.progress, precision),
22
29
  precision,
23
30
  };
24
31
  }
@@ -296,6 +303,113 @@ function nearestRayTrackState(track, car, point, progressHint, precision = 'debu
296
303
  });
297
304
  }
298
305
 
306
+ function reusableRayOriginState(track, car, origin) {
307
+ const synthesizedState = synthesizedMainRoadRayOriginState(track, car, origin);
308
+ if (synthesizedState) return synthesizedState;
309
+ const trackState = car?.trackState;
310
+ if (!trackState || trackState.inPitLane) return null;
311
+ const mainRoadEdge = track.width / 2 + (track.kerbWidth ?? 0);
312
+ if (!Number.isFinite(trackState.crossTrackError) || trackState.crossTrackError > mainRoadEdge) return null;
313
+ if (
314
+ typeof car?.trackStatePoseX === 'number' &&
315
+ typeof car?.trackStatePoseY === 'number' &&
316
+ typeof car?.trackStatePoseHeading === 'number'
317
+ ) {
318
+ if (
319
+ car.trackStatePoseX !== car.x ||
320
+ car.trackStatePoseY !== car.y ||
321
+ car.trackStatePoseHeading !== car.heading
322
+ ) {
323
+ return null;
324
+ }
325
+ if (!Number.isInteger(trackState.segmentId) && Number.isFinite(trackState.distance)) {
326
+ trackState.segmentId = sampleIndexAtDistance(track, trackState.distance);
327
+ }
328
+ } else {
329
+ if (!Number.isFinite(trackState.distance) || !Number.isFinite(trackState.signedOffset)) return null;
330
+ const base = pointAt(track, trackState.distance);
331
+ const projected = offsetTrackPoint(base, trackState.signedOffset);
332
+ const dx = (origin?.x ?? car?.x ?? 0) - projected.x;
333
+ const dy = (origin?.y ?? car?.y ?? 0) - projected.y;
334
+ if ((dx * dx) + (dy * dy) > RAY_TRACK_STATE_REUSE_POSITION_TOLERANCE * RAY_TRACK_STATE_REUSE_POSITION_TOLERANCE) {
335
+ return null;
336
+ }
337
+ if (wrappedDistanceDelta(trackState.distance, car?.progress, track.length) > RAY_TRACK_STATE_REUSE_PROGRESS_TOLERANCE) {
338
+ return null;
339
+ }
340
+ if (Math.abs(normalizeAngle((trackState.heading ?? base.heading) - base.heading)) > RAY_TRACK_STATE_REUSE_HEADING_TOLERANCE) {
341
+ return null;
342
+ }
343
+ const segmentId = trackState.segmentId ?? sampleIndexAtDistance(track, trackState.distance);
344
+ return {
345
+ segmentId,
346
+ x: base.x,
347
+ y: base.y,
348
+ distance: base.distance,
349
+ heading: base.heading,
350
+ normalX: base.normalX,
351
+ normalY: base.normalY,
352
+ curvature: base.curvature,
353
+ signedOffset: trackState.signedOffset,
354
+ crossTrackError: trackState.crossTrackError,
355
+ surface: trackState.surface ?? 'track',
356
+ onTrack: trackState.surface === 'track' || trackState.surface === 'kerb',
357
+ distanceSquared: trackState.distanceSquared,
358
+ inPitLane: false,
359
+ pitLanePart: null,
360
+ pitBoxId: null,
361
+ mainTrackSignedOffset: trackState.mainTrackSignedOffset,
362
+ mainTrackCrossTrackError: trackState.mainTrackCrossTrackError,
363
+ pitLaneCrossTrackError: trackState.pitLaneCrossTrackError,
364
+ };
365
+ }
366
+ return trackState;
367
+ }
368
+
369
+ function synthesizedMainRoadRayOriginState(track, car, origin) {
370
+ if (!Number.isFinite(car?.progress) || !Number.isFinite(car?.signedOffset)) return null;
371
+ if (car?.inPitLane || car?.pitLanePart) return null;
372
+ const crossTrackError = Math.abs(car.signedOffset);
373
+ const mainRoadEdge = track.width / 2 + (track.kerbWidth ?? 0);
374
+ if (crossTrackError > mainRoadEdge) return null;
375
+ const base = pointAt(track, car.progress);
376
+ const projected = offsetTrackPoint(base, car.signedOffset);
377
+ const dx = (origin?.x ?? car?.x ?? 0) - projected.x;
378
+ const dy = (origin?.y ?? car?.y ?? 0) - projected.y;
379
+ if ((dx * dx) + (dy * dy) > RAY_TRACK_STATE_REUSE_POSITION_TOLERANCE * RAY_TRACK_STATE_REUSE_POSITION_TOLERANCE) {
380
+ return null;
381
+ }
382
+ const segmentId = car?.trackState?.segmentId ?? sampleIndexAtDistance(track, car.progress);
383
+ return {
384
+ segmentId,
385
+ x: base.x,
386
+ y: base.y,
387
+ distance: base.distance,
388
+ heading: base.heading,
389
+ normalX: base.normalX,
390
+ normalY: base.normalY,
391
+ curvature: base.curvature,
392
+ signedOffset: car.signedOffset,
393
+ crossTrackError,
394
+ surface: crossTrackError <= track.width / 2 ? 'track' : 'kerb',
395
+ onTrack: true,
396
+ distanceSquared: car?.trackState?.distanceSquared,
397
+ inPitLane: false,
398
+ pitLanePart: null,
399
+ pitBoxId: null,
400
+ mainTrackSignedOffset: car?.trackState?.mainTrackSignedOffset,
401
+ mainTrackCrossTrackError: car?.trackState?.mainTrackCrossTrackError,
402
+ pitLaneCrossTrackError: car?.trackState?.pitLaneCrossTrackError,
403
+ };
404
+ }
405
+
406
+ function wrappedDistanceDelta(first, second, totalLength) {
407
+ if (!Number.isFinite(first) || !Number.isFinite(second)) return Infinity;
408
+ if (!Number.isFinite(totalLength) || totalLength <= 0) return Math.abs(first - second);
409
+ const delta = Math.abs(first - second);
410
+ return Math.min(delta, totalLength - delta);
411
+ }
412
+
299
413
  function refineStepsForPrecision(precision) {
300
414
  return precision === 'debug' ? TRACK_RAY_REFINE_STEPS : DRIVER_RAY_REFINE_STEPS;
301
415
  }
package/src/index.d.ts CHANGED
@@ -337,6 +337,32 @@ export interface F1SimulatorTheme extends Omit<F1SimulatorThemePackage, 'compone
337
337
  >;
338
338
  }
339
339
 
340
+ export type ResolvedPaddockThemeMode = Exclude<PaddockThemeMode, 'system'>;
341
+
342
+ export interface ResolvedPaddockThemePackage {
343
+ tokens: Record<ResolvedPaddockThemeMode, F1SimulatorThemeTokens>;
344
+ components: Record<string, Partial<Record<string, PaddockThemeTokenName>>>;
345
+ }
346
+
347
+ export interface ResolvedPaddockTheme {
348
+ mode: PaddockThemeMode;
349
+ activeMode: ResolvedPaddockThemeMode;
350
+ use: string;
351
+ tokens: Record<ResolvedPaddockThemeMode, F1SimulatorThemeTokens>;
352
+ activeTokens: F1SimulatorThemeTokens;
353
+ components: Record<string, Partial<Record<string, PaddockThemeTokenName>>>;
354
+ themes: Record<string, ResolvedPaddockThemePackage>;
355
+ componentThemes: Record<string, PaddockThemeSelector>;
356
+ teamThemes: Record<string, PaddockThemeSelector>;
357
+ light: F1SimulatorThemeTokens;
358
+ dark: F1SimulatorThemeTokens;
359
+ }
360
+
361
+ export interface PaddockThemeSyncOptions {
362
+ attribute?: string;
363
+ map?: Record<string, PaddockThemeMode | F1SimulatorTheme>;
364
+ }
365
+
340
366
  export interface F1SimulatorUiOptions {
341
367
  layoutPreset?: LayoutPreset;
342
368
  cameraControls?: CameraControlsMode;
@@ -1000,6 +1026,10 @@ export interface F1MountedSimulator {
1000
1026
  readonly expert: F1SimulatorExpertApi | null;
1001
1027
  destroy(): void;
1002
1028
  restart(nextOptions?: F1SimulatorRestartOptions): void;
1029
+ setTheme(theme: F1SimulatorTheme): ResolvedPaddockTheme;
1030
+ setThemeMode(mode: PaddockThemeMode): ResolvedPaddockTheme;
1031
+ getTheme(): ResolvedPaddockTheme;
1032
+ syncThemeFrom(source: Element, options?: PaddockThemeSyncOptions): () => void;
1003
1033
  selectDriver(driverId: string): void;
1004
1034
  setSafetyCarDeployed(deployed: boolean): void;
1005
1035
  setRedFlagDeployed(deployed: boolean): void;
@@ -1040,6 +1070,10 @@ export interface PaddockSimulatorController {
1040
1070
  start(): Promise<PaddockSimulatorController>;
1041
1071
  destroy(): void;
1042
1072
  restart(nextOptions?: F1SimulatorRestartOptions): void;
1073
+ setTheme(theme: F1SimulatorTheme): ResolvedPaddockTheme;
1074
+ setThemeMode(mode: PaddockThemeMode): ResolvedPaddockTheme;
1075
+ getTheme(): ResolvedPaddockTheme;
1076
+ syncThemeFrom(source: Element, options?: PaddockThemeSyncOptions): () => void;
1043
1077
  selectDriver(driverId: string): void;
1044
1078
  setSafetyCarDeployed(deployed: boolean): void;
1045
1079
  setRedFlagDeployed(deployed: boolean): void;
@@ -1062,10 +1096,13 @@ export interface PaddockSimulatorController {
1062
1096
  export const CHAMPIONSHIP_ENTRY_BLUEPRINTS: ChampionshipEntryBlueprint[];
1063
1097
  export const DEMO_PROJECT_DRIVERS: SimulatorDriver[];
1064
1098
  export const DEFAULT_F1_SIMULATOR_ASSETS: Required<F1SimulatorAssets>;
1099
+ export const DEFAULT_PADDOCK_THEME: F1SimulatorTheme;
1065
1100
  export const PADDOCK_SIMULATOR_PRESETS: Record<PaddockPresetName, {
1066
1101
  ui?: F1SimulatorUiOptions;
1067
1102
  theme?: F1SimulatorTheme;
1068
1103
  }>;
1104
+ export const PADDOCK_THEME_CSS_VARIABLES: Record<PaddockThemeTokenName, string>;
1105
+ export const PADDOCK_THEME_TOKEN_KEYS: readonly PaddockThemeTokenName[];
1069
1106
 
1070
1107
  export const REAL_F1_CAR_LENGTH_METERS: number;
1071
1108
  export const SIM_UNITS_PER_METER: number;
@@ -1081,6 +1118,12 @@ export function normalizeSimulatorDrivers(
1081
1118
  drivers: SimulatorDriver[],
1082
1119
  options?: { entries?: ChampionshipEntryBlueprint[]; caller?: string },
1083
1120
  ): NormalizedSimulatorDriver[];
1121
+ export function resolvePaddockTheme(theme?: F1SimulatorTheme): ResolvedPaddockTheme;
1122
+ export function applyPaddockTheme(
1123
+ root: Element,
1124
+ theme?: F1SimulatorTheme | ResolvedPaddockTheme,
1125
+ context?: { selectedTeamId?: string | null },
1126
+ ): void;
1084
1127
 
1085
1128
  export function metersToSimUnits(meters: number): number;
1086
1129
  export function simUnitsToMeters(simUnits: number): number;
package/src/index.js CHANGED
@@ -2,6 +2,7 @@ import './styles.css';
2
2
  import { F1SimulatorApp } from './app/F1SimulatorApp.js';
3
3
  import { resolveF1SimulatorOptions } from './config/defaultOptions.js';
4
4
  import { mergeRestartOptions } from './config/restartOptions.js';
5
+ import { createThemeSync, resolveRuntimeThemeModeOptions, resolveRuntimeThemeOptions } from './api/runtimeTheme.js';
5
6
  import { createF1SimulatorShell } from './ui/shellTemplate.js';
6
7
  export {
7
8
  createPaddockSimulator,
@@ -30,7 +31,14 @@ export {
30
31
  } from './data/championship.js';
31
32
  export { DEMO_PROJECT_DRIVERS } from './data/demoDrivers.js';
32
33
  export { DEFAULT_F1_SIMULATOR_ASSETS } from './config/defaultAssets.js';
33
- export { PADDOCK_SIMULATOR_PRESETS } from './config/defaultOptions.js';
34
+ export {
35
+ DEFAULT_PADDOCK_THEME,
36
+ PADDOCK_SIMULATOR_PRESETS,
37
+ PADDOCK_THEME_CSS_VARIABLES,
38
+ PADDOCK_THEME_TOKEN_KEYS,
39
+ applyPaddockTheme,
40
+ resolvePaddockTheme,
41
+ } from './config/defaultOptions.js';
34
42
  export { createPaddockDriverControllerLoop } from './environment/controllerLoop.js';
35
43
  export { normalizeSimulatorDrivers } from './data/normalizeDrivers.js';
36
44
  export { createProceduralTrack } from './simulation/track/trackModel.js';
@@ -76,7 +84,7 @@ export async function mountF1Simulator(root, options = {}) {
76
84
  return resolvedOptions;
77
85
  };
78
86
 
79
- return {
87
+ const mountedSimulator = {
80
88
  get expert() {
81
89
  return app.expert ?? null;
82
90
  },
@@ -89,6 +97,22 @@ export async function mountF1Simulator(root, options = {}) {
89
97
  app.restart(nextResolvedOptions);
90
98
  resolvedOptions = nextResolvedOptions;
91
99
  },
100
+ setTheme(themeInput = {}) {
101
+ const nextResolvedOptions = resolveRuntimeThemeOptions(syncResolvedRuntimeOptions(), themeInput);
102
+ resolvedOptions = nextResolvedOptions;
103
+ return app.setTheme(nextResolvedOptions.theme);
104
+ },
105
+ setThemeMode(mode) {
106
+ const nextResolvedOptions = resolveRuntimeThemeModeOptions(syncResolvedRuntimeOptions(), mode);
107
+ resolvedOptions = nextResolvedOptions;
108
+ return app.setTheme(nextResolvedOptions.theme);
109
+ },
110
+ getTheme() {
111
+ return resolvedOptions.theme;
112
+ },
113
+ syncThemeFrom(source, options = {}) {
114
+ return createThemeSync(mountedSimulator, source, options);
115
+ },
92
116
  selectDriver(driverId) {
93
117
  app.selectCar(driverId, { focus: true });
94
118
  },
@@ -142,4 +166,5 @@ export async function mountF1Simulator(root, options = {}) {
142
166
  return app.getSnapshot();
143
167
  },
144
168
  };
169
+ return mountedSimulator;
145
170
  }
@@ -0,0 +1,11 @@
1
+ export type PaddockLoadingPlaceholderVariant = 'lights' | 'custom';
2
+
3
+ export interface PaddockLoadingPlaceholderOptions {
4
+ label?: string;
5
+ detail?: string;
6
+ className?: string;
7
+ variant?: PaddockLoadingPlaceholderVariant;
8
+ attributes?: Record<string, string | number | boolean | null | undefined>;
9
+ }
10
+
11
+ export function createPaddockLoadingPlaceholder(options?: PaddockLoadingPlaceholderOptions): string;