1use crate::math::Vec2;
4
5#[derive(Debug, Clone, Copy, PartialEq)]
7pub struct Aabb {
8 pub center: Vec2,
10 pub half: Vec2,
12}
13
14impl Aabb {
15 #[must_use]
17 pub const fn new(center: Vec2, half: Vec2) -> Self {
18 Self { center, half }
19 }
20
21 #[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 #[must_use]
32 pub fn min(&self) -> Vec2 {
33 self.center - self.half
34 }
35
36 #[must_use]
38 pub fn max(&self) -> Vec2 {
39 self.center + self.half
40 }
41
42 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq)]
64pub struct Hit {
65 pub t: f32,
69 pub normal: Vec2,
72}
73
74#[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 if entry > exit || !(0.0..=1.0).contains(&entry) {
118 return None;
119 }
120
121 let normal = if entry_x > entry_y {
122 Vec2::new(if delta.x > 0.0 { -1.0 } else { 1.0 }, 0.0)
124 } else {
125 Vec2::new(0.0, if delta.y > 0.0 { -1.0 } else { 1.0 })
127 };
128
129 Some(Hit { t: entry, normal })
130}
131
132fn 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 #[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 #[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 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 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 #[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 #[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 #[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 #[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 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 #[wasm_bindgen_test(unsupported = test)]
270 fn sweep_corner_tie_resolves_to_the_horizontal_face() {
271 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 #[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 #[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 #[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 #[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 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}