Skip to main content

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, RotationMatrix};
36
37/// Maximum allowed iterations for Xenocollide portal refinement.
38const XENOCOLLIDE_MAX_ITER: usize = 1024;
39
40/// Result of portal discovery.
41enum Discovery<const N: usize> {
42    /// Collision result already determined during discovery.
43    Known(bool),
44    /// Portal discovered — proceed to refinement.
45    Found([Cartesian<N>; N]),
46}
47
48/// Dimension-specific operations for Minkowski Portal Refinement.
49///
50/// This trait encapsulates the operations that differ between MPR in various dimensions
51/// - Tolerance for convergence check
52/// - Portal discovery (can be done directly in 2D, requires search in 3D and up)
53/// - Portal vertex replacement logic
54trait MinkowskiPortalRefinement<const N: usize> {
55    /// Dimension-specific convergence tolerance.
56    const TOLERANCE: f64;
57
58    /// Compute the outward-facing normal to the portal (facing toward the surface of the minkowski difference)
59    fn outward_normal(portal: &[Cartesian<N>; N], interior: &Cartesian<N>) -> Cartesian<N>;
60
61    /// Discover the initial portal for MPR refinement.
62    ///
63    /// The initial portal will be an (N-1)-simplex, through which the ray `-v0` must
64    /// pass on its way to the origin.
65    fn discover_portal<A, B>(s: &MinkowskiDifference<N, A, B>, v0: &Cartesian<N>) -> Discovery<N>
66    where
67        A: SupportMapping<Cartesian<N>>,
68        B: SupportMapping<Cartesian<N>>;
69
70    /// Check whether the portal has reached the surface of the Minkowski
71    /// difference within numerical precision (in which case we can determine overlap).
72    ///
73    /// Returns `Some(result)` if convergence is detected, or `None` if
74    /// refinement can continue.
75    fn tolerance_check(
76        portal: &[Cartesian<N>; N],
77        v_new: &Cartesian<N>,
78        normal: &Cartesian<N>,
79    ) -> Option<bool>;
80
81    /// Narrow the portal by replacing one vertex with a new support point.
82    ///
83    /// The portal vertices and `v_new` form an N-simplex whose interior face
84    /// is the current portal. The origin ray enters through this face and must
85    /// exit through one of the outer faces. This method identifies which outer
86    /// face the ray exits through and replaces the portal vertex opposite that
87    /// face with `v_new`, ensuring that the origin ray always passes through the portal
88    fn narrow_portal(interior: &Cartesian<N>, portal: &mut [Cartesian<N>; N], v_new: Cartesian<N>);
89}
90
91impl MinkowskiPortalRefinement<2> for Cartesian<2> {
92    const TOLERANCE: f64 = 1e-16;
93
94    #[inline]
95    fn outward_normal(portal: &[Cartesian<2>; 2], interior: &Cartesian<2>) -> Cartesian<2> {
96        let mut n = (portal[1] - portal[0]).perpendicular();
97        if (portal[0] - *interior).dot(&n) < 0.0 {
98            n = -n;
99        }
100        n
101    }
102
103    #[inline]
104    fn discover_portal<A: SupportMapping<Cartesian<2>>, B: SupportMapping<Cartesian<2>>>(
105        s: &MinkowskiDifference<2, A, B>,
106        v0: &Cartesian<2>,
107    ) -> Discovery<2> {
108        // Find the support point in the direction of the origin ray
109        let v1 = s.composite_support_mapping(-*v0);
110
111        // v_perp is on the same side as the origin if v1.dot(v_perp) < 0
112        let mut v_perp_v1v0 = (v1 - *v0).perpendicular();
113        if v1.dot(&v_perp_v1v0) > 0.0 {
114            v_perp_v1v0 = -v_perp_v1v0;
115        }
116
117        // Support point perpendicular to plane containing the origin, v0, and v1
118        let v2 = s.composite_support_mapping(v_perp_v1v0);
119
120        // NOTE: this assumes the origin is within the shape. This assumption matches
121        // HOOMD-Blue, but is important to note regardless.
122
123        Discovery::Found([v1, v2])
124    }
125
126    #[inline]
127    fn tolerance_check(
128        portal: &[Cartesian<2>; 2],
129        v_new: &Cartesian<2>,
130        _normal: &Cartesian<2>,
131    ) -> Option<bool> {
132        // In 2D, we either find a valid vertex or require further search to be sure
133        let d = (*v_new - portal[0]) - (*v_new - portal[0]).project(&(portal[1] - portal[0]));
134        if d.norm_squared() < Self::TOLERANCE * v_new.norm_squared() {
135            return Some(true);
136        }
137        None
138    }
139
140    #[inline]
141    fn narrow_portal(interior: &Cartesian<2>, portal: &mut [Cartesian<2>; 2], v_new: Cartesian<2>) {
142        let mut v_perp = (v_new - *interior).perpendicular();
143        // Orient toward portal[0]
144        if (portal[0] - v_new).dot(&v_perp) < 0.0 {
145            v_perp = -v_perp;
146        }
147        if v_new.dot(&v_perp) < 0.0 {
148            // Origin is on the portal[0] side — replace portal[1]
149            portal[1] = v_new;
150        } else {
151            // Origin is on the portal[1] side — replace portal[0]
152            portal[0] = v_new;
153        }
154    }
155}
156
157impl MinkowskiPortalRefinement<3> for Cartesian<3> {
158    const TOLERANCE: f64 = 2e-12;
159
160    #[inline]
161    fn outward_normal(portal: &[Cartesian<3>; 3], interior: &Cartesian<3>) -> Cartesian<3> {
162        let e1 = portal[1] - portal[0];
163        let e2 = portal[2] - portal[0];
164        let mut n = e1.cross(&e2);
165        if (portal[0] - *interior).dot(&n) < 0.0 {
166            n = -n;
167        }
168        n
169    }
170
171    #[inline]
172    fn discover_portal<A: SupportMapping<Cartesian<3>>, B: SupportMapping<Cartesian<3>>>(
173        s: &MinkowskiDifference<3, A, B>,
174        v0: &Cartesian<3>,
175    ) -> Discovery<3> {
176        // Interior point at origin implies overlap
177        if v0.into_iter().all(|x| x.abs() < Self::TOLERANCE) {
178            return Discovery::Known(true);
179        }
180        // Support point in the direction of the origin ray
181        let mut v1 = s.composite_support_mapping(-*v0);
182
183        // Equivalent to v1 . (v1-v0) <= 0 by convexity
184        if v1.dot(v0) > 0.0 {
185            return Discovery::Known(false);
186        }
187
188        // Direction perpendicular to v0, v1 plane
189        let n = v1.cross(v0);
190
191        // Cross product is zero if v0,v1 collinear with origin, but we have already
192        // determined the origin is within the v1 support plane.
193        // If the origin is on a line between v1 and v0, particles overlap.
194        if n.into_iter().all(|x| x.abs() < Self::TOLERANCE) {
195            return Discovery::Known(true);
196        }
197
198        // Support point perpendicular to plane containing the origin, v0, and v1
199        let mut v2 = s.composite_support_mapping(n);
200
201        if v2.dot(&n) < 0.0 {
202            return Discovery::Known(false);
203        }
204
205        // Support point perpendicular to plane containing interior point and first 2 supports
206        let mut n = (v1 - *v0).cross(&(v2 - *v0));
207        // Maintain known handedness of the portal
208        if n.dot(v0) > 0.0 {
209            (v1, v2) = (v2, v1);
210            n = -n;
211        }
212
213        // while origin_ray_does_not_intersect_candidate()
214        let mut count = 0_usize;
215        let v3 = loop {
216            count += 1;
217
218            if count >= XENOCOLLIDE_MAX_ITER {
219                return Discovery::Known(true);
220            }
221
222            let v3 = s.composite_support_mapping(n);
223            if v3.dot(&n) <= 0.0 {
224                return Discovery::Known(false);
225            }
226
227            // If origin lies on the opposite side of the plane from our third support
228            // point, use the outer facing plane normal.
229            // Check the v3, v0, v1 plane for validity
230            if v1.cross(&v3).dot(v0) < 0.0 {
231                v2 = v3; // Preserve handedness
232                n = (v1 - *v0).cross(&(v2 - *v0));
233                continue;
234            }
235            if v3.cross(&v2).dot(v0) < 0.0 {
236                v1 = v3; // Preserve handedness
237                n = (v1 - *v0).cross(&(v2 - *v0));
238                continue;
239            }
240            break v3;
241        };
242
243        Discovery::Found([v1, v2, v3])
244    }
245
246    #[inline]
247    fn tolerance_check(
248        portal: &[Cartesian<3>; 3],
249        v_new: &Cartesian<3>,
250        normal: &Cartesian<3>,
251    ) -> Option<bool> {
252        let tolerance = Self::TOLERANCE * normal.norm(); // Handle non-unit shapes
253
254        // Check if v_new is on the portal plane: if so, no more refinement is possible
255        let d = (*v_new - portal[0]).dot(normal);
256        if d.abs() < tolerance {
257            return Some(false);
258        }
259        // Check if origin is on the portal plane: if so, intersection detected
260        let d = portal[0].dot(normal);
261        if d.abs() < tolerance {
262            return Some(true);
263        }
264        None
265    }
266
267    #[inline]
268    fn narrow_portal(interior: &Cartesian<3>, portal: &mut [Cartesian<3>; 3], v_new: Cartesian<3>) {
269        let [v1, v2, v3] = *portal;
270        // Test origin against the three planes that separate the new portal candidates
271        // using the triple product identities as an optimization:
272        //   (v1 % v4) * v0 == v1 * (v4 % v0) > 0 if origin inside (v1, v4, v0)
273        //   (v2 % v4) * v0 == v2 * (v4 % v0) > 0 if origin inside (v2, v4, v0)
274        //   (v3 % v4) * v0 == v3 * (v4 % v0) > 0 if origin inside (v3, v4, v0)
275        let v_perp = v_new.cross(interior);
276
277        #[expect(
278            clippy::match_same_arms,
279            reason = "Clearly illustrate translation from c."
280        )]
281        match (
282            v_perp.dot(&v1) > 0.0,
283            v_perp.dot(&v2) > 0.0,
284            v_perp.dot(&v3) > 0.0,
285        ) {
286            (true, true, _) => portal[0] = v_new, // Inside  v1 && inside  v2 => eliminate v1
287            (true, false, _) => portal[2] = v_new, // Inside  v1 && OUTside v2 => eliminate v3
288            (false, _, true) => portal[1] = v_new, // OUTside v1 && inside  v3 => eliminate v2
289            (false, _, false) => portal[0] = v_new, // OUTside v1 && OUTside v3 => eliminate v1
290        }
291    }
292}
293
294/// Stateful type that efficiently computes repeated Minkowski differences.
295pub struct MinkowskiDifference<
296    'a,
297    const N: usize,
298    A: SupportMapping<Cartesian<N>>,
299    B: SupportMapping<Cartesian<N>>,
300> {
301    /// Support-function shape A
302    sa: &'a A,
303    /// Support-function shape B
304    sb: &'a B,
305    /// Vector separating A and B
306    v_ij: &'a Cartesian<N>,
307    /// Relative orientation between A and B
308    q_ij: RotationMatrix<N>,
309    /// Inverse of relative orientation between A and B
310    q_ij_inv: RotationMatrix<N>,
311}
312
313impl<'a, const N: usize, A: SupportMapping<Cartesian<N>>, B: SupportMapping<Cartesian<N>>>
314    MinkowskiDifference<'_, N, A, B>
315{
316    /// Compute the support function on the Minkowski difference of two shapes.
317    #[inline]
318    fn composite_support_mapping(&self, n: Cartesian<N>) -> Cartesian<N> {
319        // Support point of b in the direction of vij
320        // 'translation/rotation formula comes from pg 168 of "Games Programming Gems 7"'
321        // Dimension-agnostic formula: r @ sb.support_mapping(r_inverse @ n) + v_ij
322        // Applying rotation takes ~24% of total runtime in collide3d simplex3
323        let sb_n = self
324            .q_ij
325            .rotate(&self.sb.support_mapping(&self.q_ij_inv.rotate(&n)))
326            + *self.v_ij;
327        sb_n - self.sa.support_mapping(&-n) // eq. 2.5.6 in GPG7
328    }
329
330    /// Create a new `MinkowskiDifference`
331    #[inline]
332    fn new<R>(
333        sa: &'a A,
334        sb: &'a B,
335        v_ij: &'a Cartesian<N>,
336        r: R,
337    ) -> MinkowskiDifference<'a, N, A, B>
338    where
339        R: Copy,
340        RotationMatrix<N>: From<R>,
341    {
342        let q_ij = RotationMatrix::<N>::from(r);
343        let q_ij_inv = q_ij.inverted();
344        MinkowskiDifference {
345            sa,
346            sb,
347            v_ij,
348            q_ij,
349            q_ij_inv,
350        }
351    }
352}
353
354/// Detect collision between two convex N-dimensional objects via Minkowski Portal Refinement.
355///
356/// This is the generic implementation underlying [`collide2d`] and [`collide3d`].
357/// Dimension-specific operations (portal discovery, normal computation, tolerance
358/// checks, vertex replacement) are resolved at compile time via the
359/// [`MinkowskiPortalRefinement`] trait.
360#[inline]
361fn collide<const N: usize, R, A, B>(sa: &A, sb: &B, v_ij: &Cartesian<N>, q_ij: &R) -> bool
362where
363    A: SupportMapping<Cartesian<N>>,
364    B: SupportMapping<Cartesian<N>>,
365    R: Copy,
366    RotationMatrix<N>: From<R>,
367    Cartesian<N>: MinkowskiPortalRefinement<N>,
368{
369    let s = MinkowskiDifference::new(sa, sb, v_ij, *q_ij);
370    let v0 = *v_ij;
371
372    // Portal discovery
373    let mut portal = match Cartesian::<N>::discover_portal(&s, &v0) {
374        Discovery::Found(p) => p,
375        Discovery::Known(r) => return r,
376    };
377
378    // Portal refinement
379    // The loop is the same in general dimension, but the outward facing normal function
380    // depends on the (N-1)-ary cross product (perp in 2d, cross in 3d)
381    // See https://ncatlab.org/nlab/show/cross+product#counary for further details on
382    // this operation
383    let mut count = 0_usize;
384    loop {
385        count += 1;
386
387        let normal = Cartesian::<N>::outward_normal(&portal, &v0);
388
389        // Hit test: is the origin enclosed by the portal?
390        if portal[0].dot(&normal) >= 0.0 {
391            return true;
392        }
393
394        // Support query in the direction of the portal normal
395        let v_new = s.composite_support_mapping(normal);
396
397        // Miss test: is the origin outside the support plane?
398        if v_new.dot(&normal) < 0.0 {
399            return false;
400        }
401
402        // Can we numerically distinguish the portal face and the support plane?
403        if let Some(result) = Cartesian::<N>::tolerance_check(&portal, &v_new, &normal) {
404            return result;
405        }
406
407        // Face test and vertex replacement (dimension-specific) TODO
408        Cartesian::<N>::narrow_portal(&v0, &mut portal, v_new);
409
410        if count >= XENOCOLLIDE_MAX_ITER {
411            return true;
412        }
413    }
414}
415
416/// Detect collision between two convex 2D objects via Minkowski Portal Refinement.
417#[inline(never)]
418pub fn collide2d<R: Copy, A: SupportMapping<Cartesian<2>>, B: SupportMapping<Cartesian<2>>>(
419    sa: &A,
420    sb: &B,
421    v_ij: &Cartesian<2>,
422    q_ij: &R,
423) -> bool
424where
425    RotationMatrix<2>: From<R>,
426{
427    collide::<2, R, A, B>(sa, sb, v_ij, q_ij)
428}
429
430/// Detect collision between two convex 3D objects via Minkowski Portal Refinement.
431#[inline(never)]
432pub fn collide3d<R, A, B>(sa: &A, sb: &B, v_ij: &Cartesian<3>, q_ij: &R) -> bool
433where
434    A: SupportMapping<Cartesian<3>>,
435    B: SupportMapping<Cartesian<3>>,
436    R: Copy,
437    RotationMatrix<3>: From<R>,
438{
439    collide::<3, R, A, B>(sa, sb, v_ij, q_ij)
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445    use crate::IntersectsAt;
446    use rstest::*;
447
448    use crate::shape::{Circle, Hypercuboid, Hypersphere};
449    use hoomd_utility::valid::PositiveReal;
450    use hoomd_vector::{Angle, Rotation, Versor};
451
452    #[rstest(
453        v => [[0.1, 0.1], [999.9, 0.0], [0.0, 5.123_f64.next_down()], [0.0, 5.123_000_001]],
454        radius => [0.001, 1.0, 4.123, 99.05],
455        o_ij => [
456            Angle::default(),
457            Angle::from(std::f64::consts::PI / 3.0),
458            Angle::from(1.234)
459        ],
460    )]
461    fn test_discs_collide(v: [f64; 2], radius: f64, o_ij: Angle) {
462        let (s0, s1) = (
463            Hypersphere {
464                radius: 1.0.try_into().expect("test value is a positive real"),
465            },
466            Circle {
467                radius: radius.try_into().expect("test value is a positive real"),
468            },
469        );
470
471        let overlaps = collide2d(&s0, &s1, &v.into(), &o_ij);
472
473        assert_eq!(overlaps, s0.intersects_at(&s1, &Cartesian::from(v), &o_ij));
474    }
475    #[rstest(
476        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]],
477        radius => [0.001, 1.0, 4.123, 99.05],
478        o_ij => [
479            Versor::default(),
480            Versor::from_axis_angle(
481                [1.0, 0.0, 0.0].try_into().unwrap(), std::f64::consts::FRAC_PI_2
482            ),
483            Versor::from_axis_angle([0.0, 1.0, 0.0].try_into().unwrap(), 0.1234)
484        ]
485    )]
486    fn test_spheres_collide(v: [f64; 3], radius: f64, o_ij: Versor) {
487        let (s0, s1) = (
488            Hypersphere {
489                radius: 1.0.try_into().expect("test value is a positive real"),
490            },
491            Hypersphere::<3> {
492                radius: radius.try_into().expect("test value is a positive real"),
493            },
494        );
495        let overlaps = collide3d(&s0, &s1, &v.into(), &o_ij);
496
497        assert_eq!(
498            overlaps,
499            s0.intersects_at(&s1, &Cartesian::from(v), &o_ij),
500            "Xenocollide result did not match standard implementation!"
501        );
502    }
503
504    #[rstest(
505        v => [[0.1, 0.1], [999.9, 0.0], [0.0, 5.123], [0.0, 5.123_000_000_000_001]],
506        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")]]
507    )]
508    fn test_aabrs_collide(v: [f64; 2], rect: [PositiveReal; 2]) {
509        let c0 = Hypercuboid { edge_lengths: rect };
510        let c1 = Hypercuboid {
511            edge_lengths: [1.0.try_into().expect("test value is a positive real"); 2],
512        };
513        let theta = Angle::from(0.0);
514
515        let overlaps = collide2d(&c0, &c1, &v.into(), &theta);
516        assert_eq!(overlaps, c0.intersects_aligned(&c1, &v.into()));
517    }
518    #[rstest(
519        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]],
520        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")]]
521
522    )]
523    fn test_aabbs_collide(v: [f64; 3], aabb: [PositiveReal; 3]) {
524        let c0 = Hypercuboid { edge_lengths: aabb };
525        let c1 = Hypercuboid {
526            edge_lengths: [1.0.try_into().expect("test value is a positive real"); 3],
527        };
528        let theta = Versor::identity();
529
530        let overlaps = collide3d(&c0, &c1, &v.into(), &theta);
531        assert_eq!(overlaps, c0.intersects_aligned(&c1, &v.into()));
532    }
533}