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 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>(
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        R: Copy,
88        RotationMatrix<N>: From<R>,
89    {
90        let q_ij = RotationMatrix::<N>::from(r);
91        let q_ij_inv = q_ij.inverted();
92        MinkowskiDifference {
93            sa,
94            sb,
95            v_ij,
96            q_ij,
97            q_ij_inv,
98        }
99    }
100}
101
102/// Detect collision between two convex 2D objects via Minkowski Portal Refinement.
103#[inline]
104pub fn collide2d<R: Copy, A: SupportMapping<Cartesian<2>>, B: SupportMapping<Cartesian<2>>>(
105    sa: &A,
106    sb: &B,
107    v_ij: &Cartesian<2>,
108    q_ij: &R,
109) -> bool
110where
111    RotationMatrix<2>: From<R>,
112{
113    const TOLERANCE: f64 = 1e-16;
114    let s = MinkowskiDifference::new(sa, sb, v_ij, *q_ij);
115
116    // Phase 1: Portal discovery
117    // Obtain a point lying deep within B⊖A
118    let v0 = *v_ij; // self.centroid()-other.centroid() in extrinsic coords
119
120    // TODO: This is unsafe for types like `ConvexPolytope`. Users can construct
121    // such types where the origin is outside the shape. Option 1: Validate
122    // the vertices when constructing `ConvexPolytope` Option 2: Implement
123    // a `SomePointInside` trait that must always return a point inside the
124    // shape. For `ConvexPolytope` this could be the mean of the vertices.
125    // `ConvexPolytope` makes vertices private, so this point could be
126    // precomputed and result in no performance impact to xenocollide.
127
128    // Find the support point in the direction of the origin ray
129    let mut v1 = s.composite_support_mapping(-v0); // negative, to ensure ||v1|| > 0
130
131    // v_perp is on the same side as the origin if v1.dot(v_perp) < 0
132    let mut v_perp_v1v0 = (v1 - v0).perpendicular();
133    if v1.dot(&v_perp_v1v0) > 0.0 {
134        v_perp_v1v0 = -v_perp_v1v0;
135    }
136
137    // Support point perpendicular to plane containing the origin, v0, and v1
138    let mut v2 = s.composite_support_mapping(v_perp_v1v0);
139
140    // 2. Portal Refinement
141    // Now we have three points which form our portal
142
143    let mut count = 0_usize;
144    loop {
145        count += 1;
146
147        // Vector normal to the portal segment, facing away from the interior point
148        let mut v_perp_v2v1 = (v2 - v1).perpendicular();
149        if (v1 - v0).dot(&v_perp_v2v1) < 0.0 {
150            v_perp_v2v1 = -v_perp_v2v1;
151        }
152
153        // Check if origin is inside or overlapping the initial portal
154        if v1.dot(&v_perp_v2v1) >= 0.0 {
155            // FUTURE: `v1.dot(&v_perp_v2v1) / v_perp_v2v1.norm()` is the approximate overlap distance
156            return true;
157        }
158
159        // Support point in the direction of the portal
160        let v3 = s.composite_support_mapping(v_perp_v2v1);
161
162        // If the origin is outside the support plane, return false (no overlap)
163        if v3.dot(&v_perp_v2v1) < 0.0 {
164            return false;
165        }
166
167        // are we within an epsilon of the surface of the shape? If yes, done (overlap)
168        let d = (v3 - v1) - (v3 - v1).project(&(v2 - v1));
169        if d.norm_squared() < TOLERANCE * v3.norm_squared() {
170            // FUTURE: `d.norm()` is the approximate overlap distance
171            return true;
172        }
173
174        // Choose new portal, which may either be v3v2 or v1v3
175        let mut v_perp_v3v0 = (v3 - v0).perpendicular();
176        // make v_perp_v3v0 point toward v1
177        if (v1 - v3).dot(&v_perp_v3v0) < 0.0 {
178            v_perp_v3v0 = -v_perp_v3v0;
179        }
180        if v3.dot(&v_perp_v3v0) < 0.0 {
181            // Origin is on the v1 side, so new portal is v3v1
182            v2 = v3;
183        } else {
184            // Origin is on the v2 side, so new portal is v3v2
185            v1 = v3;
186        }
187
188        if count >= XENOCOLLIDE_2D_MAX_ITER {
189            // FUTURE: `TOLERANCE` is the approximate overlap distance
190            return true;
191        }
192    }
193}
194
195/// Detect collision between two convex 3D objects via Minkowski Portal Refinement.
196#[inline(never)]
197pub fn collide3d<R, A, B>(
198    sa: &A,
199    sb: &B,
200    v_ij: &Cartesian<3>, // Probably ok to take ownership?
201    q_ij: &R,
202) -> bool
203where
204    A: SupportMapping<Cartesian<3>>,
205    B: SupportMapping<Cartesian<3>>,
206    R: Copy,
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, Rotation, 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, &Cartesian::from(v), &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, &Cartesian::from(v), &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}