@inventure71/paddockjs 0.3.0 → 1.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 (54) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/INSTALL_AND_UPDATE.md +3 -1
  3. package/README.md +142 -9
  4. package/docs/architecture.md +49 -15
  5. package/docs/concepts.md +20 -3
  6. package/docs/data_contract.md +185 -27
  7. package/docs/data_model.md +1 -1
  8. package/docs/learnings.md +1 -1
  9. package/docs/rules.md +140 -19
  10. package/docs/system_specs.md +39 -16
  11. package/docs/training.md +103 -1
  12. package/examples/train-basic-policy.mjs +5 -6
  13. package/examples/trainingData.mjs +133 -0
  14. package/package.json +5 -2
  15. package/src/api/PaddockSimulatorController.js +32 -2
  16. package/src/app/F1SimulatorApp.js +1101 -128
  17. package/src/app/domBindings.js +10 -1
  18. package/src/config/defaultOptions.js +4 -4
  19. package/src/environment/actions.js +25 -2
  20. package/src/environment/evaluation.js +184 -0
  21. package/src/environment/index.d.ts +281 -1
  22. package/src/environment/index.js +13 -0
  23. package/src/environment/observationOptions.js +12 -0
  24. package/src/environment/observations.js +189 -17
  25. package/src/environment/options.js +7 -0
  26. package/src/environment/recorder.js +28 -0
  27. package/src/environment/runtime.js +41 -6
  28. package/src/environment/scenarios.js +220 -0
  29. package/src/environment/sensors.js +145 -35
  30. package/src/environment/specs.js +92 -8
  31. package/src/environment/workerProtocol.js +52 -0
  32. package/src/index.d.ts +366 -3
  33. package/src/index.js +21 -0
  34. package/src/rendering/proceduralTrackAsset.js +336 -23
  35. package/src/simulation/collisionGeometry.js +214 -0
  36. package/src/simulation/driverController.js +431 -65
  37. package/src/simulation/raceSimulation.js +2132 -171
  38. package/src/simulation/rules/collisionSteward.js +69 -0
  39. package/src/simulation/rules/penaltyLedger.js +153 -0
  40. package/src/simulation/rules/pitLaneSpeedingSteward.js +71 -0
  41. package/src/simulation/rules/tireRequirementSteward.js +29 -0
  42. package/src/simulation/rules/trackLimitsSteward.js +85 -0
  43. package/src/simulation/rulesConfig.js +352 -0
  44. package/src/simulation/trackModel.js +1154 -76
  45. package/src/simulation/units.js +6 -4
  46. package/src/simulation/vehicleGeometry.js +247 -0
  47. package/src/simulation/vehiclePhysics.js +82 -50
  48. package/src/simulation/wheelSurface.js +338 -0
  49. package/src/styles.css +300 -42
  50. package/src/ui/bannerTemplates.js +69 -0
  51. package/src/ui/componentTemplates.js +36 -96
  52. package/src/ui/raceControlStatusBanner.js +39 -0
  53. package/src/ui/shellTemplate.js +1 -1
  54. package/src/ui/templateUtils.js +23 -0
@@ -1,16 +1,17 @@
1
- import { clamp, createMulberry32, normalizeAngle, seededRange, TWO_PI, wrapDistance } from './simMath.js';
1
+ import { clamp, createMulberry32, normalizeAngle, seededRange, wrapDistance } from './simMath.js';
2
+ import { metersToSimUnits } from './units.js';
2
3
 
3
4
  export const WORLD = {
4
- width: 7600,
5
- height: 4600,
5
+ width: metersToSimUnits(3550),
6
+ height: metersToSimUnits(2148),
6
7
  };
7
8
 
8
9
  export const TRACK = {
9
10
  name: 'Apex Harbor GP',
10
- width: 230,
11
- kerbWidth: 34,
12
- gravelWidth: 165,
13
- runoffWidth: 260,
11
+ width: metersToSimUnits(15),
12
+ kerbWidth: metersToSimUnits(1.5),
13
+ gravelWidth: metersToSimUnits(12),
14
+ runoffWidth: metersToSimUnits(20),
14
15
  sampleCount: 3600,
15
16
  drsZones: [
16
17
  { id: 'main-straight', startRatio: 0.02, endRatio: 0.18 },
@@ -19,16 +20,52 @@ export const TRACK = {
19
20
  ],
20
21
  };
21
22
 
22
- const GENERATED_TRACK_MIN_LENGTH = 7600;
23
- const GENERATED_TRACK_MAX_LENGTH = 14500;
24
- const GENERATED_TRACK_ATTEMPTS = 320;
25
- const GENERATED_CONTROL_COUNT = 20;
26
- const TRACK_BOUNDARY_PADDING = 520;
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);
27
29
  const MIN_TRACK_CLEARANCE_MULTIPLIER = 1.55;
