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}