Skip to main content

symtropy_math/
halfspace.rs

1// Copyright (C) 2024-2026 Tristan Stoltz / Luminous Dynamics
2// SPDX-License-Identifier: AGPL-3.0-or-later
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
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    #[test]
189    fn signed_distance_above() {
190        let plane = HalfSpace::<3>::ground(1, 0.0); // Y=0 plane
191        let point = SVector::from([0.0, 5.0, 0.0]);
192        assert!((plane.signed_distance(&point) - 5.0).abs() < 1e-12);
193    }
194
195    #[test]
196    fn signed_distance_below() {
197        let plane = HalfSpace::<3>::ground(1, 0.0);
198        let point = SVector::from([0.0, -3.0, 0.0]);
199        assert!((plane.signed_distance(&point) - (-3.0)).abs() < 1e-12);
200    }
201
202    #[test]
203    fn project_onto_plane() {
204        let plane = HalfSpace::<3>::ground(1, 0.0);
205        let point = SVector::from([3.0, 5.0, 7.0]);
206        let proj = plane.project(&point);
207        assert!((proj[0] - 3.0).abs() < 1e-12);
208        assert!((proj[1] - 0.0).abs() < 1e-12);
209        assert!((proj[2] - 7.0).abs() < 1e-12);
210    }
211
212    #[test]
213    fn sphere_contact_resting() {
214        let plane = HalfSpace::<3>::ground(1, 0.0);
215        let center = SVector::from([0.0, 0.5, 0.0]); // half-embedded
216        let (contact, depth) = plane.contact_sphere(&center, 1.0).unwrap();
217        assert!((depth - 0.5).abs() < 1e-12, "depth = {depth}");
218        assert!((contact[1] - 0.0).abs() < 1e-12, "contact Y = {}", contact[1]);
219    }
220
221    #[test]
222    fn sphere_no_contact() {
223        let plane = HalfSpace::<3>::ground(1, 0.0);
224        let center = SVector::from([0.0, 2.0, 0.0]);
225        assert!(plane.contact_sphere(&center, 1.0).is_none());
226    }
227
228    #[test]
229    fn capsule_two_contacts() {
230        let plane = HalfSpace::<3>::ground(1, 0.0);
231        // Capsule lying on its side (X-aligned), center at Y=0.3
232        let pos = SVector::from([0.0, 0.3, 0.0]);
233        let contacts = plane.contact_capsule(&pos, 2.0, 0.5, 0);
234        // Both hemisphere centers at Y=0.3, radius 0.5 → both penetrate by 0.2
235        assert_eq!(contacts.len(), 2);
236        for (_, depth) in &contacts {
237            assert!(
238                (depth - 0.2).abs() < 1e-10,
239                "capsule contact depth = {depth}"
240            );
241        }
242    }
243
244    #[test]
245    fn box_contact_on_plane() {
246        let plane = HalfSpace::<3>::ground(1, 0.0);
247        // Unit box centered at Y=0.5 → bottom face at Y=-0.5 → 4 vertices penetrate
248        let pos = SVector::from([0.0, 0.5, 0.0]);
249        let contacts = plane.contact_box(&pos, &[1.0, 1.0, 1.0]);
250        // Bottom 4 vertices (Y = 0.5-1.0 = -0.5) penetrate by 0.5
251        assert_eq!(contacts.len(), 4, "expected 4 bottom vertices to penetrate");
252        for (_, depth) in &contacts {
253            assert!(
254                (depth - 0.5).abs() < 1e-10,
255                "box vertex depth = {depth}"
256            );
257        }
258    }
259
260    #[test]
261    fn box_no_contact() {
262        let plane = HalfSpace::<3>::ground(1, 0.0);
263        let pos = SVector::from([0.0, 5.0, 0.0]);
264        let contacts = plane.contact_box(&pos, &[1.0, 1.0, 1.0]);
265        assert!(contacts.is_empty());
266    }
267
268    #[test]
269    fn halfspace_4d() {
270        let plane = HalfSpace::<4>::ground(3, 0.0); // W=0 plane
271        let center = SVector::from([0.0, 0.0, 0.0, 0.8]);
272        let contact = plane.contact_sphere(&center, 1.0);
273        assert!(contact.is_some());
274        let (_, depth) = contact.unwrap();
275        assert!((depth - 0.2).abs() < 1e-12, "4D depth = {depth}");
276    }
277
278    #[test]
279    fn halfspace_offset() {
280        let plane = HalfSpace::<3>::ground(1, 2.0); // Y=2 plane
281        let center = SVector::from([0.0, 2.5, 0.0]);
282        let contact = plane.contact_sphere(&center, 1.0);
283        assert!(contact.is_some());
284        let (_, depth) = contact.unwrap();
285        assert!((depth - 0.5).abs() < 1e-12, "offset plane depth = {depth}");
286    }
287
288    #[test]
289    fn halfspace_2d() {
290        let plane = HalfSpace::<2>::ground(1, 0.0);
291        let center = SVector::from([3.0, 0.5]);
292        let (contact, depth) = plane.contact_sphere(&center, 1.0).unwrap();
293        assert!((depth - 0.5).abs() < 1e-12);
294        assert!((contact[0] - 3.0).abs() < 1e-12);
295    }
296}