ifc_lite_geometry/
triangulation.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Polygon triangulation utilities
6//!
7//! Primary path: a deterministic Constrained Delaunay Triangulation
8//! (`crate::cdt`) that avoids the high-aspect sliver fans greedy ear-clipping
9//! produces. `earcutr` is retained as a robustness fallback for any degenerate
10//! input the CDT declines (self-touching rings, fully-collinear loops). See
11//! `crate::cdt` for the determinism / watertightness / bounded-refinement
12//! contract.
13
14#[cfg(feature = "triangulation-alt")]
15pub(crate) mod alt_oracle;
16mod ring_geom;
17use ring_geom::{point_in_ring, ring_bbox};
18
19use crate::{Error, Point2, Point3, Result, Vector3};
20
21/// Guarded ear-clipping — the ONLY sanctioned way to call `earcutr` in this
22/// crate.
23///
24/// `earcutr` 0.5's hole elimination bridges every "hole" into the outer ring
25/// and assumes the hole lies inside it. Real-world exports violate that:
26/// a Revit→Bonsai IFC4X3 door (found in production) authors an
27/// `IfcArbitraryProfileDefWithVoids` whose "voids" are disjoint rectangles
28/// entirely OUTSIDE the outer boundary (sibling door panels). Bridging an
29/// outside ring produces a self-intersecting loop on which earcutr's
30/// `filter_points` spins FOREVER — wedging the rayon worker (native) or the
31/// whole WASM worker (the browser's geometry-stream stall). There is no
32/// upstream fix to take (0.5.0 is current), so this wrapper makes the input
33/// safe first:
34///
35/// - any non-finite coordinate ⇒ the face is rejected (an error, like any
36///   other failed triangulation — callers already handle that);
37/// - consecutive bitwise-duplicate vertices are dropped per ring, including
38///   the wrap-around closing duplicate (`P0 … P0`) many exporters write;
39/// - rings that collapse below 3 vertices are dropped (a collapsed hole is
40///   simply ignored; a collapsed outer ring yields zero triangles);
41/// - each "hole" is classified before earcut sees it: contained in the outer
42///   ring ⇒ a real hole; fully disjoint from it ⇒ a SEPARATE polygon,
43///   triangulated independently so the (malformed but renderable) geometry
44///   still shows; straddling the boundary ⇒ dropped (no safe interpretation).
45///
46/// Returned indices are remapped to the CALLER's original vertex order, so
47/// call sites keep indexing their own arrays.
48/// Ear-clip one sanitised ring group. The ONLY place `safe_earcut` reaches a
49/// triangulator, so the test-only oracle can stand in here and see byte-identical
50/// input to the production path.
51fn ear_clip(data: &[f64], hole_indices: &[usize]) -> std::result::Result<Vec<usize>, String> {
52    #[cfg(feature = "triangulation-alt")]
53    if let Some(alt) = alt_oracle::maybe_earcut(data, hole_indices) {
54        return Ok(alt);
55    }
56    earcutr::earcut(data, hole_indices, 2).map_err(|e| format!("{e:?}"))
57}
58
59pub(crate) fn safe_earcut(
60    data: &[f64],
61    hole_indices: &[usize],
62    dims: usize,
63) -> std::result::Result<Vec<usize>, String> {
64    debug_assert_eq!(dims, 2, "safe_earcut is used for 2D rings only");
65    let n = data.len() / dims;
66
67    if data.iter().any(|c| !c.is_finite()) {
68        return Err("non-finite coordinate in polygon ring".to_string());
69    }
70
71    // FAST PATH (the overwhelming majority of calls): a single ring with no
72    // consecutive/closing duplicates needs no sanitisation, no classification,
73    // and no index remap — hand it straight to earcutr, zero-copy. Profile
74    // triangulation sits on the hot path for EVERY face in the pipeline.
75    if hole_indices.is_empty() {
76        let has_dup = n >= 2
77            && ((0..n - 1).any(|v| {
78                data[v * 2] == data[v * 2 + 2] && data[v * 2 + 1] == data[v * 2 + 3]
79            }) || (data[0] == data[(n - 1) * 2] && data[1] == data[(n - 1) * 2 + 1]));
80        if !has_dup {
81            return ear_clip(data, &[]);
82        }
83    }
84
85    // Ring boundaries in vertex indices: [start, end) per ring.
86    let mut ring_bounds: Vec<(usize, usize)> = Vec::with_capacity(hole_indices.len() + 1);
87    {
88        let mut start = 0usize;
89        for &h in hole_indices {
90            ring_bounds.push((start, h));
91            start = h;
92        }
93        ring_bounds.push((start, n));
94    }
95
96    // Sanitize each ring into its own (coords, original-indices) pair:
97    // drop consecutive duplicates incl. the closing wrap-around duplicate.
98    let mut rings: Vec<(Vec<f64>, Vec<usize>)> = Vec::with_capacity(ring_bounds.len());
99    for &(start, end) in &ring_bounds {
100        let mut coords: Vec<f64> = Vec::with_capacity((end - start) * 2);
101        let mut orig: Vec<usize> = Vec::with_capacity(end - start);
102        for v in start..end {
103            let (x, y) = (data[v * 2], data[v * 2 + 1]);
104            if let (Some(&px), Some(&py)) = (
105                coords.len().checked_sub(2).and_then(|i| coords.get(i)),
106                coords.len().checked_sub(1).and_then(|i| coords.get(i)),
107            ) {
108                if px == x && py == y {
109                    continue;
110                }
111            }
112            coords.push(x);
113            coords.push(y);
114            orig.push(v);
115        }
116        // Wrap-around closing duplicate.
117        if orig.len() >= 2
118            && coords[0] == coords[coords.len() - 2]
119            && coords[1] == coords[coords.len() - 1]
120        {
121            coords.truncate(coords.len() - 2);
122            orig.pop();
123        }
124        rings.push((coords, orig));
125    }
126
127    let (outer_coords, outer_orig) = &rings[0];
128    if outer_orig.len() < 3 {
129        // The outer boundary collapsed — nothing to triangulate.
130        return Ok(Vec::new());
131    }
132    let outer_bbox = ring_bbox(outer_coords);
133
134    // Classify each candidate hole.
135    let mut real_holes: Vec<usize> = Vec::new(); // index into `rings`
136    let mut separate_polys: Vec<usize> = Vec::new();
137    for (ring_no, (coords, orig)) in rings.iter().enumerate().skip(1) {
138        if orig.len() < 3 {
139            continue; // collapsed ring
140        }
141        let bbox = ring_bbox(coords);
142        let bbox_disjoint = bbox.2 < outer_bbox.0
143            || bbox.0 > outer_bbox.2
144            || bbox.3 < outer_bbox.1
145            || bbox.1 > outer_bbox.3;
146        if bbox_disjoint {
147            separate_polys.push(ring_no);
148        } else if coords
149            .chunks_exact(2)
150            .any(|p| point_in_ring(p[0], p[1], outer_coords))
151        {
152            // ANY vertex strictly inside ⇒ a real hole. The any-vertex vote
153            // keeps valid holes that touch the outer boundary (a vertex ON
154            // the boundary ray-casts as outside, but a touching hole's other
155            // vertices are interior).
156            real_holes.push(ring_no);
157        }
158        // else: overlapping bboxes but no vertex inside — straddles or sits
159        // against the outer boundary from outside. Drop it: there is no safe
160        // interpretation, and this is exactly the shape that wedges
161        // earcutr's bridge walk.
162    }
163
164    let mut out: Vec<usize> = Vec::new();
165
166    // Outer ring + its real holes in one earcut call.
167    {
168        let mut coords: Vec<f64> =
169            Vec::with_capacity(outer_coords.len() + real_holes.len() * 8);
170        let mut orig: Vec<usize> = Vec::with_capacity(outer_orig.len());
171        coords.extend_from_slice(outer_coords);
172        orig.extend_from_slice(outer_orig);
173        let mut holes: Vec<usize> = Vec::with_capacity(real_holes.len());
174        for &ring_no in &real_holes {
175            holes.push(orig.len());
176            coords.extend_from_slice(&rings[ring_no].0);
177            orig.extend_from_slice(&rings[ring_no].1);
178        }
179        let indices = ear_clip(&coords, &holes)?;
180        out.extend(indices.into_iter().map(|i| orig[i]));
181    }
182
183    // Disjoint "holes" render as their own hole-less polygons.
184    for &ring_no in &separate_polys {
185        let (coords, orig) = &rings[ring_no];
186        let indices = ear_clip(coords, &[])?;
187        out.extend(indices.into_iter().map(|i| orig[i]));
188    }
189
190    Ok(out)
191}
192
193/// Check if a polygon is convex (all cross products have same sign)
194#[inline]
195fn is_convex(points: &[Point2<f64>]) -> bool {
196    if points.len() < 3 {
197        return false;
198    }
199
200    let n = points.len();
201    let mut sign = 0i8;
202
203    for i in 0..n {
204        let p0 = &points[i];
205        let p1 = &points[(i + 1) % n];
206        let p2 = &points[(i + 2) % n];
207
208        // Cross product of edges
209        let cross = (p1.x - p0.x) * (p2.y - p1.y) - (p1.y - p0.y) * (p2.x - p1.x);
210
211        if cross.abs() > 1e-10 {
212            let current_sign = if cross > 0.0 { 1i8 } else { -1i8 };
213            if sign == 0 {
214                sign = current_sign;
215            } else if sign != current_sign {
216                return false; // Sign changed - not convex
217            }
218        }
219    }
220
221    true
222}
223
224/// Split a quad across the diagonal that lies INSIDE the ring.
225///
226/// This arm used to hard-code the 0-2 diagonal with no test at all, while the
227/// very next arm tested convexity. A fan is only valid from a vertex that SEES
228/// the whole ring, so on a CONCAVE quad the 0-2 split can cross outside the
229/// polygon: for the dart `[(0,0), (2,2), (4,0), (2,10)]` (reflex at vertex 1) the
230/// ring's signed area is +16.0 while `tri(0,1,2)` is -4.0 and `tri(0,2,3)` is
231/// +20.0, so the first triangle is wound BACKWARDS and lies entirely outside the
232/// polygon while the second covers that outside region twice. Every extruded
233/// profile cap reaches this (`extrusion.rs` via `triangulate_polygon_with_holes`),
234/// as do the faceted-brep, advanced-face and sectioned processors.
235///
236/// `orient(a, b, c)` is twice the signed area of that triangle. `c1` is the
237/// convexity of vertex 1, `c3` that of vertex 3, and `c1 + c3` is twice the
238/// ring's own signed area. A simple quad has at most ONE reflex vertex and the
239/// only interior diagonal is the one incident to it, so: the two agreeing in sign
240/// means neither 1 nor 3 is reflex and 0-2 is interior; disagreeing means one of
241/// them is, and 1-3 is. Either way both emitted triangles carry the ring's
242/// winding. A degenerate (collinear) vertex scores 0, which agrees with anything
243/// and keeps the old 0-2 split.
244///
245/// Kept as an orientation test rather than deleted in favour of the `is_convex`
246/// arm below, and not for speed: the delete measured 4 ns per call, under the
247/// cost of the `vec!` this call already pays. The reason that binds is winding.
248/// A concave quad that fell through would reach `safe_earcut`, and earcutr
249/// re-winds EVERY output counter-clockwise regardless of input, so a
250/// clockwise concave quad would come back flipped. This arm splits it across
251/// the interior diagonal in the ring's own winding, which
252/// `concave_quad_split_is_correct_for_a_clockwise_ring` pins; deleting the
253/// arm fails that test with both triangles at +16. Convex quads keep
254/// byte-identical output either way.
255#[inline]
256fn quad_indices(p: &[Point2<f64>]) -> Vec<usize> {
257    let orient = |a: &Point2<f64>, b: &Point2<f64>, c: &Point2<f64>| {
258        (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)
259    };
260    let c1 = orient(&p[0], &p[1], &p[2]);
261    let c3 = orient(&p[2], &p[3], &p[0]);
262    if c1 * c3 >= 0.0 {
263        vec![0, 1, 2, 0, 2, 3]
264    } else {
265        vec![1, 2, 3, 1, 3, 0]
266    }
267}
268
269/// Simple fan triangulation for convex polygons
270#[inline]
271fn fan_triangulate(n: usize) -> Vec<usize> {
272    let mut indices = Vec::with_capacity((n - 2) * 3);
273    for i in 1..n - 1 {
274        indices.push(0);
275        indices.push(i);
276        indices.push(i + 1);
277    }
278    indices
279}
280
281/// Triangulate a simple polygon (no holes)
282/// Returns triangle indices into the input points
283#[inline]
284pub fn triangulate_polygon(points: &[Point2<f64>]) -> Result<Vec<usize>> {
285    let n = points.len();
286
287    if n < 3 {
288        return Err(Error::TriangulationError(
289            "Need at least 3 points to triangulate".to_string(),
290        ));
291    }
292
293    // FAST PATH: Triangle - no triangulation needed
294    if n == 3 {
295        return Ok(vec![0, 1, 2]);
296    }
297
298    // FAST PATH: Quad - fan across the diagonal that lies INSIDE the ring.
299    if n == 4 {
300        return Ok(quad_indices(points));
301    }
302
303    // FAST PATH: Convex polygon - use fan triangulation
304    if n <= 8 && is_convex(points) {
305        return Ok(fan_triangulate(n));
306    }
307
308    // earcutr. (A no-Steiner CDT was tried here and reverted: this function is
309    // on the hot path for EVERY profile/face in the pipeline, and Bowyer-Watson
310    // costs ~2x total CSG time on opening-heavy models. The quality CDT runs
311    // only on the consolidate path via triangulate_polygon_with_holes_refined,
312    // which is where the sliver-prone cut faces are re-triangulated.)
313    let mut vertices = Vec::with_capacity(n * 2);
314    for p in points {
315        vertices.push(p.x);
316        vertices.push(p.y);
317    }
318    let indices = safe_earcut(&vertices, &[], 2).map_err(Error::TriangulationError)?;
319
320    Ok(indices)
321}
322
323/// Triangulate a polygon with holes
324/// Returns triangle indices into the combined vertex array (outer + all holes)
325#[inline]
326pub fn triangulate_polygon_with_holes(
327    outer: &[Point2<f64>],
328    holes: &[Vec<Point2<f64>>],
329) -> Result<Vec<usize>> {
330    if outer.len() < 3 {
331        return Err(Error::TriangulationError(
332            "Need at least 3 points in outer boundary".to_string(),
333        ));
334    }
335
336    // FAST PATH: No holes - use optimized simple triangulation
337    // Filter out empty or invalid holes
338    let valid_holes: Vec<&Vec<Point2<f64>>> = holes.iter().filter(|h| h.len() >= 3).collect();
339
340    if valid_holes.is_empty() {
341        return triangulate_polygon(outer);
342    }
343
344    // earcutr. (See triangulate_polygon: the no-Steiner CDT here was reverted
345    // for hot-path cost; the consolidate path uses the quality CDT via
346    // triangulate_polygon_with_holes_refined.)
347    let total_points: usize = outer.len() + valid_holes.iter().map(|h| h.len()).sum::<usize>();
348    let mut vertices = Vec::with_capacity(total_points * 2);
349    for p in outer {
350        vertices.push(p.x);
351        vertices.push(p.y);
352    }
353    let mut hole_indices = Vec::with_capacity(valid_holes.len());
354    for hole in valid_holes {
355        hole_indices.push(vertices.len() / 2);
356        for p in hole {
357            vertices.push(p.x);
358            vertices.push(p.y);
359        }
360    }
361    let indices =
362        safe_earcut(&vertices, &hole_indices, 2).map_err(Error::TriangulationError)?;
363
364    Ok(indices)
365}
366
367/// Quality-triangulate a polygon-with-holes WITH bounded Ruppert min-angle
368/// refinement (Steiner points allowed). Returns the augmented 2D vertex list
369/// (input vertices in `outer ++ holes` order, followed by Steiner points) and
370/// triangle indices into it.
371///
372/// Use this ONLY from callers that lift a generic vertex list to 3D (the
373/// coplanar-consolidation path), NOT from callers that map indices onto a fixed
374/// input ring — those must use [`triangulate_polygon_with_holes`]. Returns the
375/// outer+holes vertex list and an earcut index list (no Steiner) if the CDT
376/// declines, so the caller always gets a usable result.
377///
378/// Refinement is interior-only and NEVER touches the outer/hole rings, so a
379/// region whose boundary is SHARED with neighbouring plane buckets (the
380/// consolidate path — the only caller) stays watertight at the seam (no
381/// boundary Steiner T-junction). See [`crate::cdt::triangulate_refined`] for
382/// why this is the only supported mode.
383pub fn triangulate_polygon_with_holes_refined(
384    outer: &[Point2<f64>],
385    holes: &[Vec<Point2<f64>>],
386) -> Result<(Vec<Point2<f64>>, Vec<usize>)> {
387    if outer.len() < 3 {
388        return Err(Error::TriangulationError(
389            "Need at least 3 points in outer boundary".to_string(),
390        ));
391    }
392    let valid_holes: Vec<Vec<Point2<f64>>> =
393        holes.iter().filter(|h| h.len() >= 3).cloned().collect();
394
395    // Quality CDT + bounded refinement.
396    if let Some((pts, idx)) = crate::cdt::triangulate_refined(outer, &valid_holes) {
397        return Ok((pts, idx));
398    }
399
400    // FALLBACK: earcut over the un-refined vertex set (outer ++ holes).
401    let mut all: Vec<Point2<f64>> = outer.to_vec();
402    for h in &valid_holes {
403        all.extend_from_slice(h);
404    }
405    let idx = if valid_holes.is_empty() {
406        triangulate_polygon(outer)?
407    } else {
408        triangulate_polygon_with_holes(outer, &valid_holes)?
409    };
410    Ok((all, idx))
411}
412
413/// Project 3D points onto a 2D plane defined by a normal
414/// Returns 2D points and the coordinate system (u_axis, v_axis, origin)
415#[inline]
416pub fn project_to_2d(
417    points_3d: &[Point3<f64>],
418    normal: &Vector3<f64>,
419) -> (Vec<Point2<f64>>, Vector3<f64>, Vector3<f64>, Point3<f64>) {
420    if points_3d.is_empty() {
421        return (
422            Vec::new(),
423            Vector3::zeros(),
424            Vector3::zeros(),
425            Point3::origin(),
426        );
427    }
428
429    // Use first point as origin
430    let origin = points_3d[0];
431
432    // Create orthonormal basis on the plane
433    // Find the axis least parallel to the normal for stable cross product
434    let abs_x = normal.x.abs();
435    let abs_y = normal.y.abs();
436    let abs_z = normal.z.abs();
437
438    let reference = if abs_x <= abs_y && abs_x <= abs_z {
439        Vector3::new(1.0, 0.0, 0.0)
440    } else if abs_y <= abs_z {
441        Vector3::new(0.0, 1.0, 0.0)
442    } else {
443        Vector3::new(0.0, 0.0, 1.0)
444    };
445
446    let u_axis = normal.cross(&reference).normalize();
447    let v_axis = normal.cross(&u_axis).normalize();
448
449    // Project all points to 2D
450    let points_2d = points_3d
451        .iter()
452        .map(|p| {
453            let v = p - origin;
454            Point2::new(v.dot(&u_axis), v.dot(&v_axis))
455        })
456        .collect();
457
458    (points_2d, u_axis, v_axis, origin)
459}
460
461/// Project 3D points using an existing coordinate system
462/// This ensures multiple sets of points use the same 2D space
463#[inline]
464pub fn project_to_2d_with_basis(
465    points_3d: &[Point3<f64>],
466    u_axis: &Vector3<f64>,
467    v_axis: &Vector3<f64>,
468    origin: &Point3<f64>,
469) -> Vec<Point2<f64>> {
470    points_3d
471        .iter()
472        .map(|p| {
473            let v = p - origin;
474            Point2::new(v.dot(u_axis), v.dot(v_axis))
475        })
476        .collect()
477}
478
479/// Calculate the normal of a polygon from its vertices
480/// Optimized for triangles and quads using simple cross product
481#[inline]
482pub fn calculate_polygon_normal(points: &[Point3<f64>]) -> Vector3<f64> {
483    let n = points.len();
484
485    if n < 3 {
486        return Vector3::new(0.0, 0.0, 1.0);
487    }
488
489    // FAST PATH: Triangle or quad - use simple cross product
490    if n <= 4 {
491        let v1 = points[1] - points[0];
492        let v2 = points[2] - points[0];
493        let normal = v1.cross(&v2);
494        let len = normal.norm();
495        if len > 1e-10 {
496            return normal / len;
497        }
498        // Fallback for degenerate triangles
499        if n == 4 {
500            // Try different edges for quad
501            let v3 = points[3] - points[0];
502            let normal = v2.cross(&v3);
503            let len = normal.norm();
504            if len > 1e-10 {
505                return normal / len;
506            }
507        }
508        return Vector3::new(0.0, 0.0, 1.0);
509    }
510
511    // Use Newell's method for robust normal calculation on complex polygons
512    let mut normal = Vector3::<f64>::zeros();
513
514    for i in 0..n {
515        let current = &points[i];
516        let next = &points[(i + 1) % n];
517
518        normal.x += (current.y - next.y) * (current.z + next.z);
519        normal.y += (current.z - next.z) * (current.x + next.x);
520        normal.z += (current.x - next.x) * (current.y + next.y);
521    }
522
523    let len = normal.norm();
524    if len > 1e-10 {
525        normal.normalize()
526    } else {
527        Vector3::new(0.0, 0.0, 1.0)
528    }
529}
530
531#[cfg(test)]
532#[path = "triangulation_tests.rs"]
533mod tests;