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#[derive(Debug, Clone, Copy, PartialEq)]
18pub struct CollisionConfig {
19    /// Unit vector naming the world's up axis. `Vec2::Y` for a y-up world,
20    /// `Vec2::NEG_Y` for a y-down one. Every floor, ceiling, one-way, and
21    /// ground-snap decision is made against this axis and nothing else.
22    pub up: Vec2,
23    /// Gap left between the body and a surface after a contact resolves, so
24    /// the next frame's sweep starts clear of the surface instead of touching
25    /// it. Small relative to the body.
26    pub skin: f32,
27    /// How far [`snap_to_ground`] probes along `-up`.
28    pub snap_distance: f32,
29    /// `normal · up` above which a face counts as a floor, and below whose
30    /// negation it counts as a ceiling. `0.7` is roughly 45 degrees.
31    pub normal_threshold: f32,
32    /// Tolerance when comparing requested against achieved motion along `up`
33    /// for the blocked flags. Must be well under `skin`, or a resolved contact
34    /// reads as unblocked.
35    pub block_epsilon: f32,
36}
37
38impl CollisionConfig {
39    /// Builds a config, normalizing `up`.
40    ///
41    /// # Panics
42    /// If `up` is not a usable direction, or `normal_threshold` is outside
43    /// `(0, 1)` — outside that range every face classifies the same way and
44    /// floors, ceilings, and walls stop being distinguishable. Both are
45    /// authoring errors.
46    #[must_use]
47    pub fn new(
48        up: Vec2,
49        skin: f32,
50        snap_distance: f32,
51        normal_threshold: f32,
52        block_epsilon: f32,
53    ) -> Self {
54        assert!(
55            up.is_finite() && up.length_squared() > 0.0,
56            "CollisionConfig: up must be a finite non-zero direction, got {up}"
57        );
58        assert!(
59            normal_threshold > 0.0 && normal_threshold < 1.0,
60            "CollisionConfig: normal threshold must be in (0, 1), got {normal_threshold}"
61        );
62        Self {
63            up: up.normalize(),
64            skin,
65            snap_distance,
66            normal_threshold,
67            block_epsilon,
68        }
69    }
70
71    /// Whether `normal` belongs to a face the body can stand on.
72    #[must_use]
73    pub fn is_floor(&self, normal: Vec2) -> bool {
74        normal.dot(self.up) > self.normal_threshold
75    }
76
77    /// Whether `normal` belongs to a face the body can bump its head on.
78    #[must_use]
79    pub fn is_ceiling(&self, normal: Vec2) -> bool {
80        normal.dot(self.up) < -self.normal_threshold
81    }
82}
83
84/// What a resolved move ran into.
85///
86/// Normals are stored whole rather than as a single component so the caller can
87/// read them in its own axis convention.
88// The flags are independent observations of one move — a body can touch a floor
89// and a wall in the same step — so they do not collapse into a state enum.
90#[allow(clippy::struct_excessive_bools)]
91#[derive(Debug, Clone, Copy, Default, PartialEq)]
92pub struct Contacts {
93    /// A floor face was contacted.
94    pub floor: bool,
95    /// A ceiling face was contacted.
96    pub ceiling: bool,
97    /// A side face was contacted.
98    pub wall: bool,
99    /// Normal of the most recent floor contact, or zero if there was none.
100    pub last_floor_normal: Vec2,
101    /// Normal of the most recent ceiling contact, or zero if there was none.
102    pub last_ceiling_normal: Vec2,
103    /// The delta the caller asked for.
104    pub desired: Vec2,
105    /// The delta actually applied.
106    pub effective: Vec2,
107    /// Motion against `up` was requested and not achieved — the body is
108    /// resting on something.
109    pub down_blocked: bool,
110    /// Motion along `up` was requested and not achieved — a head-bump.
111    pub up_blocked: bool,
112}
113
114/// Sweeps `body` by `delta` through `set`, sliding along the surfaces it meets,
115/// and returns the translation to apply to `body.center` plus what it hit.
116///
117/// Up to two passes: stop a `skin` short of the earliest contact, project the
118/// unspent motion onto that surface's tangent, and sweep again. Colliders
119/// that are sensors, or not solid, are skipped; a one-way
120/// platform blocks only a landing on its up-facing side (see
121/// [`ColliderFlags::one_way`](super::ColliderFlags::one_way)).
122///
123/// Ties between colliders contacted at the same instant go to the lowest
124/// collider index, which [`ColliderSet::query`] makes deterministic.
125///
126/// A non-finite `delta` is refused outright — it has no meaningful destination,
127/// and letting it through would ask the broadphase for an unbounded region.
128#[must_use]
129pub fn move_and_collide(
130    body: Aabb,
131    delta: Vec2,
132    set: &ColliderSet,
133    config: &CollisionConfig,
134) -> (Vec2, Contacts) {
135    let mut contacts = Contacts {
136        desired: delta,
137        ..Contacts::default()
138    };
139    if !delta.is_finite() {
140        return (Vec2::ZERO, contacts);
141    }
142
143    let mut body = body;
144    let mut remaining = delta;
145    let mut total = Vec2::ZERO;
146
147    for _ in 0..MAX_SLIDE_PASSES {
148        if remaining.abs_diff_eq(Vec2::ZERO, f32::EPSILON) {
149            break;
150        }
151
152        let Some(hit) = earliest_hit(&body, remaining, set, config, |_| true) else {
153            // Nothing in the way: spend the rest of the motion.
154            total += remaining;
155            break;
156        };
157
158        let move_to = remaining * hit.t;
159        let safe = back_off(move_to, hit.normal, config.skin);
160        total += safe;
161        body = body.translated(safe);
162        classify(&mut contacts, hit.normal, config);
163
164        // Slide: keep only the part of the unspent motion along the surface.
165        let leftover = remaining - move_to;
166        remaining = leftover - hit.normal * leftover.dot(hit.normal);
167    }
168
169    contacts.effective = total;
170
171    // Blocked flags compare requested against achieved motion along `up`, so
172    // they mean the same thing in a y-up and a y-down world.
173    let desired_up = delta.dot(config.up);
174    let effective_up = total.dot(config.up);
175    contacts.down_blocked = desired_up < 0.0 && effective_up > desired_up + config.block_epsilon;
176    contacts.up_blocked = desired_up > 0.0 && effective_up < desired_up - config.block_epsilon;
177
178    (total, contacts)
179}
180
181/// Probes `snap_distance` along `-up` and returns the translation that puts the
182/// body back on the ground, or zero if there is no ground within reach.
183///
184/// Only a floor-classified face grounds the body, so a body rising through a
185/// one-way platform is never yanked back onto its underside. Useful after a
186/// move that walked off a small lip: it keeps a body attached across seams and
187/// downward steps instead of dropping it into a one-frame fall.
188#[must_use]
189pub fn snap_to_ground(body: Aabb, set: &ColliderSet, config: &CollisionConfig) -> (Vec2, Contacts) {
190    let probe = -config.up * config.snap_distance;
191    let mut contacts = Contacts {
192        desired: probe,
193        ..Contacts::default()
194    };
195
196    let Some(hit) = earliest_hit(&body, probe, set, config, |normal| config.is_floor(normal))
197    else {
198        return (Vec2::ZERO, contacts);
199    };
200
201    let safe = back_off(probe * hit.t, hit.normal, config.skin);
202    contacts.effective = safe;
203    classify(&mut contacts, hit.normal, config);
204    (safe, contacts)
205}
206
207/// The first blocking contact of `body` moving by `delta`, ignoring faces that
208/// `accept` rejects.
209fn earliest_hit(
210    body: &Aabb,
211    delta: Vec2,
212    set: &ColliderSet,
213    config: &CollisionConfig,
214    accept: impl Fn(Vec2) -> bool,
215) -> Option<Hit> {
216    let mut best: Option<Hit> = None;
217    for index in set.query(&swept_region(body, delta)) {
218        let Some(collider) = set.get(index) else {
219            continue;
220        };
221        if collider.flags.sensor || !collider.flags.solid {
222            continue;
223        }
224        let Some(hit) = sweep_aabb(body, delta, &collider.aabb) else {
225            continue;
226        };
227        if !accept(hit.normal) {
228            continue;
229        }
230        if collider.flags.one_way && !one_way_blocks(hit.normal, delta, config) {
231            continue;
232        }
233        if best.is_none_or(|previous| hit.t < previous.t) {
234            best = Some(hit);
235        }
236    }
237    best
238}
239
240/// Whether a one-way platform blocks this motion: only when the body is coming
241/// down onto the face that points along `up`.
242fn one_way_blocks(normal: Vec2, motion: Vec2, config: &CollisionConfig) -> bool {
243    config.is_floor(normal) && motion.dot(config.up) < 0.0
244}
245
246/// Records a contact normal as floor, ceiling, or wall.
247fn classify(contacts: &mut Contacts, normal: Vec2, config: &CollisionConfig) {
248    if config.is_floor(normal) {
249        contacts.floor = true;
250        contacts.last_floor_normal = normal;
251    } else if config.is_ceiling(normal) {
252        contacts.ceiling = true;
253        contacts.last_ceiling_normal = normal;
254    } else {
255        contacts.wall = true;
256    }
257}
258
259/// Shortens a move-to-contact by `skin` along the contact's entry axis, leaving
260/// the body a hair clear of the surface.
261fn back_off(move_to: Vec2, normal: Vec2, skin: f32) -> Vec2 {
262    // Contact normals are axis-aligned, so the larger component names the axis
263    // the body entered on.
264    if normal.x.abs() > normal.y.abs() {
265        Vec2::new(reduce_toward_zero(move_to.x, skin), move_to.y)
266    } else {
267        Vec2::new(move_to.x, reduce_toward_zero(move_to.y, skin))
268    }
269}
270
271/// Moves `value` `amount` closer to zero, stopping at zero. Backing off is
272/// never allowed to reverse the move: a contact within `skin` of the start
273/// yields no motion rather than motion the caller never asked for.
274fn reduce_toward_zero(value: f32, amount: f32) -> f32 {
275    if value >= 0.0 {
276        (value - amount).max(0.0)
277    } else {
278        (value + amount).min(0.0)
279    }
280}
281
282/// The region covering `body` from the start to the end of `delta`.
283fn swept_region(body: &Aabb, delta: Vec2) -> Aabb {
284    let start_min = body.min();
285    let start_max = body.max();
286    Aabb::from_min_max(
287        start_min.min(start_min + delta),
288        start_max.max(start_max + delta),
289    )
290}
291
292#[cfg(test)]
293mod tests {
294    use super::super::collider_set::ColliderFlags;
295    use super::*;
296    use wasm_bindgen_test::wasm_bindgen_test;
297
298    /// #249: `Contacts::desired` records the requested motion for both
299    /// entry points, and the floor/ceiling classification is strict at the
300    /// configured threshold.
301    #[wasm_bindgen_test(unsupported = test)]
302    fn contacts_record_the_request_and_classification_is_strict() {
303        let config = CollisionConfig::new(Vec2::Y, 0.01, 0.1, 0.7, 1e-4);
304        let set = ColliderSet::new(1.0);
305        let body = Aabb::new(Vec2::ZERO, Vec2::splat(0.5));
306        let (_, contacts) = move_and_collide(body, Vec2::new(1.5, -2.0), &set, &config);
307        assert_eq!(contacts.desired, Vec2::new(1.5, -2.0));
308        let (_, contacts) = snap_to_ground(body, &set, &config);
309        assert_eq!(contacts.desired, Vec2::new(0.0, -0.1));
310
311        assert!(
312            !config.is_floor(Vec2::new(0.3, 0.7)),
313            "exactly at the threshold"
314        );
315        assert!(config.is_floor(Vec2::new(0.0, 0.7001)));
316        assert!(
317            !config.is_ceiling(Vec2::new(0.3, -0.7)),
318            "exactly at the threshold"
319        );
320        assert!(config.is_ceiling(Vec2::new(0.0, -0.7001)));
321    }
322
323    /// #249: the blocked flags compare requested against achieved motion
324    /// along `up`; a move that lands exactly at its requested distance is
325    /// not blocked, one that falls short by more than `block_epsilon` is.
326    #[wasm_bindgen_test(unsupported = test)]
327    fn blocked_flags_need_a_shortfall_beyond_the_epsilon() {
328        let config = CollisionConfig::new(Vec2::Y, 0.0, 0.1, 0.7, 0.0);
329        let mut set = ColliderSet::new(1.0);
330        // Floor top at y = -2, ceiling bottom at y = 2.
331        set.insert(
332            Aabb::new(Vec2::new(0.0, -3.0), Vec2::splat(1.0)),
333            ColliderFlags::SOLID,
334        );
335        set.insert(
336            Aabb::new(Vec2::new(0.0, 3.0), Vec2::splat(1.0)),
337            ColliderFlags::SOLID,
338        );
339        let body = Aabb::new(Vec2::ZERO, Vec2::splat(1.0));
340        let (moved, contacts) = move_and_collide(body, Vec2::new(0.0, -1.0), &set, &config);
341        assert_eq!(moved, Vec2::new(0.0, -1.0));
342        assert!(contacts.floor);
343        assert!(!contacts.down_blocked, "landed exactly where asked");
344        let (_, contacts) = move_and_collide(body, Vec2::new(0.0, -5.0), &set, &config);
345        assert!(contacts.down_blocked);
346        let (moved, contacts) = move_and_collide(body, Vec2::new(0.0, 1.0), &set, &config);
347        assert_eq!(moved, Vec2::new(0.0, 1.0));
348        assert!(contacts.ceiling);
349        assert!(!contacts.up_blocked, "rose exactly as far as asked");
350        let (_, contacts) = move_and_collide(body, Vec2::new(0.0, 5.0), &set, &config);
351        assert!(contacts.up_blocked);
352    }
353
354    const SKIN: f32 = 0.01;
355    /// 2 px in a 32 px-per-unit world, the shadow-sp ground probe.
356    const SNAP: f32 = 2.0 / 32.0;
357
358    fn aabb(cx: f32, cy: f32, hx: f32, hy: f32) -> Aabb {
359        Aabb::new(Vec2::new(cx, cy), Vec2::new(hx, hy))
360    }
361
362    fn config(up: Vec2) -> CollisionConfig {
363        CollisionConfig::new(up, SKIN, SNAP, 0.7, 0.0001)
364    }
365
366    fn set_with(colliders: &[(Aabb, ColliderFlags)]) -> ColliderSet {
367        let mut set = ColliderSet::new(1.0);
368        for &(aabb, flags) in colliders {
369            set.insert(aabb, flags);
370        }
371        set
372    }
373
374    /// Falling onto a floor must stop the body exactly one skin above the
375    /// surface and report both the floor contact and the blocked descent. A
376    /// back-off on the wrong axis, or none at all, moves the resting height.
377    #[wasm_bindgen_test(unsupported = test)]
378    fn landing_rests_one_skin_above_the_floor() {
379        let set = set_with(&[(aabb(0.0, 0.0, 5.0, 0.5), ColliderFlags::SOLID)]);
380        let body = aabb(0.0, 2.0, 0.5, 0.5);
381        let (effective, contacts) =
382            move_and_collide(body, Vec2::new(0.0, -3.0), &set, &config(Vec2::Y));
383
384        // Floor top 0.5 + body half 0.5 + skin.
385        let rest = 2.0 + effective.y;
386        assert!((rest - (1.0 + SKIN)).abs() < 1e-4, "rested at {rest}");
387        assert!(contacts.floor && contacts.down_blocked);
388        assert!(!contacts.ceiling && !contacts.wall);
389        assert_eq!(contacts.last_floor_normal, Vec2::Y);
390    }
391
392    /// Jumping into a ceiling must stop the body and report the head-bump. A
393    /// normal signed the wrong way would classify this as a floor and leave the
394    /// caller thinking it had landed.
395    #[wasm_bindgen_test(unsupported = test)]
396    fn head_bump_stops_below_the_ceiling() {
397        let set = set_with(&[(aabb(0.0, 0.0, 5.0, 0.5), ColliderFlags::SOLID)]);
398        let body = aabb(0.0, -2.0, 0.5, 0.5);
399        let (effective, contacts) =
400            move_and_collide(body, Vec2::new(0.0, 3.0), &set, &config(Vec2::Y));
401
402        let rest = -2.0 + effective.y;
403        assert!((rest - (-1.0 - SKIN)).abs() < 1e-4, "rested at {rest}");
404        assert!(contacts.ceiling && contacts.up_blocked);
405        assert!(!contacts.floor);
406        assert_eq!(contacts.last_ceiling_normal, Vec2::NEG_Y);
407    }
408
409    /// Walking diagonally into a wall must stop the horizontal motion a skin
410    /// short and still spend the whole vertical motion on the slide pass.
411    /// Without the second pass the vertical motion is thrown away.
412    #[wasm_bindgen_test(unsupported = test)]
413    fn wall_blocks_one_axis_and_the_slide_keeps_the_other() {
414        let set = set_with(&[(aabb(2.0, 0.0, 0.5, 5.0), ColliderFlags::SOLID)]);
415        let body = aabb(0.0, 0.0, 0.5, 0.5);
416        let (effective, contacts) =
417            move_and_collide(body, Vec2::new(3.0, 1.0), &set, &config(Vec2::Y));
418
419        // Body right edge 0.5 to wall left edge 1.5, less the skin.
420        assert!((effective.x - 0.99).abs() < 1e-4, "x was {}", effective.x);
421        assert!((effective.y - 1.0).abs() < 1e-4, "y was {}", effective.y);
422        assert!(contacts.wall);
423        assert!(!contacts.floor && !contacts.ceiling);
424    }
425
426    /// Running down onto a floor must keep the full horizontal travel: the
427    /// leftover motion is projected onto the floor rather than dropped.
428    #[wasm_bindgen_test(unsupported = test)]
429    fn landing_while_running_keeps_the_horizontal_travel() {
430        let set = set_with(&[(aabb(0.0, 0.0, 10.0, 0.5), ColliderFlags::SOLID)]);
431        let body = aabb(0.0, 2.0, 0.5, 0.5);
432        let (effective, contacts) =
433            move_and_collide(body, Vec2::new(2.0, -3.0), &set, &config(Vec2::Y));
434
435        assert!(contacts.floor);
436        assert!((effective.x - 2.0).abs() < 1e-4, "x was {}", effective.x);
437    }
438
439    /// Among several colliders the *earliest* contact wins, not the first one
440    /// queried. The lower floor is inserted first, so a search that keeps its
441    /// first hit drops the body straight through the upper one.
442    #[wasm_bindgen_test(unsupported = test)]
443    fn the_earliest_contact_wins_over_query_order() {
444        let set = set_with(&[
445            (aabb(0.0, -4.0, 5.0, 0.5), ColliderFlags::SOLID),
446            (aabb(0.0, 0.0, 5.0, 0.5), ColliderFlags::SOLID),
447        ]);
448        let body = aabb(0.0, 2.0, 0.5, 0.5);
449        let (effective, contacts) =
450            move_and_collide(body, Vec2::new(0.0, -6.0), &set, &config(Vec2::Y));
451
452        let rest = 2.0 + effective.y;
453        assert!((rest - (1.0 + SKIN)).abs() < 1e-4, "rested at {rest}");
454        assert!(contacts.floor);
455    }
456
457    /// A contact reached exactly at the end of the delta must still be
458    /// reported. Seeding the search with `t = 1` instead of "no hit yet" leaves
459    /// the body flush against the floor with no contact recorded, so the caller
460    /// spends a frame believing it is airborne.
461    #[wasm_bindgen_test(unsupported = test)]
462    fn contact_exactly_at_the_end_of_the_delta_is_reported() {
463        let set = set_with(&[(aabb(0.0, 0.0, 5.0, 0.5), ColliderFlags::SOLID)]);
464        // Contact happens when the body center reaches y = 1: exactly t = 1.
465        let body = aabb(0.0, 2.0, 0.5, 0.5);
466        let (effective, contacts) =
467            move_and_collide(body, Vec2::new(0.0, -1.0), &set, &config(Vec2::Y));
468
469        assert!(contacts.floor && contacts.down_blocked);
470        assert!(
471            (effective.y - (-1.0 + SKIN)).abs() < 1e-4,
472            "y was {}",
473            effective.y
474        );
475    }
476
477    /// Backing off must never push the body backwards. Moving 0.003 into a
478    /// surface with a 0.01 skin leaves no room, so the move must collapse to
479    /// zero rather than turning into motion away from the wall.
480    #[wasm_bindgen_test(unsupported = test)]
481    fn the_skin_back_off_never_reverses_the_move() {
482        let set = set_with(&[(aabb(0.0, 0.0, 0.5, 0.5), ColliderFlags::SOLID)]);
483        let body = aabb(-1.003, 0.0, 0.5, 0.5);
484        let (effective, contacts) =
485            move_and_collide(body, Vec2::new(0.006, 0.0), &set, &config(Vec2::Y));
486
487        assert!(contacts.wall);
488        assert!(effective.x >= 0.0, "moved backwards: {}", effective.x);
489        assert!(effective.x <= 0.003, "moved past contact: {}", effective.x);
490    }
491
492    /// Sensors and non-solid colliders must never block, however squarely the
493    /// body runs into them.
494    #[wasm_bindgen_test(unsupported = test)]
495    fn sensors_and_non_solid_colliders_do_not_block() {
496        let set = set_with(&[
497            (aabb(0.0, 0.0, 5.0, 0.5), ColliderFlags::SENSOR),
498            (
499                aabb(0.0, -1.5, 5.0, 0.5),
500                ColliderFlags {
501                    solid: false,
502                    one_way: false,
503                    sensor: false,
504                },
505            ),
506        ]);
507        let body = aabb(0.0, 2.0, 0.5, 0.5);
508        let (effective, contacts) =
509            move_and_collide(body, Vec2::new(0.0, -3.0), &set, &config(Vec2::Y));
510
511        assert!((effective.y + 3.0).abs() < 1e-6, "y was {}", effective.y);
512        assert!(!contacts.floor && !contacts.wall && !contacts.down_blocked);
513    }
514
515    /// A one-way platform blocks a landing on its top face and nothing else: a
516    /// body rising through it must pass clean through.
517    #[wasm_bindgen_test(unsupported = test)]
518    fn one_way_blocks_only_a_landing_from_above() {
519        let set = set_with(&[(aabb(0.0, 0.0, 5.0, 0.25), ColliderFlags::ONE_WAY)]);
520
521        let rising = aabb(0.0, -2.0, 0.5, 0.5);
522        let (effective, contacts) =
523            move_and_collide(rising, Vec2::new(0.0, 4.0), &set, &config(Vec2::Y));
524        assert!(!contacts.ceiling && !contacts.up_blocked);
525        assert!((effective.y - 4.0).abs() < 1e-6, "y was {}", effective.y);
526
527        let falling = aabb(0.0, 2.0, 0.5, 0.5);
528        let (_, contacts) = move_and_collide(falling, Vec2::new(0.0, -3.0), &set, &config(Vec2::Y));
529        assert!(contacts.floor && contacts.down_blocked);
530    }
531
532    /// A non-finite delta must resolve to no motion at all. Passing it through
533    /// asks the broadphase for an unbounded region and hands the caller a NaN
534    /// position.
535    #[wasm_bindgen_test(unsupported = test)]
536    fn non_finite_delta_moves_nothing() {
537        let set = set_with(&[(aabb(0.0, 0.0, 5.0, 0.5), ColliderFlags::SOLID)]);
538        let body = aabb(0.0, 2.0, 0.5, 0.5);
539        for delta in [Vec2::new(f32::NAN, -1.0), Vec2::new(0.0, f32::NEG_INFINITY)] {
540            let (effective, contacts) = move_and_collide(body, delta, &set, &config(Vec2::Y));
541            assert_eq!(effective, Vec2::ZERO);
542            assert!(!contacts.floor && !contacts.down_blocked);
543        }
544    }
545
546    /// The snap distance is a parameter, not a constant: the same 1 px gap must
547    /// be bridged by a 2 px probe and missed by a half-pixel one.
548    #[wasm_bindgen_test(unsupported = test)]
549    fn snap_reaches_exactly_as_far_as_it_is_configured_to() {
550        let set = set_with(&[(aabb(0.0, 0.0, 5.0, 0.5), ColliderFlags::SOLID)]);
551        let body = aabb(0.0, 1.0 + 1.0 / 32.0, 0.5, 0.5);
552
553        let (snap, contacts) = snap_to_ground(body, &set, &config(Vec2::Y));
554        assert!(contacts.floor, "a 2 px probe must bridge a 1 px gap");
555        assert!(snap.y < 0.0, "snap pulls the body toward the floor");
556        assert_eq!(contacts.last_floor_normal, Vec2::Y);
557
558        let short = CollisionConfig::new(Vec2::Y, SKIN, 0.5 / 32.0, 0.7, 0.0001);
559        let (snap, contacts) = snap_to_ground(body, &set, &short);
560        assert!(!contacts.floor, "a half-pixel probe must not reach");
561        assert_eq!(snap, Vec2::ZERO);
562    }
563
564    /// Snapping treats a one-way platform exactly like its landing face: it
565    /// grounds a body hovering over the top, and grounds nothing when the body
566    /// is under the platform. A one-way rule keyed to the wrong motion sign
567    /// refuses the first case and sticks the body to the underside in the
568    /// second.
569    #[wasm_bindgen_test(unsupported = test)]
570    fn snap_grounds_on_a_one_way_top_but_not_from_under_it() {
571        let set = set_with(&[(aabb(0.0, 0.0, 5.0, 0.25), ColliderFlags::ONE_WAY)]);
572
573        // Platform top 0.25 + body half 0.5, hovering a pixel above it.
574        let above = aabb(0.0, 0.75 + 1.0 / 32.0, 0.5, 0.5);
575        let (snap, contacts) = snap_to_ground(above, &set, &config(Vec2::Y));
576        assert!(contacts.floor, "a one-way top still grounds a landing");
577        assert!(snap.y < 0.0);
578
579        // Body top just below the platform: a downward probe meets no floor.
580        let below = aabb(0.0, -1.0, 0.5, 0.5);
581        let (snap, contacts) = snap_to_ground(below, &set, &config(Vec2::Y));
582        assert!(!contacts.floor);
583        assert_eq!(snap, Vec2::ZERO);
584    }
585
586    /// The same geometry mirrored into a y-down world must classify the same
587    /// way. Anything that reads a raw `y` instead of projecting onto `up`
588    /// swaps floor and ceiling here.
589    #[wasm_bindgen_test(unsupported = test)]
590    fn y_down_worlds_classify_floors_and_ceilings_the_same() {
591        let config = config(Vec2::NEG_Y);
592        let set = set_with(&[(aabb(0.0, 0.0, 5.0, 0.5), ColliderFlags::SOLID)]);
593
594        // "Above" in a y-down world is smaller y; falling is +y.
595        let above = aabb(0.0, -2.0, 0.5, 0.5);
596        let (effective, contacts) = move_and_collide(above, Vec2::new(0.0, 3.0), &set, &config);
597        let rest = -2.0 + effective.y;
598        assert!((rest - (-1.0 - SKIN)).abs() < 1e-4, "rested at {rest}");
599        assert!(contacts.floor && contacts.down_blocked);
600        assert!(!contacts.ceiling && !contacts.up_blocked);
601        assert_eq!(contacts.last_floor_normal, Vec2::NEG_Y);
602
603        let below = aabb(0.0, 2.0, 0.5, 0.5);
604        let (_, contacts) = move_and_collide(below, Vec2::new(0.0, -3.0), &set, &config);
605        assert!(contacts.ceiling && contacts.up_blocked);
606        assert!(!contacts.floor && !contacts.down_blocked);
607        assert_eq!(contacts.last_ceiling_normal, Vec2::Y);
608    }
609
610    /// One-way platforms and ground snapping must mirror into a y-down world
611    /// too: the passable side is the one facing away from `up`, and the probe
612    /// runs along `-up`.
613    #[wasm_bindgen_test(unsupported = test)]
614    fn y_down_worlds_mirror_one_way_and_snapping() {
615        let config = config(Vec2::NEG_Y);
616        let set = set_with(&[(aabb(0.0, 0.0, 5.0, 0.25), ColliderFlags::ONE_WAY)]);
617
618        let rising = aabb(0.0, 2.0, 0.5, 0.5);
619        let (effective, contacts) = move_and_collide(rising, Vec2::new(0.0, -4.0), &set, &config);
620        assert!(!contacts.ceiling);
621        assert!((effective.y + 4.0).abs() < 1e-6, "y was {}", effective.y);
622
623        let falling = aabb(0.0, -2.0, 0.5, 0.5);
624        let (_, contacts) = move_and_collide(falling, Vec2::new(0.0, 3.0), &set, &config);
625        assert!(contacts.floor && contacts.down_blocked);
626
627        // Resting a pixel above the platform's up-facing side, which is at
628        // y = -0.25 in a y-down world.
629        let hovering = aabb(0.0, -0.75 - 1.0 / 32.0, 0.5, 0.5);
630        let (snap, contacts) = snap_to_ground(hovering, &set, &config);
631        assert!(contacts.floor, "the probe must run along -up, toward +y");
632        assert!(snap.y > 0.0, "snap pulls the body toward the platform");
633    }
634}