Skip to main content

symtropy_math/
halfspace.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 half-space (infinite plane with one solid side).
5//!
6//! Defined by a normal and offset: all points `p` where `normal · p <= offset`
7//! are "inside" the solid region. The boundary plane is `normal · p = offset`.
8//!
9//! # GJK Compatibility
10//!
11//! Half-spaces are unbounded, so the support function returns a large finite
12//! extent (`HALFSPACE_EXTENT = 1e6`) in the query direction. This works for
13//! GJK but is imprecise. For sphere, capsule, and box shapes, use the
14//! analytical contact functions instead — they're exact and faster.
15
16use crate::point::Point;
17use crate::shape::Shape;
18use nalgebra::SVector;
19
20/// Large finite extent for GJK compatibility (half-spaces are unbounded).
21const HALFSPACE_EXTENT: f64 = 1e6;
22
23/// D-dimensional half-space: the set of points where `normal · p <= offset`.
24///
25/// The boundary plane has equation `normal · p = offset`.
26/// `normal` must be a unit vector.
27#[derive(Clone, Copy, Debug)]
28pub struct HalfSpace<const D: usize> {
29    /// Outward-pointing unit normal of the boundary plane.
30    pub normal: SVector<f64, D>,
31    /// Signed distance from the origin to the boundary plane along the normal.
32    /// Positive = plane is on the positive side of the origin.
33    pub offset: f64,
34}
35
36impl<const D: usize> HalfSpace<D> {
37    /// Create a half-space from a normal and offset.
38    /// The normal should be a unit vector (not enforced, but expected).
39    pub fn new(normal: SVector<f64, D>, offset: f64) -> Self {
40        Self { normal, offset }
41    }
42
43    /// Create a ground plane at the given height along the given axis.
44    /// Normal points in the positive axis direction (upward for Y-up).
45    pub fn ground(axis: usize, height: f64) -> Self {
46        let mut normal = SVector::zeros();
47        normal[axis] = 1.0;
48        Self {
49            normal,
50            offset: height,
51        }
52    }
53
54    /// Signed distance from a point to the boundary plane.
55    /// Positive = point is on the "outside" (normal side).
56    /// Negative = point is inside the solid half-space.
57    #[inline]
58    pub fn signed_distance(&self, point: &SVector<f64, D>) -> f64 {
59        self.normal.dot(point) - self.offset
60    }
61
62    /// Project a point onto the boundary plane.
63    pub fn project(&self, point: &SVector<f64, D>) -> SVector<f64, D> {
64        point - self.normal * self.signed_distance(point)
65    }
66
67    // ═══════════════════════════════════════════════════════════════════
68    // Analytical contact functions — bypass GJK for exact, fast contacts
69    // ═══════════════════════════════════════════════════════════════════
70
71    /// Analytical contact between a sphere and this half-space.
72    ///
73    /// Returns `Some((contact_point, depth))` if overlapping, `None` otherwise.
74    /// `sphere_center` is in world space.
75    pub fn contact_sphere(
76        &self,
77        sphere_center: &SVector<f64, D>,
78        sphere_radius: f64,
79    ) -> Option<(SVector<f64, D>, f64)> {
80        let dist = self.signed_distance(sphere_center);
81        let depth = sphere_radius - dist;
82        if depth <= 0.0 {
83            return None;
84        }
85        // Contact point: closest point on sphere to the plane
86        let contact = sphere_center - self.normal * dist;
87        Some((contact, depth))
88    }
89
90    /// Analytical contact between a capsule and this half-space.
91    ///
92    /// Returns contacts for both hemisphere centers (0, 1, or 2 contacts).
93    /// `capsule_pos` is the capsule's world-space center.
94    pub fn contact_capsule(
95        &self,
96        capsule_pos: &SVector<f64, D>,
97        half_height: f64,
98        radius: f64,
99        axis: usize,
100    ) -> Vec<(SVector<f64, D>, f64)> {
101        let mut contacts = Vec::new();
102
103        // Two hemisphere centers
104        let mut axis_vec = SVector::zeros();
105        axis_vec[axis] = 1.0;
106
107        let center_a = capsule_pos + axis_vec * half_height;
108        let center_b = capsule_pos - axis_vec * half_height;
109
110        if let Some(c) = self.contact_sphere(&center_a, radius) {
111            contacts.push(c);
112        }
113        if let Some(c) = self.contact_sphere(&center_b, radius) {
114            contacts.push(c);
115        }
116
117        contacts
118    }
119
120    /// Analytical contact between an axis-aligned box and this half-space.
121    ///
122    /// Returns contacts for all box vertices that penetrate the plane.
123    /// `box_pos` is the box's world-space center.
124    pub fn contact_box(
125        &self,
126        box_pos: &SVector<f64, D>,
127        half_extents: &[f64; D],
128    ) -> Vec<(SVector<f64, D>, f64)> {
129        let mut contacts = Vec::new();
130
131        // Enumerate all 2^D vertices (acceptable for D <= 4)
132        let num_vertices = 1usize << D;
133        for bits in 0..num_vertices {
134            let mut vertex = *box_pos;
135            for axis in 0..D {
136                if bits & (1 << axis) != 0 {
137                    vertex[axis] += half_extents[axis];
138                } else {
139                    vertex[axis] -= half_extents[axis];
140                }
141            }
142
143            let dist = self.signed_distance(&vertex);
144            if dist < 0.0 {
145                let contact = self.project(&vertex);
146                contacts.push((contact, -dist));
147            }
148        }
149
150        contacts
151    }
152}
153
154impl<const D: usize> Shape<D> for HalfSpace<D> {
155    /// Support function for GJK compatibility.
156    ///
157    /// Returns a point at `HALFSPACE_EXTENT` in the query direction (projected
158    /// onto the plane) or at `-HALFSPACE_EXTENT * normal` if direction points
159    /// into the solid. This is an approximation — use analytical contacts
160    /// for sphere/capsule/box instead.
161    fn support(&self, direction: &SVector<f64, D>) -> SVector<f64, D> {
162        let dot = direction.dot(&self.normal);
163        if dot >= 0.0 {
164            // Direction faces the normal — support is at the plane surface,
165            // offset in the tangential direction
166            let tangent = direction - self.normal * dot;
167            let t_norm = tangent.norm();
168            if t_norm > 1e-15 {
169                self.normal * self.offset + tangent / t_norm * HALFSPACE_EXTENT
170            } else {
171                self.normal * self.offset
172            }
173        } else {
174            // Direction faces into the solid — support is deep inside
175            self.normal * (self.offset - HALFSPACE_EXTENT)
176        }
177    }
178
179    fn bounding_sphere(&self) -> (Point<D>, f64) {
180        (Point::origin(), HALFSPACE_EXTENT)
181    }
182
183    fn as_any(&self) -> &dyn std::any::Any {
184        self
185    }
186
187    fn clone_box(&self) -> Box<dyn Shape<D>> {
188        Box::new(*self)
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn signed_distance_above() {
198        let plane = HalfSpace::<3>::ground(1, 0.0); // Y=0 plane
199        let point = SVector::from([0.0, 5.0, 0.0]);
200        assert!((plane.signed_distance(&point) - 5.0).abs() < 1e-12);
201    }
202
203    #[test]
204    fn signed_distance_below() {
205        let plane = HalfSpace::<3>::ground(1, 0.0);
206        let point = SVector::from([0.0, -3.0, 0.0]);
207        assert!((plane.signed_distance(&point) - (-3.0)).abs() < 1e-12);
208    }
209
210    #[test]
211    fn project_onto_plane() {
212        let plane = HalfSpace::<3>::ground(1, 0.0);
213        let point = SVector::from([3.0, 5.0, 7.0]);
214        let proj = plane.project(&point);
215        assert!((proj[0] - 3.0).abs() < 1e-12);
216        assert!((proj[1] - 0.0).abs() < 1e-12);
217        assert!((proj[2] - 7.0).abs() < 1e-12);
218    }
219
220    #[test]
221    fn sphere_contact_resting() {
222        let plane = HalfSpace::<3>::ground(1, 0.0);
223        let center = SVector::from([0.0, 0.5, 0.0]); // half-embedded
224        let (contact, depth) = plane.contact_sphere(&center, 1.0).unwrap();
225        assert!((depth - 0.5).abs() < 1e-12, "depth = {depth}");
226        assert!(
227            (contact[1] - 0.0).abs() < 1e-12,
228            "contact Y = {}",
229            contact[1]
230        );
231    }
232
233    #[test]
234    fn sphere_no_contact() {
235        let plane = HalfSpace::<3>::ground(1, 0.0);
236        let center = SVector::from([0.0, 2.0, 0.0]);
237        assert!(plane.contact_sphere(&center, 1.0).is_none());
238    }
239
240    #[test]
241    fn capsule_two_contacts() {
242        let plane = HalfSpace::<3>::ground(1, 0.0);
243        // Capsule lying on its side (X-aligned), center at Y=0.3
244        let pos = SVector::from([0.0, 0.3, 0.0]);
245        let contacts = plane.contact_capsule(&pos, 2.0, 0.5, 0);
246        // Both hemisphere centers at Y=0.3, radius 0.5 → both penetrate by 0.2
247        assert_eq!(contacts.len(), 2);
248        for (_, depth) in &contacts {
249            assert!(
250                (depth - 0.2).abs() < 1e-10,
251                "capsule contact depth = {depth}"
252            );
253        }
254    }
255
256    #[test]
257    fn box_contact_on_plane() {
258        let plane = HalfSpace::<3>::ground(1, 0.0);
259        // Unit box centered at Y=0.5 → bottom face at Y=-0.5 → 4 vertices penetrate
260        let pos = SVector::from([0.0, 0.5, 0.0]);
261        let contacts = plane.contact_box(&pos, &[1.0, 1.0, 1.0]);
262        // Bottom 4 vertices (Y = 0.5-1.0 = -0.5) penetrate by 0.5
263        assert_eq!(contacts.len(), 4, "expected 4 bottom vertices to penetrate");
264        for (_, depth) in &contacts {
265            assert!((depth - 0.5).abs() < 1e-10, "box vertex depth = {depth}");
266        }
267    }
268
269    #[test]
270    fn box_no_contact() {
271        let plane = HalfSpace::<3>::ground(1, 0.0);
272        let pos = SVector::from([0.0, 5.0, 0.0]);
273        let contacts = plane.contact_box(&pos, &[1.0, 1.0, 1.0]);
274        assert!(contacts.is_empty());
275    }
276
277    #[test]
278    fn halfspace_4d() {
279        let plane = HalfSpace::<4>::ground(3, 0.0); // W=0 plane
280        let center = SVector::from([0.0, 0.0, 0.0, 0.8]);
281        let contact = plane.contact_sphere(&center, 1.0);
282        assert!(contact.is_some());
283        let (_, depth) = contact.unwrap();
284        assert!((depth - 0.2).abs() < 1e-12, "4D depth = {depth}");
285    }
286
287    #[test]
288    fn halfspace_offset() {
289        let plane = HalfSpace::<3>::ground(1, 2.0); // Y=2 plane
290        let center = SVector::from([0.0, 2.5, 0.0]);
291        let contact = plane.contact_sphere(&center, 1.0);
292        assert!(contact.is_some());
293        let (_, depth) = contact.unwrap();
294        assert!((depth - 0.5).abs() < 1e-12, "offset plane depth = {depth}");
295    }
296
297    #[test]
298    fn halfspace_2d() {
299        let plane = HalfSpace::<2>::ground(1, 0.0);
300        let center = SVector::from([3.0, 0.5]);
301        let (contact, depth) = plane.contact_sphere(&center, 1.0).unwrap();
302        assert!((depth - 0.5).abs() < 1e-12);
303        assert!((contact[0] - 3.0).abs() < 1e-12);
304    }
305}