@inventure71/paddockjs 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/INSTALL_AND_UPDATE.md +4 -3
  3. package/README.md +194 -12
  4. package/docs/architecture.md +119 -44
  5. package/docs/concepts.md +4 -2
  6. package/docs/custom_model_controller.md +329 -0
  7. package/docs/data_contract.md +299 -17
  8. package/docs/index.md +1 -0
  9. package/docs/learnings.md +22 -0
  10. package/docs/rules.md +37 -10
  11. package/docs/system_specs.md +23 -10
  12. package/docs/training.md +272 -30
  13. package/examples/python/README.md +62 -0
  14. package/examples/python/base_policy_server.py +191 -0
  15. package/examples/python/requirements.txt +2 -0
  16. package/package.json +12 -4
  17. package/src/api/PaddockSimulatorController.js +4 -0
  18. package/src/app/BrowserExpertAdapter.js +367 -9
  19. package/src/app/F1SimulatorApp.js +305 -1603
  20. package/src/app/banners/bannerFormatters.js +55 -0
  21. package/src/app/banners/raceDataBannerController.js +141 -0
  22. package/src/app/banners/stewardMessageController.js +99 -0
  23. package/src/app/camera/cameraConstants.js +21 -0
  24. package/src/app/camera/cameraController.js +265 -0
  25. package/src/app/camera/cameraGeometry.js +176 -0
  26. package/src/app/domBindings.js +4 -0
  27. package/src/app/readouts/carOverviewRenderer.js +109 -0
  28. package/src/app/readouts/controlStateRenderer.js +37 -0
  29. package/src/app/readouts/raceStatusRenderer.js +133 -0
  30. package/src/app/readouts/readoutFormatters.js +58 -0
  31. package/src/app/readouts/telemetryRenderer.js +113 -0
  32. package/src/app/readouts/timingTowerRenderer.js +177 -0
  33. package/src/app/rendering/appAssets.js +38 -0
  34. package/src/app/rendering/carRenderer.js +286 -0
  35. package/src/app/rendering/displayUtils.js +22 -0
  36. package/src/app/rendering/drsTrailRenderer.js +90 -0
  37. package/src/app/rendering/expertSensorRenderer.js +126 -0
  38. package/src/app/rendering/pitLaneStatusRenderer.js +50 -0
  39. package/src/app/rendering/replayGhostRenderer.js +124 -0
  40. package/src/app/rendering/trackRenderer.js +25 -0
  41. package/src/app/runtime/frameLoop.js +66 -0
  42. package/src/app/runtime/runtimeTiming.js +34 -0
  43. package/src/app/runtime/runtimeVisibility.js +45 -0
  44. package/src/config/defaultOptions.js +20 -0
  45. package/src/environment/actions.js +13 -5
  46. package/src/environment/controllerLoop.js +246 -0
  47. package/src/environment/episode.js +86 -2
  48. package/src/environment/evaluation.js +54 -32
  49. package/src/environment/index.d.ts +361 -9
  50. package/src/environment/index.js +1 -0
  51. package/src/environment/metrics.js +159 -0
  52. package/src/environment/observationOptions.js +22 -1
  53. package/src/environment/observations.js +325 -40
  54. package/src/environment/options.js +37 -5
  55. package/src/environment/runtime.js +222 -21
  56. package/src/environment/scenarios.js +37 -8
  57. package/src/environment/sensors/bodySenses.js +23 -0
  58. package/src/environment/sensors/boundarySenses.js +17 -0
  59. package/src/environment/sensors/carRays.js +75 -0
  60. package/src/environment/sensors/contactSenses.js +32 -0
  61. package/src/environment/sensors/index.js +157 -0
  62. package/src/environment/sensors/indexedRayBands.js +103 -0
  63. package/src/environment/sensors/nearbyCars.js +52 -0
  64. package/src/environment/sensors/opponentRadar.js +65 -0
  65. package/src/environment/sensors/rayConfig.js +110 -0
  66. package/src/environment/sensors/rayDefaults.js +6 -0
  67. package/src/environment/sensors/rayGeometry.js +60 -0
  68. package/src/environment/sensors/rayGuards.js +19 -0
  69. package/src/environment/sensors/sensorTargets.js +60 -0
  70. package/src/environment/sensors/surfaceRays.js +296 -0
  71. package/src/environment/sensors/trackRays.js +261 -0
  72. package/src/environment/sensors.js +1 -367
  73. package/src/environment/specs.js +100 -5
  74. package/src/environment/workerProtocol.js +13 -1
  75. package/src/index.d.ts +169 -6
  76. package/src/index.js +5 -0
  77. package/src/rendering/proceduralTrackAsset.js +20 -823
  78. package/src/rendering/renderSnapshot.js +38 -12
  79. package/src/rendering/track/finishLineRenderer.js +34 -0
  80. package/src/rendering/track/gridRenderer.js +47 -0
  81. package/src/rendering/track/offsetStrokeSafety.js +136 -0
  82. package/src/rendering/track/pitLaneRenderer.js +248 -0
  83. package/src/rendering/track/trackMaterialRenderer.js +326 -0
  84. package/src/rendering/track/trackRenderConstants.js +38 -0
  85. package/src/rendering/track/trackRenderGeometry.js +94 -0
  86. package/src/simulation/collisionGeometry.js +1 -1
  87. package/src/simulation/driver/driverControlConstants.js +28 -0
  88. package/src/simulation/driver/driverController.js +47 -0
  89. package/src/simulation/driver/driverInput.js +28 -0
  90. package/src/simulation/driver/driverMath.js +6 -0
  91. package/src/simulation/driver/driverPersonality.js +25 -0
  92. package/src/simulation/driver/edgeRecovery.js +48 -0
  93. package/src/simulation/driver/racingControls.js +399 -0
  94. package/src/simulation/driver/racingLinePlan.js +166 -0
  95. package/src/simulation/driver/recoveryDynamics.js +38 -0
  96. package/src/simulation/driver/rejoinControls.js +213 -0
  97. package/src/simulation/driver/safetyCarControls.js +35 -0
  98. package/src/simulation/driver/trafficScan.js +51 -0
  99. package/src/simulation/driverController.js +4 -619
  100. package/src/simulation/participants/participantInteractions.js +120 -0
  101. package/src/simulation/pit/pitFlow.js +234 -0
  102. package/src/simulation/pit/pitIntent.js +92 -0
  103. package/src/simulation/pit/pitOccupancy.js +47 -0
  104. package/src/simulation/pit/pitPenaltyService.js +71 -0
  105. package/src/simulation/pit/pitQueue.js +55 -0
  106. package/src/simulation/pit/pitRouteMovement.js +160 -0
  107. package/src/simulation/pit/pitRouting.js +319 -0
  108. package/src/simulation/pit/pitService.js +41 -0
  109. package/src/simulation/pit/pitServiceConstants.js +21 -0
  110. package/src/simulation/pit/pitServiceProfile.js +45 -0
  111. package/src/simulation/pit/pitSnapshots.js +61 -0
  112. package/src/simulation/pit/pitState.js +113 -0
  113. package/src/simulation/pit/pitTireService.js +92 -0
  114. package/src/simulation/race/classification.js +104 -0
  115. package/src/simulation/race/gridPenalties.js +37 -0
  116. package/src/simulation/race/raceControlState.js +38 -0
  117. package/src/simulation/race/raceDistance.js +32 -0
  118. package/src/simulation/race/raceFinish.js +65 -0
  119. package/src/simulation/race/raceLifecycle.js +71 -0
  120. package/src/simulation/race/raceOrder.js +113 -0
  121. package/src/simulation/race/raceProgress.js +124 -0
  122. package/src/simulation/race/raceSetup.js +93 -0
  123. package/src/simulation/race/raceStep.js +80 -0
  124. package/src/simulation/race/redFlag.js +15 -0
  125. package/src/simulation/race/retirements.js +44 -0
  126. package/src/simulation/race/safetyCar.js +86 -0
  127. package/src/simulation/race/stalledDnf.js +86 -0
  128. package/src/simulation/raceSimulation.js +230 -2817
  129. package/src/simulation/replay/replayGhosts.js +128 -0
  130. package/src/simulation/rules/moduleConfig.js +72 -0
  131. package/src/simulation/rules/penaltyConfig.js +99 -0
  132. package/src/simulation/rules/penaltyEvents.js +18 -0
  133. package/src/simulation/rules/penaltyStats.js +26 -0
  134. package/src/simulation/rules/ruleConfigMerge.js +41 -0
  135. package/src/simulation/rules/ruleDefaults.js +85 -0
  136. package/src/simulation/rules/rulesReview.js +141 -0
  137. package/src/simulation/rules/rulesetPresets.js +47 -0
  138. package/src/simulation/rules/trackLimitsSteward.js +1 -1
  139. package/src/simulation/rulesConfig.js +5 -323
  140. package/src/simulation/snapshots/raceSnapshots.js +192 -0
  141. package/src/simulation/timing/drsTiming.js +87 -0
  142. package/src/simulation/timing/gapEstimation.js +38 -0
  143. package/src/simulation/timing/lapTelemetry.js +182 -0
  144. package/src/simulation/timing/raceTiming.js +15 -0
  145. package/src/simulation/timing/sectorPerformance.js +60 -0
  146. package/src/simulation/timing/timingConstants.js +5 -0
  147. package/src/simulation/timing/timingHistory.js +46 -0
  148. package/src/simulation/timing/timingLines.js +79 -0
  149. package/src/simulation/track/centerlineSpline.js +59 -0
  150. package/src/simulation/track/drsZones.js +67 -0
  151. package/src/simulation/track/pitLaneAccess.js +103 -0
  152. package/src/simulation/track/pitLaneLayout.js +395 -0
  153. package/src/simulation/track/pitLaneState.js +160 -0
  154. package/src/simulation/track/proceduralCenterline.js +237 -0
  155. package/src/simulation/track/sampleGeometry.js +49 -0
  156. package/src/simulation/track/spatialQueries.js +154 -0
  157. package/src/simulation/track/startStraight.js +141 -0
  158. package/src/simulation/track/trackConstants.js +109 -0
  159. package/src/simulation/track/trackGenerationOptions.js +283 -0
  160. package/src/simulation/track/trackMath.js +241 -0
  161. package/src/simulation/track/trackModel.js +240 -0
  162. package/src/simulation/track/trackQueryIndex.js +945 -0
  163. package/src/simulation/track/trackSectors.js +19 -0
  164. package/src/simulation/track/trackStatePolicy.js +24 -0
  165. package/src/simulation/track/trackValidation.js +98 -0
  166. package/src/simulation/trackModel.js +1 -1706
  167. package/src/simulation/vehicle/contactResolution.js +116 -0
  168. package/src/simulation/vehicle/mainTrackWheelSurface.js +82 -0
  169. package/src/simulation/vehicle/pitWheelSurface.js +69 -0
  170. package/src/simulation/vehicle/runoffResponse.js +88 -0
  171. package/src/simulation/vehicle/surfacePriority.js +28 -0
  172. package/src/simulation/vehicle/vehicleGeometry.js +247 -0
  173. package/src/simulation/vehicle/vehiclePhysics.js +363 -0
  174. package/src/simulation/vehicle/vehicleSnapshots.js +316 -0
  175. package/src/simulation/vehicle/vehicleState.js +186 -0
  176. package/src/simulation/vehicle/wheelSurface.js +150 -0
  177. package/src/simulation/vehicleGeometry.js +1 -247
  178. package/src/simulation/vehiclePhysics.js +1 -136
  179. package/src/simulation/warmup/runtimeWarmup.js +224 -0
  180. package/src/simulation/wheelSurface.js +1 -338
  181. package/src/styles.css +42 -0
  182. package/src/ui/cameraControlsTemplate.js +20 -0
  183. package/src/ui/carOverviewTemplate.js +33 -0
  184. package/src/ui/componentTemplates.js +14 -360
  185. package/src/ui/raceCanvasTemplate.js +69 -0
  186. package/src/ui/raceControlsTemplate.js +36 -0
  187. package/src/ui/shellTemplate.js +5 -4
  188. package/src/ui/telemetryDrawerTemplate.js +55 -0
  189. package/src/ui/telemetryTemplates.js +136 -0
  190. package/src/ui/timingTowerTemplate.js +34 -0
