Skip to main content

geometry_algorithm/
triangulate_earcut.rs

1//! Native ear-clipping triangulation for Cartesian polygons.
2//!
3//! Boost.Geometry has no triangulation entry. This implementation follows the
4//! classic Meisters ear-clipping method, using the adaptive exact-sign
5//! [`geometry_coords::precise_math::orient2d`] predicate for every turn test.
6//! Interior rings are connected to the exterior by non-crossing visibility
7//! bridges before clipping.
8
9use alloc::{vec, vec::Vec};
10
11use geometry_coords::precise_math;
12use geometry_cs::{CartesianFamily, CoordinateSystem};
13use geometry_model::{Polygon as ModelPolygon, Ring as ModelRing};
14use geometry_tag::SameAs;
15use geometry_trait::{Point, Polygon, Ring};
16
17/// Triangulate a Cartesian polygon into owned clockwise, closed stock polygons.
18///
19/// Degenerate rings and interiors that cannot be bridged into the exterior
20/// return an empty vector. Every returned polygon has exactly three distinct
21/// vertices plus the closing duplicate.
22#[inline]
23#[must_use]
24pub fn triangulate_earcut<Pg, P>(polygon: &Pg) -> Vec<ModelPolygon<P>>
25where
26    Pg: Polygon<Point = P>,
27    P: Point<Scalar = f64> + Copy,
28    P::Cs: CoordinateSystem,
29    <P::Cs as CoordinateSystem>::Family: SameAs<CartesianFamily>,
30{
31    let mut exterior = ring_vertices(polygon.exterior());
32    if exterior.len() < 3 {
33        return Vec::new();
34    }
35    make_counter_clockwise(&mut exterior);
36
37    let mut holes: Vec<Vec<P>> = polygon
38        .interiors()
39        .map(ring_vertices)
40        .filter(|ring| ring.len() >= 3)
41        .collect();
42    for hole in &mut holes {
43        make_clockwise(hole);
44    }
45    for hole_index in 0..holes.len() {
46        let Some(merged) = bridge_hole(&exterior, &holes[hole_index], &holes) else {
47            return Vec::new();
48        };
49        exterior = merged;
50    }
51
52    clip_ears(&exterior)
53}
54
55fn ring_vertices<R, P>(ring: &R) -> Vec<P>
56where
57    R: Ring<Point = P>,
58    P: Point<Scalar = f64> + Copy,
59{
60    let mut points: Vec<P> = ring.points().copied().collect();
61    while points.len() > 1 && same_point(&points[0], points.last().unwrap_or(&points[0])) {
62        points.pop();
63    }
64    points.dedup_by(|second, first| same_point(first, second));
65    points
66}
67
68fn make_counter_clockwise<P: Point<Scalar = f64>>(points: &mut [P]) {
69    if signed_area(points) < 0.0 {
70        points.reverse();
71    }
72}
73
74fn make_clockwise<P: Point<Scalar = f64>>(points: &mut [P]) {
75    if signed_area(points) > 0.0 {
76        points.reverse();
77    }
78}
79
80fn signed_area<P: Point<Scalar = f64>>(points: &[P]) -> f64 {
81    if points.len() < 3 {
82        return 0.0;
83    }
84    let mut area = 0.0;
85    for index in 0..points.len() {
86        let first = &points[index];
87        let second = &points[(index + 1) % points.len()];
88        area += first.get::<0>() * second.get::<1>() - second.get::<0>() * first.get::<1>();
89    }
90    area / 2.0
91}
92
93fn bridge_hole<P>(exterior: &[P], hole: &[P], holes: &[Vec<P>]) -> Option<Vec<P>>
94where
95    P: Point<Scalar = f64> + Copy,
96{
97    let hole_vertex = hole
98        .iter()
99        .enumerate()
100        .max_by(|(_, left), (_, right)| {
101            left.get::<0>()
102                .total_cmp(&right.get::<0>())
103                .then_with(|| right.get::<1>().total_cmp(&left.get::<1>()))
104        })?
105        .0;
106    let source = hole[hole_vertex];
107
108    let exterior_vertex = (0..exterior.len())
109        .filter(|&index| bridge_is_visible(source, exterior[index], index, exterior, hole, holes))
110        .min_by(|&left, &right| {
111            squared_distance(source, exterior[left])
112                .total_cmp(&squared_distance(source, exterior[right]))
113        })?;
114
115    let mut merged = Vec::with_capacity(exterior.len() + hole.len() + 2);
116    merged.extend_from_slice(&exterior[..=exterior_vertex]);
117    merged.push(source);
118    for offset in 1..hole.len() {
119        merged.push(hole[(hole_vertex + offset) % hole.len()]);
120    }
121    merged.push(source);
122    merged.push(exterior[exterior_vertex]);
123    merged.extend_from_slice(&exterior[exterior_vertex + 1..]);
124    Some(merged)
125}
126
127fn bridge_is_visible<P>(
128    source: P,
129    target: P,
130    target_index: usize,
131    exterior: &[P],
132    source_hole: &[P],
133    holes: &[Vec<P>],
134) -> bool
135where
136    P: Point<Scalar = f64> + Copy,
137{
138    if same_point(&source, &target) {
139        return false;
140    }
141    for edge in 0..exterior.len() {
142        let next = (edge + 1) % exterior.len();
143        if edge == target_index || next == target_index {
144            continue;
145        }
146        if segments_intersect(source, target, exterior[edge], exterior[next]) {
147            return false;
148        }
149    }
150    for ring in holes {
151        for edge in 0..ring.len() {
152            let next = (edge + 1) % ring.len();
153            if core::ptr::eq(ring.as_slice(), source_hole)
154                && (same_point(&ring[edge], &source) || same_point(&ring[next], &source))
155            {
156                continue;
157            }
158            if segments_intersect(source, target, ring[edge], ring[next]) {
159                return false;
160            }
161        }
162    }
163    let midpoint = [
164        source.get::<0>() / 2.0 + target.get::<0>() / 2.0,
165        source.get::<1>() / 2.0 + target.get::<1>() / 2.0,
166    ];
167    point_in_ring(midpoint, exterior)
168        && holes.iter().all(|ring| {
169            core::ptr::eq(ring.as_slice(), source_hole) || !point_in_ring(midpoint, ring)
170        })
171}
172
173fn clip_ears<P>(points: &[P]) -> Vec<ModelPolygon<P>>
174where
175    P: Point<Scalar = f64> + Copy,
176{
177    if points.len() < 3 {
178        return Vec::new();
179    }
180    let mut indices: Vec<usize> = (0..points.len()).collect();
181    let mut triangles = Vec::with_capacity(points.len().saturating_sub(2));
182    while indices.len() > 3 {
183        let mut clipped = false;
184        for position in 0..indices.len() {
185            let previous = indices[(position + indices.len() - 1) % indices.len()];
186            let current = indices[position];
187            let next = indices[(position + 1) % indices.len()];
188            if !is_ear(points, &indices, previous, current, next) {
189                continue;
190            }
191            triangles.push(triangle(points[previous], points[current], points[next]));
192            indices.remove(position);
193            clipped = true;
194            break;
195        }
196        if !clipped {
197            if let Some(position) = removable_collinear(points, &indices) {
198                indices.remove(position);
199            } else {
200                return Vec::new();
201            }
202        }
203    }
204    if orientation(points[indices[0]], points[indices[1]], points[indices[2]]) == 0.0 {
205        return Vec::new();
206    }
207    triangles.push(triangle(
208        points[indices[0]],
209        points[indices[1]],
210        points[indices[2]],
211    ));
212    triangles
213}
214
215fn is_ear<P>(points: &[P], polygon: &[usize], previous: usize, current: usize, next: usize) -> bool
216where
217    P: Point<Scalar = f64> + Copy,
218{
219    let a = points[previous];
220    let b = points[current];
221    let c = points[next];
222    if orientation(a, b, c) <= 0.0 {
223        return false;
224    }
225    if diagonal_crosses_polygon(points, polygon, previous, next) {
226        return false;
227    }
228    !polygon.iter().copied().any(|index| {
229        index != previous
230            && index != current
231            && index != next
232            && !same_point(&points[index], &a)
233            && !same_point(&points[index], &b)
234            && !same_point(&points[index], &c)
235            && point_in_triangle(points[index], a, b, c)
236    })
237}
238
239fn diagonal_crosses_polygon<P>(points: &[P], polygon: &[usize], first: usize, second: usize) -> bool
240where
241    P: Point<Scalar = f64> + Copy,
242{
243    for edge in 0..polygon.len() {
244        let edge_first = polygon[edge];
245        let edge_second = polygon[(edge + 1) % polygon.len()];
246        if edge_first == first
247            || edge_second == first
248            || edge_first == second
249            || edge_second == second
250            || same_point(&points[edge_first], &points[first])
251            || same_point(&points[edge_second], &points[first])
252            || same_point(&points[edge_first], &points[second])
253            || same_point(&points[edge_second], &points[second])
254        {
255            continue;
256        }
257        if segments_intersect(
258            points[first],
259            points[second],
260            points[edge_first],
261            points[edge_second],
262        ) {
263            return true;
264        }
265    }
266    false
267}
268
269fn removable_collinear<P>(points: &[P], polygon: &[usize]) -> Option<usize>
270where
271    P: Point<Scalar = f64> + Copy,
272{
273    (0..polygon.len()).find(|&position| {
274        let previous = polygon[(position + polygon.len() - 1) % polygon.len()];
275        let current = polygon[position];
276        let next = polygon[(position + 1) % polygon.len()];
277        same_point(&points[previous], &points[current])
278            || same_point(&points[current], &points[next])
279            || orientation(points[previous], points[current], points[next]) == 0.0
280    })
281}
282
283fn triangle<P: Point<Scalar = f64> + Copy>(a: P, b: P, c: P) -> ModelPolygon<P> {
284    // Ear clipping works counter-clockwise; swap the final two vertices so the
285    // stock polygon's default clockwise order reports positive area.
286    ModelPolygon::new(ModelRing::from_vec(vec![a, c, b, a]))
287}
288
289fn point_in_triangle<P: Point<Scalar = f64> + Copy>(point: P, a: P, b: P, c: P) -> bool {
290    orientation(a, b, point) >= 0.0
291        && orientation(b, c, point) >= 0.0
292        && orientation(c, a, point) >= 0.0
293}
294
295fn point_in_ring<P: Point<Scalar = f64>>(point: [f64; 2], ring: &[P]) -> bool {
296    let mut inside = false;
297    for index in 0..ring.len() {
298        let first = &ring[index];
299        let second = &ring[(index + 1) % ring.len()];
300        let crosses = (first.get::<1>() > point[1]) != (second.get::<1>() > point[1]);
301        if crosses {
302            let x = (second.get::<0>() - first.get::<0>()) * (point[1] - first.get::<1>())
303                / (second.get::<1>() - first.get::<1>())
304                + first.get::<0>();
305            if point[0] < x {
306                inside = !inside;
307            }
308        }
309    }
310    inside
311}
312
313fn segments_intersect<P>(a: P, b: P, c: P, d: P) -> bool
314where
315    P: Point<Scalar = f64> + Copy,
316{
317    let ab_c = orientation(a, b, c);
318    let ab_d = orientation(a, b, d);
319    let cd_a = orientation(c, d, a);
320    let cd_b = orientation(c, d, b);
321    if ab_c == 0.0 && on_segment(a, b, c) {
322        return true;
323    }
324    if ab_d == 0.0 && on_segment(a, b, d) {
325        return true;
326    }
327    if cd_a == 0.0 && on_segment(c, d, a) {
328        return true;
329    }
330    if cd_b == 0.0 && on_segment(c, d, b) {
331        return true;
332    }
333    (ab_c > 0.0) != (ab_d > 0.0) && (cd_a > 0.0) != (cd_b > 0.0)
334}
335
336#[allow(
337    clippy::needless_pass_by_value,
338    reason = "ear clipping operates on Copy point handles throughout"
339)]
340fn orientation<P: Point<Scalar = f64>>(a: P, b: P, c: P) -> f64 {
341    precise_math::orient2d(
342        [a.get::<0>(), a.get::<1>()],
343        [b.get::<0>(), b.get::<1>()],
344        [c.get::<0>(), c.get::<1>()],
345    )
346}
347
348fn on_segment<P: Point<Scalar = f64> + Copy>(a: P, b: P, point: P) -> bool {
349    point.get::<0>() >= a.get::<0>().min(b.get::<0>())
350        && point.get::<0>() <= a.get::<0>().max(b.get::<0>())
351        && point.get::<1>() >= a.get::<1>().min(b.get::<1>())
352        && point.get::<1>() <= a.get::<1>().max(b.get::<1>())
353}
354
355fn squared_distance<P: Point<Scalar = f64> + Copy>(first: P, second: P) -> f64 {
356    let dx = second.get::<0>() - first.get::<0>();
357    let dy = second.get::<1>() - first.get::<1>();
358    dx * dx + dy * dy
359}
360
361#[allow(
362    clippy::float_cmp,
363    reason = "coordinate identity, not approximate geometric equality, is required"
364)]
365fn same_point<P: Point<Scalar = f64>>(first: &P, second: &P) -> bool {
366    first.get::<0>() == second.get::<0>() && first.get::<1>() == second.get::<1>()
367}
368
369#[cfg(test)]
370mod tests {
371    use geometry_cs::Cartesian;
372    use geometry_model::{Point2D, Polygon, Ring};
373
374    use super::*;
375    use crate::area::area;
376
377    #[test]
378    fn concave_pentagon_becomes_three_triangles() {
379        type P = Point2D<f64, Cartesian>;
380        let polygon: Polygon<P> = Polygon::new(Ring::from_vec(alloc::vec![
381            P::new(0.0, 0.0),
382            P::new(0.0, 2.0),
383            P::new(1.0, 1.0),
384            P::new(2.0, 2.0),
385            P::new(2.0, 0.0),
386            P::new(0.0, 0.0),
387        ]));
388        let triangles = triangulate_earcut(&polygon);
389        assert_eq!(triangles.len(), 3);
390        let sum: f64 = triangles.iter().map(|triangle| area(triangle).abs()).sum();
391        assert!((sum - area(&polygon).abs()).abs() < 1e-12);
392    }
393
394    #[test]
395    fn square_with_square_hole_preserves_area() {
396        type P = Point2D<f64, Cartesian>;
397        let outer: Ring<P> = Ring::from_vec(alloc::vec![
398            P::new(0.0, 0.0),
399            P::new(0.0, 4.0),
400            P::new(4.0, 4.0),
401            P::new(4.0, 0.0),
402            P::new(0.0, 0.0),
403        ]);
404        let hole: Ring<P> = Ring::from_vec(alloc::vec![
405            P::new(1.0, 1.0),
406            P::new(3.0, 1.0),
407            P::new(3.0, 3.0),
408            P::new(1.0, 3.0),
409            P::new(1.0, 1.0),
410        ]);
411        let polygon = Polygon::with_inners(outer, alloc::vec![hole]);
412
413        let triangles = triangulate_earcut(&polygon);
414
415        assert_eq!(triangles.len(), 8);
416        let sum: f64 = triangles.iter().map(|triangle| area(triangle).abs()).sum();
417        assert!((sum - area(&polygon).abs()).abs() < 1e-12);
418    }
419
420    #[test]
421    fn private_degenerate_clipping_and_intersection_guards() {
422        type P = Point2D<f64, Cartesian>;
423        assert!(signed_area(&[P::new(0.0, 0.0), P::new(1.0, 0.0)]).abs() < f64::EPSILON);
424        assert!(clip_ears::<P>(&[]).is_empty());
425        assert!(
426            clip_ears(&[
427                P::new(0.0, 0.0),
428                P::new(1.0, 0.0),
429                P::new(2.0, 0.0),
430                P::new(3.0, 0.0),
431            ])
432            .is_empty()
433        );
434
435        assert!(segments_intersect(
436            P::new(0.0, 0.0),
437            P::new(2.0, 0.0),
438            P::new(1.0, 0.0),
439            P::new(1.0, 1.0),
440        ));
441        assert!(segments_intersect(
442            P::new(0.0, 0.0),
443            P::new(2.0, 0.0),
444            P::new(1.0, 1.0),
445            P::new(1.0, 0.0),
446        ));
447        assert!(segments_intersect(
448            P::new(1.0, 0.0),
449            P::new(1.0, 1.0),
450            P::new(0.0, 0.0),
451            P::new(2.0, 0.0),
452        ));
453        assert!(segments_intersect(
454            P::new(1.0, 1.0),
455            P::new(1.0, 0.0),
456            P::new(0.0, 0.0),
457            P::new(2.0, 0.0),
458        ));
459
460        let exterior = [P::new(0.0, 0.0), P::new(2.0, 0.0), P::new(0.0, 2.0)];
461        assert!(!bridge_is_visible(
462            exterior[0],
463            exterior[0],
464            0,
465            &exterior,
466            &[],
467            &[],
468        ));
469    }
470}