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>>,
158{
159    #[inline]
160    fn intersects_at(&self, other: &Capsule<N>, v_ij: &Cartesian<N>, o_ij: &R) -> bool {
161        // Adapted from Real Time Collision Detection, D. Eberly, pp 148
162        // Note we ignore fallbacks when the capsule length is small, as these
163        // could be valid inputs. If we somehow underflow to NaN, a (possibly spurious)
164        // overlap will be detected.
165
166        let d1 = axis_aligned_cartesian::<N>(self.height.get());
167        let p1 = d1 * -0.5;
168
169        let d2 = o_ij.rotate(&axis_aligned_cartesian(other.height.get()));
170        let p2 = *v_ij - d2 * 0.5;
171
172        let distance_between_centers = p1 - p2;
173
174        let d1_norm_sq = d1.dot(&d1);
175        let d2_norm_sq = d2.dot(&d2);
176        let f = d2.dot(&distance_between_centers);
177
178        let c = d1.dot(&distance_between_centers);
179
180        // The general nondegenerate case - very small capsules are valid.
181        let d1_dot_d2 = d1.dot(&d2);
182        let denom = d1_norm_sq * d2_norm_sq - d1_dot_d2 * d1_dot_d2;
183
184        // If segments not parallel, compute closest point on L1 to L2 and
185        // clamp to segment S1. Else pick arbitrary s (here 0)
186        let s = if denom == 0.0 {
187            0.0
188        } else {
189            ((d1_dot_d2 * f - c * d2_norm_sq) / denom).clamp(0.0, 1.0)
190        };
191
192        // Compute point on L2 closest to S1(s) using
193        // t = Dot((P1 + D1*s) - P2,D2) / Dot(D2,D2) = (b*s + f) / e
194        let tnom = d1_dot_d2 * s + f;
195        let (t, s) = if tnom < 0.0 {
196            (0.0, (-c / d1_norm_sq).clamp(0.0, 1.0))
197        } else if tnom > d2_norm_sq {
198            (1.0, ((d1_dot_d2 - c) / d1_norm_sq).clamp(0.0, 1.0))
199        } else {
200            (tnom / d2_norm_sq, s)
201        };
202
203        let (c1, c2) = (p1 + d1 * s, p2 + d2 * t);
204        let dist_sq = (c1 - c2).norm_squared();
205
206        let total_radius = self.radius.get() + other.radius.get();
207        dist_sq <= total_radius.powi(2)
208    }
209}
210#[cfg(test)]
211mod tests {
212
213    use crate::{
214        Convex, IntersectsAt,
215        shape::{Circle, Cylinder, Hypersphere},
216    };
217    use hoomd_vector::{Angle, Versor};
218    use rand::{RngExt, SeedableRng};
219
220    use super::*;
221    use approxim::assert_relative_eq;
222    use rstest::*;
223    use std::f64::consts::PI;
224
225    #[rstest(
226        radius => [1e-6, 1.0, 34.56],
227        height => [1e-6, 1.0, 34.56],
228    )]
229    fn test_elongated_capsule_volume(radius: f64, height: f64) {
230        let capsule = Capsule::<3> {
231            radius: radius.try_into().expect("test value is a positive real"),
232            height: height.try_into().expect("test value is a positive real"),
233        };
234        assert_relative_eq!(
235            capsule.volume(),
236            Hypersphere::<3> {
237                radius: radius.try_into().expect("test value is a positive real")
238            }
239            .volume()
240                + Cylinder {
241                    radius: radius.try_into().expect("test value is a positive real"),
242                    height: capsule.height
243                }
244                .volume()
245        );
246
247        assert_relative_eq!(
248            capsule.bounding_sphere_radius().get(),
249            radius + height / 2.0
250        );
251    }
252
253    #[test]
254    fn intersect_xenocollide_2d() {
255        let capsule_tall = Convex(Capsule::<2> {
256            radius: 0.5.try_into().expect("test value is a positive real"),
257            height: 6.0.try_into().expect("test value is a positive real"),
258        });
259
260        let circle = Convex(Circle::with_radius(
261            0.5.try_into().expect("test value is a positive real"),
262        ));
263
264        let identity = Angle::default();
265        let rotate = Angle::from(PI / 2.0);
266
267        assert!(!capsule_tall.intersects_at(&circle, &[0.0, 4.1].into(), &identity));
268        assert!(capsule_tall.intersects_at(&circle, &[0.0, 3.9].into(), &identity));
269        assert!(!circle.intersects_at(&capsule_tall, &[0.0, 4.1].into(), &identity));
270        assert!(circle.intersects_at(&capsule_tall, &[0.0, 3.9].into(), &identity));
271        assert!(!circle.intersects_at(&capsule_tall, &[4.1, 0.0].into(), &rotate));
272        assert!(circle.intersects_at(&capsule_tall, &[3.9, 0.0].into(), &rotate));
273
274        assert!(capsule_tall.intersects_at(&capsule_tall, &[0.2, -0.4].into(), &rotate));
275        assert!(capsule_tall.intersects_at(&capsule_tall, &[3.9, 2.0].into(), &rotate));
276        assert!(!capsule_tall.intersects_at(&capsule_tall, &[4.1, -2.0].into(), &rotate));
277    }
278
279    #[test]
280    fn intersect_xenocollide_3d() {
281        let capsule_tall = Convex(Capsule::<3> {
282            radius: 0.5.try_into().expect("test value is a positive real"),
283            height: 6.0.try_into().expect("test value is a positive real"),
284        });
285
286        let sphere = Convex(Circle::with_radius(
287            0.5.try_into().expect("test value is a positive real"),
288        ));
289
290        let identity = Versor::default();
291        let rotate = Versor::from_axis_angle(
292            [0.0, 1.0, 0.0]
293                .try_into()
294                .expect("hard-coded vector is non-zero"),
295            PI / 2.0,
296        );
297
298        assert!(!capsule_tall.intersects_at(&sphere, &[0.0, 0.0, 4.1].into(), &identity));
299        assert!(capsule_tall.intersects_at(&sphere, &[0.0, 0.0, 3.9].into(), &identity));
300        assert!(!sphere.intersects_at(&capsule_tall, &[0.0, 0.0, 4.1].into(), &identity));
301        assert!(sphere.intersects_at(&capsule_tall, &[0.0, 0.0, 3.9].into(), &identity));
302        assert!(!sphere.intersects_at(&capsule_tall, &[4.1, 0.0, 0.0].into(), &rotate));
303        assert!(sphere.intersects_at(&capsule_tall, &[3.9, 0.0, 0.0].into(), &rotate));
304
305        assert!(capsule_tall.intersects_at(&capsule_tall, &[0.2, -0.4, 0.0].into(), &rotate));
306        assert!(capsule_tall.intersects_at(&capsule_tall, &[3.9, 0.0, 2.0].into(), &rotate));
307        assert!(!capsule_tall.intersects_at(&capsule_tall, &[4.1, 0.0, -2.0].into(), &rotate));
308    }
309
310    #[test]
311    fn support_mapping_2d() {
312        let capsule = Convex(Capsule::<3> {
313            radius: 0.5.try_into().expect("test value is a positive real"),
314            height: 6.0.try_into().expect("test value is a positive real"),
315        });
316
317        // top and bottom
318        assert_relative_eq!(
319            capsule.support_mapping(&[0.0, 0.0, 1.0].into()),
320            [0.0, 0.0, 3.5].into()
321        );
322        assert_relative_eq!(
323            capsule.support_mapping(&[0.0, 0.0, -1.0].into()),
324            [0.0, 0.0, -3.5].into()
325        );
326
327        // the top ring
328        assert_relative_eq!(
329            capsule.support_mapping(&[1.0, 0.0, 1e-12].into()),
330            [0.5, 0.0, 3.0].into(),
331            epsilon = 1e-6
332        );
333        assert_relative_eq!(
334            capsule.support_mapping(&[-1.0, 0.0, 1e-12].into()),
335            [-0.5, 0.0, 3.0].into(),
336            epsilon = 1e-6
337        );
338        assert_relative_eq!(
339            capsule.support_mapping(&[0.0, 1.0, 1e-12].into()),
340            [0.0, 0.5, 3.0].into(),
341            epsilon = 1e-6
342        );
343        assert_relative_eq!(
344            capsule.support_mapping(&[0.0, -1.0, 1e-12].into()),
345            [0.0, -0.5, 3.0].into(),
346            epsilon = 1e-6
347        );
348
349        // the bottom ring
350        assert_relative_eq!(
351            capsule.support_mapping(&[1.0, 0.0, -1e-12].into()),
352            [0.5, 0.0, -3.0].into(),
353            epsilon = 1e-6
354        );
355        assert_relative_eq!(
356            capsule.support_mapping(&[-1.0, 0.0, -1e-12].into()),
357            [-0.5, 0.0, -3.0].into(),
358            epsilon = 1e-6
359        );
360        assert_relative_eq!(
361            capsule.support_mapping(&[0.0, 1.0, -1e-12].into()),
362            [0.0, 0.5, -3.0].into(),
363            epsilon = 1e-6
364        );
365        assert_relative_eq!(
366            capsule.support_mapping(&[0.0, -1.0, -1e-12].into()),
367            [0.0, -0.5, -3.0].into(),
368            epsilon = 1e-6
369        );
370
371        // on the caps is not so easy to test manually...
372    }
373
374    #[rstest]
375    #[case(true, 1.999_999, 0.0, 0.0)]
376    #[case(true, 2.0, 0.0, 0.0)]
377    #[case(false, 2.000_001, 0.0, 0.0)]
378    fn test_intersect_capsule_capsule_2d(
379        #[case] expected: bool,
380        #[case] x: f64,
381        #[case] y: f64,
382        #[case] angle: f64,
383    ) {
384        let capsule1 = Capsule::<2> {
385            radius: 1.0.try_into().unwrap(),
386            height: 2.0.try_into().unwrap(),
387        };
388        let capsule2 = Capsule::<2> {
389            radius: 1.0.try_into().unwrap(),
390            height: 2.0.try_into().unwrap(),
391        };
392
393        let v_ij = [x, y].into();
394        let o_ij = Angle::from(angle);
395        assert_eq!(capsule1.intersects_at(&capsule2, &v_ij, &o_ij), expected);
396        assert_eq!(
397            capsule2.intersects_at(&capsule1, &(-v_ij), &o_ij.inverted()),
398            expected
399        );
400    }
401
402    #[rstest]
403    #[case(true, 0.0, 2.0, 90.0)]
404    #[case(true, 0.0, 3.0, 90.0)]
405    #[case(false, 0.0, 3.000_001, 90.0)]
406    fn test_intersect_capsule_capsule_2d_rotated(
407        #[case] expected: bool,
408        #[case] x: f64,
409        #[case] y: f64,
410        #[case] angle: f64,
411    ) {
412        let capsule1 = Capsule::<2> {
413            radius: 1.0.try_into().unwrap(),
414            height: 2.0.try_into().unwrap(),
415        };
416        let capsule2 = Capsule::<2> {
417            radius: 1.0.try_into().unwrap(),
418            height: 2.0.try_into().unwrap(),
419        };
420
421        let v_ij = [x, y].into();
422        let o_ij = Angle::from(angle.to_radians());
423        assert_eq!(capsule1.intersects_at(&capsule2, &v_ij, &o_ij), expected);
424        assert_eq!(
425            capsule2.intersects_at(&capsule1, &(-v_ij), &o_ij.inverted()),
426            expected
427        );
428    }
429
430    /// Nearly-0 height
431    #[test]
432    fn test_intersect_degenerate_capsules() {
433        let sphere1 = Capsule::<3> {
434            radius: 1.0.try_into().unwrap(),
435            height: 1e-12.try_into().unwrap(),
436        };
437        let sphere2 = Capsule::<3> {
438            radius: 1.0.try_into().unwrap(),
439            height: 1e-12.try_into().unwrap(),
440        };
441
442        let o_ij = Versor::identity();
443        // Intersecting
444        let v_ij = [1.999_999, 0.0, 0.0].into();
445        assert!(sphere1.intersects_at(&sphere2, &v_ij, &o_ij));
446        // Touching
447        let v_ij = [2.0, 0.0, 0.0].into();
448        assert!(sphere1.intersects_at(&sphere2, &v_ij, &o_ij));
449        // Not intersecting
450        let v_ij = [2.000_001, 0.0, 0.0].into();
451        assert!(!sphere1.intersects_at(&sphere2, &v_ij, &o_ij));
452
453        let capsule = Capsule::<3> {
454            radius: 1.0.try_into().unwrap(),
455            height: 2.0.try_into().unwrap(),
456        };
457        // Intersecting
458        let v_ij = [1.999_999, 0.0, 0.0].into();
459        assert!(sphere1.intersects_at(&capsule, &v_ij, &o_ij));
460        assert!(capsule.intersects_at(&sphere1, &v_ij, &o_ij));
461        // Touching
462        let v_ij = [2.0, 0.0, 0.0].into();
463        assert!(sphere1.intersects_at(&capsule, &v_ij, &o_ij));
464        assert!(capsule.intersects_at(&sphere1, &v_ij, &o_ij));
465        // Not intersecting
466        let v_ij = [2.000_001, 0.0, 0.0].into();
467        assert!(!sphere1.intersects_at(&capsule, &v_ij, &o_ij));
468        assert!(!capsule.intersects_at(&sphere1, &v_ij, &o_ij));
469    }
470
471    #[test]
472    fn test_intersect_capsule_capsule_complex_3d_random() -> Result<(), Box<dyn std::error::Error>>
473    {
474        let mut rng = rand::rngs::StdRng::seed_from_u64(0);
475
476        for _ in 0..10_000 {
477            let r1 = rng.random_range(0.1..10.0);
478            let h1 = rng.random_range(0.1..10.0);
479            let r2 = rng.random_range(0.1..10.0);
480            let h2 = rng.random_range(0.1..10.0);
481
482            let capsule1 = Capsule::<3> {
483                radius: r1.try_into()?,
484                height: h1.try_into()?,
485            };
486            let capsule2 = Capsule::<3> {
487                radius: r2.try_into()?,
488                height: h2.try_into()?,
489            };
490
491            let v_ij = (rng.random::<Cartesian<3>>() * 10.0) - Cartesian::from([5.0; 3]);
492
493            let o_ij = rng.random::<Versor>();
494
495            let result_direct = capsule1.intersects_at(&capsule2, &v_ij, &o_ij);
496
497            let result_xeno = Convex(capsule1).intersects_at(&Convex(capsule2), &v_ij, &o_ij);
498            assert_eq!(
499                result_direct, result_xeno,
500                "Failed with r1={r1}, h1={h1}, r2={r2}, h2={h2}, v_ij={v_ij:?}, o_ij={o_ij:?}"
501            );
502        }
503        Ok(())
504    }
505}