@@ -1,1706 +1 @@
1
- import { clamp, createMulberry32, normalizeAngle, seededRange, wrapDistance } from './simMath.js';
2
- import { metersToSimUnits } from './units.js';
3
-
4
- export const WORLD = {
5
- width: metersToSimUnits(3550),
6
- height: metersToSimUnits(2148),
7
- };
8
-
9
- export const TRACK = {
10
- name: 'Apex Harbor GP',
11
- width: metersToSimUnits(15),
12
- kerbWidth: metersToSimUnits(1.5),
13
- gravelWidth: metersToSimUnits(12),
14
- runoffWidth: metersToSimUnits(20),
15
- sampleCount: 3600,
16
- drsZones: [
17
- { id: 'main-straight', startRatio: 0.02, endRatio: 0.18 },
18
- { id: 'back-straight', startRatio: 0.43, endRatio: 0.60 },
19
- { id: 'harbor-straight', startRatio: 0.82, endRatio: 0.96 },
20
- ],
21
- };
22
-
23
- const GENERATED_TRACK_MIN_LENGTH = metersToSimUnits(3600);
24
- const GENERATED_TRACK_MAX_LENGTH = metersToSimUnits(9000);
25
- const GENERATED_TRACK_ATTEMPTS = 24;
26
- const GENERATED_FALLBACK_ATTEMPTS = 8;
27
- const TRACK_BOUNDARY_PADDING = metersToSimUnits(248);
28
- const MIN_NON_ADJACENT_ARC_DISTANCE = metersToSimUnits(700);
29
- const MIN_TRACK_CLEARANCE_MULTIPLIER = 1.55;
30
- const MIN_TRACK_SHAPE_VARIATION = 0.28;
31
- const MAX_LOCAL_TURN_RADIANS = 1.42;
32
- const MAX_SAMPLE_HEADING_DELTA_RADIANS = 0.08;
33
- const START_STRAIGHT_GRID_LENGTH = metersToSimUnits(496);
34
- const START_STRAIGHT_EXIT_LENGTH = metersToSimUnits(500);
35
- const START_STRAIGHT_LOCK_EXTRA = metersToSimUnits(43);
36
- const START_STRAIGHT_BLEND_LENGTH = metersToSimUnits(320);
37
- const NEAREST_HINT_WINDOW_SAMPLES = 240;
38
- const PIT_LANE_WIDTH = metersToSimUnits(12);
39
- const PIT_LANE_EDGE_GAP = metersToSimUnits(16);
40
- const PIT_ACCESS_MIN_LENGTH = metersToSimUnits(70);
41
- const PIT_ACCESS_MAX_LENGTH = metersToSimUnits(150);
42
- const PIT_ACCESS_TANGENT_RATIO = 0.72;
43
- const PIT_ACCESS_SAMPLE_STEPS = 24;
44
- const PIT_ACCESS_TRACK_OVERLAP = PIT_LANE_WIDTH * 0.52;
45
- const PIT_ACCESS_SEARCH_STEP = metersToSimUnits(8);
46
- const PIT_ENTRY_SEARCH_BEFORE = metersToSimUnits(230);
47
- const PIT_ENTRY_SEARCH_AFTER = metersToSimUnits(250);
48
- const PIT_EXIT_SEARCH_BEFORE = metersToSimUnits(60);
49
- const PIT_EXIT_SEARCH_AFTER = metersToSimUnits(560);
50
- const PIT_TEAM_COUNT = 10;
51
- const PIT_BOXES_PER_TEAM = 2;
52
- const PIT_BOX_COUNT = PIT_TEAM_COUNT * PIT_BOXES_PER_TEAM;
53
- const PIT_BOX_LENGTH = metersToSimUnits(9);
54
- const PIT_BOX_DEPTH = metersToSimUnits(4.5);
55
- const PIT_BOX_PAIR_GAP = metersToSimUnits(4);
56
- const PIT_TEAM_GAP = metersToSimUnits(14);
57
- const PIT_BOX_TO_LANE_GAP = metersToSimUnits(1.5);
58
- const PIT_WORKING_LANE_GAP = metersToSimUnits(1.5);
59
- const PIT_WORKING_LANE_WIDTH = metersToSimUnits(8);
60
- const PIT_SERVICE_AREA_LENGTH = metersToSimUnits(11);
61
- const PIT_SERVICE_AREA_DEPTH = metersToSimUnits(5);
62
- const PIT_SERVICE_QUEUE_GAP = metersToSimUnits(12);
63
- const PIT_LANE_ENTRY_BUFFER = metersToSimUnits(44);
64
- const PIT_LANE_EXIT_BUFFER = metersToSimUnits(44);
65
- const PIT_LANE_FINISH_RATIO = 0.64;
66
- const PIT_WORLD_PADDING = metersToSimUnits(24);
67
- const PIT_TRACK_CLEARANCE_MARGIN = metersToSimUnits(5);
68
- const PIT_LANE_OFFSET_SEARCH_STEP = metersToSimUnits(12);
69
- const PROCEDURAL_TRACK_TEMPLATES = [
70
- [
71
- [0.08, 0.55], [0.10, 0.80], [0.22, 0.89], [0.42, 0.84], [0.52, 0.93],
72
- [0.60, 0.75], [0.74, 0.88], [0.91, 0.76], [0.94, 0.54], [0.82, 0.46],
73
- [0.94, 0.28], [0.78, 0.18], [0.62, 0.31], [0.54, 0.13], [0.43, 0.30],
74
- [0.33, 0.17], [0.20, 0.24], [0.12, 0.38], [0.22, 0.48], [0.13, 0.50],
75
- ],
76
- [
77
- [0.07, 0.46], [0.14, 0.72], [0.25, 0.83], [0.39, 0.73], [0.47, 0.88],
78
- [0.56, 0.70], [0.67, 0.82], [0.88, 0.84], [0.95, 0.63], [0.83, 0.56],
79
- [0.92, 0.43], [0.79, 0.35], [0.88, 0.20], [0.68, 0.15], [0.58, 0.29],
80
- [0.47, 0.18], [0.34, 0.30], [0.21, 0.19], [0.10, 0.28], [0.17, 0.39],
81
- ],
82
- [
83
- [0.06, 0.61], [0.13, 0.86], [0.31, 0.91], [0.43, 0.79], [0.57, 0.87],
84
- [0.71, 0.70], [0.92, 0.72], [0.95, 0.50], [0.84, 0.43], [0.91, 0.32],
85
- [0.74, 0.24], [0.69, 0.11], [0.54, 0.18], [0.45, 0.08], [0.35, 0.22],
86
- [0.23, 0.17], [0.11, 0.31], [0.24, 0.43], [0.15, 0.52], [0.28, 0.60],
87
- ],
88
- ];
89
- const PROCEDURAL_TRACK_CACHE = new Map();
90
- const TRACK_MODEL_CACHE = new WeakMap();
91
-
92
- const CENTERLINE_CONTROLS = [
93
- { x: WORLD.width * 0.05, y: WORLD.height * 0.56 },
94
- { x: WORLD.width * 0.10, y: WORLD.height * 0.81 },
95
- { x: WORLD.width * 0.23, y: WORLD.height * 0.91 },
96
- { x: WORLD.width * 0.35, y: WORLD.height * 0.80 },
97
- { x: WORLD.width * 0.48, y: WORLD.height * 0.90 },
98
- { x: WORLD.width * 0.59, y: WORLD.height * 0.75 },
99
- { x: WORLD.width * 0.71, y: WORLD.height * 0.87 },
100
- { x: WORLD.width * 0.82, y: WORLD.height * 0.72 },
101
- { x: WORLD.width * 0.94, y: WORLD.height * 0.66 },
102
- { x: WORLD.width * 0.96, y: WORLD.height * 0.47 },
103
- { x: WORLD.width * 0.88, y: WORLD.height * 0.33 },
104
- { x: WORLD.width * 0.74, y: WORLD.height * 0.31 },
105
- { x: WORLD.width * 0.64, y: WORLD.height * 0.18 },
106
- { x: WORLD.width * 0.54, y: WORLD.height * 0.31 },
107
- { x: WORLD.width * 0.43, y: WORLD.height * 0.13 },
108
- { x: WORLD.width * 0.29, y: WORLD.height * 0.18 },
109
- { x: WORLD.width * 0.17, y: WORLD.height * 0.31 },
110
- { x: WORLD.width * 0.08, y: WORLD.height * 0.43 },
111
- ];
112
-
113
- function uniformCatmullRom(p0, p1, p2, p3, t) {
114
- const t2 = t * t;
115
- const t3 = t2 * t;
116
- return {
117
- x: 0.5 * (
118
- (2 * p1.x) +
119
- (-p0.x + p2.x) * t +
120
- (2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * t2 +
121
- (-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * t3
122
- ),
123
- y: 0.5 * (
124
- (2 * p1.y) +
125
- (-p0.y + p2.y) * t +
126
- (2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * t2 +
127
- (-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * t3
128
- ),
129
- };
130
- }
131
-
132
- function centripetalCatmullRom(p0, p1, p2, p3, t) {
133
- const t0 = 0;
134
- const t1 = t0 + Math.sqrt(Math.max(distance(p0, p1), 0.001));
135
- const t2 = t1 + Math.sqrt(Math.max(distance(p1, p2), 0.001));
136
- const t3 = t2 + Math.sqrt(Math.max(distance(p2, p3), 0.001));
137
- const localT = t1 + (t2 - t1) * t;
138
-
139
- const interpolate = (a, b, start, end) => {
140
- const span = Math.max(end - start, 0.001);
141
- const amount = (localT - start) / span;
142
- return {
143
- x: a.x + (b.x - a.x) * amount,
144
- y: a.y + (b.y - a.y) * amount,
145
- };
146
- };
147
-
148
- const a1 = interpolate(p0, p1, t0, t1);
149
- const a2 = interpolate(p1, p2, t1, t2);
150
- const a3 = interpolate(p2, p3, t2, t3);
151
- const b1 = interpolate(a1, a2, t0, t2);
152
- const b2 = interpolate(a2, a3, t1, t3);
153
- return interpolate(b1, b2, t1, t2);
154
- }
155
-
156
- function normalizeSeed(seed) {
157
- if (Number.isFinite(seed)) return seed >>> 0;
158
- let hash = 2166136261;
159
- String(seed ?? 'f1-track').split('').forEach((character) => {
160
- hash ^= character.charCodeAt(0);
161
- hash = Math.imul(hash, 16777619);
162
- });
163
- return hash >>> 0;
164
- }
165
-
166
- function rawCenterPoint(ratio, controls = CENTERLINE_CONTROLS, interpolation = 'uniform') {
167
- const count = controls.length;
168
- const scaled = ratio * count;
169
- const index = Math.floor(scaled) % count;
170
- const localT = scaled - Math.floor(scaled);
171
- const p0 = controls[(index - 1 + count) % count];
172
- const p1 = controls[index];
173
- const p2 = controls[(index + 1) % count];
174
- const p3 = controls[(index + 2) % count];
175
- return interpolation === 'centripetal'
176
- ? centripetalCatmullRom(p0, p1, p2, p3, localT)
177
- : uniformCatmullRom(p0, p1, p2, p3, localT);
178
- }
179
-
180
- function distance(a, b) {
181
- return Math.hypot(a.x - b.x, a.y - b.y);
182
- }
183
-
184
- function orientation(a, b, c) {
185
- return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
186
- }
187
-
188
- function segmentBoxesOverlap(a, b, c, d) {
189
- return (
190
- Math.max(Math.min(a.x, b.x), Math.min(c.x, d.x)) <= Math.min(Math.max(a.x, b.x), Math.max(c.x, d.x)) &&
191
- Math.max(Math.min(a.y, b.y), Math.min(c.y, d.y)) <= Math.min(Math.max(a.y, b.y), Math.max(c.y, d.y))
192
- );
193
- }
194
-
195
- function segmentsIntersect(a, b, c, d) {
196
- if (!segmentBoxesOverlap(a, b, c, d)) return false;
197
- const abC = orientation(a, b, c);
198
- const abD = orientation(a, b, d);
199
- const cdA = orientation(c, d, a);
200
- const cdB = orientation(c, d, b);
201
- return abC * abD < 0 && cdA * cdB < 0;
202
- }
203
-
204
- function hasSelfIntersections(samples) {
205
- const points = samples.slice(0, -1).filter((_, index) => index % 12 === 0);
206
- for (let first = 0; first < points.length - 1; first += 1) {
207
- for (let second = first + 2; second < points.length - 1; second += 1) {
208
- const sharesLoopClosure = first === 0 && second >= points.length - 3;
209
- if (sharesLoopClosure) continue;
210
- if (segmentsIntersect(points[first], points[first + 1], points[second], points[second + 1])) return true;
211
- }
212
- }
213
- return false;
214
- }
215
-
216
- function samplesStayInsideWorld(samples) {
217
- return samples.every((sample) => (
218
- sample.x >= TRACK_BOUNDARY_PADDING &&
219
- sample.x <= WORLD.width - TRACK_BOUNDARY_PADDING &&
220
- sample.y >= TRACK_BOUNDARY_PADDING &&
221
- sample.y <= WORLD.height - TRACK_BOUNDARY_PADDING
222
- ));
223
- }
224
-
225
- function hasEnoughTrackClearance(samples, totalLength, minimumClearance) {
226
- const points = samples.slice(0, -1).filter((_, index) => index % 24 === 0);
227
- const minimumClearanceSquared = minimumClearance * minimumClearance;
228
- for (let first = 0; first < points.length; first += 1) {
229
- for (let second = first + 1; second < points.length; second += 1) {
230
- const arcDistance = Math.abs(points[second].distance - points[first].distance);
231
- const loopDistance = Math.min(arcDistance, totalLength - arcDistance);
232
- if (loopDistance < MIN_NON_ADJACENT_ARC_DISTANCE) continue;
233
- const dx = points[first].x - points[second].x;
234
- const dy = points[first].y - points[second].y;
235
- if (dx * dx + dy * dy < minimumClearanceSquared) return false;
236
- }
237
- }
238
- return true;
239
- }
240
-
241
- function hasReasonableTurnSharpness(samples) {
242
- const usableSamples = samples.slice(0, -1);
243
- const windows = [30, 36];
244
- const step = 6;
245
-
246
- for (let index = 0; index < usableSamples.length; index += 1) {
247
- const current = usableSamples[index];
248
- const next = usableSamples[(index + 1) % usableSamples.length];
249
- if (Math.abs(normalizeAngle(next.heading - current.heading)) > MAX_SAMPLE_HEADING_DELTA_RADIANS) {
250
- return false;
251
- }
252
- }
253
-
254
- for (let index = 0; index < usableSamples.length; index += step) {
255
- for (const window of windows) {
256
- let accumulatedTurn = 0;
257
- for (let offset = 0; offset < window; offset += step) {
258
- const current = usableSamples[(index + offset) % usableSamples.length];
259
- const next = usableSamples[(index + offset + step) % usableSamples.length];
260
- accumulatedTurn += Math.abs(normalizeAngle(next.heading - current.heading));
261
- }
262
- if (accumulatedTurn > MAX_LOCAL_TURN_RADIANS) return false;
263
- }
264
- }
265
-
266
- return true;
267
- }
268
-
269
- function hasEnoughShapeVariation(controls) {
270
- const center = { x: WORLD.width / 2, y: WORLD.height / 2 };
271
- const radii = controls.map((point) => distance(point, center));
272
- const mean = radii.reduce((total, radius) => total + radius, 0) / Math.max(1, radii.length);
273
- if (mean <= 0) return false;
274
- const variance = radii.reduce((total, radius) => total + (radius - mean) ** 2, 0) / Math.max(1, radii.length);
275
- return Math.sqrt(variance) / mean > MIN_TRACK_SHAPE_VARIATION;
276
- }
277
-
278
- function distanceForwardAlongTrack(from, to, totalLength) {
279
- return to >= from ? to - from : totalLength - from + to;
280
- }
281
-
282
- function recalculateSampleGeometry(samples) {
283
- const usableCount = samples.length - 1;
284
- for (let index = 0; index < usableCount; index += 1) {
285
- const sample = samples[index];
286
- const previous = samples[(index - 1 + usableCount) % usableCount];
287
- const next = samples[(index + 1) % usableCount];
288
- const nextNext = samples[(index + 2) % usableCount];
289
- const heading = Math.atan2(next.y - previous.y, next.x - previous.x);
290
- const nextHeading = Math.atan2(nextNext.y - sample.y, nextNext.x - sample.x);
291
- sample.heading = heading;
292
- sample.normalX = -Math.sin(heading);
293
- sample.normalY = Math.cos(heading);
294
- sample.curvature = Math.abs(normalizeAngle(nextHeading - heading)) / 28;
295
- }
296
-
297
- samples[usableCount] = {
298
- ...samples[0],
299
- distance: samples[usableCount].distance,
300
- };
301
-
302
- return samples;
303
- }
304
-
305
- function rebuildSampleDistances(samples) {
306
- const usable = samples.slice(0, -1).map((sample) => ({ ...sample }));
307
- let rebuiltLength = 0;
308
-
309
- usable.forEach((sample, index) => {
310
- if (index > 0) rebuiltLength += distance(usable[index - 1], sample);
311
- sample.distance = rebuiltLength;
312
- });
313
-
314
- rebuiltLength += distance(usable.at(-1), usable[0]);
315
- const rebuilt = [
316
- ...usable,
317
- {
318
- ...usable[0],
319
- distance: rebuiltLength,
320
- },
321
- ];
322
-
323
- return {
324
- samples: recalculateSampleGeometry(rebuilt),
325
- totalLength: rebuiltLength,
326
- };
327
- }
328
-
329
- function smoothstep(value) {
330
- const amount = clamp(value, 0, 1);
331
- return amount * amount * (3 - 2 * amount);
332
- }
333
-
334
- function blendPoint(original, target, amount) {
335
- return {
336
- ...original,
337
- x: original.x + (target.x - original.x) * amount,
338
- y: original.y + (target.y - original.y) * amount,
339
- };
340
- }
341
-
342
- function straightenStandingStartSamples(samples, totalLength) {
343
- const start = samples[0];
344
- const forward = {
345
- x: Math.cos(start.heading),
346
- y: Math.sin(start.heading),
347
- };
348
- const gridLockLength = START_STRAIGHT_GRID_LENGTH + START_STRAIGHT_LOCK_EXTRA;
349
- const exitLockLength = START_STRAIGHT_EXIT_LENGTH + START_STRAIGHT_LOCK_EXTRA;
350
-
351
- const straightened = samples.slice(0, -1).map((sample) => {
352
- const distanceFromStart = sample.distance;
353
- const distanceToStart = totalLength - sample.distance;
354
- let lineDistance = null;
355
- let blendAmount = 0;
356
-
357
- if (distanceFromStart <= exitLockLength + START_STRAIGHT_BLEND_LENGTH) {
358
- lineDistance = distanceFromStart;
359
- blendAmount = distanceFromStart <= exitLockLength
360
- ? 1
361
- : 1 - smoothstep((distanceFromStart - exitLockLength) / START_STRAIGHT_BLEND_LENGTH);
362
- } else if (distanceToStart <= gridLockLength + START_STRAIGHT_BLEND_LENGTH) {
363
- lineDistance = -distanceToStart;
364
- blendAmount = distanceToStart <= gridLockLength
365
- ? 1
366
- : 1 - smoothstep((distanceToStart - gridLockLength) / START_STRAIGHT_BLEND_LENGTH);
367
- }
368
-
369
- if (lineDistance == null || blendAmount <= 0) return { ...sample };
370
-
371
- return blendPoint(sample, {
372
- ...sample,
373
- x: start.x + forward.x * lineDistance,
374
- y: start.y + forward.y * lineDistance,
375
- }, blendAmount);
376
- });
377
-
378
- return rebuildSampleDistances([
379
- ...straightened,
380
- {
381
- ...straightened[0],
382
- distance: totalLength,
383
- },
384
- ]);
385
- }
386
-
387
- function chooseStandingStartIndex(samples, totalLength) {
388
- const usableSamples = samples.slice(0, -1);
389
- let best = { index: 0, score: Infinity };
390
-
391
- for (let index = 0; index < usableSamples.length; index += 1) {
392
- const candidate = usableSamples[index];
393
- const candidateForward = {
394
- x: Math.cos(candidate.heading),
395
- y: Math.sin(candidate.heading),
396
- };
397
- const projectedGridEnd = {
398
- x: candidate.x - candidateForward.x * START_STRAIGHT_GRID_LENGTH,
399
- y: candidate.y - candidateForward.y * START_STRAIGHT_GRID_LENGTH,
400
- };
401
- const projectedExitEnd = {
402
- x: candidate.x + candidateForward.x * START_STRAIGHT_EXIT_LENGTH,
403
- y: candidate.y + candidateForward.y * START_STRAIGHT_EXIT_LENGTH,
404
- };
405
- const projectedClearance = Math.min(pointWorldClearance(projectedGridEnd), pointWorldClearance(projectedExitEnd));
406
- if (projectedClearance < 0) continue;
407
-
408
- let gridTurn = 0;
409
- let gridCurvature = 0;
410
- let gridCount = 0;
411
- let exitTurn = 0;
412
- let exitCurvature = 0;
413
- let exitCount = 0;
414
-
415
- for (let offset = 1; offset < usableSamples.length; offset += 1) {
416
- const sample = usableSamples[(index - offset + usableSamples.length) % usableSamples.length];
417
- const distanceToLine = distanceForwardAlongTrack(sample.distance, candidate.distance, totalLength);
418
- if (distanceToLine > START_STRAIGHT_GRID_LENGTH) break;
419
- gridTurn = Math.max(gridTurn, Math.abs(normalizeAngle(candidate.heading - sample.heading)));
420
- gridCurvature += sample.curvature;
421
- gridCount += 1;
422
- }
423
-
424
- for (let offset = 1; offset < usableSamples.length; offset += 1) {
425
- const sample = usableSamples[(index + offset) % usableSamples.length];
426
- const distanceFromLine = distanceForwardAlongTrack(candidate.distance, sample.distance, totalLength);
427
- if (distanceFromLine > START_STRAIGHT_EXIT_LENGTH) break;
428
- exitTurn = Math.max(exitTurn, Math.abs(normalizeAngle(sample.heading - candidate.heading)));
429
- exitCurvature += sample.curvature;
430
- exitCount += 1;
431
- }
432
-
433
- const score =
434
- gridTurn * 5 +
435
- exitTurn * 1.2 +
436
- (gridCurvature / Math.max(1, gridCount)) * 2600 +
437
- (exitCurvature / Math.max(1, exitCount)) * 650 -
438
- projectedClearance * 0.002;
439
-
440
- if (score < best.score) best = { index, score };
441
- }
442
-
443
- return best.index;
444
- }
445
-
446
- function rotateSamplesToStandingStart(samples, totalLength) {
447
- const usableSamples = samples.slice(0, -1);
448
- const startIndex = chooseStandingStartIndex(samples, totalLength);
449
- const startDistance = usableSamples[startIndex].distance;
450
-
451
- const rotated = [
452
- ...usableSamples.slice(startIndex),
453
- ...usableSamples.slice(0, startIndex),
454
- ].map((sample) => ({
455
- ...sample,
456
- distance: distanceForwardAlongTrack(startDistance, sample.distance, totalLength),
457
- }));
458
-
459
- return [
460
- ...rotated,
461
- {
462
- ...rotated[0],
463
- distance: totalLength,
464
- },
465
- ];
466
- }
467
-
468
- function makeTemplatePoint([x, y], random, index) {
469
- const edgeDistance = Math.min(x, 1 - x, y, 1 - y);
470
- const jitter = edgeDistance < 0.13 ? 0.014 : 0.03;
471
- const chicaneNudge = index % 5 === 2 ? seededRange(random, -0.024, 0.024) : 0;
472
-
473
- return {
474
- x: clamp(x + seededRange(random, -jitter, jitter) + chicaneNudge, 0.06, 0.94),
475
- y: clamp(y + seededRange(random, -jitter, jitter) - chicaneNudge * 0.55, 0.08, 0.92),
476
- };
477
- }
478
-
479
- function applySectorMorph(points, random) {
480
- const center = { x: 0.5, y: 0.5 };
481
- const horizontalBias = seededRange(random, -0.035, 0.035);
482
- const verticalBias = seededRange(random, -0.035, 0.035);
483
- const squeeze = seededRange(random, 0.94, 1.06);
484
- const stretch = seededRange(random, 0.93, 1.07);
485
- const layoutScale = seededRange(random, 0.76, 0.84);
486
-
487
- return points.map((point, index) => {
488
- const turnComplex = index % 4 === 1 ? seededRange(random, -0.024, 0.024) : 0;
489
- return {
490
- x: clamp(center.x + (point.x - center.x) * stretch * layoutScale + horizontalBias + turnComplex, 0.06, 0.94),
491
- y: clamp(center.y + (point.y - center.y) * squeeze * layoutScale + verticalBias - turnComplex * 0.4, 0.08, 0.92),
492
- };
493
- });
494
- }
495
-
496
- function normalizedToWorld(point) {
497
- const usableWidth = WORLD.width - TRACK_BOUNDARY_PADDING * 2;
498
- const usableHeight = WORLD.height - TRACK_BOUNDARY_PADDING * 2;
499
- return {
500
- x: TRACK_BOUNDARY_PADDING + point.x * usableWidth,
501
- y: TRACK_BOUNDARY_PADDING + point.y * usableHeight,
502
- };
503
- }
504
-
505
- function rotateControls(controls, random) {
506
- const rotation = Math.floor(seededRange(random, 0, controls.length));
507
- return [...controls.slice(rotation), ...controls.slice(0, rotation)];
508
- }
509
-
510
- function strengthenShapeVariation(controls) {
511
- const center = { x: WORLD.width / 2, y: WORLD.height / 2 };
512
- const radii = controls.map((point) => distance(point, center));
513
- const mean = radii.reduce((total, radius) => total + radius, 0) / Math.max(1, radii.length);
514
- const variation = mean > 0
515
- ? Math.sqrt(radii.reduce((total, radius) => total + (radius - mean) ** 2, 0) / Math.max(1, radii.length)) / mean
516
- : 0;
517
- if (variation > MIN_TRACK_SHAPE_VARIATION + 0.015) return controls;
518
-
519
- return controls.map((point) => {
520
- const radius = distance(point, center);
521
- if (radius <= 0) return point;
522
- const amount = mean + (radius - mean) * 1.18;
523
- const scale = amount / radius;
524
- return {
525
- x: clamp(center.x + (point.x - center.x) * scale, TRACK_BOUNDARY_PADDING, WORLD.width - TRACK_BOUNDARY_PADDING),
526
- y: clamp(center.y + (point.y - center.y) * scale, TRACK_BOUNDARY_PADDING, WORLD.height - TRACK_BOUNDARY_PADDING),
527
- };
528
- });
529
- }
530
-
531
- function generateCenterlineControls(seed) {
532
- const random = createMulberry32(seed);
533
- const template = PROCEDURAL_TRACK_TEMPLATES[Math.floor(seededRange(random, 0, PROCEDURAL_TRACK_TEMPLATES.length))]
534
- ?? PROCEDURAL_TRACK_TEMPLATES[0];
535
- const normalized = applySectorMorph(
536
- template.map((point, index) => makeTemplatePoint(point, random, index)),
537
- random,
538
- );
539
-
540
- return rotateControls(strengthenShapeVariation(normalized.map(normalizedToWorld)), random);
541
- }
542
-
543
- function generateFallbackCenterlineControls(seed) {
544
- const random = createMulberry32(seed ^ 0xa5a5a5a5);
545
- const template = PROCEDURAL_TRACK_TEMPLATES[(seed >>> 2) % PROCEDURAL_TRACK_TEMPLATES.length] ??
546
- PROCEDURAL_TRACK_TEMPLATES[0];
547
- const normalized = applySectorMorph(
548
- template.map((point, index) => makeTemplatePoint(point, random, index)),
549
- random,
550
- );
551
-
552
- return rotateControls(strengthenShapeVariation(normalized.map(normalizedToWorld)), random);
553
- }
554
-
555
- function generateSafeFallbackCenterlineControls(seed) {
556
- const center = { x: WORLD.width / 2, y: WORLD.height / 2 };
557
- const scale = 0.78 + ((seed >>> 3) % 4) * 0.02;
558
- const mirrorX = (seed & 1) === 1 ? -1 : 1;
559
- const mirrorY = (seed & 2) === 2 ? -1 : 1;
560
- const controls = CENTERLINE_CONTROLS.map((point) => ({
561
- x: center.x + (point.x - center.x) * scale * mirrorX,
562
- y: center.y + (point.y - center.y) * scale * mirrorY,
563
- }));
564
-
565
- return rotateControls(controls, createMulberry32(seed ^ 0x9e3779b9));
566
- }
567
-
568
- function normalizeDrsZone(zone, totalLength) {
569
- if (Number.isFinite(zone.start) && Number.isFinite(zone.end)) return zone;
570
- const start = zone.startRatio * totalLength;
571
- const rawEnd = zone.endRatio * totalLength;
572
- return {
573
- ...zone,
574
- start,
575
- end: rawEnd >= start ? rawEnd : rawEnd + totalLength,
576
- };
577
- }
578
-
579
- function createTrackSectors(totalLength) {
580
- return Array.from({ length: 3 }, (_, index) => {
581
- const startRatio = index / 3;
582
- const endRatio = (index + 1) / 3;
583
- const start = totalLength * startRatio;
584
- const end = totalLength * endRatio;
585
-
586
- return {
587
- index: index + 1,
588
- id: `s${index + 1}`,
589
- label: `S${index + 1}`,
590
- start,
591
- end,
592
- startRatio,
593
- endRatio,
594
- length: end - start,
595
- };
596
- });
597
- }
598
-
599
- function clonePoint(point) {
600
- return {
601
- x: point.x,
602
- y: point.y,
603
- heading: point.heading,
604
- distance: point.distance,
605
- };
606
- }
607
-
608
- function normalizeVector(vector) {
609
- const length = Math.hypot(vector.x, vector.y) || 1;
610
- return {
611
- x: vector.x / length,
612
- y: vector.y / length,
613
- length,
614
- };
615
- }
616
-
617
- function headingVector(heading) {
618
- return {
619
- x: Math.cos(heading),
620
- y: Math.sin(heading),
621
- };
622
- }
623
-
624
- function dotVectors(first, second) {
625
- return first.x * second.x + first.y * second.y;
626
- }
627
-
628
- function angleBetweenVectors(first, second) {
629
- const firstUnit = normalizeVector(first);
630
- const secondUnit = normalizeVector(second);
631
- return Math.acos(clamp(dotVectors(firstUnit, secondUnit), -1, 1));
632
- }
633
-
634
- function signedLateralOffsetToPoint(trackPoint, point) {
635
- return (point.x - trackPoint.x) * trackPoint.normalX + (point.y - trackPoint.y) * trackPoint.normalY;
636
- }
637
-
638
- function interpolatePitPoint(start, forward, distanceAlong) {
639
- return {
640
- x: start.x + forward.x * distanceAlong,
641
- y: start.y + forward.y * distanceAlong,
642
- };
643
- }
644
-
645
- function projectPitPoint(startPoint, forward, normal, distanceAlong, lateralOffset) {
646
- return {
647
- x: startPoint.x + forward.x * distanceAlong + normal.x * lateralOffset,
648
- y: startPoint.y + forward.y * distanceAlong + normal.y * lateralOffset,
649
- };
650
- }
651
-
652
- function pointWorldClearance(point) {
653
- return Math.min(
654
- point.x - PIT_WORLD_PADDING,
655
- WORLD.width - PIT_WORLD_PADDING - point.x,
656
- point.y - PIT_WORLD_PADDING,
657
- WORLD.height - PIT_WORLD_PADDING - point.y,
658
- );
659
- }
660
-
661
- function createStartStraightBasis(track) {
662
- const finish = pointAt(track, 0);
663
- return {
664
- finish,
665
- forward: {
666
- x: Math.cos(finish.heading),
667
- y: Math.sin(finish.heading),
668
- },
669
- normal: {
670
- x: finish.normalX,
671
- y: finish.normalY,
672
- },
673
- };
674
- }
675
-
676
- function getPitBoxRunLength({
677
- teamCount = PIT_TEAM_COUNT,
678
- boxesPerTeam = PIT_BOXES_PER_TEAM,
679
- boxLength = PIT_BOX_LENGTH,
680
- pairGap = PIT_BOX_PAIR_GAP,
681
- teamGap = PIT_TEAM_GAP,
682
- } = {}) {
683
- const boxCount = teamCount * boxesPerTeam;
684
- return (
685
- boxCount * boxLength +
686
- teamCount * Math.max(0, boxesPerTeam - 1) * pairGap +
687
- Math.max(0, teamCount - 1) * teamGap
688
- );
689
- }
690
-
691
- function createPitLaneLayout() {
692
- const runLength = getPitBoxRunLength();
693
- const length = runLength + PIT_LANE_ENTRY_BUFFER + PIT_LANE_EXIT_BUFFER;
694
- return {
695
- runLength,
696
- length,
697
- entryDistance: -length * PIT_LANE_FINISH_RATIO,
698
- exitDistance: length * (1 - PIT_LANE_FINISH_RATIO),
699
- entryBuffer: PIT_LANE_ENTRY_BUFFER,
700
- exitBuffer: PIT_LANE_EXIT_BUFFER,
701
- };
702
- }
703
-
704
- function createPitLaneEndpoints(track, side, laneOffset, layout = createPitLaneLayout()) {
705
- const basis = createStartStraightBasis(track);
706
- return {
707
- ...basis,
708
- start: projectPitPoint(basis.finish, basis.forward, basis.normal, layout.entryDistance, side * laneOffset),
709
- end: projectPitPoint(basis.finish, basis.forward, basis.normal, layout.exitDistance, side * laneOffset),
710
- };
711
- }
712
-
713
- function scorePitLanePlacement(track, side, laneOffset, layout) {
714
- const placement = createPitLaneEndpoints(track, side, laneOffset, layout);
715
- const entryLanePoint = placement.start;
716
- const exitLanePoint = placement.end;
717
- const worldCenter = { x: WORLD.width / 2, y: WORLD.height / 2 };
718
- const laneMidpoint = {
719
- x: (entryLanePoint.x + exitLanePoint.x) / 2,
720
- y: (entryLanePoint.y + exitLanePoint.y) / 2,
721
- };
722
- const outwardDistance = distance(laneMidpoint, worldCenter) - distance(placement.finish, worldCenter);
723
- const laneVector = normalizeVector({
724
- x: exitLanePoint.x - entryLanePoint.x,
725
- y: exitLanePoint.y - entryLanePoint.y,
726
- });
727
- const serviceNormal = normalizeVector({
728
- x: placement.normal.x * side,
729
- y: placement.normal.y * side,
730
- });
731
- const workingLaneOffset = PIT_LANE_WIDTH / 2 + PIT_WORKING_LANE_GAP + PIT_WORKING_LANE_WIDTH / 2;
732
- const boxLateral = workingLaneOffset + PIT_WORKING_LANE_WIDTH / 2 + PIT_BOX_TO_LANE_GAP + PIT_BOX_DEPTH / 2;
733
- let minimumClearance = Infinity;
734
- let minimumTrackClearance = Infinity;
735
-
736
- for (let index = 0; index <= 48; index += 1) {
737
- const amount = index / 48;
738
- const lanePoint = interpolatePitPoint(entryLanePoint, laneVector, laneVector.length * amount);
739
- const boxPoint = {
740
- x: lanePoint.x + serviceNormal.x * boxLateral,
741
- y: lanePoint.y + serviceNormal.y * boxLateral,
742
- };
743
- minimumClearance = Math.min(minimumClearance, pointWorldClearance(lanePoint), pointWorldClearance(boxPoint));
744
- minimumTrackClearance = Math.min(
745
- minimumTrackClearance,
746
- nearestTrackState(track, lanePoint).crossTrackError -
747
- (track.width / 2 + (track.kerbWidth ?? 0) + PIT_TRACK_CLEARANCE_MARGIN),
748
- );
749
- }
750
-
751
- return {
752
- side,
753
- laneOffset,
754
- worldClearance: minimumClearance,
755
- trackClearance: minimumTrackClearance,
756
- outwardDistance,
757
- score: minimumTrackClearance * 5 + minimumClearance + outwardDistance * 3,
758
- };
759
- }
760
-
761
- function choosePitLanePlacement(track, baseLaneOffset, layout) {
762
- const candidates = [];
763
- for (let offsetStep = 0; offsetStep <= 9; offsetStep += 1) {
764
- const laneOffset = baseLaneOffset + offsetStep * PIT_LANE_OFFSET_SEARCH_STEP;
765
- candidates.push(scorePitLanePlacement(track, -1, laneOffset, layout));
766
- candidates.push(scorePitLanePlacement(track, 1, laneOffset, layout));
767
- }
768
-
769
- const valid = candidates
770
- .filter((candidate) => candidate.worldClearance > 0 && candidate.trackClearance > 0)
771
- .sort((left, right) => (
772
- Number(right.outwardDistance > 0) - Number(left.outwardDistance > 0) ||
773
- right.trackClearance - left.trackClearance ||
774
- right.worldClearance - left.worldClearance ||
775
- left.laneOffset - right.laneOffset
776
- ));
777
- if (valid.length) return valid[0];
778
-
779
- return candidates.sort((left, right) => right.score - left.score)[0];
780
- }
781
-
782
- function createPitBoxCorners(center, forward, serviceNormal) {
783
- const halfLength = PIT_BOX_LENGTH / 2;
784
- const halfDepth = PIT_BOX_DEPTH / 2;
785
- return [
786
- {
787
- x: center.x + forward.x * halfLength + serviceNormal.x * halfDepth,
788
- y: center.y + forward.y * halfLength + serviceNormal.y * halfDepth,
789
- },
790
- {
791
- x: center.x - forward.x * halfLength + serviceNormal.x * halfDepth,
792
- y: center.y - forward.y * halfLength + serviceNormal.y * halfDepth,
793
- },
794
- {
795
- x: center.x - forward.x * halfLength - serviceNormal.x * halfDepth,
796
- y: center.y - forward.y * halfLength - serviceNormal.y * halfDepth,
797
- },
798
- {
799
- x: center.x + forward.x * halfLength - serviceNormal.x * halfDepth,
800
- y: center.y + forward.y * halfLength - serviceNormal.y * halfDepth,
801
- },
802
- ];
803
- }
804
-
805
- function createPitRectangleCorners(center, forward, serviceNormal, length, depth) {
806
- const halfLength = length / 2;
807
- const halfDepth = depth / 2;
808
- return [
809
- {
810
- x: center.x + forward.x * halfLength + serviceNormal.x * halfDepth,
811
- y: center.y + forward.y * halfLength + serviceNormal.y * halfDepth,
812
- },
813
- {
814
- x: center.x - forward.x * halfLength + serviceNormal.x * halfDepth,
815
- y: center.y - forward.y * halfLength + serviceNormal.y * halfDepth,
816
- },
817
- {
818
- x: center.x - forward.x * halfLength - serviceNormal.x * halfDepth,
819
- y: center.y - forward.y * halfLength - serviceNormal.y * halfDepth,
820
- },
821
- {
822
- x: center.x + forward.x * halfLength - serviceNormal.x * halfDepth,
823
- y: center.y + forward.y * halfLength - serviceNormal.y * halfDepth,
824
- },
825
- ];
826
- }
827
-
828
- function createPitBoxes({ laneStart, laneForward, laneLength, serviceNormal }) {
829
- const runLength = getPitBoxRunLength();
830
- let cursor = Math.max(PIT_SERVICE_QUEUE_GAP + PIT_BOX_LENGTH / 2, (laneLength - runLength) / 2);
831
- const workingLaneOffset = PIT_LANE_WIDTH / 2 + PIT_WORKING_LANE_GAP + PIT_WORKING_LANE_WIDTH / 2;
832
- const boxLateral = workingLaneOffset + PIT_WORKING_LANE_WIDTH / 2 + PIT_BOX_TO_LANE_GAP + PIT_BOX_DEPTH / 2;
833
- const boxes = [];
834
- const serviceAreas = [];
835
-
836
- for (let index = 0; index < PIT_BOX_COUNT; index += 1) {
837
- const distanceAlongLane = cursor + PIT_BOX_LENGTH / 2;
838
- const laneTarget = interpolatePitPoint(laneStart, laneForward, distanceAlongLane);
839
- const center = {
840
- x: laneTarget.x + serviceNormal.x * boxLateral,
841
- y: laneTarget.y + serviceNormal.y * boxLateral,
842
- };
843
- const teamIndex = Math.floor(index / PIT_BOXES_PER_TEAM);
844
- const teamBoxIndex = index % PIT_BOXES_PER_TEAM;
845
-
846
- boxes.push({
847
- id: `team-${teamIndex + 1}-box-${teamBoxIndex + 1}`,
848
- index,
849
- teamIndex,
850
- teamBoxIndex,
851
- distanceAlongLane,
852
- laneTarget,
853
- center,
854
- length: PIT_BOX_LENGTH,
855
- depth: PIT_BOX_DEPTH,
856
- corners: createPitBoxCorners(center, laneForward, serviceNormal),
857
- });
858
-
859
- if (teamBoxIndex === PIT_BOXES_PER_TEAM - 1) {
860
- const firstTeamBox = boxes[index - (PIT_BOXES_PER_TEAM - 1)];
861
- const serviceDistance = (firstTeamBox.distanceAlongLane + distanceAlongLane) / 2;
862
- const queueDistance = Math.max(PIT_SERVICE_QUEUE_GAP / 2, serviceDistance - PIT_SERVICE_QUEUE_GAP);
863
- const serviceCenter = projectPitPoint(
864
- laneStart,
865
- laneForward,
866
- serviceNormal,
867
- serviceDistance,
868
- workingLaneOffset,
869
- );
870
- const queuePoint = projectPitPoint(
871
- laneStart,
872
- laneForward,
873
- serviceNormal,
874
- queueDistance,
875
- workingLaneOffset,
876
- );
877
-
878
- serviceAreas.push({
879
- id: `team-${teamIndex + 1}-service`,
880
- index: teamIndex,
881
- teamIndex,
882
- distanceAlongLane: serviceDistance,
883
- queueDistanceAlongLane: queueDistance,
884
- laneTarget: serviceCenter,
885
- center: serviceCenter,
886
- queuePoint,
887
- length: PIT_SERVICE_AREA_LENGTH,
888
- depth: PIT_SERVICE_AREA_DEPTH,
889
- corners: createPitRectangleCorners(serviceCenter, laneForward, serviceNormal, PIT_SERVICE_AREA_LENGTH, PIT_SERVICE_AREA_DEPTH),
890
- queueCorners: createPitRectangleCorners(queuePoint, laneForward, serviceNormal, PIT_SERVICE_AREA_LENGTH * 0.72, PIT_SERVICE_AREA_DEPTH),
891
- garageBoxIds: boxes
892
- .slice(index - (PIT_BOXES_PER_TEAM - 1), index + 1)
893
- .map((box) => box.id),
894
- });
895
- }
896
-
897
- cursor += PIT_BOX_LENGTH;
898
- if (teamBoxIndex === 0) cursor += PIT_BOX_PAIR_GAP;
899
- else if (teamIndex < PIT_TEAM_COUNT - 1) cursor += PIT_TEAM_GAP;
900
- }
901
-
902
- return { boxes, serviceAreas, workingLaneOffset };
903
- }
904
-
905
- function sampleCubicBezier(p0, p1, p2, p3, steps) {
906
- return Array.from({ length: steps + 1 }, (_, index) => {
907
- const t = index / steps;
908
- const mt = 1 - t;
909
- const mt2 = mt * mt;
910
- const t2 = t * t;
911
- return {
912
- x: mt2 * mt * p0.x + 3 * mt2 * t * p1.x + 3 * mt * t2 * p2.x + t2 * t * p3.x,
913
- y: mt2 * mt * p0.y + 3 * mt2 * t * p1.y + 3 * mt * t2 * p2.y + t2 * t * p3.y,
914
- };
915
- });
916
- }
917
-
918
- function createPitAccessRoadCenterline(start, end, startForward, endForward, tangentLength) {
919
- return sampleCubicBezier(
920
- start,
921
- {
922
- x: start.x + startForward.x * tangentLength,
923
- y: start.y + startForward.y * tangentLength,
924
- },
925
- {
926
- x: end.x - endForward.x * tangentLength,
927
- y: end.y - endForward.y * tangentLength,
928
- },
929
- end,
930
- PIT_ACCESS_SAMPLE_STEPS,
931
- );
932
- }
933
-
934
- function expandBounds(bounds, point) {
935
- if (!point) return bounds;
936
- return {
937
- minX: Math.min(bounds.minX, point.x),
938
- maxX: Math.max(bounds.maxX, point.x),
939
- minY: Math.min(bounds.minY, point.y),
940
- maxY: Math.max(bounds.maxY, point.y),
941
- };
942
- }
943
-
944
- function createPointBounds(points, padding = 0) {
945
- const bounds = points.filter(Boolean).reduce(
946
- (current, point) => expandBounds(current, point),
947
- { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity },
948
- );
949
- if (!Number.isFinite(bounds.minX)) return null;
950
- return {
951
- minX: bounds.minX - padding,
952
- maxX: bounds.maxX + padding,
953
- minY: bounds.minY - padding,
954
- maxY: bounds.maxY + padding,
955
- };
956
- }
957
-
958
- function pointInsideBounds(point, bounds) {
959
- if (!bounds) return true;
960
- return (
961
- point.x >= bounds.minX &&
962
- point.x <= bounds.maxX &&
963
- point.y >= bounds.minY &&
964
- point.y <= bounds.maxY
965
- );
966
- }
967
-
968
- function createLaneFacingTrackConnection(track, distanceFromStart, lanePoint) {
969
- const trackPoint = pointAt(track, distanceFromStart);
970
- const laneSide = signedLateralOffsetToPoint(trackPoint, lanePoint) >= 0 ? 1 : -1;
971
- const edgePoint = offsetTrackPoint(trackPoint, laneSide * (track.width / 2));
972
- const trackConnectPoint = offsetTrackPoint(
973
- trackPoint,
974
- laneSide * Math.max(0, track.width / 2 - PIT_ACCESS_TRACK_OVERLAP),
975
- );
976
-
977
- return {
978
- distanceFromStart,
979
- trackDistance: wrapDistance(trackPoint.distance, track.length),
980
- trackPoint,
981
- edgePoint,
982
- trackConnectPoint,
983
- laneSide,
984
- };
985
- }
986
-
987
- function scorePitAccessConnection(track, connection, lanePoint, laneForward, direction) {
988
- const roadVector = direction === 'entry'
989
- ? normalizeVector({
990
- x: lanePoint.x - connection.trackConnectPoint.x,
991
- y: lanePoint.y - connection.trackConnectPoint.y,
992
- })
993
- : normalizeVector({
994
- x: connection.trackConnectPoint.x - lanePoint.x,
995
- y: connection.trackConnectPoint.y - lanePoint.y,
996
- });
997
- const trackForward = headingVector(connection.trackPoint.heading);
998
- const trackAngle = angleBetweenVectors(roadVector, trackForward);
999
- const laneAngle = angleBetweenVectors(roadVector, laneForward);
1000
- const laneFacingClearance = Math.abs(signedLateralOffsetToPoint(connection.trackPoint, lanePoint)) - track.width / 2;
1001
-
1002
- return {
1003
- ...connection,
1004
- roadVector,
1005
- trackAngle,
1006
- laneAngle,
1007
- pathLength: roadVector.length,
1008
- score:
1009
- trackAngle * 1100 +
1010
- laneAngle * 900 +
1011
- roadVector.length * 0.32 -
1012
- Math.max(0, laneFacingClearance) * 0.18,
1013
- };
1014
- }
1015
-
1016
- function findPitAccessConnection(track, lanePoint, laneForward, {
1017
- direction,
1018
- startDistance,
1019
- endDistance,
1020
- fallbackDistance,
1021
- }) {
1022
- const start = Math.min(startDistance, endDistance);
1023
- const end = Math.max(startDistance, endDistance);
1024
- let best = null;
1025
- let fallback = null;
1026
-
1027
- for (let distanceFromStart = start; distanceFromStart <= end; distanceFromStart += PIT_ACCESS_SEARCH_STEP) {
1028
- const connection = scorePitAccessConnection(
1029
- track,
1030
- createLaneFacingTrackConnection(track, distanceFromStart, lanePoint),
1031
- lanePoint,
1032
- laneForward,
1033
- direction,
1034
- );
1035
- fallback = !fallback || connection.score < fallback.score ? connection : fallback;
1036
- if (connection.trackAngle > 1.15 || connection.laneAngle > 1.15) continue;
1037
- best = !best || connection.score < best.score ? connection : best;
1038
- }
1039
-
1040
- if (best) return best;
1041
-
1042
- return fallback ?? scorePitAccessConnection(
1043
- track,
1044
- createLaneFacingTrackConnection(track, fallbackDistance, lanePoint),
1045
- lanePoint,
1046
- laneForward,
1047
- direction,
1048
- );
1049
- }
1050
-
1051
- function createPitLaneBounds(pitLane) {
1052
- const points = [
1053
- ...(pitLane.entry?.roadCenterline ?? []),
1054
- ...(pitLane.exit?.roadCenterline ?? []),
1055
- ...(pitLane.mainLane?.points ?? []),
1056
- ...(pitLane.workingLane?.points ?? []),
1057
- ...(pitLane.boxes ?? []).flatMap((box) => box.corners ?? []),
1058
- ...(pitLane.serviceAreas ?? []).flatMap((area) => [
1059
- ...(area.corners ?? []),
1060
- ...(area.queueCorners ?? []),
1061
- ]),
1062
- ];
1063
- const padding = Math.max(
1064
- pitLane.width ?? 0,
1065
- pitLane.workingLane?.width ?? 0,
1066
- PIT_BOX_DEPTH,
1067
- PIT_SERVICE_AREA_DEPTH,
1068
- ) / 2 + metersToSimUnits(3);
1069
- return createPointBounds(points, padding);
1070
- }
1071
-
1072
- function createPitLaneModel(track) {
1073
- const layout = createPitLaneLayout();
1074
- const laneOffset = track.width / 2 + (track.kerbWidth ?? 0) + PIT_LANE_EDGE_GAP + PIT_LANE_WIDTH / 2;
1075
- const placement = choosePitLanePlacement(track, laneOffset, layout);
1076
- const side = placement.side;
1077
- const placedLaneOffset = placement.laneOffset;
1078
- const startStraight = createPitLaneEndpoints(track, side, placedLaneOffset, layout);
1079
- const laneStart = startStraight.start;
1080
- const laneEnd = startStraight.end;
1081
- const laneVector = normalizeVector({
1082
- x: laneEnd.x - laneStart.x,
1083
- y: laneEnd.y - laneStart.y,
1084
- });
1085
- const lateralSpan = Math.max(0, placedLaneOffset - track.width / 2);
1086
- const accessLength = clamp(lateralSpan * 0.9, PIT_ACCESS_MIN_LENGTH, PIT_ACCESS_MAX_LENGTH);
1087
- const serviceNormal = normalizeVector({
1088
- x: startStraight.normal.x * side,
1089
- y: startStraight.normal.y * side,
1090
- });
1091
- const entryConnection = findPitAccessConnection(track, laneStart, laneVector, {
1092
- direction: 'entry',
1093
- startDistance: layout.entryDistance - PIT_ENTRY_SEARCH_BEFORE,
1094
- endDistance: layout.entryDistance + PIT_ENTRY_SEARCH_AFTER,
1095
- fallbackDistance: layout.entryDistance - accessLength,
1096
- });
1097
- const exitConnection = findPitAccessConnection(track, laneEnd, laneVector, {
1098
- direction: 'exit',
1099
- startDistance: layout.exitDistance - PIT_EXIT_SEARCH_BEFORE,
1100
- endDistance: layout.exitDistance + PIT_EXIT_SEARCH_AFTER,
1101
- fallbackDistance: layout.exitDistance + accessLength,
1102
- });
1103
- const entryTangentLength = clamp(
1104
- entryConnection.pathLength * PIT_ACCESS_TANGENT_RATIO,
1105
- PIT_LANE_WIDTH,
1106
- PIT_ACCESS_MAX_LENGTH,
1107
- );
1108
- const exitTangentLength = clamp(
1109
- exitConnection.pathLength * PIT_ACCESS_TANGENT_RATIO,
1110
- PIT_LANE_WIDTH,
1111
- PIT_ACCESS_MAX_LENGTH,
1112
- );
1113
- const entryMergePoint = interpolatePitPoint(laneStart, laneVector, Math.min(metersToSimUnits(86), laneVector.length * 0.16));
1114
- const exitMergePoint = interpolatePitPoint(laneEnd, laneVector, -Math.min(metersToSimUnits(86), laneVector.length * 0.16));
1115
-
1116
- const entryRoadCenterline = createPitAccessRoadCenterline(
1117
- entryConnection.trackConnectPoint,
1118
- laneStart,
1119
- headingVector(entryConnection.trackPoint.heading),
1120
- laneVector,
1121
- entryTangentLength,
1122
- );
1123
- const exitRoadCenterline = createPitAccessRoadCenterline(
1124
- laneEnd,
1125
- exitConnection.trackConnectPoint,
1126
- laneVector,
1127
- headingVector(exitConnection.trackPoint.heading),
1128
- exitTangentLength,
1129
- );
1130
- const { boxes, serviceAreas, workingLaneOffset } = createPitBoxes({
1131
- laneStart,
1132
- laneForward: laneVector,
1133
- laneLength: laneVector.length,
1134
- serviceNormal,
1135
- });
1136
- const workingLaneStart = projectPitPoint(laneStart, laneVector, serviceNormal, 0, workingLaneOffset);
1137
- const workingLaneEnd = projectPitPoint(laneStart, laneVector, serviceNormal, laneVector.length, workingLaneOffset);
1138
-
1139
- const pitLane = {
1140
- enabled: true,
1141
- side,
1142
- width: PIT_LANE_WIDTH,
1143
- offset: placedLaneOffset,
1144
- layout,
1145
- boxCount: PIT_BOX_COUNT,
1146
- teamCount: PIT_TEAM_COUNT,
1147
- boxesPerTeam: PIT_BOXES_PER_TEAM,
1148
- entry: {
1149
- trackDistance: entryConnection.trackDistance,
1150
- distanceFromStart: entryConnection.distanceFromStart,
1151
- trackPoint: clonePoint(entryConnection.trackPoint),
1152
- edgePoint: entryConnection.edgePoint,
1153
- trackConnectPoint: entryConnection.trackConnectPoint,
1154
- lanePoint: laneStart,
1155
- roadCenterline: entryRoadCenterline,
1156
- connector: [clonePoint(entryConnection.trackPoint), ...entryRoadCenterline, entryMergePoint],
1157
- },
1158
- exit: {
1159
- trackDistance: exitConnection.trackDistance,
1160
- distanceFromStart: exitConnection.distanceFromStart,
1161
- trackPoint: clonePoint(exitConnection.trackPoint),
1162
- edgePoint: exitConnection.edgePoint,
1163
- trackConnectPoint: exitConnection.trackConnectPoint,
1164
- lanePoint: laneEnd,
1165
- roadCenterline: exitRoadCenterline,
1166
- connector: [exitMergePoint, ...exitRoadCenterline, clonePoint(exitConnection.trackPoint)],
1167
- },
1168
- mainLane: {
1169
- start: laneStart,
1170
- end: laneEnd,
1171
- points: [laneStart, laneEnd],
1172
- length: laneVector.length,
1173
- heading: Math.atan2(laneVector.y, laneVector.x),
1174
- },
1175
- fastLane: {
1176
- offset: 0,
1177
- width: PIT_LANE_WIDTH,
1178
- },
1179
- workingLane: {
1180
- start: workingLaneStart,
1181
- end: workingLaneEnd,
1182
- points: [workingLaneStart, workingLaneEnd],
1183
- offset: workingLaneOffset,
1184
- width: PIT_WORKING_LANE_WIDTH,
1185
- },
1186
- serviceNormal,
1187
- boxes,
1188
- serviceAreas,
1189
- };
1190
- return {
1191
- ...pitLane,
1192
- bounds: createPitLaneBounds(pitLane),
1193
- };
1194
- }
1195
-
1196
- function scoreStraightWindow(samples, startIndex, windowSize) {
1197
- let curvature = 0;
1198
- for (let offset = 0; offset < windowSize; offset += 1) {
1199
- curvature += samples[(startIndex + offset) % (samples.length - 1)].curvature;
1200
- }
1201
- return curvature / windowSize;
1202
- }
1203
-
1204
- function deriveDrsZones(samples, totalLength) {
1205
- const usableSampleCount = samples.length - 1;
1206
- const windowSize = Math.max(96, Math.floor(usableSampleCount * 0.07));
1207
- const candidates = [];
1208
-
1209
- for (let index = 0; index < usableSampleCount; index += Math.floor(windowSize / 2)) {
1210
- const start = samples[index];
1211
- const end = samples[(index + windowSize) % usableSampleCount];
1212
- const distance = end.distance >= start.distance
1213
- ? end.distance - start.distance
1214
- : totalLength - start.distance + end.distance;
1215
- if (distance < 360) continue;
1216
- candidates.push({
1217
- startRatio: start.distance / totalLength,
1218
- endRatio: (start.distance + Math.min(distance, totalLength * 0.16)) / totalLength,
1219
- score: scoreStraightWindow(samples, index, windowSize),
1220
- });
1221
- }
1222
-
1223
- return candidates
1224
- .sort((a, b) => a.score - b.score)
1225
- .reduce((zones, candidate) => {
1226
- const farEnough = zones.every((zone) => Math.abs(zone.startRatio - candidate.startRatio) > 0.18);
1227
- if (farEnough && zones.length < 3) {
1228
- zones.push({
1229
- id: `generated-drs-${zones.length + 1}`,
1230
- startRatio: candidate.startRatio % 1,
1231
- endRatio: candidate.endRatio % 1,
1232
- });
1233
- }
1234
- return zones;
1235
- }, [])
1236
- .sort((a, b) => a.startRatio - b.startRatio);
1237
- }
1238
-
1239
- function isValidProceduralTrackModel(model) {
1240
- return (
1241
- model.length >= GENERATED_TRACK_MIN_LENGTH &&
1242
- model.length <= GENERATED_TRACK_MAX_LENGTH &&
1243
- hasEnoughShapeVariation(model.centerlineControls) &&
1244
- samplesStayInsideWorld(model.samples) &&
1245
- hasEnoughTrackClearance(model.samples, model.length, model.width * MIN_TRACK_CLEARANCE_MULTIPLIER) &&
1246
- hasReasonableTurnSharpness(model.samples) &&
1247
- !hasSelfIntersections(model.samples)
1248
- );
1249
- }
1250
-
1251
- export function buildTrackModel(track = TRACK) {
1252
- const cached = TRACK_MODEL_CACHE.get(track);
1253
- if (cached) return cached;
1254
-
1255
- const controls = track.centerlineControls ?? CENTERLINE_CONTROLS;
1256
- const base = [];
1257
- for (let index = 0; index <= track.sampleCount; index += 1) {
1258
- base.push(rawCenterPoint(index / track.sampleCount, controls, track.curveInterpolation));
1259
- }
1260
-
1261
- let totalLength = 0;
1262
- const samples = base.map((point, index) => {
1263
- if (index > 0) totalLength += distance(base[index - 1], point);
1264
- return { ...point, distance: totalLength, heading: 0, normalX: 0, normalY: 0, curvature: 0 };
1265
- });
1266
- recalculateSampleGeometry(samples);
1267
-
1268
- const rotatedSamples = rotateSamplesToStandingStart(samples, totalLength);
1269
- const straightenedTrack = straightenStandingStartSamples(rotatedSamples, totalLength);
1270
- const normalizedSamples = straightenedTrack.samples;
1271
- totalLength = straightenedTrack.totalLength;
1272
-
1273
- const model = {
1274
- ...track,
1275
- centerlineControls: controls,
1276
- length: totalLength,
1277
- samples: normalizedSamples,
1278
- sectors: createTrackSectors(totalLength),
1279
- drsZones: (track.drsZones ?? deriveDrsZones(normalizedSamples, totalLength))
1280
- .map((zone) => normalizeDrsZone(zone, totalLength)),
1281
- };
1282
- model.pitLane = createPitLaneModel(model);
1283
- TRACK_MODEL_CACHE.set(track, model);
1284
- return model;
1285
- }
1286
-
1287
- export function createProceduralTrack(seed = Date.now()) {
1288
- const normalizedSeed = normalizeSeed(seed);
1289
- const cached = PROCEDURAL_TRACK_CACHE.get(normalizedSeed);
1290
- if (cached) return cached;
1291
-
1292
- for (let attempt = 0; attempt < GENERATED_FALLBACK_ATTEMPTS; attempt += 1) {
1293
- const candidateSeed = (normalizedSeed + Math.imul(attempt, 2654435761)) >>> 0;
1294
- const candidate = {
1295
- ...TRACK,
1296
- name: `Generated GP ${candidateSeed.toString(36).toUpperCase().padStart(6, '0').slice(-6)}`,
1297
- seed: candidateSeed,
1298
- curveInterpolation: 'centripetal',
1299
- centerlineControls: generateCenterlineControls(candidateSeed),
1300
- drsZones: null,
1301
- };
1302
- const model = buildTrackModel(candidate);
1303
- if (isValidProceduralTrackModel(model)) {
1304
- const trackDefinition = {
1305
- ...candidate,
1306
- drsZones: model.drsZones.map(({ id, startRatio, endRatio }) => ({ id, startRatio, endRatio })),
1307
- };
1308
- PROCEDURAL_TRACK_CACHE.set(normalizedSeed, trackDefinition);
1309
- return trackDefinition;
1310
- }
1311
- }
1312
-
1313
- let fallback = null;
1314
- let fallbackModel = null;
1315
- for (let attempt = 0; attempt < GENERATED_TRACK_ATTEMPTS; attempt += 1) {
1316
- const candidateSeed = (normalizedSeed ^ Math.imul(attempt + 1, 2246822519)) >>> 0;
1317
- const candidate = {
1318
- ...TRACK,
1319
- name: `Generated GP ${normalizedSeed.toString(36).toUpperCase().padStart(6, '0').slice(-6)}`,
1320
- seed: candidateSeed,
1321
- curveInterpolation: 'centripetal',
1322
- centerlineControls: generateFallbackCenterlineControls(candidateSeed),
1323
- drsZones: null,
1324
- };
1325
- const model = buildTrackModel(candidate);
1326
- if (isValidProceduralTrackModel(model)) {
1327
- fallback = candidate;
1328
- fallbackModel = model;
1329
- break;
1330
- }
1331
- }
1332
-
1333
- if (!fallback || !fallbackModel) {
1334
- fallback = {
1335
- ...TRACK,
1336
- name: `Generated GP ${normalizedSeed.toString(36).toUpperCase().padStart(6, '0').slice(-6)}`,
1337
- seed: normalizedSeed,
1338
- curveInterpolation: 'centripetal',
1339
- centerlineControls: generateSafeFallbackCenterlineControls(normalizedSeed),
1340
- drsZones: null,
1341
- };
1342
- fallbackModel = buildTrackModel(fallback);
1343
- }
1344
- const fallbackDefinition = {
1345
- ...fallback,
1346
- drsZones: fallbackModel.drsZones.map(({ id, startRatio, endRatio }) => ({ id, startRatio, endRatio })),
1347
- };
1348
- PROCEDURAL_TRACK_CACHE.set(normalizedSeed, fallbackDefinition);
1349
- return fallbackDefinition;
1350
- }
1351
-
1352
- export function pointAt(track, distanceAlong) {
1353
- const wrapped = wrapDistance(distanceAlong, track.length);
1354
- const low = sampleIndexAtDistance(track, wrapped);
1355
-
1356
- const next = track.samples[low] ?? track.samples[0];
1357
- const previous = track.samples[Math.max(0, low - 1)] ?? next;
1358
- const span = Math.max(1, next.distance - previous.distance);
1359
- const amount = clamp((wrapped - previous.distance) / span, 0, 1);
1360
-
1361
- return {
1362
- x: previous.x + (next.x - previous.x) * amount,
1363
- y: previous.y + (next.y - previous.y) * amount,
1364
- heading: previous.heading + normalizeAngle(next.heading - previous.heading) * amount,
1365
- normalX: previous.normalX + (next.normalX - previous.normalX) * amount,
1366
- normalY: previous.normalY + (next.normalY - previous.normalY) * amount,
1367
- curvature: previous.curvature + (next.curvature - previous.curvature) * amount,
1368
- distance: wrapped,
1369
- };
1370
- }
1371
-
1372
- function sampleIndexAtDistance(track, distanceAlong) {
1373
- const wrapped = wrapDistance(distanceAlong, track.length);
1374
- let low = 0;
1375
- let high = track.samples.length - 1;
1376
-
1377
- while (low < high) {
1378
- const mid = Math.floor((low + high) / 2);
1379
- if (track.samples[mid].distance < wrapped) low = mid + 1;
1380
- else high = mid;
1381
- }
1382
-
1383
- return low;
1384
- }
1385
-
1386
- function nearestSampleInRange(track, position, startIndex, endIndex) {
1387
- const sampleCount = track.samples.length - 1;
1388
- let best = null;
1389
- let bestDistance = Infinity;
1390
-
1391
- for (let index = startIndex; index <= endIndex; index += 1) {
1392
- const wrappedIndex = ((index % sampleCount) + sampleCount) % sampleCount;
1393
- const sample = track.samples[wrappedIndex];
1394
- const dx = position.x - sample.x;
1395
- const dy = position.y - sample.y;
1396
- const squared = dx * dx + dy * dy;
1397
- if (squared < bestDistance) {
1398
- bestDistance = squared;
1399
- best = sample;
1400
- }
1401
- }
1402
-
1403
- return { best, bestDistance };
1404
- }
1405
-
1406
- function nearestSampleGlobal(track, position) {
1407
- return nearestSampleInRange(track, position, 0, track.samples.length - 2);
1408
- }
1409
-
1410
- function projectPointToSegment(position, start, end) {
1411
- const dx = end.x - start.x;
1412
- const dy = end.y - start.y;
1413
- const lengthSquared = dx * dx + dy * dy;
1414
- const amount = lengthSquared > 0
1415
- ? clamp(((position.x - start.x) * dx + (position.y - start.y) * dy) / lengthSquared, 0, 1)
1416
- : 0;
1417
- const projected = {
1418
- x: start.x + dx * amount,
1419
- y: start.y + dy * amount,
1420
- };
1421
- const length = Math.sqrt(lengthSquared);
1422
- const heading = Math.atan2(dy, dx);
1423
- const normalX = length > 0 ? -dy / length : 0;
1424
- const normalY = length > 0 ? dx / length : 1;
1425
- const signedOffset = (position.x - projected.x) * normalX + (position.y - projected.y) * normalY;
1426
-
1427
- return {
1428
- point: projected,
1429
- amount,
1430
- length,
1431
- heading,
1432
- normalX,
1433
- normalY,
1434
- signedOffset,
1435
- crossTrackError: Math.abs(signedOffset),
1436
- };
1437
- }
1438
-
1439
- function nearestPointOnPolyline(points, position) {
1440
- if (!Array.isArray(points) || points.length < 2) return null;
1441
- let best = null;
1442
- let distanceBefore = 0;
1443
- let totalLength = 0;
1444
-
1445
- for (let index = 0; index < points.length - 1; index += 1) {
1446
- const current = points[index];
1447
- const next = points[index + 1];
1448
- const projection = projectPointToSegment(position, current, next);
1449
- if (!best || projection.crossTrackError < best.crossTrackError) {
1450
- best = {
1451
- ...projection,
1452
- segmentIndex: index,
1453
- distanceAlong: distanceBefore + projection.length * projection.amount,
1454
- };
1455
- }
1456
- distanceBefore += projection.length;
1457
- totalLength += projection.length;
1458
- }
1459
-
1460
- if (!best) return null;
1461
- return {
1462
- ...best,
1463
- totalLength,
1464
- };
1465
- }
1466
-
1467
- function mapPitDistance(track, startDistance, endDistance, amount) {
1468
- return wrapDistance(startDistance + (endDistance - startDistance) * clamp(amount, 0, 1), track.length);
1469
- }
1470
-
1471
- function createPitRoadState(track, position, pitLane, {
1472
- points,
1473
- surface,
1474
- part,
1475
- roadWidth,
1476
- startDistance,
1477
- endDistance,
1478
- }) {
1479
- const projected = nearestPointOnPolyline(points, position);
1480
- if (!projected || projected.crossTrackError > roadWidth / 2) return null;
1481
- const amount = projected.totalLength > 0 ? projected.distanceAlong / projected.totalLength : 0;
1482
- const distanceAlongTrack = mapPitDistance(track, startDistance, endDistance, amount);
1483
-
1484
- return {
1485
- x: projected.point.x,
1486
- y: projected.point.y,
1487
- heading: projected.heading,
1488
- normalX: projected.normalX,
1489
- normalY: projected.normalY,
1490
- curvature: 0,
1491
- distance: distanceAlongTrack,
1492
- signedOffset: projected.signedOffset,
1493
- crossTrackError: projected.crossTrackError,
1494
- surface,
1495
- onTrack: true,
1496
- inPitLane: true,
1497
- pitLanePart: part,
1498
- pitLaneSignedOffset: projected.signedOffset,
1499
- pitLaneCrossTrackError: projected.crossTrackError,
1500
- pitLaneDistanceAlong: projected.distanceAlong,
1501
- pitLaneTotalLength: projected.totalLength,
1502
- pitLaneRouteAmount: amount,
1503
- pitLaneRoadWidth: roadWidth,
1504
- };
1505
- }
1506
-
1507
- function pointIsInsidePolygon(point, polygon) {
1508
- if (!Array.isArray(polygon) || polygon.length < 3) return false;
1509
- let inside = false;
1510
-
1511
- for (let currentIndex = 0, previousIndex = polygon.length - 1; currentIndex < polygon.length; previousIndex = currentIndex, currentIndex += 1) {
1512
- const current = polygon[currentIndex];
1513
- const previous = polygon[previousIndex];
1514
- const crossesY = (current.y > point.y) !== (previous.y > point.y);
1515
- if (!crossesY) continue;
1516
- const xAtY = ((previous.x - current.x) * (point.y - current.y)) / (previous.y - current.y) + current.x;
1517
- if (point.x < xAtY) inside = !inside;
1518
- }
1519
-
1520
- return inside;
1521
- }
1522
-
1523
- function createPitBoxState(track, position, pitLane) {
1524
- const serviceArea = pitLane.serviceAreas?.find((candidate) => (
1525
- pointIsInsidePolygon(position, candidate.corners) ||
1526
- pointIsInsidePolygon(position, candidate.queueCorners)
1527
- ));
1528
- const box = serviceArea ?? pitLane.boxes?.find((candidate) => pointIsInsidePolygon(position, candidate.corners));
1529
- if (!box) return null;
1530
- const distanceAmount = pitLane.mainLane.length > 0
1531
- ? box.distanceAlongLane / pitLane.mainLane.length
1532
- : 0;
1533
- const distanceAlongTrack = mapPitDistance(
1534
- track,
1535
- pitLane.layout?.entryDistance ?? pitLane.entry.distanceFromStart,
1536
- pitLane.layout?.exitDistance ?? pitLane.exit.distanceFromStart,
1537
- distanceAmount,
1538
- );
1539
- const heading = pitLane.mainLane.heading;
1540
-
1541
- return {
1542
- x: box.center.x,
1543
- y: box.center.y,
1544
- heading,
1545
- normalX: pitLane.serviceNormal.x,
1546
- normalY: pitLane.serviceNormal.y,
1547
- curvature: 0,
1548
- distance: distanceAlongTrack,
1549
- signedOffset: 0,
1550
- crossTrackError: 0,
1551
- surface: 'pit-box',
1552
- onTrack: true,
1553
- inPitLane: true,
1554
- pitLanePart: serviceArea ? 'service-box' : 'garage-box',
1555
- pitLaneSignedOffset: 0,
1556
- pitLaneCrossTrackError: 0,
1557
- pitBoxId: box.id,
1558
- pitBoxIndex: box.index,
1559
- pitTeamId: box.teamId ?? null,
1560
- pitLaneDistanceAlong: box.distanceAlongLane,
1561
- pitLaneTotalLength: pitLane.mainLane.length,
1562
- pitLaneRoadWidth: box.depth,
1563
- };
1564
- }
1565
-
1566
- function nearestPitLaneState(track, position) {
1567
- const pitLane = track.pitLane;
1568
- if (!pitLane?.enabled) return null;
1569
- if (!pointInsideBounds(position, pitLane.bounds)) return null;
1570
-
1571
- const boxState = createPitBoxState(track, position, pitLane);
1572
- if (boxState) return boxState;
1573
-
1574
- const laneEntryDistance = pitLane.layout?.entryDistance ?? pitLane.entry.distanceFromStart;
1575
- const laneExitDistance = pitLane.layout?.exitDistance ?? pitLane.exit.distanceFromStart;
1576
- const candidates = [
1577
- createPitRoadState(track, position, pitLane, {
1578
- points: pitLane.entry.roadCenterline,
1579
- surface: 'pit-entry',
1580
- part: 'entry',
1581
- roadWidth: pitLane.width,
1582
- startDistance: pitLane.entry.distanceFromStart,
1583
- endDistance: laneEntryDistance,
1584
- }),
1585
- createPitRoadState(track, position, pitLane, {
1586
- points: pitLane.mainLane.points,
1587
- surface: 'pit-lane',
1588
- part: 'fast-lane',
1589
- roadWidth: pitLane.width,
1590
- startDistance: laneEntryDistance,
1591
- endDistance: laneExitDistance,
1592
- }),
1593
- createPitRoadState(track, position, pitLane, {
1594
- points: pitLane.workingLane?.points,
1595
- surface: 'pit-lane',
1596
- part: 'working-lane',
1597
- roadWidth: pitLane.workingLane?.width ?? 0,
1598
- startDistance: laneEntryDistance,
1599
- endDistance: laneExitDistance,
1600
- }),
1601
- createPitRoadState(track, position, pitLane, {
1602
- points: pitLane.exit.roadCenterline,
1603
- surface: 'pit-exit',
1604
- part: 'exit',
1605
- roadWidth: pitLane.width,
1606
- startDistance: laneExitDistance,
1607
- endDistance: pitLane.exit.distanceFromStart,
1608
- }),
1609
- ].filter(Boolean);
1610
-
1611
- return candidates.sort((left, right) => left.crossTrackError - right.crossTrackError)[0] ?? null;
1612
- }
1613
-
1614
- function createTrackState(track, position, best) {
1615
- const dx = position.x - best.x;
1616
- const dy = position.y - best.y;
1617
- const signedOffset = dx * best.normalX + dy * best.normalY;
1618
- const crossTrackError = Math.abs(signedOffset);
1619
- const trackEdge = track.width / 2;
1620
- const kerbEdge = trackEdge + (track.kerbWidth ?? 0);
1621
- const gravelEdge = kerbEdge + track.gravelWidth;
1622
- const runoffEdge = gravelEdge + track.runoffWidth;
1623
- const surface = crossTrackError <= trackEdge
1624
- ? 'track'
1625
- : crossTrackError <= kerbEdge
1626
- ? 'kerb'
1627
- : crossTrackError <= gravelEdge
1628
- ? 'gravel'
1629
- : crossTrackError <= runoffEdge
1630
- ? 'grass'
1631
- : 'barrier';
1632
-
1633
- return {
1634
- ...best,
1635
- signedOffset,
1636
- crossTrackError,
1637
- surface,
1638
- onTrack: surface === 'track' || surface === 'kerb',
1639
- };
1640
- }
1641
-
1642
- export function nearestTrackState(track, position, progressHint = null) {
1643
- let nearest = null;
1644
-
1645
- if (Number.isFinite(progressHint)) {
1646
- const centerIndex = sampleIndexAtDistance(track, progressHint);
1647
- nearest = nearestSampleInRange(
1648
- track,
1649
- position,
1650
- centerIndex - NEAREST_HINT_WINDOW_SAMPLES,
1651
- centerIndex + NEAREST_HINT_WINDOW_SAMPLES,
1652
- );
1653
- const fallbackDistance = track.width / 2 + (track.kerbWidth ?? 0) + track.gravelWidth + track.runoffWidth + 180;
1654
- if (!nearest.best || nearest.bestDistance > fallbackDistance * fallbackDistance) {
1655
- nearest = null;
1656
- }
1657
- }
1658
-
1659
- const best = nearest?.best ?? nearestSampleGlobal(track, position).best;
1660
- const trackState = createTrackState(track, position, best);
1661
- const pitState = nearestPitLaneState(track, position);
1662
- if (!pitState) return trackState;
1663
-
1664
- const mainRoadEdge = track.width / 2 + (track.kerbWidth ?? 0);
1665
- if (trackState.crossTrackError <= mainRoadEdge && pitState.surface !== 'pit-box') return trackState;
1666
-
1667
- const mergeBuffer = PIT_LANE_WIDTH * 0.16;
1668
- const isMainTrackMerge =
1669
- trackState.surface === 'track' &&
1670
- (
1671
- (pitState.surface === 'pit-entry' && pitState.pitLaneDistanceAlong <= mergeBuffer) ||
1672
- (pitState.surface === 'pit-exit' && (pitState.pitLaneTotalLength - pitState.pitLaneDistanceAlong) <= mergeBuffer)
1673
- );
1674
-
1675
- if (isMainTrackMerge) return trackState;
1676
-
1677
- return {
1678
- ...pitState,
1679
- mainTrackSignedOffset: trackState.signedOffset,
1680
- mainTrackCrossTrackError: trackState.crossTrackError,
1681
- signedOffset: trackState.signedOffset,
1682
- crossTrackError: trackState.crossTrackError,
1683
- };
1684
- }
1685
-
1686
- export function offsetTrackPoint(point, offset) {
1687
- return {
1688
- x: point.x + point.normalX * offset,
1689
- y: point.y + point.normalY * offset,
1690
- heading: point.heading,
1691
- };
1692
- }
1693
-
1694
- export function isInDrsZone(track, progress) {
1695
- const wrapped = wrapDistance(progress, track.length);
1696
- return track.drsZones.some((zone) => {
1697
- const start = wrapDistance(zone.start, track.length);
1698
- const end = wrapDistance(zone.end, track.length);
1699
- if (zone.end - zone.start >= track.length) return true;
1700
- return end >= start
1701
- ? wrapped >= start && wrapped <= end
1702
- : wrapped >= start || wrapped <= end;
1703
- });
1704
- }
1705
-
1706
- export { normalizeAngle };
1
+ export * from './track/trackModel.js';