Skip to main content

brepkit_math/
predicates.rs

1//! Exact geometric predicates backed by the [`robust`] crate.
2//!
3//! These wrappers accept [`Point2`] and [`Point3`] values and return either
4//! raw `f64` results or classified enum values.
5
6use crate::vec::{Point2, Point3};
7
8// ---------------------------------------------------------------------------
9// 2D predicates
10// ---------------------------------------------------------------------------
11
12/// Classification of the orientation of three points in the plane.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
14pub enum Orientation {
15    /// The triple (a, b, c) is counter-clockwise (positive orientation).
16    CounterClockwise,
17    /// The triple (a, b, c) is clockwise (negative orientation).
18    Clockwise,
19    /// The three points are collinear.
20    Collinear,
21}
22
23/// Convert a [`Point2`] to a [`robust::Coord`].
24const fn to_coord(p: Point2) -> robust::Coord<f64> {
25    robust::Coord { x: p.x(), y: p.y() }
26}
27
28/// Compute the exact orientation determinant of the triangle (a, b, c).
29///
30/// Returns a positive value if the points are counter-clockwise,
31/// negative if clockwise, and zero if collinear.
32#[inline]
33#[must_use]
34pub fn orient2d(a: Point2, b: Point2, c: Point2) -> f64 {
35    robust::orient2d(to_coord(a), to_coord(b), to_coord(c))
36}
37
38/// Classify the orientation of three points in the plane.
39#[must_use]
40pub fn orientation2d(a: Point2, b: Point2, c: Point2) -> Orientation {
41    let det = orient2d(a, b, c);
42    if det > 0.0 {
43        Orientation::CounterClockwise
44    } else if det < 0.0 {
45        Orientation::Clockwise
46    } else {
47        Orientation::Collinear
48    }
49}
50
51/// Exact in-circle test for four 2D points.
52///
53/// Returns a positive value if `d` lies inside the circumcircle of (a, b, c)
54/// (when a, b, c are in counter-clockwise order), negative if outside, and
55/// zero if on the circle.
56#[must_use]
57pub fn in_circle(a: Point2, b: Point2, c: Point2, d: Point2) -> f64 {
58    robust::incircle(to_coord(a), to_coord(b), to_coord(c), to_coord(d))
59}
60
61/// Compute the winding number of a point with respect to a polygon.
62///
63/// The polygon is given as a slice of vertices forming a closed loop (the
64/// last vertex is implicitly connected to the first). Returns the winding
65/// number: non-zero means the point is inside.
66#[must_use]
67pub fn winding_number(point: Point2, polygon: &[Point2]) -> i32 {
68    let n = polygon.len();
69    if n < 3 {
70        return 0;
71    }
72
73    let mut wn = 0i32;
74    for i in 0..n {
75        let j = (i + 1) % n;
76        let vi = polygon[i];
77        let vj = polygon[j];
78
79        if vi.y() <= point.y() {
80            if vj.y() > point.y() {
81                // Upward crossing
82                if orient2d(vi, vj, point) > 0.0 {
83                    wn += 1;
84                }
85            }
86        } else if vj.y() <= point.y() {
87            // Downward crossing
88            if orient2d(vi, vj, point) < 0.0 {
89                wn -= 1;
90            }
91        }
92    }
93    wn
94}
95
96/// Test whether a point lies inside a polygon using the winding number rule.
97///
98/// Returns `true` if the winding number is non-zero.
99#[must_use]
100pub fn point_in_polygon(point: Point2, polygon: &[Point2]) -> bool {
101    winding_number(point, polygon) != 0
102}
103
104// ---------------------------------------------------------------------------
105// 3D predicates
106// ---------------------------------------------------------------------------
107
108/// Classification of a point's position relative to an oriented plane.
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
110pub enum Orientation3D {
111    /// The point lies above the plane (positive side).
112    Above,
113    /// The point lies below the plane (negative side).
114    Below,
115    /// The point lies on the plane.
116    Coplanar,
117}
118
119/// Convert a [`Point3`] to a [`robust::Coord3D`].
120const fn to_coord3d(p: Point3) -> robust::Coord3D<f64> {
121    robust::Coord3D {
122        x: p.x(),
123        y: p.y(),
124        z: p.z(),
125    }
126}
127
128/// Compute the exact orientation of point `d` relative to the plane through
129/// `(a, b, c)`.
130///
131/// Returns a positive value if `d` lies below the plane (a, b, c appear
132/// counter-clockwise when viewed from above), negative if above, zero if
133/// coplanar. This matches the convention of the `robust` crate.
134#[inline]
135#[must_use]
136pub fn orient3d(a: Point3, b: Point3, c: Point3, d: Point3) -> f64 {
137    robust::orient3d(to_coord3d(a), to_coord3d(b), to_coord3d(c), to_coord3d(d))
138}
139
140/// Classify the orientation of point `d` relative to the plane through
141/// `(a, b, c)`.
142#[must_use]
143pub fn orientation3d(a: Point3, b: Point3, c: Point3, d: Point3) -> Orientation3D {
144    let det = orient3d(a, b, c, d);
145    if det > 0.0 {
146        Orientation3D::Below
147    } else if det < 0.0 {
148        Orientation3D::Above
149    } else {
150        Orientation3D::Coplanar
151    }
152}
153
154/// Exact in-sphere test for five 3D points.
155///
156/// Returns a positive value if `e` lies inside the circumsphere of
157/// `(a, b, c, d)` (when a, b, c, d have positive orientation), negative if
158/// outside, zero if on the sphere.
159#[must_use]
160#[allow(clippy::many_single_char_names)]
161pub fn insphere(a: Point3, b: Point3, c: Point3, d: Point3, e: Point3) -> f64 {
162    robust::insphere(
163        to_coord3d(a),
164        to_coord3d(b),
165        to_coord3d(c),
166        to_coord3d(d),
167        to_coord3d(e),
168    )
169}
170
171// ---------------------------------------------------------------------------
172// Symbolic perturbation (SoS — Simulation of Simplicity)
173// ---------------------------------------------------------------------------
174
175/// Compute `orient2d(a, b, c)` with symbolic perturbation to resolve degeneracy.
176///
177/// When the exact `orient2d` returns 0 (collinear points), applies an
178/// index-based perturbation: the point with the highest index is perturbed
179/// infinitesimally upward, breaking ties consistently.
180///
181/// Returns a non-zero `f64` whose sign indicates the resolved orientation.
182/// The magnitude is arbitrary when the exact result was zero.
183// SoS predicates require exact-zero detection by design
184#[allow(clippy::float_cmp)]
185#[must_use]
186pub fn orient2d_sos(a: Point2, b: Point2, c: Point2, ia: usize, ib: usize, ic: usize) -> f64 {
187    let det = orient2d(a, b, c);
188    if det != 0.0 {
189        return det;
190    }
191
192    // SoS perturbation: the point with the largest index is perturbed.
193    // The sign depends on its position in the argument list (even/odd parity).
194    let max_idx = ia.max(ib).max(ic);
195    if max_idx == ic {
196        1.0 // c is perturbed → positive (CCW)
197    } else if max_idx == ib {
198        -1.0 // b is perturbed → negative (CW)
199    } else {
200        1.0 // a is perturbed → positive
201    }
202}
203
204/// Compute `orient3d(a, b, c, d)` with symbolic perturbation to resolve degeneracy.
205///
206/// When the exact `orient3d` returns 0 (coplanar points), applies an
207/// index-based perturbation: the point with the highest index is perturbed
208/// infinitesimally along the z-axis, breaking ties consistently.
209///
210/// Returns a non-zero `f64` whose sign indicates the resolved orientation.
211/// The magnitude is arbitrary when the exact result was zero.
212// SoS predicates require exact-zero detection by design
213#[allow(clippy::float_cmp)]
214#[must_use]
215#[allow(clippy::too_many_arguments)]
216pub fn orient3d_sos(
217    a: Point3,
218    b: Point3,
219    c: Point3,
220    d: Point3,
221    ia: usize,
222    ib: usize,
223    ic: usize,
224    id: usize,
225) -> f64 {
226    let det = orient3d(a, b, c, d);
227    if det != 0.0 {
228        return det;
229    }
230
231    // SoS perturbation: the point with the largest index receives an
232    // infinitesimal perturbation. The sign depends on which argument
233    // position it occupies (even permutation → positive, odd → negative).
234    let max_idx = ia.max(ib).max(ic).max(id);
235    if max_idx == id {
236        1.0 // d perturbed: positive (below plane)
237    } else if max_idx == ic {
238        -1.0 // c perturbed: negative (above plane)
239    } else if max_idx == ib {
240        1.0 // b perturbed: positive
241    } else {
242        -1.0 // a perturbed: negative
243    }
244}
245
246#[cfg(test)]
247#[allow(clippy::float_cmp)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn orient2d_ccw() {
253        let a = Point2::new(0.0, 0.0);
254        let b = Point2::new(1.0, 0.0);
255        let c = Point2::new(0.0, 1.0);
256        assert!(orient2d(a, b, c) > 0.0);
257        assert_eq!(orientation2d(a, b, c), Orientation::CounterClockwise);
258    }
259
260    #[test]
261    fn orient2d_cw() {
262        let a = Point2::new(0.0, 0.0);
263        let b = Point2::new(0.0, 1.0);
264        let c = Point2::new(1.0, 0.0);
265        assert!(orient2d(a, b, c) < 0.0);
266        assert_eq!(orientation2d(a, b, c), Orientation::Clockwise);
267    }
268
269    #[test]
270    fn orient2d_collinear() {
271        let a = Point2::new(0.0, 0.0);
272        let b = Point2::new(1.0, 1.0);
273        let c = Point2::new(2.0, 2.0);
274        assert_eq!(orient2d(a, b, c), 0.0);
275        assert_eq!(orientation2d(a, b, c), Orientation::Collinear);
276    }
277
278    #[test]
279    fn orient2d_swap_reverses_sign() {
280        let a = Point2::new(0.0, 0.0);
281        let b = Point2::new(1.0, 0.0);
282        let c = Point2::new(0.5, 1.0);
283        let d1 = orient2d(a, b, c);
284        let d2 = orient2d(b, a, c);
285        assert!((d1 + d2).abs() < 1e-15, "swap should reverse sign");
286    }
287
288    #[test]
289    fn orient3d_basic() {
290        let a = Point3::new(0.0, 0.0, 0.0);
291        let b = Point3::new(1.0, 0.0, 0.0);
292        let c = Point3::new(0.0, 1.0, 0.0);
293        let above = Point3::new(0.0, 0.0, 1.0);
294        let below = Point3::new(0.0, 0.0, -1.0);
295        let on = Point3::new(0.5, 0.5, 0.0);
296
297        assert!(orient3d(a, b, c, above) < 0.0); // above the plane
298        assert!(orient3d(a, b, c, below) > 0.0); // below
299        assert_eq!(orient3d(a, b, c, on), 0.0); // coplanar
300
301        assert_eq!(orientation3d(a, b, c, above), Orientation3D::Above);
302        assert_eq!(orientation3d(a, b, c, below), Orientation3D::Below);
303        assert_eq!(orientation3d(a, b, c, on), Orientation3D::Coplanar);
304    }
305
306    #[test]
307    fn insphere_inside() {
308        // Tetrahedron with positive orientation, test point at origin.
309        let a = Point3::new(1.0, 0.0, 0.0);
310        let b = Point3::new(0.0, 1.0, 0.0);
311        let c = Point3::new(0.0, 0.0, 1.0);
312        let d = Point3::new(0.0, 0.0, 0.0);
313        // Ensure positive orientation by checking orient3d sign.
314        // If orient3d(a,b,c,d) > 0, they have the right order.
315        let center = Point3::new(0.25, 0.25, 0.25);
316        // The circumsphere of a regular-ish tetrahedron, center should be inside.
317        let result = insphere(a, b, c, d, center);
318        // Just check it returns a finite value (exact inside/outside depends on geometry)
319        assert!(result.is_finite());
320    }
321
322    #[test]
323    fn winding_number_square() {
324        let square = vec![
325            Point2::new(0.0, 0.0),
326            Point2::new(1.0, 0.0),
327            Point2::new(1.0, 1.0),
328            Point2::new(0.0, 1.0),
329        ];
330        // Inside
331        assert_eq!(winding_number(Point2::new(0.5, 0.5), &square), 1);
332        assert!(point_in_polygon(Point2::new(0.5, 0.5), &square));
333        // Outside
334        assert_eq!(winding_number(Point2::new(2.0, 2.0), &square), 0);
335        assert!(!point_in_polygon(Point2::new(2.0, 2.0), &square));
336    }
337
338    #[test]
339    fn winding_number_triangle() {
340        let tri = vec![
341            Point2::new(0.0, 0.0),
342            Point2::new(4.0, 0.0),
343            Point2::new(2.0, 3.0),
344        ];
345        assert!(point_in_polygon(Point2::new(2.0, 1.0), &tri));
346        assert!(!point_in_polygon(Point2::new(5.0, 0.0), &tri));
347    }
348
349    #[test]
350    fn winding_number_degenerate() {
351        // Too few points
352        assert_eq!(winding_number(Point2::new(0.0, 0.0), &[]), 0);
353        assert_eq!(
354            winding_number(
355                Point2::new(0.0, 0.0),
356                &[Point2::new(0.0, 0.0), Point2::new(1.0, 1.0)]
357            ),
358            0
359        );
360    }
361
362    use proptest::prelude::*;
363
364    proptest! {
365        #[test]
366        fn prop_orient2d_swap_sign(
367            ax in -10.0f64..10.0, ay in -10.0f64..10.0,
368            bx in -10.0f64..10.0, by in -10.0f64..10.0,
369            cx in -10.0f64..10.0, cy in -10.0f64..10.0,
370        ) {
371            let a = Point2::new(ax, ay);
372            let b = Point2::new(bx, by);
373            let c = Point2::new(cx, cy);
374            let d1 = orient2d(a, b, c);
375            let d2 = orient2d(b, a, c);
376            prop_assert!((d1 + d2).abs() < 1e-10, "d1={}, d2={}", d1, d2);
377        }
378    }
379
380    #[test]
381    fn orient2d_sos_never_zero() {
382        // Collinear points: orient2d returns 0, orient2d_sos must not.
383        let a = Point2::new(0.0, 0.0);
384        let b = Point2::new(1.0, 1.0);
385        let c = Point2::new(2.0, 2.0);
386        assert_eq!(orient2d(a, b, c), 0.0);
387        assert_ne!(orient2d_sos(a, b, c, 0, 1, 2), 0.0);
388    }
389
390    #[test]
391    fn orient2d_sos_consistent() {
392        // Same call twice gives same sign.
393        let a = Point2::new(0.0, 0.0);
394        let b = Point2::new(1.0, 1.0);
395        let c = Point2::new(2.0, 2.0);
396        let s1 = orient2d_sos(a, b, c, 0, 1, 2);
397        let s2 = orient2d_sos(a, b, c, 0, 1, 2);
398        assert_eq!(s1.signum(), s2.signum());
399    }
400
401    #[test]
402    fn orient3d_sos_never_zero() {
403        // Coplanar points: orient3d returns 0, orient3d_sos must not.
404        let a = Point3::new(0.0, 0.0, 0.0);
405        let b = Point3::new(1.0, 0.0, 0.0);
406        let c = Point3::new(0.0, 1.0, 0.0);
407        let d = Point3::new(0.5, 0.5, 0.0);
408        assert_eq!(orient3d(a, b, c, d), 0.0);
409        assert_ne!(orient3d_sos(a, b, c, d, 0, 1, 2, 3), 0.0);
410    }
411
412    #[test]
413    fn orient3d_sos_passes_through_nonzero() {
414        // Non-degenerate case: orient3d_sos returns the same sign as orient3d.
415        let a = Point3::new(0.0, 0.0, 0.0);
416        let b = Point3::new(1.0, 0.0, 0.0);
417        let c = Point3::new(0.0, 1.0, 0.0);
418        let d = Point3::new(0.0, 0.0, 1.0);
419        let exact = orient3d(a, b, c, d);
420        let sos = orient3d_sos(a, b, c, d, 0, 1, 2, 3);
421        assert_eq!(exact.signum(), sos.signum());
422    }
423}