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}