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;