Skip to main content

geop_core_math/
polygon.rs

1//! Generic 2-D polygon area, containment and triangulation — no topology
2//! or rendering dependency, so it
3//! lives here rather than in `geop-core-rasterize`: `geop-core-topology`'s
4//! own edit code (`splice_edge_into_face`'s loop-orientation check) needs
5//! it too, and topology sits below rasterize in the dependency order.
6
7use crate::{
8    plain::{self, cross2, sub},
9    scalars::Scalar,
10    vector::Vector2,
11};
12
13/// Twice the signed area of `poly` (shoelace formula): positive for CCW,
14/// negative for CW.
15fn signed_area2(poly: &[Vector2]) -> Scalar {
16    let n = poly.len();
17    let mut sum = Scalar::ZERO;
18    for i in 0..n {
19        let j = (i + 1) % n;
20        sum = sum.add(poly[i][0].mul(poly[j][1]).sub(poly[j][0].mul(poly[i][1])));
21    }
22    sum
23}
24
25/// Signed area of `poly` (positive for CCW, negative for CW).
26pub fn polygon_signed_area(poly: &[Vector2]) -> Scalar {
27    signed_area2(poly).div(Scalar::TWO).unwrap_or(Scalar::ZERO)
28}
29
30/// Whether `p` lies inside the closed polylines `loops`, outer boundaries
31/// and holes alike: an odd number of crossings of a ray from `p` along `+x`.
32/// Only crossings that definitely happen count, so a `p` whose ray could
33/// graze a corner is decided as if it did not.
34pub fn polygons_contain(loops: &[Vec<Vector2>], p: &Vector2) -> bool {
35    let mut inside = false;
36    for poly in loops {
37        for (i, a) in poly.iter().enumerate() {
38            let b = poly[(i + 1) % poly.len()];
39            if a[1].definitely_greater(p[1]) != b[1].definitely_greater(p[1]) {
40                let Ok(along) = p[1].sub(a[1]).div(b[1].sub(a[1])) else {
41                    continue;
42                };
43                if a[0].add(along.mul(b[0].sub(a[0]))).definitely_greater(p[0]) {
44                    inside = !inside;
45                }
46            }
47        }
48    }
49    inside
50}
51
52/// A convex polygon, as the half-planes it is the intersection of: each an
53/// outward normal `n` and an offset `c`, the polygon lying where
54/// `n · p ≤ c`. What a box drawn on screen covers of a plane it looks at.
55#[derive(Clone, Debug, PartialEq)]
56pub struct Convex {
57    pub sides: Vec<(Vector2, Scalar)>,
58}
59
60impl Convex {
61    /// The convex polygon of `corners`, in order either way round. None
62    /// where they could enclose no area: fewer than three, or all on a
63    /// line. Corners that do not go round convexly are no convex polygon,
64    /// and give one that contains less than they do.
65    pub fn new(corners: &[Vector2]) -> Option<Self> {
66        let area = signed_area2(corners);
67        if corners.len() < 3 || area.could_be_equal(Scalar::ZERO) {
68            return None;
69        }
70        // Counter-clockwise, the outward normal of a side is its direction
71        // turned clockwise.
72        let counter = area.definitely_greater(Scalar::ZERO);
73        let sides = (0..corners.len())
74            .map(|i| {
75                let (a, b) = (corners[i], corners[(i + 1) % corners.len()]);
76                let along = b.sub(&a);
77                let left = along.perp();
78                let n = if counter { left.neg() } else { left };
79                (n, n.prod_dot(&a))
80            })
81            .collect();
82        Some(Self { sides })
83    }
84
85    /// How far `p` lies past each side, along its normal (not normalized):
86    /// positive outside it.
87    pub fn past<'a>(&'a self, p: &'a Vector2) -> impl Iterator<Item = Scalar> + 'a {
88        (self.sides.iter()).map(move |(n, c)| n.prod_dot(p).sub(*c))
89    }
90
91    /// Whether `p` could lie in the polygon, its boundary included: past no
92    /// side definitely.
93    pub fn could_contain(&self, p: &Vector2) -> bool {
94        self.past(p).all(|d| !d.definitely_greater(Scalar::ZERO))
95    }
96}
97
98/// Ear-clip a (possibly non-convex) simple polygon into triangles, as
99/// index triples into `poly`: `n - 2` of them for `n >= 3` corners, none
100/// for fewer. Plain numbers, for rendering: a corner that rounding leaves
101/// no ear at is cut off all the same.
102pub fn ear_clip(poly: &[[f64; 2]]) -> Vec<[usize; 3]> {
103    let n = poly.len();
104    if n < 3 {
105        return Vec::new();
106    }
107    let orient_positive = plain::signed_area2(poly) >= 0.0;
108    let point_in_triangle = |p: [f64; 2], a: [f64; 2], b: [f64; 2], c: [f64; 2]| -> bool {
109        let d1 = cross2(sub(b, a), sub(p, a));
110        let d2 = cross2(sub(c, b), sub(p, b));
111        let d3 = cross2(sub(a, c), sub(p, c));
112        let has_neg = d1 < 0.0 || d2 < 0.0 || d3 < 0.0;
113        let has_pos = d1 > 0.0 || d2 > 0.0 || d3 > 0.0;
114        !(has_neg && has_pos)
115    };
116
117    let mut indices: Vec<usize> = (0..n).collect();
118    let mut triangles = Vec::with_capacity(n.saturating_sub(2));
119    // Each round cuts off one corner — an ear, or, where rounding leaves
120    // none, the first corner — until a triangle is left.
121    for _ in 3..n {
122        let m = indices.len();
123        let mut clipped = false;
124        for k in 0..m {
125            let ip = indices[(k + m - 1) % m];
126            let ic = indices[k];
127            let inext = indices[(k + 1) % m];
128            let turn = cross2(sub(poly[ic], poly[ip]), sub(poly[inext], poly[ic]));
129            let is_convex = if orient_positive {
130                turn > 0.0
131            } else {
132                turn < 0.0
133            };
134            if !is_convex {
135                continue;
136            }
137            // A vertex coinciding with one of the ear's own corners (e.g.
138            // a zero-width slit bridge's duplicated endpoint) lies on the
139            // triangle's boundary by construction, not its interior —
140            // don't let that reject an otherwise-valid ear.
141            let contains_other = indices.iter().any(|&iq| {
142                iq != ip
143                    && iq != ic
144                    && iq != inext
145                    && poly[iq] != poly[ip]
146                    && poly[iq] != poly[ic]
147                    && poly[iq] != poly[inext]
148                    && point_in_triangle(poly[iq], poly[ip], poly[ic], poly[inext])
149            });
150            if contains_other {
151                continue;
152            }
153            triangles.push([ip, ic, inext]);
154            indices.remove(k);
155            clipped = true;
156            break;
157        }
158        if !clipped {
159            let m = indices.len();
160            triangles.push([indices[m - 1], indices[0], indices[1]]);
161            indices.remove(0);
162        }
163    }
164    triangles.push([indices[0], indices[1], indices[2]]);
165    triangles
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    fn v(x: f64, y: f64) -> Vector2 {
173        Vector2::from_array([Scalar::from_f64(x), Scalar::from_f64(y)])
174    }
175
176    /// A square either way round holds its middle and its corners, and
177    /// nothing outside; corners on a line are no polygon.
178    #[test]
179    fn convex_polygons_either_way_round() {
180        let square = [v(0.0, 0.0), v(2.0, 0.0), v(2.0, 1.0), v(0.0, 1.0)];
181        let mut reversed = square;
182        reversed.reverse();
183        for corners in [square, reversed] {
184            let convex = Convex::new(&corners).unwrap();
185            assert!(convex.could_contain(&v(1.0, 0.5)));
186            assert!(convex.could_contain(&v(2.0, 1.0)));
187            assert!(!convex.could_contain(&v(2.5, 0.5)));
188            assert!(!convex.could_contain(&v(1.0, -0.1)));
189        }
190        assert!(Convex::new(&[v(0.0, 0.0), v(1.0, 1.0), v(2.0, 2.0)]).is_none());
191    }
192
193    #[test]
194    fn ear_clip_triangulates_convex_polygon_fully() {
195        let poly = vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
196        let tris = ear_clip(&poly);
197        let total: f64 = tris
198            .iter()
199            .map(|t| cross2(sub(poly[t[1]], poly[t[0]]), sub(poly[t[2]], poly[t[0]])).abs() / 2.0)
200            .sum();
201        assert!((total - 1.0).abs() < 1e-9);
202    }
203}