28
- const MAX_LOCAL_TURN_RADIANS = 1.5;
29
- const START_STRAIGHT_GRID_LENGTH = 760;
30
- const START_STRAIGHT_EXIT_LENGTH = 260;
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);
31
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);
32
69
  const PROCEDURAL_TRACK_TEMPLATES = [
33
70
  [
34
71
  [0.08, 0.55], [0.10, 0.80], [0.22, 0.89], [0.42, 0.84], [0.52, 0.93],
@@ -73,7 +110,7 @@ const CENTERLINE_CONTROLS = [
73
110
  { x: WORLD.width * 0.08, y: WORLD.height * 0.43 },
74
111
  ];
75
112
 
76
- function catmullRom(p0, p1, p2, p3, t) {
113
+ function uniformCatmullRom(p0, p1, p2, p3, t) {
77
114
  const t2 = t * t;
78
115
  const t3 = t2 * t;
79
116
  return {
@@ -92,6 +129,30 @@ function catmullRom(p0, p1, p2, p3, t) {
92
129
  };
93
130
  }
94
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
+
95
156
  function normalizeSeed(seed) {
96
157
  if (Number.isFinite(seed)) return seed >>> 0;
97
158
  let hash = 2166136261;
@@ -102,7 +163,7 @@ function normalizeSeed(seed) {
102
163
  return hash >>> 0;
103
164
  }
104
165
 
105
- function rawCenterPoint(ratio, controls = CENTERLINE_CONTROLS) {
166
+ function rawCenterPoint(ratio, controls = CENTERLINE_CONTROLS, interpolation = 'uniform') {
106
167
  const count = controls.length;
107
168
  const scaled = ratio * count;
108
169
  const index = Math.floor(scaled) % count;
@@ -111,7 +172,9 @@ function rawCenterPoint(ratio, controls = CENTERLINE_CONTROLS) {
111
172
  const p1 = controls[index];
112
173
  const p2 = controls[(index + 1) % count];
113
174
  const p3 = controls[(index + 2) % count];
114
- return catmullRom(p0, p1, p2, p3, localT);
175
+ return interpolation === 'centripetal'
176
+ ? centripetalCatmullRom(p0, p1, p2, p3, localT)
177
+ : uniformCatmullRom(p0, p1, p2, p3, localT);
115
178
  }
116
179
 
117
180
  function distance(a, b) {
@@ -166,7 +229,7 @@ function hasEnoughTrackClearance(samples, totalLength, minimumClearance) {
166
229
  for (let second = first + 1; second < points.length; second += 1) {
167
230
  const arcDistance = Math.abs(points[second].distance - points[first].distance);
168
231
  const loopDistance = Math.min(arcDistance, totalLength - arcDistance);
169
- if (loopDistance < 700) continue;
232
+ if (loopDistance < MIN_NON_ADJACENT_ARC_DISTANCE) continue;
170
233
  const dx = points[first].x - points[second].x;
171
234
  const dy = points[first].y - points[second].y;
172
235
  if (dx * dx + dy * dy < minimumClearanceSquared) return false;
@@ -177,32 +240,171 @@ function hasEnoughTrackClearance(samples, totalLength, minimumClearance) {
177
240
 
178
241
  function hasReasonableTurnSharpness(samples) {
179
242
  const usableSamples = samples.slice(0, -1);
180
- const window = 30;
243
+ const windows = [30, 36];
181
244
  const step = 6;
182
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
+
183
254
  for (let index = 0; index < usableSamples.length; index += step) {
184
- let accumulatedTurn = 0;
185
- for (let offset = 0; offset < window; offset += step) {
186
- const current = usableSamples[(index + offset) % usableSamples.length];
187
- const next = usableSamples[(index + offset + step) % usableSamples.length];
188
- accumulatedTurn += Math.abs(normalizeAngle(next.heading - current.heading));
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;
189
263
  }
190
- if (accumulatedTurn > MAX_LOCAL_TURN_RADIANS) return false;
191
264
  }
192
265
 
193
266
  return true;
194
267
  }
195
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
+
196
278
  function distanceForwardAlongTrack(from, to, totalLength) {
197
279
  return to >= from ? to - from : totalLength - from + to;
198
280
  }
199
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
+
200
387
  function chooseStandingStartIndex(samples, totalLength) {
201
388
  const usableSamples = samples.slice(0, -1);
202
389
  let best = { index: 0, score: Infinity };
203
390
 
204
391
  for (let index = 0; index < usableSamples.length; index += 1) {
205
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
+
206
408
  let gridTurn = 0;
207
409
  let gridCurvature = 0;
208
410
  let gridCount = 0;
@@ -232,7 +434,8 @@ function chooseStandingStartIndex(samples, totalLength) {
232
434
  gridTurn * 5 +
233
435
  exitTurn * 1.2 +
234
436
  (gridCurvature / Math.max(1, gridCount)) * 2600 +
235
- (exitCurvature / Math.max(1, exitCount)) * 650;
437
+ (exitCurvature / Math.max(1, exitCount)) * 650 -
438
+ projectedClearance * 0.002;
236
439
 
237
440
  if (score < best.score) best = { index, score };
238
441
  }
@@ -304,6 +507,27 @@ function rotateControls(controls, random) {
304
507
  return [...controls.slice(rotation), ...controls.slice(0, rotation)];
305
508
  }
306
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
+
307
531
  function generateCenterlineControls(seed) {
308
532
  const random = createMulberry32(seed);
309
533
  const template = PROCEDURAL_TRACK_TEMPLATES[Math.floor(seededRange(random, 0, PROCEDURAL_TRACK_TEMPLATES.length))]
@@ -313,26 +537,32 @@ function generateCenterlineControls(seed) {
313
537
  random,
314
538
  );
315
539
 
316
- return rotateControls(normalized.map(normalizedToWorld), random);
540
+ return rotateControls(strengthenShapeVariation(normalized.map(normalizedToWorld)), random);
317
541
  }
318
542
 
319
543
  function generateFallbackCenterlineControls(seed) {
320
544
  const random = createMulberry32(seed ^ 0xa5a5a5a5);
321
- const center = { x: WORLD.width / 2, y: WORLD.height / 2 };
322
- const phaseA = seededRange(random, 0, TWO_PI);
323
- const phaseB = seededRange(random, 0, TWO_PI);
324
- const angleStep = TWO_PI / GENERATED_CONTROL_COUNT;
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
+ );
325
551
 
326
- return Array.from({ length: GENERATED_CONTROL_COUNT }, (_, index) => {
327
- const angle = Math.PI + index * angleStep;
328
- const rx = WORLD.width * (0.345 + Math.sin(angle * 3 + phaseA) * 0.024);
329
- const ry = WORLD.height * (0.315 + Math.sin(angle * 2 + phaseB) * 0.022);
552
+ return rotateControls(strengthenShapeVariation(normalized.map(normalizedToWorld)), random);
553
+ }
330
554
 
331
- return {
332
- x: center.x + Math.cos(angle) * rx,
333
- y: center.y + Math.sin(angle) * ry,
334
- };
335
- });
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));
336
566
  }
337
567
 
338
568
  function normalizeDrsZone(zone, totalLength) {
@@ -366,6 +596,603 @@ function createTrackSectors(totalLength) {
366
596
  });
367
597
  }
368
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
+
369
1196
  function scoreStraightWindow(samples, startIndex, windowSize) {
370
1197
  let curvature = 0;
371
1198
  for (let offset = 0; offset < windowSize; offset += 1) {
@@ -409,6 +1236,18 @@ function deriveDrsZones(samples, totalLength) {
409
1236
  .sort((a, b) => a.startRatio - b.startRatio);
410
1237
  }
411
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
+
412
1251
  export function buildTrackModel(track = TRACK) {
413
1252
  const cached = TRACK_MODEL_CACHE.get(track);
414
1253
  if (cached) return cached;
@@ -416,7 +1255,7 @@ export function buildTrackModel(track = TRACK) {
416
1255
  const controls = track.centerlineControls ?? CENTERLINE_CONTROLS;
417
1256
  const base = [];
418
1257
  for (let index = 0; index <= track.sampleCount; index += 1) {
419
- base.push(rawCenterPoint(index / track.sampleCount, controls));
1258
+ base.push(rawCenterPoint(index / track.sampleCount, controls, track.curveInterpolation));
420
1259
  }
421
1260
 
422
1261
  let totalLength = 0;
@@ -424,22 +1263,12 @@ export function buildTrackModel(track = TRACK) {
424
1263
  if (index > 0) totalLength += distance(base[index - 1], point);
425
1264
  return { ...point, distance: totalLength, heading: 0, normalX: 0, normalY: 0, curvature: 0 };
426
1265
  });
1266
+ recalculateSampleGeometry(samples);
427
1267
 
428
- samples.forEach((sample, index) => {
429
- const previous = samples[(index - 1 + samples.length - 1) % (samples.length - 1)];
430
- const next = samples[(index + 1) % (samples.length - 1)];
431
- const heading = Math.atan2(next.y - previous.y, next.x - previous.x);
432
- const nextHeading = Math.atan2(
433
- samples[(index + 2) % (samples.length - 1)].y - sample.y,
434
- samples[(index + 2) % (samples.length - 1)].x - sample.x,
435
- );
436
- sample.heading = heading;
437
- sample.normalX = -Math.sin(heading);
438
- sample.normalY = Math.cos(heading);
439
- sample.curvature = Math.abs(normalizeAngle(nextHeading - heading)) / 28;
440
- });
441
-
442
- const normalizedSamples = rotateSamplesToStandingStart(samples, totalLength);
1268
+ const rotatedSamples = rotateSamplesToStandingStart(samples, totalLength);
1269
+ const straightenedTrack = straightenStandingStartSamples(rotatedSamples, totalLength);
1270
+ const normalizedSamples = straightenedTrack.samples;
1271
+ totalLength = straightenedTrack.totalLength;
443
1272
 
444
1273
  const model = {
445
1274
  ...track,
@@ -450,6 +1279,7 @@ export function buildTrackModel(track = TRACK) {
450
1279
  drsZones: (track.drsZones ?? deriveDrsZones(normalizedSamples, totalLength))
451
1280
  .map((zone) => normalizeDrsZone(zone, totalLength)),
452
1281
  };
1282
+ model.pitLane = createPitLaneModel(model);
453
1283
  TRACK_MODEL_CACHE.set(track, model);
454
1284
  return model;
455
1285
  }
@@ -459,25 +1289,18 @@ export function createProceduralTrack(seed = Date.now()) {
459
1289
  const cached = PROCEDURAL_TRACK_CACHE.get(normalizedSeed);
460
1290
  if (cached) return cached;
461
1291
 
462
- for (let attempt = 0; attempt < GENERATED_TRACK_ATTEMPTS; attempt += 1) {
1292
+ for (let attempt = 0; attempt < GENERATED_FALLBACK_ATTEMPTS; attempt += 1) {
463
1293
  const candidateSeed = (normalizedSeed + Math.imul(attempt, 2654435761)) >>> 0;
464
1294
  const candidate = {
465
1295
  ...TRACK,
466
1296
  name: `Generated GP ${candidateSeed.toString(36).toUpperCase().padStart(6, '0').slice(-6)}`,
467
1297
  seed: candidateSeed,
1298
+ curveInterpolation: 'centripetal',
468
1299
  centerlineControls: generateCenterlineControls(candidateSeed),
469
1300
  drsZones: null,
470
1301
  };
471
1302
  const model = buildTrackModel(candidate);
472
- const valid =
473
- model.length >= GENERATED_TRACK_MIN_LENGTH &&
474
- model.length <= GENERATED_TRACK_MAX_LENGTH &&
475
- samplesStayInsideWorld(model.samples) &&
476
- hasEnoughTrackClearance(model.samples, model.length, model.width * MIN_TRACK_CLEARANCE_MULTIPLIER) &&
477
- hasReasonableTurnSharpness(model.samples) &&
478
- !hasSelfIntersections(model.samples);
479
-
480
- if (valid) {
1303
+ if (isValidProceduralTrackModel(model)) {
481
1304
  const trackDefinition = {
482
1305
  ...candidate,
483
1306
  drsZones: model.drsZones.map(({ id, startRatio, endRatio }) => ({ id, startRatio, endRatio })),
@@ -487,15 +1310,43 @@ export function createProceduralTrack(seed = Date.now()) {
487
1310
  }
488
1311
  }
489
1312
 
490
- const fallback = {
491
- ...TRACK,
492
- name: `Generated GP ${normalizedSeed.toString(36).toUpperCase().padStart(6, '0').slice(-6)}`,
493
- seed: normalizedSeed,
494
- centerlineControls: generateFallbackCenterlineControls(normalizedSeed),
495
- drsZones: null,
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 })),
496
1347
  };
497
- PROCEDURAL_TRACK_CACHE.set(normalizedSeed, fallback);
498
- return fallback;
1348
+ PROCEDURAL_TRACK_CACHE.set(normalizedSeed, fallbackDefinition);
1349
+ return fallbackDefinition;
499
1350
  }
500
1351
 
501
1352
  export function pointAt(track, distanceAlong) {
@@ -556,6 +1407,210 @@ function nearestSampleGlobal(track, position) {
556
1407
  return nearestSampleInRange(track, position, 0, track.samples.length - 2);
557
1408
  }
558
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
+
559
1614
  function createTrackState(track, position, best) {
560
1615
  const dx = position.x - best.x;
561
1616
  const dy = position.y - best.y;
@@ -602,7 +1657,30 @@ export function nearestTrackState(track, position, progressHint = null) {
602
1657
  }
603
1658
 
604
1659
  const best = nearest?.best ?? nearestSampleGlobal(track, position).best;
605
- return createTrackState(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
+ };
606
1684
  }
607
1685
 
608
1686
  export function offsetTrackPoint(point, offset) {