Skip to main content

symtropy_math/
capsule.rs

1// Copyright (C) 2024-2026 Tristan Stoltz / Luminous Dynamics
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3// Commercial licensing: see COMMERCIAL_LICENSE.md at repository root
4//! D-dimensional capsule (cylinder with hemispherical caps).
5//!
6//! A capsule is the Minkowski sum of a line segment and a sphere. This makes
7//! it ideal for character controllers, robot limbs, and elongated bodies.
8//! The support function is O(1) — no iteration over vertices.
9
10use crate::point::Point;
11use crate::shape::Shape;
12use nalgebra::SVector;
13
14/// D-dimensional capsule: a line segment of length `2 * half_height` along
15/// `axis`, swept by a sphere of `radius`.
16///
17/// In local space, the two hemisphere centers are at:
18/// - `+half_height * e_axis`
19/// - `-half_height * e_axis`
20///
21/// where `e_axis` is the unit vector along the chosen axis.
22#[derive(Clone, Copy, Debug)]
23pub struct Capsule<const D: usize> {
24    /// Half the distance between hemisphere centers.
25    pub half_height: f64,
26    /// Radius of the hemispherical caps (and the cylinder).
27    pub radius: f64,
28    /// Which coordinate axis the capsule is aligned to (0=X, 1=Y, 2=Z, ...).
29    pub axis: usize,
30}
31
32impl<const D: usize> Capsule<D> {
33    /// Create a capsule along the given axis.
34    ///
35    /// `half_height` is half the distance between hemisphere centers.
36    /// Total length = `2 * half_height + 2 * radius`.
37    pub fn new(half_height: f64, radius: f64, axis: usize) -> Self {
38        debug_assert!(axis < D, "axis {axis} out of range for D={D}");
39        Self {
40            half_height,
41            radius,
42            axis,
43        }
44    }
45
46    /// Create a Y-aligned capsule (the most common orientation).
47    pub fn y_aligned(half_height: f64, radius: f64) -> Self {
48        assert!(D >= 2, "Y-axis requires D >= 2");
49        Self::new(half_height, radius, 1)
50    }
51
52    /// Total length along the axis (including caps).
53    pub fn total_length(&self) -> f64 {
54        2.0 * self.half_height + 2.0 * self.radius
55    }
56}
57
58impl<const D: usize> Shape<D> for Capsule<D> {
59    /// Support function: pick the hemisphere center that dots highest with
60    /// the direction, then offset by `radius * normalize(direction)`.
61    ///
62    /// `support(d) = sign(d[axis]) * half_height * e_axis + radius * normalize(d)`
63    fn support(&self, direction: &SVector<f64, D>) -> SVector<f64, D> {
64        let norm = direction.norm();
65        if norm < 1e-15 {
66            // Degenerate direction — return the +axis hemisphere center
67            let mut result = SVector::zeros();
68            result[self.axis] = self.half_height;
69            return result;
70        }
71
72        // Pick the hemisphere center in the direction of the projection
73        let mut center = SVector::zeros();
74        center[self.axis] = if direction[self.axis] >= 0.0 {
75            self.half_height
76        } else {
77            -self.half_height
78        };
79
80        // Offset by radius in the support direction
81        center + direction * (self.radius / norm)
82    }
83
84    fn bounding_sphere(&self) -> (Point<D>, f64) {
85        (Point::origin(), self.half_height + self.radius)
86    }
87
88    fn as_any(&self) -> &dyn std::any::Any {
89        self
90    }
91
92    fn clone_box(&self) -> Box<dyn Shape<D>> {
93        Box::new(*self)
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    #[test]
102    fn support_along_axis() {
103        let cap = Capsule::<3>::y_aligned(2.0, 0.5);
104        let dir = SVector::from([0.0, 1.0, 0.0]);
105        let sp = cap.support(&dir);
106        // Should be at +half_height + radius along Y
107        assert!((sp[1] - 2.5).abs() < 1e-12, "support Y+ = {}", sp[1]);
108    }
109
110    #[test]
111    fn support_negative_axis() {
112        let cap = Capsule::<3>::y_aligned(2.0, 0.5);
113        let dir = SVector::from([0.0, -1.0, 0.0]);
114        let sp = cap.support(&dir);
115        assert!((sp[1] - (-2.5)).abs() < 1e-12, "support Y- = {}", sp[1]);
116    }
117
118    #[test]
119    fn support_perpendicular() {
120        let cap = Capsule::<3>::y_aligned(2.0, 0.5);
121        let dir = SVector::from([1.0, 0.0, 0.0]);
122        let sp = cap.support(&dir);
123        // Perpendicular: picks +Y hemisphere, offsets by radius in X
124        assert!((sp[0] - 0.5).abs() < 1e-12, "support X = {}", sp[0]);
125    }
126
127    #[test]
128    fn support_diagonal() {
129        let cap = Capsule::<3>::y_aligned(2.0, 0.5);
130        let dir = SVector::from([1.0, 1.0, 0.0]);
131        let sp = cap.support(&dir);
132        // +Y hemisphere at (0, 2, 0), offset by 0.5 in direction (1,1,0)/sqrt(2)
133        let expected_y = 2.0 + 0.5 / 2.0f64.sqrt();
134        let expected_x = 0.5 / 2.0f64.sqrt();
135        assert!((sp[1] - expected_y).abs() < 1e-10, "diag Y = {}", sp[1]);
136        assert!((sp[0] - expected_x).abs() < 1e-10, "diag X = {}", sp[0]);
137    }
138
139    #[test]
140    fn bounding_sphere_contains_capsule() {
141        let cap = Capsule::<3>::y_aligned(3.0, 1.0);
142        let (center, radius) = cap.bounding_sphere();
143        assert!((center.coord(0)).abs() < 1e-12);
144        assert!((radius - 4.0).abs() < 1e-12);
145    }
146
147    #[test]
148    fn capsule_x_aligned() {
149        let cap = Capsule::<3>::new(1.5, 0.3, 0);
150        let dir = SVector::from([1.0, 0.0, 0.0]);
151        let sp = cap.support(&dir);
152        assert!((sp[0] - 1.8).abs() < 1e-12, "X-capsule support = {}", sp[0]);
153    }
154
155    #[test]
156    fn capsule_4d() {
157        let cap = Capsule::<4>::new(2.0, 1.0, 3); // W-axis aligned
158        let dir = SVector::from([0.0, 0.0, 0.0, 1.0]);
159        let sp = cap.support(&dir);
160        assert!((sp[3] - 3.0).abs() < 1e-12, "4D capsule W+ = {}", sp[3]);
161    }
162
163    #[test]
164    fn capsule_2d() {
165        let cap = Capsule::<2>::new(1.0, 0.5, 0); // X-axis aligned
166        let dir = SVector::from([0.0, 1.0]);
167        let sp = cap.support(&dir);
168        // Perpendicular: picks +X hemisphere, offsets by radius in Y
169        assert!((sp[1] - 0.5).abs() < 1e-12, "2D capsule Y = {}", sp[1]);
170    }
171
172    #[test]
173    fn degenerate_direction() {
174        let cap = Capsule::<3>::y_aligned(2.0, 0.5);
175        let dir = SVector::from([0.0, 0.0, 0.0]);
176        let sp = cap.support(&dir);
177        // Returns +axis hemisphere center
178        assert!((sp[1] - 2.0).abs() < 1e-12);
179    }
180
181    #[test]
182    fn total_length() {
183        let cap = Capsule::<3>::y_aligned(2.0, 0.5);
184        assert!((cap.total_length() - 5.0).abs() < 1e-12);
185    }
186}