Skip to main content

sgl_core/collision/
aabb.rs

1//! Axis-aligned boxes and the closed-form swept-AABB slab test.
2
3use crate::math::Vec2;
4
5/// An axis-aligned box centered at `center` with per-axis `half`-extents.
6#[derive(Debug, Clone, Copy, PartialEq)]
7pub struct Aabb {
8    /// Center in world units.
9    pub center: Vec2,
10    /// Half-extents in world units. Zero is legal (a point or a line).
11    pub half: Vec2,
12}
13
14impl Aabb {
15    /// A box centered at `center` with half-extents `half`.
16    #[must_use]
17    pub const fn new(center: Vec2, half: Vec2) -> Self {
18        Self { center, half }
19    }
20
21    /// A box spanning the `min`..`max` corner pair.
22    #[must_use]
23    pub fn from_min_max(min: Vec2, max: Vec2) -> Self {
24        Self {
25            center: (min + max) * 0.5,
26            half: (max - min) * 0.5,
27        }
28    }
29
30    /// The lower corner.
31    #[must_use]
32    pub fn min(&self) -> Vec2 {
33        self.center - self.half
34    }
35
36    /// The upper corner.
37    #[must_use]
38    pub fn max(&self) -> Vec2 {
39        self.center + self.half
40    }
41
42    /// The same box moved by `delta`.
43    #[must_use]
44    pub fn translated(&self, delta: Vec2) -> Self {
45        Self {
46            center: self.center + delta,
47            half: self.half,
48        }
49    }
50
51    /// Whether the two boxes share interior area. Strict: boxes that merely
52    /// touch along an edge do **not** overlap, so a body resting exactly on a
53    /// surface is not reported as inside it.
54    #[must_use]
55    pub fn overlaps(&self, other: &Self) -> bool {
56        let (a_min, a_max) = (self.min(), self.max());
57        let (b_min, b_max) = (other.min(), other.max());
58        a_min.x < b_max.x && a_max.x > b_min.x && a_min.y < b_max.y && a_max.y > b_min.y
59    }
60}
61
62/// A swept contact: when along the motion it happens, and which face was hit.
63#[derive(Debug, Clone, Copy, PartialEq)]
64pub struct Hit {
65    /// Fraction of the swept `delta` traveled before contact, in `[0, 1]`.
66    /// `0` means already touching along the entering axis; `1` means contact
67    /// exactly at the end of the motion.
68    pub t: f32,
69    /// Axis-aligned unit normal of the surface struck, pointing from that
70    /// surface toward the body.
71    pub normal: Vec2,
72}
73
74/// Sweeps `body` by `delta` against the static box `target` and returns the
75/// first contact, or `None` if the motion does not enter `target` within
76/// `[0, 1]`.
77///
78/// A closed-form slab test on the Minkowski sum: `target` is grown by `body`'s
79/// half-extents and `body` is swept as a point at its center. The normal comes
80/// from the **last axis to enter** — the axis whose entry time is greatest —
81/// signed against the motion on that axis.
82///
83/// Semantics at the edges, all of which return `None` rather than an invented
84/// contact:
85///
86/// * **Already overlapping.** Entry lies behind the start, so an embedded body
87///   reports no hit and is free to move out. It never receives a normal from a
88///   face it is already past, which would otherwise pin it inside.
89/// * **Moving away.** Same reason: entry is negative.
90/// * **Zero or non-finite `delta`.** There is no motion to find an entry time
91///   along.
92///
93/// A tie between the two axes (an exact corner hit) resolves to the horizontal
94/// face — the floor or ceiling — because a body arriving exactly at a corner
95/// while falling should land rather than be shoved sideways.
96#[must_use]
97pub fn sweep_aabb(body: &Aabb, delta: Vec2, target: &Aabb) -> Option<Hit> {
98    if delta == Vec2::ZERO || !delta.is_finite() {
99        return None;
100    }
101
102    let expanded = Aabb::new(target.center, target.half + body.half);
103    let p = body.center;
104    let min = expanded.min();
105    let max = expanded.max();
106
107    let (entry_x, exit_x) = slab(p.x, delta.x, min.x, max.x)?;
108    let (entry_y, exit_y) = slab(p.y, delta.y, min.y, max.y)?;
109
110    let entry = entry_x.max(entry_y);
111    let exit = exit_x.min(exit_y);
112
113    // `entry > exit`: the slabs are never crossed at the same time — the sweep
114    // passes beside the target. Outside `[0, 1]`: contact is behind the start
115    // (already overlapping or moving away) or beyond the end of the motion. A
116    // NaN entry fails the range test and lands here too.
117    if entry > exit || !(0.0..=1.0).contains(&entry) {
118        return None;
119    }
120
121    let normal = if entry_x > entry_y {
122        // The x axis entered last: a vertical face.
123        Vec2::new(if delta.x > 0.0 { -1.0 } else { 1.0 }, 0.0)
124    } else {
125        // The y axis entered last, ties included: a horizontal face.
126        Vec2::new(0.0, if delta.y > 0.0 { -1.0 } else { 1.0 })
127    };
128
129    Some(Hit { t: entry, normal })
130}
131
132/// Entry and exit times of `pos + t * d` crossing the slab `[lo, hi]`.
133///
134/// Motion smaller than `f32::EPSILON` counts as parallel: it cannot carry the
135/// point across a slab it is outside of, and dividing by it would manufacture
136/// enormous or infinite times. A parallel point already strictly inside the
137/// slab is unconstrained on this axis (`-inf`..`inf`) and lets the other axis
138/// decide; a parallel point outside can never enter.
139fn slab(pos: f32, d: f32, lo: f32, hi: f32) -> Option<(f32, f32)> {
140    if d.abs() < f32::EPSILON {
141        if pos > lo && pos < hi {
142            Some((f32::NEG_INFINITY, f32::INFINITY))
143        } else {
144            None
145        }
146    } else {
147        let t1 = (lo - pos) / d;
148        let t2 = (hi - pos) / d;
149        Some((t1.min(t2), t1.max(t2)))
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use wasm_bindgen_test::wasm_bindgen_test;
157
158    /// #249: boxes that share an edge or a corner do not overlap on any side,
159    /// and one nudged inward on any side does — the strict `<`/`>` in
160    /// [`Aabb::overlaps`] is the contract that lets a body rest on a surface.
161    #[wasm_bindgen_test(unsupported = test)]
162    fn touching_on_every_side_is_not_overlap_but_a_nudge_is() {
163        let body = Aabb::new(Vec2::ZERO, Vec2::splat(1.0));
164        for (dx, dy) in [(2.0, 0.0), (-2.0, 0.0), (0.0, 2.0), (0.0, -2.0), (2.0, 2.0)] {
165            let touching = Aabb::new(Vec2::new(dx, dy), Vec2::splat(1.0));
166            assert!(!body.overlaps(&touching), "touching at {dx},{dy}");
167            let nudged = Aabb::new(Vec2::new(dx * 0.999, dy * 0.999), Vec2::splat(1.0));
168            assert!(body.overlaps(&nudged), "nudged at {dx},{dy}");
169        }
170    }
171
172    /// #249: a sweep whose path grazes the target's corner exactly (entry
173    /// and exit coincide) is a contact at that instant, on the face the
174    /// motion entered last; a body sliding along a touching edge with no
175    /// motion toward it is not a contact at all.
176    #[wasm_bindgen_test(unsupported = test)]
177    fn corner_grazes_hit_and_edge_slides_do_not() {
178        let target = Aabb::new(Vec2::new(4.0, 4.0), Vec2::splat(1.0));
179        let graze = sweep_aabb(
180            &Aabb::new(Vec2::new(0.0, 4.0), Vec2::splat(1.0)),
181            Vec2::new(4.0, 4.0),
182            &target,
183        )
184        .expect("grazing the corner is a contact");
185        assert_eq!(graze.t.to_bits(), 0.5f32.to_bits());
186        assert_eq!(graze.normal, Vec2::NEG_X);
187
188        // Bottom edge of the body exactly on the target's top edge, moving
189        // sideways across it: parallel on y and only touching, so no hit.
190        let slide = sweep_aabb(
191            &Aabb::new(Vec2::new(0.0, 6.0), Vec2::splat(1.0)),
192            Vec2::new(8.0, 0.0),
193            &target,
194        );
195        assert_eq!(slide, None);
196        // The same slide one hair lower is an immediate hit.
197        let scrape = sweep_aabb(
198            &Aabb::new(Vec2::new(0.0, 5.999), Vec2::splat(1.0)),
199            Vec2::new(8.0, 0.0),
200            &target,
201        );
202        assert!(scrape.is_some());
203    }
204
205    fn aabb(cx: f32, cy: f32, hx: f32, hy: f32) -> Aabb {
206        Aabb::new(Vec2::new(cx, cy), Vec2::new(hx, hy))
207    }
208
209    /// Corner-pair construction must recover the geometric center and half
210    /// extents of an asymmetric box, and touching boxes must not count as
211    /// overlapping — a body resting on a floor would otherwise read as
212    /// embedded in it.
213    #[wasm_bindgen_test(unsupported = test)]
214    fn from_min_max_recovers_geometry_and_touching_is_not_overlap() {
215        let a = Aabb::from_min_max(Vec2::new(1.0, 2.0), Vec2::new(4.0, 6.0));
216        assert_eq!(a.center, Vec2::new(2.5, 4.0));
217        assert_eq!(a.half, Vec2::new(1.5, 2.0));
218        assert_eq!(a.min(), Vec2::new(1.0, 2.0));
219        assert_eq!(a.max(), Vec2::new(4.0, 6.0));
220
221        let touching = Aabb::from_min_max(Vec2::new(4.0, 2.0), Vec2::new(5.0, 6.0));
222        assert!(!a.overlaps(&touching), "shared edge is not an overlap");
223        assert!(a.overlaps(&touching.translated(Vec2::new(-0.5, 0.0))));
224    }
225
226    /// The entry time and normal must come from the geometry: a body whose
227    /// right edge is 4 units from the wall's left edge, moving 10 units,
228    /// contacts at t = 0.4 with the normal pointing back along the motion.
229    #[wasm_bindgen_test(unsupported = test)]
230    fn sweep_wall_reports_geometric_entry_time_and_facing_normal() {
231        let body = aabb(-5.0, 0.0, 0.5, 0.5);
232        let wall = aabb(0.0, 0.0, 0.5, 0.5);
233        let hit = sweep_aabb(&body, Vec2::new(10.0, 0.0), &wall).expect("hit");
234        assert_eq!(hit.normal, Vec2::new(-1.0, 0.0));
235        assert!((hit.t - 0.4).abs() < 1e-6, "t was {}", hit.t);
236    }
237
238    /// Vertical faces must be signed against the motion: landing yields `+y`
239    /// and a head-bump `-y`. A sign slip here inverts floor and ceiling.
240    #[wasm_bindgen_test(unsupported = test)]
241    fn sweep_vertical_normals_are_signed_against_the_motion() {
242        let surface = aabb(0.0, 0.0, 5.0, 0.5);
243        let above = aabb(0.0, 5.0, 0.5, 0.5);
244        let below = aabb(0.0, -5.0, 0.5, 0.5);
245        let landing = sweep_aabb(&above, Vec2::new(0.0, -10.0), &surface).expect("hit");
246        let bump = sweep_aabb(&below, Vec2::new(0.0, 10.0), &surface).expect("hit");
247        assert_eq!(landing.normal, Vec2::new(0.0, 1.0));
248        assert_eq!(bump.normal, Vec2::new(0.0, -1.0));
249    }
250
251    /// Motion that passes beside the target, or stops short of it, is a miss.
252    /// Contact reached exactly at the end of the motion is a hit at t = 1.
253    #[wasm_bindgen_test(unsupported = test)]
254    fn sweep_miss_and_end_of_motion_boundaries() {
255        let body = aabb(-5.0, 0.0, 0.5, 0.5);
256        let wall = aabb(0.0, 0.0, 0.5, 0.5);
257        let beside = aabb(-5.0, 5.0, 0.5, 0.5);
258
259        assert!(sweep_aabb(&beside, Vec2::new(10.0, 0.0), &wall).is_none());
260        // Contact needs 4 units of travel; 1 unit stops short.
261        assert!(sweep_aabb(&body, Vec2::new(1.0, 0.0), &wall).is_none());
262        let hit = sweep_aabb(&body, Vec2::new(4.0, 0.0), &wall).expect("hit at t=1");
263        assert!((hit.t - 1.0).abs() < 1e-6, "t was {}", hit.t);
264    }
265
266    /// An exact corner arrival (equal entry times on both axes) must resolve to
267    /// the horizontal face, so a body falling into the corner of a ledge lands
268    /// on it instead of being pushed sideways.
269    #[wasm_bindgen_test(unsupported = test)]
270    fn sweep_corner_tie_resolves_to_the_horizontal_face() {
271        // Both axes have 1 unit of gap and 4 units of travel: entry ties at 0.25.
272        let body = aabb(-2.0, -2.0, 0.5, 0.5);
273        let block = aabb(0.0, 0.0, 0.5, 0.5);
274        let hit = sweep_aabb(&body, Vec2::new(4.0, 4.0), &block).expect("hit");
275        assert!((hit.t - 0.25).abs() < 1e-6, "t was {}", hit.t);
276        assert_eq!(hit.normal, Vec2::new(0.0, -1.0));
277    }
278
279    /// A body that starts inside a collider reports no hit at all, in either
280    /// direction. Reporting a contact here would hand back a normal from a face
281    /// the body is already past and trap it inside the box forever.
282    #[wasm_bindgen_test(unsupported = test)]
283    fn sweep_embedded_body_reports_no_hit_in_any_direction() {
284        let block = aabb(0.0, 0.0, 1.0, 1.0);
285        let body = aabb(0.2, 0.0, 0.5, 0.5);
286        for delta in [
287            Vec2::new(3.0, 0.0),
288            Vec2::new(-3.0, 0.0),
289            Vec2::new(0.0, 3.0),
290            Vec2::new(0.0, -3.0),
291        ] {
292            assert!(
293                sweep_aabb(&body, delta, &block).is_none(),
294                "embedded body must be free to move by {delta}"
295            );
296        }
297    }
298
299    /// Degenerate motion must not produce a contact: zero delta has no entry
300    /// time, and NaN or infinite delta must fall out instead of yielding a
301    /// bogus t (NaN comparisons are all false, so a naive range check passes).
302    #[wasm_bindgen_test(unsupported = test)]
303    fn sweep_zero_and_non_finite_delta_report_no_hit() {
304        let body = aabb(-5.0, 0.0, 0.5, 0.5);
305        let wall = aabb(0.0, 0.0, 0.5, 0.5);
306        assert!(sweep_aabb(&body, Vec2::ZERO, &wall).is_none());
307        assert!(sweep_aabb(&body, Vec2::new(f32::NAN, 0.0), &wall).is_none());
308        assert!(sweep_aabb(&body, Vec2::new(f32::INFINITY, 0.0), &wall).is_none());
309        assert!(sweep_aabb(&body, Vec2::new(0.0, f32::NEG_INFINITY), &wall).is_none());
310    }
311
312    /// A delta far too small to cross the 4-unit gap must miss. Dividing by a
313    /// near-zero component yields an enormous entry time, so this fails the
314    /// moment the range gate is loosened or the times are clamped.
315    #[wasm_bindgen_test(unsupported = test)]
316    fn sweep_sub_epsilon_delta_cannot_reach_a_distant_target() {
317        let body = aabb(-5.0, 0.0, 0.5, 0.5);
318        let wall = aabb(0.0, 0.0, 0.5, 0.5);
319        assert!(sweep_aabb(&body, Vec2::new(1e-9, 0.0), &wall).is_none());
320        assert!(sweep_aabb(&body, Vec2::new(1e-3, 0.0), &wall).is_none());
321    }
322
323    /// A zero-extent body sweeps as a ray against the target's real faces, so
324    /// callers can probe points without a special code path.
325    #[wasm_bindgen_test(unsupported = test)]
326    fn sweep_zero_extent_body_behaves_like_a_ray() {
327        let point = aabb(-4.0, 0.0, 0.0, 0.0);
328        let block = aabb(0.0, 0.0, 2.0, 2.0);
329        // The point starts 2 units from the block's left face and travels 8.
330        let hit = sweep_aabb(&point, Vec2::new(8.0, 0.0), &block).expect("hit");
331        assert!((hit.t - 0.25).abs() < 1e-6, "t was {}", hit.t);
332        assert_eq!(hit.normal, Vec2::new(-1.0, 0.0));
333    }
334}