Skip to main content

phyz_collision/
lib.rs

1//! Collision detection for phyz physics engine.
2//!
3//! Implements GJK (Gilbert-Johnson-Keerthi) for distance queries,
4//! EPA (Expanding Polytope Algorithm) for penetration depth,
5//! analytic ray casting, and broad-phase spatial hashing.
6
7#![warn(missing_docs)]
8
9// Compile the crate README's Rust blocks as doc-tests so the documented API
10// cannot drift from the real one. `cfg(doctest)` keeps it out of rendered docs.
11#[cfg(doctest)]
12#[doc = include_str!("../README.md")]
13pub struct ReadmeDocTests;
14
15pub mod broad_phase;
16pub mod epa;
17pub mod geometry;
18pub mod gjk;
19pub mod manifold;
20pub mod raycast;
21
22pub use broad_phase::sweep_and_prune;
23pub use epa::{epa_from_simplex, epa_penetration, epa_penetration_rot};
24pub use geometry::{AABB, Geometry};
25pub use gjk::{GjkOutcome, gjk_distance, gjk_distance_rot, gjk_rot};
26pub use manifold::{
27    MAX_MANIFOLD_POINTS, Manifold, ManifoldPoint, contact_manifold, contact_manifold_within,
28};
29pub use raycast::{Ray, RayHit, ray_cast, ray_intersect};
30
31use phyz_math::Vec3;
32use phyz_model::Attachment;
33
34/// Result of a collision query between two geometries.
35#[derive(Debug, Clone)]
36pub struct Collision {
37    /// Index of first body involved.
38    pub body_i: usize,
39    /// Index of the second body involved, or [`Collision::WORLD`] for the
40    /// static world (ground, terrain, any immovable half-space).
41    ///
42    /// This is an **in-band sentinel**, kept for the moment because the whole
43    /// contact pipeline is written against it. Prefer
44    /// [`Collision::attachment_j`] over comparing to `WORLD` by hand: it
45    /// returns a [`phyz_model::Attachment`], which makes the world a distinct
46    /// variant instead of a reserved index, and is what the Jacobian and
47    /// constraint APIs take. The hazard the typed form removes is that
48    /// `usize::MAX` looks like an ordinary index, so a stale one is silently
49    /// reinterpreted as "the world" and the contact quietly stops pushing back
50    /// on one side.
51    pub body_j: usize,
52    /// Contact point in world frame.
53    pub contact_point: Vec3,
54    /// Contact normal: **the direction `body_i` must move to separate from
55    /// `body_j`**, i.e. it points from `j` toward `i`.
56    ///
57    /// Stated this way because the two producers used to disagree and only one
58    /// of them was ever exercised. `find_ground_contacts` emits `+z` for a
59    /// body resting on the ground — the separating direction for that body,
60    /// and the sense the contact solver is built around, since its
61    /// non-penetration row is `J·v ≥ 0` with `J = J_i − J_j`.
62    /// `find_contacts` used to pass [`Manifold::normal`] straight through, and
63    /// that points from shape *a* toward shape *b* — the exact opposite. The
64    /// constraint therefore measured approach instead of separation and the
65    /// solver drove overlapping bodies *together*: a box dropped on a box sank
66    /// clean through it to full overlap, with four contacts detected the whole
67    /// way down. Invisible until bodies other than the feet had collision
68    /// geometry, because before that no body-body contact ever occurred.
69    pub contact_normal: Vec3,
70    /// Penetration depth (positive = penetrating).
71    pub penetration_depth: f64,
72}
73
74impl Collision {
75    /// The value of [`Collision::body_j`] that means "the static world".
76    ///
77    /// Named so the magic number appears once, here, instead of being spelled
78    /// `usize::MAX` at every comparison site — where it reads as a bounds
79    /// check rather than as a semantic test and is easy to get backwards.
80    pub const WORLD: usize = usize::MAX;
81
82    /// [`Collision::body_j`] as a typed [`Attachment`].
83    ///
84    /// This is the accessor to reach for: it turns the sentinel into a variant
85    /// at the one place that knows the convention, so consumers never compare
86    /// an index against `usize::MAX` themselves.
87    #[inline]
88    pub fn attachment_j(&self) -> Attachment {
89        if self.body_j == Self::WORLD {
90            Attachment::World
91        } else {
92            Attachment::Body(self.body_j)
93        }
94    }
95
96    /// Whether the second side of this contact is the static world.
97    #[inline]
98    pub fn is_world_j(&self) -> bool {
99        self.body_j == Self::WORLD
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use geometry::Geometry;
107    use phyz_math::Vec3;
108
109    #[test]
110    fn test_sphere_sphere_separated() {
111        let s1 = Geometry::Sphere { radius: 1.0 };
112        let s2 = Geometry::Sphere { radius: 1.0 };
113        let p1 = Vec3::zeros();
114        let p2 = Vec3::new(3.0, 0.0, 0.0);
115
116        let dist = gjk_distance(&s1, &s2, &p1, &p2);
117        assert!(
118            (dist - 1.0).abs() < 1e-6,
119            "Expected distance ~1.0, got {}",
120            dist
121        );
122    }
123
124    #[test]
125    fn test_sphere_sphere_touching() {
126        let s1 = Geometry::Sphere { radius: 1.0 };
127        let s2 = Geometry::Sphere { radius: 1.0 };
128        let p1 = Vec3::zeros();
129        let p2 = Vec3::new(2.0, 0.0, 0.0);
130
131        let dist = gjk_distance(&s1, &s2, &p1, &p2);
132        assert!(dist.abs() < 1e-6, "Expected distance ~0.0, got {}", dist);
133    }
134
135    #[test]
136    fn test_sphere_sphere_penetrating() {
137        let s1 = Geometry::Sphere { radius: 1.0 };
138        let s2 = Geometry::Sphere { radius: 1.0 };
139        let p1 = Vec3::zeros();
140        let p2 = Vec3::new(1.5, 0.0, 0.0);
141
142        let dist = gjk_distance(&s1, &s2, &p1, &p2);
143        // GJK returns -1.0 for penetration (approximate), EPA gives exact depth
144        assert!(
145            dist <= 0.0,
146            "Expected non-positive distance (penetration), got {}",
147            dist
148        );
149
150        if let Some(pen) = epa_penetration(&s1, &s2, &p1, &p2) {
151            // Penetration depth should be approximately 0.5
152            // (1.5 distance between centers, 2.0 combined radii => 0.5 overlap)
153            assert!(
154                (pen.0 - 0.5).abs() < 0.1,
155                "Expected penetration ~0.5, got {}",
156                pen.0
157            );
158        }
159    }
160}
161
162#[cfg(test)]
163mod attachment_tests {
164    use super::*;
165
166    fn collision_with(body_j: usize) -> Collision {
167        Collision {
168            body_i: 0,
169            body_j,
170            contact_point: Vec3::zeros(),
171            contact_normal: Vec3::z(),
172            penetration_depth: 0.0,
173        }
174    }
175
176    /// The sentinel maps to the typed variant, and an ordinary index does not.
177    #[test]
178    fn attachment_j_translates_the_sentinel() {
179        assert_eq!(
180            collision_with(Collision::WORLD).attachment_j(),
181            Attachment::World
182        );
183        assert!(collision_with(Collision::WORLD).is_world_j());
184
185        assert_eq!(collision_with(3).attachment_j(), Attachment::Body(3));
186        assert!(!collision_with(3).is_world_j());
187    }
188
189    /// `Collision::WORLD` is the value the pipeline has always used. If this
190    /// ever changes, every `usize::MAX` still spelled out by hand elsewhere in
191    /// the workspace silently stops matching, so pin it.
192    #[test]
193    fn world_sentinel_is_usize_max() {
194        assert_eq!(Collision::WORLD, usize::MAX);
195    }
196}