@inventure71/paddockjs 2.0.0 → 2.0.1

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 (62) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/INSTALL_AND_UPDATE.md +1 -1
  3. package/README.md +30 -5
  4. package/docs/architecture.md +15 -9
  5. package/docs/concepts.md +6 -4
  6. package/docs/data_contract.md +28 -15
  7. package/docs/data_model.md +1 -1
  8. package/docs/index.md +9 -0
  9. package/docs/rules.md +37 -7
  10. package/docs/sense_contract.md +59 -0
  11. package/docs/system_specs.md +56 -7
  12. package/docs/training.md +16 -10
  13. package/package.json +1 -1
  14. package/src/api/PaddockSimulatorController.js +2 -33
  15. package/src/app/BrowserExpertAdapter.js +9 -300
  16. package/src/app/BrowserExpertExternalRenderer.js +303 -0
  17. package/src/app/F1SimulatorApp.js +1 -42
  18. package/src/config/restartOptions.js +34 -0
  19. package/src/environment/controllerLoop.js +17 -2
  20. package/src/environment/episode.js +14 -2
  21. package/src/environment/index.d.ts +16 -2
  22. package/src/environment/observationVector.js +200 -0
  23. package/src/environment/observations.js +58 -285
  24. package/src/environment/recorder.js +13 -6
  25. package/src/environment/rewards.js +13 -2
  26. package/src/environment/runtime.js +62 -14
  27. package/src/environment/scenarios.js +22 -3
  28. package/src/environment/sensors/index.js +3 -3
  29. package/src/environment/sensors/opponentRadar.js +13 -3
  30. package/src/environment/sensors/sensorTargets.js +8 -2
  31. package/src/environment/sensors/surfaceRays.js +90 -26
  32. package/src/environment/sensors/trackRays.js +185 -22
  33. package/src/environment/specs.js +78 -40
  34. package/src/index.d.ts +2 -0
  35. package/src/index.js +1 -32
  36. package/src/simulation/pit/pitFlow.js +5 -0
  37. package/src/simulation/pit/pitKinematics.js +24 -0
  38. package/src/simulation/pit/pitQueue.js +5 -0
  39. package/src/simulation/pit/pitRouteMovement.js +7 -0
  40. package/src/simulation/pit/pitService.js +2 -0
  41. package/src/simulation/pit/pitTireService.js +2 -0
  42. package/src/simulation/race/raceLifecycle.js +2 -6
  43. package/src/simulation/race/raceProgress.js +6 -1
  44. package/src/simulation/race/raceStep.js +1 -1
  45. package/src/simulation/race/redFlag.js +3 -3
  46. package/src/simulation/race/safetyCar.js +10 -4
  47. package/src/simulation/race/stalledDnf.js +8 -17
  48. package/src/simulation/raceSimulation.js +1 -0
  49. package/src/simulation/rules/rulesReview.js +3 -0
  50. package/src/simulation/snapshots/raceSnapshots.js +1 -0
  51. package/src/simulation/track/trackQueryGrid.js +94 -0
  52. package/src/simulation/track/trackQueryIndex.js +16 -302
  53. package/src/simulation/track/trackQueryPitIndex.js +140 -0
  54. package/src/simulation/track/trackQueryScratch.js +46 -0
  55. package/src/simulation/track/trackQueryStats.js +28 -0
  56. package/src/simulation/vehicle/contactResolution.js +67 -2
  57. package/src/simulation/vehicle/runoffResponse.js +11 -12
  58. package/src/simulation/vehicle/vehicleKinematics.js +37 -0
  59. package/src/simulation/vehicle/vehiclePhysics.js +2 -1
  60. package/src/simulation/vehicle/vehicleSnapshots.js +11 -0
  61. package/src/simulation/vehicle/vehicleState.js +36 -2
  62. package/src/simulation/vehicle/wheelSurface.js +3 -1
@@ -1,5 +1,21 @@
1
1
  import { clamp, wrapDistance } from '../simMath.js';
2
2
  import { metersToSimUnits } from '../units.js';
3
+ import {
4
+ candidateIdsFromGrid,
5
+ candidateIdsFromGridBounds,
6
+ createSpatialGrid,
7
+ gridCellForPoint,
8
+ insertIdIntoGridBounds,
9
+ } from './trackQueryGrid.js';
10
+ import { createPitQueryIndex } from './trackQueryPitIndex.js';
11
+ import { createQueryScratch, ensureQueryScratch, ensureScratchArray, nextScratchEpoch } from './trackQueryScratch.js';
12
+ import { createQueryStats, recordFallback, recordStat } from './trackQueryStats.js';
13
+
14
+ export {
15
+ queryPitBoxCandidates,
16
+ queryPitRoadSegmentCandidates,
17
+ queryPitRoadSegmentCandidatesByRoute,
18
+ } from './trackQueryPitIndex.js';
3
19
 
