Skip to main content

sgl_core/collision/
sweep.rs

1//! Move-and-slide resolution of a kinematic body against a [`ColliderSet`].
2
3use super::aabb::{Aabb, Hit, sweep_aabb};
4use super::collider_set::ColliderSet;
5use crate::math::Vec2;
6
7/// Slide passes per [`move_and_collide`]. Two is enough to round an inside
8/// corner (one surface, then the one the slide runs into); more passes only
9/// let a body worm through geometry it should have stopped against.
10const MAX_SLIDE_PASSES: u32 = 2;
11
12/// The game's collision tunables, all in the caller's world units.
13///
14/// There is deliberately no `Default`: `skin` and `snap_distance` are lengths
15/// in the game's own units and `up` is the game's axis convention, so a default
16/// would silently bake one game's world into every other.
17///
18/// Construct it with [`Self::new`], which validates lengths and thresholds and
19/// normalizes `up`. Read-only accessors preserve these invariants.
20///
21/// A struct literal cannot bypass validation:
22///
23/// ```compile_fail
24/// use sgl_core::{collision::CollisionConfig, math::Vec2};
25///
26/// let config = CollisionConfig {
27///     up: Vec2::new(0.0, 2.0),
28///     skin: -1.0,
29///     snap_distance: 0.1,
30///     normal_threshold: 0.7,
31///     block_epsilon: 0.0,
32/// };
33/// ```
34#[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    /// Builds a config, normalizing `up`.
45    ///
46    /// # Panics
47    /// If `up` is not a usable direction, including an overflowing length;
48    /// if `normal_threshold` is outside
49    /// `(0, 1)` — outside that range every face classifies the same way and
50    /// floors, ceilings, and walls stop being distinguishable; or if `skin`,
51    /// `snap_distance` or `block_epsilon` is negative or not finite — a
52    /// negative skin backs a contact off *into* the surface. All are
53    /// authoring errors.
54    #[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    /// Unit vector naming the world's up axis. `Vec2::Y` for a y-up world,
89    /// `Vec2::NEG_Y` for a y-down one. Every floor, ceiling, one-way, and
90    /// ground-snap decision is made against this axis and nothing else.
91    #[must_use]
92    pub fn up(&self) -> Vec2 {
93        self.up
94    }
95
96    /// Gap left between the body and a surface after a contact resolves, so
97    /// the next frame's sweep starts clear of the surface instead of touching
98    /// it. Small relative to the body. Finite and non-negative.
99    #[must_use]
100    pub fn skin(&self) -> f32 {
101        self.skin
102    }
103
104    /// How far [`snap_to_ground`] probes along `-up`. Finite and
105    /// non-negative.
106    #[must_use]
107    pub fn snap_distance(&self) -> f32 {
108        self.snap_distance
109    }
110
111    /// `normal · up` above which a face counts as a floor, and below whose
112    /// negation it counts as a ceiling. `0.7` is roughly 45 degrees.
113    #[must_use]
114    pub fn normal_threshold(&self) -> f32 {
115        self.normal_threshold
116    }
117
118    /// Tolerance when comparing requested against achieved motion along `up`
119    /// for the blocked flags. Must be well under `skin`, or a resolved contact
120    /// reads as unblocked. Finite and non-negative.
121    #[must_use]
122    pub fn block_epsilon(&self) -> f32 {
123        self.block_epsilon
124    }
125
126    /// Whether `normal` belongs to a face the body can stand on.
127    #[must_use]
128    pub fn is_floor(&self, normal: Vec2) -> bool {
129        normal.dot(self.up) > self.normal_threshold
130    }
131
132    /// Whether `normal` belongs to a face the body can bump its head on.
133    #[must_use]
134    pub fn is_ceiling(&self, normal: Vec2) -> bool {
135        normal.dot(self.up) < -self.normal_threshold
136    }
137}
138
139/// What a resolved move ran into.
140///
141/// Normals are stored whole rather than as a single component so the caller can
142/// read them in its own axis convention.
143// The flags are independent observations of one move — a body can touch a floor
144// and a wall in the same step — so they do not collapse into a state enum.
145#[allow(clippy::struct_excessive_bools)]
146#[derive(Debug, Clone, Copy, Default, PartialEq)]
147pub struct Contacts {
148    /// A floor face was contacted.
149    pub floor: bool,
150    /// A ceiling face was contacted.
151    pub ceiling: bool,
152    /// A side face was contacted.
153    pub wall: bool,
154    /// Normal of the most recent floor contact, or zero if there was none.
155    pub last_floor_normal: Vec2,
156    /// Normal of the most recent ceiling contact, or zero if there was none.
157    pub last_ceiling_normal: Vec2,
158    /// The delta the caller asked for.
159    pub desired: Vec2,
160    /// The delta actually applied.
161    pub effective: Vec2,
162    /// Motion against `up` was requested and not achieved — the body is
163    /// resting on something.
164    pub down_blocked: bool,
165    /// Motion along `up` was requested and not achieved — a head-bump.
166    pub up_blocked: bool,
167}
168
169/// Sweeps `body` by `delta` through `set`, sliding along the surfaces it meets,
170/// and returns the translation to apply to `body.center` plus what it hit.
171///
172/// Up to two passes: stop along the swept path a `skin` short of the earliest
173/// contact along its normal, project the motion left after the contact time
174/// onto that surface's tangent, and sweep again. The backed-off portion is
175/// discarded, including its tangential component. Colliders
176/// that are sensors, or not solid, are skipped; a one-way
177/// platform blocks only a landing on its up-facing side (see
178/// [`ColliderFlags::one_way`](super::ColliderFlags::one_way)).
179///
180/// A body that starts inside a solid collider (a spawn, a moved collider, or
181/// float rounding at a contact) may move out of it or along it, never deeper:
182/// motion toward the collider on its axis of least penetration is a contact
183/// at the start, and only the slide along that face remains. A one-way
184/// platform the body is inside blocks only a fall onto it from within `skin`
185/// (plus float rounding) of its top face. `move_and_collide` does not push a
186/// body out: the game keeps bodies clear itself (where it spawns them, and
187/// carrying riders on moving platforms).
188///
189/// Ties between colliders contacted at the same instant go to the lowest
190/// collider index, which [`ColliderSet::query`] makes deterministic.
191///
192/// Every finite nonzero remainder is swept, including motion smaller than
193/// `f32::EPSILON` in the caller's world units. There is no absolute motion cutoff.
194///
195/// A non-finite `delta` is refused outright — it has no meaningful destination,
196/// and letting it through would ask the broadphase for an unbounded region.
197#[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            // Nothing in the way: spend the rest of the motion.
223            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        // Slide: keep only the part of the unspent motion along the surface.
234        let leftover = remaining - move_to;
235        remaining = leftover - hit.normal * leftover.dot(hit.normal);
236    }
237
238    contacts.effective = total;
239
240    // Blocked flags compare requested against achieved motion along `up`, so
241    // they mean the same thing in a y-up and a y-down world.
242    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/// Probes `snap_distance` along `-up` and returns the translation that puts the
251/// body back on the ground, or zero if there is no ground within reach.
252///
253/// Only a floor-classified face grounds the body, so a body rising through a
254/// one-way platform is never yanked back onto its underside. Useful after a
255/// move that walked off a small lip: it keeps a body attached across seams and
256/// downward steps instead of dropping it into a one-frame fall.
257#[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
276/// The first blocking contact of `body` moving by `delta`, ignoring faces that
277/// `accept` rejects.
278fn 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
319/// The contact for a body that already overlaps `target` (a spawn, a moved
320/// collider, or float rounding at a contact). It may move out of `target` or
321/// along it, but not deeper: motion toward `target`'s center on the axis of
322/// least penetration is a contact at `t = 0` on that axis's face, so the slide
323/// keeps only the motion along it. Ties go to the horizontal face, as in
324/// [`sweep_aabb`].
325fn 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    // Compare signs directly: multiplying tiny offset and motion can underflow
334    // to signed zero and incorrectly permit motion deeper into the collider.
335    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
339/// The contact for a body that already overlaps a one-way platform. A body
340/// no deeper than `skin` (plus float rounding) below the platform's floor face
341/// is resting on it, so moving into that face lands it there, as Godot's
342/// one-way collision margin does; deeper, it is passing through (part-way
343/// through a jump) and the platform never blocks.
344fn 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
363/// Whether a one-way platform blocks this motion: only when the body is coming
364/// into a floor-classified face. Motion into the face, not against `up`,
365/// decides: the two differ when `up` is not axis-aligned.
366fn one_way_blocks(normal: Vec2, motion: Vec2, config: &CollisionConfig) -> bool {
367    config.is_floor(normal) && motion.dot(normal) < 0.0
368}
369
370/// Records a contact normal as floor, ceiling, or wall.
371fn 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
383/// Stops on the swept segment with a `skin` gap along the contact normal.
384/// Shortening the whole motion keeps the body on the path the sweep cleared.
385/// A contact within `skin` of the start yields zero, never reversed motion.
386fn back_off(delta: Vec2, hit: Hit, skin: f32) -> Vec2 {
387    // Every blocking hit has nonzero motion into its axis-aligned normal,
388    // including embedded hits (which only block inward motion).
389    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
394/// The region covering `body` from the start to the end of `delta`.
395fn swept_region(body: &Aabb, delta: Vec2) -> (Vec2, Vec2) {
396    let start_min = body.min();
397    let start_max = body.max();
398    // Do not round these corners through Aabb's center/half representation:
399    // an inward-rounded end corner can omit the cell containing a t=1 hit.
400    (
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    /// Tiny inward motion from an overlap must not escape the embedded-body
413    /// response when multiplying the offset and motion would underflow.
414    #[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    /// #707: sub-epsilon motion in empty space still reaches its destination,
431    /// including at a jump apex with zero blocked-motion tolerance.
432    #[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    /// Tiny motion still sweeps against geometry rather than tunneling through
446    /// it merely because it is below `f32::EPSILON` in world units.
447    #[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    /// #706: rounding a swept region through center/half must not omit the
462    /// floor cell touched exactly at the end, in either axis convention.
463    #[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    /// #249: `Contacts::desired` records the requested motion for both
481    /// entry points, and the floor/ceiling classification is strict at the
482    /// configured threshold.
483    #[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    /// #249: the blocked flags compare requested against achieved motion
506    /// along `up`; a move that lands exactly at its requested distance is
507    /// not blocked, one that falls short by more than `block_epsilon` is.
508    #[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        // Floor top at y = -2, ceiling bottom at y = 2.
513        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    /// 2 px in a 32 px-per-unit world, the shadow-sp ground probe.
538    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    /// Falling onto a floor must stop the body exactly one skin above the
557    /// surface and report both the floor contact and the blocked descent. A
558    /// back-off on the wrong axis, or none at all, moves the resting height.
559    #[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        // Floor top 0.5 + body half 0.5 + skin.
567        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    /// Jumping into a ceiling must stop the body and report the head-bump. A
575    /// normal signed the wrong way would classify this as a floor and leave the
576    /// caller thinking it had landed.
577    #[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    /// Walking diagonally into a wall must stop the horizontal motion a skin
592    /// short and spend the vertical motion remaining after the contact time.
593    /// Without the second pass the vertical motion is thrown away.
594    #[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        // Body right edge 0.5 to wall left edge 1.5, less the skin.
602        assert!((effective.x - 0.99).abs() < 1e-4, "x was {}", effective.x);
603        // Contact at (1, 1/3); backing off 0.01 in x also removes 1/300 in y.
604        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    /// #632: backing off a wall contact must stay on the tested diagonal.
614    /// The ledge is cleared at x = 0.453125 before its top is reached at
615    /// y = -0.46875. Backing off x alone instead embeds the body in it.
616    #[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        // Contact is at (0.5, -0.5), so leaving a 0.125 x-gap along the
629        // diagonal stops at (0.375, -0.375). The slide then finds the ledge
630        // only 0.09375 below the body's bottom, inside the skin: no advance.
631        assert_eq!(effective, Vec2::new(0.375, -0.375));
632        assert!(contacts.wall && contacts.floor);
633    }
634
635    /// #632: a diagonal ground probe must also remain on the swept segment.
636    /// With diagonal `up`, the wall is floor-classified; backing off only x
637    /// places the body below the ledge's top and inside its right edge.
638    #[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    /// Running down onto a floor keeps the horizontal travel after contact,
657    /// discarding only the portion removed by skin back-off.
658    #[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        // Contact at (2/3, -1); back-off removes 0.01 * 2/3 in x as well.
667        assert!(
668            (effective.x - 299.0 / 150.0).abs() < 1e-4,
669            "x was {}",
670            effective.x
671        );
672    }
673
674    /// Among several colliders the *earliest* contact wins, not the first one
675    /// queried. The lower floor is inserted first, so a search that keeps its
676    /// first hit drops the body straight through the upper one.
677    #[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    /// A contact reached exactly at the end of the delta must still be
693    /// reported. Seeding the search with `t = 1` instead of "no hit yet" leaves
694    /// the body flush against the floor with no contact recorded, so the caller
695    /// spends a frame believing it is airborne.
696    #[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        // Contact happens when the body center reaches y = 1: exactly t = 1.
700        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    /// Backing off must never push the body backwards. Moving 0.003 into a
713    /// surface with a 0.01 skin leaves no room, so the move must collapse to
714    /// zero rather than turning into motion away from the wall.
715    #[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    /// Sensors and non-solid colliders must never block, however squarely the
728    /// body runs into them.
729    #[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    /// A one-way platform blocks a landing on its top face and nothing else: a
751    /// body rising through it must pass clean through.
752    #[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    /// A one-way platform blocks motion into its floor face, not motion
768    /// against a tilted `up`. Here `(1, -0.1)` descends onto the top face
769    /// while rising along `up`, and `(-1, 0)` from within the landing margin
770    /// runs along the face while descending along `up`.
771    #[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        // Within the skin of the top face, as a resolved landing leaves it.
786        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    /// A non-finite delta must resolve to no motion at all. Passing it through
792    /// asks the broadphase for an unbounded region and hands the caller a NaN
793    /// position.
794    #[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    /// The snap distance is a parameter, not a constant: the same 1 px gap must
806    /// be bridged by a 2 px probe and missed by a half-pixel one.
807    #[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    /// Snapping treats a one-way platform exactly like its landing face: it
824    /// grounds a body hovering over the top, and grounds nothing when the body
825    /// is under the platform. A one-way rule keyed to the wrong motion sign
826    /// refuses the first case and sticks the body to the underside in the
827    /// second.
828    #[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        // Platform top 0.25 + body half 0.5, hovering a pixel above it.
833        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        // Body top just below the platform: a downward probe meets no floor.
839        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    /// The same geometry mirrored into a y-down world must classify the same
846    /// way. Anything that reads a raw `y` instead of projecting onto `up`
847    /// swaps floor and ceiling here.
848    #[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        // "Above" in a y-down world is smaller y; falling is +y.
854        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    /// One-way platforms and ground snapping must mirror into a y-down world
870    /// too: the passable side is the one facing away from `up`, and the probe
871    /// runs along `-up`.
872    #[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        // Resting a pixel above the platform's up-facing side, which is at
887        // y = -0.25 in a y-down world.
888        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    /// #315: a body one float step inside a wall (rounding at a contact)
895    /// cannot move deeper or through it; the motion along the wall survives.
896    #[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        // Right edge at the float just past the wall's left edge, x = 2.
901        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    /// #427: a body resting exactly on a floor at coordinates that round
911    /// stays on it when it moves down, and snapping finds the floor.
912    #[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    /// #315: an embedded body is still free to move out of the collider.
927    #[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    /// #315: a body inside a one-way platform (part-way through a jump) that
937    /// starts falling drops through it rather than landing inside it.
938    #[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    /// #315: a body rounded one float step into a one-way platform's top
949    /// lands on it instead of falling through.
950    #[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    /// #315: negative or non-finite lengths are authoring errors.
963    #[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    /// Finite components alone do not make a usable direction: the old
977    /// squared-length check accepted this vector and normalized it to zero.
978    #[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}