use crate::math::Vec2;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Aabb {
pub center: Vec2,
pub half: Vec2,
}
impl Aabb {
#[must_use]
pub const fn new(center: Vec2, half: Vec2) -> Self {
Self { center, half }
}
#[must_use]
pub fn from_min_max(min: Vec2, max: Vec2) -> Self {
Self {
center: (min + max) * 0.5,
half: (max - min) * 0.5,
}
}
#[must_use]
pub fn min(&self) -> Vec2 {
self.center - self.half
}
#[must_use]
pub fn max(&self) -> Vec2 {
self.center + self.half
}
#[must_use]
pub fn translated(&self, delta: Vec2) -> Self {
Self {
center: self.center + delta,
half: self.half,
}
}
#[must_use]
pub fn overlaps(&self, other: &Self) -> bool {
let (a_min, a_max) = (self.min(), self.max());
let (b_min, b_max) = (other.min(), other.max());
a_min.x < b_max.x && a_max.x > b_min.x && a_min.y < b_max.y && a_max.y > b_min.y
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Hit {
pub t: f32,
pub normal: Vec2,
}
#[must_use]
pub fn sweep_aabb(body: &Aabb, delta: Vec2, target: &Aabb) -> Option<Hit> {
if delta == Vec2::ZERO || !delta.is_finite() {
return None;
}
let (b_min, b_max) = (body.min(), body.max());
let (t_min, t_max) = (target.min(), target.max());
let (entry_x, exit_x) = slab(b_min.x, b_max.x, delta.x, t_min.x, t_max.x)?;
let (entry_y, exit_y) = slab(b_min.y, b_max.y, delta.y, t_min.y, t_max.y)?;
let entry = entry_x.max(entry_y);
let exit = exit_x.min(exit_y);
if entry > exit || !(0.0..=1.0).contains(&entry) {
return None;
}
let normal = if entry_x > entry_y {
Vec2::new(if delta.x > 0.0 { -1.0 } else { 1.0 }, 0.0)
} else {
Vec2::new(0.0, if delta.y > 0.0 { -1.0 } else { 1.0 })
};
Some(Hit {
t: entry + 0.0,
normal,
})
}
fn slab(b_lo: f32, b_hi: f32, d: f32, t_lo: f32, t_hi: f32) -> Option<(f32, f32)> {
if d == 0.0 {
if b_hi > t_lo && b_lo < t_hi {
Some((f32::NEG_INFINITY, f32::INFINITY))
} else {
None
}
} else if d > 0.0 {
Some(((t_lo - b_hi) / d, (t_hi - b_lo) / d))
} else {
Some(((t_hi - b_lo) / d, (t_lo - b_hi) / d))
}
}
#[cfg(test)]
mod tests {
use super::*;
use wasm_bindgen_test::wasm_bindgen_test;
#[wasm_bindgen_test(unsupported = test)]
fn touching_on_every_side_is_not_overlap_but_a_nudge_is() {
let body = Aabb::new(Vec2::ZERO, Vec2::splat(1.0));
for (dx, dy) in [(2.0, 0.0), (-2.0, 0.0), (0.0, 2.0), (0.0, -2.0), (2.0, 2.0)] {
let touching = Aabb::new(Vec2::new(dx, dy), Vec2::splat(1.0));
assert!(!body.overlaps(&touching), "touching at {dx},{dy}");
let nudged = Aabb::new(Vec2::new(dx * 0.999, dy * 0.999), Vec2::splat(1.0));
assert!(body.overlaps(&nudged), "nudged at {dx},{dy}");
}
}
#[wasm_bindgen_test(unsupported = test)]
fn corner_grazes_hit_and_edge_slides_do_not() {
let target = Aabb::new(Vec2::new(4.0, 4.0), Vec2::splat(1.0));
let graze = sweep_aabb(
&Aabb::new(Vec2::new(0.0, 4.0), Vec2::splat(1.0)),
Vec2::new(4.0, 4.0),
&target,
)
.expect("grazing the corner is a contact");
assert_eq!(graze.t.to_bits(), 0.5f32.to_bits());
assert_eq!(graze.normal, Vec2::NEG_X);
let slide = sweep_aabb(
&Aabb::new(Vec2::new(0.0, 6.0), Vec2::splat(1.0)),
Vec2::new(8.0, 0.0),
&target,
);
assert_eq!(slide, None);
let scrape = sweep_aabb(
&Aabb::new(Vec2::new(0.0, 5.999), Vec2::splat(1.0)),
Vec2::new(8.0, 0.0),
&target,
);
assert!(scrape.is_some());
}
#[wasm_bindgen_test(unsupported = test)]
fn a_body_exactly_touching_at_rounding_coordinates_hits_at_once() {
let body = aabb(1.090_340_4, 4.711_776_3, 0.4, 0.249_400_84);
let floor = aabb(1.090_340_4, 4.337_375_6, 50.0, 0.125);
assert_eq!(body.min().y.to_bits(), floor.max().y.to_bits());
assert!(!body.overlaps(&floor));
let hit = sweep_aabb(&body, Vec2::new(0.0, -3.0), &floor).expect("touching is a contact");
assert_eq!(hit.t.to_bits(), 0.0f32.to_bits());
assert_eq!(hit.normal, Vec2::Y);
}
fn aabb(cx: f32, cy: f32, hx: f32, hy: f32) -> Aabb {
Aabb::new(Vec2::new(cx, cy), Vec2::new(hx, hy))
}
#[wasm_bindgen_test(unsupported = test)]
fn from_min_max_recovers_geometry_and_touching_is_not_overlap() {
let a = Aabb::from_min_max(Vec2::new(1.0, 2.0), Vec2::new(4.0, 6.0));
assert_eq!(a.center, Vec2::new(2.5, 4.0));
assert_eq!(a.half, Vec2::new(1.5, 2.0));
assert_eq!(a.min(), Vec2::new(1.0, 2.0));
assert_eq!(a.max(), Vec2::new(4.0, 6.0));
let touching = Aabb::from_min_max(Vec2::new(4.0, 2.0), Vec2::new(5.0, 6.0));
assert!(!a.overlaps(&touching), "shared edge is not an overlap");
assert!(a.overlaps(&touching.translated(Vec2::new(-0.5, 0.0))));
}
#[wasm_bindgen_test(unsupported = test)]
fn sweep_wall_reports_geometric_entry_time_and_facing_normal() {
let body = aabb(-5.0, 0.0, 0.5, 0.5);
let wall = aabb(0.0, 0.0, 0.5, 0.5);
let hit = sweep_aabb(&body, Vec2::new(10.0, 0.0), &wall).expect("hit");
assert_eq!(hit.normal, Vec2::new(-1.0, 0.0));
assert!((hit.t - 0.4).abs() < 1e-6, "t was {}", hit.t);
}
#[wasm_bindgen_test(unsupported = test)]
fn sweep_vertical_normals_are_signed_against_the_motion() {
let surface = aabb(0.0, 0.0, 5.0, 0.5);
let above = aabb(0.0, 5.0, 0.5, 0.5);
let below = aabb(0.0, -5.0, 0.5, 0.5);
let landing = sweep_aabb(&above, Vec2::new(0.0, -10.0), &surface).expect("hit");
let bump = sweep_aabb(&below, Vec2::new(0.0, 10.0), &surface).expect("hit");
assert_eq!(landing.normal, Vec2::new(0.0, 1.0));
assert_eq!(bump.normal, Vec2::new(0.0, -1.0));
}
#[wasm_bindgen_test(unsupported = test)]
fn sweep_miss_and_end_of_motion_boundaries() {
let body = aabb(-5.0, 0.0, 0.5, 0.5);
let wall = aabb(0.0, 0.0, 0.5, 0.5);
let beside = aabb(-5.0, 5.0, 0.5, 0.5);
assert!(sweep_aabb(&beside, Vec2::new(10.0, 0.0), &wall).is_none());
assert!(sweep_aabb(&body, Vec2::new(1.0, 0.0), &wall).is_none());
let hit = sweep_aabb(&body, Vec2::new(4.0, 0.0), &wall).expect("hit at t=1");
assert!((hit.t - 1.0).abs() < 1e-6, "t was {}", hit.t);
}
#[wasm_bindgen_test(unsupported = test)]
fn sweep_corner_tie_resolves_to_the_horizontal_face() {
let body = aabb(-2.0, -2.0, 0.5, 0.5);
let block = aabb(0.0, 0.0, 0.5, 0.5);
let hit = sweep_aabb(&body, Vec2::new(4.0, 4.0), &block).expect("hit");
assert!((hit.t - 0.25).abs() < 1e-6, "t was {}", hit.t);
assert_eq!(hit.normal, Vec2::new(0.0, -1.0));
}
#[wasm_bindgen_test(unsupported = test)]
fn sweep_embedded_body_reports_no_hit_in_any_direction() {
let block = aabb(0.0, 0.0, 1.0, 1.0);
let body = aabb(0.2, 0.0, 0.5, 0.5);
for delta in [
Vec2::new(3.0, 0.0),
Vec2::new(-3.0, 0.0),
Vec2::new(0.0, 3.0),
Vec2::new(0.0, -3.0),
] {
assert!(
sweep_aabb(&body, delta, &block).is_none(),
"embedded body must be free to move by {delta}"
);
}
}
#[wasm_bindgen_test(unsupported = test)]
fn sweep_zero_and_non_finite_delta_report_no_hit() {
let body = aabb(-5.0, 0.0, 0.5, 0.5);
let wall = aabb(0.0, 0.0, 0.5, 0.5);
assert!(sweep_aabb(&body, Vec2::ZERO, &wall).is_none());
assert!(sweep_aabb(&body, Vec2::new(f32::NAN, 0.0), &wall).is_none());
assert!(sweep_aabb(&body, Vec2::new(f32::INFINITY, 0.0), &wall).is_none());
assert!(sweep_aabb(&body, Vec2::new(0.0, f32::NEG_INFINITY), &wall).is_none());
}
#[wasm_bindgen_test(unsupported = test)]
fn sweep_sub_epsilon_delta_cannot_reach_a_distant_target() {
let body = aabb(-5.0, 0.0, 0.5, 0.5);
let wall = aabb(0.0, 0.0, 0.5, 0.5);
assert!(sweep_aabb(&body, Vec2::new(1e-9, 0.0), &wall).is_none());
assert!(sweep_aabb(&body, Vec2::new(1e-3, 0.0), &wall).is_none());
}
#[wasm_bindgen_test(unsupported = test)]
fn sweep_zero_extent_body_behaves_like_a_ray() {
let point = aabb(-4.0, 0.0, 0.0, 0.0);
let block = aabb(0.0, 0.0, 2.0, 2.0);
let hit = sweep_aabb(&point, Vec2::new(8.0, 0.0), &block).expect("hit");
assert!((hit.t - 0.25).abs() < 1e-6, "t was {}", hit.t);
assert_eq!(hit.normal, Vec2::new(-1.0, 0.0));
}
}