4
20
  const DEFAULT_GRID_CELL_SIZE = metersToSimUnits(32);
5
21
  const GRID_NEIGHBOR_LIMIT = 2;
@@ -263,59 +279,6 @@ function projectionTieScore(projection, preferredDistance) {
263
279
  return Math.abs(projection.distance - preferredDistance);
264
280
  }
265
281
 
266
- export function queryPitBoxCandidates(track, position) {
267
- const index = track?.queryIndex;
268
- const pit = index?.pit;
269
- if (!pit?.boxGrid) {
270
- recordFallback(index, 'pitFallbackReasons', 'missing-box-index');
271
- return null;
272
- }
273
- if (!finitePoint(position)) {
274
- recordFallback(index, 'pitFallbackReasons', 'invalid-position');
275
- return null;
276
- }
277
- index.stats.pitQueries += 1;
278
- const ids = candidateIdsFromGrid(index, pit.boxGrid, position, 1);
279
- if (!ids.length) {
280
- recordStat(index, 'pitPaths', 'box-grid-miss');
281
- return [];
282
- }
283
- recordStat(index, 'pitPaths', 'box-grid-hit');
284
- return ids.map((id) => pit.boxCandidates[id]).filter(Boolean);
285
- }
286
-
287
- export function queryPitRoadSegmentCandidates(track, routeId, position) {
288
- const candidatesByRoute = queryPitRoadSegmentCandidatesByRoute(track, position);
289
- return candidatesByRoute?.[routeId] ?? candidatesByRoute;
290
- }
291
-
292
- export function queryPitRoadSegmentCandidatesByRoute(track, position) {
293
- const index = track?.queryIndex;
294
- const pit = index?.pit;
295
- if (!pit?.roadGrid) {
296
- recordFallback(index, 'pitFallbackReasons', 'missing-road-index');
297
- return null;
298
- }
299
- if (!finitePoint(position)) {
300
- recordFallback(index, 'pitFallbackReasons', 'invalid-position');
301
- return null;
302
- }
303
- index.stats.pitQueries += 1;
304
- const ids = candidateIdsFromGrid(index, pit.roadGrid, position, 1);
305
- if (!ids.length) {
306
- recordStat(index, 'pitPaths', 'road-grid-miss');
307
- return null;
308
- }
309
- const candidatesByRoute = { entry: [], main: [], working: [], exit: [] };
310
- ids.forEach((id) => {
311
- const segment = pit.roadSegments[id];
312
- if (!segment || !candidatesByRoute[segment.routeId]) return;
313
- candidatesByRoute[segment.routeId].push(segment.segmentIndex);
314
- });
315
- recordStat(index, 'pitPaths', 'road-grid-hit');
316
- return candidatesByRoute;
317
- }
318
-
319
282
  export function queryNearbyTrackProjections(track, position, { neighborLimit = GRID_NEIGHBOR_LIMIT } = {}) {
320
283
  const index = track?.queryIndex;
321
284
  if (!index?.centerline?.segmentCount || !finitePoint(position)) return null;
@@ -422,35 +385,6 @@ export function snapshotTrackQueryStats(track) {
422
385
  return stats ? JSON.parse(JSON.stringify(stats)) : null;
423
386
  }
424
387
 
425
- function createQueryStats() {
426
- return {
427
- nearestQueries: 0,
428
- nearestFallbacks: 0,
429
- nearestPaths: {},
430
- nearestFallbackReasons: {
431
- 'spatial-grid-no-candidates': 0,
432
- },
433
- pitQueries: 0,
434
- pitFallbacks: 0,
435
- pitPaths: {},
436
- pitFallbackReasons: {},
437
- };
438
- }
439
-
440
- function recordFallback(index, bucket, reason) {
441
- if (!index?.stats) return;
442
- if (bucket.startsWith('nearest')) index.stats.nearestFallbacks += 1;
443
- if (bucket.startsWith('pit')) index.stats.pitFallbacks += 1;
444
- recordStat(index, bucket, reason);
445
- }
446
-
447
- function recordStat(index, bucket, reason) {
448
- if (!index?.stats || !reason) return;
449
- const target = index.stats[bucket] ?? {};
450
- target[reason] = (target[reason] ?? 0) + 1;
451
- index.stats[bucket] = target;
452
- }
453
-
454
388
  function createTrackBands(track) {
455
389
  const trackEdge = track.width / 2;
456
390
  const kerbEdge = trackEdge + (track.kerbWidth ?? 0);
@@ -559,18 +493,6 @@ function expandedSampleBounds(samples, expansion) {
559
493
  };
560
494
  }
561
495
 
562
- function createSpatialGrid(bounds, cellSize) {
563
- const columns = Math.max(1, Math.ceil((bounds.maxX - bounds.minX) / cellSize));
564
- const rows = Math.max(1, Math.ceil((bounds.maxY - bounds.minY) / cellSize));
565
- return {
566
- bounds,
567
- cellSize,
568
- columns,
569
- rows,
570
- cells: new Array(columns * rows),
571
- };
572
- }
573
-
574
496
  function insertSegmentIntoGrid(grid, centerline, segmentId, expansion) {
575
497
  insertIdIntoGridBounds(grid, segmentId, {
576
498
  minX: centerline.minX[segmentId] - expansion,
@@ -580,19 +502,6 @@ function insertSegmentIntoGrid(grid, centerline, segmentId, expansion) {
580
502
  });
581
503
  }
582
504
 
583
- function insertIdIntoGridBounds(grid, id, bounds) {
584
- const minCell = gridCellForPoint(grid, { x: bounds.minX, y: bounds.minY }, true);
585
- const maxCell = gridCellForPoint(grid, { x: bounds.maxX, y: bounds.maxY }, true);
586
- for (let row = minCell.row; row <= maxCell.row; row += 1) {
587
- for (let column = minCell.column; column <= maxCell.column; column += 1) {
588
- const cellIndex = row * grid.columns + column;
589
- const cell = grid.cells[cellIndex];
590
- if (cell) cell.push(id);
591
- else grid.cells[cellIndex] = [id];
592
- }
593
- }
594
- }
595
-
596
505
  function candidateIdsFromArcBuckets(index, distanceAlong, radius) {
597
506
  const arcBuckets = index?.arcBuckets;
598
507
  if (!arcBuckets?.count || !Number.isFinite(distanceAlong)) return [];
@@ -613,73 +522,6 @@ function candidateIdsFromArcBuckets(index, distanceAlong, radius) {
613
522
  return ids;
614
523
  }
615
524
 
616
- function candidateIdsFromGrid(index, grid, position, neighborLimit) {
617
- const center = gridCellForPoint(grid, position, false);
618
- if (!center) return [];
619
- const scratch = ensureQueryScratch(index);
620
- const maxId = Math.max(index.centerline.segmentCount, index.pit?.roadSegments?.length ?? 0, index.pit?.boxCandidates?.length ?? 0);
621
- const idMarks = ensureScratchArray(scratch, 'candidateMarks', maxId);
622
- const ids = [];
623
- for (let radius = 0; radius <= neighborLimit; radius += 1) {
624
- const candidateEpoch = nextScratchEpoch(scratch, 'candidateEpoch', idMarks);
625
- ids.length = 0;
626
- for (let row = center.row - radius; row <= center.row + radius; row += 1) {
627
- for (let column = center.column - radius; column <= center.column + radius; column += 1) {
628
- if (column < 0 || row < 0 || column >= grid.columns || row >= grid.rows) continue;
629
- const cell = grid.cells[row * grid.columns + column];
630
- if (!cell) continue;
631
- for (const id of cell) {
632
- if (idMarks[id] === candidateEpoch) continue;
633
- idMarks[id] = candidateEpoch;
634
- ids.push(id);
635
- }
636
- }
637
- }
638
- if (ids.length) return ids.slice();
639
- }
640
- return [];
641
- }
642
-
643
- function candidateIdsFromGridBounds(index, grid, bounds) {
644
- if (
645
- bounds.maxX < grid.bounds.minX ||
646
- bounds.minX > grid.bounds.maxX ||
647
- bounds.maxY < grid.bounds.minY ||
648
- bounds.minY > grid.bounds.maxY
649
- ) return [];
650
- const minPoint = { x: bounds.minX, y: bounds.minY };
651
- const maxPoint = { x: bounds.maxX, y: bounds.maxY };
652
- const minCell = gridCellForPoint(grid, minPoint, true);
653
- const maxCell = gridCellForPoint(grid, maxPoint, true);
654
- const scratch = ensureQueryScratch(index);
655
- const maxId = Math.max(index.centerline.segmentCount, index.pit?.roadSegments?.length ?? 0, index.pit?.boxCandidates?.length ?? 0);
656
- const idMarks = ensureScratchArray(scratch, 'candidateMarks', maxId);
657
- const candidateEpoch = nextScratchEpoch(scratch, 'candidateEpoch', idMarks);
658
- const ids = [];
659
- for (let row = minCell.row; row <= maxCell.row; row += 1) {
660
- for (let column = minCell.column; column <= maxCell.column; column += 1) {
661
- const cell = grid.cells[row * grid.columns + column];
662
- if (!cell) continue;
663
- for (const id of cell) {
664
- if (idMarks[id] === candidateEpoch) continue;
665
- idMarks[id] = candidateEpoch;
666
- ids.push(id);
667
- }
668
- }
669
- }
670
- return ids;
671
- }
672
-
673
- function gridCellForPoint(grid, point, clampToGrid) {
674
- const column = Math.floor((point.x - grid.bounds.minX) / grid.cellSize);
675
- const row = Math.floor((point.y - grid.bounds.minY) / grid.cellSize);
676
- if (!clampToGrid && (column < 0 || row < 0 || column >= grid.columns || row >= grid.rows)) return null;
677
- return {
678
- column: clampToGrid ? clamp(column, 0, grid.columns - 1) : column,
679
- row: clampToGrid ? clamp(row, 0, grid.rows - 1) : row,
680
- };
681
- }
682
-
683
525
  function projectIndexedSegment(centerline, segmentId, position) {
684
526
  const ax = centerline.startX[segmentId];
685
527
  const ay = centerline.startY[segmentId];
@@ -733,134 +575,6 @@ function segmentFromIndex(centerline, segmentId) {
733
575
  };
734
576
  }
735
577
 
736
- function createPitQueryIndex(track, cellSize) {
737
- const pitLane = track.pitLane;
738
- if (!pitLane?.enabled) return null;
739
- const bounds = {
740
- minX: pitLane.bounds.minX - cellSize,
741
- maxX: pitLane.bounds.maxX + cellSize,
742
- minY: pitLane.bounds.minY - cellSize,
743
- maxY: pitLane.bounds.maxY + cellSize,
744
- };
745
- const roadGrid = createSpatialGrid(bounds, cellSize);
746
- const boxGrid = createSpatialGrid(bounds, cellSize);
747
- const roadSegments = [];
748
- const routes = {};
749
-
750
- [
751
- ['entry', pitLane.entry?.roadCenterline],
752
- ['main', pitLane.mainLane?.points],
753
- ['working', pitLane.workingLane?.points],
754
- ['exit', pitLane.exit?.roadCenterline],
755
- ].forEach(([routeId, points]) => {
756
- routes[routeId] = { start: roadSegments.length };
757
- indexPitRoute(roadGrid, roadSegments, routeId, points, cellSize);
758
- routes[routeId].end = roadSegments.length;
759
- });
760
-
761
- const boxCandidates = [];
762
- [
763
- ...(pitLane.serviceAreas ?? []).flatMap((area) => [
764
- { type: 'service-area', target: area, polygon: area.corners },
765
- { type: 'service-queue', target: area, polygon: area.queueCorners },
766
- ]),
767
- ...(pitLane.boxes ?? []).map((box) => ({ type: 'garage-box', target: box, polygon: box.corners })),
768
- ]
769
- .filter((candidate) => Array.isArray(candidate.polygon) && candidate.polygon.length >= 3)
770
- .forEach((candidate) => {
771
- const id = boxCandidates.length;
772
- boxCandidates.push(candidate);
773
- insertIdIntoGridBounds(boxGrid, id, polygonBounds(candidate.polygon, cellSize));
774
- });
775
-
776
- return {
777
- roadGrid,
778
- boxGrid,
779
- routes,
780
- roadSegments,
781
- boxCandidates,
782
- };
783
- }
784
-
785
- function indexPitRoute(grid, roadSegments, routeId, points, expansion) {
786
- if (!Array.isArray(points) || points.length < 2) return;
787
- for (let index = 0; index < points.length - 1; index += 1) {
788
- const start = points[index];
789
- const end = points[index + 1];
790
- const id = roadSegments.length;
791
- roadSegments.push({ routeId, segmentIndex: index });
792
- const bounds = {
793
- minX: Math.min(start.x, end.x) - expansion,
794
- maxX: Math.max(start.x, end.x) + expansion,
795
- minY: Math.min(start.y, end.y) - expansion,
796
- maxY: Math.max(start.y, end.y) + expansion,
797
- };
798
- insertIdIntoGridBounds(grid, id, bounds);
799
- }
800
- }
801
-
802
- function polygonBounds(polygon, expansion) {
803
- const bounds = polygon.reduce((current, point) => ({
804
- minX: Math.min(current.minX, point.x),
805
- maxX: Math.max(current.maxX, point.x),
806
- minY: Math.min(current.minY, point.y),
807
- maxY: Math.max(current.maxY, point.y),
808
- }), { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity });
809
- return {
810
- minX: bounds.minX - expansion,
811
- maxX: bounds.maxX + expansion,
812
- minY: bounds.minY - expansion,
813
- maxY: bounds.maxY + expansion,
814
- };
815
- }
816
-
817
- function createQueryScratch(index) {
818
- const segmentCount = Math.max(0, index?.centerline?.segmentCount ?? 0);
819
- const maxId = Math.max(
820
- segmentCount,
821
- index?.pit?.roadSegments?.length ?? 0,
822
- index?.pit?.boxCandidates?.length ?? 0,
823
- );
824
- const grid = index?.segmentGrid ?? index?.grid;
825
- const cellCount = Math.max(0, (grid?.columns ?? 0) * (grid?.rows ?? 0));
826
- return {
827
- candidateMarks: new Uint32Array(Math.max(1, maxId)),
828
- candidateEpoch: 1,
829
- sampleMarks: new Uint32Array(Math.max(1, segmentCount)),
830
- sampleEpoch: 1,
831
- raySegmentMarks: new Uint32Array(Math.max(1, segmentCount)),
832
- raySegmentEpoch: 1,
833
- rayCellMarks: new Uint32Array(Math.max(1, cellCount)),
834
- rayCellEpoch: 1,
835
- raySegmentIds: [],
836
- rayVisitedCells: [],
837
- rayTraceCache: new Map(),
838
- };
839
- }
840
-
841
- function ensureQueryScratch(index) {
842
- if (index.queryScratch) return index.queryScratch;
843
- index.queryScratch = createQueryScratch(index);
844
- return index.queryScratch;
845
- }
846
-
847
- function ensureScratchArray(scratch, key, minimumLength) {
848
- if (!scratch[key] || scratch[key].length < minimumLength) {
849
- scratch[key] = new Uint32Array(Math.max(1, minimumLength));
850
- }
851
- return scratch[key];
852
- }
853
-
854
- function nextScratchEpoch(scratch, key, marks) {
855
- let epoch = (scratch[key] ?? 0) + 1;
856
- if (epoch >= 0xFFFFFFFF) {
857
- marks.fill(0);
858
- epoch = 1;
859
- }
860
- scratch[key] = epoch;
861
- return epoch;
862
- }
863
-
864
578
  function seedRayTraceFromNearbyCache(
865
579
  index,
866
580
  grid,
@@ -0,0 +1,140 @@
1
+ import { createSpatialGrid, insertIdIntoGridBounds, candidateIdsFromGrid } from './trackQueryGrid.js';
2
+ import { recordFallback, recordStat } from './trackQueryStats.js';
3
+
4
+ export function createPitQueryIndex(track, cellSize) {
5
+ const pitLane = track.pitLane;
6
+ if (!pitLane?.enabled) return null;
7
+ const bounds = {
8
+ minX: pitLane.bounds.minX - cellSize,
9
+ maxX: pitLane.bounds.maxX + cellSize,
10
+ minY: pitLane.bounds.minY - cellSize,
11
+ maxY: pitLane.bounds.maxY + cellSize,
12
+ };
13
+ const roadGrid = createSpatialGrid(bounds, cellSize);
14
+ const boxGrid = createSpatialGrid(bounds, cellSize);
15
+ const roadSegments = [];
16
+ const routes = {};
17
+
18
+ [
19
+ ['entry', pitLane.entry?.roadCenterline],
20
+ ['main', pitLane.mainLane?.points],
21
+ ['working', pitLane.workingLane?.points],
22
+ ['exit', pitLane.exit?.roadCenterline],
23
+ ].forEach(([routeId, points]) => {
24
+ routes[routeId] = { start: roadSegments.length };
25
+ indexPitRoute(roadGrid, roadSegments, routeId, points, cellSize);
26
+ routes[routeId].end = roadSegments.length;
27
+ });
28
+
29
+ const boxCandidates = [];
30
+ [
31
+ ...(pitLane.serviceAreas ?? []).flatMap((area) => [
32
+ { type: 'service-area', target: area, polygon: area.corners },
33
+ { type: 'service-queue', target: area, polygon: area.queueCorners },
34
+ ]),
35
+ ...(pitLane.boxes ?? []).map((box) => ({ type: 'garage-box', target: box, polygon: box.corners })),
36
+ ]
37
+ .filter((candidate) => Array.isArray(candidate.polygon) && candidate.polygon.length >= 3)
38
+ .forEach((candidate) => {
39
+ const id = boxCandidates.length;
40
+ boxCandidates.push(candidate);
41
+ insertIdIntoGridBounds(boxGrid, id, polygonBounds(candidate.polygon, cellSize));
42
+ });
43
+
44
+ return {
45
+ roadGrid,
46
+ boxGrid,
47
+ routes,
48
+ roadSegments,
49
+ boxCandidates,
50
+ };
51
+ }
52
+
53
+ export function queryPitBoxCandidates(track, position) {
54
+ const index = track?.queryIndex;
55
+ const pit = index?.pit;
56
+ if (!pit?.boxGrid) {
57
+ recordFallback(index, 'pitFallbackReasons', 'missing-box-index');
58
+ return null;
59
+ }
60
+ if (!finitePoint(position)) {
61
+ recordFallback(index, 'pitFallbackReasons', 'invalid-position');
62
+ return null;
63
+ }
64
+ index.stats.pitQueries += 1;
65
+ const ids = candidateIdsFromGrid(index, pit.boxGrid, position, 1);
66
+ if (!ids.length) {
67
+ recordStat(index, 'pitPaths', 'box-grid-miss');
68
+ return [];
69
+ }
70
+ recordStat(index, 'pitPaths', 'box-grid-hit');
71
+ return ids.map((id) => pit.boxCandidates[id]).filter(Boolean);
72
+ }
73
+
74
+ export function queryPitRoadSegmentCandidates(track, routeId, position) {
75
+ const candidatesByRoute = queryPitRoadSegmentCandidatesByRoute(track, position);
76
+ return candidatesByRoute?.[routeId] ?? candidatesByRoute;
77
+ }
78
+
79
+ export function queryPitRoadSegmentCandidatesByRoute(track, position) {
80
+ const index = track?.queryIndex;
81
+ const pit = index?.pit;
82
+ if (!pit?.roadGrid) {
83
+ recordFallback(index, 'pitFallbackReasons', 'missing-road-index');
84
+ return null;
85
+ }
86
+ if (!finitePoint(position)) {
87
+ recordFallback(index, 'pitFallbackReasons', 'invalid-position');
88
+ return null;
89
+ }
90
+ index.stats.pitQueries += 1;
91
+ const ids = candidateIdsFromGrid(index, pit.roadGrid, position, 1);
92
+ if (!ids.length) {
93
+ recordStat(index, 'pitPaths', 'road-grid-miss');
94
+ return null;
95
+ }
96
+ const candidatesByRoute = { entry: [], main: [], working: [], exit: [] };
97
+ ids.forEach((id) => {
98
+ const segment = pit.roadSegments[id];
99
+ if (!segment || !candidatesByRoute[segment.routeId]) return;
100
+ candidatesByRoute[segment.routeId].push(segment.segmentIndex);
101
+ });
102
+ recordStat(index, 'pitPaths', 'road-grid-hit');
103
+ return candidatesByRoute;
104
+ }
105
+
106
+ function indexPitRoute(grid, roadSegments, routeId, points, expansion) {
107
+ if (!Array.isArray(points) || points.length < 2) return;
108
+ for (let index = 0; index < points.length - 1; index += 1) {
109
+ const start = points[index];
110
+ const end = points[index + 1];
111
+ const id = roadSegments.length;
112
+ roadSegments.push({ routeId, segmentIndex: index });
113
+ const bounds = {
114
+ minX: Math.min(start.x, end.x) - expansion,
115
+ maxX: Math.max(start.x, end.x) + expansion,
116
+ minY: Math.min(start.y, end.y) - expansion,
117
+ maxY: Math.max(start.y, end.y) + expansion,
118
+ };
119
+ insertIdIntoGridBounds(grid, id, bounds);
120
+ }
121
+ }
122
+
123
+ function polygonBounds(polygon, expansion) {
124
+ const bounds = polygon.reduce((current, point) => ({
125
+ minX: Math.min(current.minX, point.x),
126
+ maxX: Math.max(current.maxX, point.x),
127
+ minY: Math.min(current.minY, point.y),
128
+ maxY: Math.max(current.maxY, point.y),
129
+ }), { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity });
130
+ return {
131
+ minX: bounds.minX - expansion,
132
+ maxX: bounds.maxX + expansion,
133
+ minY: bounds.minY - expansion,
134
+ maxY: bounds.maxY + expansion,
135
+ };
136
+ }
137
+
138
+ function finitePoint(point) {
139
+ return Number.isFinite(point?.x) && Number.isFinite(point?.y);
140
+ }
@@ -0,0 +1,46 @@
1
+ export function createQueryScratch(index) {
2
+ const segmentCount = Math.max(0, index?.centerline?.segmentCount ?? 0);
3
+ const maxId = Math.max(
4
+ segmentCount,
5
+ index?.pit?.roadSegments?.length ?? 0,
6
+ index?.pit?.boxCandidates?.length ?? 0,
7
+ );
8
+ const grid = index?.segmentGrid ?? index?.grid;
9
+ const cellCount = Math.max(0, (grid?.columns ?? 0) * (grid?.rows ?? 0));
10
+ return {
11
+ candidateMarks: new Uint32Array(Math.max(1, maxId)),
12
+ candidateEpoch: 1,
13
+ sampleMarks: new Uint32Array(Math.max(1, segmentCount)),
14
+ sampleEpoch: 1,
15
+ raySegmentMarks: new Uint32Array(Math.max(1, segmentCount)),
16
+ raySegmentEpoch: 1,
17
+ rayCellMarks: new Uint32Array(Math.max(1, cellCount)),
18
+ rayCellEpoch: 1,
19
+ raySegmentIds: [],
20
+ rayVisitedCells: [],
21
+ rayTraceCache: new Map(),
22
+ };
23
+ }
24
+
25
+ export function ensureQueryScratch(index) {
26
+ if (index.queryScratch) return index.queryScratch;
27
+ index.queryScratch = createQueryScratch(index);
28
+ return index.queryScratch;
29
+ }
30
+
31
+ export function ensureScratchArray(scratch, key, minimumLength) {
32
+ if (!scratch[key] || scratch[key].length < minimumLength) {
33
+ scratch[key] = new Uint32Array(Math.max(1, minimumLength));
34
+ }
35
+ return scratch[key];
36
+ }
37
+
38
+ export function nextScratchEpoch(scratch, key, marks) {
39
+ let epoch = (scratch[key] ?? 0) + 1;
40
+ if (epoch >= 0xFFFFFFFF) {
41
+ marks.fill(0);
42
+ epoch = 1;
43
+ }
44
+ scratch[key] = epoch;
45
+ return epoch;
46
+ }
@@ -0,0 +1,28 @@
1
+ export function createQueryStats() {
2
+ return {
3
+ nearestQueries: 0,
4
+ nearestFallbacks: 0,
5
+ nearestPaths: {},
6
+ nearestFallbackReasons: {
7
+ 'spatial-grid-no-candidates': 0,
8
+ },
9
+ pitQueries: 0,
10
+ pitFallbacks: 0,
11
+ pitPaths: {},
12
+ pitFallbackReasons: {},
13
+ };
14
+ }
15
+
16
+ export function recordFallback(index, bucket, reason) {
17
+ if (!index?.stats) return;
18
+ if (bucket === 'nearestFallbackReasons') index.stats.nearestFallbacks += 1;
19
+ if (bucket === 'pitFallbackReasons') index.stats.pitFallbacks += 1;
20
+ recordStat(index, bucket, reason);
21
+ }
22
+
23
+ export function recordStat(index, bucket, key) {
24
+ if (!index?.stats || !bucket || !key) return;
25
+ const target = index.stats[bucket] ?? {};
26
+ target[key] = (target[key] ?? 0) + 1;
27
+ index.stats[bucket] = target;
28
+ }
@@ -15,10 +15,32 @@ function forwardVector(car) {
15
15
  return { x: Math.cos(car.heading), y: Math.sin(car.heading) };
16
16
  }
17
17
 
18
+ function velocityVector(car) {
19
+ if (Number.isFinite(car.velocityX) && Number.isFinite(car.velocityY)) {
20
+ return { x: car.velocityX, y: car.velocityY };
21
+ }
22
+ const forward = forwardVector(car);
23
+ return { x: forward.x * car.speed, y: forward.y * car.speed };
24
+ }
25
+
18
26
  function dot(a, b) {
19
27
  return a.x * b.x + a.y * b.y;
20
28
  }
21
29
 
30
+ function applyVelocityVector(car, velocity) {
31
+ const speed = clamp(Math.hypot(velocity.x, velocity.y), 0, VEHICLE_LIMITS.maxSpeed);
32
+ if (speed <= 0) {
33
+ car.velocityX = 0;
34
+ car.velocityY = 0;
35
+ car.speed = 0;
36
+ return;
37
+ }
38
+ const scale = speed / Math.max(1e-9, Math.hypot(velocity.x, velocity.y));
39
+ car.velocityX = velocity.x * scale;
40
+ car.velocityY = velocity.y * scale;
41
+ car.speed = speed;
42
+ }
43
+
22
44
  export function resolveCollisionsForSimulation(sim) {
23
45
  const collidableCars = sim.cars.filter(isCollidable);
24
46
  if (collidableCars.length < 2) return;
@@ -57,8 +79,8 @@ export function resolveCollisionsForSimulation(sim) {
57
79
  }
58
80
 
59
81
  if (oneCarFixed) {
60
- if (!firstPitControlled) first.speed = clamp(first.speed * 0.985, 0, VEHICLE_LIMITS.maxSpeed);
61
- if (!secondPitControlled) second.speed = clamp(second.speed * 0.985, 0, VEHICLE_LIMITS.maxSpeed);
82
+ if (!firstPitControlled) dampPitContactVelocity(first, 0.985, sim.physicsMode === 'simulator');
83
+ if (!secondPitControlled) dampPitContactVelocity(second, 0.985, sim.physicsMode === 'simulator');
62
84
  } else {
63
85
  applyContactVelocityResponse(sim, first, second, collision.axis);
64
86
  }
@@ -99,6 +121,37 @@ export function resolveCollisionsForSimulation(sim) {
99
121
  }
100
122
 
101
123
  export function applyContactVelocityResponse(sim, first, second, axis) {
124
+ if (
125
+ sim.physicsMode === 'simulator' ||
126
+ Number.isFinite(first.velocityX) ||
127
+ Number.isFinite(second.velocityX)
128
+ ) {
129
+ const firstVelocity = velocityVector(first);
130
+ const secondVelocity = velocityVector(second);
131
+ const relativeNormalVelocity = dot({
132
+ x: secondVelocity.x - firstVelocity.x,
133
+ y: secondVelocity.y - firstVelocity.y,
134
+ }, axis);
135
+
136
+ if (relativeNormalVelocity < 0) {
137
+ const impulse = clamp(-relativeNormalVelocity * (0.34 + sim.rules.collisionRestitution), 0, 16);
138
+ firstVelocity.x -= axis.x * impulse;
139
+ firstVelocity.y -= axis.y * impulse;
140
+ secondVelocity.x += axis.x * impulse;
141
+ secondVelocity.y += axis.y * impulse;
142
+ }
143
+
144
+ applyVelocityVector(first, {
145
+ x: firstVelocity.x * 0.997,
146
+ y: firstVelocity.y * 0.997,
147
+ });
148
+ applyVelocityVector(second, {
149
+ x: secondVelocity.x * 0.997,
150
+ y: secondVelocity.y * 0.997,
151
+ });
152
+ return;
153
+ }
154
+
102
155
  const firstForward = forwardVector(first);
103
156
  const secondForward = forwardVector(second);
104
157
  const firstNormal = dot(firstForward, axis);
@@ -114,3 +167,15 @@ export function applyContactVelocityResponse(sim, first, second, axis) {
114
167
  first.speed = clamp(first.speed * 0.997, 0, VEHICLE_LIMITS.maxSpeed);
115
168
  second.speed = clamp(second.speed * 0.997, 0, VEHICLE_LIMITS.maxSpeed);
116
169
  }
170
+
171
+ function dampPitContactVelocity(car, factor, syncVelocity = false) {
172
+ if (syncVelocity || Number.isFinite(car.velocityX) || Number.isFinite(car.velocityY)) {
173
+ const velocity = velocityVector(car);
174
+ applyVelocityVector(car, {
175
+ x: velocity.x * factor,
176
+ y: velocity.y * factor,
177
+ });
178
+ return;
179
+ }
180
+ car.speed = clamp(car.speed * factor, 0, VEHICLE_LIMITS.maxSpeed);
181
+ }