Skip to main content

subtr_actor/util/
ballistics.rs

1use crate::{apply_velocities_to_rigid_body, glam_to_vec, vec_to_glam};
2
3/// Rocket League's standard physics tick rate in Hz.
4pub const ROCKET_LEAGUE_PHYSICS_TICK_RATE_HZ: f32 = 120.0;
5
6/// Standard Soccar gravity in unreal units per second squared.
7pub const STANDARD_BALL_GRAVITY_Z: f32 = -650.0;
8
9/// Standard Soccar ball speed cap in unreal units per second.
10pub const STANDARD_BALL_MAX_SPEED: f32 = 6000.0;
11
12/// Standard Soccar ball radius used by common ball-prediction models.
13pub const STANDARD_BALL_RADIUS: f32 = 91.25;
14
15/// Approximate ball restitution for wall/ground bounces.
16pub const STANDARD_BALL_RESTITUTION: f32 = 0.6;
17
18/// Tangential impulse coefficient from smish.dev's Rocket League ball model.
19pub const STANDARD_BALL_TANGENTIAL_FRICTION: f32 = 0.285;
20
21/// Tangential impulse scale from smish.dev's Rocket League ball model.
22pub const STANDARD_BALL_TANGENTIAL_RATIO_SCALE: f32 = 2.0;
23
24/// Angular velocity coupling from smish.dev's Rocket League ball model.
25pub const STANDARD_BALL_ANGULAR_COUPLING: f32 = 0.0003;
26
27/// Standard Soccar goal line Y coordinate.
28pub const STANDARD_GOAL_LINE_Y: f32 = 5120.0;
29
30/// Standard Soccar back-wall Y coordinate.
31pub const STANDARD_ARENA_BACK_WALL_Y: f32 = STANDARD_GOAL_LINE_Y;
32
33/// Standard Soccar goal center-to-post distance in unreal units.
34pub const STANDARD_GOAL_MOUTH_HALF_WIDTH_X: f32 = 892.755;
35
36/// Approximate goal mouth height in unreal units.
37pub const STANDARD_GOAL_MOUTH_HEIGHT_Z: f32 = 642.775;
38
39/// Approximate radius for goal posts and crossbars in the standard arena model.
40pub const STANDARD_GOAL_FRAME_RADIUS: f32 = 75.0;
41
42/// Approximate standard Soccar wall-bottom ramp radius. RLBot documents this as
43/// roughly 256 uu, with the caveat that the real mesh is not a perfect circle.
44pub const STANDARD_ARENA_WALL_BOTTOM_RAMP_RADIUS: f32 = 256.0;
45
46/// Default tolerance for trajectory-to-goal-mouth checks.
47pub const STANDARD_GOAL_MOUTH_TRAJECTORY_MARGIN: f32 = STANDARD_BALL_RADIUS * 1.5;
48
49/// Standard Soccar side-wall X coordinate.
50pub const STANDARD_ARENA_SIDE_WALL_X: f32 = 4096.0;
51
52/// Standard Soccar ceiling Z coordinate.
53pub 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    /// Match Rocket League's fixed-step simulation style by applying acceleration
65    /// to velocity before integrating position for each substep.
66    #[default]
67    SemiImplicitEuler,
68    /// Use closed-form constant-acceleration displacement for each substep.
69    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    /// Unit normal pointing into the playable half-space.
148    pub normal: glam::Vec3,
149    /// Plane constant in `normal.dot(position) == distance_from_origin` form.
150    pub distance_from_origin: f32,
151    /// Optional axis-aligned bounds for finite wall sections. Bounds apply to
152    /// the ball center at the impact point, not to the mesh contact point.
153    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
583/// Collision planes that matter before evaluating a standard Soccar goal-line
584/// crossing. Back-wall planes are intentionally omitted because the target
585/// crossing plane is the back goal line itself.
586pub 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
595/// Simple rectangular standard Soccar arena planes for replay-wide prediction
596/// audits. This intentionally approximates the true arena and omits ramps,
597/// curved corners, post geometry, and goal interiors.
598pub 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
624/// Collision surfaces before the goal line, excluding goal-frame cylinders.
625///
626/// This is useful when the desired answer is the counterfactual goal-line
627/// crossing location itself, rather than whether the ball would hit a post or
628/// crossbar before entering the goal.
629pub 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
638/// Collision surfaces that matter before evaluating a standard Soccar goal-line
639/// crossing. Back-wall planes are still omitted because the target crossing
640/// plane is the back goal line itself, but goal-frame cylinders are included so
641/// projected post and crossbar bounces are not treated as unobstructed crosses.
642pub 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
657/// Simple standard Soccar arena surfaces for replay-wide prediction audits. This
658/// remains an approximation: ramps, curved corners, detailed goal interiors, and
659/// exact mesh normals are not modeled.
660pub 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
685/// Collision surfaces used when asking where a shot would first reach the target
686/// goal area. Field surfaces can redirect the ball before it arrives; target
687/// back-wall sections and goal-frame cylinders are reported as hits.
688pub 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
769/// Advances a ball rigid body through free flight only.
770///
771/// This intentionally ignores cars, arena surfaces, gravity mutators, beach-ball
772/// curve, heatseeker steering, and any other collision or externally applied
773/// force. Angular velocity is retained for orientation, but it does not curve the
774/// ball's center-of-mass path in standard Soccar.
775pub 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(&current, step_seconds, config);
792        remaining -= step_seconds;
793        steps += 1;
794    }
795
796    current
797}
798
799/// Advances a ball through free flight and resolves bounces against simple
800/// planes after each physics substep.
801///
802/// This is useful for ground/wall-style tests and rough predictions. Accurate
803/// arena prediction should use the same bounce model with normals from Rocket
804/// League's collision meshes.
805pub 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            &current,
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            &current,
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(&current.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(&current, remaining, trajectory_config);
958        let Some(impact) =
959            first_plane_impact(&current, &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(&current, 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(&current, remaining, trajectory_config);
1027        let Some(impact) =
1028            first_surface_impact(&current, &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(&current, 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(&parallel_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
1686/// Produces regularly sampled free-flight predictions, including the initial
1687/// sample at `time == 0.0` and the exact requested endpoint.
1688pub 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(&current, 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            &current,
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            &current,
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
1803/// Predicts where the ball center crosses a goal line under free-flight physics.
1804///
1805/// This ignores later touches and arena collisions. It is intended for the common
1806/// saved-shot question: "where was this shot projected to cross the goal line
1807/// before a defender intervened?"
1808pub 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(&current, step_seconds, trajectory_config);
1829        if let Some(crossing) = goal_line_crossing_between(
1830            elapsed,
1831            step_seconds,
1832            &current,
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
1848/// Predicts where the ball center crosses a goal line while resolving bounces
1849/// against caller-provided planes.
1850pub 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            &current,
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            &current,
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(&current, step_seconds, trajectory_config);
1984        let impact =
1985            first_surface_impact(&current, &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(&current, 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            &current,
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
2141/// Compares observed replay samples to free-flight predictions from the same
2142/// initial state. Observed times are relative to `initial`.
2143pub 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
2153/// Compares observed replay samples to predictions that include bounces against
2154/// caller-provided planes. Observed times are relative to `initial`.
2155pub 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;