use super::aabb::{Aabb, Hit, sweep_aabb};
use super::collider_set::ColliderSet;
use crate::math::Vec2;
const MAX_SLIDE_PASSES: u32 = 2;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CollisionConfig {
up: Vec2,
skin: f32,
snap_distance: f32,
normal_threshold: f32,
block_epsilon: f32,
}
impl CollisionConfig {
#[must_use]
pub fn new(
up: Vec2,
skin: f32,
snap_distance: f32,
normal_threshold: f32,
block_epsilon: f32,
) -> Self {
let up = up
.try_normalize()
.expect("CollisionConfig: up must be a finite non-zero direction");
assert!(
normal_threshold > 0.0 && normal_threshold < 1.0,
"CollisionConfig: normal threshold must be in (0, 1), got {normal_threshold}"
);
for (name, value) in [
("skin", skin),
("snap distance", snap_distance),
("block epsilon", block_epsilon),
] {
assert!(
value.is_finite() && value >= 0.0,
"CollisionConfig: {name} must be finite and non-negative, got {value}"
);
}
Self {
up,
skin,
snap_distance,
normal_threshold,
block_epsilon,
}
}
#[must_use]
pub fn up(&self) -> Vec2 {
self.up
}
#[must_use]
pub fn skin(&self) -> f32 {
self.skin
}
#[must_use]
pub fn snap_distance(&self) -> f32 {
self.snap_distance
}
#[must_use]
pub fn normal_threshold(&self) -> f32 {
self.normal_threshold
}
#[must_use]
pub fn block_epsilon(&self) -> f32 {
self.block_epsilon
}
#[must_use]
pub fn is_floor(&self, normal: Vec2) -> bool {
normal.dot(self.up) > self.normal_threshold
}
#[must_use]
pub fn is_ceiling(&self, normal: Vec2) -> bool {
normal.dot(self.up) < -self.normal_threshold
}
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct Contacts {
pub floor: bool,
pub ceiling: bool,
pub wall: bool,
pub last_floor_normal: Vec2,
pub last_ceiling_normal: Vec2,
pub desired: Vec2,
pub effective: Vec2,
pub down_blocked: bool,
pub up_blocked: bool,
}
#[must_use]
pub fn move_and_collide(
body: Aabb,
delta: Vec2,
set: &ColliderSet,
config: &CollisionConfig,
) -> (Vec2, Contacts) {
let mut contacts = Contacts {
desired: delta,
..Contacts::default()
};
if !delta.is_finite() {
return (Vec2::ZERO, contacts);
}
let mut body = body;
let mut remaining = delta;
let mut total = Vec2::ZERO;
for _ in 0..MAX_SLIDE_PASSES {
if remaining == Vec2::ZERO {
break;
}
let Some(hit) = earliest_hit(&body, remaining, set, config, |_| true) else {
total += remaining;
break;
};
let move_to = remaining * hit.t;
let safe = back_off(remaining, hit, config.skin);
total += safe;
body = body.translated(safe);
classify(&mut contacts, hit.normal, config);
let leftover = remaining - move_to;
remaining = leftover - hit.normal * leftover.dot(hit.normal);
}
contacts.effective = total;
let desired_up = delta.dot(config.up);
let effective_up = total.dot(config.up);
contacts.down_blocked = desired_up < 0.0 && effective_up > desired_up + config.block_epsilon;
contacts.up_blocked = desired_up > 0.0 && effective_up < desired_up - config.block_epsilon;
(total, contacts)
}
#[must_use]
pub fn snap_to_ground(body: Aabb, set: &ColliderSet, config: &CollisionConfig) -> (Vec2, Contacts) {
let probe = -config.up * config.snap_distance;
let mut contacts = Contacts {
desired: probe,
..Contacts::default()
};
let Some(hit) = earliest_hit(&body, probe, set, config, |normal| config.is_floor(normal))
else {
return (Vec2::ZERO, contacts);
};
let safe = back_off(probe, hit, config.skin);
contacts.effective = safe;
classify(&mut contacts, hit.normal, config);
(safe, contacts)
}
fn earliest_hit(
body: &Aabb,
delta: Vec2,
set: &ColliderSet,
config: &CollisionConfig,
accept: impl Fn(Vec2) -> bool,
) -> Option<Hit> {
let mut best: Option<Hit> = None;
let (min, max) = swept_region(body, delta);
for index in set.query_corners(min, max) {
let Some(collider) = set.get(index) else {
continue;
};
if collider.flags.sensor || !collider.flags.solid {
continue;
}
let hit = if body.overlaps(&collider.aabb) {
if collider.flags.one_way {
one_way_landing(body, delta, &collider.aabb, config)
} else {
embedded_hit(body, delta, &collider.aabb)
}
} else {
sweep_aabb(body, delta, &collider.aabb)
};
let Some(hit) = hit else {
continue;
};
if !accept(hit.normal) {
continue;
}
if collider.flags.one_way && !one_way_blocks(hit.normal, delta, config) {
continue;
}
if best.is_none_or(|previous| hit.t < previous.t) {
best = Some(hit);
}
}
best
}
fn embedded_hit(body: &Aabb, delta: Vec2, target: &Aabb) -> Option<Hit> {
let offset = body.center - target.center;
let depth = body.half + target.half - offset.abs();
let (along, motion, normal) = if depth.x < depth.y {
(offset.x, delta.x, Vec2::new(offset.x.signum(), 0.0))
} else {
(offset.y, delta.y, Vec2::new(0.0, offset.y.signum()))
};
let inward = (along < 0.0 && motion > 0.0) || (along > 0.0 && motion < 0.0);
inward.then_some(Hit { t: 0.0, normal })
}
fn one_way_landing(
body: &Aabb,
delta: Vec2,
target: &Aabb,
config: &CollisionConfig,
) -> Option<Hit> {
let offset = body.center - target.center;
let depth = body.half + target.half - offset.abs();
let face = (target.center.abs() + target.half).max_element();
let margin = config.skin + 4.0 * f32::EPSILON * face;
[
(depth.x, Vec2::new(offset.x.signum(), 0.0)),
(depth.y, Vec2::new(0.0, offset.y.signum())),
]
.into_iter()
.find(|&(depth, normal)| depth <= margin && one_way_blocks(normal, delta, config))
.map(|(_, normal)| Hit { t: 0.0, normal })
}
fn one_way_blocks(normal: Vec2, motion: Vec2, config: &CollisionConfig) -> bool {
config.is_floor(normal) && motion.dot(normal) < 0.0
}
fn classify(contacts: &mut Contacts, normal: Vec2, config: &CollisionConfig) {
if config.is_floor(normal) {
contacts.floor = true;
contacts.last_floor_normal = normal;
} else if config.is_ceiling(normal) {
contacts.ceiling = true;
contacts.last_ceiling_normal = normal;
} else {
contacts.wall = true;
}
}
fn back_off(delta: Vec2, hit: Hit, skin: f32) -> Vec2 {
let normal_motion = delta.dot(hit.normal).abs();
let safe_t = (hit.t - skin / normal_motion).max(0.0);
delta * safe_t
}
fn swept_region(body: &Aabb, delta: Vec2) -> (Vec2, Vec2) {
let start_min = body.min();
let start_max = body.max();
(
start_min.min(start_min + delta),
start_max.max(start_max + delta),
)
}
#[cfg(test)]
mod tests {
use super::super::collider_set::ColliderFlags;
use super::*;
use wasm_bindgen_test::wasm_bindgen_test;
#[wasm_bindgen_test(unsupported = test)]
fn tiny_embedded_motion_does_not_deepen_the_overlap() {
for sign in [-1.0, 1.0] {
let body = Aabb::new(Vec2::ZERO, Vec2::splat(1e-24));
let target = Aabb::new(Vec2::new(sign * 1e-24, 0.0), Vec2::splat(1e-24));
assert!(body.overlaps(&target));
let mut set = ColliderSet::new(1e-24);
set.insert(target, ColliderFlags::SOLID);
let config = CollisionConfig::new(Vec2::Y, 0.0, 0.0, 0.7, 0.0);
let (moved, contacts) =
move_and_collide(body, Vec2::new(sign * 5e-25, 0.0), &set, &config);
assert_eq!(moved, Vec2::ZERO, "inward motion deepened the overlap");
assert!(contacts.wall);
}
}
#[wasm_bindgen_test(unsupported = test)]
fn tiny_unobstructed_motion_is_not_reported_blocked() {
let body = Aabb::new(Vec2::ZERO, Vec2::splat(0.5));
let set = ColliderSet::new(1.0);
let config = CollisionConfig::new(Vec2::Y, 0.0, 0.1, 0.7, 0.0);
for delta in [Vec2::new(0.0, -1e-8), Vec2::new(5e-8, 1e-8)] {
let (moved, contacts) = move_and_collide(body, delta, &set, &config);
assert_eq!(moved, delta, "empty space must allow the entire motion");
assert!(!contacts.down_blocked);
assert!(!contacts.up_blocked);
}
}
#[wasm_bindgen_test(unsupported = test)]
fn tiny_motion_still_contacts_a_nearby_surface() {
let body = Aabb::new(Vec2::ZERO, Vec2::splat(1e-9));
let floor = Aabb::new(Vec2::new(0.0, -5e-9), Vec2::splat(1e-9));
let mut set = ColliderSet::new(1e-8);
set.insert(floor, ColliderFlags::SOLID);
let config = CollisionConfig::new(Vec2::Y, 0.0, 0.0, 0.7, 0.0);
let (moved, contacts) = move_and_collide(body, Vec2::new(0.0, -1e-8), &set, &config);
assert!(contacts.floor);
assert!(contacts.down_blocked);
assert!((moved.y + 3e-9).abs() < 1e-15, "moved {moved:?}");
assert!(!body.translated(moved).overlaps(&floor));
}
#[wasm_bindgen_test(unsupported = test)]
fn end_contact_on_a_cell_boundary_keeps_the_skin_gap() {
for sign in [-1.0, 1.0] {
let body = Aabb::new(Vec2::new(0.0, sign * 76.701_805), Vec2::splat(0.5));
let floor = Aabb::new(Vec2::new(0.0, sign * 56.5), Vec2::new(50.0, 0.5));
let delta = Vec2::new(0.0, -sign * 19.201_805);
let config = CollisionConfig::new(Vec2::new(0.0, sign), 0.01, 0.1, 0.7, 0.0);
let mut set = ColliderSet::new(1.0);
set.insert(floor, ColliderFlags::SOLID);
let (moved, contacts) = move_and_collide(body, delta, &set, &config);
assert!(contacts.floor, "missed end contact with up={sign}");
let gap = (body.translated(moved).center.y - floor.center.y).abs() - 1.0;
assert!((gap - 0.01).abs() < 1e-5, "skin gap was {gap}");
}
}
#[wasm_bindgen_test(unsupported = test)]
fn contacts_record_the_request_and_classification_is_strict() {
let config = CollisionConfig::new(Vec2::Y, 0.01, 0.1, 0.7, 1e-4);
let set = ColliderSet::new(1.0);
let body = Aabb::new(Vec2::ZERO, Vec2::splat(0.5));
let (_, contacts) = move_and_collide(body, Vec2::new(1.5, -2.0), &set, &config);
assert_eq!(contacts.desired, Vec2::new(1.5, -2.0));
let (_, contacts) = snap_to_ground(body, &set, &config);
assert_eq!(contacts.desired, Vec2::new(0.0, -0.1));
assert!(
!config.is_floor(Vec2::new(0.3, 0.7)),
"exactly at the threshold"
);
assert!(config.is_floor(Vec2::new(0.0, 0.7001)));
assert!(
!config.is_ceiling(Vec2::new(0.3, -0.7)),
"exactly at the threshold"
);
assert!(config.is_ceiling(Vec2::new(0.0, -0.7001)));
}
#[wasm_bindgen_test(unsupported = test)]
fn blocked_flags_need_a_shortfall_beyond_the_epsilon() {
let config = CollisionConfig::new(Vec2::Y, 0.0, 0.1, 0.7, 0.0);
let mut set = ColliderSet::new(1.0);
set.insert(
Aabb::new(Vec2::new(0.0, -3.0), Vec2::splat(1.0)),
ColliderFlags::SOLID,
);
set.insert(
Aabb::new(Vec2::new(0.0, 3.0), Vec2::splat(1.0)),
ColliderFlags::SOLID,
);
let body = Aabb::new(Vec2::ZERO, Vec2::splat(1.0));
let (moved, contacts) = move_and_collide(body, Vec2::new(0.0, -1.0), &set, &config);
assert_eq!(moved, Vec2::new(0.0, -1.0));
assert!(contacts.floor);
assert!(!contacts.down_blocked, "landed exactly where asked");
let (_, contacts) = move_and_collide(body, Vec2::new(0.0, -5.0), &set, &config);
assert!(contacts.down_blocked);
let (moved, contacts) = move_and_collide(body, Vec2::new(0.0, 1.0), &set, &config);
assert_eq!(moved, Vec2::new(0.0, 1.0));
assert!(contacts.ceiling);
assert!(!contacts.up_blocked, "rose exactly as far as asked");
let (_, contacts) = move_and_collide(body, Vec2::new(0.0, 5.0), &set, &config);
assert!(contacts.up_blocked);
}
const SKIN: f32 = 0.01;
const SNAP: f32 = 2.0 / 32.0;
fn aabb(cx: f32, cy: f32, hx: f32, hy: f32) -> Aabb {
Aabb::new(Vec2::new(cx, cy), Vec2::new(hx, hy))
}
fn config(up: Vec2) -> CollisionConfig {
CollisionConfig::new(up, SKIN, SNAP, 0.7, 0.0001)
}
fn set_with(colliders: &[(Aabb, ColliderFlags)]) -> ColliderSet {
let mut set = ColliderSet::new(1.0);
for &(aabb, flags) in colliders {
set.insert(aabb, flags);
}
set
}
#[wasm_bindgen_test(unsupported = test)]
fn landing_rests_one_skin_above_the_floor() {
let set = set_with(&[(aabb(0.0, 0.0, 5.0, 0.5), ColliderFlags::SOLID)]);
let body = aabb(0.0, 2.0, 0.5, 0.5);
let (effective, contacts) =
move_and_collide(body, Vec2::new(0.0, -3.0), &set, &config(Vec2::Y));
let rest = 2.0 + effective.y;
assert!((rest - (1.0 + SKIN)).abs() < 1e-4, "rested at {rest}");
assert!(contacts.floor && contacts.down_blocked);
assert!(!contacts.ceiling && !contacts.wall);
assert_eq!(contacts.last_floor_normal, Vec2::Y);
}
#[wasm_bindgen_test(unsupported = test)]
fn head_bump_stops_below_the_ceiling() {
let set = set_with(&[(aabb(0.0, 0.0, 5.0, 0.5), ColliderFlags::SOLID)]);
let body = aabb(0.0, -2.0, 0.5, 0.5);
let (effective, contacts) =
move_and_collide(body, Vec2::new(0.0, 3.0), &set, &config(Vec2::Y));
let rest = -2.0 + effective.y;
assert!((rest - (-1.0 - SKIN)).abs() < 1e-4, "rested at {rest}");
assert!(contacts.ceiling && contacts.up_blocked);
assert!(!contacts.floor);
assert_eq!(contacts.last_ceiling_normal, Vec2::NEG_Y);
}
#[wasm_bindgen_test(unsupported = test)]
fn wall_blocks_one_axis_and_the_slide_keeps_the_other() {
let set = set_with(&[(aabb(2.0, 0.0, 0.5, 5.0), ColliderFlags::SOLID)]);
let body = aabb(0.0, 0.0, 0.5, 0.5);
let (effective, contacts) =
move_and_collide(body, Vec2::new(3.0, 1.0), &set, &config(Vec2::Y));
assert!((effective.x - 0.99).abs() < 1e-4, "x was {}", effective.x);
assert!(
(effective.y - 299.0 / 300.0).abs() < 1e-4,
"y was {}",
effective.y
);
assert!(contacts.wall);
assert!(!contacts.floor && !contacts.ceiling);
}
#[wasm_bindgen_test(unsupported = test)]
fn diagonal_skin_back_off_does_not_enter_a_ledge_the_sweep_cleared() {
let body = aabb(0.0, 0.0, 0.5, 0.5);
let wall = Aabb::from_min_max(Vec2::new(1.0, -2.0), Vec2::new(2.0, 2.0));
let ledge = Aabb::from_min_max(Vec2::new(-2.0, -2.0), Vec2::new(-0.046_875, -0.96875));
let set = set_with(&[(wall, ColliderFlags::SOLID), (ledge, ColliderFlags::SOLID)]);
let config = CollisionConfig::new(Vec2::Y, 0.125, 0.0, 0.7, 1e-4);
let (effective, contacts) = move_and_collide(body, Vec2::new(1.0, -1.0), &set, &config);
let moved = body.translated(effective);
assert!(!moved.overlaps(&ledge), "ended inside ledge: {moved:?}");
assert!(!moved.overlaps(&wall), "ended inside wall: {moved:?}");
assert_eq!(effective, Vec2::new(0.375, -0.375));
assert!(contacts.wall && contacts.floor);
}
#[wasm_bindgen_test(unsupported = test)]
fn diagonal_ground_snap_stays_clear_of_a_ledge_beside_its_probe() {
let body = aabb(0.0, 0.0, 0.5, 0.5);
let surface = Aabb::from_min_max(Vec2::new(1.0, -2.0), Vec2::new(2.0, 2.0));
let ledge = Aabb::from_min_max(Vec2::new(-2.0, -2.0), Vec2::new(-0.046_875, -0.96875));
let set = set_with(&[
(surface, ColliderFlags::SOLID),
(ledge, ColliderFlags::SOLID),
]);
let config = CollisionConfig::new(Vec2::new(-1.0, 1.0), 0.125, 2.0f32.sqrt(), 0.7, 1e-4);
let (snap, contacts) = snap_to_ground(body, &set, &config);
let moved = body.translated(snap);
assert!(!moved.overlaps(&ledge), "snapped inside ledge: {moved:?}");
assert!(snap.abs_diff_eq(Vec2::new(0.375, -0.375), f32::EPSILON));
assert!(contacts.floor);
}
#[wasm_bindgen_test(unsupported = test)]
fn landing_while_running_keeps_the_horizontal_travel_after_contact() {
let set = set_with(&[(aabb(0.0, 0.0, 10.0, 0.5), ColliderFlags::SOLID)]);
let body = aabb(0.0, 2.0, 0.5, 0.5);
let (effective, contacts) =
move_and_collide(body, Vec2::new(2.0, -3.0), &set, &config(Vec2::Y));
assert!(contacts.floor);
assert!(
(effective.x - 299.0 / 150.0).abs() < 1e-4,
"x was {}",
effective.x
);
}
#[wasm_bindgen_test(unsupported = test)]
fn the_earliest_contact_wins_over_query_order() {
let set = set_with(&[
(aabb(0.0, -4.0, 5.0, 0.5), ColliderFlags::SOLID),
(aabb(0.0, 0.0, 5.0, 0.5), ColliderFlags::SOLID),
]);
let body = aabb(0.0, 2.0, 0.5, 0.5);
let (effective, contacts) =
move_and_collide(body, Vec2::new(0.0, -6.0), &set, &config(Vec2::Y));
let rest = 2.0 + effective.y;
assert!((rest - (1.0 + SKIN)).abs() < 1e-4, "rested at {rest}");
assert!(contacts.floor);
}
#[wasm_bindgen_test(unsupported = test)]
fn contact_exactly_at_the_end_of_the_delta_is_reported() {
let set = set_with(&[(aabb(0.0, 0.0, 5.0, 0.5), ColliderFlags::SOLID)]);
let body = aabb(0.0, 2.0, 0.5, 0.5);
let (effective, contacts) =
move_and_collide(body, Vec2::new(0.0, -1.0), &set, &config(Vec2::Y));
assert!(contacts.floor && contacts.down_blocked);
assert!(
(effective.y - (-1.0 + SKIN)).abs() < 1e-4,
"y was {}",
effective.y
);
}
#[wasm_bindgen_test(unsupported = test)]
fn the_skin_back_off_never_reverses_the_move() {
let set = set_with(&[(aabb(0.0, 0.0, 0.5, 0.5), ColliderFlags::SOLID)]);
let body = aabb(-1.003, 0.0, 0.5, 0.5);
let (effective, contacts) =
move_and_collide(body, Vec2::new(0.006, 0.0), &set, &config(Vec2::Y));
assert!(contacts.wall);
assert!(effective.x >= 0.0, "moved backwards: {}", effective.x);
assert!(effective.x <= 0.003, "moved past contact: {}", effective.x);
}
#[wasm_bindgen_test(unsupported = test)]
fn sensors_and_non_solid_colliders_do_not_block() {
let set = set_with(&[
(aabb(0.0, 0.0, 5.0, 0.5), ColliderFlags::SENSOR),
(
aabb(0.0, -1.5, 5.0, 0.5),
ColliderFlags {
solid: false,
one_way: false,
sensor: false,
},
),
]);
let body = aabb(0.0, 2.0, 0.5, 0.5);
let (effective, contacts) =
move_and_collide(body, Vec2::new(0.0, -3.0), &set, &config(Vec2::Y));
assert!((effective.y + 3.0).abs() < 1e-6, "y was {}", effective.y);
assert!(!contacts.floor && !contacts.wall && !contacts.down_blocked);
}
#[wasm_bindgen_test(unsupported = test)]
fn one_way_blocks_only_a_landing_from_above() {
let set = set_with(&[(aabb(0.0, 0.0, 5.0, 0.25), ColliderFlags::ONE_WAY)]);
let rising = aabb(0.0, -2.0, 0.5, 0.5);
let (effective, contacts) =
move_and_collide(rising, Vec2::new(0.0, 4.0), &set, &config(Vec2::Y));
assert!(!contacts.ceiling && !contacts.up_blocked);
assert!((effective.y - 4.0).abs() < 1e-6, "y was {}", effective.y);
let falling = aabb(0.0, 2.0, 0.5, 0.5);
let (_, contacts) = move_and_collide(falling, Vec2::new(0.0, -3.0), &set, &config(Vec2::Y));
assert!(contacts.floor && contacts.down_blocked);
}
#[wasm_bindgen_test(unsupported = test)]
fn one_way_platforms_with_a_tilted_up_block_motion_into_the_top_face() {
let platform = aabb(0.0, 0.0, 5.0, 0.25);
let set = set_with(&[(platform, ColliderFlags::ONE_WAY)]);
let config = config(Vec2::new(0.2, 1.0));
let above = aabb(0.0, 0.8, 0.5, 0.5);
let (effective, contacts) = move_and_collide(above, Vec2::new(1.0, -0.1), &set, &config);
assert!(
!above.translated(effective).overlaps(&platform),
"fell into the platform: {effective:?}"
);
assert!(contacts.floor);
let resting = aabb(0.0, 0.745, 0.5, 0.5);
let (effective, _) = move_and_collide(resting, Vec2::new(-1.0, 0.0), &set, &config);
assert_eq!(effective, Vec2::new(-1.0, 0.0));
}
#[wasm_bindgen_test(unsupported = test)]
fn non_finite_delta_moves_nothing() {
let set = set_with(&[(aabb(0.0, 0.0, 5.0, 0.5), ColliderFlags::SOLID)]);
let body = aabb(0.0, 2.0, 0.5, 0.5);
for delta in [Vec2::new(f32::NAN, -1.0), Vec2::new(0.0, f32::NEG_INFINITY)] {
let (effective, contacts) = move_and_collide(body, delta, &set, &config(Vec2::Y));
assert_eq!(effective, Vec2::ZERO);
assert!(!contacts.floor && !contacts.down_blocked);
}
}
#[wasm_bindgen_test(unsupported = test)]
fn snap_reaches_exactly_as_far_as_it_is_configured_to() {
let set = set_with(&[(aabb(0.0, 0.0, 5.0, 0.5), ColliderFlags::SOLID)]);
let body = aabb(0.0, 1.0 + 1.0 / 32.0, 0.5, 0.5);
let (snap, contacts) = snap_to_ground(body, &set, &config(Vec2::Y));
assert!(contacts.floor, "a 2 px probe must bridge a 1 px gap");
assert!(snap.y < 0.0, "snap pulls the body toward the floor");
assert_eq!(contacts.last_floor_normal, Vec2::Y);
let short = CollisionConfig::new(Vec2::Y, SKIN, 0.5 / 32.0, 0.7, 0.0001);
let (snap, contacts) = snap_to_ground(body, &set, &short);
assert!(!contacts.floor, "a half-pixel probe must not reach");
assert_eq!(snap, Vec2::ZERO);
}
#[wasm_bindgen_test(unsupported = test)]
fn snap_grounds_on_a_one_way_top_but_not_from_under_it() {
let set = set_with(&[(aabb(0.0, 0.0, 5.0, 0.25), ColliderFlags::ONE_WAY)]);
let above = aabb(0.0, 0.75 + 1.0 / 32.0, 0.5, 0.5);
let (snap, contacts) = snap_to_ground(above, &set, &config(Vec2::Y));
assert!(contacts.floor, "a one-way top still grounds a landing");
assert!(snap.y < 0.0);
let below = aabb(0.0, -1.0, 0.5, 0.5);
let (snap, contacts) = snap_to_ground(below, &set, &config(Vec2::Y));
assert!(!contacts.floor);
assert_eq!(snap, Vec2::ZERO);
}
#[wasm_bindgen_test(unsupported = test)]
fn y_down_worlds_classify_floors_and_ceilings_the_same() {
let config = config(Vec2::NEG_Y);
let set = set_with(&[(aabb(0.0, 0.0, 5.0, 0.5), ColliderFlags::SOLID)]);
let above = aabb(0.0, -2.0, 0.5, 0.5);
let (effective, contacts) = move_and_collide(above, Vec2::new(0.0, 3.0), &set, &config);
let rest = -2.0 + effective.y;
assert!((rest - (-1.0 - SKIN)).abs() < 1e-4, "rested at {rest}");
assert!(contacts.floor && contacts.down_blocked);
assert!(!contacts.ceiling && !contacts.up_blocked);
assert_eq!(contacts.last_floor_normal, Vec2::NEG_Y);
let below = aabb(0.0, 2.0, 0.5, 0.5);
let (_, contacts) = move_and_collide(below, Vec2::new(0.0, -3.0), &set, &config);
assert!(contacts.ceiling && contacts.up_blocked);
assert!(!contacts.floor && !contacts.down_blocked);
assert_eq!(contacts.last_ceiling_normal, Vec2::Y);
}
#[wasm_bindgen_test(unsupported = test)]
fn y_down_worlds_mirror_one_way_and_snapping() {
let config = config(Vec2::NEG_Y);
let set = set_with(&[(aabb(0.0, 0.0, 5.0, 0.25), ColliderFlags::ONE_WAY)]);
let rising = aabb(0.0, 2.0, 0.5, 0.5);
let (effective, contacts) = move_and_collide(rising, Vec2::new(0.0, -4.0), &set, &config);
assert!(!contacts.ceiling);
assert!((effective.y + 4.0).abs() < 1e-6, "y was {}", effective.y);
let falling = aabb(0.0, -2.0, 0.5, 0.5);
let (_, contacts) = move_and_collide(falling, Vec2::new(0.0, 3.0), &set, &config);
assert!(contacts.floor && contacts.down_blocked);
let hovering = aabb(0.0, -0.75 - 1.0 / 32.0, 0.5, 0.5);
let (snap, contacts) = snap_to_ground(hovering, &set, &config);
assert!(contacts.floor, "the probe must run along -up, toward +y");
assert!(snap.y > 0.0, "snap pulls the body toward the platform");
}
#[wasm_bindgen_test(unsupported = test)]
fn a_body_one_float_step_inside_a_wall_does_not_pass_through() {
let wall = aabb(2.5, 0.0, 0.5, 5.0);
let set = set_with(&[(wall, ColliderFlags::SOLID)]);
let body = aabb(2.0f32.next_up() - 0.5, 0.0, 0.5, 0.5);
assert!(body.overlaps(&wall));
let (effective, contacts) =
move_and_collide(body, Vec2::new(3.0, 1.0), &set, &config(Vec2::Y));
assert!(effective.x <= 0.0, "moved {} into the wall", effective.x);
assert!((effective.y - 1.0).abs() < 1e-6, "slid {}", effective.y);
assert!(contacts.wall);
}
#[wasm_bindgen_test(unsupported = test)]
fn a_body_exactly_touching_a_floor_at_rounding_coordinates_stays_on_it() {
let floor = aabb(1.090_340_4, 4.337_375_6, 50.0, 0.125);
let set = set_with(&[(floor, ColliderFlags::SOLID)]);
let body = aabb(1.090_340_4, 4.711_776_3, 0.4, 0.249_400_84);
assert_eq!(body.min().y.to_bits(), floor.max().y.to_bits());
let (effective, contacts) =
move_and_collide(body, Vec2::new(0.0, -3.0), &set, &config(Vec2::Y));
assert!(effective.y >= 0.0, "fell {}", effective.y);
assert!(contacts.floor && contacts.down_blocked);
let (_, contacts) = snap_to_ground(body, &set, &config(Vec2::Y));
assert!(contacts.floor, "snapping finds the floor it rests on");
}
#[wasm_bindgen_test(unsupported = test)]
fn an_embedded_body_can_move_out() {
let wall = aabb(2.5, 0.0, 0.5, 5.0);
let set = set_with(&[(wall, ColliderFlags::SOLID)]);
let body = aabb(1.75, 0.0, 0.5, 0.5);
let (effective, _) = move_and_collide(body, Vec2::new(-1.0, 0.0), &set, &config(Vec2::Y));
assert_eq!(effective, Vec2::new(-1.0, 0.0));
}
#[wasm_bindgen_test(unsupported = test)]
fn a_body_inside_a_one_way_platform_falls_through() {
let set = set_with(&[(aabb(0.0, 0.0, 5.0, 0.25), ColliderFlags::ONE_WAY)]);
let body = aabb(0.0, 0.5, 0.5, 0.5);
let (effective, contacts) =
move_and_collide(body, Vec2::new(0.0, -2.0), &set, &config(Vec2::Y));
assert_eq!(effective, Vec2::new(0.0, -2.0));
assert!(!contacts.floor);
}
#[wasm_bindgen_test(unsupported = test)]
fn a_body_one_float_step_into_a_one_way_platform_lands() {
let platform = aabb(0.0, 0.0, 5.0, 0.25);
let set = set_with(&[(platform, ColliderFlags::ONE_WAY)]);
let body = aabb(0.0, 0.75f32.next_down(), 0.5, 0.5);
assert!(body.overlaps(&platform));
let (effective, contacts) =
move_and_collide(body, Vec2::new(0.0, -2.0), &set, &config(Vec2::Y));
assert!(effective.y >= 0.0, "fell {}", effective.y);
assert!(contacts.floor && contacts.down_blocked);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn the_config_rejects_negative_and_non_finite_lengths() {
for bad in [-0.01, f32::NAN, f32::INFINITY] {
for (skin, snap, epsilon) in [(bad, SNAP, 1e-4), (SKIN, bad, 1e-4), (SKIN, SNAP, bad)] {
let built = std::panic::catch_unwind(|| {
CollisionConfig::new(Vec2::Y, skin, snap, 0.7, epsilon)
});
assert!(built.is_err(), "accepted {skin}, {snap}, {epsilon}");
}
}
}
#[wasm_bindgen_test(unsupported = test)]
#[should_panic(expected = "CollisionConfig: up must be a finite non-zero direction")]
fn the_config_rejects_an_up_direction_whose_length_overflows() {
let _ = CollisionConfig::new(Vec2::new(1e20, 0.0), SKIN, SNAP, 0.7, 1e-4);
}
}