hoomd_geometry/
xenocollide.rs

1// Copyright (c) 2024-2026 The Regents of the University of Michigan.
2// Part of hoomd-rs, released under the BSD 3-Clause License.
3
4//! Implementations of the Xenocollide collision detection algorithm.
5//!
6//! [`collide2d`] and [`collide3d`] test for intersections between arbitrary geometries
7//! that implement the [`SupportMapping<Cartesian<2|3>>`](`crate::SupportMapping`) trait.
8//!
9//! # Example
10//!
11//! In general, Xenocollide should be used via the [`IntersectsAt`](`crate::IntersectsAt`)
12//! trait. However, the raw xenocollide methods can be used where needed.
13//! ```
14//! use hoomd_geometry::{IntersectsAt, shape::Circle, xenocollide::collide2d};
15//! use hoomd_vector::Angle;
16//!
17//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
18//! let (s0, s1) = (
19//!     Circle {
20//!         radius: 1.0.try_into()?,
21//!     },
22//!     Circle {
23//!         radius: 2.0.try_into()?,
24//!     },
25//! );
26//! let displacement = [3.0; 2].into();
27//! assert_eq!(
28//!     collide2d(&s0, &s1, &displacement, &Angle::default()),
29//!     s0.intersects_at(&s1, &displacement, &Angle::default())
30//! );
31//! # Ok(())
32//! # }
33//! ```
34use crate::SupportMapping;
35use hoomd_vector::{Cartesian, Cross, InnerProduct, Rotate, Rotation, RotationMatrix};
36
37/// Maximum allowed iterations for Xenocollide in 2D
38const XENOCOLLIDE_2D_MAX_ITER: usize = 1024;
39/// Maximum allowed iterations for Xenocollide in 3D
40const XENOCOLLIDE_3D_MAX_ITER: usize = 1024;
41
42/// Stateful type that efficiently computes repeated Minkowski differences.
43struct MinkowskiDifference<
44    'a,
45    const N: usize,
46    A: SupportMapping<Cartesian<N>>,
47    B: SupportMapping<Cartesian<N>>,
48> {
49    /// Support-function shape A
50    sa: &'a A,
51    /// Support-function shape B
52    sb: &'a B,
53    /// Vector separating A and B
54    v_ij: &'a Cartesian<N>,
55    /// Relative orientation between A and B
56    q_ij: RotationMatrix<N>,
57    /// Inverse of relative orientation between A and B
58    q_ij_inv: RotationMatrix<N>,
59}
60
61impl<'a, const N: usize, A: SupportMapping<Cartesian<N>>, B: SupportMapping<Cartesian<N>>>
62    MinkowskiDifference<'_, N, A, B>
63{
64    /// Compute the support function on the Minkowski difference of two shapes.
65    #[inline]
66    fn composite_support_mapping(&self, n: Cartesian<N>) -> Cartesian<N> {
67        // Support point of b in the direction of vij
68        // 'translation/rotation formula comes from pg 168 of "Games Programming Gems 7"'
69        // Dimension-agnostic formula: r @ sb.support_mapping(r_inverse @ n) + v_ij
70        // Applying rotation takes ~24% of total runtime in collide3d simplex3
71        let sb_n = self
72            .q_ij
73            .rotate(&self.sb.support_mapping(&self.q_ij_inv.rotate(&n)))
74            + *self.v_ij;
75        sb_n - self.sa.support_mapping(&-n) // eq. 2.5.6 in GPG7
76    }
77
78    /// Create a new `MinkowskiDifference`
79    #[inline]
80    fn new<R: Rotation>(
81        sa: &'a A,
82        sb: &'a B,
83        v_ij: &'a Cartesian<N>,
84        r: R,
85    ) -> MinkowskiDifference<'a, N, A, B>
86    where
87        RotationMatrix<N>: From<R>,
88    {
89        let q_ij = RotationMatrix::<N>::from(r);
90        let q_ij_inv = q_ij.inverted();
91        MinkowskiDifference {
92            sa,
93            sb,
94            v_ij,
95            q_ij,
96            q_ij_inv,
97        }
98    }
99}
100
101/// Detect collision between two convex 2D objects via Minkowski Portal Refinement.
102#[inline]
103pub fn collide2d<
104    R: Copy + Rotation,
105    A: SupportMapping<Cartesian<2>>,
106    B: SupportMapping<Cartesian<2>>,
107>(
108    sa: &A,
109    sb: &B,
110    v_ij: &Cartesian<2>,
111    q_ij: &R,
112) -> bool
113where
114    RotationMatrix<2>: From<R>,
115{
116    const TOLERANCE: f64 = 1e-16;
117    let s = MinkowskiDifference::new(sa, sb, v_ij, *q_ij);
118
119    // Phase 1: Portal discovery
120    // Obtain a point lying deep within B⊖A
121    let v0 = *v_ij; // self.centroid()-other.centroid() in extrinsic coords
122
123    // TODO: This is unsafe for types like `ConvexPolytope`. Users can construct
124    // such types where the origin is outside the shape. Option 1: Validate
125    // the vertices when constructing `ConvexPolytope` Option 2: Implement
126    // a `SomePointInside` trait that must always return a point inside the
127    // shape. For `ConvexPolytope` this could be the mean of the vertices.
128    // `ConvexPolytope` makes vertices private, so this point could be
129    // precomputed and result in no performance impact to xenocollide.
130
131    // Find the support point in the direction of the origin ray
132    let mut v1 = s.composite_support_mapping(-v0); // negative, to ensure ||v1|| > 0
133
134    // v_perp is on the same side as the origin if v1.dot(v_perp) < 0
135    let mut v_perp_v1v0 = (v1 - v0).perpendicular();
136    if v1.dot(&v_perp_v1v0) > 0.0 {
137        v_perp_v1v0 = -v_perp_v1v0;
138    }
139
140    // Support point perpendicular to plane containing the origin, v0, and v1
141    let mut v2 = s.composite_support_mapping(v_perp_v1v0);
142
143    // 2. Portal Refinement
144    // Now we have three points which form our portal
145
146    let mut count = 0_usize;
147    loop {
148        count += 1;
149
150        // Vector normal to the portal segment, facing away from the interior point
151        let mut v_perp_v2v1 = (v2 - v1).perpendicular();
152        if (v1 - v0).dot(&v_perp_v2v1) < 0.0 {
153            v_perp_v2v1 = -v_perp_v2v1;
154        }
155
156        // Check if origin is inside or overlapping the initial portal
157        if v1.dot(&v_perp_v2v1) >= 0.0 {
158            // FUTURE: `v1.dot(&v_perp_v2v1) / v_perp_v2v1.norm()` is the approximate overlap distance
159            return true;
160        }
161
162        // Support point in the direction of the portal
163        let v3 = s.composite_support_mapping(v_perp_v2v1);
164
165        // If the origin is outside the support plane, return false (no overlap)
166        if v3.dot(&v_perp_v2v1) < 0.0 {
167            return false;
168        }
169
170        // are we within an epsilon of the surface of the shape? If yes, done (overlap)
171        let d = (v3 - v1) - (v3 - v1).project(&(v2 - v1));
172        if d.norm_squared() < TOLERANCE * v3.norm_squared() {
173            // FUTURE: `d.norm()` is the approximate overlap distance
174            return true;
175        }
176
177        // Choose new portal, which may either be v3v2 or v1v3
178        let mut v_perp_v3v0 = (v3 - v0).perpendicular();
179        // make v_perp_v3v0 point toward v1
180        if (v1 - v3).dot(&v_perp_v3v0) < 0.0 {
181            v_perp_v3v0 = -v_perp_v3v0;
182        }
183        if v3.dot(&v_perp_v3v0) < 0.0 {
184            // Origin is on the v1 side, so new portal is v3v1
185            v2 = v3;
186        } else {
187            // Origin is on the v2 side, so new portal is v3v2
188            v1 = v3;
189        }
190
191        if count >= XENOCOLLIDE_2D_MAX_ITER {
192            // FUTURE: `TOLERANCE` is the approximate overlap distance
193            return true;
194        }
195    }
196}
197
198/// Detect collision between two convex 3D objects via Minkowski Portal Refinement.
199#[inline(never)]
200pub fn collide3d<R: Rotation, A: SupportMapping<Cartesian<3>>, B: SupportMapping<Cartesian<3>>>(
201    sa: &A,
202    sb: &B,
203    v_ij: &Cartesian<3>, // Probably ok to take ownership?
204    q_ij: &R,
205) -> bool
206where
207    RotationMatrix<3>: From<R>,
208{
209    const TOLERANCE: f64 = 2e-12;
210
211    if v_ij.into_iter().all(|x| x.abs() < TOLERANCE) {
212        // Interior point is at the origin => shapes overlap
213        return true;
214    }
215
216    let s = MinkowskiDifference::new(sa, sb, v_ij, *q_ij);
217
218    // Phase 1: Portal discovery
219    // Obtain a point lying deep within B⊖A
220    let v0 = *v_ij; // self.centroid()-other.centroid() in extrinsic coords
221
222    // find_candidate_portal()
223
224    // Support point in the direction of the origin ray
225    let mut v1 = s.composite_support_mapping(-v0); // negative, to ensure ||v1|| > 0
226
227    // Equivalent to v1 . (v1-v0) <= 0 by convexity
228    if v1.dot(&v0) > 0.0 {
229        return false; // Origin is outside the v1 support plane
230    }
231
232    // Direction perpendicular to v0, v1 plane
233    let n = v1.cross(&v0);
234
235    // Cross product is zero if v0,v1 collinear with origin, but we have already
236    // determined origin is within v1 support plane. If origin is on a line between
237    // v1 and v0, particles overlap.
238    if n.into_iter().all(|x| x.abs() < TOLERANCE) {
239        return true;
240    }
241
242    // Support point perpendicular to plane containing the origin, v0, and v1
243    let mut v2 = s.composite_support_mapping(n);
244
245    if v2.dot(&n) < 0.0 {
246        return false; // Origin lies outside the v2 support plane
247    }
248
249    // Support point perpendicular to plane containing interior point and first 2 supports
250    let mut n = (v1 - v0).cross(&(v2 - v0));
251    // Maintain known handedness of the portal
252    if n.dot(&v0) > 0.0 {
253        (v1, v2) = (v2, v1);
254        n = -n;
255    }
256
257    // while origin_ray_does_not_intersect_candidate()
258    let mut count = 0_usize;
259    let mut v3 = loop {
260        count += 1;
261
262        if count >= XENOCOLLIDE_3D_MAX_ITER {
263            return true;
264        }
265
266        let v3 = s.composite_support_mapping(n);
267        if v3.dot(&n) <= 0.0 {
268            return false; // Origin is outside the v3 support plane
269        }
270
271        // If origin lies on the opposite side of the plane from our third support
272        // point, use the outer facing plane normal.
273        // Check the v3, v0, v1 plane for validity
274        if v1.cross(&v3).dot(&v0) < 0.0 {
275            v2 = v3; // Preserve handedness
276            n = (v1 - v0).cross(&(v2 - v0));
277            continue; // Continue iterating to find a valid portal
278        }
279        if v3.cross(&v2).dot(&v0) < 0.0 {
280            v1 = v3; // Preserve handedness
281            n = (v1 - v0).cross(&(v2 - v0));
282            continue;
283        }
284        break v3; // If we've made it this far, we've found a valid portal
285    };
286
287    count = 0;
288    loop {
289        count += 1;
290
291        // Outer-facing normal of the current portal
292        n = (v2 - v1).cross(&(v3 - v1));
293
294        // Check if origin is inside (or overlapping) the portal
295        if n.dot(&v1) >= 0.0 {
296            // We already know that the origin lies within 3 of the faces of our portal
297            // simplex. If it lies within the final face, it lies within B⊖A
298            return true;
299        }
300
301        // Support point in direction of outer-facing normal of portal
302        // This point helps us determine how far outside the portal the origin lies
303        let v4 = s.composite_support_mapping(n);
304
305        // If the origin is outside the support plane, it cannot lie inside B⊖A
306        if n.dot(&v4) < 0.0 {
307            return false;
308        }
309
310        // Are we within an epsilon of the surface of the shape? If yes, done, one way or another.
311        n = (v2 - v1).cross(&(v3 - v1));
312        let mut d = (v4 - v1).dot(&n);
313
314        // Scale the tolerance with the size of the shapes.
315        let tolerance = TOLERANCE * n.norm();
316
317        // First, check if v4 is on plane (v2, v1, v3)
318        if d.abs() < tolerance {
319            // No more refinement possible, but not intersection detected
320            return false;
321        }
322        // Second, check if origin is on plane (v2, v1, v3) and has been missed by other checks
323        d = v1.dot(&n);
324        if d.abs() < tolerance {
325            return true;
326        }
327
328        // Choose a new portal. Two of its edges will be from the planes (v4,v0,v1),
329        // (v4,v0,v2), (v4,v0,v3). Find which two have the origin on the same side.
330
331        /* Test origin against the three planes that separate the new portal candidates
332        Note:  We're taking advantage of the triple product identities here
333        as an optimization
334               (v1 % v4) * v0 == v1 * (v4 % v0) > 0 if origin inside (v1, v4, v0)
335               (v2 % v4) * v0 == v2 * (v4 % v0) > 0 if origin inside (v2, v4, v0)
336               (v3 % v4) * v0 == v3 * (v4 % v0) > 0 if origin inside (v3, v4, v0)
337        */
338        let v_perp_v4v0 = v4.cross(&v0);
339
340        // Compiles to the same code as the original if-else, despite the extra dot
341        #[expect(
342            clippy::match_same_arms,
343            reason = "Clearly illustrate translation from c."
344        )]
345        match (
346            v_perp_v4v0.dot(&v1) > 0.0,
347            v_perp_v4v0.dot(&v2) > 0.0,
348            v_perp_v4v0.dot(&v3) > 0.0,
349        ) {
350            (true, true, _) => v1 = v4,   // Inside  v1 && inside  v2 => eliminate v1
351            (true, false, _) => v3 = v4,  // Inside  v1 && OUTside v2 => eliminate v3
352            (false, _, true) => v2 = v4,  // OUTside v1 && inside  v3 => eliminate v2
353            (false, _, false) => v1 = v4, // OUTside v1 && OUTside v3 => eliminate v1
354        }
355        if count >= XENOCOLLIDE_3D_MAX_ITER {
356            return true;
357        }
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364    use crate::IntersectsAt;
365    use rstest::*;
366
367    use crate::shape::{Circle, Hypercuboid, Hypersphere};
368    use hoomd_utility::valid::PositiveReal;
369    use hoomd_vector::{Angle, Versor};
370
371    #[rstest(
372        v => [[0.1, 0.1], [999.9, 0.0], [0.0, 5.123_f64.next_down()], [0.0, 5.123_000_001]],
373        radius => [0.001, 1.0, 4.123, 99.05],
374        o_ij => [
375            Angle::default(),
376            Angle::from(std::f64::consts::PI / 3.0),
377            Angle::from(1.234)
378        ],
379    )]
380    fn test_discs_collide(v: [f64; 2], radius: f64, o_ij: Angle) {
381        let (s0, s1) = (
382            Hypersphere {
383                radius: 1.0.try_into().expect("test value is a positive real"),
384            },
385            Circle {
386                radius: radius.try_into().expect("test value is a positive real"),
387            },
388        );
389
390        let overlaps = collide2d(&s0, &s1, &v.into(), &o_ij);
391
392        assert_eq!(overlaps, s0.intersects_at(&s1, &v.into(), &o_ij));
393    }
394    #[rstest(
395        v => [[0.1, 0.1, 0.1], [999.9, 0.0, -10.9], [0.0, 5.123, 0.0], [0.0, 0.0, 5.123_000_001]],
396        radius => [0.001, 1.0, 4.123, 99.05],
397        o_ij => [
398            Versor::default(),
399            Versor::from_axis_angle(
400                [1.0, 0.0, 0.0].try_into().unwrap(), std::f64::consts::FRAC_PI_2
401            ),
402            Versor::from_axis_angle([0.0, 1.0, 0.0].try_into().unwrap(), 0.1234)
403        ]
404    )]
405    fn test_spheres_collide(v: [f64; 3], radius: f64, o_ij: Versor) {
406        let (s0, s1) = (
407            Hypersphere {
408                radius: 1.0.try_into().expect("test value is a positive real"),
409            },
410            Hypersphere::<3> {
411                radius: radius.try_into().expect("test value is a positive real"),
412            },
413        );
414        let overlaps = collide3d(&s0, &s1, &v.into(), &o_ij);
415
416        assert_eq!(
417            overlaps,
418            s0.intersects_at(&s1, &v.into(), &o_ij),
419            "Xenocollide result did not match standard implementation!"
420        );
421    }
422
423    #[rstest(
424        v => [[0.1, 0.1], [999.9, 0.0], [0.0, 5.123], [0.0, 5.123_000_000_000_001]],
425        rect => [[1.0.try_into().expect("test value is a positive real"), 1.0.try_into().expect("test value is a positive real")], [999.0.try_into().expect("test value is a positive real"), 0.1.try_into().expect("test value is a positive real")], [1.0.try_into().expect("test value is a positive real"), (2.0*4.623).try_into().expect("test value is a positive real")]]
426    )]
427    fn test_aabrs_collide(v: [f64; 2], rect: [PositiveReal; 2]) {
428        let c0 = Hypercuboid { edge_lengths: rect };
429        let c1 = Hypercuboid {
430            edge_lengths: [1.0.try_into().expect("test value is a positive real"); 2],
431        };
432        let theta = Angle::from(0.0);
433
434        let overlaps = collide2d(&c0, &c1, &v.into(), &theta);
435        assert_eq!(overlaps, c0.intersects_aligned(&c1, &v.into()));
436    }
437    #[rstest(
438        v => [[0.1, 2.1, 0.1], [999.9, 0.0, 0.05], [0.0, 5.123, 0.0], [0.0, 5.123_000_000_001, 0.0]],
439        aabb => [[1.0.try_into().expect("test value is a positive real"), 1.0.try_into().expect("test value is a positive real"), 1.0.try_into().expect("test value is a positive real")], [999.0.try_into().expect("test value is a positive real"), 0.1.try_into().expect("test value is a positive real"), 0.5.try_into().expect("test value is a positive real")], [1.0.try_into().expect("test value is a positive real"), (2.0*4.623).try_into().expect("test value is a positive real"), 5.0.try_into().expect("test value is a positive real")]]
440
441    )]
442    fn test_aabbs_collide(v: [f64; 3], aabb: [PositiveReal; 3]) {
443        let c0 = Hypercuboid { edge_lengths: aabb };
444        let c1 = Hypercuboid {
445            edge_lengths: [1.0.try_into().expect("test value is a positive real"); 3],
446        };
447        let theta = Versor::identity();
448
449        let overlaps = collide3d(&c0, &c1, &v.into(), &theta);
450        assert_eq!(overlaps, c0.intersects_aligned(&c1, &v.into()));
451    }
452}