Skip to main content

brepkit_math/cdt/
mod.rs

1//! Constrained Delaunay Triangulation (CDT).
2//!
3//! Implements an incremental CDT using a triangle-adjacency data structure.
4//! Uses exact geometric predicates ([`orient2d`] and [`in_circle`]) for
5//! robustness.
6//!
7//! # Algorithm
8//!
9//! - **Point insertion**: Bowyer-Watson incremental insertion with edge
10//!   legalization.
11//! - **Constraint insertion**: Sloan-style edge recovery by flipping
12//!   intersecting edges.
13//! - **Exterior removal**: Flood-fill from super-triangle, stopping at
14//!   constrained edges.
15
16#![allow(
17    clippy::many_single_char_names,
18    clippy::similar_names,
19    clippy::needless_range_loop,
20    clippy::suboptimal_flops,
21    clippy::manual_slice_fill,
22    clippy::option_if_let_else,
23    clippy::let_and_return,
24    clippy::unnecessary_wraps,
25    clippy::doc_markdown,
26    clippy::cast_precision_loss,
27    clippy::missing_const_for_fn,
28    clippy::manual_let_else
29)]
30
31mod adjacency;
32mod constraints;
33mod insert;
34mod locate;
35#[cfg(test)]
36mod tests;
37
38use crate::det_hash::DetHashSet;
39
40use crate::MathError;
41use crate::predicates::{in_circle, orient2d};
42use crate::vec::Point2;
43
44/// Fast floating-point in-circle test with error bound.
45///
46/// Computes the in-circle determinant using standard f64 arithmetic.
47/// If the magnitude exceeds the error bound, returns the result directly.
48/// Otherwise, falls back to the exact `in_circle` predicate.
49///
50/// The error bound is derived from Shewchuk's analysis: the maximum
51/// rounding error of the 4×4 determinant is bounded by
52/// `εB * |det|` where εB depends on the matrix entries.
53#[inline]
54fn fast_in_circle(a: Point2, b: Point2, c: Point2, d: Point2) -> f64 {
55    let adx = a.x() - d.x();
56    let ady = a.y() - d.y();
57    let bdx = b.x() - d.x();
58    let bdy = b.y() - d.y();
59    let cdx = c.x() - d.x();
60    let cdy = c.y() - d.y();
61
62    let abdet = adx * bdy - bdx * ady;
63    let bcdet = bdx * cdy - cdx * bdy;
64    let cadet = cdx * ady - adx * cdy;
65    let alift = adx * adx + ady * ady;
66    let blift = bdx * bdx + bdy * bdy;
67    let clift = cdx * cdx + cdy * cdy;
68
69    let det = alift * bcdet + blift * cadet + clift * abdet;
70
71    // Error bound (conservative): if |det| >> sum of absolute products,
72    // the sign is reliable. Use Shewchuk's iccerrboundA ≈ 10ε where
73    // ε ≈ 2^-53. For our tolerance, 1e-10 * permanent works well.
74    let permanent = alift * ((bdx * cdy).abs() + (cdx * bdy).abs())
75        + blift * ((cdx * ady).abs() + (adx * cdy).abs())
76        + clift * ((adx * bdy).abs() + (bdx * ady).abs());
77
78    // Error bound coefficient: 10 * 2^-53 ≈ 1.11e-15
79    let errbound = 1.11e-15 * permanent;
80
81    if det > errbound || det < -errbound {
82        det
83    } else {
84        // Near zero — use exact predicate
85        in_circle(a, b, c, d)
86    }
87}
88
89// ---------------------------------------------------------------------------
90// Data structures
91// ---------------------------------------------------------------------------
92
93/// A triangle in the CDT.
94struct CdtTriangle {
95    /// Vertex indices in counter-clockwise order.
96    v: [usize; 3],
97    /// Adjacent triangle across the edge opposite vertex `v[i]`.
98    /// Edge opposite `v[i]` is `(v[(i+1)%3], v[(i+2)%3])`.
99    adj: [Option<usize>; 3],
100    /// Whether this triangle has been removed (exterior or deleted).
101    removed: bool,
102}
103
104/// Half-edge based Constrained Delaunay Triangulation.
105pub struct Cdt {
106    vertices: Vec<Point2>,
107    triangles: Vec<CdtTriangle>,
108    /// Set of constrained edges stored as sorted `(min, max)` vertex pairs.
109    constraints: DetHashSet<(usize, usize)>,
110    /// Number of super-triangle vertices at the start of the vertex list.
111    super_count: usize,
112    /// Spatial hash for O(1) amortized duplicate point detection.
113    dup_grid: std::collections::HashMap<(i64, i64), Vec<usize>>,
114    /// Last successfully located triangle — used as starting point for the
115    /// walking search to exploit spatial coherence in insertion order.
116    last_located: usize,
117    /// Vertex → one incident triangle index for O(1) edge lookups.
118    /// Updated on triangle creation/removal.
119    vertex_tri: Vec<usize>,
120    /// Points minted by constraint recovery (crossing splits and bisection
121    /// backstops), as opposed to points the caller inserted.
122    ///
123    /// Recovery is mutually recursive and each level can mint a point and two
124    /// sub-constraints, so a pathological corridor grows `vertices`,
125    /// `constraints` and `dup_grid` without a structural bound. Natively that
126    /// reads as a hang; on wasm32 the 32-bit `usize` puts a hash table past
127    /// its maximum first and hashbrown aborts the whole kernel with
128    /// "Hash table capacity overflow" (#1517). The budget below turns that
129    /// into a local `ConvergenceFailure`.
130    recovery_inserts: usize,
131    /// Index for the on-segment vertex scan in `insert_constraint`.
132    vertex_grid: VertexGrid,
133}
134
135/// Uniform grid over the inserted vertices, so finding the vertices that lie
136/// on a new constraint costs the segment's neighbourhood instead of every
137/// vertex. A face with hundreds of holes inserts thousands of constraints.
138#[derive(Default)]
139struct VertexGrid {
140    origin: (f64, f64),
141    inv_cell: f64,
142    nx: usize,
143    ny: usize,
144    cells: Vec<Vec<usize>>,
145    /// Vertices below this index are in `cells`.
146    indexed: usize,
147    /// Number of vertices `cells` was laid out for.
148    laid_out_for: usize,
149}
150
151/// Below this many vertices the on-segment scan reads every vertex.
152const VERTEX_GRID_MIN: usize = 64;
153
154impl VertexGrid {
155    /// Index the vertices added since the last call, laying the grid out
156    /// again whenever the count has doubled so cells stay about one vertex.
157    #[allow(
158        clippy::cast_possible_truncation,
159        clippy::cast_sign_loss,
160        clippy::cast_precision_loss
161    )]
162    fn sync(&mut self, vertices: &[Point2], first: usize) {
163        let n = vertices.len().saturating_sub(first);
164        if n < VERTEX_GRID_MIN {
165            return;
166        }
167        if self.cells.is_empty() || n > 2 * self.laid_out_for {
168            let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
169            for p in &vertices[first..] {
170                if p.x().is_finite() && p.y().is_finite() {
171                    x0 = x0.min(p.x());
172                    y0 = y0.min(p.y());
173                    x1 = x1.max(p.x());
174                    y1 = y1.max(p.y());
175                }
176            }
177            if x0 > x1 {
178                (x0, y0, x1, y1) = (0.0, 0.0, 0.0, 0.0);
179            }
180            let (w, h) = (x1 - x0, y1 - y0);
181            let area_cell = (w * h / n as f64).sqrt();
182            let cell = if area_cell > 0.0 {
183                area_cell
184            } else if w.max(h) > 0.0 {
185                w.max(h) / n as f64
186            } else {
187                1.0
188            };
189            let cap = 4 * n;
190            self.nx = ((w / cell) as usize + 1).clamp(1, cap);
191            self.ny = ((h / cell) as usize + 1).clamp(1, (cap / self.nx).max(1));
192            self.origin = (x0, y0);
193            self.inv_cell = 1.0 / cell;
194            self.cells = vec![Vec::new(); self.nx * self.ny];
195            self.indexed = first;
196            self.laid_out_for = n;
197        }
198        for (vi, &p) in vertices.iter().enumerate().skip(self.indexed) {
199            let (ix, iy) = self.cell_of(p.x(), p.y());
200            self.cells[iy * self.nx + ix].push(vi);
201        }
202        self.indexed = vertices.len();
203    }
204
205    /// Cell holding `(x, y)`, clamped onto the grid. The clamp is monotonic,
206    /// so a box query still reaches vertices that fell outside the grid.
207    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
208    fn cell_of(&self, x: f64, y: f64) -> (usize, usize) {
209        let ix = ((x - self.origin.0) * self.inv_cell).floor().max(0.0) as usize;
210        let iy = ((y - self.origin.1) * self.inv_cell).floor().max(0.0) as usize;
211        (ix.min(self.nx - 1), iy.min(self.ny - 1))
212    }
213
214    /// Every indexed vertex inside `[lo, hi]` (and possibly some outside), in
215    /// ascending order, or `None` when the grid is not in use or the box
216    /// spans more cells than reading every vertex would cost.
217    fn near(&self, lo: Point2, hi: Point2, first: usize) -> Option<Vec<usize>> {
218        if self.cells.is_empty() {
219            return None;
220        }
221        let (ix0, iy0) = self.cell_of(lo.x(), lo.y());
222        let (ix1, iy1) = self.cell_of(hi.x(), hi.y());
223        if (ix1 - ix0 + 1) * (iy1 - iy0 + 1) > self.indexed - first {
224            return None;
225        }
226        let mut out = Vec::new();
227        for iy in iy0..=iy1 {
228            for ix in ix0..=ix1 {
229                out.extend_from_slice(&self.cells[iy * self.nx + ix]);
230            }
231        }
232        out.sort_unstable();
233        Some(out)
234    }
235}
236
237/// Cap on points constraint recovery may mint for one triangulation.
238///
239/// A legitimate crossing set costs one point per crossing pair, so this leaves
240/// room for ~180 mutually crossing constraints in one triangulation. Face
241/// boundaries do not cross by construction and recovery mints nothing at all
242/// across the 823 `brepkit-operations` tests, so in practice this only binds on
243/// a runaway corridor. Raise it only with a measurement showing a legitimate
244/// face that needs more.
245const MAX_RECOVERY_INSERTS: usize = 16_384;
246
247/// Duplicate point detection tolerance.
248///
249/// Aligned with the snap tolerance (1e-8) to avoid near-coincident points
250/// that pass the duplicate check but create degenerate triangles.
251const DUP_TOL: f64 = 1e-8;
252
253// ---------------------------------------------------------------------------
254// Public API
255// ---------------------------------------------------------------------------
256
257impl Cdt {
258    /// Create a new CDT with a super-triangle that contains the given bounds.
259    ///
260    /// The bounds `(min, max)` define an axis-aligned rectangle. The
261    /// super-triangle is constructed large enough to enclose this rectangle
262    /// with margin.
263    #[must_use]
264    pub fn new(bounds: (Point2, Point2)) -> Self {
265        Self::with_capacity(bounds, 0)
266    }
267
268    /// Create a new CDT with pre-allocated capacity for `n` points.
269    ///
270    /// Pre-allocates vertex and triangle storage to avoid reallocations
271    /// during bulk insertion. Each point insertion creates ~2 triangles,
272    /// so `2*n + 1` triangle slots are allocated.
273    #[must_use]
274    pub fn with_capacity(bounds: (Point2, Point2), n: usize) -> Self {
275        let (min, max) = bounds;
276        let dx = max.x() - min.x();
277        let dy = max.y() - min.y();
278        let margin = (dx.max(dy)).mul_add(10.0, 1.0);
279        let cx = 0.5 * (min.x() + max.x());
280        let cy = 0.5 * (min.y() + max.y());
281
282        // Super-triangle vertices (large enough to contain everything).
283        let s0 = Point2::new(cx - margin * 2.0, cy - margin);
284        let s1 = Point2::new(cx + margin * 2.0, cy - margin);
285        let s2 = Point2::new(cx, cy + margin * 2.0);
286
287        let mut vertices = Vec::with_capacity(n + 3);
288        vertices.push(s0);
289        vertices.push(s1);
290        vertices.push(s2);
291
292        let mut triangles = Vec::with_capacity(2 * n + 1);
293        triangles.push(CdtTriangle {
294            v: [0, 1, 2],
295            adj: [None, None, None],
296            removed: false,
297        });
298
299        let mut vertex_tri = Vec::with_capacity(n + 3);
300        vertex_tri.extend([0, 0, 0]); // all 3 super-verts → tri 0
301
302        Self {
303            vertices,
304            triangles,
305            constraints: DetHashSet::default(),
306            super_count: 3,
307            dup_grid: std::collections::HashMap::new(),
308            last_located: 0,
309            vertex_tri,
310            recovery_inserts: 0,
311            vertex_grid: VertexGrid::default(),
312        }
313    }
314
315    /// Insert a point into the triangulation.
316    ///
317    /// Returns the vertex index of the inserted point. If the point is a
318    /// duplicate of an existing vertex (within tolerance), the existing
319    /// vertex index is returned.
320    ///
321    /// # Errors
322    ///
323    /// Returns [`MathError::ConvergenceFailure`] if the point cannot be
324    /// located in any triangle (should not happen for valid inputs).
325    pub fn insert_point(&mut self, p: Point2) -> Result<usize, MathError> {
326        let cell = dup_grid_cell(p);
327        // Check the cell and its 8 neighbors to handle points near cell boundaries.
328        for dx in -1..=1_i64 {
329            for dy in -1..=1_i64 {
330                let neighbor = (cell.0 + dx, cell.1 + dy);
331                if let Some(indices) = self.dup_grid.get(&neighbor) {
332                    for &i in indices {
333                        let d = p - self.vertices[i];
334                        if d.length_squared() < DUP_TOL * DUP_TOL {
335                            return Ok(i);
336                        }
337                    }
338                }
339            }
340        }
341
342        let vi = self.vertices.len();
343        self.vertices.push(p);
344        self.vertex_tri.push(0); // will be updated by split_triangle/split_edge
345        self.dup_grid.entry(cell).or_default().push(vi);
346
347        let (tri_idx, location) = self.locate_point(p)?;
348        self.last_located = tri_idx;
349
350        match location {
351            locate::PointLocation::Inside => {
352                self.split_triangle(tri_idx, vi);
353            }
354            locate::PointLocation::OnEdge(local_edge) => {
355                self.split_edge(tri_idx, local_edge, vi);
356            }
357        }
358
359        Ok(vi)
360    }
361
362    /// Bulk-insert points sorted by Hilbert curve for O(1) amortized locate.
363    ///
364    /// Returns a `Vec` where `result[original_index]` is the CDT vertex index.
365    /// Points near the Hilbert curve walk path are inserted together, so each
366    /// `locate_point` call starts close to the target triangle.
367    ///
368    /// # Errors
369    ///
370    /// Returns [`MathError::ConvergenceFailure`] if any point cannot be located.
371    pub fn insert_points_hilbert(&mut self, points: &[Point2]) -> Result<Vec<usize>, MathError> {
372        if points.is_empty() {
373            return Ok(Vec::new());
374        }
375
376        let mut min_x = f64::INFINITY;
377        let mut max_x = f64::NEG_INFINITY;
378        let mut min_y = f64::INFINITY;
379        let mut max_y = f64::NEG_INFINITY;
380        for p in points {
381            min_x = min_x.min(p.x());
382            max_x = max_x.max(p.x());
383            min_y = min_y.min(p.y());
384            max_y = max_y.max(p.y());
385        }
386
387        let range = (max_x - min_x).max(max_y - min_y).max(1e-10);
388        let n = 1u32 << 16; // 65536 grid resolution
389        let scale = f64::from(n - 1) / range;
390
391        // Sort by Hilbert index for spatial locality.
392        let mut order: Vec<(u64, usize)> = points
393            .iter()
394            .enumerate()
395            .map(|(i, p)| {
396                let gx = ((p.x() - min_x) * scale) as u32;
397                let gy = ((p.y() - min_y) * scale) as u32;
398                (hilbert_xy_to_d(n, gx.min(n - 1), gy.min(n - 1)), i)
399            })
400            .collect();
401        order.sort_unstable_by_key(|&(h, _)| h);
402
403        // Insert in Hilbert order, storing results in original order.
404        let mut result = vec![0usize; points.len()];
405        for &(_, orig_idx) in &order {
406            let cdt_idx = self.insert_point(points[orig_idx])?;
407            result[orig_idx] = cdt_idx;
408        }
409
410        Ok(result)
411    }
412
413    /// Insert a constraint edge between two existing vertices.
414    ///
415    /// The edge is recovered by flipping intersecting unconstrained edges
416    /// until the constraint edge appears in the triangulation.
417    ///
418    /// # Errors
419    ///
420    /// Returns [`MathError::ConvergenceFailure`] if the constraint cannot
421    /// be recovered after the maximum number of iterations.
422    pub fn insert_constraint(&mut self, v0: usize, v1: usize) -> Result<(), MathError> {
423        if v0 == v1 {
424            return Ok(());
425        }
426        let key = sorted_pair(v0, v1);
427        if self.constraints.contains(&key) {
428            return Ok(());
429        }
430
431        // Scan for existing vertices that lie on the constraint segment.
432        // If found, recursively split the constraint through them so that
433        // recover_edge never encounters a collinear interior vertex (which
434        // causes flip-recovery deadlocks on full-revolution face seams).
435        let p0 = self.vertices[v0];
436        let p1 = self.vertices[v1];
437        let dx = p1.x() - p0.x();
438        let dy = p1.y() - p0.y();
439        let seg_len_sq = dx * dx + dy * dy;
440
441        if seg_len_sq > 0.0 {
442            // A vertex the test below accepts lies within 1e-6 of the
443            // segment's length of it; the pad leaves room for rounding.
444            let pad = 1e-5 * seg_len_sq.sqrt();
445            let lo = Point2::new(p0.x().min(p1.x()) - pad, p0.y().min(p1.y()) - pad);
446            let hi = Point2::new(p0.x().max(p1.x()) + pad, p0.y().max(p1.y()) + pad);
447            self.vertex_grid.sync(&self.vertices, self.super_count);
448            let near = self.vertex_grid.near(lo, hi, self.super_count);
449
450            let mut collinear: Vec<(f64, usize)> = Vec::new();
451            let mut test = |vi: usize| {
452                if vi == v0 || vi == v1 {
453                    return;
454                }
455                let px = self.vertices[vi].x() - p0.x();
456                let py = self.vertices[vi].y() - p0.y();
457                let t = (px * dx + py * dy) / seg_len_sq;
458                if t <= 1e-6 || t >= 1.0 - 1e-6 {
459                    return;
460                }
461                let cross = px * dy - py * dx;
462                let dist_sq = cross * cross / seg_len_sq;
463                if dist_sq < 1e-12 * seg_len_sq {
464                    collinear.push((t, vi));
465                }
466            };
467            match near {
468                Some(candidates) => candidates.into_iter().for_each(&mut test),
469                None => (self.super_count..self.vertices.len()).for_each(&mut test),
470            }
471
472            if !collinear.is_empty() {
473                collinear.sort_by(|a, b| a.0.total_cmp(&b.0));
474                collinear.dedup_by(|a, b| (a.0 - b.0).abs() < 1e-8);
475                let mut prev = v0;
476                for &(_, vi) in &collinear {
477                    self.insert_constraint(prev, vi)?;
478                    prev = vi;
479                }
480                self.insert_constraint(prev, v1)?;
481                return Ok(());
482            }
483        }
484
485        // Recover the edge by flipping.
486        self.recover_edge(v0, v1)?;
487        self.constraints.insert(key);
488        Ok(())
489    }
490
491    /// Get the triangles as index triples (vertex indices).
492    ///
493    /// Only returns non-removed triangles that do not reference
494    /// super-triangle vertices.
495    #[must_use]
496    pub fn triangles(&self) -> Vec<(usize, usize, usize)> {
497        let sc = self.super_count;
498        self.triangles
499            .iter()
500            .filter(|t| !t.removed)
501            .filter(|t| t.v[0] >= sc && t.v[1] >= sc && t.v[2] >= sc)
502            .map(|t| (t.v[0], t.v[1], t.v[2]))
503            .collect()
504    }
505
506    /// Get the vertices.
507    #[must_use]
508    pub fn vertices(&self) -> &[Point2] {
509        &self.vertices
510    }
511
512    /// Remove triangles outside the boundary defined by constraint edges.
513    ///
514    /// Flood-fills from super-triangle-adjacent triangles, stopping at
515    /// constraint edges. Also removes any triangle that references a
516    /// super-triangle vertex.
517    pub fn remove_exterior(&mut self, boundary: &[(usize, usize)]) {
518        // Build the constraint set for boundary edges.
519        let boundary_set: DetHashSet<(usize, usize)> =
520            boundary.iter().map(|&(a, b)| sorted_pair(a, b)).collect();
521
522        // Merge with existing constraints for the flood-fill barrier.
523        let all_constraints: DetHashSet<(usize, usize)> =
524            self.constraints.union(&boundary_set).copied().collect();
525
526        // Start flood-fill from triangles touching super-triangle vertices.
527        let mut stack: Vec<usize> = Vec::new();
528        let sc = self.super_count;
529
530        for (i, tri) in self.triangles.iter().enumerate() {
531            if tri.removed {
532                continue;
533            }
534            if tri.v[0] < sc || tri.v[1] < sc || tri.v[2] < sc {
535                stack.push(i);
536            }
537        }
538
539        // Flood-fill, marking triangles as removed.
540        while let Some(ti) = stack.pop() {
541            if self.triangles[ti].removed {
542                continue;
543            }
544            self.triangles[ti].removed = true;
545
546            // Check each edge — if not a constraint boundary, propagate.
547            for local in 0..3 {
548                let va = self.triangles[ti].v[(local + 1) % 3];
549                let vb = self.triangles[ti].v[(local + 2) % 3];
550                let edge_key = sorted_pair(va, vb);
551
552                if all_constraints.contains(&edge_key) {
553                    continue; // Don't cross constraint edges.
554                }
555
556                if let Some(adj) = self.triangles[ti].adj[local]
557                    && !self.triangles[adj].removed
558                {
559                    stack.push(adj);
560                }
561            }
562        }
563
564        // Note: remove_hole_interiors is only needed when there are inner loops
565        // (holes within the boundary). For simple polygons, the exterior
566        // flood-fill is sufficient.
567    }
568
569    /// Remove all non-removed triangles reachable from the triangle containing
570    /// `seed`, stopping at constraint edges.
571    ///
572    /// This is the standard CDT hole-removal approach: given a point known to
573    /// be inside a hole, find its containing triangle and flood-fill remove.
574    ///
575    /// Returns `true` if the seed triangle was found and removal occurred,
576    /// `false` if no triangle contains the seed point (e.g. concave hole
577    /// centroid falling outside the polygon).
578    pub fn flood_remove_from_point(
579        &mut self,
580        seed: Point2,
581        constraints: &DetHashSet<(usize, usize)>,
582    ) -> bool {
583        // The flood must also respect the CDT's OWN constraints: edge
584        // recovery may have split a caller-known constraint into sub-pairs
585        // (Steiner points), and the caller's set only carries the original
586        // endpoints. Both sets are consulted in place: copying them into one
587        // per call made a face's hole removal quadratic in its hole count.
588        // Use the walking point-location search (O(sqrt(n))) instead of
589        // linear scan (O(n)) to find the seed triangle.
590        let seed_tri = self.locate_point(seed).ok().map(|(i, _)| i).or_else(|| {
591            // Fallback: linear scan for removed/degenerate cases.
592            self.triangles
593                .iter()
594                .enumerate()
595                .filter(|(_, t)| !t.removed)
596                .find(|(_, t)| {
597                    let p0 = self.vertices[t.v[0]];
598                    let p1 = self.vertices[t.v[1]];
599                    let p2 = self.vertices[t.v[2]];
600                    let d0 = orient2d(p0, p1, seed);
601                    let d1 = orient2d(p1, p2, seed);
602                    let d2 = orient2d(p2, p0, seed);
603                    (d0 >= 0.0 && d1 >= 0.0 && d2 >= 0.0) || (d0 <= 0.0 && d1 <= 0.0 && d2 <= 0.0)
604                })
605                .map(|(i, _)| i)
606        });
607
608        let Some(start) = seed_tri else {
609            return false;
610        };
611
612        let mut stack = vec![start];
613        while let Some(ti) = stack.pop() {
614            if self.triangles[ti].removed {
615                continue;
616            }
617            self.triangles[ti].removed = true;
618
619            for local in 0..3 {
620                let va = self.triangles[ti].v[(local + 1) % 3];
621                let vb = self.triangles[ti].v[(local + 2) % 3];
622                let edge_key = sorted_pair(va, vb);
623                if constraints.contains(&edge_key) || self.constraints.contains(&edge_key) {
624                    continue;
625                }
626                if let Some(adj) = self.triangles[ti].adj[local]
627                    && !self.triangles[adj].removed
628                {
629                    stack.push(adj);
630                }
631            }
632        }
633
634        true
635    }
636
637    /// Partition remaining (non-removed) interior triangles into connected
638    /// regions separated by the given separator edges.
639    ///
640    /// After calling [`Cdt::remove_exterior`], this method groups interior
641    /// triangles into connected components. Two adjacent triangles belong
642    /// to the same region unless the shared edge is in `separators`.
643    ///
644    /// Returns a list of polygonal boundaries, one per connected region,
645    /// ordered as closed loops in parameter space. Each polygon is the
646    /// boundary of the union of triangles in that region.
647    ///
648    /// # Arguments
649    ///
650    /// * `separators` — edges that act as region boundaries (typically the
651    ///   pcurve constraint edges inserted during NURBS boolean splitting).
652    ///   Stored as sorted `(min, max)` pairs.
653    #[must_use]
654    pub fn extract_regions(&self, separators: &[(usize, usize)]) -> Vec<Vec<Point2>> {
655        let sep_set: DetHashSet<(usize, usize)> =
656            separators.iter().map(|&(a, b)| sorted_pair(a, b)).collect();
657
658        let sc = self.super_count;
659
660        let live_tris: Vec<usize> = self
661            .triangles
662            .iter()
663            .enumerate()
664            .filter(|(_, t)| !t.removed)
665            .filter(|(_, t)| t.v[0] >= sc && t.v[1] >= sc && t.v[2] >= sc)
666            .map(|(i, _)| i)
667            .collect();
668
669        if live_tris.is_empty() {
670            return Vec::new();
671        }
672
673        // Map from triangle index → position in live_tris (for visited tracking).
674        let mut tri_to_idx: std::collections::HashMap<usize, usize> =
675            std::collections::HashMap::with_capacity(live_tris.len());
676        for (idx, &ti) in live_tris.iter().enumerate() {
677            tri_to_idx.insert(ti, idx);
678        }
679
680        let mut visited = vec![false; live_tris.len()];
681        let mut regions: Vec<Vec<usize>> = Vec::new();
682
683        // Flood-fill to find connected components.
684        for start_idx in 0..live_tris.len() {
685            if visited[start_idx] {
686                continue;
687            }
688
689            let mut component: Vec<usize> = Vec::new();
690            let mut stack: Vec<usize> = vec![live_tris[start_idx]];
691
692            while let Some(ti) = stack.pop() {
693                let Some(&idx) = tri_to_idx.get(&ti) else {
694                    continue;
695                };
696                if visited[idx] {
697                    continue;
698                }
699                visited[idx] = true;
700                component.push(ti);
701
702                // Traverse to adjacent triangles, stopping at separator edges.
703                let tri = &self.triangles[ti];
704                for local in 0..3 {
705                    let va = tri.v[(local + 1) % 3];
706                    let vb = tri.v[(local + 2) % 3];
707                    let edge_key = sorted_pair(va, vb);
708
709                    // Don't cross separator edges.
710                    if sep_set.contains(&edge_key) {
711                        continue;
712                    }
713
714                    if let Some(adj) = tri.adj[local]
715                        && let Some(&adj_idx) = tri_to_idx.get(&adj)
716                        && !visited[adj_idx]
717                    {
718                        stack.push(adj);
719                    }
720                }
721            }
722
723            if !component.is_empty() {
724                regions.push(component);
725            }
726        }
727
728        // Extract boundary polygon for each region.
729        regions
730            .iter()
731            .filter_map(|component| {
732                let polygon = walk_region_boundary(component, &self.triangles, &self.vertices, sc);
733                if polygon.len() >= 3 {
734                    Some(polygon)
735                } else {
736                    None
737                }
738            })
739            .collect()
740    }
741
742    /// Get the set of constraint edges (sorted pairs).
743    ///
744    /// Useful for distinguishing boundary constraints from interior
745    /// (separator) constraints in callers like NURBS boolean splitting.
746    #[must_use]
747    pub fn constraint_edges(&self) -> &DetHashSet<(usize, usize)> {
748        &self.constraints
749    }
750}
751
752// ---------------------------------------------------------------------------
753// Helpers
754// ---------------------------------------------------------------------------
755
756/// Walk the boundary edges of a set of triangles, producing an ordered polygon.
757///
758/// Given a set of triangle indices and the full triangle list + vertices,
759/// finds edges that appear exactly once in the set (boundary edges) and
760/// orders them into a polygon loop.
761fn walk_region_boundary(
762    region_tris: &[usize],
763    triangles: &[CdtTriangle],
764    vertices: &[Point2],
765    super_count: usize,
766) -> Vec<Point2> {
767    use crate::det_hash::DetHashMap;
768
769    // Count how many times each edge appears in the region.
770    // An edge appearing once is a boundary edge.
771    let mut edge_count: DetHashMap<(usize, usize), Vec<(usize, usize)>> = DetHashMap::default();
772    for &ti in region_tris {
773        let tri = &triangles[ti];
774        for local in 0..3 {
775            let va = tri.v[(local + 1) % 3];
776            let vb = tri.v[(local + 2) % 3];
777            let key = sorted_pair(va, vb);
778            // Store the directed edge (va, vb) — CCW winding of the triangle.
779            edge_count.entry(key).or_default().push((va, vb));
780        }
781    }
782
783    // Boundary edges: appear exactly once. Keep them directed (CCW winding).
784    let mut next_map: DetHashMap<usize, usize> = DetHashMap::default();
785    for directed_edges in edge_count.values() {
786        if directed_edges.len() == 1 {
787            let (va, vb) = directed_edges[0];
788            // Skip super-triangle vertices.
789            if va < super_count || vb < super_count {
790                continue;
791            }
792            next_map.insert(va, vb);
793        }
794    }
795
796    if next_map.is_empty() {
797        return Vec::new();
798    }
799
800    // Walk the boundary loop starting from any vertex.
801    let &start = next_map.keys().next().unwrap_or(&0);
802    let mut polygon = Vec::with_capacity(next_map.len());
803    let mut current = start;
804    let max_steps = next_map.len() + 1;
805    for _ in 0..max_steps {
806        polygon.push(vertices[current]);
807        match next_map.get(&current) {
808            Some(&next) => {
809                if next == start {
810                    break;
811                }
812                current = next;
813            }
814            None => break,
815        }
816    }
817
818    polygon
819}
820
821/// Return a sorted pair `(min, max)`.
822fn sorted_pair(a: usize, b: usize) -> (usize, usize) {
823    if a <= b { (a, b) } else { (b, a) }
824}
825
826/// Compute the intersection point of two line segments, if they cross.
827fn segment_intersection_point(a0: Point2, a1: Point2, b0: Point2, b1: Point2) -> Option<Point2> {
828    let dx_a = a1.x() - a0.x();
829    let dy_a = a1.y() - a0.y();
830    let dx_b = b1.x() - b0.x();
831    let dy_b = b1.y() - b0.y();
832    let denom = dx_a * dy_b - dy_a * dx_b;
833    if denom.abs() < 1e-15 {
834        return None;
835    }
836    let dx_ab = b0.x() - a0.x();
837    let dy_ab = b0.y() - a0.y();
838    let t = (dx_ab * dy_b - dy_ab * dx_b) / denom;
839    let u = (dx_ab * dy_a - dy_ab * dx_a) / denom;
840    if t > 0.0 && t < 1.0 && u > 0.0 && u < 1.0 {
841        Some(Point2::new(
842            dx_a.mul_add(t, a0.x()),
843            dy_a.mul_add(t, a0.y()),
844        ))
845    } else {
846        None
847    }
848}
849
850/// Test if two line segments properly intersect (crossing, not just touching).
851fn segments_properly_intersect(a0: Point2, a1: Point2, b0: Point2, b1: Point2) -> bool {
852    let d1 = orient2d(a0, a1, b0);
853    let d2 = orient2d(a0, a1, b1);
854    let d3 = orient2d(b0, b1, a0);
855    let d4 = orient2d(b0, b1, a1);
856
857    // Segments cross if endpoints of each are on opposite sides of the other.
858    if d1 * d2 < 0.0 && d3 * d4 < 0.0 {
859        return true;
860    }
861    false
862}
863
864/// Map a 2D point to a grid cell for duplicate detection.
865/// Cell size is much larger than `DUP_TOL` so neighbors cover the tolerance radius.
866#[allow(clippy::cast_possible_truncation)]
867fn dup_grid_cell(p: Point2) -> (i64, i64) {
868    // Cell size ~1e-5: 1000× DUP_TOL to keep neighbor checks cheap while
869    // ensuring points within DUP_TOL always land in the same or adjacent cells.
870    const CELL_INV: f64 = 1e5;
871    (
872        (p.x() * CELL_INV).floor() as i64,
873        (p.y() * CELL_INV).floor() as i64,
874    )
875}
876
877/// Map (x, y) in [0, n) × [0, n) to a Hilbert curve index (n must be power of 2).
878fn hilbert_xy_to_d(n: u32, mut x: u32, mut y: u32) -> u64 {
879    let mut d: u64 = 0;
880    let mut s = n / 2;
881    while s > 0 {
882        let rx = u32::from(x & s > 0);
883        let ry = u32::from(y & s > 0);
884        d += u64::from(s) * u64::from(s) * u64::from((3 * rx) ^ ry);
885        // Rotate quadrant.
886        if ry == 0 {
887            if rx == 1 {
888                x = 2u32.wrapping_mul(s).wrapping_sub(1).wrapping_sub(x);
889                y = 2u32.wrapping_mul(s).wrapping_sub(1).wrapping_sub(y);
890            }
891            std::mem::swap(&mut x, &mut y);
892        }
893        s /= 2;
894    }
895    d
896}