Skip to main content

subtr_actor/util/
geometry.rs

1use crate::{SubtrActorError, SubtrActorErrorVariant, SubtrActorResult};
2
3pub fn vec_to_glam(v: &boxcars::Vector3f) -> glam::f32::Vec3 {
4    glam::f32::Vec3::new(v.x, v.y, v.z)
5}
6
7pub fn glam_to_vec(v: &glam::f32::Vec3) -> boxcars::Vector3f {
8    boxcars::Vector3f {
9        x: v.x,
10        y: v.y,
11        z: v.z,
12    }
13}
14
15pub fn quat_to_glam(q: &boxcars::Quaternion) -> glam::Quat {
16    let rotation = glam::Quat::from_xyzw(q.x, q.y, q.z, q.w);
17    if rotation.x.is_finite()
18        && rotation.y.is_finite()
19        && rotation.z.is_finite()
20        && rotation.w.is_finite()
21        && rotation.length_squared() > 0.0
22    {
23        rotation.normalize()
24    } else {
25        glam::Quat::IDENTITY
26    }
27}
28
29pub fn glam_to_quat(rotation: &glam::Quat) -> boxcars::Quaternion {
30    boxcars::Quaternion {
31        x: rotation.x,
32        y: rotation.y,
33        z: rotation.z,
34        w: rotation.w,
35    }
36}
37
38pub fn apply_velocities_to_rigid_body(
39    rigid_body: &boxcars::RigidBody,
40    time_delta: f32,
41) -> boxcars::RigidBody {
42    let mut interpolated = *rigid_body;
43    if time_delta == 0.0 {
44        return interpolated;
45    }
46    let linear_velocity = interpolated.linear_velocity.unwrap_or(boxcars::Vector3f {
47        x: 0.0,
48        y: 0.0,
49        z: 0.0,
50    });
51    let location = vec_to_glam(&rigid_body.location) + (time_delta * vec_to_glam(&linear_velocity));
52    interpolated.location = glam_to_vec(&location);
53    interpolated.rotation = apply_angular_velocity(rigid_body, time_delta);
54    interpolated
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum CarHitboxFamily {
59    Breakout,
60    Dominus,
61    Hybrid,
62    Merc,
63    Octane,
64    Plank,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq)]
68pub struct CarHitbox {
69    pub family: CarHitboxFamily,
70    pub length: f32,
71    pub width: f32,
72    pub height: f32,
73    pub angle: f32,
74    pub front_height: f32,
75    pub back_height: f32,
76    pub offset: f32,
77    pub elevation: f32,
78}
79
80impl CarHitbox {
81    const DEFAULT_OFFSET: f32 = 13.88;
82    const DEFAULT_ELEVATION: f32 = 17.05;
83
84    const fn from_preset(
85        family: CarHitboxFamily,
86        length: f32,
87        width: f32,
88        height: f32,
89        angle: f32,
90        front_height: f32,
91        back_height: f32,
92    ) -> Self {
93        Self {
94            family,
95            length,
96            width,
97            height,
98            angle,
99            front_height,
100            back_height,
101            offset: Self::DEFAULT_OFFSET,
102            elevation: Self::DEFAULT_ELEVATION,
103        }
104    }
105
106    pub const fn breakout() -> Self {
107        Self::from_preset(
108            CarHitboxFamily::Breakout,
109            131.4924,
110            80.521,
111            30.3,
112            -0.9795,
113            43.8976,
114            46.1454,
115        )
116    }
117
118    pub const fn dominus() -> Self {
119        Self::from_preset(
120            CarHitboxFamily::Dominus,
121            127.9268,
122            83.27995,
123            31.3,
124            -0.9635,
125            47.2238,
126            49.3749,
127        )
128    }
129
130    pub const fn hybrid() -> Self {
131        Self::from_preset(
132            CarHitboxFamily::Hybrid,
133            127.0192,
134            82.18787,
135            34.15907,
136            -0.5499,
137            54.0982,
138            55.3173,
139        )
140    }
141
142    pub const fn merc() -> Self {
143        Self::from_preset(
144            CarHitboxFamily::Merc,
145            120.72,
146            76.71,
147            41.66,
148            0.28,
149            60.76,
150            61.35,
151        )
152    }
153
154    pub const fn octane() -> Self {
155        Self::from_preset(
156            CarHitboxFamily::Octane,
157            118.0074,
158            84.19941,
159            36.15907,
160            -0.5518,
161            55.1449,
162            56.2814,
163        )
164    }
165
166    pub const fn plank() -> Self {
167        Self::from_preset(
168            CarHitboxFamily::Plank,
169            128.8198,
170            84.67036,
171            29.3944,
172            -0.3447,
173            44.998,
174            45.773,
175        )
176    }
177
178    pub const fn for_family(family: CarHitboxFamily) -> Self {
179        match family {
180            CarHitboxFamily::Breakout => Self::breakout(),
181            CarHitboxFamily::Dominus => Self::dominus(),
182            CarHitboxFamily::Hybrid => Self::hybrid(),
183            CarHitboxFamily::Merc => Self::merc(),
184            CarHitboxFamily::Octane => Self::octane(),
185            CarHitboxFamily::Plank => Self::plank(),
186        }
187    }
188}
189
190pub fn default_car_hitbox() -> CarHitbox {
191    CarHitbox::octane()
192}
193
194pub const BALL_COLLISION_RADIUS: f32 = 92.75;
195
196#[derive(Debug, Clone, Copy, PartialEq)]
197pub struct TouchCandidateScoring {
198    pub strict_contact_gap_threshold: f32,
199    pub relaxed_contact_gap_threshold: f32,
200    pub strict_contact_min_position_deviation: f32,
201    pub relaxed_contact_min_position_deviation: f32,
202    pub strict_contact_min_velocity_deviation: f32,
203    pub relaxed_contact_min_velocity_deviation: f32,
204    pub relaxed_contact_score_penalty: f32,
205    pub dodge_contact_score_bonus: f32,
206    pub simultaneous_touch_score_margin: f32,
207    pub contested_touch_score_margin: f32,
208}
209
210impl TouchCandidateScoring {
211    pub const DEFAULT: Self = Self {
212        strict_contact_gap_threshold: 5.0,
213        relaxed_contact_gap_threshold: 25.0,
214        strict_contact_min_position_deviation: 25.0,
215        relaxed_contact_min_position_deviation: 500.0,
216        strict_contact_min_velocity_deviation: 50.0,
217        relaxed_contact_min_velocity_deviation: 1000.0,
218        relaxed_contact_score_penalty: 100.0,
219        dodge_contact_score_bonus: 1.0,
220        simultaneous_touch_score_margin: 5.0,
221        contested_touch_score_margin: 5.0,
222    };
223
224    pub fn accepts_contact_gap(
225        self,
226        closest_contact_gap: f32,
227        position_deviation: f32,
228        velocity_deviation: f32,
229    ) -> bool {
230        (closest_contact_gap <= self.strict_contact_gap_threshold
231            && (position_deviation >= self.strict_contact_min_position_deviation
232                || velocity_deviation >= self.strict_contact_min_velocity_deviation))
233            || (closest_contact_gap <= self.relaxed_contact_gap_threshold
234                && (position_deviation >= self.relaxed_contact_min_position_deviation
235                    || velocity_deviation >= self.relaxed_contact_min_velocity_deviation))
236    }
237
238    pub fn score_contact_gap(self, closest_contact_gap: f32, dodge_contact: bool) -> f32 {
239        let relaxed_penalty = if closest_contact_gap > self.strict_contact_gap_threshold {
240            self.relaxed_contact_score_penalty
241        } else {
242            0.0
243        };
244        closest_contact_gap + relaxed_penalty
245            - if dodge_contact {
246                self.dodge_contact_score_bonus
247            } else {
248                0.0
249            }
250    }
251}
252
253pub fn car_hitbox_for_body_name(body_name: &str) -> Option<CarHitbox> {
254    hitbox_family_for_body_name(body_name).map(CarHitbox::for_family)
255}
256
257pub fn car_hitbox_for_body_id(body_id: u32) -> Option<CarHitbox> {
258    hitbox_family_for_body_id(body_id).map(CarHitbox::for_family)
259}
260
261pub fn car_hitbox_for_body_id_or_name(
262    body_id: Option<u32>,
263    body_name: Option<&str>,
264) -> Option<CarHitbox> {
265    hitbox_family_for_body_id_or_name(body_id, body_name).map(CarHitbox::for_family)
266}
267
268pub fn hitbox_family_for_body_id_or_name(
269    body_id: Option<u32>,
270    body_name: Option<&str>,
271) -> Option<CarHitboxFamily> {
272    body_id
273        .and_then(hitbox_family_for_body_id)
274        .or_else(|| body_name.and_then(hitbox_family_for_body_name))
275}
276
277pub fn hitbox_family_for_body_id(body_id: u32) -> Option<CarHitboxFamily> {
278    match body_id {
279        22 | 1416 | 1894 | 1932 | 3031 | 3311 | 6243 | 6489 | 7651 | 7696 | 7890 | 7901 | 8006
280        | 8360 | 8361 | 8565 | 8566 | 8669 | 9357 | 10697 | 10698 | 10817 | 10822 | 11038
281        | 11394 | 11505 | 11677 | 11800 | 11933 | 11949 | 12173 | 12315 | 12361 | 12484 => {
282            Some(CarHitboxFamily::Breakout)
283        }
284        29 | 403 | 597 | 600 | 1018 | 1171 | 1286 | 1675 | 1689 | 1883 | 2070 | 2268 | 2666
285        | 2950 | 2951 | 3155 | 3156 | 3157 | 3265 | 3426 | 3875 | 3879 | 3880 | 4014 | 4155
286        | 4367 | 4472 | 4473 | 4745 | 4770 | 4781 | 4861 | 4864 | 5709 | 5773 | 5823 | 5858
287        | 5964 | 5979 | 6122 | 6244 | 6247 | 6260 | 6836 | 7211 | 7337 | 7338 | 7341 | 7343
288        | 7415 | 7512 | 7532 | 7593 | 7772 | 8454 | 9053 | 9088 | 9089 | 9140 | 9388 | 9894
289        | 10094 | 10440 | 10441 | 10694 | 10695 | 11016 | 11095 | 11315 | 11336 | 11534 | 11941
290        | 11996 | 12106 | 12142 | 12262 | 12286 | 12325 | 12382 | 12563 | 12669 => {
291            Some(CarHitboxFamily::Dominus)
292        }
293        28 | 31 | 1159 | 1317 | 1624 | 1856 | 2269 | 3451 | 3582 | 3702 | 5470 | 5488 | 5879
294        | 7012 | 9084 | 9085 | 9427 | 10044 | 10805 | 11138 | 11141 | 11379 | 11932 | 12569
295        | 12652 => Some(CarHitboxFamily::Hybrid),
296        30 | 4780 | 7336 | 7477 | 7815 | 7979 | 10689 | 11098 | 11736 | 11905 | 11950 | 12318
297        | 12335 => Some(CarHitboxFamily::Merc),
298        21 | 23 | 25 | 26 | 27 | 402 | 404 | 523 | 607 | 625 | 723 | 1172 | 1295 | 1300 | 1475
299        | 1478 | 1533 | 1568 | 1623 | 2665 | 2853 | 2919 | 2949 | 4284 | 4318 | 4319 | 4320
300        | 4782 | 4906 | 5020 | 5039 | 5188 | 5361 | 5547 | 5713 | 5837 | 5951 | 6939 | 7947
301        | 7948 | 8383 | 8806 | 8807 | 10896 | 10897 | 10900 | 10901 | 11314 | 11603 | 12104
302        | 12105 => Some(CarHitboxFamily::Octane),
303        24 | 803 | 1603 | 1691 | 1919 | 3594 | 3614 | 3622 | 4268 | 5265 | 7052 | 8524 => {
304            Some(CarHitboxFamily::Plank)
305        }
306        _ => None,
307    }
308}
309
310pub fn hitbox_family_for_body_name(body_name: &str) -> Option<CarHitboxFamily> {
311    let normalized = normalized_car_body_name(body_name);
312    if normalized.is_empty() {
313        return None;
314    }
315
316    if normalized_body_name_matches(&normalized, BREAKOUT_HITBOX_BODIES) {
317        Some(CarHitboxFamily::Breakout)
318    } else if normalized_body_name_matches(&normalized, DOMINUS_HITBOX_BODIES) {
319        Some(CarHitboxFamily::Dominus)
320    } else if normalized_body_name_matches(&normalized, HYBRID_HITBOX_BODIES) {
321        Some(CarHitboxFamily::Hybrid)
322    } else if normalized_body_name_matches(&normalized, MERC_HITBOX_BODIES) {
323        Some(CarHitboxFamily::Merc)
324    } else if normalized_body_name_matches(&normalized, OCTANE_HITBOX_BODIES) {
325        Some(CarHitboxFamily::Octane)
326    } else if normalized_body_name_matches(&normalized, PLANK_HITBOX_BODIES) {
327        Some(CarHitboxFamily::Plank)
328    } else {
329        None
330    }
331}
332
333fn normalized_body_name_matches(normalized: &str, candidates: &[&str]) -> bool {
334    candidates
335        .iter()
336        .any(|candidate| normalized_car_body_name(candidate) == normalized)
337}
338
339fn normalized_car_body_name(body_name: &str) -> String {
340    body_name
341        .chars()
342        .filter(|character| character.is_ascii_alphanumeric())
343        .flat_map(char::to_lowercase)
344        .collect()
345}
346
347// Body-to-family mapping follows Epic's Rocket League Car Hitboxes support
348// article when available. Newer body IDs are Rocket League product IDs surfaced
349// in ClientLoadouts and cross-checked against a BakkesMod body product dump.
350// Preset dimensions follow rlhitboxes.com/stats.json.
351
352const BREAKOUT_HITBOX_BODIES: &[&str] = &[
353    "1966 Cadillac DeVille",
354    "Ace",
355    "Animus GP",
356    "Aston Martin Valhalla",
357    "Azura",
358    "Breakout",
359    "Breakout Type-S",
360    "Breakout X",
361    "Cyberpunk Quadra",
362    "Cyclone",
363    "Diesel",
364    "Emperor",
365    "Emperor II",
366    "Emperor II: Frozen",
367    "Emperor II: Scorched",
368    "Fast & Furious Mazda RX-7",
369    "Ferrari F40",
370    "Fast and Furious Mazda-RX7",
371    "Fuse",
372    "Havoc",
373    "Chevrolet Corvette Stingray",
374    "Chevrolet Corvette ZR1",
375    "Komodo",
376    "Mako",
377    "McLaren Senna",
378    "Megastar",
379    "Nexus",
380    "Nexus SC",
381    "Pontiac Firebird",
382    "Porsche 918 Spyder",
383    "Quadra Turbo-R",
384    "Redline",
385    "Revolver",
386    "Samurai",
387    "The Incredibile",
388    "Whiplash",
389];
390
391const DOMINUS_HITBOX_BODIES: &[&str] = &[
392    "'89 Batmobile",
393    "007's Aston Martin DBS",
394    "007's Aston Martin Valhalla",
395    "Admiral",
396    "Aftershock",
397    "Back To The Future Time Machine",
398    "Batmobile (1989)",
399    "Batmobile (2022)",
400    "Bumblebee Car",
401    "Bumblebee",
402    "BMW M3 (E30)",
403    "BMW M2 Racing",
404    "BMW M4 GT3 EVO",
405    "BMW M240i",
406    "Chikara",
407    "Chikara G1",
408    "Chikara GXT",
409    "DeLorean Time Machine",
410    "Diestro",
411    "Dodge Charger Daytona Scat Pack",
412    "Dodger Charger Daytona Scat Pack",
413    "Dominus",
414    "Dominus: Neon",
415    "Dominus GT",
416    "Ecto-1",
417    "Ecto-1 (Ghostbusters)",
418    "Fast & Furious Dodge Charger",
419    "Fast and Furious Dodge Charger",
420    "Fast & Furious Dodge Charger SRT Hellcat",
421    "Ferrari 296 GTB",
422    "Ford Mustang Shelby GT350R RLE",
423    "Ford Mustang Shelby GT500",
424    "Ford Mustang GTD",
425    "Gazella GT",
426    "Gazella GT (Hot Wheels)",
427    "Guardian",
428    "Guardian G1",
429    "Guardian GXT",
430    "Homer's Car",
431    "Hotshot",
432    "Ice Charger",
433    "Imperator DT5",
434    "K.I.T.T.",
435    "K.I.T.T. (Knight Rider)",
436    "Lamborghini Countach LPI 800-4",
437    "Lamborghini Huracan STO",
438    "Lamborghini Huracán STO",
439    "Lightning McQueen",
440    "Lightning McQueen Car",
441    "Lockjaw",
442    "Maestro",
443    "Magnifique",
444    "Magnifique GXT",
445    "Mamba",
446    "Masamune",
447    "Maven",
448    "Maverick",
449    "Maverick G1",
450    "Maverick GXT",
451    "McLaren 570S",
452    "McLaren 765LT",
453    "McLaren P1",
454    "Mercedes-AMG GT 63 S",
455    "Mercedes-Benz CLA",
456    "MR11",
457    "MR11 (Hot Wheels)",
458    "NASCAR Chevrolet Camaro",
459    "NASCAR Ford Mustang",
460    "NASCAR Toyota Camry",
461    "NASCAR Next Gen Chevrolet Camaro",
462    "NASCAR Next Gen Chevrolet Camaro (2022)",
463    "NASCAR Next Gen Ford Mustang",
464    "NASCAR Next Gen Ford Mustang (2022)",
465    "NASCAR Next Gen Toyota Camry",
466    "NASCAR Next Gen Toyota Camry (2022)",
467    "Nemesis",
468    "Nissan 350Z",
469    "Nissan Fairlady Z",
470    "Nissan Fairlady Z RLE",
471    "Nissan Z Performance",
472    "Nissan Z Performance Car",
473    "Peregrine TT",
474    "Perigrine TT",
475    "Porsche 911 GT3 RS",
476    "Porsche 911 Turbo",
477    "Porsche 911 Turbo RLE",
478    "Ripper",
479    "Ronin",
480    "Ronin G1",
481    "Ronin GXT",
482    "Samus' Gunship",
483    "Samus' Gunship (Nintendo Exclusive)",
484    "Scorpion",
485    "Tyranno",
486    "Tyranno GXT",
487    "Werewolf",
488    "Zefira",
489];
490
491const HYBRID_HITBOX_BODIES: &[&str] = &[
492    "Beskar",
493    "Chrysler Pacifica",
494    "Endo",
495    "Esper",
496    "Fast & Furious Nissan Skyline",
497    "Fast and Furious Nissan Skyline",
498    "Fast & Furious Pontiac Fiero",
499    "Fast and Furious Pontiac Fiero",
500    "Hearse",
501    "Insidio",
502    "Jager 619",
503    "Jäger 619",
504    "Jäger 619 RS",
505    "Lamborghini Urus",
506    "Lamborghini Urus SE",
507    "Nimbus",
508    "Nissan Silvia",
509    "Nissan Silvia RLE",
510    "Nissan Skyline GT-R",
511    "Nissan Skyline GT-R (R32)",
512    "Primo",
513    "R3MX",
514    "R3MX GXT",
515    "RAM 1500 RHO",
516    "Rivian R1S",
517    "Tesla Cybertruck",
518    "Tygris",
519    "Venom",
520    "Void Burn",
521    "X-Devil",
522    "X-Devil MK2",
523];
524
525const MERC_HITBOX_BODIES: &[&str] = &[
526    "Battle Bus",
527    "Behemoth",
528    "Chevrolet Astro",
529    "Defender D7X-R",
530    "Ford Bronco Raptor RLE",
531    "Merc",
532    "The Mystery Machine",
533    "Nomad",
534    "Nomad GXT",
535    "Pizza Planet Delivery Truck",
536    "Recoil AV",
537    "Stampede",
538    "Turtle Van",
539];
540
541const OCTANE_HITBOX_BODIES: &[&str] = &[
542    "007's Aston Martin DB5",
543    "Armadillo",
544    "Armadillo (Xbox Exclusive)",
545    "Backfire",
546    "BMW 1 Series",
547    "BMW 1 Series RLE",
548    "Bone Shaker",
549    "Corlay",
550    "Dingo",
551    "Fast 4WD",
552    "Fast 4WD (Hot Wheels)",
553    "Fennec",
554    "Fennec ZR-F",
555    "Ford F-150 RLE",
556    "Ford Mustang Mach-E RLE",
557    "Gizmo",
558    "Grog",
559    "Harbinger",
560    "Harbinger GXT",
561    "Hogsticker",
562    "Hogsticker (Xbox Exclusive)",
563    "Honda Civic Type R",
564    "Honda Civic Type R-LE",
565    "Jackal",
566    "Jurassic Jeep Wrangler",
567    "Jeep Wrangler Rubicon",
568    "Marauder",
569    "Mario NSR",
570    "Luigi NSR",
571    "Mudcat",
572    "Mudcat G1",
573    "Mudcat GXT",
574    "Octane",
575    "Octane ZSR",
576    "Outlaw",
577    "Outlaw GXT",
578    "Patty Wagon",
579    "Proteus",
580    "Psyclops",
581    "Road Hog",
582    "Road Hog XL",
583    "Scarab",
584    "Shokunin",
585    "Shokunin GXT",
586    "Sweet Tooth",
587    "Sweet Tooth (PlayStation Exclusive)",
588    "Takumi",
589    "Takumi RX-T",
590    "The Dark Knight's Tumbler",
591    "The Dark Knight Tumbler",
592    "Triton",
593    "Twinzer",
594    "Volkswagen Golf GTI",
595    "Volkswagen Golf GTI RLE",
596    "Vulcan",
597    "Xentari",
598    "Zippy",
599];
600
601const PLANK_HITBOX_BODIES: &[&str] = &[
602    "'16 Batmobile",
603    "Batmobile (2016)",
604    "Bugatti Centodieci",
605    "Artemis",
606    "Artemis G1",
607    "Artemis GXT",
608    "Centio",
609    "Centio V17",
610    "Formula 1 2021",
611    "Formula 1 2022",
612    "Mantis",
613    "Paladin",
614    "Sentinel",
615    "Twin Mill III",
616];
617
618#[derive(Debug, Clone, Copy, PartialEq)]
619pub(crate) struct CarHitboxContactEstimate {
620    pub distance: f32,
621    pub local_ball_position: glam::Vec3,
622    pub local_contact_point: glam::Vec3,
623}
624
625pub(crate) fn car_hitbox_contact_estimate(
626    ball_position: glam::Vec3,
627    player_body: &boxcars::RigidBody,
628    hitbox: CarHitbox,
629) -> Option<CarHitboxContactEstimate> {
630    let car_local_ball_position = quat_to_glam(&player_body.rotation).inverse()
631        * (ball_position - vec_to_glam(&player_body.location));
632    let hitbox_center = glam::Vec3::new(hitbox.offset, 0.0, hitbox.elevation);
633    let hitbox_rotation = glam::Quat::from_rotation_y(hitbox.angle.to_radians());
634    let local_ball_position = hitbox_rotation.inverse() * (car_local_ball_position - hitbox_center);
635    if !local_ball_position.is_finite() {
636        return None;
637    }
638
639    let x_min = -hitbox.length / 2.0;
640    let x_max = hitbox.length / 2.0;
641    let y_min = -hitbox.width / 2.0;
642    let y_max = hitbox.width / 2.0;
643    let z_min = -hitbox.height / 2.0;
644    let z_max = hitbox.height / 2.0;
645    let local_contact_point = glam::Vec3::new(
646        local_ball_position.x.clamp(x_min, x_max),
647        local_ball_position.y.clamp(y_min, y_max),
648        local_ball_position.z.clamp(z_min, z_max),
649    );
650    let distance = (local_ball_position - local_contact_point).length();
651    if !distance.is_finite() {
652        return None;
653    }
654
655    Some(CarHitboxContactEstimate {
656        distance,
657        local_ball_position,
658        local_contact_point,
659    })
660}
661
662pub(crate) fn car_hitbox_distance(
663    ball_position: glam::Vec3,
664    player_body: &boxcars::RigidBody,
665    hitbox: CarHitbox,
666) -> Option<f32> {
667    car_hitbox_contact_estimate(ball_position, player_body, hitbox)
668        .map(|estimate| estimate.distance)
669}
670
671pub fn car_hitbox_ball_contact_gap(
672    ball_position: glam::Vec3,
673    player_body: &boxcars::RigidBody,
674    hitbox: CarHitbox,
675) -> Option<f32> {
676    car_hitbox_distance(ball_position, player_body, hitbox)
677        .map(|center_distance| (center_distance - BALL_COLLISION_RADIUS).max(0.0))
678}
679
680#[derive(Debug, Clone, Copy)]
681struct OrientedCarHitbox {
682    center: glam::Vec3,
683    axes: [glam::Vec3; 3],
684    half_extents: glam::Vec3,
685}
686
687impl OrientedCarHitbox {
688    fn corners(self) -> [glam::Vec3; 8] {
689        let x = self.axes[0] * self.half_extents.x;
690        let y = self.axes[1] * self.half_extents.y;
691        let z = self.axes[2] * self.half_extents.z;
692
693        [
694            self.center - x - y - z,
695            self.center - x - y + z,
696            self.center - x + y - z,
697            self.center - x + y + z,
698            self.center + x - y - z,
699            self.center + x - y + z,
700            self.center + x + y - z,
701            self.center + x + y + z,
702        ]
703    }
704
705    fn edge_segments(self) -> [(glam::Vec3, glam::Vec3); 12] {
706        let corners = self.corners();
707        [
708            (corners[0], corners[1]),
709            (corners[0], corners[2]),
710            (corners[0], corners[4]),
711            (corners[3], corners[1]),
712            (corners[3], corners[2]),
713            (corners[3], corners[7]),
714            (corners[5], corners[1]),
715            (corners[5], corners[4]),
716            (corners[5], corners[7]),
717            (corners[6], corners[2]),
718            (corners[6], corners[4]),
719            (corners[6], corners[7]),
720        ]
721    }
722}
723
724fn oriented_car_hitbox(
725    player_body: &boxcars::RigidBody,
726    hitbox: CarHitbox,
727) -> Option<OrientedCarHitbox> {
728    let car_position = vec_to_glam(&player_body.location);
729    let car_rotation = quat_to_glam(&player_body.rotation);
730    let hitbox_center = glam::Vec3::new(hitbox.offset, 0.0, hitbox.elevation);
731    let hitbox_rotation = glam::Quat::from_rotation_y(hitbox.angle.to_radians());
732    let rotation = car_rotation * hitbox_rotation;
733    let center = car_position + car_rotation * hitbox_center;
734    let axes = [
735        rotation * glam::Vec3::X,
736        rotation * glam::Vec3::Y,
737        rotation * glam::Vec3::Z,
738    ];
739    let half_extents =
740        glam::Vec3::new(hitbox.length / 2.0, hitbox.width / 2.0, hitbox.height / 2.0);
741
742    if center.is_finite() && axes.iter().all(|axis| axis.is_finite()) && half_extents.is_finite() {
743        Some(OrientedCarHitbox {
744            center,
745            axes,
746            half_extents,
747        })
748    } else {
749        None
750    }
751}
752
753fn point_oriented_box_distance(point: glam::Vec3, hitbox: OrientedCarHitbox) -> f32 {
754    let delta = point - hitbox.center;
755    let mut closest = hitbox.center;
756    for (axis, half_extent) in hitbox.axes.into_iter().zip([
757        hitbox.half_extents.x,
758        hitbox.half_extents.y,
759        hitbox.half_extents.z,
760    ]) {
761        let distance_on_axis = delta.dot(axis).clamp(-half_extent, half_extent);
762        closest += axis * distance_on_axis;
763    }
764    (point - closest).length()
765}
766
767fn segment_segment_distance(
768    left_start: glam::Vec3,
769    left_end: glam::Vec3,
770    right_start: glam::Vec3,
771    right_end: glam::Vec3,
772) -> f32 {
773    let left_delta = left_end - left_start;
774    let right_delta = right_end - right_start;
775    let offset = left_start - right_start;
776    let left_length_sq = left_delta.length_squared();
777    let right_length_sq = right_delta.length_squared();
778    let left_right_dot = left_delta.dot(right_delta);
779    let left_offset_dot = left_delta.dot(offset);
780    let right_offset_dot = right_delta.dot(offset);
781    let denominator = left_length_sq * right_length_sq - left_right_dot * left_right_dot;
782
783    let mut left_t;
784    let mut right_t;
785    if denominator.abs() > f32::EPSILON {
786        left_t = ((left_right_dot * right_offset_dot - right_length_sq * left_offset_dot)
787            / denominator)
788            .clamp(0.0, 1.0);
789    } else {
790        left_t = 0.0;
791    }
792
793    right_t = (left_right_dot * left_t + right_offset_dot) / right_length_sq;
794    if right_t < 0.0 {
795        right_t = 0.0;
796        left_t = (-left_offset_dot / left_length_sq).clamp(0.0, 1.0);
797    } else if right_t > 1.0 {
798        right_t = 1.0;
799        left_t = ((left_right_dot - left_offset_dot) / left_length_sq).clamp(0.0, 1.0);
800    }
801
802    let left_closest = left_start + left_delta * left_t;
803    let right_closest = right_start + right_delta * right_t;
804    (left_closest - right_closest).length()
805}
806
807fn projected_interval(points: &[glam::Vec3; 8], axis: glam::Vec3) -> (f32, f32) {
808    points
809        .iter()
810        .map(|point| point.dot(axis))
811        .fold((f32::INFINITY, f32::NEG_INFINITY), |(min, max), value| {
812            (min.min(value), max.max(value))
813        })
814}
815
816fn separated_on_axis(
817    left_corners: &[glam::Vec3; 8],
818    right_corners: &[glam::Vec3; 8],
819    axis: glam::Vec3,
820) -> bool {
821    if axis.length_squared() <= f32::EPSILON {
822        return false;
823    }
824    let axis = axis.normalize();
825    let (left_min, left_max) = projected_interval(left_corners, axis);
826    let (right_min, right_max) = projected_interval(right_corners, axis);
827    left_max < right_min || right_max < left_min
828}
829
830fn oriented_boxes_intersect(left: OrientedCarHitbox, right: OrientedCarHitbox) -> bool {
831    let left_corners = left.corners();
832    let right_corners = right.corners();
833    let face_axes = left.axes.into_iter().chain(right.axes);
834    let edge_axes = left
835        .axes
836        .into_iter()
837        .flat_map(|left_axis| right.axes.map(|right_axis| left_axis.cross(right_axis)));
838
839    face_axes
840        .chain(edge_axes)
841        .all(|axis| !separated_on_axis(&left_corners, &right_corners, axis))
842}
843
844fn oriented_box_distance(left: OrientedCarHitbox, right: OrientedCarHitbox) -> f32 {
845    if oriented_boxes_intersect(left, right) {
846        return 0.0;
847    }
848
849    let left_corners = left.corners();
850    let right_corners = right.corners();
851    let mut distance = f32::INFINITY;
852
853    for corner in left_corners {
854        distance = distance.min(point_oriented_box_distance(corner, right));
855    }
856    for corner in right_corners {
857        distance = distance.min(point_oriented_box_distance(corner, left));
858    }
859    for (left_start, left_end) in left.edge_segments() {
860        for (right_start, right_end) in right.edge_segments() {
861            distance = distance.min(segment_segment_distance(
862                left_start,
863                left_end,
864                right_start,
865                right_end,
866            ));
867        }
868    }
869
870    distance
871}
872
873pub fn car_hitbox_pair_contact_gap(
874    left_body: &boxcars::RigidBody,
875    left_hitbox: CarHitbox,
876    right_body: &boxcars::RigidBody,
877    right_hitbox: CarHitbox,
878) -> Option<f32> {
879    let left = oriented_car_hitbox(left_body, left_hitbox)?;
880    let right = oriented_car_hitbox(right_body, right_hitbox)?;
881    let distance = oriented_box_distance(left, right);
882    distance.is_finite().then_some(distance)
883}
884
885pub fn car_hitbox_min_world_z(player_body: &boxcars::RigidBody, hitbox: CarHitbox) -> Option<f32> {
886    let car_position = vec_to_glam(&player_body.location);
887    let car_rotation = quat_to_glam(&player_body.rotation);
888    let hitbox_center = glam::Vec3::new(hitbox.offset, 0.0, hitbox.elevation);
889    let hitbox_rotation = glam::Quat::from_rotation_y(hitbox.angle.to_radians());
890    let mut min_z: Option<f32> = None;
891
892    for x in [-hitbox.length / 2.0, hitbox.length / 2.0] {
893        for y in [-hitbox.width / 2.0, hitbox.width / 2.0] {
894            for z in [-hitbox.height / 2.0, hitbox.height / 2.0] {
895                let local_corner = glam::Vec3::new(x, y, z);
896                let world_corner =
897                    car_position + car_rotation * (hitbox_center + hitbox_rotation * local_corner);
898                if !world_corner.z.is_finite() {
899                    return None;
900                }
901                min_z = Some(min_z.map_or(world_corner.z, |current| current.min(world_corner.z)));
902            }
903        }
904    }
905
906    min_z
907}
908
909pub fn car_hitbox_touches_floor(player_body: &boxcars::RigidBody, hitbox: CarHitbox) -> bool {
910    const FLOOR_CONTACT_MAX_Z: f32 = 5.0;
911
912    car_hitbox_min_world_z(player_body, hitbox).is_some_and(|min_z| min_z <= FLOOR_CONTACT_MAX_Z)
913}
914
915pub fn touch_candidate_contact_gap_rank_with_hitbox(
916    ball_body: &boxcars::RigidBody,
917    player_body: &boxcars::RigidBody,
918    hitbox: CarHitbox,
919) -> Option<(f32, f32)> {
920    touch_candidate_rank_with_hitbox(ball_body, player_body, hitbox).map(
921        |(closest_center_distance, current_center_distance)| {
922            (
923                (closest_center_distance - BALL_COLLISION_RADIUS).max(0.0),
924                (current_center_distance - BALL_COLLISION_RADIUS).max(0.0),
925            )
926        },
927    )
928}
929
930#[derive(Debug, Clone, Copy, PartialEq)]
931pub struct BallTrajectoryDeviation {
932    pub position_deviation: f32,
933    pub velocity_deviation: f32,
934    pub seconds: f32,
935}
936
937pub fn ball_trajectory_deviation_with_gravity(
938    previous_body: &boxcars::RigidBody,
939    previous_time: f32,
940    actual_body: &boxcars::RigidBody,
941    actual_time: f32,
942    gravity_z: f32,
943) -> Option<BallTrajectoryDeviation> {
944    let seconds = actual_time - previous_time;
945    if !seconds.is_finite() || seconds <= 0.0 {
946        return None;
947    }
948
949    let previous_velocity = vec_to_glam(&previous_body.linear_velocity?);
950    let actual_velocity = vec_to_glam(&actual_body.linear_velocity?);
951    let gravity = glam::Vec3::new(0.0, 0.0, gravity_z);
952    let expected_position = vec_to_glam(&previous_body.location)
953        + previous_velocity * seconds
954        + 0.5 * gravity * seconds * seconds;
955    let expected_velocity = previous_velocity + gravity * seconds;
956    let actual_position = vec_to_glam(&actual_body.location);
957
958    let position_deviation = (actual_position - expected_position).length();
959    let velocity_deviation = (actual_velocity - expected_velocity).length();
960    if !position_deviation.is_finite() || !velocity_deviation.is_finite() {
961        return None;
962    }
963
964    Some(BallTrajectoryDeviation {
965        position_deviation,
966        velocity_deviation,
967        seconds,
968    })
969}
970
971/// Ranks how plausible it is that `player_body` was the car that touched the
972/// ball near the current frame, using velocity-applied closest approach to the
973/// car's moving, oriented hitbox.
974///
975/// The frame's ball state can already be slightly post-contact, so we do not
976/// just compare current distance. Instead we look for the minimum ball/hitbox
977/// separation over a short window centered slightly before the frame time.
978#[cfg_attr(not(test), allow(dead_code))]
979pub(crate) fn touch_candidate_rank(
980    ball_body: &boxcars::RigidBody,
981    player_body: &boxcars::RigidBody,
982) -> Option<(f32, f32)> {
983    touch_candidate_rank_with_hitbox(ball_body, player_body, default_car_hitbox())
984}
985
986pub fn touch_candidate_rank_with_hitbox(
987    ball_body: &boxcars::RigidBody,
988    player_body: &boxcars::RigidBody,
989    hitbox: CarHitbox,
990) -> Option<(f32, f32)> {
991    const TOUCH_LOOKBACK_SECONDS: f32 = 0.12;
992    const TOUCH_LOOKAHEAD_SECONDS: f32 = 0.03;
993    const TOUCH_RANK_SAMPLES: usize = 9;
994
995    let current_distance =
996        car_hitbox_distance(vec_to_glam(&ball_body.location), player_body, hitbox)?;
997
998    let mut closest_distance = current_distance;
999    for sample_index in 0..=TOUCH_RANK_SAMPLES {
1000        let sample_fraction = sample_index as f32 / TOUCH_RANK_SAMPLES as f32;
1001        let sample_time = -TOUCH_LOOKBACK_SECONDS
1002            + sample_fraction * (TOUCH_LOOKBACK_SECONDS + TOUCH_LOOKAHEAD_SECONDS);
1003        let sample_ball_body = apply_velocities_to_rigid_body(ball_body, sample_time);
1004        let sample_player_body = apply_velocities_to_rigid_body(player_body, sample_time);
1005        let sample_distance = car_hitbox_distance(
1006            vec_to_glam(&sample_ball_body.location),
1007            &sample_player_body,
1008            hitbox,
1009        )?;
1010        closest_distance = closest_distance.min(sample_distance);
1011    }
1012
1013    Some((closest_distance, current_distance))
1014}
1015
1016fn apply_angular_velocity(rigid_body: &boxcars::RigidBody, time_delta: f32) -> boxcars::Quaternion {
1017    let rbav = rigid_body.angular_velocity.unwrap_or(boxcars::Vector3f {
1018        x: 0.0,
1019        y: 0.0,
1020        z: 0.0,
1021    });
1022    let angular_velocity = glam::Vec3::new(rbav.x, rbav.y, rbav.z);
1023    let magnitude = angular_velocity.length();
1024    let angular_velocity_unit_vector = angular_velocity.normalize_or_zero();
1025
1026    let mut rotation = glam::Quat::from_xyzw(
1027        rigid_body.rotation.x,
1028        rigid_body.rotation.y,
1029        rigid_body.rotation.z,
1030        rigid_body.rotation.w,
1031    );
1032
1033    if angular_velocity_unit_vector.length() != 0.0 {
1034        let delta_rotation =
1035            glam::Quat::from_axis_angle(angular_velocity_unit_vector, magnitude * time_delta);
1036        rotation *= delta_rotation;
1037    }
1038    rotation = if rotation.length_squared() > 0.0 {
1039        rotation.normalize()
1040    } else {
1041        glam::Quat::IDENTITY
1042    };
1043
1044    boxcars::Quaternion {
1045        x: rotation.x,
1046        y: rotation.y,
1047        z: rotation.z,
1048        w: rotation.w,
1049    }
1050}
1051
1052/// Interpolates between two [`boxcars::RigidBody`] states based on the provided time.
1053///
1054/// # Arguments
1055///
1056/// * `start_body` - The initial `RigidBody` state.
1057/// * `start_time` - The timestamp of the initial `RigidBody` state.
1058/// * `end_body` - The final `RigidBody` state.
1059/// * `end_time` - The timestamp of the final `RigidBody` state.
1060/// * `time` - The desired timestamp to interpolate to.
1061///
1062/// # Returns
1063///
1064/// A new [`boxcars::RigidBody`] that represents the interpolated state at the specified time.
1065pub fn get_interpolated_rigid_body(
1066    start_body: &boxcars::RigidBody,
1067    start_time: f32,
1068    end_body: &boxcars::RigidBody,
1069    end_time: f32,
1070    time: f32,
1071) -> SubtrActorResult<boxcars::RigidBody> {
1072    if !(start_time <= time && time <= end_time) {
1073        return SubtrActorError::new_result(SubtrActorErrorVariant::InterpolationTimeOrderError {
1074            start_time,
1075            time,
1076            end_time,
1077        });
1078    }
1079
1080    let duration = end_time - start_time;
1081    let interpolation_amount = (time - start_time) / duration;
1082    let start_position = vec_to_glam(&start_body.location);
1083    let end_position = vec_to_glam(&end_body.location);
1084    let interpolated_location = start_position.lerp(end_position, interpolation_amount);
1085    let start_rotation = quat_to_glam(&start_body.rotation);
1086    let end_rotation = quat_to_glam(&end_body.rotation);
1087    let interpolated_rotation = start_rotation.slerp(end_rotation, interpolation_amount);
1088
1089    Ok(boxcars::RigidBody {
1090        location: glam_to_vec(&interpolated_location),
1091        rotation: glam_to_quat(&interpolated_rotation),
1092        sleeping: start_body.sleeping,
1093        linear_velocity: start_body.linear_velocity,
1094        angular_velocity: start_body.angular_velocity,
1095    })
1096}
1097
1098#[cfg(test)]
1099#[path = "geometry_tests.rs"]
1100mod tests;