1use super::aabb::{Aabb, Hit, sweep_aabb};
4use super::collider_set::ColliderSet;
5use crate::math::Vec2;
6
7const MAX_SLIDE_PASSES: u32 = 2;
11
12#[derive(Debug, Clone, Copy, PartialEq)]
35pub struct CollisionConfig {
36 up: Vec2,
37 skin: f32,
38 snap_distance: f32,
39 normal_threshold: f32,
40 block_epsilon: f32,
41}
42
43impl CollisionConfig {
44 #[must_use]
55 pub fn new(
56 up: Vec2,
57 skin: f32,
58 snap_distance: f32,
59 normal_threshold: f32,
60 block_epsilon: f32,
61 ) -> Self {
62 let up = up
63 .try_normalize()
64 .expect("CollisionConfig: up must be a finite non-zero direction");
65 assert!(
66 normal_threshold > 0.0 && normal_threshold < 1.0,
67 "CollisionConfig: normal threshold must be in (0, 1), got {normal_threshold}"
68 );
69 for (name, value) in [
70 ("skin", skin),
71 ("snap distance", snap_distance),
72 ("block epsilon", block_epsilon),
73 ] {
74 assert!(
75 value.is_finite() && value >= 0.0,
76 "CollisionConfig: {name} must be finite and non-negative, got {value}"
77 );
78 }
79 Self {
80 up,
81 skin,
82 snap_distance,
83 normal_threshold,
84 block_epsilon,
85 }
86 }
87
88 #[must_use]
92 pub fn up(&self) -> Vec2 {
93 self.up
94 }
95
96 #[must_use]
100 pub fn skin(&self) -> f32 {
101 self.skin
102 }
103
104 #[must_use]
107 pub fn snap_distance(&self) -> f32 {
108 self.snap_distance
109 }
110
111 #[must_use]
114 pub fn normal_threshold(&self) -> f32 {
115 self.normal_threshold
116 }
117
118 #[must_use]
122 pub fn block_epsilon(&self) -> f32 {
123 self.block_epsilon
124 }
125
126 #[must_use]
128 pub fn is_floor(&self, normal: Vec2) -> bool {
129 normal.dot(self.up) > self.normal_threshold
130 }
131
132 #[must_use]
134 pub fn is_ceiling(&self, normal: Vec2) -> bool {
135 normal.dot(self.up) < -self.normal_threshold
136 }
137}
138
139#[allow(clippy::struct_excessive_bools)]
146#[derive(Debug, Clone, Copy, Default, PartialEq)]
147pub struct Contacts {
148 pub floor: bool,
150 pub ceiling: bool,
152 pub wall: bool,
154 pub last_floor_normal: Vec2,
156 pub last_ceiling_normal: Vec2,
158 pub desired: Vec2,
160 pub effective: Vec2,
162 pub down_blocked: bool,
165 pub up_blocked: bool,
167}
168
169#[must_use]
198pub fn move_and_collide(
199 body: Aabb,
200 delta: Vec2,
201 set: &ColliderSet,
202 config: &CollisionConfig,
203) -> (Vec2, Contacts) {
204 let mut contacts = Contacts {
205 desired: delta,
206 ..Contacts::default()
207 };
208 if !delta.is_finite() {
209 return (Vec2::ZERO, contacts);
210 }
211
212 let mut body = body;
213 let mut remaining = delta;
214 let mut total = Vec2::ZERO;
215
216 for _ in 0..MAX_SLIDE_PASSES {
217 if remaining == Vec2::ZERO {
218 break;
219 }
220
221 let Some(hit) = earliest_hit(&body, remaining, set, config, |_| true) else {
222 total += remaining;
224 break;
225 };
226
227 let move_to = remaining * hit.t;
228 let safe = back_off(remaining, hit, config.skin);
229 total += safe;
230 body = body.translated(safe);
231 classify(&mut contacts, hit.normal, config);
232
233 let leftover = remaining - move_to;
235 remaining = leftover - hit.normal * leftover.dot(hit.normal);
236 }
237
238 contacts.effective = total;
239
240 let desired_up = delta.dot(config.up);
243 let effective_up = total.dot(config.up);
244 contacts.down_blocked = desired_up < 0.0 && effective_up > desired_up + config.block_epsilon;
245 contacts.up_blocked = desired_up > 0.0 && effective_up < desired_up - config.block_epsilon;
246
247 (total, contacts)
248}
249
250#[must_use]
258pub fn snap_to_ground(body: Aabb, set: &ColliderSet, config: &CollisionConfig) -> (Vec2, Contacts) {
259 let probe = -config.up * config.snap_distance;
260 let mut contacts = Contacts {
261 desired: probe,
262 ..Contacts::default()
263 };
264
265 let Some(hit) = earliest_hit(&body, probe, set, config, |normal| config.is_floor(normal))
266 else {
267 return (Vec2::ZERO, contacts);
268 };
269
270 let safe = back_off(probe, hit, config.skin);
271 contacts.effective = safe;
272 classify(&mut contacts, hit.normal, config);
273 (safe, contacts)
274}
275
276fn earliest_hit(
279 body: &Aabb,
280 delta: Vec2,
281 set: &ColliderSet,
282 config: &CollisionConfig,
283 accept: impl Fn(Vec2) -> bool,
284) -> Option<Hit> {
285 let mut best: Option<Hit> = None;
286 let (min, max) = swept_region(body, delta);
287 for index in set.query_corners(min, max) {
288 let Some(collider) = set.get(index) else {
289 continue;
290 };
291 if collider.flags.sensor || !collider.flags.solid {
292 continue;
293 }
294 let hit = if body.overlaps(&collider.aabb) {
295 if collider.flags.one_way {
296 one_way_landing(body, delta, &collider.aabb, config)
297 } else {
298 embedded_hit(body, delta, &collider.aabb)
299 }
300 } else {
301 sweep_aabb(body, delta, &collider.aabb)
302 };
303 let Some(hit) = hit else {
304 continue;
305 };
306 if !accept(hit.normal) {
307 continue;
308 }
309 if collider.flags.one_way && !one_way_blocks(hit.normal, delta, config) {
310 continue;
311 }
312 if best.is_none_or(|previous| hit.t < previous.t) {
313 best = Some(hit);
314 }
315 }
316 best
317}
318
319fn embedded_hit(body: &Aabb, delta: Vec2, target: &Aabb) -> Option<Hit> {
326 let offset = body.center - target.center;
327 let depth = body.half + target.half - offset.abs();
328 let (along, motion, normal) = if depth.x < depth.y {
329 (offset.x, delta.x, Vec2::new(offset.x.signum(), 0.0))
330 } else {
331 (offset.y, delta.y, Vec2::new(0.0, offset.y.signum()))
332 };
333 let inward = (along < 0.0 && motion > 0.0) || (along > 0.0 && motion < 0.0);
336 inward.then_some(Hit { t: 0.0, normal })
337}
338
339fn one_way_landing(
345 body: &Aabb,
346 delta: Vec2,
347 target: &Aabb,
348 config: &CollisionConfig,
349) -> Option<Hit> {
350 let offset = body.center - target.center;
351 let depth = body.half + target.half - offset.abs();
352 let face = (target.center.abs() + target.half).max_element();
353 let margin = config.skin + 4.0 * f32::EPSILON * face;
354 [
355 (depth.x, Vec2::new(offset.x.signum(), 0.0)),
356 (depth.y, Vec2::new(0.0, offset.y.signum())),
357 ]
358 .into_iter()
359 .find(|&(depth, normal)| depth <= margin && one_way_blocks(normal, delta, config))
360 .map(|(_, normal)| Hit { t: 0.0, normal })
361}
362
363fn one_way_blocks(normal: Vec2, motion: Vec2, config: &CollisionConfig) -> bool {
367 config.is_floor(normal) && motion.dot(normal) < 0.0
368}
369
370fn classify(contacts: &mut Contacts, normal: Vec2, config: &CollisionConfig) {
372 if config.is_floor(normal) {
373 contacts.floor = true;
374 contacts.last_floor_normal = normal;
375 } else if config.is_ceiling(normal) {
376 contacts.ceiling = true;
377 contacts.last_ceiling_normal = normal;
378 } else {
379 contacts.wall = true;
380 }
381}
382
383fn back_off(delta: Vec2, hit: Hit, skin: f32) -> Vec2 {
387 let normal_motion = delta.dot(hit.normal).abs();
390 let safe_t = (hit.t - skin / normal_motion).max(0.0);
391 delta * safe_t
392}
393
394fn swept_region(body: &Aabb, delta: Vec2) -> (Vec2, Vec2) {
396 let start_min = body.min();
397 let start_max = body.max();
398 (
401 start_min.min(start_min + delta),
402 start_max.max(start_max + delta),
403 )
404}
405
406#[cfg(test)]
407mod tests {
408 use super::super::collider_set::ColliderFlags;
409 use super::*;
410 use wasm_bindgen_test::wasm_bindgen_test;
411
412 #[wasm_bindgen_test(unsupported = test)]
415 fn tiny_embedded_motion_does_not_deepen_the_overlap() {
416 for sign in [-1.0, 1.0] {
417 let body = Aabb::new(Vec2::ZERO, Vec2::splat(1e-24));
418 let target = Aabb::new(Vec2::new(sign * 1e-24, 0.0), Vec2::splat(1e-24));
419 assert!(body.overlaps(&target));
420 let mut set = ColliderSet::new(1e-24);
421 set.insert(target, ColliderFlags::SOLID);
422 let config = CollisionConfig::new(Vec2::Y, 0.0, 0.0, 0.7, 0.0);
423 let (moved, contacts) =
424 move_and_collide(body, Vec2::new(sign * 5e-25, 0.0), &set, &config);
425 assert_eq!(moved, Vec2::ZERO, "inward motion deepened the overlap");
426 assert!(contacts.wall);
427 }
428 }
429
430 #[wasm_bindgen_test(unsupported = test)]
433 fn tiny_unobstructed_motion_is_not_reported_blocked() {
434 let body = Aabb::new(Vec2::ZERO, Vec2::splat(0.5));
435 let set = ColliderSet::new(1.0);
436 let config = CollisionConfig::new(Vec2::Y, 0.0, 0.1, 0.7, 0.0);
437 for delta in [Vec2::new(0.0, -1e-8), Vec2::new(5e-8, 1e-8)] {
438 let (moved, contacts) = move_and_collide(body, delta, &set, &config);
439 assert_eq!(moved, delta, "empty space must allow the entire motion");
440 assert!(!contacts.down_blocked);
441 assert!(!contacts.up_blocked);
442 }
443 }
444
445 #[wasm_bindgen_test(unsupported = test)]
448 fn tiny_motion_still_contacts_a_nearby_surface() {
449 let body = Aabb::new(Vec2::ZERO, Vec2::splat(1e-9));
450 let floor = Aabb::new(Vec2::new(0.0, -5e-9), Vec2::splat(1e-9));
451 let mut set = ColliderSet::new(1e-8);
452 set.insert(floor, ColliderFlags::SOLID);
453 let config = CollisionConfig::new(Vec2::Y, 0.0, 0.0, 0.7, 0.0);
454 let (moved, contacts) = move_and_collide(body, Vec2::new(0.0, -1e-8), &set, &config);
455 assert!(contacts.floor);
456 assert!(contacts.down_blocked);
457 assert!((moved.y + 3e-9).abs() < 1e-15, "moved {moved:?}");
458 assert!(!body.translated(moved).overlaps(&floor));
459 }
460
461 #[wasm_bindgen_test(unsupported = test)]
464 fn end_contact_on_a_cell_boundary_keeps_the_skin_gap() {
465 for sign in [-1.0, 1.0] {
466 let body = Aabb::new(Vec2::new(0.0, sign * 76.701_805), Vec2::splat(0.5));
467 let floor = Aabb::new(Vec2::new(0.0, sign * 56.5), Vec2::new(50.0, 0.5));
468 let delta = Vec2::new(0.0, -sign * 19.201_805);
469 let config = CollisionConfig::new(Vec2::new(0.0, sign), 0.01, 0.1, 0.7, 0.0);
470 let mut set = ColliderSet::new(1.0);
471 set.insert(floor, ColliderFlags::SOLID);
472
473 let (moved, contacts) = move_and_collide(body, delta, &set, &config);
474 assert!(contacts.floor, "missed end contact with up={sign}");
475 let gap = (body.translated(moved).center.y - floor.center.y).abs() - 1.0;
476 assert!((gap - 0.01).abs() < 1e-5, "skin gap was {gap}");
477 }
478 }
479
480 #[wasm_bindgen_test(unsupported = test)]
484 fn contacts_record_the_request_and_classification_is_strict() {
485 let config = CollisionConfig::new(Vec2::Y, 0.01, 0.1, 0.7, 1e-4);
486 let set = ColliderSet::new(1.0);
487 let body = Aabb::new(Vec2::ZERO, Vec2::splat(0.5));
488 let (_, contacts) = move_and_collide(body, Vec2::new(1.5, -2.0), &set, &config);
489 assert_eq!(contacts.desired, Vec2::new(1.5, -2.0));
490 let (_, contacts) = snap_to_ground(body, &set, &config);
491 assert_eq!(contacts.desired, Vec2::new(0.0, -0.1));
492
493 assert!(
494 !config.is_floor(Vec2::new(0.3, 0.7)),
495 "exactly at the threshold"
496 );
497 assert!(config.is_floor(Vec2::new(0.0, 0.7001)));
498 assert!(
499 !config.is_ceiling(Vec2::new(0.3, -0.7)),
500 "exactly at the threshold"
501 );
502 assert!(config.is_ceiling(Vec2::new(0.0, -0.7001)));
503 }
504
505 #[wasm_bindgen_test(unsupported = test)]
509 fn blocked_flags_need_a_shortfall_beyond_the_epsilon() {
510 let config = CollisionConfig::new(Vec2::Y, 0.0, 0.1, 0.7, 0.0);
511 let mut set = ColliderSet::new(1.0);
512 set.insert(
514 Aabb::new(Vec2::new(0.0, -3.0), Vec2::splat(1.0)),
515 ColliderFlags::SOLID,
516 );
517 set.insert(
518 Aabb::new(Vec2::new(0.0, 3.0), Vec2::splat(1.0)),
519 ColliderFlags::SOLID,
520 );
521 let body = Aabb::new(Vec2::ZERO, Vec2::splat(1.0));
522 let (moved, contacts) = move_and_collide(body, Vec2::new(0.0, -1.0), &set, &config);
523 assert_eq!(moved, Vec2::new(0.0, -1.0));
524 assert!(contacts.floor);
525 assert!(!contacts.down_blocked, "landed exactly where asked");
526 let (_, contacts) = move_and_collide(body, Vec2::new(0.0, -5.0), &set, &config);
527 assert!(contacts.down_blocked);
528 let (moved, contacts) = move_and_collide(body, Vec2::new(0.0, 1.0), &set, &config);
529 assert_eq!(moved, Vec2::new(0.0, 1.0));
530 assert!(contacts.ceiling);
531 assert!(!contacts.up_blocked, "rose exactly as far as asked");
532 let (_, contacts) = move_and_collide(body, Vec2::new(0.0, 5.0), &set, &config);
533 assert!(contacts.up_blocked);
534 }
535
536 const SKIN: f32 = 0.01;
537 const SNAP: f32 = 2.0 / 32.0;
539
540 fn aabb(cx: f32, cy: f32, hx: f32, hy: f32) -> Aabb {
541 Aabb::new(Vec2::new(cx, cy), Vec2::new(hx, hy))
542 }
543
544 fn config(up: Vec2) -> CollisionConfig {
545 CollisionConfig::new(up, SKIN, SNAP, 0.7, 0.0001)
546 }
547
548 fn set_with(colliders: &[(Aabb, ColliderFlags)]) -> ColliderSet {
549 let mut set = ColliderSet::new(1.0);
550 for &(aabb, flags) in colliders {
551 set.insert(aabb, flags);
552 }
553 set
554 }
555
556 #[wasm_bindgen_test(unsupported = test)]
560 fn landing_rests_one_skin_above_the_floor() {
561 let set = set_with(&[(aabb(0.0, 0.0, 5.0, 0.5), ColliderFlags::SOLID)]);
562 let body = aabb(0.0, 2.0, 0.5, 0.5);
563 let (effective, contacts) =
564 move_and_collide(body, Vec2::new(0.0, -3.0), &set, &config(Vec2::Y));
565
566 let rest = 2.0 + effective.y;
568 assert!((rest - (1.0 + SKIN)).abs() < 1e-4, "rested at {rest}");
569 assert!(contacts.floor && contacts.down_blocked);
570 assert!(!contacts.ceiling && !contacts.wall);
571 assert_eq!(contacts.last_floor_normal, Vec2::Y);
572 }
573
574 #[wasm_bindgen_test(unsupported = test)]
578 fn head_bump_stops_below_the_ceiling() {
579 let set = set_with(&[(aabb(0.0, 0.0, 5.0, 0.5), ColliderFlags::SOLID)]);
580 let body = aabb(0.0, -2.0, 0.5, 0.5);
581 let (effective, contacts) =
582 move_and_collide(body, Vec2::new(0.0, 3.0), &set, &config(Vec2::Y));
583
584 let rest = -2.0 + effective.y;
585 assert!((rest - (-1.0 - SKIN)).abs() < 1e-4, "rested at {rest}");
586 assert!(contacts.ceiling && contacts.up_blocked);
587 assert!(!contacts.floor);
588 assert_eq!(contacts.last_ceiling_normal, Vec2::NEG_Y);
589 }
590
591 #[wasm_bindgen_test(unsupported = test)]
595 fn wall_blocks_one_axis_and_the_slide_keeps_the_other() {
596 let set = set_with(&[(aabb(2.0, 0.0, 0.5, 5.0), ColliderFlags::SOLID)]);
597 let body = aabb(0.0, 0.0, 0.5, 0.5);
598 let (effective, contacts) =
599 move_and_collide(body, Vec2::new(3.0, 1.0), &set, &config(Vec2::Y));
600
601 assert!((effective.x - 0.99).abs() < 1e-4, "x was {}", effective.x);
603 assert!(
605 (effective.y - 299.0 / 300.0).abs() < 1e-4,
606 "y was {}",
607 effective.y
608 );
609 assert!(contacts.wall);
610 assert!(!contacts.floor && !contacts.ceiling);
611 }
612
613 #[wasm_bindgen_test(unsupported = test)]
617 fn diagonal_skin_back_off_does_not_enter_a_ledge_the_sweep_cleared() {
618 let body = aabb(0.0, 0.0, 0.5, 0.5);
619 let wall = Aabb::from_min_max(Vec2::new(1.0, -2.0), Vec2::new(2.0, 2.0));
620 let ledge = Aabb::from_min_max(Vec2::new(-2.0, -2.0), Vec2::new(-0.046_875, -0.96875));
621 let set = set_with(&[(wall, ColliderFlags::SOLID), (ledge, ColliderFlags::SOLID)]);
622 let config = CollisionConfig::new(Vec2::Y, 0.125, 0.0, 0.7, 1e-4);
623
624 let (effective, contacts) = move_and_collide(body, Vec2::new(1.0, -1.0), &set, &config);
625 let moved = body.translated(effective);
626 assert!(!moved.overlaps(&ledge), "ended inside ledge: {moved:?}");
627 assert!(!moved.overlaps(&wall), "ended inside wall: {moved:?}");
628 assert_eq!(effective, Vec2::new(0.375, -0.375));
632 assert!(contacts.wall && contacts.floor);
633 }
634
635 #[wasm_bindgen_test(unsupported = test)]
639 fn diagonal_ground_snap_stays_clear_of_a_ledge_beside_its_probe() {
640 let body = aabb(0.0, 0.0, 0.5, 0.5);
641 let surface = Aabb::from_min_max(Vec2::new(1.0, -2.0), Vec2::new(2.0, 2.0));
642 let ledge = Aabb::from_min_max(Vec2::new(-2.0, -2.0), Vec2::new(-0.046_875, -0.96875));
643 let set = set_with(&[
644 (surface, ColliderFlags::SOLID),
645 (ledge, ColliderFlags::SOLID),
646 ]);
647 let config = CollisionConfig::new(Vec2::new(-1.0, 1.0), 0.125, 2.0f32.sqrt(), 0.7, 1e-4);
648
649 let (snap, contacts) = snap_to_ground(body, &set, &config);
650 let moved = body.translated(snap);
651 assert!(!moved.overlaps(&ledge), "snapped inside ledge: {moved:?}");
652 assert!(snap.abs_diff_eq(Vec2::new(0.375, -0.375), f32::EPSILON));
653 assert!(contacts.floor);
654 }
655
656 #[wasm_bindgen_test(unsupported = test)]
659 fn landing_while_running_keeps_the_horizontal_travel_after_contact() {
660 let set = set_with(&[(aabb(0.0, 0.0, 10.0, 0.5), ColliderFlags::SOLID)]);
661 let body = aabb(0.0, 2.0, 0.5, 0.5);
662 let (effective, contacts) =
663 move_and_collide(body, Vec2::new(2.0, -3.0), &set, &config(Vec2::Y));
664
665 assert!(contacts.floor);
666 assert!(
668 (effective.x - 299.0 / 150.0).abs() < 1e-4,
669 "x was {}",
670 effective.x
671 );
672 }
673
674 #[wasm_bindgen_test(unsupported = test)]
678 fn the_earliest_contact_wins_over_query_order() {
679 let set = set_with(&[
680 (aabb(0.0, -4.0, 5.0, 0.5), ColliderFlags::SOLID),
681 (aabb(0.0, 0.0, 5.0, 0.5), ColliderFlags::SOLID),
682 ]);
683 let body = aabb(0.0, 2.0, 0.5, 0.5);
684 let (effective, contacts) =
685 move_and_collide(body, Vec2::new(0.0, -6.0), &set, &config(Vec2::Y));
686
687 let rest = 2.0 + effective.y;
688 assert!((rest - (1.0 + SKIN)).abs() < 1e-4, "rested at {rest}");
689 assert!(contacts.floor);
690 }
691
692 #[wasm_bindgen_test(unsupported = test)]
697 fn contact_exactly_at_the_end_of_the_delta_is_reported() {
698 let set = set_with(&[(aabb(0.0, 0.0, 5.0, 0.5), ColliderFlags::SOLID)]);
699 let body = aabb(0.0, 2.0, 0.5, 0.5);
701 let (effective, contacts) =
702 move_and_collide(body, Vec2::new(0.0, -1.0), &set, &config(Vec2::Y));
703
704 assert!(contacts.floor && contacts.down_blocked);
705 assert!(
706 (effective.y - (-1.0 + SKIN)).abs() < 1e-4,
707 "y was {}",
708 effective.y
709 );
710 }
711
712 #[wasm_bindgen_test(unsupported = test)]
716 fn the_skin_back_off_never_reverses_the_move() {
717 let set = set_with(&[(aabb(0.0, 0.0, 0.5, 0.5), ColliderFlags::SOLID)]);
718 let body = aabb(-1.003, 0.0, 0.5, 0.5);
719 let (effective, contacts) =
720 move_and_collide(body, Vec2::new(0.006, 0.0), &set, &config(Vec2::Y));
721
722 assert!(contacts.wall);
723 assert!(effective.x >= 0.0, "moved backwards: {}", effective.x);
724 assert!(effective.x <= 0.003, "moved past contact: {}", effective.x);
725 }
726
727 #[wasm_bindgen_test(unsupported = test)]
730 fn sensors_and_non_solid_colliders_do_not_block() {
731 let set = set_with(&[
732 (aabb(0.0, 0.0, 5.0, 0.5), ColliderFlags::SENSOR),
733 (
734 aabb(0.0, -1.5, 5.0, 0.5),
735 ColliderFlags {
736 solid: false,
737 one_way: false,
738 sensor: false,
739 },
740 ),
741 ]);
742 let body = aabb(0.0, 2.0, 0.5, 0.5);
743 let (effective, contacts) =
744 move_and_collide(body, Vec2::new(0.0, -3.0), &set, &config(Vec2::Y));
745
746 assert!((effective.y + 3.0).abs() < 1e-6, "y was {}", effective.y);
747 assert!(!contacts.floor && !contacts.wall && !contacts.down_blocked);
748 }
749
750 #[wasm_bindgen_test(unsupported = test)]
753 fn one_way_blocks_only_a_landing_from_above() {
754 let set = set_with(&[(aabb(0.0, 0.0, 5.0, 0.25), ColliderFlags::ONE_WAY)]);
755
756 let rising = aabb(0.0, -2.0, 0.5, 0.5);
757 let (effective, contacts) =
758 move_and_collide(rising, Vec2::new(0.0, 4.0), &set, &config(Vec2::Y));
759 assert!(!contacts.ceiling && !contacts.up_blocked);
760 assert!((effective.y - 4.0).abs() < 1e-6, "y was {}", effective.y);
761
762 let falling = aabb(0.0, 2.0, 0.5, 0.5);
763 let (_, contacts) = move_and_collide(falling, Vec2::new(0.0, -3.0), &set, &config(Vec2::Y));
764 assert!(contacts.floor && contacts.down_blocked);
765 }
766
767 #[wasm_bindgen_test(unsupported = test)]
772 fn one_way_platforms_with_a_tilted_up_block_motion_into_the_top_face() {
773 let platform = aabb(0.0, 0.0, 5.0, 0.25);
774 let set = set_with(&[(platform, ColliderFlags::ONE_WAY)]);
775 let config = config(Vec2::new(0.2, 1.0));
776
777 let above = aabb(0.0, 0.8, 0.5, 0.5);
778 let (effective, contacts) = move_and_collide(above, Vec2::new(1.0, -0.1), &set, &config);
779 assert!(
780 !above.translated(effective).overlaps(&platform),
781 "fell into the platform: {effective:?}"
782 );
783 assert!(contacts.floor);
784
785 let resting = aabb(0.0, 0.745, 0.5, 0.5);
787 let (effective, _) = move_and_collide(resting, Vec2::new(-1.0, 0.0), &set, &config);
788 assert_eq!(effective, Vec2::new(-1.0, 0.0));
789 }
790
791 #[wasm_bindgen_test(unsupported = test)]
795 fn non_finite_delta_moves_nothing() {
796 let set = set_with(&[(aabb(0.0, 0.0, 5.0, 0.5), ColliderFlags::SOLID)]);
797 let body = aabb(0.0, 2.0, 0.5, 0.5);
798 for delta in [Vec2::new(f32::NAN, -1.0), Vec2::new(0.0, f32::NEG_INFINITY)] {
799 let (effective, contacts) = move_and_collide(body, delta, &set, &config(Vec2::Y));
800 assert_eq!(effective, Vec2::ZERO);
801 assert!(!contacts.floor && !contacts.down_blocked);
802 }
803 }
804
805 #[wasm_bindgen_test(unsupported = test)]
808 fn snap_reaches_exactly_as_far_as_it_is_configured_to() {
809 let set = set_with(&[(aabb(0.0, 0.0, 5.0, 0.5), ColliderFlags::SOLID)]);
810 let body = aabb(0.0, 1.0 + 1.0 / 32.0, 0.5, 0.5);
811
812 let (snap, contacts) = snap_to_ground(body, &set, &config(Vec2::Y));
813 assert!(contacts.floor, "a 2 px probe must bridge a 1 px gap");
814 assert!(snap.y < 0.0, "snap pulls the body toward the floor");
815 assert_eq!(contacts.last_floor_normal, Vec2::Y);
816
817 let short = CollisionConfig::new(Vec2::Y, SKIN, 0.5 / 32.0, 0.7, 0.0001);
818 let (snap, contacts) = snap_to_ground(body, &set, &short);
819 assert!(!contacts.floor, "a half-pixel probe must not reach");
820 assert_eq!(snap, Vec2::ZERO);
821 }
822
823 #[wasm_bindgen_test(unsupported = test)]
829 fn snap_grounds_on_a_one_way_top_but_not_from_under_it() {
830 let set = set_with(&[(aabb(0.0, 0.0, 5.0, 0.25), ColliderFlags::ONE_WAY)]);
831
832 let above = aabb(0.0, 0.75 + 1.0 / 32.0, 0.5, 0.5);
834 let (snap, contacts) = snap_to_ground(above, &set, &config(Vec2::Y));
835 assert!(contacts.floor, "a one-way top still grounds a landing");
836 assert!(snap.y < 0.0);
837
838 let below = aabb(0.0, -1.0, 0.5, 0.5);
840 let (snap, contacts) = snap_to_ground(below, &set, &config(Vec2::Y));
841 assert!(!contacts.floor);
842 assert_eq!(snap, Vec2::ZERO);
843 }
844
845 #[wasm_bindgen_test(unsupported = test)]
849 fn y_down_worlds_classify_floors_and_ceilings_the_same() {
850 let config = config(Vec2::NEG_Y);
851 let set = set_with(&[(aabb(0.0, 0.0, 5.0, 0.5), ColliderFlags::SOLID)]);
852
853 let above = aabb(0.0, -2.0, 0.5, 0.5);
855 let (effective, contacts) = move_and_collide(above, Vec2::new(0.0, 3.0), &set, &config);
856 let rest = -2.0 + effective.y;
857 assert!((rest - (-1.0 - SKIN)).abs() < 1e-4, "rested at {rest}");
858 assert!(contacts.floor && contacts.down_blocked);
859 assert!(!contacts.ceiling && !contacts.up_blocked);
860 assert_eq!(contacts.last_floor_normal, Vec2::NEG_Y);
861
862 let below = aabb(0.0, 2.0, 0.5, 0.5);
863 let (_, contacts) = move_and_collide(below, Vec2::new(0.0, -3.0), &set, &config);
864 assert!(contacts.ceiling && contacts.up_blocked);
865 assert!(!contacts.floor && !contacts.down_blocked);
866 assert_eq!(contacts.last_ceiling_normal, Vec2::Y);
867 }
868
869 #[wasm_bindgen_test(unsupported = test)]
873 fn y_down_worlds_mirror_one_way_and_snapping() {
874 let config = config(Vec2::NEG_Y);
875 let set = set_with(&[(aabb(0.0, 0.0, 5.0, 0.25), ColliderFlags::ONE_WAY)]);
876
877 let rising = aabb(0.0, 2.0, 0.5, 0.5);
878 let (effective, contacts) = move_and_collide(rising, Vec2::new(0.0, -4.0), &set, &config);
879 assert!(!contacts.ceiling);
880 assert!((effective.y + 4.0).abs() < 1e-6, "y was {}", effective.y);
881
882 let falling = aabb(0.0, -2.0, 0.5, 0.5);
883 let (_, contacts) = move_and_collide(falling, Vec2::new(0.0, 3.0), &set, &config);
884 assert!(contacts.floor && contacts.down_blocked);
885
886 let hovering = aabb(0.0, -0.75 - 1.0 / 32.0, 0.5, 0.5);
889 let (snap, contacts) = snap_to_ground(hovering, &set, &config);
890 assert!(contacts.floor, "the probe must run along -up, toward +y");
891 assert!(snap.y > 0.0, "snap pulls the body toward the platform");
892 }
893
894 #[wasm_bindgen_test(unsupported = test)]
897 fn a_body_one_float_step_inside_a_wall_does_not_pass_through() {
898 let wall = aabb(2.5, 0.0, 0.5, 5.0);
899 let set = set_with(&[(wall, ColliderFlags::SOLID)]);
900 let body = aabb(2.0f32.next_up() - 0.5, 0.0, 0.5, 0.5);
902 assert!(body.overlaps(&wall));
903 let (effective, contacts) =
904 move_and_collide(body, Vec2::new(3.0, 1.0), &set, &config(Vec2::Y));
905 assert!(effective.x <= 0.0, "moved {} into the wall", effective.x);
906 assert!((effective.y - 1.0).abs() < 1e-6, "slid {}", effective.y);
907 assert!(contacts.wall);
908 }
909
910 #[wasm_bindgen_test(unsupported = test)]
913 fn a_body_exactly_touching_a_floor_at_rounding_coordinates_stays_on_it() {
914 let floor = aabb(1.090_340_4, 4.337_375_6, 50.0, 0.125);
915 let set = set_with(&[(floor, ColliderFlags::SOLID)]);
916 let body = aabb(1.090_340_4, 4.711_776_3, 0.4, 0.249_400_84);
917 assert_eq!(body.min().y.to_bits(), floor.max().y.to_bits());
918 let (effective, contacts) =
919 move_and_collide(body, Vec2::new(0.0, -3.0), &set, &config(Vec2::Y));
920 assert!(effective.y >= 0.0, "fell {}", effective.y);
921 assert!(contacts.floor && contacts.down_blocked);
922 let (_, contacts) = snap_to_ground(body, &set, &config(Vec2::Y));
923 assert!(contacts.floor, "snapping finds the floor it rests on");
924 }
925
926 #[wasm_bindgen_test(unsupported = test)]
928 fn an_embedded_body_can_move_out() {
929 let wall = aabb(2.5, 0.0, 0.5, 5.0);
930 let set = set_with(&[(wall, ColliderFlags::SOLID)]);
931 let body = aabb(1.75, 0.0, 0.5, 0.5);
932 let (effective, _) = move_and_collide(body, Vec2::new(-1.0, 0.0), &set, &config(Vec2::Y));
933 assert_eq!(effective, Vec2::new(-1.0, 0.0));
934 }
935
936 #[wasm_bindgen_test(unsupported = test)]
939 fn a_body_inside_a_one_way_platform_falls_through() {
940 let set = set_with(&[(aabb(0.0, 0.0, 5.0, 0.25), ColliderFlags::ONE_WAY)]);
941 let body = aabb(0.0, 0.5, 0.5, 0.5);
942 let (effective, contacts) =
943 move_and_collide(body, Vec2::new(0.0, -2.0), &set, &config(Vec2::Y));
944 assert_eq!(effective, Vec2::new(0.0, -2.0));
945 assert!(!contacts.floor);
946 }
947
948 #[wasm_bindgen_test(unsupported = test)]
951 fn a_body_one_float_step_into_a_one_way_platform_lands() {
952 let platform = aabb(0.0, 0.0, 5.0, 0.25);
953 let set = set_with(&[(platform, ColliderFlags::ONE_WAY)]);
954 let body = aabb(0.0, 0.75f32.next_down(), 0.5, 0.5);
955 assert!(body.overlaps(&platform));
956 let (effective, contacts) =
957 move_and_collide(body, Vec2::new(0.0, -2.0), &set, &config(Vec2::Y));
958 assert!(effective.y >= 0.0, "fell {}", effective.y);
959 assert!(contacts.floor && contacts.down_blocked);
960 }
961
962 #[cfg(not(target_arch = "wasm32"))]
964 #[test]
965 fn the_config_rejects_negative_and_non_finite_lengths() {
966 for bad in [-0.01, f32::NAN, f32::INFINITY] {
967 for (skin, snap, epsilon) in [(bad, SNAP, 1e-4), (SKIN, bad, 1e-4), (SKIN, SNAP, bad)] {
968 let built = std::panic::catch_unwind(|| {
969 CollisionConfig::new(Vec2::Y, skin, snap, 0.7, epsilon)
970 });
971 assert!(built.is_err(), "accepted {skin}, {snap}, {epsilon}");
972 }
973 }
974 }
975
976 #[wasm_bindgen_test(unsupported = test)]
979 #[should_panic(expected = "CollisionConfig: up must be a finite non-zero direction")]
980 fn the_config_rejects_an_up_direction_whose_length_overflows() {
981 let _ = CollisionConfig::new(Vec2::new(1e20, 0.0), SKIN, SNAP, 0.7, 1e-4);
982 }
983}