1use crate::{apply_velocities_to_rigid_body, glam_to_vec, vec_to_glam};
2
3pub const ROCKET_LEAGUE_PHYSICS_TICK_RATE_HZ: f32 = 120.0;
5
6pub const STANDARD_BALL_GRAVITY_Z: f32 = -650.0;
8
9pub const STANDARD_BALL_MAX_SPEED: f32 = 6000.0;
11
12pub const STANDARD_BALL_RADIUS: f32 = 91.25;
14
15pub const STANDARD_BALL_RESTITUTION: f32 = 0.6;
17
18pub const STANDARD_BALL_TANGENTIAL_FRICTION: f32 = 0.285;
20
21pub const STANDARD_BALL_TANGENTIAL_RATIO_SCALE: f32 = 2.0;
23
24pub const STANDARD_BALL_ANGULAR_COUPLING: f32 = 0.0003;
26
27pub const STANDARD_GOAL_LINE_Y: f32 = 5120.0;
29
30pub const STANDARD_ARENA_BACK_WALL_Y: f32 = STANDARD_GOAL_LINE_Y;
32
33pub const STANDARD_GOAL_MOUTH_HALF_WIDTH_X: f32 = 892.755;
35
36pub const STANDARD_GOAL_MOUTH_HEIGHT_Z: f32 = 642.775;
38
39pub const STANDARD_GOAL_FRAME_RADIUS: f32 = 75.0;
41
42pub const STANDARD_ARENA_WALL_BOTTOM_RAMP_RADIUS: f32 = 256.0;
45
46pub const STANDARD_GOAL_MOUTH_TRAJECTORY_MARGIN: f32 = STANDARD_BALL_RADIUS * 1.5;
48
49pub const STANDARD_ARENA_SIDE_WALL_X: f32 = 4096.0;
51
52pub const STANDARD_ARENA_CEILING_Z: f32 = 2044.0;
54
55const MIN_TICK_RATE_HZ: f32 = 1.0;
56const MAX_INTEGRATION_STEPS: usize = 1_000_000;
57const MAX_COLLISIONS_PER_STEP: usize = 16;
58const COLLISION_TIME_EPSILON: f32 = 0.000_001;
59const ARENA_BOUND_EPSILON: f32 = 0.001;
60const RESTING_CONTACT_NORMAL_SPEED_THRESHOLD: f32 = 50.0;
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
63pub enum BallTrajectoryIntegration {
64 #[default]
67 SemiImplicitEuler,
68 ClosedForm,
70}
71
72#[derive(Debug, Clone, Copy, PartialEq)]
73pub struct BallTrajectoryConfig {
74 pub gravity: glam::Vec3,
75 pub max_speed: f32,
76 pub tick_rate_hz: f32,
77 pub integration: BallTrajectoryIntegration,
78}
79
80impl BallTrajectoryConfig {
81 pub const STANDARD_SOCCAR: Self = Self {
82 gravity: glam::Vec3::new(0.0, 0.0, STANDARD_BALL_GRAVITY_Z),
83 max_speed: STANDARD_BALL_MAX_SPEED,
84 tick_rate_hz: ROCKET_LEAGUE_PHYSICS_TICK_RATE_HZ,
85 integration: BallTrajectoryIntegration::SemiImplicitEuler,
86 };
87
88 fn fixed_step_seconds(self) -> f32 {
89 1.0 / self.tick_rate_hz.max(MIN_TICK_RATE_HZ)
90 }
91}
92
93impl Default for BallTrajectoryConfig {
94 fn default() -> Self {
95 Self::STANDARD_SOCCAR
96 }
97}
98
99#[derive(Debug, Clone, Copy, PartialEq)]
100pub struct BallBounceConfig {
101 pub radius: f32,
102 pub restitution: f32,
103 pub tangential_friction: f32,
104 pub tangential_ratio_scale: f32,
105 pub angular_coupling: f32,
106}
107
108impl BallBounceConfig {
109 pub const STANDARD_SOCCAR: Self = Self {
110 radius: STANDARD_BALL_RADIUS,
111 restitution: STANDARD_BALL_RESTITUTION,
112 tangential_friction: STANDARD_BALL_TANGENTIAL_FRICTION,
113 tangential_ratio_scale: STANDARD_BALL_TANGENTIAL_RATIO_SCALE,
114 angular_coupling: STANDARD_BALL_ANGULAR_COUPLING,
115 };
116}
117
118impl Default for BallBounceConfig {
119 fn default() -> Self {
120 Self::STANDARD_SOCCAR
121 }
122}
123
124#[derive(Debug, Clone, Copy, PartialEq)]
125pub struct BallCollisionPlaneBounds {
126 pub min: glam::Vec3,
127 pub max: glam::Vec3,
128}
129
130impl BallCollisionPlaneBounds {
131 pub const fn new(min: glam::Vec3, max: glam::Vec3) -> Self {
132 Self { min, max }
133 }
134
135 pub fn contains(self, position: glam::Vec3) -> bool {
136 position.x + ARENA_BOUND_EPSILON >= self.min.x
137 && position.x - ARENA_BOUND_EPSILON <= self.max.x
138 && position.y + ARENA_BOUND_EPSILON >= self.min.y
139 && position.y - ARENA_BOUND_EPSILON <= self.max.y
140 && position.z + ARENA_BOUND_EPSILON >= self.min.z
141 && position.z - ARENA_BOUND_EPSILON <= self.max.z
142 }
143}
144
145#[derive(Debug, Clone, Copy, PartialEq)]
146pub struct BallCollisionPlane {
147 pub normal: glam::Vec3,
149 pub distance_from_origin: f32,
151 pub bounds: Option<BallCollisionPlaneBounds>,
154}
155
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157pub enum BallCollisionCylinderAxis {
158 X,
159 Y,
160 Z,
161}
162
163#[derive(Debug, Clone, Copy, PartialEq)]
164pub struct BallCollisionCylinder {
165 pub axis: BallCollisionCylinderAxis,
166 pub center: glam::Vec3,
167 pub radius: f32,
168 pub min_axis: f32,
169 pub max_axis: f32,
170}
171
172impl BallCollisionCylinder {
173 pub const fn new(
174 axis: BallCollisionCylinderAxis,
175 center: glam::Vec3,
176 radius: f32,
177 min_axis: f32,
178 max_axis: f32,
179 ) -> Self {
180 Self {
181 axis,
182 center,
183 radius,
184 min_axis,
185 max_axis,
186 }
187 }
188
189 pub const fn standard_positive_goal_left_post() -> Self {
190 Self::standard_goal_post(
191 -STANDARD_GOAL_MOUTH_HALF_WIDTH_X - STANDARD_GOAL_FRAME_RADIUS,
192 STANDARD_GOAL_LINE_Y,
193 )
194 }
195
196 pub const fn standard_positive_goal_right_post() -> Self {
197 Self::standard_goal_post(
198 STANDARD_GOAL_MOUTH_HALF_WIDTH_X + STANDARD_GOAL_FRAME_RADIUS,
199 STANDARD_GOAL_LINE_Y,
200 )
201 }
202
203 pub const fn standard_negative_goal_left_post() -> Self {
204 Self::standard_goal_post(
205 -STANDARD_GOAL_MOUTH_HALF_WIDTH_X - STANDARD_GOAL_FRAME_RADIUS,
206 -STANDARD_GOAL_LINE_Y,
207 )
208 }
209
210 pub const fn standard_negative_goal_right_post() -> Self {
211 Self::standard_goal_post(
212 STANDARD_GOAL_MOUTH_HALF_WIDTH_X + STANDARD_GOAL_FRAME_RADIUS,
213 -STANDARD_GOAL_LINE_Y,
214 )
215 }
216
217 pub const fn standard_positive_goal_crossbar() -> Self {
218 Self::standard_goal_crossbar(STANDARD_GOAL_LINE_Y)
219 }
220
221 pub const fn standard_negative_goal_crossbar() -> Self {
222 Self::standard_goal_crossbar(-STANDARD_GOAL_LINE_Y)
223 }
224
225 const fn standard_goal_post(x: f32, y: f32) -> Self {
226 Self::new(
227 BallCollisionCylinderAxis::Z,
228 glam::Vec3::new(x, y, 0.0),
229 STANDARD_GOAL_FRAME_RADIUS,
230 0.0,
231 STANDARD_GOAL_MOUTH_HEIGHT_Z + STANDARD_GOAL_FRAME_RADIUS,
232 )
233 }
234
235 const fn standard_goal_crossbar(y: f32) -> Self {
236 Self::new(
237 BallCollisionCylinderAxis::X,
238 glam::Vec3::new(
239 0.0,
240 y,
241 STANDARD_GOAL_MOUTH_HEIGHT_Z + STANDARD_GOAL_FRAME_RADIUS,
242 ),
243 STANDARD_GOAL_FRAME_RADIUS,
244 -STANDARD_GOAL_MOUTH_HALF_WIDTH_X - STANDARD_GOAL_FRAME_RADIUS,
245 STANDARD_GOAL_MOUTH_HALF_WIDTH_X + STANDARD_GOAL_FRAME_RADIUS,
246 )
247 }
248}
249
250#[derive(Debug, Clone, Copy, PartialEq)]
251pub struct BallCollisionConcaveCylinder {
252 pub axis: BallCollisionCylinderAxis,
253 pub center: glam::Vec3,
254 pub radius: f32,
255 pub min_axis: f32,
256 pub max_axis: f32,
257 pub bounds: BallCollisionPlaneBounds,
258}
259
260impl BallCollisionConcaveCylinder {
261 pub const fn new(
262 axis: BallCollisionCylinderAxis,
263 center: glam::Vec3,
264 radius: f32,
265 min_axis: f32,
266 max_axis: f32,
267 bounds: BallCollisionPlaneBounds,
268 ) -> Self {
269 Self {
270 axis,
271 center,
272 radius,
273 min_axis,
274 max_axis,
275 bounds,
276 }
277 }
278
279 pub fn standard_positive_x_wall_bottom_ramp() -> Self {
280 let center_x = STANDARD_ARENA_SIDE_WALL_X - STANDARD_ARENA_WALL_BOTTOM_RAMP_RADIUS;
281 Self::new(
282 BallCollisionCylinderAxis::Y,
283 glam::Vec3::new(center_x, 0.0, STANDARD_ARENA_WALL_BOTTOM_RAMP_RADIUS),
284 STANDARD_ARENA_WALL_BOTTOM_RAMP_RADIUS,
285 -STANDARD_ARENA_BACK_WALL_Y,
286 STANDARD_ARENA_BACK_WALL_Y,
287 BallCollisionPlaneBounds::new(
288 glam::Vec3::new(center_x, -STANDARD_ARENA_BACK_WALL_Y, 0.0),
289 glam::Vec3::new(
290 STANDARD_ARENA_SIDE_WALL_X,
291 STANDARD_ARENA_BACK_WALL_Y,
292 STANDARD_ARENA_WALL_BOTTOM_RAMP_RADIUS,
293 ),
294 ),
295 )
296 }
297
298 pub fn standard_negative_x_wall_bottom_ramp() -> Self {
299 let center_x = -STANDARD_ARENA_SIDE_WALL_X + STANDARD_ARENA_WALL_BOTTOM_RAMP_RADIUS;
300 Self::new(
301 BallCollisionCylinderAxis::Y,
302 glam::Vec3::new(center_x, 0.0, STANDARD_ARENA_WALL_BOTTOM_RAMP_RADIUS),
303 STANDARD_ARENA_WALL_BOTTOM_RAMP_RADIUS,
304 -STANDARD_ARENA_BACK_WALL_Y,
305 STANDARD_ARENA_BACK_WALL_Y,
306 BallCollisionPlaneBounds::new(
307 glam::Vec3::new(
308 -STANDARD_ARENA_SIDE_WALL_X,
309 -STANDARD_ARENA_BACK_WALL_Y,
310 0.0,
311 ),
312 glam::Vec3::new(
313 center_x,
314 STANDARD_ARENA_BACK_WALL_Y,
315 STANDARD_ARENA_WALL_BOTTOM_RAMP_RADIUS,
316 ),
317 ),
318 )
319 }
320
321 pub fn standard_positive_y_wall_bottom_ramp_left() -> Self {
322 Self::standard_y_wall_bottom_ramp(
323 STANDARD_ARENA_BACK_WALL_Y,
324 -STANDARD_ARENA_SIDE_WALL_X,
325 -STANDARD_GOAL_MOUTH_HALF_WIDTH_X - STANDARD_BALL_RADIUS,
326 )
327 }
328
329 pub fn standard_positive_y_wall_bottom_ramp_right() -> Self {
330 Self::standard_y_wall_bottom_ramp(
331 STANDARD_ARENA_BACK_WALL_Y,
332 STANDARD_GOAL_MOUTH_HALF_WIDTH_X + STANDARD_BALL_RADIUS,
333 STANDARD_ARENA_SIDE_WALL_X,
334 )
335 }
336
337 pub fn standard_negative_y_wall_bottom_ramp_left() -> Self {
338 Self::standard_y_wall_bottom_ramp(
339 -STANDARD_ARENA_BACK_WALL_Y,
340 -STANDARD_ARENA_SIDE_WALL_X,
341 -STANDARD_GOAL_MOUTH_HALF_WIDTH_X - STANDARD_BALL_RADIUS,
342 )
343 }
344
345 pub fn standard_negative_y_wall_bottom_ramp_right() -> Self {
346 Self::standard_y_wall_bottom_ramp(
347 -STANDARD_ARENA_BACK_WALL_Y,
348 STANDARD_GOAL_MOUTH_HALF_WIDTH_X + STANDARD_BALL_RADIUS,
349 STANDARD_ARENA_SIDE_WALL_X,
350 )
351 }
352
353 fn standard_y_wall_bottom_ramp(y: f32, min_x: f32, max_x: f32) -> Self {
354 let center_y = if y > 0.0 {
355 y - STANDARD_ARENA_WALL_BOTTOM_RAMP_RADIUS
356 } else {
357 y + STANDARD_ARENA_WALL_BOTTOM_RAMP_RADIUS
358 };
359 let min_y = if y > 0.0 { center_y } else { y };
360 let max_y = if y > 0.0 { y } else { center_y };
361 Self::new(
362 BallCollisionCylinderAxis::X,
363 glam::Vec3::new(0.0, center_y, STANDARD_ARENA_WALL_BOTTOM_RAMP_RADIUS),
364 STANDARD_ARENA_WALL_BOTTOM_RAMP_RADIUS,
365 min_x,
366 max_x,
367 BallCollisionPlaneBounds::new(
368 glam::Vec3::new(min_x, min_y, 0.0),
369 glam::Vec3::new(max_x, max_y, STANDARD_ARENA_WALL_BOTTOM_RAMP_RADIUS),
370 ),
371 )
372 }
373}
374
375#[derive(Debug, Clone, Copy, PartialEq)]
376pub enum BallCollisionSurface {
377 Plane(BallCollisionPlane),
378 Cylinder(BallCollisionCylinder),
379 ConcaveCylinder(BallCollisionConcaveCylinder),
380}
381
382impl From<BallCollisionPlane> for BallCollisionSurface {
383 fn from(plane: BallCollisionPlane) -> Self {
384 Self::Plane(plane)
385 }
386}
387
388impl From<BallCollisionCylinder> for BallCollisionSurface {
389 fn from(cylinder: BallCollisionCylinder) -> Self {
390 Self::Cylinder(cylinder)
391 }
392}
393
394impl From<BallCollisionConcaveCylinder> for BallCollisionSurface {
395 fn from(cylinder: BallCollisionConcaveCylinder) -> Self {
396 Self::ConcaveCylinder(cylinder)
397 }
398}
399
400impl BallCollisionPlane {
401 pub fn new(normal: glam::Vec3, distance_from_origin: f32) -> Option<Self> {
402 if !normal.is_finite() || normal.length_squared() <= f32::EPSILON {
403 return None;
404 }
405 Some(Self {
406 normal: normal.normalize(),
407 distance_from_origin,
408 bounds: None,
409 })
410 }
411
412 pub const fn from_unit_normal(normal: glam::Vec3, distance_from_origin: f32) -> Self {
413 Self {
414 normal,
415 distance_from_origin,
416 bounds: None,
417 }
418 }
419
420 pub const fn with_bounds(mut self, bounds: BallCollisionPlaneBounds) -> Self {
421 self.bounds = Some(bounds);
422 self
423 }
424
425 pub fn contains_impact_point(self, position: glam::Vec3) -> bool {
426 self.bounds.is_none_or(|bounds| bounds.contains(position))
427 }
428
429 pub const fn standard_ground() -> Self {
430 Self::from_unit_normal(glam::Vec3::Z, 0.0)
431 }
432
433 pub const fn standard_positive_x_wall() -> Self {
434 Self::from_unit_normal(glam::Vec3::NEG_X, -STANDARD_ARENA_SIDE_WALL_X)
435 }
436
437 pub const fn standard_negative_x_wall() -> Self {
438 Self::from_unit_normal(glam::Vec3::X, -STANDARD_ARENA_SIDE_WALL_X)
439 }
440
441 pub const fn standard_positive_y_wall() -> Self {
442 Self::from_unit_normal(glam::Vec3::NEG_Y, -STANDARD_ARENA_BACK_WALL_Y)
443 }
444
445 pub const fn standard_negative_y_wall() -> Self {
446 Self::from_unit_normal(glam::Vec3::Y, -STANDARD_ARENA_BACK_WALL_Y)
447 }
448
449 pub const fn standard_ceiling() -> Self {
450 Self::from_unit_normal(glam::Vec3::NEG_Z, -STANDARD_ARENA_CEILING_Z)
451 }
452
453 pub const fn standard_ground_bounded() -> Self {
454 Self::standard_ground().with_bounds(Self::standard_full_arena_bounds())
455 }
456
457 pub const fn standard_positive_x_wall_bounded() -> Self {
458 Self::standard_positive_x_wall().with_bounds(Self::standard_full_arena_bounds())
459 }
460
461 pub const fn standard_negative_x_wall_bounded() -> Self {
462 Self::standard_negative_x_wall().with_bounds(Self::standard_full_arena_bounds())
463 }
464
465 pub const fn standard_ceiling_bounded() -> Self {
466 Self::standard_ceiling().with_bounds(Self::standard_full_arena_bounds())
467 }
468
469 pub const fn standard_positive_y_wall_left_bounded() -> Self {
470 Self::standard_positive_y_wall().with_bounds(BallCollisionPlaneBounds::new(
471 glam::Vec3::new(
472 -STANDARD_ARENA_SIDE_WALL_X,
473 -STANDARD_ARENA_BACK_WALL_Y,
474 0.0,
475 ),
476 glam::Vec3::new(
477 -STANDARD_GOAL_MOUTH_HALF_WIDTH_X - STANDARD_BALL_RADIUS,
478 STANDARD_ARENA_BACK_WALL_Y,
479 STANDARD_ARENA_CEILING_Z,
480 ),
481 ))
482 }
483
484 pub const fn standard_positive_y_wall_right_bounded() -> Self {
485 Self::standard_positive_y_wall().with_bounds(BallCollisionPlaneBounds::new(
486 glam::Vec3::new(
487 STANDARD_GOAL_MOUTH_HALF_WIDTH_X + STANDARD_BALL_RADIUS,
488 -STANDARD_ARENA_BACK_WALL_Y,
489 0.0,
490 ),
491 glam::Vec3::new(
492 STANDARD_ARENA_SIDE_WALL_X,
493 STANDARD_ARENA_BACK_WALL_Y,
494 STANDARD_ARENA_CEILING_Z,
495 ),
496 ))
497 }
498
499 pub const fn standard_positive_y_wall_above_goal_bounded() -> Self {
500 Self::standard_positive_y_wall().with_bounds(BallCollisionPlaneBounds::new(
501 glam::Vec3::new(
502 -STANDARD_GOAL_MOUTH_HALF_WIDTH_X - STANDARD_BALL_RADIUS,
503 -STANDARD_ARENA_BACK_WALL_Y,
504 STANDARD_GOAL_MOUTH_HEIGHT_Z + STANDARD_BALL_RADIUS,
505 ),
506 glam::Vec3::new(
507 STANDARD_GOAL_MOUTH_HALF_WIDTH_X + STANDARD_BALL_RADIUS,
508 STANDARD_ARENA_BACK_WALL_Y,
509 STANDARD_ARENA_CEILING_Z,
510 ),
511 ))
512 }
513
514 pub const fn standard_negative_y_wall_left_bounded() -> Self {
515 Self::standard_negative_y_wall().with_bounds(BallCollisionPlaneBounds::new(
516 glam::Vec3::new(
517 -STANDARD_ARENA_SIDE_WALL_X,
518 -STANDARD_ARENA_BACK_WALL_Y,
519 0.0,
520 ),
521 glam::Vec3::new(
522 -STANDARD_GOAL_MOUTH_HALF_WIDTH_X - STANDARD_BALL_RADIUS,
523 STANDARD_ARENA_BACK_WALL_Y,
524 STANDARD_ARENA_CEILING_Z,
525 ),
526 ))
527 }
528
529 pub const fn standard_negative_y_wall_right_bounded() -> Self {
530 Self::standard_negative_y_wall().with_bounds(BallCollisionPlaneBounds::new(
531 glam::Vec3::new(
532 STANDARD_GOAL_MOUTH_HALF_WIDTH_X + STANDARD_BALL_RADIUS,
533 -STANDARD_ARENA_BACK_WALL_Y,
534 0.0,
535 ),
536 glam::Vec3::new(
537 STANDARD_ARENA_SIDE_WALL_X,
538 STANDARD_ARENA_BACK_WALL_Y,
539 STANDARD_ARENA_CEILING_Z,
540 ),
541 ))
542 }
543
544 pub const fn standard_negative_y_wall_above_goal_bounded() -> Self {
545 Self::standard_negative_y_wall().with_bounds(BallCollisionPlaneBounds::new(
546 glam::Vec3::new(
547 -STANDARD_GOAL_MOUTH_HALF_WIDTH_X - STANDARD_BALL_RADIUS,
548 -STANDARD_ARENA_BACK_WALL_Y,
549 STANDARD_GOAL_MOUTH_HEIGHT_Z + STANDARD_BALL_RADIUS,
550 ),
551 glam::Vec3::new(
552 STANDARD_GOAL_MOUTH_HALF_WIDTH_X + STANDARD_BALL_RADIUS,
553 STANDARD_ARENA_BACK_WALL_Y,
554 STANDARD_ARENA_CEILING_Z,
555 ),
556 ))
557 }
558
559 const fn standard_full_arena_bounds() -> BallCollisionPlaneBounds {
560 BallCollisionPlaneBounds::new(
561 glam::Vec3::new(
562 -STANDARD_ARENA_SIDE_WALL_X,
563 -STANDARD_ARENA_BACK_WALL_Y,
564 0.0,
565 ),
566 glam::Vec3::new(
567 STANDARD_ARENA_SIDE_WALL_X,
568 STANDARD_ARENA_BACK_WALL_Y,
569 STANDARD_ARENA_CEILING_Z,
570 ),
571 )
572 }
573
574 pub fn center_distance(self, position: glam::Vec3) -> f32 {
575 self.normal.dot(position) - self.distance_from_origin
576 }
577
578 pub fn penetration_depth(self, position: glam::Vec3, radius: f32) -> f32 {
579 radius - self.center_distance(position)
580 }
581}
582
583pub const fn standard_soccar_goal_line_prediction_planes() -> [BallCollisionPlane; 4] {
587 [
588 BallCollisionPlane::standard_ground_bounded(),
589 BallCollisionPlane::standard_positive_x_wall_bounded(),
590 BallCollisionPlane::standard_negative_x_wall_bounded(),
591 BallCollisionPlane::standard_ceiling_bounded(),
592 ]
593}
594
595pub const fn standard_soccar_prediction_planes() -> [BallCollisionPlane; 10] {
599 [
600 BallCollisionPlane::standard_ground_bounded(),
601 BallCollisionPlane::standard_positive_x_wall_bounded(),
602 BallCollisionPlane::standard_negative_x_wall_bounded(),
603 BallCollisionPlane::standard_positive_y_wall_left_bounded(),
604 BallCollisionPlane::standard_positive_y_wall_right_bounded(),
605 BallCollisionPlane::standard_positive_y_wall_above_goal_bounded(),
606 BallCollisionPlane::standard_negative_y_wall_left_bounded(),
607 BallCollisionPlane::standard_negative_y_wall_right_bounded(),
608 BallCollisionPlane::standard_negative_y_wall_above_goal_bounded(),
609 BallCollisionPlane::standard_ceiling_bounded(),
610 ]
611}
612
613pub fn standard_soccar_goal_frame_surfaces() -> [BallCollisionSurface; 6] {
614 [
615 BallCollisionSurface::Cylinder(BallCollisionCylinder::standard_positive_goal_left_post()),
616 BallCollisionSurface::Cylinder(BallCollisionCylinder::standard_positive_goal_right_post()),
617 BallCollisionSurface::Cylinder(BallCollisionCylinder::standard_positive_goal_crossbar()),
618 BallCollisionSurface::Cylinder(BallCollisionCylinder::standard_negative_goal_left_post()),
619 BallCollisionSurface::Cylinder(BallCollisionCylinder::standard_negative_goal_right_post()),
620 BallCollisionSurface::Cylinder(BallCollisionCylinder::standard_negative_goal_crossbar()),
621 ]
622}
623
624pub const fn standard_soccar_goal_line_prediction_field_surfaces() -> [BallCollisionSurface; 4] {
630 [
631 BallCollisionSurface::Plane(BallCollisionPlane::standard_ground_bounded()),
632 BallCollisionSurface::Plane(BallCollisionPlane::standard_positive_x_wall_bounded()),
633 BallCollisionSurface::Plane(BallCollisionPlane::standard_negative_x_wall_bounded()),
634 BallCollisionSurface::Plane(BallCollisionPlane::standard_ceiling_bounded()),
635 ]
636}
637
638pub fn standard_soccar_goal_line_prediction_surfaces() -> [BallCollisionSurface; 10] {
643 [
644 BallCollisionSurface::Plane(BallCollisionPlane::standard_ground_bounded()),
645 BallCollisionSurface::Plane(BallCollisionPlane::standard_positive_x_wall_bounded()),
646 BallCollisionSurface::Plane(BallCollisionPlane::standard_negative_x_wall_bounded()),
647 BallCollisionSurface::Plane(BallCollisionPlane::standard_ceiling_bounded()),
648 BallCollisionSurface::Cylinder(BallCollisionCylinder::standard_positive_goal_left_post()),
649 BallCollisionSurface::Cylinder(BallCollisionCylinder::standard_positive_goal_right_post()),
650 BallCollisionSurface::Cylinder(BallCollisionCylinder::standard_positive_goal_crossbar()),
651 BallCollisionSurface::Cylinder(BallCollisionCylinder::standard_negative_goal_left_post()),
652 BallCollisionSurface::Cylinder(BallCollisionCylinder::standard_negative_goal_right_post()),
653 BallCollisionSurface::Cylinder(BallCollisionCylinder::standard_negative_goal_crossbar()),
654 ]
655}
656
657pub fn standard_soccar_prediction_surfaces() -> [BallCollisionSurface; 16] {
661 [
662 BallCollisionSurface::Plane(BallCollisionPlane::standard_ground_bounded()),
663 BallCollisionSurface::Plane(BallCollisionPlane::standard_positive_x_wall_bounded()),
664 BallCollisionSurface::Plane(BallCollisionPlane::standard_negative_x_wall_bounded()),
665 BallCollisionSurface::Plane(BallCollisionPlane::standard_positive_y_wall_left_bounded()),
666 BallCollisionSurface::Plane(BallCollisionPlane::standard_positive_y_wall_right_bounded()),
667 BallCollisionSurface::Plane(
668 BallCollisionPlane::standard_positive_y_wall_above_goal_bounded(),
669 ),
670 BallCollisionSurface::Plane(BallCollisionPlane::standard_negative_y_wall_left_bounded()),
671 BallCollisionSurface::Plane(BallCollisionPlane::standard_negative_y_wall_right_bounded()),
672 BallCollisionSurface::Plane(
673 BallCollisionPlane::standard_negative_y_wall_above_goal_bounded(),
674 ),
675 BallCollisionSurface::Plane(BallCollisionPlane::standard_ceiling_bounded()),
676 BallCollisionSurface::Cylinder(BallCollisionCylinder::standard_positive_goal_left_post()),
677 BallCollisionSurface::Cylinder(BallCollisionCylinder::standard_positive_goal_right_post()),
678 BallCollisionSurface::Cylinder(BallCollisionCylinder::standard_positive_goal_crossbar()),
679 BallCollisionSurface::Cylinder(BallCollisionCylinder::standard_negative_goal_left_post()),
680 BallCollisionSurface::Cylinder(BallCollisionCylinder::standard_negative_goal_right_post()),
681 BallCollisionSurface::Cylinder(BallCollisionCylinder::standard_negative_goal_crossbar()),
682 ]
683}
684
685pub fn standard_soccar_goal_target_prediction_surfaces() -> [BallCollisionSurface; 16] {
689 standard_soccar_prediction_surfaces()
690}
691
692#[derive(Debug, Clone, Copy, PartialEq)]
693pub struct BallTrajectorySample {
694 pub time: f32,
695 pub rigid_body: boxcars::RigidBody,
696}
697
698#[derive(Debug, Clone, Copy, PartialEq)]
699pub struct BallGoalLineCrossing {
700 pub time: f32,
701 pub position: glam::Vec3,
702 pub velocity: Option<glam::Vec3>,
703 pub inside_goal_mouth: bool,
704}
705
706#[derive(Debug, Clone, Copy, PartialEq, Eq)]
707pub enum BallGoalTargetHitKind {
708 GoalLine,
709 BackWall,
710 GoalFrame,
711}
712
713#[derive(Debug, Clone, Copy, PartialEq)]
714pub struct BallGoalTargetHit {
715 pub time: f32,
716 pub position: glam::Vec3,
717 pub velocity: Option<glam::Vec3>,
718 pub hit_kind: BallGoalTargetHitKind,
719}
720
721#[derive(Debug, Clone, Copy, PartialEq)]
722pub struct BallGoalLineCrossingConfig {
723 pub target_goal_y: f32,
724 pub max_seconds: f32,
725 pub goal_mouth_half_width_x: f32,
726 pub goal_mouth_height_z: f32,
727 pub goal_mouth_margin: f32,
728}
729
730impl BallGoalLineCrossingConfig {
731 pub const fn team_zero_attacking_goal() -> Self {
732 Self {
733 target_goal_y: STANDARD_GOAL_LINE_Y,
734 max_seconds: 6.0,
735 goal_mouth_half_width_x: STANDARD_GOAL_MOUTH_HALF_WIDTH_X,
736 goal_mouth_height_z: STANDARD_GOAL_MOUTH_HEIGHT_Z,
737 goal_mouth_margin: STANDARD_GOAL_MOUTH_TRAJECTORY_MARGIN,
738 }
739 }
740
741 pub const fn team_one_attacking_goal() -> Self {
742 Self {
743 target_goal_y: -STANDARD_GOAL_LINE_Y,
744 max_seconds: 6.0,
745 goal_mouth_half_width_x: STANDARD_GOAL_MOUTH_HALF_WIDTH_X,
746 goal_mouth_height_z: STANDARD_GOAL_MOUTH_HEIGHT_Z,
747 goal_mouth_margin: STANDARD_GOAL_MOUTH_TRAJECTORY_MARGIN,
748 }
749 }
750
751 pub const fn attacking_goal(is_team_0: bool) -> Self {
752 if is_team_0 {
753 Self::team_zero_attacking_goal()
754 } else {
755 Self::team_one_attacking_goal()
756 }
757 }
758}
759
760#[derive(Debug, Clone, Copy, PartialEq)]
761pub struct BallTrajectoryError {
762 pub sample_count: usize,
763 pub max_position_error: f32,
764 pub rms_position_error: f32,
765 pub max_velocity_error: Option<f32>,
766 pub rms_velocity_error: Option<f32>,
767}
768
769pub fn advance_ball_free_flight(
776 initial: &boxcars::RigidBody,
777 duration_seconds: f32,
778 config: BallTrajectoryConfig,
779) -> boxcars::RigidBody {
780 if duration_seconds <= 0.0 {
781 return *initial;
782 }
783
784 let mut current = *initial;
785 let mut remaining = duration_seconds;
786 let fixed_step_seconds = config.fixed_step_seconds();
787 let mut steps = 0usize;
788
789 while remaining > f32::EPSILON && steps < MAX_INTEGRATION_STEPS {
790 let step_seconds = remaining.min(fixed_step_seconds);
791 current = advance_ball_free_flight_step(¤t, step_seconds, config);
792 remaining -= step_seconds;
793 steps += 1;
794 }
795
796 current
797}
798
799pub fn advance_ball_with_plane_bounces(
806 initial: &boxcars::RigidBody,
807 duration_seconds: f32,
808 trajectory_config: BallTrajectoryConfig,
809 bounce_config: BallBounceConfig,
810 planes: &[BallCollisionPlane],
811) -> boxcars::RigidBody {
812 if duration_seconds <= 0.0 {
813 return *initial;
814 }
815
816 let mut current = *initial;
817 let mut remaining = duration_seconds;
818 let fixed_step_seconds = trajectory_config.fixed_step_seconds();
819 let mut steps = 0usize;
820
821 while remaining > f32::EPSILON && steps < MAX_INTEGRATION_STEPS {
822 let step_seconds = remaining.min(fixed_step_seconds);
823 current = advance_ball_with_plane_bounces_step(
824 ¤t,
825 step_seconds,
826 trajectory_config,
827 bounce_config,
828 planes,
829 );
830 remaining -= step_seconds;
831 steps += 1;
832 }
833
834 current
835}
836
837pub fn advance_ball_with_surface_bounces(
838 initial: &boxcars::RigidBody,
839 duration_seconds: f32,
840 trajectory_config: BallTrajectoryConfig,
841 bounce_config: BallBounceConfig,
842 surfaces: &[BallCollisionSurface],
843) -> boxcars::RigidBody {
844 if duration_seconds <= 0.0 {
845 return *initial;
846 }
847
848 let mut current = *initial;
849 let mut remaining = duration_seconds;
850 let fixed_step_seconds = trajectory_config.fixed_step_seconds();
851 let mut steps = 0usize;
852
853 while remaining > f32::EPSILON && steps < MAX_INTEGRATION_STEPS {
854 let step_seconds = remaining.min(fixed_step_seconds);
855 current = advance_ball_with_surface_bounces_step(
856 ¤t,
857 step_seconds,
858 trajectory_config,
859 bounce_config,
860 surfaces,
861 );
862 remaining -= step_seconds;
863 steps += 1;
864 }
865
866 current
867}
868
869fn advance_ball_free_flight_step(
870 current: &boxcars::RigidBody,
871 step_seconds: f32,
872 config: BallTrajectoryConfig,
873) -> boxcars::RigidBody {
874 let mut advanced = *current;
875 let position = vec_to_glam(¤t.location);
876 let velocity = current
877 .linear_velocity
878 .as_ref()
879 .map(vec_to_glam)
880 .unwrap_or(glam::Vec3::ZERO);
881
882 let mut next_velocity = velocity + config.gravity * step_seconds;
883 next_velocity = clamp_speed(next_velocity, config.max_speed);
884
885 let next_position = match config.integration {
886 BallTrajectoryIntegration::SemiImplicitEuler => position + next_velocity * step_seconds,
887 BallTrajectoryIntegration::ClosedForm => {
888 position + velocity * step_seconds + 0.5 * config.gravity * step_seconds.powi(2)
889 }
890 };
891
892 advanced.location = glam_to_vec(&next_position);
893 advanced.linear_velocity = Some(glam_to_vec(&next_velocity));
894 advanced.rotation = apply_velocities_to_rigid_body(&advanced, step_seconds).rotation;
895 advanced
896}
897
898fn advance_ball_with_plane_bounces_step(
899 current: &boxcars::RigidBody,
900 step_seconds: f32,
901 trajectory_config: BallTrajectoryConfig,
902 bounce_config: BallBounceConfig,
903 planes: &[BallCollisionPlane],
904) -> boxcars::RigidBody {
905 ball_with_plane_bounces_step_segments(
906 current,
907 step_seconds,
908 trajectory_config,
909 bounce_config,
910 planes,
911 )
912 .last()
913 .map(|segment| segment.end)
914 .unwrap_or(*current)
915}
916
917fn advance_ball_with_surface_bounces_step(
918 current: &boxcars::RigidBody,
919 step_seconds: f32,
920 trajectory_config: BallTrajectoryConfig,
921 bounce_config: BallBounceConfig,
922 surfaces: &[BallCollisionSurface],
923) -> boxcars::RigidBody {
924 ball_with_surface_bounces_step_segments(
925 current,
926 step_seconds,
927 trajectory_config,
928 bounce_config,
929 surfaces,
930 )
931 .last()
932 .map(|segment| segment.end)
933 .unwrap_or(*current)
934}
935
936#[derive(Debug, Clone, Copy, PartialEq)]
937struct BallTrajectorySegment {
938 duration: f32,
939 start: boxcars::RigidBody,
940 end: boxcars::RigidBody,
941}
942
943fn ball_with_plane_bounces_step_segments(
944 current: &boxcars::RigidBody,
945 step_seconds: f32,
946 trajectory_config: BallTrajectoryConfig,
947 bounce_config: BallBounceConfig,
948 planes: &[BallCollisionPlane],
949) -> Vec<BallTrajectorySegment> {
950 let mut current = *current;
951 let mut remaining = step_seconds;
952 let mut collisions = 0usize;
953 let mut segments = Vec::new();
954
955 while remaining > f32::EPSILON && collisions <= MAX_COLLISIONS_PER_STEP {
956 let free_flight_next =
957 advance_ball_free_flight_step(¤t, remaining, trajectory_config);
958 let Some(impact) =
959 first_plane_impact(¤t, &free_flight_next, bounce_config.radius, planes)
960 else {
961 segments.push(BallTrajectorySegment {
962 duration: remaining,
963 start: current,
964 end: free_flight_next,
965 });
966 return segments;
967 };
968
969 let impact_time = remaining * impact.fraction;
970 let mut impact_body =
971 advance_ball_free_flight_step(¤t, impact_time, trajectory_config);
972 snap_ball_to_plane(&mut impact_body, impact.plane, bounce_config.radius);
973 let bounced = bounce_ball_off_surface(
974 &impact_body,
975 impact.plane.normal,
976 bounce_config,
977 trajectory_config,
978 );
979
980 if impact_time <= COLLISION_TIME_EPSILON && bounced == impact_body {
981 let resolved = resolve_ball_plane_collisions(
982 &free_flight_next,
983 bounce_config,
984 trajectory_config,
985 planes,
986 );
987 segments.push(BallTrajectorySegment {
988 duration: remaining,
989 start: current,
990 end: resolved,
991 });
992 return segments;
993 }
994
995 segments.push(BallTrajectorySegment {
996 duration: impact_time,
997 start: current,
998 end: impact_body,
999 });
1000 current = bounced;
1001 remaining -= impact_time;
1002 collisions += 1;
1003
1004 if impact_time <= COLLISION_TIME_EPSILON {
1005 remaining = (remaining - COLLISION_TIME_EPSILON).max(0.0);
1006 }
1007 }
1008
1009 segments
1010}
1011
1012fn ball_with_surface_bounces_step_segments(
1013 current: &boxcars::RigidBody,
1014 step_seconds: f32,
1015 trajectory_config: BallTrajectoryConfig,
1016 bounce_config: BallBounceConfig,
1017 surfaces: &[BallCollisionSurface],
1018) -> Vec<BallTrajectorySegment> {
1019 let mut current = *current;
1020 let mut remaining = step_seconds;
1021 let mut collisions = 0usize;
1022 let mut segments = Vec::new();
1023
1024 while remaining > f32::EPSILON && collisions <= MAX_COLLISIONS_PER_STEP {
1025 let free_flight_next =
1026 advance_ball_free_flight_step(¤t, remaining, trajectory_config);
1027 let Some(impact) =
1028 first_surface_impact(¤t, &free_flight_next, bounce_config.radius, surfaces)
1029 else {
1030 segments.push(BallTrajectorySegment {
1031 duration: remaining,
1032 start: current,
1033 end: free_flight_next,
1034 });
1035 return segments;
1036 };
1037
1038 let impact_time = remaining * impact.fraction;
1039 let mut impact_body =
1040 advance_ball_free_flight_step(¤t, impact_time, trajectory_config);
1041 snap_ball_to_surface(
1042 &mut impact_body,
1043 impact.surface,
1044 impact.normal,
1045 bounce_config.radius,
1046 );
1047 let bounced = bounce_ball_off_surface(
1048 &impact_body,
1049 impact.normal,
1050 bounce_config,
1051 trajectory_config,
1052 );
1053
1054 if impact_time <= COLLISION_TIME_EPSILON && bounced == impact_body {
1055 let resolved = resolve_ball_surface_collisions(
1056 &free_flight_next,
1057 bounce_config,
1058 trajectory_config,
1059 surfaces,
1060 );
1061 segments.push(BallTrajectorySegment {
1062 duration: remaining,
1063 start: current,
1064 end: resolved,
1065 });
1066 return segments;
1067 }
1068
1069 segments.push(BallTrajectorySegment {
1070 duration: impact_time,
1071 start: current,
1072 end: impact_body,
1073 });
1074 current = bounced;
1075 remaining -= impact_time;
1076 collisions += 1;
1077
1078 if impact_time <= COLLISION_TIME_EPSILON {
1079 remaining = (remaining - COLLISION_TIME_EPSILON).max(0.0);
1080 }
1081 }
1082
1083 segments
1084}
1085
1086pub fn bounce_ball_off_surface(
1087 rigid_body: &boxcars::RigidBody,
1088 surface_normal: glam::Vec3,
1089 bounce_config: BallBounceConfig,
1090 trajectory_config: BallTrajectoryConfig,
1091) -> boxcars::RigidBody {
1092 if !surface_normal.is_finite() || surface_normal.length_squared() <= f32::EPSILON {
1093 return *rigid_body;
1094 }
1095
1096 let normal = surface_normal.normalize();
1097 let velocity = rigid_body
1098 .linear_velocity
1099 .as_ref()
1100 .map(vec_to_glam)
1101 .unwrap_or(glam::Vec3::ZERO);
1102 if velocity.dot(normal) >= 0.0 {
1103 return *rigid_body;
1104 }
1105
1106 let angular_velocity = rigid_body
1107 .angular_velocity
1108 .as_ref()
1109 .map(vec_to_glam)
1110 .unwrap_or(glam::Vec3::ZERO);
1111 let perpendicular_velocity = velocity.dot(normal) * normal;
1112 let parallel_velocity = velocity - perpendicular_velocity;
1113 if perpendicular_velocity.length() <= RESTING_CONTACT_NORMAL_SPEED_THRESHOLD {
1114 let mut rested = *rigid_body;
1115 rested.linear_velocity = Some(glam_to_vec(¶llel_velocity));
1116 return rested;
1117 }
1118
1119 let spin_velocity = bounce_config.radius * normal.cross(angular_velocity);
1120 let slip_velocity = parallel_velocity + spin_velocity;
1121
1122 let delta_perpendicular_velocity = -(1.0 + bounce_config.restitution) * perpendicular_velocity;
1123 let delta_parallel_velocity = if slip_velocity.length_squared() <= f32::EPSILON {
1124 glam::Vec3::ZERO
1125 } else {
1126 let ratio = perpendicular_velocity.length() / slip_velocity.length();
1127 let impulse_fraction = 1.0f32.min(bounce_config.tangential_ratio_scale * ratio);
1128 -impulse_fraction * bounce_config.tangential_friction * slip_velocity
1129 };
1130
1131 let next_velocity = clamp_speed(
1132 velocity + delta_perpendicular_velocity + delta_parallel_velocity,
1133 trajectory_config.max_speed,
1134 );
1135 let next_angular_velocity = angular_velocity
1136 + bounce_config.angular_coupling
1137 * bounce_config.radius
1138 * delta_parallel_velocity.cross(normal);
1139
1140 let mut bounced = *rigid_body;
1141 bounced.linear_velocity = Some(glam_to_vec(&next_velocity));
1142 bounced.angular_velocity = Some(glam_to_vec(&next_angular_velocity));
1143 bounced
1144}
1145
1146#[derive(Debug, Clone, Copy, PartialEq)]
1147struct PlaneImpact {
1148 plane: BallCollisionPlane,
1149 fraction: f32,
1150}
1151
1152#[derive(Debug, Clone, Copy, PartialEq)]
1153struct SurfaceImpact {
1154 surface: BallCollisionSurface,
1155 normal: glam::Vec3,
1156 fraction: f32,
1157}
1158
1159fn first_plane_impact(
1160 start: &boxcars::RigidBody,
1161 end: &boxcars::RigidBody,
1162 radius: f32,
1163 planes: &[BallCollisionPlane],
1164) -> Option<PlaneImpact> {
1165 let start_position = vec_to_glam(&start.location);
1166 let end_position = vec_to_glam(&end.location);
1167 let displacement = end_position - start_position;
1168 let mut first_impact: Option<PlaneImpact> = None;
1169
1170 for &plane in planes {
1171 let movement_toward_plane = displacement.dot(plane.normal);
1172 if movement_toward_plane >= -f32::EPSILON {
1173 continue;
1174 }
1175
1176 let start_distance = plane.center_distance(start_position);
1177 let end_distance = plane.center_distance(end_position);
1178 if start_distance < radius - f32::EPSILON {
1179 if plane.contains_impact_point(start_position) {
1180 return Some(PlaneImpact {
1181 plane,
1182 fraction: 0.0,
1183 });
1184 }
1185 continue;
1186 }
1187 if end_distance >= radius {
1188 continue;
1189 }
1190
1191 let distance_delta = end_distance - start_distance;
1192 if distance_delta >= -f32::EPSILON {
1193 continue;
1194 }
1195
1196 let fraction = ((radius - start_distance) / distance_delta).clamp(0.0, 1.0);
1197 let impact_position = start_position + displacement * fraction;
1198 if !plane.contains_impact_point(impact_position) {
1199 continue;
1200 }
1201 if first_impact.is_none_or(|impact| fraction < impact.fraction) {
1202 first_impact = Some(PlaneImpact { plane, fraction });
1203 }
1204 }
1205
1206 first_impact
1207}
1208
1209fn first_surface_impact(
1210 start: &boxcars::RigidBody,
1211 end: &boxcars::RigidBody,
1212 radius: f32,
1213 surfaces: &[BallCollisionSurface],
1214) -> Option<SurfaceImpact> {
1215 let mut first_impact: Option<SurfaceImpact> = None;
1216
1217 for &surface in surfaces {
1218 let impact = match surface {
1219 BallCollisionSurface::Plane(plane) => {
1220 plane_impact(start, end, radius, plane).map(|impact| SurfaceImpact {
1221 surface,
1222 normal: plane.normal,
1223 fraction: impact.fraction,
1224 })
1225 }
1226 BallCollisionSurface::Cylinder(cylinder) => {
1227 cylinder_impact(start, end, radius, cylinder).map(|(fraction, normal)| {
1228 SurfaceImpact {
1229 surface,
1230 normal,
1231 fraction,
1232 }
1233 })
1234 }
1235 BallCollisionSurface::ConcaveCylinder(cylinder) => {
1236 concave_cylinder_impact(start, end, radius, cylinder).map(|(fraction, normal)| {
1237 SurfaceImpact {
1238 surface,
1239 normal,
1240 fraction,
1241 }
1242 })
1243 }
1244 };
1245
1246 if let Some(impact) = impact
1247 && first_impact.is_none_or(|first| impact.fraction < first.fraction)
1248 {
1249 first_impact = Some(impact);
1250 }
1251 }
1252
1253 first_impact
1254}
1255
1256fn plane_impact(
1257 start: &boxcars::RigidBody,
1258 end: &boxcars::RigidBody,
1259 radius: f32,
1260 plane: BallCollisionPlane,
1261) -> Option<PlaneImpact> {
1262 let start_position = vec_to_glam(&start.location);
1263 let end_position = vec_to_glam(&end.location);
1264 let displacement = end_position - start_position;
1265 let movement_toward_plane = displacement.dot(plane.normal);
1266 if movement_toward_plane >= -f32::EPSILON {
1267 return None;
1268 }
1269
1270 let start_distance = plane.center_distance(start_position);
1271 let end_distance = plane.center_distance(end_position);
1272 if start_distance < radius - f32::EPSILON {
1273 return plane
1274 .contains_impact_point(start_position)
1275 .then_some(PlaneImpact {
1276 plane,
1277 fraction: 0.0,
1278 });
1279 }
1280 if end_distance >= radius {
1281 return None;
1282 }
1283
1284 let distance_delta = end_distance - start_distance;
1285 if distance_delta >= -f32::EPSILON {
1286 return None;
1287 }
1288
1289 let fraction = ((radius - start_distance) / distance_delta).clamp(0.0, 1.0);
1290 let impact_position = start_position + displacement * fraction;
1291 plane
1292 .contains_impact_point(impact_position)
1293 .then_some(PlaneImpact { plane, fraction })
1294}
1295
1296fn cylinder_impact(
1297 start: &boxcars::RigidBody,
1298 end: &boxcars::RigidBody,
1299 ball_radius: f32,
1300 cylinder: BallCollisionCylinder,
1301) -> Option<(f32, glam::Vec3)> {
1302 if cylinder.radius <= 0.0 {
1303 return None;
1304 }
1305
1306 let start_position = vec_to_glam(&start.location);
1307 let end_position = vec_to_glam(&end.location);
1308 let start_perp = cylinder_perpendicular(cylinder.axis, start_position);
1309 let end_perp = cylinder_perpendicular(cylinder.axis, end_position);
1310 let center_perp = cylinder_perpendicular(cylinder.axis, cylinder.center);
1311 let displacement = end_perp - start_perp;
1312 let from_center = start_perp - center_perp;
1313 let contact_radius = cylinder.radius + ball_radius;
1314 let start_axis = cylinder_axis_value(cylinder.axis, start_position);
1315 let end_axis = cylinder_axis_value(cylinder.axis, end_position);
1316 let start_distance_sq = from_center.length_squared();
1317
1318 if start_distance_sq < contact_radius.powi(2) - f32::EPSILON {
1319 return cylinder_axis_value_is_in_bounds(cylinder, start_axis, ball_radius).then(|| {
1320 let normal = cylinder_normal(cylinder.axis, start_perp, center_perp, displacement);
1321 (0.0, normal)
1322 });
1323 }
1324
1325 let a = displacement.dot(displacement);
1326 if a <= f32::EPSILON {
1327 return None;
1328 }
1329
1330 let b = 2.0 * from_center.dot(displacement);
1331 if b >= 0.0 {
1332 return None;
1333 }
1334
1335 let c = start_distance_sq - contact_radius.powi(2);
1336 let discriminant = b * b - 4.0 * a * c;
1337 if discriminant < 0.0 {
1338 return None;
1339 }
1340
1341 let fraction = (-b - discriminant.sqrt()) / (2.0 * a);
1342 if !(-f32::EPSILON..=1.0 + f32::EPSILON).contains(&fraction) {
1343 return None;
1344 }
1345 let fraction = fraction.clamp(0.0, 1.0);
1346 let impact_axis = start_axis + (end_axis - start_axis) * fraction;
1347 if !cylinder_axis_value_is_in_bounds(cylinder, impact_axis, ball_radius) {
1348 return None;
1349 }
1350
1351 let impact_perp = start_perp + displacement * fraction;
1352 let normal = cylinder_normal(cylinder.axis, impact_perp, center_perp, displacement);
1353 Some((fraction, normal))
1354}
1355
1356fn concave_cylinder_impact(
1357 start: &boxcars::RigidBody,
1358 end: &boxcars::RigidBody,
1359 ball_radius: f32,
1360 cylinder: BallCollisionConcaveCylinder,
1361) -> Option<(f32, glam::Vec3)> {
1362 let contact_radius = cylinder.radius - ball_radius;
1363 if contact_radius <= 0.0 {
1364 return None;
1365 }
1366
1367 let start_position = vec_to_glam(&start.location);
1368 let end_position = vec_to_glam(&end.location);
1369 let start_perp = cylinder_perpendicular(cylinder.axis, start_position);
1370 let end_perp = cylinder_perpendicular(cylinder.axis, end_position);
1371 let center_perp = cylinder_perpendicular(cylinder.axis, cylinder.center);
1372 let displacement = end_perp - start_perp;
1373 let from_center = start_perp - center_perp;
1374 let start_axis = cylinder_axis_value(cylinder.axis, start_position);
1375 let end_axis = cylinder_axis_value(cylinder.axis, end_position);
1376 let start_distance_sq = from_center.length_squared();
1377
1378 if start_distance_sq > contact_radius.powi(2) + f32::EPSILON {
1379 return concave_cylinder_contains_center_position(cylinder, start_position, ball_radius)
1380 .then(|| {
1381 let normal =
1382 concave_cylinder_normal(cylinder.axis, start_perp, center_perp, displacement);
1383 (0.0, normal)
1384 });
1385 }
1386
1387 let a = displacement.dot(displacement);
1388 if a <= f32::EPSILON {
1389 return None;
1390 }
1391
1392 let b = 2.0 * from_center.dot(displacement);
1393 if b <= 0.0 {
1394 return None;
1395 }
1396
1397 let c = start_distance_sq - contact_radius.powi(2);
1398 let discriminant = b * b - 4.0 * a * c;
1399 if discriminant < 0.0 {
1400 return None;
1401 }
1402
1403 let fraction = (-b + discriminant.sqrt()) / (2.0 * a);
1404 if !(-f32::EPSILON..=1.0 + f32::EPSILON).contains(&fraction) {
1405 return None;
1406 }
1407 let fraction = fraction.clamp(0.0, 1.0);
1408 let impact_axis = start_axis + (end_axis - start_axis) * fraction;
1409 if !concave_cylinder_axis_value_is_in_bounds(cylinder, impact_axis, ball_radius) {
1410 return None;
1411 }
1412
1413 let impact_position = vec_to_glam(&start.location) + (end_position - start_position) * fraction;
1414 if !concave_cylinder_contains_center_position(cylinder, impact_position, ball_radius) {
1415 return None;
1416 }
1417
1418 let impact_perp = start_perp + displacement * fraction;
1419 let normal = concave_cylinder_normal(cylinder.axis, impact_perp, center_perp, displacement);
1420 Some((fraction, normal))
1421}
1422
1423fn cylinder_perpendicular(axis: BallCollisionCylinderAxis, position: glam::Vec3) -> glam::Vec2 {
1424 match axis {
1425 BallCollisionCylinderAxis::X => glam::Vec2::new(position.y, position.z),
1426 BallCollisionCylinderAxis::Y => glam::Vec2::new(position.x, position.z),
1427 BallCollisionCylinderAxis::Z => glam::Vec2::new(position.x, position.y),
1428 }
1429}
1430
1431fn cylinder_axis_value(axis: BallCollisionCylinderAxis, position: glam::Vec3) -> f32 {
1432 match axis {
1433 BallCollisionCylinderAxis::X => position.x,
1434 BallCollisionCylinderAxis::Y => position.y,
1435 BallCollisionCylinderAxis::Z => position.z,
1436 }
1437}
1438
1439fn cylinder_axis_value_is_in_bounds(
1440 cylinder: BallCollisionCylinder,
1441 axis_value: f32,
1442 ball_radius: f32,
1443) -> bool {
1444 axis_value + ball_radius + ARENA_BOUND_EPSILON >= cylinder.min_axis
1445 && axis_value - ball_radius - ARENA_BOUND_EPSILON <= cylinder.max_axis
1446}
1447
1448fn concave_cylinder_axis_value_is_in_bounds(
1449 cylinder: BallCollisionConcaveCylinder,
1450 axis_value: f32,
1451 ball_radius: f32,
1452) -> bool {
1453 axis_value + ball_radius + ARENA_BOUND_EPSILON >= cylinder.min_axis
1454 && axis_value - ball_radius - ARENA_BOUND_EPSILON <= cylinder.max_axis
1455}
1456
1457fn concave_cylinder_contains_center_position(
1458 cylinder: BallCollisionConcaveCylinder,
1459 position: glam::Vec3,
1460 ball_radius: f32,
1461) -> bool {
1462 concave_cylinder_axis_value_is_in_bounds(
1463 cylinder,
1464 cylinder_axis_value(cylinder.axis, position),
1465 ball_radius,
1466 ) && cylinder.bounds.contains(position)
1467}
1468
1469fn cylinder_normal(
1470 axis: BallCollisionCylinderAxis,
1471 impact_perp: glam::Vec2,
1472 center_perp: glam::Vec2,
1473 displacement: glam::Vec2,
1474) -> glam::Vec3 {
1475 let normal_perp = (impact_perp - center_perp).normalize_or_zero();
1476 let normal_perp = if normal_perp.length_squared() <= f32::EPSILON {
1477 -displacement.normalize_or_zero()
1478 } else {
1479 normal_perp
1480 };
1481
1482 match axis {
1483 BallCollisionCylinderAxis::X => glam::Vec3::new(0.0, normal_perp.x, normal_perp.y),
1484 BallCollisionCylinderAxis::Y => glam::Vec3::new(normal_perp.x, 0.0, normal_perp.y),
1485 BallCollisionCylinderAxis::Z => glam::Vec3::new(normal_perp.x, normal_perp.y, 0.0),
1486 }
1487 .normalize_or_zero()
1488}
1489
1490fn concave_cylinder_normal(
1491 axis: BallCollisionCylinderAxis,
1492 impact_perp: glam::Vec2,
1493 center_perp: glam::Vec2,
1494 displacement: glam::Vec2,
1495) -> glam::Vec3 {
1496 let normal_perp = (center_perp - impact_perp).normalize_or_zero();
1497 let normal_perp = if normal_perp.length_squared() <= f32::EPSILON {
1498 -displacement.normalize_or_zero()
1499 } else {
1500 normal_perp
1501 };
1502
1503 match axis {
1504 BallCollisionCylinderAxis::X => glam::Vec3::new(0.0, normal_perp.x, normal_perp.y),
1505 BallCollisionCylinderAxis::Y => glam::Vec3::new(normal_perp.x, 0.0, normal_perp.y),
1506 BallCollisionCylinderAxis::Z => glam::Vec3::new(normal_perp.x, normal_perp.y, 0.0),
1507 }
1508 .normalize_or_zero()
1509}
1510
1511fn snap_ball_to_plane(rigid_body: &mut boxcars::RigidBody, plane: BallCollisionPlane, radius: f32) {
1512 let position = vec_to_glam(&rigid_body.location);
1513 let center_distance = plane.center_distance(position);
1514 rigid_body.location = glam_to_vec(&(position + plane.normal * (radius - center_distance)));
1515}
1516
1517fn snap_ball_to_surface(
1518 rigid_body: &mut boxcars::RigidBody,
1519 surface: BallCollisionSurface,
1520 normal: glam::Vec3,
1521 ball_radius: f32,
1522) {
1523 match surface {
1524 BallCollisionSurface::Plane(plane) => snap_ball_to_plane(rigid_body, plane, ball_radius),
1525 BallCollisionSurface::Cylinder(cylinder) => {
1526 let position = vec_to_glam(&rigid_body.location);
1527 let center_perp = cylinder_perpendicular(cylinder.axis, cylinder.center);
1528 let current_perp = cylinder_perpendicular(cylinder.axis, position);
1529 let normal_perp = cylinder_perpendicular(cylinder.axis, normal).normalize_or_zero();
1530 let normal_perp = if normal_perp.length_squared() <= f32::EPSILON {
1531 (current_perp - center_perp).normalize_or_zero()
1532 } else {
1533 normal_perp
1534 };
1535 let snapped_perp = center_perp + normal_perp * (ball_radius + cylinder.radius);
1536 let snapped_position =
1537 with_cylinder_perpendicular(cylinder.axis, position, snapped_perp);
1538 rigid_body.location = glam_to_vec(&snapped_position);
1539 }
1540 BallCollisionSurface::ConcaveCylinder(cylinder) => {
1541 let position = vec_to_glam(&rigid_body.location);
1542 let center_perp = cylinder_perpendicular(cylinder.axis, cylinder.center);
1543 let current_perp = cylinder_perpendicular(cylinder.axis, position);
1544 let normal_perp = cylinder_perpendicular(cylinder.axis, normal).normalize_or_zero();
1545 let normal_perp = if normal_perp.length_squared() <= f32::EPSILON {
1546 (center_perp - current_perp).normalize_or_zero()
1547 } else {
1548 normal_perp
1549 };
1550 let contact_radius = (cylinder.radius - ball_radius).max(0.0);
1551 let snapped_perp = center_perp - normal_perp * contact_radius;
1552 let snapped_position =
1553 with_cylinder_perpendicular(cylinder.axis, position, snapped_perp);
1554 rigid_body.location = glam_to_vec(&snapped_position);
1555 }
1556 }
1557}
1558
1559fn with_cylinder_perpendicular(
1560 axis: BallCollisionCylinderAxis,
1561 position: glam::Vec3,
1562 perpendicular: glam::Vec2,
1563) -> glam::Vec3 {
1564 match axis {
1565 BallCollisionCylinderAxis::X => {
1566 glam::Vec3::new(position.x, perpendicular.x, perpendicular.y)
1567 }
1568 BallCollisionCylinderAxis::Y => {
1569 glam::Vec3::new(perpendicular.x, position.y, perpendicular.y)
1570 }
1571 BallCollisionCylinderAxis::Z => {
1572 glam::Vec3::new(perpendicular.x, perpendicular.y, position.z)
1573 }
1574 }
1575}
1576
1577fn resolve_ball_plane_collisions(
1578 rigid_body: &boxcars::RigidBody,
1579 bounce_config: BallBounceConfig,
1580 trajectory_config: BallTrajectoryConfig,
1581 planes: &[BallCollisionPlane],
1582) -> boxcars::RigidBody {
1583 let mut resolved = *rigid_body;
1584 for plane in planes {
1585 let position = vec_to_glam(&resolved.location);
1586 let penetration_depth = plane.penetration_depth(position, bounce_config.radius);
1587 if penetration_depth <= 0.0 || !plane.contains_impact_point(position) {
1588 continue;
1589 }
1590
1591 snap_ball_to_plane(&mut resolved, *plane, bounce_config.radius);
1592 resolved =
1593 bounce_ball_off_surface(&resolved, plane.normal, bounce_config, trajectory_config);
1594 }
1595
1596 resolved
1597}
1598
1599fn resolve_ball_surface_collisions(
1600 rigid_body: &boxcars::RigidBody,
1601 bounce_config: BallBounceConfig,
1602 trajectory_config: BallTrajectoryConfig,
1603 surfaces: &[BallCollisionSurface],
1604) -> boxcars::RigidBody {
1605 let mut resolved = *rigid_body;
1606 for surface in surfaces {
1607 let Some(normal) = surface_penetration_normal(&resolved, bounce_config.radius, *surface)
1608 else {
1609 continue;
1610 };
1611
1612 snap_ball_to_surface(&mut resolved, *surface, normal, bounce_config.radius);
1613 resolved = bounce_ball_off_surface(&resolved, normal, bounce_config, trajectory_config);
1614 }
1615
1616 resolved
1617}
1618
1619fn surface_penetration_normal(
1620 rigid_body: &boxcars::RigidBody,
1621 ball_radius: f32,
1622 surface: BallCollisionSurface,
1623) -> Option<glam::Vec3> {
1624 let position = vec_to_glam(&rigid_body.location);
1625 match surface {
1626 BallCollisionSurface::Plane(plane) => {
1627 let penetration_depth = plane.penetration_depth(position, ball_radius);
1628 (penetration_depth > 0.0 && plane.contains_impact_point(position))
1629 .then_some(plane.normal)
1630 }
1631 BallCollisionSurface::Cylinder(cylinder) => {
1632 let axis_value = cylinder_axis_value(cylinder.axis, position);
1633 if !cylinder_axis_value_is_in_bounds(cylinder, axis_value, ball_radius) {
1634 return None;
1635 }
1636 let center_perp = cylinder_perpendicular(cylinder.axis, cylinder.center);
1637 let current_perp = cylinder_perpendicular(cylinder.axis, position);
1638 let offset = current_perp - center_perp;
1639 let expanded_radius = ball_radius + cylinder.radius;
1640 if offset.length_squared() >= expanded_radius.powi(2) {
1641 return None;
1642 }
1643 Some(cylinder_normal(
1644 cylinder.axis,
1645 current_perp,
1646 center_perp,
1647 glam::Vec2::ZERO,
1648 ))
1649 }
1650 BallCollisionSurface::ConcaveCylinder(cylinder) => {
1651 if !concave_cylinder_contains_center_position(cylinder, position, ball_radius) {
1652 return None;
1653 }
1654 let contact_radius = cylinder.radius - ball_radius;
1655 if contact_radius <= 0.0 {
1656 return None;
1657 }
1658 let center_perp = cylinder_perpendicular(cylinder.axis, cylinder.center);
1659 let current_perp = cylinder_perpendicular(cylinder.axis, position);
1660 let offset = current_perp - center_perp;
1661 if offset.length_squared() <= contact_radius.powi(2) {
1662 return None;
1663 }
1664 Some(concave_cylinder_normal(
1665 cylinder.axis,
1666 current_perp,
1667 center_perp,
1668 glam::Vec2::ZERO,
1669 ))
1670 }
1671 }
1672}
1673
1674fn clamp_speed(velocity: glam::Vec3, max_speed: f32) -> glam::Vec3 {
1675 if !max_speed.is_finite() || max_speed <= 0.0 {
1676 return velocity;
1677 }
1678 let speed = velocity.length();
1679 if speed > max_speed {
1680 velocity * (max_speed / speed)
1681 } else {
1682 velocity
1683 }
1684}
1685
1686pub fn predict_ball_free_flight_trajectory(
1689 initial: &boxcars::RigidBody,
1690 duration_seconds: f32,
1691 sample_interval_seconds: f32,
1692 config: BallTrajectoryConfig,
1693) -> Vec<BallTrajectorySample> {
1694 if duration_seconds < 0.0 || sample_interval_seconds <= 0.0 {
1695 return Vec::new();
1696 }
1697
1698 let mut samples = vec![BallTrajectorySample {
1699 time: 0.0,
1700 rigid_body: *initial,
1701 }];
1702 if duration_seconds == 0.0 {
1703 return samples;
1704 }
1705
1706 let mut elapsed = 0.0;
1707 let mut current = *initial;
1708 while elapsed < duration_seconds {
1709 let step = (duration_seconds - elapsed).min(sample_interval_seconds);
1710 current = advance_ball_free_flight(¤t, step, config);
1711 elapsed += step;
1712 samples.push(BallTrajectorySample {
1713 time: elapsed.min(duration_seconds),
1714 rigid_body: current,
1715 });
1716 }
1717
1718 samples
1719}
1720
1721pub fn predict_ball_with_plane_bounces_trajectory(
1722 initial: &boxcars::RigidBody,
1723 duration_seconds: f32,
1724 sample_interval_seconds: f32,
1725 trajectory_config: BallTrajectoryConfig,
1726 bounce_config: BallBounceConfig,
1727 planes: &[BallCollisionPlane],
1728) -> Vec<BallTrajectorySample> {
1729 if duration_seconds < 0.0 || sample_interval_seconds <= 0.0 {
1730 return Vec::new();
1731 }
1732
1733 let mut samples = vec![BallTrajectorySample {
1734 time: 0.0,
1735 rigid_body: *initial,
1736 }];
1737 if duration_seconds == 0.0 {
1738 return samples;
1739 }
1740
1741 let mut elapsed = 0.0;
1742 let mut current = *initial;
1743 while elapsed < duration_seconds {
1744 let step = (duration_seconds - elapsed).min(sample_interval_seconds);
1745 current = advance_ball_with_plane_bounces(
1746 ¤t,
1747 step,
1748 trajectory_config,
1749 bounce_config,
1750 planes,
1751 );
1752 elapsed += step;
1753 samples.push(BallTrajectorySample {
1754 time: elapsed.min(duration_seconds),
1755 rigid_body: current,
1756 });
1757 }
1758
1759 samples
1760}
1761
1762pub fn predict_ball_with_surface_bounces_trajectory(
1763 initial: &boxcars::RigidBody,
1764 duration_seconds: f32,
1765 sample_interval_seconds: f32,
1766 trajectory_config: BallTrajectoryConfig,
1767 bounce_config: BallBounceConfig,
1768 surfaces: &[BallCollisionSurface],
1769) -> Vec<BallTrajectorySample> {
1770 if duration_seconds < 0.0 || sample_interval_seconds <= 0.0 {
1771 return Vec::new();
1772 }
1773
1774 let mut samples = vec![BallTrajectorySample {
1775 time: 0.0,
1776 rigid_body: *initial,
1777 }];
1778 if duration_seconds == 0.0 {
1779 return samples;
1780 }
1781
1782 let mut elapsed = 0.0;
1783 let mut current = *initial;
1784 while elapsed < duration_seconds {
1785 let step = (duration_seconds - elapsed).min(sample_interval_seconds);
1786 current = advance_ball_with_surface_bounces(
1787 ¤t,
1788 step,
1789 trajectory_config,
1790 bounce_config,
1791 surfaces,
1792 );
1793 elapsed += step;
1794 samples.push(BallTrajectorySample {
1795 time: elapsed.min(duration_seconds),
1796 rigid_body: current,
1797 });
1798 }
1799
1800 samples
1801}
1802
1803pub fn predict_free_flight_goal_line_crossing(
1809 initial: &boxcars::RigidBody,
1810 crossing_config: BallGoalLineCrossingConfig,
1811 trajectory_config: BallTrajectoryConfig,
1812) -> Option<BallGoalLineCrossing> {
1813 if initial.linear_velocity.is_none()
1814 || crossing_config.max_seconds < 0.0
1815 || crossing_config.target_goal_y.abs() <= f32::EPSILON
1816 {
1817 return None;
1818 }
1819
1820 let direction = crossing_config.target_goal_y.signum();
1821 let fixed_step_seconds = trajectory_config.fixed_step_seconds();
1822 let mut current = *initial;
1823 let mut elapsed = 0.0f32;
1824 let mut steps = 0usize;
1825
1826 while elapsed < crossing_config.max_seconds && steps < MAX_INTEGRATION_STEPS {
1827 let step_seconds = (crossing_config.max_seconds - elapsed).min(fixed_step_seconds);
1828 let next = advance_ball_free_flight_step(¤t, step_seconds, trajectory_config);
1829 if let Some(crossing) = goal_line_crossing_between(
1830 elapsed,
1831 step_seconds,
1832 ¤t,
1833 &next,
1834 direction,
1835 crossing_config,
1836 ) {
1837 return Some(crossing);
1838 }
1839
1840 current = next;
1841 elapsed += step_seconds;
1842 steps += 1;
1843 }
1844
1845 None
1846}
1847
1848pub fn predict_ball_with_plane_bounces_goal_line_crossing(
1851 initial: &boxcars::RigidBody,
1852 crossing_config: BallGoalLineCrossingConfig,
1853 trajectory_config: BallTrajectoryConfig,
1854 bounce_config: BallBounceConfig,
1855 planes: &[BallCollisionPlane],
1856) -> Option<BallGoalLineCrossing> {
1857 if initial.linear_velocity.is_none()
1858 || crossing_config.max_seconds < 0.0
1859 || crossing_config.target_goal_y.abs() <= f32::EPSILON
1860 {
1861 return None;
1862 }
1863
1864 let direction = crossing_config.target_goal_y.signum();
1865 let fixed_step_seconds = trajectory_config.fixed_step_seconds();
1866 let mut current = *initial;
1867 let mut elapsed = 0.0f32;
1868 let mut steps = 0usize;
1869
1870 while elapsed < crossing_config.max_seconds && steps < MAX_INTEGRATION_STEPS {
1871 let step_seconds = (crossing_config.max_seconds - elapsed).min(fixed_step_seconds);
1872 let segments = ball_with_plane_bounces_step_segments(
1873 ¤t,
1874 step_seconds,
1875 trajectory_config,
1876 bounce_config,
1877 planes,
1878 );
1879 let mut segment_start_time = elapsed;
1880 for segment in &segments {
1881 if let Some(crossing) = goal_line_crossing_between(
1882 segment_start_time,
1883 segment.duration,
1884 &segment.start,
1885 &segment.end,
1886 direction,
1887 crossing_config,
1888 ) {
1889 return Some(crossing);
1890 }
1891 segment_start_time += segment.duration;
1892 }
1893
1894 current = segments
1895 .last()
1896 .map(|segment| segment.end)
1897 .unwrap_or(current);
1898 elapsed += step_seconds;
1899 steps += 1;
1900 }
1901
1902 None
1903}
1904
1905pub fn predict_ball_with_surface_bounces_goal_line_crossing(
1906 initial: &boxcars::RigidBody,
1907 crossing_config: BallGoalLineCrossingConfig,
1908 trajectory_config: BallTrajectoryConfig,
1909 bounce_config: BallBounceConfig,
1910 surfaces: &[BallCollisionSurface],
1911) -> Option<BallGoalLineCrossing> {
1912 if initial.linear_velocity.is_none()
1913 || crossing_config.max_seconds < 0.0
1914 || crossing_config.target_goal_y.abs() <= f32::EPSILON
1915 {
1916 return None;
1917 }
1918
1919 let direction = crossing_config.target_goal_y.signum();
1920 let fixed_step_seconds = trajectory_config.fixed_step_seconds();
1921 let mut current = *initial;
1922 let mut elapsed = 0.0f32;
1923 let mut steps = 0usize;
1924
1925 while elapsed < crossing_config.max_seconds && steps < MAX_INTEGRATION_STEPS {
1926 let step_seconds = (crossing_config.max_seconds - elapsed).min(fixed_step_seconds);
1927 let segments = ball_with_surface_bounces_step_segments(
1928 ¤t,
1929 step_seconds,
1930 trajectory_config,
1931 bounce_config,
1932 surfaces,
1933 );
1934 let mut segment_start_time = elapsed;
1935 for segment in &segments {
1936 if let Some(crossing) = goal_line_crossing_between(
1937 segment_start_time,
1938 segment.duration,
1939 &segment.start,
1940 &segment.end,
1941 direction,
1942 crossing_config,
1943 ) {
1944 return Some(crossing);
1945 }
1946 segment_start_time += segment.duration;
1947 }
1948
1949 current = segments
1950 .last()
1951 .map(|segment| segment.end)
1952 .unwrap_or(current);
1953 elapsed += step_seconds;
1954 steps += 1;
1955 }
1956
1957 None
1958}
1959
1960pub fn predict_ball_with_surface_bounces_goal_target_hit(
1961 initial: &boxcars::RigidBody,
1962 crossing_config: BallGoalLineCrossingConfig,
1963 trajectory_config: BallTrajectoryConfig,
1964 bounce_config: BallBounceConfig,
1965 surfaces: &[BallCollisionSurface],
1966) -> Option<BallGoalTargetHit> {
1967 if initial.linear_velocity.is_none()
1968 || crossing_config.max_seconds < 0.0
1969 || crossing_config.target_goal_y.abs() <= f32::EPSILON
1970 {
1971 return None;
1972 }
1973
1974 let direction = crossing_config.target_goal_y.signum();
1975 let fixed_step_seconds = trajectory_config.fixed_step_seconds();
1976 let mut current = *initial;
1977 let mut elapsed = 0.0f32;
1978 let mut steps = 0usize;
1979
1980 while elapsed < crossing_config.max_seconds && steps < MAX_INTEGRATION_STEPS {
1981 let step_seconds = (crossing_config.max_seconds - elapsed).min(fixed_step_seconds);
1982 let free_flight_next =
1983 advance_ball_free_flight_step(¤t, step_seconds, trajectory_config);
1984 let impact =
1985 first_surface_impact(¤t, &free_flight_next, bounce_config.radius, surfaces);
1986
1987 let (segment_end, segment_duration) = if let Some(impact) = impact {
1988 let impact_time = step_seconds * impact.fraction;
1989 let mut impact_body =
1990 advance_ball_free_flight_step(¤t, impact_time, trajectory_config);
1991 snap_ball_to_surface(
1992 &mut impact_body,
1993 impact.surface,
1994 impact.normal,
1995 bounce_config.radius,
1996 );
1997 if let Some(hit_kind) = goal_target_surface_hit_kind(impact.surface, crossing_config) {
1998 return Some(BallGoalTargetHit {
1999 time: elapsed + impact_time,
2000 position: goal_target_surface_contact_position(
2001 impact_body,
2002 impact.normal,
2003 bounce_config.radius,
2004 ),
2005 velocity: impact_body.linear_velocity.as_ref().map(vec_to_glam),
2006 hit_kind,
2007 });
2008 }
2009 (impact_body, impact_time)
2010 } else {
2011 (free_flight_next, step_seconds)
2012 };
2013
2014 if let Some(crossing) = goal_line_crossing_between(
2015 elapsed,
2016 segment_duration,
2017 ¤t,
2018 &segment_end,
2019 direction,
2020 crossing_config,
2021 ) && crossing.inside_goal_mouth
2022 {
2023 return Some(BallGoalTargetHit {
2024 time: crossing.time,
2025 position: crossing.position,
2026 velocity: crossing.velocity,
2027 hit_kind: BallGoalTargetHitKind::GoalLine,
2028 });
2029 }
2030
2031 if let Some(impact) = impact {
2032 let bounced = bounce_ball_off_surface(
2033 &segment_end,
2034 impact.normal,
2035 bounce_config,
2036 trajectory_config,
2037 );
2038 current = if segment_duration <= COLLISION_TIME_EPSILON && bounced == segment_end {
2039 resolve_ball_surface_collisions(
2040 &free_flight_next,
2041 bounce_config,
2042 trajectory_config,
2043 surfaces,
2044 )
2045 } else {
2046 bounced
2047 };
2048 } else {
2049 current = free_flight_next;
2050 }
2051 elapsed += step_seconds;
2052 steps += 1;
2053 }
2054
2055 None
2056}
2057
2058fn goal_target_surface_hit_kind(
2059 surface: BallCollisionSurface,
2060 crossing_config: BallGoalLineCrossingConfig,
2061) -> Option<BallGoalTargetHitKind> {
2062 match surface {
2063 BallCollisionSurface::Plane(plane) => {
2064 let plane_y = (plane.normal.y.abs() > f32::EPSILON)
2065 .then_some(plane.distance_from_origin / plane.normal.y)?;
2066 ((plane_y - crossing_config.target_goal_y).abs() <= ARENA_BOUND_EPSILON)
2067 .then_some(BallGoalTargetHitKind::BackWall)
2068 }
2069 BallCollisionSurface::Cylinder(cylinder) => {
2070 ((cylinder.center.y - crossing_config.target_goal_y).abs() <= ARENA_BOUND_EPSILON)
2071 .then_some(BallGoalTargetHitKind::GoalFrame)
2072 }
2073 BallCollisionSurface::ConcaveCylinder(_) => None,
2074 }
2075}
2076
2077fn goal_target_surface_contact_position(
2078 impact_body: boxcars::RigidBody,
2079 impact_normal: glam::Vec3,
2080 ball_radius: f32,
2081) -> glam::Vec3 {
2082 let position = vec_to_glam(&impact_body.location);
2083 if impact_normal.length_squared() <= f32::EPSILON {
2084 position
2085 } else {
2086 position - impact_normal.normalize() * ball_radius
2087 }
2088}
2089
2090fn goal_line_crossing_between(
2091 start_time: f32,
2092 step_seconds: f32,
2093 start: &boxcars::RigidBody,
2094 end: &boxcars::RigidBody,
2095 direction: f32,
2096 crossing_config: BallGoalLineCrossingConfig,
2097) -> Option<BallGoalLineCrossing> {
2098 let start_position = vec_to_glam(&start.location);
2099 let end_position = vec_to_glam(&end.location);
2100 let start_signed_distance = direction * (start_position.y - crossing_config.target_goal_y);
2101 let end_signed_distance = direction * (end_position.y - crossing_config.target_goal_y);
2102 if start_signed_distance > 0.0 || end_signed_distance < 0.0 {
2103 return None;
2104 }
2105
2106 let delta_y = end_position.y - start_position.y;
2107 if direction * delta_y <= f32::EPSILON {
2108 return None;
2109 }
2110
2111 let fraction = ((crossing_config.target_goal_y - start_position.y) / delta_y).clamp(0.0, 1.0);
2112 let position = start_position.lerp(end_position, fraction);
2113 let velocity = match (
2114 start.linear_velocity.as_ref().map(vec_to_glam),
2115 end.linear_velocity.as_ref().map(vec_to_glam),
2116 ) {
2117 (Some(start_velocity), Some(end_velocity)) => {
2118 Some(start_velocity.lerp(end_velocity, fraction))
2119 }
2120 (Some(velocity), None) | (None, Some(velocity)) => Some(velocity),
2121 (None, None) => None,
2122 };
2123
2124 Some(BallGoalLineCrossing {
2125 time: start_time + step_seconds * fraction,
2126 position: glam::Vec3::new(position.x, crossing_config.target_goal_y, position.z),
2127 velocity,
2128 inside_goal_mouth: goal_line_crossing_is_inside_mouth(position, crossing_config),
2129 })
2130}
2131
2132fn goal_line_crossing_is_inside_mouth(
2133 position: glam::Vec3,
2134 crossing_config: BallGoalLineCrossingConfig,
2135) -> bool {
2136 position.x.abs() <= crossing_config.goal_mouth_half_width_x + crossing_config.goal_mouth_margin
2137 && position.z >= STANDARD_BALL_RADIUS - crossing_config.goal_mouth_margin
2138 && position.z <= crossing_config.goal_mouth_height_z + crossing_config.goal_mouth_margin
2139}
2140
2141pub fn ball_free_flight_prediction_error(
2144 initial: &boxcars::RigidBody,
2145 observed: &[(f32, boxcars::RigidBody)],
2146 config: BallTrajectoryConfig,
2147) -> Option<BallTrajectoryError> {
2148 ball_prediction_error(initial, observed, |initial, time| {
2149 advance_ball_free_flight(initial, time, config)
2150 })
2151}
2152
2153pub fn ball_plane_bounce_prediction_error(
2156 initial: &boxcars::RigidBody,
2157 observed: &[(f32, boxcars::RigidBody)],
2158 trajectory_config: BallTrajectoryConfig,
2159 bounce_config: BallBounceConfig,
2160 planes: &[BallCollisionPlane],
2161) -> Option<BallTrajectoryError> {
2162 ball_prediction_error(initial, observed, |initial, time| {
2163 advance_ball_with_plane_bounces(initial, time, trajectory_config, bounce_config, planes)
2164 })
2165}
2166
2167pub fn ball_surface_bounce_prediction_error(
2168 initial: &boxcars::RigidBody,
2169 observed: &[(f32, boxcars::RigidBody)],
2170 trajectory_config: BallTrajectoryConfig,
2171 bounce_config: BallBounceConfig,
2172 surfaces: &[BallCollisionSurface],
2173) -> Option<BallTrajectoryError> {
2174 ball_prediction_error(initial, observed, |initial, time| {
2175 advance_ball_with_surface_bounces(initial, time, trajectory_config, bounce_config, surfaces)
2176 })
2177}
2178
2179fn ball_prediction_error(
2180 initial: &boxcars::RigidBody,
2181 observed: &[(f32, boxcars::RigidBody)],
2182 predict: impl Fn(&boxcars::RigidBody, f32) -> boxcars::RigidBody,
2183) -> Option<BallTrajectoryError> {
2184 if observed.is_empty() {
2185 return None;
2186 }
2187
2188 let mut max_position_error = 0.0f32;
2189 let mut position_error_sum_sq = 0.0f32;
2190 let mut max_velocity_error = 0.0f32;
2191 let mut velocity_error_sum_sq = 0.0f32;
2192 let mut velocity_sample_count = 0usize;
2193
2194 for (time, observed_body) in observed {
2195 let predicted = predict(initial, *time);
2196 let position_error =
2197 vec_to_glam(&predicted.location).distance(vec_to_glam(&observed_body.location));
2198 max_position_error = max_position_error.max(position_error);
2199 position_error_sum_sq += position_error.powi(2);
2200
2201 if let (Some(predicted_velocity), Some(observed_velocity)) = (
2202 predicted.linear_velocity.as_ref(),
2203 observed_body.linear_velocity.as_ref(),
2204 ) {
2205 let velocity_error =
2206 vec_to_glam(predicted_velocity).distance(vec_to_glam(observed_velocity));
2207 max_velocity_error = max_velocity_error.max(velocity_error);
2208 velocity_error_sum_sq += velocity_error.powi(2);
2209 velocity_sample_count += 1;
2210 }
2211 }
2212
2213 Some(BallTrajectoryError {
2214 sample_count: observed.len(),
2215 max_position_error,
2216 rms_position_error: (position_error_sum_sq / observed.len() as f32).sqrt(),
2217 max_velocity_error: (velocity_sample_count > 0).then_some(max_velocity_error),
2218 rms_velocity_error: (velocity_sample_count > 0)
2219 .then_some((velocity_error_sum_sq / velocity_sample_count as f32).sqrt()),
2220 })
2221}
2222
2223#[cfg(test)]
2224#[path = "ballistics_tests.rs"]
2225mod tests;