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