ferravex_core/
collision.rs

1//! Small allocation-free 2D collision queries for game logic, independent of rendering.
2use ferravex_math::Vec2;
3
4#[derive(Debug, Clone, Copy, PartialEq)]
5pub struct Aabb2<V = Vec2> {
6    pub min: V,
7    pub max: V,
8}
9
10impl<V: Copy + Into<[f32; 2]> + From<[f32; 2]>> Aabb2<V> {
11    pub fn from_min_size(min: V, size: V) -> Self {
12        let a: [f32; 2] = min.into();
13        let b: [f32; 2] = size.into();
14        Self {
15            min,
16            max: [a[0] + b[0], a[1] + b[1]].into(),
17        }
18    }
19
20    fn owned(self) -> Aabb2 {
21        Aabb2 {
22            min: Vec2::from(self.min.into()),
23            max: Vec2::from(self.max.into()),
24        }
25    }
26    fn valid(self) -> bool {
27        let a = self.owned();
28        a.min.is_finite() && a.max.is_finite() && a.max.cmpge(a.min).all()
29    }
30
31    /// Positive-area overlap: touching edges alone do not count as penetration.
32    /// This distinction matters when a character rests on a platform.
33    pub fn overlaps(self, other: Self) -> bool {
34        let (this, other) = (self.owned(), other.owned());
35        this.valid()
36            && other.valid()
37            && this.min.cmplt(this.max).all()
38            && other.min.cmplt(other.max).all()
39            && this.min.cmplt(other.max).all()
40            && this.max.cmpgt(other.min).all()
41    }
42
43    /// First hit along a finite segment, as a fraction in [0, 1].
44    /// Starts inside/on the boundary return zero; touching boundaries count as hits.
45    pub fn cast_segment(self, start: V, end: V) -> Option<f32> {
46        let this = self.owned();
47        let (start, end) = (Vec2::from(start.into()), Vec2::from(end.into()));
48        if !this.valid() || !start.is_finite() || !end.is_finite() {
49            return None;
50        }
51        let delta = end - start;
52        if !delta.is_finite() {
53            return None;
54        }
55        let mut near: f32 = 0.0;
56        let mut far: f32 = 1.0;
57        for axis in 0..2 {
58            if delta[axis] == 0.0 {
59                if start[axis] < this.min[axis] || start[axis] > this.max[axis] {
60                    return None;
61                }
62            } else {
63                let a = (this.min[axis] - start[axis]) / delta[axis];
64                let b = (this.max[axis] - start[axis]) / delta[axis];
65                near = near.max(a.min(b));
66                far = far.min(a.max(b));
67                if near > far {
68                    return None;
69                }
70            }
71        }
72        Some(near)
73    }
74}
75
76/// Earliest segment/circle hit in [0, 1], including a stationary point inside.
77/// Useful for fast projectiles that cross a target between simulation ticks.
78pub fn segment_circle<V: Copy + Into<[f32; 2]>>(
79    start: V,
80    end: V,
81    center: V,
82    radius: f32,
83) -> Option<f32> {
84    let (start, end, center) = (
85        Vec2::from(start.into()),
86        Vec2::from(end.into()),
87        Vec2::from(center.into()),
88    );
89    if !start.is_finite()
90        || !end.is_finite()
91        || !center.is_finite()
92        || !radius.is_finite()
93        || radius < 0.0
94    {
95        return None;
96    }
97    let delta = end - start;
98    let relative = start - center;
99    let c = relative.length_squared() - radius * radius;
100    if c <= 0.0 {
101        return Some(0.0);
102    }
103    let a = delta.length_squared();
104    if a == 0.0 || !a.is_finite() || !c.is_finite() {
105        return None;
106    }
107    let b = relative.dot(delta);
108    let discriminant = b * b - a * c;
109    if discriminant < 0.0 || !discriminant.is_finite() {
110        return None;
111    }
112    let t = (-b - discriminant.sqrt()) / a;
113    (0.0..=1.0).contains(&t).then_some(t)
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    #[test]
120    fn resting_edges_do_not_push_a_platformer_sideways() {
121        let floor = Aabb2::from_min_size(Vec2::new(0.0, 10.0), Vec2::new(100.0, 10.0));
122        assert!(!floor.overlaps(Aabb2::from_min_size(Vec2::new(4.0, 0.0), Vec2::splat(10.0))));
123        assert!(floor.overlaps(Aabb2::from_min_size(Vec2::new(4.0, 0.1), Vec2::splat(10.0))));
124        assert!(!floor.overlaps(Aabb2::from_min_size(Vec2::new(4.0, 11.0), Vec2::ZERO)));
125    }
126    #[test]
127    fn segment_queries_cover_tunneling_tangency_and_stationary_input() {
128        let box_ = Aabb2::from_min_size(Vec2::splat(5.0), Vec2::splat(2.0));
129        assert_eq!(
130            box_.cast_segment(Vec2::new(0.0, 6.0), Vec2::new(10.0, 6.0)),
131            Some(0.5)
132        );
133        assert_eq!(
134            box_.cast_segment(Vec2::splat(6.0), Vec2::splat(6.0)),
135            Some(0.0)
136        );
137        assert_eq!(box_.cast_segment(Vec2::ZERO, Vec2::X), None);
138        assert_eq!(
139            segment_circle(
140                Vec2::ZERO,
141                Vec2::new(100.0, 0.0),
142                Vec2::new(50.0, 0.0),
143                10.0
144            ),
145            Some(0.4)
146        );
147        assert_eq!(
148            segment_circle(
149                Vec2::ZERO,
150                Vec2::new(100.0, 0.0),
151                Vec2::new(50.0, 10.0),
152                10.0
153            ),
154            Some(0.5)
155        );
156        assert_eq!(
157            segment_circle(Vec2::ZERO, Vec2::ZERO, Vec2::ZERO, 1.0),
158            Some(0.0)
159        );
160        assert_eq!(segment_circle(Vec2::ZERO, Vec2::ZERO, Vec2::X, 0.1), None);
161        assert_eq!(segment_circle(Vec2::ZERO, Vec2::X, Vec2::X, f32::NAN), None);
162    }
163}