Skip to main content

hoomd_geometry/shape/
capsule.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//! Implement [`Capsule`]
5
6use serde::{Deserialize, Serialize};
7
8use super::sphere::sphere_volume_prefactor;
9use crate::{BoundingSphereRadius, IntersectsAt, IntersectsAtGlobal, SupportMapping, Volume};
10use hoomd_utility::valid::PositiveReal;
11use hoomd_vector::{Cartesian, InnerProduct, Rotate, Rotation};
12
13/// All points less than or equal to a distance `r` from a line segment of length `h`.
14///
15/// This line is oriented along the `[0 0 ... 1]` direction, and has extents `+h/2`,
16/// `-h/2` along that axis.
17///
18/// # Examples
19///
20/// Construction and basic methods:
21/// ```
22/// use approxim::assert_relative_eq;
23/// use hoomd_geometry::{BoundingSphereRadius, Volume, shape::Capsule};
24/// use hoomd_vector::Cartesian;
25/// use std::f64::consts::PI;
26///
27/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
28/// let capsule = Capsule::<2> {
29///     radius: 1.0.try_into()?,
30///     height: 8.0.try_into()?,
31/// };
32/// let bounding_radius = capsule.bounding_sphere_radius();
33/// let volume = capsule.volume();
34///
35/// assert_eq!(bounding_radius.get(), 5.0);
36/// assert_relative_eq!(volume, 16.0 + PI);
37/// # Ok(())
38/// # }
39/// ```
40///
41/// Intersection test:
42/// ```
43/// use hoomd_geometry::{Convex, IntersectsAt, shape::Capsule};
44/// use hoomd_vector::{Angle, Cartesian, Rotation};
45/// use std::f64::consts::PI;
46///
47/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
48/// let capsule = Capsule::<2> {
49///     radius: 1.0.try_into()?,
50///     height: 8.0.try_into()?,
51/// };
52///
53/// assert!(capsule.intersects_at(
54///     &capsule,
55///     &[1.75, 0.0].into(),
56///     &Angle::identity()
57/// ));
58/// assert!(!capsule.intersects_at(
59///     &capsule,
60///     &[4.0, 2.0].into(),
61///     &Angle::identity()
62/// ),);
63/// assert!(capsule.intersects_at(
64///     &capsule,
65///     &[4.0, -2.0].into(),
66///     &Angle::from(PI / 2.0)
67/// ));
68/// # Ok(())
69/// # }
70/// ```
71#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
72pub struct Capsule<const N: usize> {
73    /// Radius of points that are considered enclosed in the shape.
74    pub radius: PositiveReal,
75    /// Length of the line segment.
76    pub height: PositiveReal,
77}
78
79/// A sphere swept along a line segment in $`\mathbb{R}^3`$.
80pub type Spherocylinder = Capsule<3>;
81
82impl<const N: usize> SupportMapping<Cartesian<N>> for Capsule<N> {
83    #[inline]
84    fn support_mapping(&self, n: &Cartesian<N>) -> Cartesian<N> {
85        // Same support function as a ConvexPolyhedron with 2 vertices, plus the radius.
86        let mut v_tip = [0.0; N];
87        v_tip[N - 1] = self.height.get() / 2.0;
88        let v_tip = v_tip.into();
89
90        let mut v_base = [0.0; N];
91        v_base[N - 1] = -self.height.get() / 2.0;
92        let v_base = v_base.into();
93
94        let (v_tip_dot_n, v_base_dot_n) = (n.dot(&v_tip), n.dot(&v_base));
95
96        let rshift = *n / n.norm() * self.radius.get();
97        if v_tip_dot_n > v_base_dot_n {
98            v_tip + rshift
99        } else {
100            v_base + rshift
101        }
102    }
103}
104
105impl<const N: usize> BoundingSphereRadius for Capsule<N> {
106    #[inline]
107    fn bounding_sphere_radius(&self) -> PositiveReal {
108        (self.height.get() / 2.0 + self.radius.get())
109            .try_into()
110            .expect("this expression should evaluate to a positive real")
111    }
112}
113
114impl<const N: usize> Volume for Capsule<N> {
115    #[inline]
116    fn volume(&self) -> f64 {
117        if N == 0 {
118            return 0.0;
119        }
120        let r_n_minus_one = self.radius.get().powi(
121            (N - 1)
122                .try_into()
123                .expect("dimension {N}-1 should fit in an i32"),
124        );
125        let cylinder_volume = sphere_volume_prefactor(N - 1) * r_n_minus_one * self.height.get();
126        cylinder_volume + sphere_volume_prefactor(N) * (r_n_minus_one * self.radius.get())
127    }
128}
129
130/// Create a `Cartesian<N>` with zeros except in the final index.
131#[inline]
132fn axis_aligned_cartesian<const N: usize>(h: f64) -> Cartesian<N> {
133    Cartesian::from(std::array::from_fn(|i| if i == (N - 1) { h } else { 0.0 }))
134}
135
136impl<const N: usize, R> IntersectsAtGlobal<Capsule<N>, Cartesian<N>, R> for Capsule<N>
137where
138    R: Rotation + Rotate<Cartesian<N>>,
139{
140    #[inline]
141    fn intersects_at_global(
142        &self,
143        other: &Capsule<N>,
144        r_self: &Cartesian<N>,
145        o_self: &R,
146        r_other: &Cartesian<N>,
147        o_other: &R,
148    ) -> bool {
149        let (v_ij, o_ij) = hoomd_vector::pair_system_to_local(r_self, o_self, r_other, o_other);
150
151        self.intersects_at(other, &v_ij, &o_ij)
152    }
153}
154
155impl<const N: usize, R> IntersectsAt<Capsule<N>, Cartesian<N>, R> for Capsule<N>
156where
157    R: Rotate<Cartesian<N>> + Rotation,
158    Cartesian<N>: From<[f64; N]>,
159{
160    #[inline]
161    fn intersects_at(&self, other: &Capsule<N>, v_ij: &Cartesian<N>, o_ij: &R) -> bool {
162        // Adapted from Real Time Collision Detection, D. Eberly, pp 148
163        // Note we ignore fallbacks when the capsule length is small, as these
164        // could be valid inputs. If we somehow underflow to NaN, a (possibly spurious)
165        // overlap will be detected.
166
167        let d1 = axis_aligned_cartesian::<N>(self.height.get());
168        let p1 = d1 * -0.5;
169
170        let d2 = o_ij.rotate(&axis_aligned_cartesian(other.height.get()));
171        let p2 = *v_ij - d2 * 0.5;
172
173        let distance_between_centers = p1 - p2;
174
175        let d1_norm_sq = d1.dot(&d1);
176        let d2_norm_sq = d2.dot(&d2);
177        let f = d2.dot(&distance_between_centers);
178
179        let c = d1.dot(&distance_between_centers);
180
181        // The general nondegenerate case - very small capsules are valid.
182        let d1_dot_d2 = d1.dot(&d2);
183        let denom = d1_norm_sq * d2_norm_sq - d1_dot_d2 * d1_dot_d2;
184
185        // If segments not parallel, compute closest point on L1 to L2 and
186        // clamp to segment S1. Else pick arbitrary s (here 0)
187        let s = if denom == 0.0 {
188            0.0
189        } else {
190            ((d1_dot_d2 * f - c * d2_norm_sq) / denom).clamp(0.0, 1.0)
191        };
192
193        // Compute point on L2 closest to S1(s) using
194        // t = Dot((P1 + D1*s) - P2,D2) / Dot(D2,D2) = (b*s + f) / e
195        let tnom = d1_dot_d2 * s + f;
196        let (t, s) = if tnom < 0.0 {
197            (0.0, (-c / d1_norm_sq).clamp(0.0, 1.0))
198        } else if tnom > d2_norm_sq {
199            (1.0, ((d1_dot_d2 - c) / d1_norm_sq).clamp(0.0, 1.0))
200        } else {
201            (tnom / d2_norm_sq, s)
202        };
203
204        let (c1, c2) = (p1 + d1 * s, p2 + d2 * t);
205        let dist_sq = (c1 - c2).norm_squared();
206
207        let total_radius = self.radius.get() + other.radius.get();
208        dist_sq <= total_radius.powi(2)
209    }
210}
211#[cfg(test)]
212mod tests {
213
214    use crate::{
215        Convex, IntersectsAt,
216        shape::{Circle, Cylinder, Hypersphere},
217    };
218    use hoomd_vector::{Angle, Versor};
219    use rand::{RngExt, SeedableRng};
220
221    use super::*;
222    use approxim::assert_relative_eq;
223    use rstest::*;
224    use std::f64::consts::PI;
225
226    #[rstest(
227        radius => [1e-6, 1.0, 34.56],
228        height => [1e-6, 1.0, 34.56],
229    )]
230    fn test_elongated_capsule_volume(radius: f64, height: f64) {
231        let capsule = Capsule::<3> {
232            radius: radius.try_into().expect("test value is a positive real"),
233            height: height.try_into().expect("test value is a positive real"),
234        };
235        assert_relative_eq!(
236            capsule.volume(),
237            Hypersphere::<3> {
238                radius: radius.try_into().expect("test value is a positive real")
239            }
240            .volume()
241                + Cylinder {
242                    radius: radius.try_into().expect("test value is a positive real"),
243                    height: capsule.height
244                }
245                .volume()
246        );
247
248        assert_relative_eq!(
249            capsule.bounding_sphere_radius().get(),
250            radius + height / 2.0
251        );
252    }
253
254    #[test]
255    fn intersect_xenocollide_2d() {
256        let capsule_tall = Convex(Capsule::<2> {
257            radius: 0.5.try_into().expect("test value is a positive real"),
258            height: 6.0.try_into().expect("test value is a positive real"),
259        });
260
261        let circle = Convex(Circle::with_radius(
262            0.5.try_into().expect("test value is a positive real"),
263        ));
264
265        let identity = Angle::default();
266        let rotate = Angle::from(PI / 2.0);
267
268        assert!(!capsule_tall.intersects_at(&circle, &[0.0, 4.1].into(), &identity));
269        assert!(capsule_tall.intersects_at(&circle, &[0.0, 3.9].into(), &identity));
270        assert!(!circle.intersects_at(&capsule_tall, &[0.0, 4.1].into(), &identity));
271        assert!(circle.intersects_at(&capsule_tall, &[0.0, 3.9].into(), &identity));
272        assert!(!circle.intersects_at(&capsule_tall, &[4.1, 0.0].into(), &rotate));
273        assert!(circle.intersects_at(&capsule_tall, &[3.9, 0.0].into(), &rotate));
274
275        assert!(capsule_tall.intersects_at(&capsule_tall, &[0.2, -0.4].into(), &rotate));
276        assert!(capsule_tall.intersects_at(&capsule_tall, &[3.9, 2.0].into(), &rotate));
277        assert!(!capsule_tall.intersects_at(&capsule_tall, &[4.1, -2.0].into(), &rotate));
278    }
279
280    #[test]
281    fn intersect_xenocollide_3d() {
282        let capsule_tall = Convex(Capsule::<3> {
283            radius: 0.5.try_into().expect("test value is a positive real"),
284            height: 6.0.try_into().expect("test value is a positive real"),
285        });
286
287        let sphere = Convex(Circle::with_radius(
288            0.5.try_into().expect("test value is a positive real"),
289        ));
290
291        let identity = Versor::default();
292        let rotate = Versor::from_axis_angle(
293            [0.0, 1.0, 0.0]
294                .try_into()
295                .expect("hard-coded vector is non-zero"),
296            PI / 2.0,
297        );
298
299        assert!(!capsule_tall.intersects_at(&sphere, &[0.0, 0.0, 4.1].into(), &identity));
300        assert!(capsule_tall.intersects_at(&sphere, &[0.0, 0.0, 3.9].into(), &identity));
301        assert!(!sphere.intersects_at(&capsule_tall, &[0.0, 0.0, 4.1].into(), &identity));
302        assert!(sphere.intersects_at(&capsule_tall, &[0.0, 0.0, 3.9].into(), &identity));
303        assert!(!sphere.intersects_at(&capsule_tall, &[4.1, 0.0, 0.0].into(), &rotate));
304        assert!(sphere.intersects_at(&capsule_tall, &[3.9, 0.0, 0.0].into(), &rotate));
305
306        assert!(capsule_tall.intersects_at(&capsule_tall, &[0.2, -0.4, 0.0].into(), &rotate));
307        assert!(capsule_tall.intersects_at(&capsule_tall, &[3.9, 0.0, 2.0].into(), &rotate));
308        assert!(!capsule_tall.intersects_at(&capsule_tall, &[4.1, 0.0, -2.0].into(), &rotate));
309    }
310
311    #[test]
312    fn support_mapping_2d() {
313        let capsule = Convex(Capsule::<3> {
314            radius: 0.5.try_into().expect("test value is a positive real"),
315            height: 6.0.try_into().expect("test value is a positive real"),
316        });
317
318        // top and bottom
319        assert_relative_eq!(
320            capsule.support_mapping(&[0.0, 0.0, 1.0].into()),
321            [0.0, 0.0, 3.5].into()
322        );
323        assert_relative_eq!(
324            capsule.support_mapping(&[0.0, 0.0, -1.0].into()),
325            [0.0, 0.0, -3.5].into()
326        );
327
328        // the top ring
329        assert_relative_eq!(
330            capsule.support_mapping(&[1.0, 0.0, 1e-12].into()),
331            [0.5, 0.0, 3.0].into(),
332            epsilon = 1e-6
333        );
334        assert_relative_eq!(
335            capsule.support_mapping(&[-1.0, 0.0, 1e-12].into()),
336            [-0.5, 0.0, 3.0].into(),
337            epsilon = 1e-6
338        );
339        assert_relative_eq!(
340            capsule.support_mapping(&[0.0, 1.0, 1e-12].into()),
341            [0.0, 0.5, 3.0].into(),
342            epsilon = 1e-6
343        );
344        assert_relative_eq!(
345            capsule.support_mapping(&[0.0, -1.0, 1e-12].into()),
346            [0.0, -0.5, 3.0].into(),
347            epsilon = 1e-6
348        );
349
350        // the bottom ring
351        assert_relative_eq!(
352            capsule.support_mapping(&[1.0, 0.0, -1e-12].into()),
353            [0.5, 0.0, -3.0].into(),
354            epsilon = 1e-6
355        );
356        assert_relative_eq!(
357            capsule.support_mapping(&[-1.0, 0.0, -1e-12].into()),
358            [-0.5, 0.0, -3.0].into(),
359            epsilon = 1e-6
360        );
361        assert_relative_eq!(
362            capsule.support_mapping(&[0.0, 1.0, -1e-12].into()),
363            [0.0, 0.5, -3.0].into(),
364            epsilon = 1e-6
365        );
366        assert_relative_eq!(
367            capsule.support_mapping(&[0.0, -1.0, -1e-12].into()),
368            [0.0, -0.5, -3.0].into(),
369            epsilon = 1e-6
370        );
371
372        // on the caps is not so easy to test manually...
373    }
374
375    #[rstest]
376    #[case(true, 1.999_999, 0.0, 0.0)]
377    #[case(true, 2.0, 0.0, 0.0)]
378    #[case(false, 2.000_001, 0.0, 0.0)]
379    fn test_intersect_capsule_capsule_2d(
380        #[case] expected: bool,
381        #[case] x: f64,
382        #[case] y: f64,
383        #[case] angle: f64,
384    ) {
385        let capsule1 = Capsule::<2> {
386            radius: 1.0.try_into().unwrap(),
387            height: 2.0.try_into().unwrap(),
388        };
389        let capsule2 = Capsule::<2> {
390            radius: 1.0.try_into().unwrap(),
391            height: 2.0.try_into().unwrap(),
392        };
393
394        let v_ij = [x, y].into();
395        let o_ij = Angle::from(angle);
396        assert_eq!(capsule1.intersects_at(&capsule2, &v_ij, &o_ij), expected);
397        assert_eq!(
398            capsule2.intersects_at(&capsule1, &(-v_ij), &o_ij.inverted()),
399            expected
400        );
401    }
402
403    #[rstest]
404    #[case(true, 0.0, 2.0, 90.0)]
405    #[case(true, 0.0, 3.0, 90.0)]
406    #[case(false, 0.0, 3.000_001, 90.0)]
407    fn test_intersect_capsule_capsule_2d_rotated(
408        #[case] expected: bool,
409        #[case] x: f64,
410        #[case] y: f64,
411        #[case] angle: f64,
412    ) {
413        let capsule1 = Capsule::<2> {
414            radius: 1.0.try_into().unwrap(),
415            height: 2.0.try_into().unwrap(),
416        };
417        let capsule2 = Capsule::<2> {
418            radius: 1.0.try_into().unwrap(),
419            height: 2.0.try_into().unwrap(),
420        };
421
422        let v_ij = [x, y].into();
423        let o_ij = Angle::from(angle.to_radians());
424        assert_eq!(capsule1.intersects_at(&capsule2, &v_ij, &o_ij), expected);
425        assert_eq!(
426            capsule2.intersects_at(&capsule1, &(-v_ij), &o_ij.inverted()),
427            expected
428        );
429    }
430
431    /// Nearly-0 height
432    #[test]
433    fn test_intersect_degenerate_capsules() {
434        let sphere1 = Capsule::<3> {
435            radius: 1.0.try_into().unwrap(),
436            height: 1e-12.try_into().unwrap(),
437        };
438        let sphere2 = Capsule::<3> {
439            radius: 1.0.try_into().unwrap(),
440            height: 1e-12.try_into().unwrap(),
441        };
442
443        let o_ij = Versor::identity();
444        // Intersecting
445        let v_ij = [1.999_999, 0.0, 0.0].into();
446        assert!(sphere1.intersects_at(&sphere2, &v_ij, &o_ij));
447        // Touching
448        let v_ij = [2.0, 0.0, 0.0].into();
449        assert!(sphere1.intersects_at(&sphere2, &v_ij, &o_ij));
450        // Not intersecting
451        let v_ij = [2.000_001, 0.0, 0.0].into();
452        assert!(!sphere1.intersects_at(&sphere2, &v_ij, &o_ij));
453
454        let capsule = Capsule::<3> {
455            radius: 1.0.try_into().unwrap(),
456            height: 2.0.try_into().unwrap(),
457        };
458        // Intersecting
459        let v_ij = [1.999_999, 0.0, 0.0].into();
460        assert!(sphere1.intersects_at(&capsule, &v_ij, &o_ij));
461        assert!(capsule.intersects_at(&sphere1, &v_ij, &o_ij));
462        // Touching
463        let v_ij = [2.0, 0.0, 0.0].into();
464        assert!(sphere1.intersects_at(&capsule, &v_ij, &o_ij));
465        assert!(capsule.intersects_at(&sphere1, &v_ij, &o_ij));
466        // Not intersecting
467        let v_ij = [2.000_001, 0.0, 0.0].into();
468        assert!(!sphere1.intersects_at(&capsule, &v_ij, &o_ij));
469        assert!(!capsule.intersects_at(&sphere1, &v_ij, &o_ij));
470    }
471
472    #[test]
473    fn test_intersect_capsule_capsule_complex_3d_random() -> Result<(), Box<dyn std::error::Error>>
474    {
475        let mut rng = rand::rngs::StdRng::seed_from_u64(0);
476
477        for _ in 0..10_000 {
478            let r1 = rng.random_range(0.1..10.0);
479            let h1 = rng.random_range(0.1..10.0);
480            let r2 = rng.random_range(0.1..10.0);
481            let h2 = rng.random_range(0.1..10.0);
482
483            let capsule1 = Capsule::<3> {
484                radius: r1.try_into()?,
485                height: h1.try_into()?,
486            };
487            let capsule2 = Capsule::<3> {
488                radius: r2.try_into()?,
489                height: h2.try_into()?,
490            };
491
492            let v_ij = (rng.random::<Cartesian<3>>() * 10.0) - Cartesian::from([5.0; 3]);
493
494            let o_ij = rng.random::<Versor>();
495
496            let result_direct = capsule1.intersects_at(&capsule2, &v_ij, &o_ij);
497
498            let result_xeno = Convex(capsule1).intersects_at(&Convex(capsule2), &v_ij, &o_ij);
499            assert_eq!(
500                result_direct, result_xeno,
501                "Failed with r1={r1}, h1={h1}, r2={r2}, h2={h2}, v_ij={v_ij:?}, o_ij={o_ij:?}"
502            );
503        }
504        Ok(())
505    }
506}