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}