Skip to main content

alice_physics/
world_character.rs

1//! Move-and-slide of a [`CharacterController`] against the geometry a
2//! [`PhysicsWorld`] collides with.
3//!
4//! [`CharacterController::move_and_slide`] takes a slice of bodies and SDF
5//! colliders and sees each static body as a sphere of the character's radius; it
6//! does not see the world's static colliders (planes, height fields, triangle
7//! meshes) or the bodies' true shapes. [`PhysicsWorld::move_character`] sweeps the
8//! controller's capsule with [`PhysicsWorld::cast_capsule`], so it meets the same
9//! geometry the world collides with: body shapes, compound children, static
10//! colliders and (`std`) SDF colliders.
11//!
12//! # The capsule
13//!
14//! Centre [`CharacterController::position`], total height
15//! [`CharacterConfig::height`] (hemispheres included) and radius
16//! [`CharacterConfig::radius`]: the segment runs from
17//! `position − (height/2 − radius)·Y` to `position + (height/2 − radius)·Y`
18//! (a sphere when `height ≤ 2·radius`). `+Y` is up, as in
19//! [`CharacterController::move_and_slide`].
20//!
21//! # One move
22//!
23//! The displacement plus [`CharacterController::platform_velocity`] (as in
24//! [`CharacterController::move_and_slide`]) is swept in up to
25//! [`CharacterConfig::max_slides`] steps:
26//!
27//! 1. Sweep the capsule along the rest of the displacement (`len + skin`). No hit:
28//!    move the whole rest and stop.
29//! 2. Hit at distance `t` with normal `n`: move along the direction until the
30//!    capsule is [`CharacterConfig::skin_width`] from the surface along `n`,
31//!    `t − skin / (−direction·n)` (at least 0). The capsule only moves along the
32//!    swept path, which the cast found clear, so a move never makes it overlap.
33//! 3. Project the rest (`len` minus the distance moved, along the direction)
34//!    onto the contact plane:
35//!    `rest − n (rest · n)`. A surface steeper than
36//!    [`CharacterConfig::max_slope_angle`] whose projection would climb (`y > 0`)
37//!    is treated as a vertical wall: the rest is projected onto its horizontal
38//!    normal instead, so a steep slope cannot be walked up.
39//!
40//! The rest left after the last step is dropped. In a corner the projections run
41//! from one wall into the other; each such step moves 0 (the capsule already is
42//! a skin width from the wall it runs into), so the capsule stays put.
43//!
44//! # Steps
45//!
46//! When the controller was grounded before the move, its step height is positive,
47//! and the sweep met a surface steeper than the slope limit, the move is tried
48//! again raised: up by the step height (less if a ceiling is nearer), the
49//! horizontal part of the displacement swept from there, then down by the rise
50//! plus two skin widths, stopping a skin width above the surface. The raised result is taken when it lands on a walkable
51//! surface and gets more than a skin width further along the horizontal direction
52//! than the plain sweep.
53//!
54//! # Ground
55//!
56//! After the move a downward sweep of [`CharacterConfig::ground_probe_distance`]
57//! plus a skin width looks for ground: grounded when it hits a surface whose
58//! normal makes at most [`CharacterConfig::max_slope_angle`] with `+Y`. The body
59//! it belongs to (if any) becomes [`CharacterController::ground_body_index`] and
60//! its velocity [`CharacterController::platform_velocity`].
61//!
62//! # Starting overlap
63//!
64//! Before the move, a capsule that starts overlapping colliders is pushed out:
65//!
66//! 1. Find every collider the capsule overlaps and how far: the distance `δ` from
67//!    the capsule's segment to the collider (the queries of
68//!    [`PhysicsWorld::overlap_sphere`], on the segment) is below the radius, the
69//!    penetration is `r − δ` and the direction the collider's normal at the
70//!    nearest point.
71//! 2. Push the capsule along that normal by the penetration plus
72//!    [`CharacterConfig::skin_width`], the deepest overlap first (ties by target:
73//!    bodies, then static colliders, then SDF colliders, each by index), so it
74//!    ends a skin width clear of that surface, as a move leaves it.
75//! 3. Repeat until nothing overlaps, at most [`CharacterConfig::max_slides`]
76//!    pushes.
77//!
78//! The move of § One move then starts from the freed position. A plane is
79//! two-sided: a capsule below it is pushed down.
80//!
81//! The capsule is **not** freed, and keeps its position, when its segment itself
82//! meets a solid (an overlap deeper than the radius: there is no distance and so
83//! no push-out direction), when overlaps remain after
84//! [`CharacterConfig::max_slides`] pushes, or when the pushes would add up to more
85//! than [`CharacterConfig::radius`] `+` [`CharacterConfig::height`] (so a capsule
86//! deep inside a large body is not flung out of it). It then starts the move where
87//! it is, and [`PhysicsWorld::cast_capsule`] reports a cast that starts overlapping
88//! at `t = 0` with normal `−direction`: there is no surface normal to slide along,
89//! the move goes 0 along the direction and its projection onto the plane normal
90//! to the direction is 0, so the controller does not move
91//! ([`MoveResult::position`] is the old position); such a contact is not ground,
92//! and no step is tried from inside (the upward sweep of § Steps also starts
93//! overlapping).
94//!
95//! # Configuration
96//!
97//! A [`CharacterConfig`] with a negative [`CharacterConfig::radius`],
98//! [`CharacterConfig::skin_width`] or [`CharacterConfig::height`] describes no
99//! capsule: the move is refused, the controller is left unchanged and the
100//! returned [`MoveResult`] is its current state. [`CharacterConfig::max_slides`]
101//! of 0 is taken as 1 (one sweep, no slide). A height below `2·radius` is a
102//! sphere of the radius.
103//!
104//! # Excluding the character's own body
105//!
106//! [`PhysicsWorld::move_character_with_filter`] takes a [`RayFilter`]: pass
107//! [`RayFilter::excluding_body`] when the character also has a body in the world.
108//!
109//! Author: Moroya Sakamoto
110
111use crate::character::{CharacterConfig, CharacterController, MoveResult};
112use crate::math::{Fix128, Vec3Fix};
113use crate::shape_raycast::RayFilter;
114use crate::solver::PhysicsWorld;
115use crate::world_shape_query::{Penetration, WorldShapeHit};
116
117/// The controller's capsule and the constants a move uses.
118#[derive(Clone, Copy)]
119struct Sweep<'a> {
120    world: &'a PhysicsWorld,
121    filter: &'a RayFilter,
122    /// Half the segment length, `height/2 − radius` (at least 0).
123    half: Fix128,
124    radius: Fix128,
125    skin: Fix128,
126    /// `cos(max_slope_angle)`: a normal with `n·Y` at least this is walkable.
127    walkable: Fix128,
128    max_slides: usize,
129}
130
131/// Result of [`Sweep::slide`].
132#[derive(Clone, Copy)]
133struct Slid {
134    position: Vec3Fix,
135    /// A surface steeper than the slope limit was met.
136    steep: bool,
137}
138
139impl Sweep<'_> {
140    /// The capsule centred at `center` swept along `direction` for `max_t`.
141    fn cast(self, center: Vec3Fix, direction: Vec3Fix, max_t: Fix128) -> Option<WorldShapeHit> {
142        let off = Vec3Fix::new(Fix128::ZERO, self.half, Fix128::ZERO);
143        self.world.cast_capsule(
144            center - off,
145            center + off,
146            self.radius,
147            direction,
148            max_t,
149            self.filter,
150        )
151    }
152
153    /// How far to move along the unit `dir` toward a contact at distance `t` with
154    /// normal `n` to stop a skin width from the surface along `n`:
155    /// `t − skin / (−dir·n)`, at least 0 (`t − skin` when `n` does not face `dir`).
156    fn short_of(self, t: Fix128, dir: Vec3Fix, n: Vec3Fix) -> Fix128 {
157        let facing = -dir.dot(n);
158        let back = if facing > Fix128::ZERO {
159            self.skin / facing
160        } else {
161            self.skin
162        };
163        if t > back {
164            t - back
165        } else {
166            Fix128::ZERO
167        }
168    }
169
170    fn is_walkable(self, n: Vec3Fix) -> bool {
171        n.y >= self.walkable
172    }
173
174    /// Move-and-slide of `rest` from `position` (module doc, steps 1-3).
175    fn slide(self, mut position: Vec3Fix, mut rest: Vec3Fix) -> Slid {
176        let mut steep = false;
177        for _ in 0..self.max_slides {
178            let Some(dir) = rest.try_normalize() else {
179                break;
180            };
181            let len = rest.length();
182            let Some(hit) = self.cast(position, rest, len + self.skin) else {
183                position = position + rest;
184                break;
185            };
186            let n = hit.normal;
187            let travel = self.short_of(hit.t, dir, n);
188            position = position + dir * travel;
189            let left = dir * (len - travel);
190            let walkable = self.is_walkable(n);
191            steep |= !walkable;
192            let mut next = project(left, n);
193            if !walkable && n.y > Fix128::ZERO && next.y > Fix128::ZERO {
194                if let Some(wall) = Vec3Fix::new(n.x, Fix128::ZERO, n.z).try_normalize() {
195                    next = project(left, wall);
196                }
197            }
198            rest = next;
199        }
200        Slid { position, steep }
201    }
202
203    /// The raised move of the module doc § Steps, if it is taken.
204    fn step(
205        self,
206        start: Vec3Fix,
207        total: Vec3Fix,
208        step_height: Fix128,
209        plain: Vec3Fix,
210    ) -> Option<Vec3Fix> {
211        let horizontal = Vec3Fix::new(total.x, Fix128::ZERO, total.z);
212        let along = horizontal.try_normalize()?;
213        let rise = match self.cast(start, Vec3Fix::UNIT_Y, step_height + self.skin) {
214            Some(hit) if hit.t > self.skin => hit.t - self.skin,
215            Some(_) => return None,
216            None => step_height,
217        };
218        let raised = start + Vec3Fix::new(Fix128::ZERO, rise, Fix128::ZERO);
219        let across = self.slide(raised, horizontal).position;
220        let down = -Vec3Fix::UNIT_Y;
221        let hit = self.cast(across, down, rise + self.skin + self.skin)?;
222        if !self.is_walkable(hit.normal) {
223            return None;
224        }
225        let landed = across + down * self.short_of(hit.t, down, hit.normal);
226        let progress = |p: Vec3Fix| (p - start).dot(along);
227        (progress(landed) > progress(plain) + self.skin).then_some(landed)
228    }
229
230    /// The capsule at `position` pushed out of the colliders it starts overlapping
231    /// (module doc § Starting overlap): `None` when it cannot be freed.
232    fn depenetrate(self, position: Vec3Fix, max_push: Fix128) -> Option<Vec3Fix> {
233        let off = Vec3Fix::new(Fix128::ZERO, self.half, Fix128::ZERO);
234        let mut p = position;
235        let mut pushed = Fix128::ZERO;
236        for _ in 0..=self.max_slides {
237            let overlaps =
238                self.world
239                    .capsule_penetrations(p - off, p + off, self.radius, self.filter)?;
240            // Deepest first, ties by target (the list is sorted by target and only
241            // a strictly deeper one replaces the first).
242            let Some(deepest) =
243                overlaps
244                    .iter()
245                    .fold(None, |best: Option<&Penetration>, o| match best {
246                        Some(b) if b.depth >= o.depth => Some(b),
247                        _ => Some(o),
248                    })
249            else {
250                return Some(p);
251            };
252            let step = deepest.depth + self.skin;
253            pushed = pushed + step;
254            if pushed > max_push {
255                return None;
256            }
257            p = p + deepest.normal * step;
258        }
259        None
260    }
261
262    /// The walkable ground under a capsule at `position` (module doc § Ground).
263    fn ground(self, position: Vec3Fix, probe: Fix128) -> Option<WorldShapeHit> {
264        let down = -Vec3Fix::UNIT_Y;
265        let hit = self.cast(position, down, probe + self.skin)?;
266        (!starts_inside(&hit, down) && self.is_walkable(hit.normal)).then_some(hit)
267    }
268}
269
270/// `v` without its component along the unit `n`.
271fn project(v: Vec3Fix, n: Vec3Fix) -> Vec3Fix {
272    v - n * v.dot(n)
273}
274
275/// A cast that started overlapping: `t = 0`, normal `−direction` (the
276/// [`crate::world_shape_query`] convention). `direction` is the vector passed to
277/// the cast, normalized here the same way the cast normalizes it.
278fn starts_inside(hit: &WorldShapeHit, direction: Vec3Fix) -> bool {
279    hit.t.is_zero() && direction.try_normalize().is_some_and(|d| hit.normal == -d)
280}
281
282impl PhysicsWorld {
283    /// Move `ctrl` by `displacement`, sliding along everything the world collides
284    /// with (see [`crate::world_character`]), with the default [`RayFilter`].
285    ///
286    /// Updates the controller's position, grounded state, ground body and platform
287    /// velocity as [`CharacterController::move_and_slide`] does, and returns them.
288    pub fn move_character(
289        &self,
290        ctrl: &mut CharacterController,
291        displacement: Vec3Fix,
292    ) -> MoveResult {
293        self.move_character_with_filter(ctrl, displacement, &RayFilter::default())
294    }
295
296    /// [`PhysicsWorld::move_character`] seeing only what `filter` sees (for
297    /// instance [`RayFilter::excluding_body`] for the character's own body).
298    pub fn move_character_with_filter(
299        &self,
300        ctrl: &mut CharacterController,
301        displacement: Vec3Fix,
302        filter: &RayFilter,
303    ) -> MoveResult {
304        let config: CharacterConfig = ctrl.config;
305        // A negative radius, skin width or height describes no capsule: refused
306        // (module doc § Configuration).
307        if config.radius.is_negative()
308            || config.skin_width.is_negative()
309            || config.height.is_negative()
310        {
311            return MoveResult {
312                position: ctrl.position,
313                grounded: ctrl.grounded,
314                velocity: ctrl.velocity,
315                platform_velocity: ctrl.platform_velocity,
316            };
317        }
318        let half = config.height.half() - config.radius;
319        let sweep = Sweep {
320            world: self,
321            filter,
322            half: if half.is_negative() {
323                Fix128::ZERO
324            } else {
325                half
326            },
327            radius: config.radius,
328            skin: config.skin_width,
329            walkable: config.max_slope_angle.cos(),
330            max_slides: config.max_slides.max(1),
331        };
332        // A capsule that cannot be freed keeps its position, and the sweep from
333        // inside then blocks the move (module doc § Starting overlap).
334        let start = sweep
335            .depenetrate(ctrl.position, config.radius + config.height)
336            .unwrap_or(ctrl.position);
337        let total = displacement + ctrl.platform_velocity;
338        let plain = sweep.slide(start, total);
339        let mut position = plain.position;
340        if ctrl.grounded && plain.steep && config.step_height > Fix128::ZERO {
341            if let Some(stepped) = sweep.step(start, total, config.step_height, position) {
342                position = stepped;
343            }
344        }
345
346        let ground = sweep.ground(position, config.ground_probe_distance);
347        let ground_body = ground.and_then(|h| h.body);
348        let platform_velocity = ground_body
349            .and_then(|i| self.bodies.get(i))
350            .map_or(Vec3Fix::ZERO, |b| b.velocity);
351
352        ctrl.position = position;
353        ctrl.grounded = ground.is_some();
354        ctrl.ground_body_index = ground_body;
355        ctrl.platform_velocity = platform_velocity;
356        ctrl.velocity = displacement;
357
358        MoveResult {
359            position,
360            grounded: ctrl.grounded,
361            velocity: displacement,
362            platform_velocity,
363        }
364    }
365}
366
367#[cfg(test)]
368mod tests {
369    //! Closed-form unit tests of the move: every expected position is written
370    //! from the geometry by hand (the closed form is in a comment next to each
371    //! assertion); none calls the code under test.
372    //!
373    //! Default config: radius `r = 0.3`, height `h = 1.8` (segment half `0.6`),
374    //! skin `s = 0.01`, step height `0.3`, slope limit `0.785` rad
375    //! (`cos = 0.7074`), 4 slides. A capsule standing a skin width above the
376    //! floor `y = 0` has its centre at `h/2 + s = 0.91`.
377
378    use super::*;
379    use crate::plane_collider::PlaneCollider;
380    use crate::shape::Shape;
381    use crate::solver::{PhysicsConfig, RigidBody};
382    use crate::static_collider::StaticCollider;
383
384    const TOL: f64 = 1e-9;
385    const ITER: f64 = 1e-7;
386
387    fn fx(v: f64) -> Fix128 {
388        Fix128::from_f64(v)
389    }
390
391    fn v3(x: f64, y: f64, z: f64) -> Vec3Fix {
392        Vec3Fix::new(fx(x), fx(y), fx(z))
393    }
394
395    fn world() -> PhysicsWorld {
396        PhysicsWorld::new(PhysicsConfig::default())
397    }
398
399    fn plane(w: &mut PhysicsWorld, normal: Vec3Fix, offset: f64) -> usize {
400        w.add_static_collider(StaticCollider::Plane(PlaneCollider::new(
401            normal,
402            fx(offset),
403        )))
404    }
405
406    #[track_caller]
407    fn assert_pos(got: Vec3Fix, want: [f64; 3], tol: f64) {
408        let g = [got.x.to_f64(), got.y.to_f64(), got.z.to_f64()];
409        for k in 0..3 {
410            assert!(
411                (g[k] - want[k]).abs() < tol,
412                "position {g:?} but the closed form is {want:?}"
413            );
414        }
415    }
416
417    #[test]
418    fn a_negative_radius_skin_or_height_refuses_the_move() {
419        let w = world();
420        for k in 0..3 {
421            let mut config = CharacterConfig::default();
422            match k {
423                0 => config.radius = fx(-0.3),
424                1 => config.skin_width = fx(-0.01),
425                _ => config.height = fx(-1.8),
426            }
427            let mut ctrl = CharacterController::new(v3(1.0, 2.0, 3.0), config);
428            ctrl.velocity = v3(4.0, 0.0, 0.0);
429            ctrl.grounded = true;
430            ctrl.platform_velocity = v3(0.5, 0.0, 0.0);
431            let r = w.move_character(&mut ctrl, v3(5.0, 0.0, 0.0));
432            // oracle: refused, the result is the controller's current state.
433            assert_eq!(r.position, v3(1.0, 2.0, 3.0));
434            assert!(r.grounded);
435            assert_eq!(r.velocity, v3(4.0, 0.0, 0.0));
436            assert_eq!(r.platform_velocity, v3(0.5, 0.0, 0.0));
437            assert_eq!(ctrl.position, v3(1.0, 2.0, 3.0));
438            assert_eq!(ctrl.velocity, v3(4.0, 0.0, 0.0));
439        }
440    }
441
442    #[test]
443    fn a_free_move_takes_the_whole_displacement() {
444        let w = world();
445        let mut ctrl = CharacterController::new_default(v3(1.0, 5.0, -2.0));
446        let r = w.move_character(&mut ctrl, v3(3.0, -1.0, 2.0));
447        // oracle: nothing in the way: start + displacement, nothing under it.
448        assert_pos(r.position, [4.0, 4.0, 0.0], TOL);
449        assert!(!r.grounded);
450        assert_eq!(r.velocity, v3(3.0, -1.0, 2.0));
451        assert_eq!(r.platform_velocity, Vec3Fix::ZERO);
452        assert_eq!(ctrl.position, r.position);
453        assert_eq!(ctrl.ground_body_index, None);
454    }
455
456    #[test]
457    fn walking_down_into_the_floor_keeps_the_tangential_part() {
458        let mut w = world();
459        plane(&mut w, Vec3Fix::UNIT_Y, 0.0);
460        let mut ctrl = CharacterController::new_default(v3(0.0, 0.91, 0.0));
461        let r = w.move_character(&mut ctrl, v3(1.0, -0.5, 2.0));
462        // oracle: starting a skin width above the plane, D − n (D·n) with
463        // n = +Y: (1, 0, 2) added, the height stays 0.91; the probe finds the
464        // floor 0.01 below (a static collider: no ground body).
465        assert_pos(r.position, [1.0, 0.91, 2.0], TOL);
466        assert!(r.grounded);
467        assert!(ctrl.grounded);
468        assert_eq!(ctrl.ground_body_index, None);
469        assert_eq!(r.platform_velocity, Vec3Fix::ZERO);
470    }
471
472    #[test]
473    fn a_wall_removes_the_normal_component() {
474        let mut w = world();
475        // The wall x = 2 (normal −X, offset −2).
476        plane(&mut w, -Vec3Fix::UNIT_X, -2.0);
477        let mut ctrl = CharacterController::new_default(Vec3Fix::ZERO);
478        let r = w.move_character(&mut ctrl, v3(3.0, 0.0, 1.0));
479        // oracle: the capsule stops a skin width from the wall, x = 2 − r − s =
480        // 1.69; the tangential part of the whole displacement is kept, z = 1.
481        assert_pos(r.position, [1.69, 0.0, 1.0], TOL);
482        assert!(!r.grounded);
483
484        // max_slides 0 is one sweep without a slide: the move along the unit
485        // direction (3, 0, 1)/√10 stops at x = 1.69, z = 1.69/3.
486        let config = CharacterConfig {
487            max_slides: 0,
488            ..CharacterConfig::default()
489        };
490        let mut ctrl = CharacterController::new(Vec3Fix::ZERO, config);
491        let r = w.move_character(&mut ctrl, v3(3.0, 0.0, 1.0));
492        assert_pos(r.position, [1.69, 0.0, 1.69 / 3.0], TOL);
493    }
494
495    #[test]
496    fn a_steep_slope_is_a_wall_that_is_not_climbed() {
497        let mut w = world();
498        // The plane through the origin with normal n = (−√3/2, 1/2, 0), 60° from
499        // horizontal: steeper than the slope limit (n·Y = 0.5 < 0.7074).
500        let s3 = 3f64.sqrt();
501        plane(&mut w, v3(-s3, 1.0, 0.0), 0.0);
502        let mut ctrl = CharacterController::new_default(v3(-5.0, 0.0, 0.0));
503        let r = w.move_character(&mut ctrl, v3(10.0, 0.0, 0.0));
504        // oracle: the lower end (x, −0.6) has n·a = −√3/2·x − 0.3; the capsule is
505        // a skin width from the plane when that is r + s = 0.31, x = −1.22/√3.
506        // The rest projected on the plane would climb (y > 0), so it is projected
507        // on the horizontal normal −X instead: 0. No walkable ground.
508        assert_pos(r.position, [-1.22 / s3, 0.0, 0.0], TOL);
509        assert!(!r.grounded);
510    }
511
512    #[test]
513    fn a_low_step_is_climbed_when_grounded() {
514        let mut w = world();
515        // A box step: half extents (1, 0.1, 5) at (3, 0.1, 0), top y = 0.2,
516        // front face x = 2 (below the 0.3 step height).
517        let b = w
518            .add_shaped_body(
519                &Shape::Box {
520                    half_extents: v3(1.0, 0.1, 5.0),
521                },
522                Fix128::ONE,
523                v3(3.0, 0.1, 0.0),
524            )
525            .expect("valid shape");
526        let mut ctrl = CharacterController::new_default(v3(0.0, 0.91, 0.0));
527        ctrl.grounded = true;
528        let r = w.move_character(&mut ctrl, v3(2.5, 0.0, 0.0));
529        // oracle: raised by the step height, swept to x = 2.5 over the step,
530        // lowered to a skin width above its top: y = 0.2 + 0.91.
531        assert_pos(r.position, [2.5, 1.11, 0.0], ITER);
532        assert!(r.grounded);
533        assert_eq!(ctrl.ground_body_index, Some(b));
534
535        // Not grounded before the move: no step. The lower hemisphere (centre
536        // y = 0.31) meets the step's edge (2, 0.2), 0.11 below it, when its
537        // centre is √(0.3² − 0.11²) = √0.0779 short of x = 2, normal
538        // n = (−√0.0779, 0.11)/0.3; it backs off s/(−d·n) = 0.003/√0.0779, and
539        // the rest, which would climb the steep normal, is cancelled.
540        let mut ctrl = CharacterController::new_default(v3(0.0, 0.91, 0.0));
541        let r = w.move_character(&mut ctrl, v3(2.5, 0.0, 0.0));
542        let q = 0.0779f64.sqrt();
543        assert_pos(r.position, [2.0 - q - 0.003 / q, 0.91, 0.0], ITER);
544    }
545
546    #[test]
547    fn a_moving_platform_becomes_ground_and_carries_the_next_move() {
548        let mut w = world();
549        // A box platform: half extents (5, 0.5, 5) at the origin, top y = 0.5,
550        // moving at (2, 0, 0).
551        let b = w
552            .add_shaped_body(
553                &Shape::Box {
554                    half_extents: v3(5.0, 0.5, 5.0),
555                },
556                Fix128::ONE,
557                Vec3Fix::ZERO,
558            )
559            .expect("valid shape");
560        w.bodies[b].velocity = v3(2.0, 0.0, 0.0);
561        let mut ctrl = CharacterController::new_default(v3(0.0, 1.41, 0.0));
562        let r = w.move_character(&mut ctrl, Vec3Fix::ZERO);
563        // oracle: standing a skin width above the top (0.5 + 0.91): no move,
564        // the platform is the ground and its velocity is inherited.
565        assert_pos(r.position, [0.0, 1.41, 0.0], TOL);
566        assert!(r.grounded);
567        assert_eq!(ctrl.ground_body_index, Some(b));
568        assert_eq!(r.platform_velocity, v3(2.0, 0.0, 0.0));
569        // oracle: the next move adds the platform velocity: x = 0 + 2.
570        let r = w.move_character(&mut ctrl, Vec3Fix::ZERO);
571        assert_pos(r.position, [2.0, 1.41, 0.0], ITER);
572        assert_eq!(r.velocity, Vec3Fix::ZERO);
573    }
574
575    #[test]
576    fn a_capsule_sunk_into_the_floor_is_pushed_out_first() {
577        let mut w = world();
578        plane(&mut w, Vec3Fix::UNIT_Y, 0.0);
579        let mut ctrl = CharacterController::new_default(v3(0.0, 0.8, 0.0));
580        let r = w.move_character(&mut ctrl, Vec3Fix::ZERO);
581        // oracle: the segment's lowest point y = 0.2 is 0.1 deeper than r: pushed
582        // up by 0.1 + s, centre 0.91.
583        assert_pos(r.position, [0.0, 0.91, 0.0], TOL);
584        assert!(r.grounded);
585    }
586
587    #[test]
588    fn a_capsule_whose_segment_crosses_a_solid_stays_put() {
589        let mut w = world();
590        plane(&mut w, Vec3Fix::UNIT_Y, 0.0);
591        // The segment runs from y = −0.1 to 1.1 and crosses the plane.
592        let mut ctrl = CharacterController::new_default(v3(0.0, 0.5, 0.0));
593        let r = w.move_character(&mut ctrl, v3(1.0, 0.0, 0.0));
594        // oracle: not freed; the cast starts overlapping (t = 0, normal
595        // −direction): the move is 0 and its projection is 0; not ground.
596        assert_pos(r.position, [0.0, 0.5, 0.0], TOL);
597        assert!(!r.grounded);
598    }
599
600    #[test]
601    fn a_capsule_between_walls_closer_than_its_diameter_is_not_freed() {
602        let mut w = world();
603        // Walls x = −0.25 and x = 0.25: a capsule of radius 0.3 at x = 0
604        // overlaps both by 0.05, and every push out of one goes into the other.
605        plane(&mut w, Vec3Fix::UNIT_X, -0.25);
606        plane(&mut w, Vec3Fix::UNIT_X, 0.25);
607        for max_slides in [4, 100] {
608            // 4 slides: overlaps remain after the pushes; 100 slides: the pushes
609            // (0.06, then 0.12 each) add up past r + h = 2.1.
610            let config = CharacterConfig {
611                max_slides,
612                ..CharacterConfig::default()
613            };
614            let mut ctrl = CharacterController::new(Vec3Fix::ZERO, config);
615            let r = w.move_character(&mut ctrl, v3(0.0, 0.0, 1.0));
616            // oracle: not freed, the cast from inside blocks the move.
617            assert_pos(r.position, [0.0, 0.0, 0.0], TOL);
618        }
619    }
620
621    #[test]
622    fn a_height_below_the_diameter_is_a_sphere() {
623        let mut w = world();
624        plane(&mut w, Vec3Fix::UNIT_Y, 0.0);
625        let config = CharacterConfig {
626            radius: fx(0.5),
627            height: fx(0.4),
628            ..CharacterConfig::default()
629        };
630        let mut ctrl = CharacterController::new(v3(0.0, 2.0, 0.0), config);
631        let r = w.move_character(&mut ctrl, v3(0.0, -5.0, 0.0));
632        // oracle: a sphere of radius 0.5 falls onto y = 0 and stops a skin width
633        // above it: centre y = 0.5 + 0.01.
634        assert_pos(r.position, [0.0, 0.51, 0.0], TOL);
635        assert!(r.grounded);
636    }
637
638    #[test]
639    fn the_filter_excludes_the_characters_own_body() {
640        let mut w = world();
641        // The character's own body: a sphere of radius 0.5 at its centre.
642        let own = w.add_body_with_radius(RigidBody::new_static(Vec3Fix::ZERO), fx(0.5));
643        let mut ctrl = CharacterController::new_default(Vec3Fix::ZERO);
644        let r = w.move_character(&mut ctrl, v3(1.0, 0.0, 0.0));
645        // oracle: the segment crosses its own body: not freed, no move.
646        assert_pos(r.position, [0.0, 0.0, 0.0], TOL);
647        let r = w.move_character_with_filter(
648            &mut ctrl,
649            v3(1.0, 0.0, 0.0),
650            &RayFilter::default().excluding_body(own),
651        );
652        // oracle: with its body excluded nothing is in the way: x = 1.
653        assert_pos(r.position, [1.0, 0.0, 0.0], TOL);
654    }
655
656    #[test]
657    fn short_of_and_project_closed_forms() {
658        let w = world();
659        let filter = RayFilter::default();
660        let sweep = Sweep {
661            world: &w,
662            filter: &filter,
663            half: fx(0.6),
664            radius: fx(0.3),
665            skin: fx(0.01),
666            walkable: fx(0.5),
667            max_slides: 4,
668        };
669        let d = Vec3Fix::UNIT_X;
670        // oracle: head-on (−d·n = 1): t − s = 0.99.
671        assert_pos(
672            Vec3Fix::new(
673                sweep.short_of(Fix128::ONE, d, -d),
674                Fix128::ZERO,
675                Fix128::ZERO,
676            ),
677            [0.99, 0.0, 0.0],
678            TOL,
679        );
680        // oracle: at 60° (−d·n = 1/2): t − s/(1/2) = 1 − 0.02.
681        let n = v3(-0.5, 0.75f64.sqrt(), 0.0);
682        assert_pos(
683            Vec3Fix::new(
684                sweep.short_of(Fix128::ONE, d, n),
685                Fix128::ZERO,
686                Fix128::ZERO,
687            ),
688            [0.98, 0.0, 0.0],
689            TOL,
690        );
691        // oracle: a normal not facing d backs off s; never below 0.
692        assert_pos(
693            Vec3Fix::new(
694                sweep.short_of(Fix128::ONE, d, d),
695                Fix128::ZERO,
696                Fix128::ZERO,
697            ),
698            [0.99, 0.0, 0.0],
699            TOL,
700        );
701        assert_eq!(sweep.short_of(fx(0.005), d, -d), Fix128::ZERO);
702        // oracle: walkable when n·Y ≥ 0.5.
703        assert!(sweep.is_walkable(Vec3Fix::UNIT_Y));
704        assert!(!sweep.is_walkable(v3(0.9, 0.4359, 0.0)));
705        // oracle: v − n (v·n): (3, 4, 5) without its Y part.
706        assert_eq!(
707            project(v3(3.0, 4.0, 5.0), Vec3Fix::UNIT_Y),
708            v3(3.0, 0.0, 5.0)
709        );
710    }
711}