ifc_lite_geometry/
cdt.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//! Deterministic Constrained Delaunay Triangulation (CDT) with bounded
6//! Ruppert/Chew min-angle refinement.
7//!
8//! ## Why this exists
9//!
10//! The coplanar-consolidation path (`csg.rs::consolidate_coplanar`) re-merges
11//! per-plane CSG fragments via a 2D union and must re-triangulate the
12//! resulting (possibly annular) regions. The previous implementation handed
13//! these to greedy ear-clipping (`earcutr`), which fans a small opening notch
14//! to a far boundary corner and produces high-aspect sliver triangles (worst
15//! rim-incident aspect 25.28:1 on the real #1112 roof opening).
16//!
17//! This module replaces that with a real quality triangulator:
18//!
19//! 1. **Constrained Delaunay** over the boundary + hole rings (kept as hard
20//!    constraint segments, so a hole stays a hole and the boundary is exact).
21//!    The empty-circumcircle property alone already avoids the long ear-clip
22//!    slivers.
23//! 2. **Bounded, interior-only Ruppert/Chew refinement**: insert the
24//!    circumcenter of any skinny interior triangle. The constraint segments
25//!    (boundary + hole rings) are NEVER split — the sole production caller
26//!    (`consolidate_coplanar`) re-triangulates a region whose boundary is
27//!    SHARED with neighbouring plane buckets triangulated independently, so a
28//!    boundary Steiner point would open a T-junction at the bucket seam. A
29//!    skinny triangle whose circumcenter would *encroach* a constraint segment
30//!    (lie inside its diametral circle) is simply left as-is (best-effort
31//!    quality, not torn) instead of splitting the segment.
32//!
33//! ## Determinism (native == wasm)
34//!
35//! All arithmetic is plain `f64` — **no FMA, no transcendental tie-breaks**.
36//! Orientation and in-circle SIGN decisions use Shewchuk's adaptive exact
37//! predicates (`geometry_predicates::{orient2d, incircle}`), which are
38//! sign-exact and bit-identical across x86_64 / aarch64 / wasm. Every worklist
39//! is processed in a **canonical order** (point insertion in index order;
40//! refinement queues drained by sorted/lowest-index triangle), never via
41//! `HashMap` iteration. The same input therefore yields a byte-identical
42//! triangle list on every target, which the mesh diff / `geom_hash` relies on.
43//!
44//! ## Watertightness & T-junctions
45//!
46//! Constraint segments are recovered exactly and never flipped, crossed, or
47//! split — refinement only ever inserts a Steiner point strictly INTERIOR to
48//! the domain, so the boundary/hole rings a caller shares across independently
49//! triangulated regions are never touched (no T-junction risk there). An
50//! interior Steiner point still splits every triangle incident to its
51//! insertion cavity in lockstep, so no T-junction is left on a shared interior
52//! edge either. Hole rings are excluded from the emitted domain by an
53//! inside/outside flood-fill across constraint edges.
54//!
55//! ## Bound
56//!
57//! Refinement is capped three ways so it always terminates fast and never
58//! explodes triangle count: a min-angle target (`COS_MIN_ANGLE`), a hard
59//! Steiner-point budget proportional to the boundary size, and an absolute
60//! iteration cap. On hitting any cap the CURRENT valid CDT is returned (still
61//! constrained-Delaunay, still watertight) — quality is best-effort, validity
62//! is not.
63
64mod enc_grid;
65mod insertion;
66mod predicates;
67
68static RECOVERY_STUCK: AtomicU64 = AtomicU64::new(0);
69static RECOVERY_EXHAUSTED: AtomicU64 = AtomicU64::new(0);
70
71/// Read + reset `(stuck, exhausted)`: constraint segments
72/// [`Cdt::recover_segment`] gave up on since the last call, each of which made
73/// `build_from` return `None` so the caller ear-clipped. `stuck` is the "no
74/// flippable crossing edge" exit (crossing constraints, a duplicate vertex the
75/// segment names); `exhausted` is the 100 000-flip guard. Process-global
76/// relaxed atomics, like [`crate::take_bool2d_stats`]: a stale read under
77/// concurrency mis-reports a diagnostic count, never geometry.
78pub fn take_cdt_recovery_fallbacks() -> (u64, u64) {
79    (
80        RECOVERY_STUCK.swap(0, Ordering::Relaxed),
81        RECOVERY_EXHAUSTED.swap(0, Ordering::Relaxed),
82    )
83}
84
85use crate::Point2;
86use enc_grid::EncGrid;
87use predicates::{dist2, rings_to_pslg, segments_properly_cross, strictly_between};
88use std::collections::{BTreeMap, BTreeSet, VecDeque};
89use std::sync::atomic::{AtomicU64, Ordering};
90
91/// Target minimum angle for refinement, expressed as `cos(angle)` so the
92/// skinny test is a transcendental-free `cos θ > COS_MIN_ANGLE` comparison (see
93/// `tri_is_skinny`). 20.7° is the proven Ruppert termination ceiling; this is
94/// `cos(22°)`, a compile-time literal so it is bit-identical on every platform
95/// (computing it at runtime with `to_radians().cos()` would reintroduce a
96/// platform-variant transcendental into the decision path).
97const COS_MIN_ANGLE: f64 = 0.927_183_854_566_787_4; // cos(22°)
98
99/// Secondary trigger: maximum acceptable edge-length aspect (longest/shortest).
100const MAX_ASPECT: f64 = 7.0;
101
102/// Absolute iteration cap on the refinement loop (independent of the budget).
103const MAX_REFINE_ITERS: usize = 20_000;
104
105type P2 = [f64; 2];
106const NONE: usize = usize::MAX;
107
108#[inline]
109fn p2(p: &Point2<f64>) -> P2 {
110    [p.x, p.y]
111}
112
113/// Exact orientation sign of `(a, b, c)`: `+1` CCW, `-1` CW, `0` collinear.
114use crate::geom2d::orientation as orient;
115
116/// Exact in-circle sign: `> 0` when `d` is strictly inside the circumcircle of
117/// the CCW triangle `(a, b, c)`.
118#[inline]
119fn in_circle_sign(a: P2, b: P2, c: P2, d: P2) -> i32 {
120    let v = geometry_predicates::incircle(a, b, c, d);
121    if v > 0.0 {
122        1
123    } else if v < 0.0 {
124        -1
125    } else {
126        0
127    }
128}
129
130/// Canonical undirected-edge key (sorted vertex indices).
131#[inline]
132fn ekey(a: usize, b: usize) -> (usize, usize) {
133    if a < b {
134        (a, b)
135    } else {
136        (b, a)
137    }
138}
139
140/// Triangle: CCW vertex triple + neighbour across each *edge*. Convention:
141/// edge `e` runs `verts[e] -> verts[(e+1)%3]`, and `neighbor[e]` is the
142/// triangle on the other side of that edge (`NONE` if none).
143#[derive(Clone, Copy)]
144struct Tri {
145    v: [usize; 3],
146    n: [usize; 3],
147    alive: bool,
148}
149
150impl Tri {
151    /// Local edge index (0,1,2) whose endpoints are `{a,b}`, or `None`.
152    #[inline]
153    fn edge_of(&self, a: usize, b: usize) -> Option<usize> {
154        for e in 0..3 {
155            let x = self.v[e];
156            let y = self.v[(e + 1) % 3];
157            if (x == a && y == b) || (x == b && y == a) {
158                return Some(e);
159            }
160        }
161        None
162    }
163}
164
165struct Cdt {
166    /// `[0, n_real)` = input then Steiner points; `[n_real, super_base)` = the
167    /// PRE-RESERVED Steiner slots (placeholders, never referenced by a triangle);
168    /// `[super_base, super_base+3)` = the super triangle.
169    points: Vec<P2>,
170    tris: Vec<Tri>,
171    /// Constraint segments (canonical edge keys); never flipped, never crossed.
172    /// Ord-keyed so the recovery ORDER in [`Cdt::enforce_constraints`] is
173    /// target-independent.
174    constraints: BTreeSet<(usize, usize)>,
175    /// Membership-only mirror of `constraints`. The cavity BFS, legalization
176    /// and the depth-parity flood probe it millions of times per model and only
177    /// ever ask "is this edge pinned?", where a tree walk is pure overhead;
178    /// every mutation of `constraints` updates both.
179    cset: rustc_hash::FxHashSet<(usize, usize)>,
180    /// Index of the first super-triangle vertex. FIXED at build time (the
181    /// Steiner budget is reserved below it), so a refinement insertion never
182    /// renumbers vertices — see [`Cdt::insert_steiner`].
183    super_base: usize,
184    /// Count of REAL points in use (input + Steiner emitted so far).
185    n_real: usize,
186    /// Walk-locate hint: a triangle incident to the last inserted point.
187    last_loc: usize,
188    /// Broad-phase for [`Cdt::is_encroached`], (re)built at
189    /// [`Cdt::start_refinement`] and invalidated by a constraint split.
190    enc: EncGrid,
191    /// Per-triangle inside-domain flag, parallel to `tris`. Maintained
192    /// incrementally during NO-SPLIT refinement (after [`Cdt::start_refinement`]):
193    /// a flip reuses its two slots within one region (a flipped edge is never a
194    /// constraint, so both sides share a region), and a cavity re-fan inherits
195    /// the seed's region (the cavity BFS never crosses a constraint). Garbage
196    /// before `start_refinement`; [`Cdt::emit`] always recomputes from scratch.
197    inside: Vec<bool>,
198    /// Ordered worklist of skinny-candidate triangle indices for incremental
199    /// refinement. Entries are validated lazily on pop; slot rewrites re-evaluate
200    /// via [`Cdt::track_tri`]. Empty / unused outside refinement.
201    skinny: BTreeSet<usize>,
202    /// Incremental-refinement tracking hooks enabled.
203    track: bool,
204    /// Quality target used by the tracking hooks.
205    cos_min_angle: f64,
206    /// Set when an insertion hits a "can't happen" topology invariant (e.g. a
207    /// shared edge whose neighbour has no apex vertex). Rather than panic, the
208    /// insertion bails and every `Option`-returning entry point (`build_from`,
209    /// the incremental refinement driver) treats the CDT as unbuildable and
210    /// returns `None`, so the caller falls back to ear-clipping — matching how
211    /// every other degenerate case in this module degrades.
212    failed: bool,
213    /// Whether the fixed legalization guard is a caller-visible work budget.
214    /// Ordinary geometry callers preserve their historical best-effort break;
215    /// hostile terrain parsing reports the bound explicitly.
216    fail_on_legalization_guard: bool,
217}
218
219impl Cdt {
220    /// Build a CDT from an explicit point list + constraint segment list. The
221    /// segment list is the source of truth for constraints (so refinement can
222    /// add Steiner points and replace a segment with its two halves, then
223    /// rebuild cleanly from scratch — no fragile in-place mutation).
224    fn build_from(points: Vec<P2>, segments: &[(usize, usize)], steiner_cap: usize) -> Option<Cdt> {
225        // The progress callback never cancels here; it only forwards a strided
226        // heartbeat for a host waiting on one long triangulation.
227        let mut calls = 0u32;
228        let mut heartbeat = || {
229            crate::progress::tick_strided(&mut calls, 0xFFF);
230            Ok(())
231        };
232        Self::build_from_with_progress_mode(points, segments, steiner_cap, false, &mut heartbeat)
233            .ok()
234            .flatten()
235    }
236
237    fn build_from_with_progress(
238        points: Vec<P2>,
239        segments: &[(usize, usize)],
240        steiner_cap: usize,
241        progress: &mut dyn FnMut() -> Result<(), crate::terrain_cdt::TerrainCdtError>,
242    ) -> Result<Option<Cdt>, crate::terrain_cdt::TerrainCdtError> {
243        Self::build_from_with_progress_mode(points, segments, steiner_cap, true, progress)
244    }
245
246    fn build_from_with_progress_mode(
247        mut points: Vec<P2>,
248        segments: &[(usize, usize)],
249        steiner_cap: usize,
250        fail_on_legalization_guard: bool,
251        progress: &mut dyn FnMut() -> Result<(), crate::terrain_cdt::TerrainCdtError>,
252    ) -> Result<Option<Cdt>, crate::terrain_cdt::TerrainCdtError> {
253        let n_input = points.len();
254        if n_input < 3 {
255            return Ok(None);
256        }
257        let mut constraints = BTreeSet::new();
258        for &(a, b) in segments {
259            progress()?;
260            if a != b && a < n_input && b < n_input {
261                constraints.insert(ekey(a, b));
262            }
263        }
264
265        // Super-triangle containing every input point with wide clearance.
266        let (mut minx, mut miny, mut maxx, mut maxy) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
267        for p in &points {
268            progress()?;
269            if !p[0].is_finite() || !p[1].is_finite() {
270                return Ok(None);
271            }
272            minx = minx.min(p[0]);
273            miny = miny.min(p[1]);
274            maxx = maxx.max(p[0]);
275            maxy = maxy.max(p[1]);
276        }
277        let span = (maxx - minx).max(maxy - miny);
278        if !(span > 0.0) || !span.is_finite() {
279            return Ok(None);
280        }
281        let cx = (minx + maxx) * 0.5;
282        let cy = (miny + maxy) * 0.5;
283        let big = span * 32.0;
284        // Reserve the whole Steiner budget BELOW the super vertices. Splicing a
285        // Steiner point in at `super_base` used to renumber every triangle's
286        // vertex ids (an O(T) pass per point — 1.07e9 index touches on
287        // ISSUE_129, the single largest cost in the consolidate path). With the
288        // slots pre-reserved the super ids are constant, so nothing renumbers.
289        // Ids stay in the SAME relative order as before (super still above every
290        // real vertex), which is what the index-ordered tie-breaks
291        // (`boundary.sort_unstable`, `ekey`) depend on ⇒ output-identical.
292        points.resize(n_input + steiner_cap, [0.0, 0.0]);
293        let super_base = points.len();
294        points.push([cx - big, cy - big]);
295        points.push([cx + big, cy - big]);
296        points.push([cx, cy + big]);
297
298        let mut cdt = Cdt {
299            points,
300            tris: Vec::new(),
301            cset: constraints.iter().copied().collect(),
302            constraints,
303            super_base,
304            n_real: n_input,
305            last_loc: 0,
306            enc: EncGrid::default(),
307            inside: Vec::new(),
308            skinny: BTreeSet::new(),
309            track: false,
310            cos_min_angle: COS_MIN_ANGLE,
311            failed: false,
312            fail_on_legalization_guard,
313        };
314        cdt.tris.push(Tri {
315            v: [super_base, super_base + 1, super_base + 2],
316            n: [NONE; 3],
317            alive: true,
318        });
319        cdt.inside.push(false);
320
321        // Incremental Delaunay insertion in canonical index order.
322        for vi in 0..n_input {
323            progress()?;
324            cdt.insert_point_with_progress(vi, progress)?;
325        }
326        if cdt.failed {
327            return Ok(None); // an insertion tripped a topology invariant — fall back to ear-clipping
328        }
329        if !cdt.enforce_constraints_with_progress(progress)? {
330            return Ok(None);
331        }
332        cdt.restore_constrained_delaunay_with_progress(progress)?;
333        Ok(Some(cdt))
334    }
335
336    /// Wire adjacency for an internal cavity edge once both owners are known.
337    fn link_internal(
338        &mut self,
339        owner: &mut BTreeMap<(usize, usize), (usize, usize)>,
340        key: (usize, usize),
341        ti: usize,
342        e: usize,
343    ) {
344        if let Some(&(ot, oe)) = owner.get(&key) {
345            self.tris[ti].n[e] = ot;
346            self.tris[ot].n[oe] = ti;
347        } else {
348            owner.insert(key, (ti, e));
349        }
350    }
351
352    /// Split a triangle that strictly contains `vi` (or has `vi` on an edge)
353    /// into up to three children and legalize. Fallback for the no-bad-tri case.
354    fn split_in_triangle_with_progress(
355        &mut self,
356        t: usize,
357        vi: usize,
358        progress: &mut dyn FnMut() -> Result<(), crate::terrain_cdt::TerrainCdtError>,
359    ) -> Result<(), crate::terrain_cdt::TerrainCdtError> {
360        if !self.tris[t].alive {
361            return Ok(());
362        }
363        let region = self.inside.get(t).copied().unwrap_or(false);
364        let v = self.tris[t].v;
365        let n = self.tris[t].n;
366        let degenerate: [bool; 3] = std::array::from_fn(|e| {
367            orient(
368                self.points[v[e]],
369                self.points[v[(e + 1) % 3]],
370                self.points[vi],
371            ) == 0
372        });
373        if degenerate.iter().filter(|&&d| d).count() >= 2 {
374            // `vi` coincides with a vertex of `t`: a duplicate input coordinate
375            // (`rings_to_pslg` does not dedup, so two rings sharing a corner
376            // arrive as two indices at one point). Retiring `t` for the single
377            // child that survives would leave the neighbours across the two
378            // skipped edges linked to a dead triangle. Leave `t` alone and skip
379            // the point: a constraint naming it then fails recovery and the
380            // caller falls back, and a free duplicate is merely unreferenced.
381            return Ok(());
382        }
383        self.tris[t].alive = false;
384        let mut owner: BTreeMap<(usize, usize), (usize, usize)> = BTreeMap::new();
385        let mut children: Vec<usize> = Vec::new();
386        for e in 0..3 {
387            progress()?;
388            let a = v[e];
389            let b = v[(e + 1) % 3];
390            if degenerate[e] {
391                continue; // degenerate child (vi on edge a-b)
392            }
393            let ti = self.tris.len();
394            self.tris.push(Tri {
395                v: [a, b, vi],
396                n: [NONE; 3],
397                alive: true,
398            });
399            self.inside.push(region);
400            children.push(ti);
401            self.tris[ti].n[0] = n[e];
402            if n[e] != NONE {
403                if let Some(oe) = self.tris[n[e]].edge_of(a, b) {
404                    self.tris[n[e]].n[oe] = ti;
405                }
406            }
407            self.link_internal(&mut owner, ekey(b, vi), ti, 1);
408            self.link_internal(&mut owner, ekey(vi, a), ti, 2);
409        }
410        let mut stack: Vec<(usize, usize)> = children.iter().map(|&c| (c, 0usize)).collect();
411        self.legalize_with_progress(&mut stack, progress)?;
412        for c in children {
413            progress()?;
414            self.track_tri(c);
415        }
416        Ok(())
417    }
418
419    /// Insertion fallback for an empty Bowyer–Watson cavity: route a point
420    /// lying EXACTLY on an edge of `start` (exact `orient == 0` AND strictly
421    /// between the endpoints) to the lockstep both-sides split
422    /// ([`Cdt::split_on_edge`]); everything else (genuinely interior) to
423    /// [`Cdt::split_in_triangle`].
424    #[cfg(test)]
425    fn split_at(&mut self, start: usize, vi: usize) {
426        let _ = self.split_at_with_progress(start, vi, &mut || Ok(()));
427    }
428
429    fn split_at_with_progress(
430        &mut self,
431        start: usize,
432        vi: usize,
433        progress: &mut dyn FnMut() -> Result<(), crate::terrain_cdt::TerrainCdtError>,
434    ) -> Result<(), crate::terrain_cdt::TerrainCdtError> {
435        if !self.tris[start].alive {
436            return Ok(());
437        }
438        let v = self.tris[start].v;
439        let p = self.points[vi];
440        for e in 0..3 {
441            progress()?;
442            let a = self.points[v[e]];
443            let b = self.points[v[(e + 1) % 3]];
444            if orient(a, b, p) == 0 && strictly_between(a, b, p) {
445                return self.split_on_edge_with_progress(start, e, vi, progress);
446            }
447        }
448        self.split_in_triangle_with_progress(start, vi, progress)
449    }
450
451    /// Split triangle `t` around `vi`, which lies EXACTLY on `t`'s local edge
452    /// `e` (strictly between its endpoints), together with the neighbour
453    /// across that edge when one exists. [`Cdt::split_in_triangle`] cannot be
454    /// used here: it skips the degenerate child on the collinear edge, so only
455    /// ONE side of the edge would be re-filled around `vi` — a T-junction,
456    /// with the far triangle still pointing at the dead parent. This splits
457    /// BOTH incident triangles in lockstep (4 children; 2 on a boundary
458    /// edge), wires every adjacency, and legalizes the children's outer
459    /// (parent-perimeter) edges; the spoke edges are incident to the freshly
460    /// inserted `vi` and need no Delaunay test.
461    fn split_on_edge_with_progress(
462        &mut self,
463        t: usize,
464        e: usize,
465        vi: usize,
466        progress: &mut dyn FnMut() -> Result<(), crate::terrain_cdt::TerrainCdtError>,
467    ) -> Result<(), crate::terrain_cdt::TerrainCdtError> {
468        let v = self.tris[t].v;
469        let n = self.tris[t].n;
470        let a = v[e];
471        let b = v[(e + 1) % 3];
472        let c = v[(e + 2) % 3];
473        let nb = n[e];
474        let n_bc = n[(e + 1) % 3];
475        let n_ca = n[(e + 2) % 3];
476        let region_t = self.inside.get(t).copied().unwrap_or(false);
477
478        // A point landing exactly on a CONSTRAINT edge is a segment split:
479        // replace `a-b` with its two halves so every invariant that consults
480        // `constraints` (cavity blocking, legalization pinning, the inside
481        // depth-parity flood) sees the sub-segments. The production NO-SPLIT
482        // refinement driver skips encroaching candidates, so this should be
483        // unreachable there — it is required for the split-mode path (which
484        // shares this insertion code) and kept as a defensive guarantee.
485        if self.constraints.remove(&ekey(a, b)) {
486            self.constraints.insert(ekey(a, vi));
487            self.constraints.insert(ekey(vi, b));
488            self.cset.remove(&ekey(a, b));
489            self.cset.insert(ekey(a, vi));
490            self.cset.insert(ekey(vi, b));
491            self.enc = EncGrid::default(); // stale broad-phase → linear fallback
492        }
493
494        self.tris[t].alive = false;
495
496        if nb == NONE {
497            // Boundary edge: only `t` exists — split it into 2 children.
498            let t1 = self.tris.len(); // (vi, b, c)
499            let t2 = t1 + 1; //          (a, vi, c)
500            self.tris.push(Tri {
501                v: [vi, b, c],
502                n: [NONE, n_bc, t2],
503                alive: true,
504            });
505            self.tris.push(Tri {
506                v: [a, vi, c],
507                n: [NONE, t1, n_ca],
508                alive: true,
509            });
510            self.inside.push(region_t);
511            self.inside.push(region_t);
512            for (ext, x, y, child) in [(n_bc, b, c, t1), (n_ca, c, a, t2)] {
513                if ext != NONE {
514                    if let Some(oe) = self.tris[ext].edge_of(x, y) {
515                        self.tris[ext].n[oe] = child;
516                    }
517                }
518            }
519            let mut stack: Vec<(usize, usize)> = vec![(t1, 1), (t2, 2)];
520            self.legalize_with_progress(&mut stack, progress)?;
521            self.track_tri(t1);
522            self.track_tri(t2);
523            return Ok(());
524        }
525
526        // Interior (shared) edge: capture the neighbour's data, then split
527        // both parents in lockstep. Each child inherits its OWN parent's
528        // region flag — the two regions can differ across a constraint edge.
529        let region_nb = self.inside.get(nb).copied().unwrap_or(false);
530        let Some(d) = self.tris[nb].v.iter().copied().find(|&x| x != a && x != b) else {
531            // "Can't happen": the neighbour across a shared edge must have a
532            // third (apex) vertex. Degrade to ear-clipping instead of panicking
533            // — `t` is already retired above, so leave the CDT flagged
534            // unbuildable and let the entry point return `None`.
535            self.failed = true;
536            return Ok(());
537        };
538        let outer_of = |s: &Self, t: usize, x: usize, y: usize| -> usize {
539            s.tris[t]
540                .edge_of(x, y)
541                .map(|oe| s.tris[t].n[oe])
542                .unwrap_or(NONE)
543        };
544        let n_ad = outer_of(self, nb, a, d);
545        let n_db = outer_of(self, nb, d, b);
546        self.tris[nb].alive = false;
547
548        // Parent `t` is (a, b, c) CCW with vi strictly inside a-b, so all four
549        // children below are CCW and non-degenerate by construction.
550        let t1 = self.tris.len(); // (vi, b, c) — t's side
551        let t2 = t1 + 1; //          (a, vi, c) — t's side
552        let t3 = t1 + 2; //          (b, vi, d) — neighbour's side
553        let t4 = t1 + 3; //          (vi, a, d) — neighbour's side
554        self.tris.push(Tri {
555            v: [vi, b, c],
556            n: [t3, n_bc, t2],
557            alive: true,
558        });
559        self.tris.push(Tri {
560            v: [a, vi, c],
561            n: [t4, t1, n_ca],
562            alive: true,
563        });
564        self.tris.push(Tri {
565            v: [b, vi, d],
566            n: [t1, t4, n_db],
567            alive: true,
568        });
569        self.tris.push(Tri {
570            v: [vi, a, d],
571            n: [t2, n_ad, t3],
572            alive: true,
573        });
574        self.inside.push(region_t);
575        self.inside.push(region_t);
576        self.inside.push(region_nb);
577        self.inside.push(region_nb);
578
579        // Re-point the four EXTERNAL neighbours at the child replacing their
580        // side of each parent (the cross-pairs over the old edge and the
581        // internal vi-c / vi-d links were wired in the constructors above).
582        for (ext, x, y, child) in [
583            (n_bc, b, c, t1),
584            (n_ca, c, a, t2),
585            (n_db, d, b, t3),
586            (n_ad, a, d, t4),
587        ] {
588            if ext != NONE {
589                if let Some(oe) = self.tris[ext].edge_of(x, y) {
590                    self.tris[ext].n[oe] = child;
591                }
592            }
593        }
594
595        let mut stack: Vec<(usize, usize)> = vec![(t1, 1), (t2, 2), (t3, 2), (t4, 1)];
596        self.legalize_with_progress(&mut stack, progress)?;
597        for ti in [t1, t2, t3, t4] {
598            progress()?;
599            self.track_tri(ti);
600        }
601        Ok(())
602    }
603
604    /// Lawson legalization. Each `(ti, e)` names an edge of a just-built
605    /// triangle to test for the Delaunay (empty-circumcircle) condition.
606    /// Constraint edges are skipped. Diagonal flips never touch constraints.
607    /// Progress-aware form used by untrusted terrain PSLGs. Keep the ordinary
608    /// insertion callers on the same deterministic stack order.
609    fn legalize_with_progress(
610        &mut self,
611        stack: &mut Vec<(usize, usize)>,
612        progress: &mut dyn FnMut() -> Result<(), crate::terrain_cdt::TerrainCdtError>,
613    ) -> Result<(), crate::terrain_cdt::TerrainCdtError> {
614        let mut guard = 0usize;
615        while let Some((ti, e)) = stack.pop() {
616            guard += 1;
617            progress()?;
618            if guard > 4_000_000 {
619                if self.fail_on_legalization_guard {
620                    return Err(crate::terrain_cdt::TerrainCdtError::WorkLimitExceeded);
621                }
622                break;
623            }
624            if !self.tris[ti].alive {
625                continue;
626            }
627            let a = self.tris[ti].v[e];
628            let b = self.tris[ti].v[(e + 1) % 3];
629            let apex = self.tris[ti].v[(e + 2) % 3];
630            if self.cset.contains(&ekey(a, b)) {
631                continue;
632            }
633            let opp = self.tris[ti].n[e];
634            if opp == NONE || !self.tris[opp].alive {
635                continue;
636            }
637            // Opposite apex = vertex of `opp` not on edge a-b.
638            let ov = self.tris[opp].v;
639            let q = ov.iter().copied().find(|&x| x != a && x != b);
640            let Some(q) = q else { continue };
641            // Delaunay test on this triangle's circumcircle.
642            let tv = self.tris[ti].v;
643            if in_circle_sign(
644                self.points[tv[0]],
645                self.points[tv[1]],
646                self.points[tv[2]],
647                self.points[q],
648            ) <= 0
649            {
650                continue;
651            }
652            // Convexity guard: the flip a-b -> apex-q is only legal when the
653            // quad (a, apex, b, q) is strictly convex, i.e. `a` and `b` lie on
654            // STRICTLY OPPOSITE sides of the new diagonal apex-q. If apex, a, q
655            // (or apex, b, q) are collinear the flip would create a degenerate
656            // zero-area triangle and a T-junction (e.g. a segment-split midpoint
657            // collinear with the edge it replaced). Skip such non-convex flips.
658            let sa = orient(self.points[apex], self.points[q], self.points[a]);
659            let sb = orient(self.points[apex], self.points[q], self.points[b]);
660            if sa == 0 || sb == 0 || sa == sb {
661                continue;
662            }
663            self.flip(ti, opp, a, b, apex, q, stack);
664        }
665        Ok(())
666    }
667
668    /// Flip shared edge `a-b` of triangles `ti=(…apex…)` / `opp=(…q…)` to the
669    /// diagonal `apex-q`. Rebuilds both triangles CCW and re-links adjacency,
670    /// then queues the four outer edges for re-legalization.
671    #[allow(clippy::too_many_arguments)]
672    fn flip(
673        &mut self,
674        ti: usize,
675        opp: usize,
676        a: usize,
677        b: usize,
678        apex: usize,
679        q: usize,
680        stack: &mut Vec<(usize, usize)>,
681    ) {
682        // Capture the four outer neighbours before rewriting.
683        let outer = |s: &Self, t: usize, x: usize, y: usize| -> usize {
684            s.tris[t]
685                .edge_of(x, y)
686                .map(|e| s.tris[t].n[e])
687                .unwrap_or(NONE)
688        };
689        let n_apex_a = outer(self, ti, apex, a); // ti edge apex-a
690        let n_apex_b = outer(self, ti, apex, b); // ti edge apex-b
691        let n_q_a = outer(self, opp, q, a); // opp edge q-a
692        let n_q_b = outer(self, opp, q, b); // opp edge q-b
693
694        // Build the two CCW children of the quad (a, q, b, apex) split on apex-q.
695        let mk = |s: &Self, x: usize, y: usize, z: usize| -> [usize; 3] {
696            if orient(s.points[x], s.points[y], s.points[z]) >= 0 {
697                [x, y, z]
698            } else {
699                [x, z, y]
700            }
701        };
702        // T0 carries side `a`: (apex, q, a). T1 carries side `b`: (apex, q, b).
703        let t0 = mk(self, apex, q, a);
704        let t1 = mk(self, apex, q, b);
705        self.tris[ti] = Tri {
706            v: t0,
707            n: [NONE; 3],
708            alive: true,
709        };
710        self.tris[opp] = Tri {
711            v: t1,
712            n: [NONE; 3],
713            alive: true,
714        };
715
716        let set_nb = |s: &mut Self, t: usize, x: usize, y: usize, val: usize| {
717            if let Some(e) = s.tris[t].edge_of(x, y) {
718                s.tris[t].n[e] = val;
719            }
720        };
721        // Shared diagonal apex-q links the two.
722        set_nb(self, ti, apex, q, opp);
723        set_nb(self, opp, apex, q, ti);
724        // ti = (apex,q,a): outer edges apex-a and q-a.
725        set_nb(self, ti, apex, a, n_apex_a);
726        set_nb(self, ti, q, a, n_q_a);
727        // opp = (apex,q,b): outer edges apex-b and q-b.
728        set_nb(self, opp, apex, b, n_apex_b);
729        set_nb(self, opp, q, b, n_q_b);
730        // Back-pointers on outer neighbours.
731        if n_apex_a != NONE {
732            if let Some(e) = self.tris[n_apex_a].edge_of(apex, a) {
733                self.tris[n_apex_a].n[e] = ti;
734            }
735        }
736        if n_q_a != NONE {
737            if let Some(e) = self.tris[n_q_a].edge_of(q, a) {
738                self.tris[n_q_a].n[e] = ti;
739            }
740        }
741        if n_apex_b != NONE {
742            if let Some(e) = self.tris[n_apex_b].edge_of(apex, b) {
743                self.tris[n_apex_b].n[e] = opp;
744            }
745        }
746        if n_q_b != NONE {
747            if let Some(e) = self.tris[n_q_b].edge_of(q, b) {
748                self.tris[n_q_b].n[e] = opp;
749            }
750        }
751
752        // Queue the four outer edges.
753        for (t, x, y) in [(ti, apex, a), (ti, q, a), (opp, apex, b), (opp, q, b)] {
754            if let Some(e) = self.tris[t].edge_of(x, y) {
755                stack.push((t, e));
756            }
757        }
758        // A flip reuses its two slots; their region is unchanged (the flipped
759        // edge is never a constraint, so both sides share one region) but
760        // their shape is new — re-evaluate for the skinny worklist.
761        self.track_tri(ti);
762        self.track_tri(opp);
763    }
764
765    /// Incremental-refinement hook: (re-)evaluate triangle slot `ti` for the
766    /// skinny worklist. No-op unless [`Cdt::start_refinement`] enabled tracking.
767    #[inline]
768    fn track_tri(&mut self, ti: usize) {
769        if !self.track {
770            return;
771        }
772        let cand = self.tris[ti].alive
773            && self.inside.get(ti).copied().unwrap_or(false)
774            && !self.tris[ti].v.iter().any(|&x| x >= self.super_base)
775            && self.tri_is_skinny(ti, self.cos_min_angle);
776        if cand {
777            self.skinny.insert(ti);
778        } else {
779            self.skinny.remove(&ti);
780        }
781    }
782
783    /// Enable incremental refinement: materialise the per-triangle inside
784    /// flags once, then seed the skinny worklist. From here on the tracking
785    /// hooks ([`Cdt::track_tri`], the region inheritance in the insertion
786    /// paths) keep both maintained under [`Cdt::insert_steiner`] mutations.
787    fn start_refinement(&mut self, cos_min_angle: f64) {
788        self.inside = self.inside_flags();
789        self.build_enc_grid();
790        self.cos_min_angle = cos_min_angle;
791        self.track = true;
792        self.skinny.clear();
793        for ti in 0..self.tris.len() {
794            self.track_tri(ti);
795        }
796    }
797
798    /// Locate an alive triangle whose closed region contains `p`. Canonical
799    /// (ascending-index) linear scan — deterministic and robust; regions here
800    /// are small so the O(n) cost is acceptable.
801    fn locate(&self, p: P2) -> Option<usize> {
802        self.locate_with_progress(p, &mut || Ok(())).ok().flatten()
803    }
804
805    fn locate_with_progress(
806        &self,
807        p: P2,
808        progress: &mut dyn FnMut() -> Result<(), crate::terrain_cdt::TerrainCdtError>,
809    ) -> Result<Option<usize>, crate::terrain_cdt::TerrainCdtError> {
810        let mut on_edge = None;
811        for ti in 0..self.tris.len() {
812            progress()?;
813            if !self.tris[ti].alive {
814                continue;
815            }
816            let v = self.tris[ti].v;
817            let o0 = orient(self.points[v[0]], self.points[v[1]], p);
818            let o1 = orient(self.points[v[1]], self.points[v[2]], p);
819            let o2 = orient(self.points[v[2]], self.points[v[0]], p);
820            if o0 >= 0 && o1 >= 0 && o2 >= 0 {
821                if o0 > 0 && o1 > 0 && o2 > 0 {
822                    return Ok(Some(ti));
823                }
824                on_edge.get_or_insert(ti);
825            }
826        }
827        Ok(on_edge)
828    }
829
830    /// [`Cdt::locate`] by deterministic straight-line walk from alive triangle
831    /// `start` (the refinement caller knows a triangle near `p` — its skinny
832    /// source — so the walk is a few steps instead of an O(T) scan). At each
833    /// triangle, step across the FIRST edge (canonical edge order) that `p` is
834    /// strictly outside of; when no edge rejects, the closed triangle contains
835    /// `p`. Exact orients ⇒ deterministic path. Falls back to the linear scan
836    /// on a dead/missing start, a hull exit, or a step-cap hit (degenerate
837    /// cycling), so the result is always the same kind of answer `locate` gives.
838    fn locate_from(&self, start: usize, p: P2) -> Option<usize> {
839        if !self.tris.get(start).is_some_and(|t| t.alive) {
840            return self.locate(p);
841        }
842        let mut cur = start;
843        let cap = self.tris.len() * 2 + 16;
844        for _ in 0..cap {
845            let v = self.tris[cur].v;
846            let mut moved = false;
847            for e in 0..3 {
848                let a = self.points[v[e]];
849                let b = self.points[v[(e + 1) % 3]];
850                if orient(a, b, p) < 0 {
851                    let nb = self.tris[cur].n[e];
852                    if nb == NONE || !self.tris[nb].alive {
853                        return self.locate(p); // walked off the hull — fall back
854                    }
855                    cur = nb;
856                    moved = true;
857                    break;
858                }
859            }
860            if !moved {
861                return Some(cur);
862            }
863        }
864        self.locate(p)
865    }
866
867    /// Walk-locate that is EXACTLY [`Cdt::locate`] whenever it answers: it only
868    /// returns a triangle that STRICTLY contains `p`, and a strictly-containing
869    /// triangle is unique, so the canonical lowest-index scan must return the
870    /// same one. Every other outcome — `p` on an edge/vertex (the one case
871    /// where `locate`'s lowest-index tie-break is observable), a dead start, a
872    /// hull exit, a step-cap hit — answers `None` and the caller falls back to
873    /// the scan. That is what makes the walk a pure speedup and not a behaviour
874    /// change.
875    fn walk_strict_with_progress(
876        &self,
877        start: usize,
878        p: P2,
879        progress: &mut dyn FnMut() -> Result<(), crate::terrain_cdt::TerrainCdtError>,
880    ) -> Result<Option<usize>, crate::terrain_cdt::TerrainCdtError> {
881        if !self.tris.get(start).is_some_and(|t| t.alive) {
882            return Ok(None);
883        }
884        let mut cur = start;
885        // Any cap is safe (a miss just takes the scan); this one bounds the
886        // pathological "first negative edge" cycle without being O(T) in
887        // practice.
888        let cap = self.tris.len().min(4096) + 16;
889        for _ in 0..cap {
890            progress()?;
891            let v = self.tris[cur].v;
892            let o0 = orient(self.points[v[0]], self.points[v[1]], p);
893            let o1 = orient(self.points[v[1]], self.points[v[2]], p);
894            let o2 = orient(self.points[v[2]], self.points[v[0]], p);
895            if o0 > 0 && o1 > 0 && o2 > 0 {
896                return Ok(Some(cur));
897            }
898            let e = if o0 < 0 {
899                0
900            } else if o1 < 0 {
901                1
902            } else if o2 < 0 {
903                2
904            } else {
905                return Ok(None); // on the closed boundary — let `locate` tie-break
906            };
907            let nb = self.tris[cur].n[e];
908            if nb == NONE || !self.tris[nb].alive {
909                return Ok(None);
910            }
911            cur = nb;
912        }
913        Ok(None)
914    }
915
916    // ─────────────────────── constraint recovery ──────────────────────────
917
918    fn enforce_constraints_with_progress(
919        &mut self,
920        progress: &mut dyn FnMut() -> Result<(), crate::terrain_cdt::TerrainCdtError>,
921    ) -> Result<bool, crate::terrain_cdt::TerrainCdtError> {
922        // Materialise the alive edge set ONCE instead of re-scanning every
923        // triangle per `edge_exists` probe (4.7e8 slot visits on ISSUE_129 —
924        // the second-largest cost in the consolidate path). Recovery's only
925        // topology mutation is the explicit `flip` below, whose edge delta is
926        // exactly "drop u-w, add apex-q", so the set stays exact.
927        let mut edges: rustc_hash::FxHashSet<(usize, usize)> = rustc_hash::FxHashSet::default();
928        for t in self.tris.iter().filter(|t| t.alive) {
929            progress()?;
930            for e in 0..3 {
931                edges.insert(ekey(t.v[e], t.v[(e + 1) % 3]));
932            }
933        }
934        let segs: Vec<(usize, usize)> = self.constraints.iter().copied().collect();
935        for (a, b) in segs {
936            progress()?;
937            if !self.recover_segment_with_progress(a, b, &mut edges, progress)? {
938                return Ok(false);
939            }
940        }
941        Ok(true)
942    }
943
944    /// Recover a single constraint segment `a-b` by repeatedly flipping a
945    /// triangulation edge that crosses it. Deterministic: each pass scans the
946    /// triangle slots in index order and flips the FIRST crossing edge whose
947    /// quad is convex, which is not the crossing nearest `a` (Sloan's order,
948    /// whose termination proof this does not inherit). The `guard` below is
949    /// the only bound, and it counts flips: every one of those passes is an
950    /// O(T) rescan with exact predicates, so an unrecoverable segment costs up
951    /// to 100 000 x T predicate evaluations before the caller falls back.
952    /// Reordering the walk would change the flip sequence and, on cocircular
953    /// input (every rectangular profile), the emitted triangulation, which
954    /// the mesh-determinism manifests pin; re-pin both if you reorder it.
955    fn recover_segment_with_progress(
956        &mut self,
957        a: usize,
958        b: usize,
959        edges: &mut rustc_hash::FxHashSet<(usize, usize)>,
960        progress: &mut dyn FnMut() -> Result<(), crate::terrain_cdt::TerrainCdtError>,
961    ) -> Result<bool, crate::terrain_cdt::TerrainCdtError> {
962        if edges.contains(&ekey(a, b)) {
963            return Ok(true);
964        }
965        let pa = self.points[a];
966        let pb = self.points[b];
967        let mut guard = 0usize;
968        loop {
969            progress()?;
970            guard += 1;
971            if guard > 100_000 {
972                RECOVERY_EXHAUSTED.fetch_add(1, Ordering::Relaxed);
973                return Ok(false);
974            }
975            if edges.contains(&ekey(a, b)) {
976                return Ok(true);
977            }
978            // Find an edge (u,v) strictly crossing segment a-b whose flip is
979            // legal (the quad is convex). Scan triangles in index order for
980            // determinism; pick the first crossing edge encountered.
981            let mut flipped = false;
982            'scan: for ti in 0..self.tris.len() {
983                if ti % 64 == 0 {
984                    progress()?;
985                }
986                if !self.tris[ti].alive {
987                    continue;
988                }
989                for e in 0..3 {
990                    let u = self.tris[ti].v[e];
991                    let w = self.tris[ti].v[(e + 1) % 3];
992                    // Skip edges touching a or b (they can't strictly cross).
993                    if u == a || u == b || w == a || w == b {
994                        continue;
995                    }
996                    if self.cset.contains(&ekey(u, w)) {
997                        continue;
998                    }
999                    if !segments_properly_cross(pa, pb, self.points[u], self.points[w]) {
1000                        continue;
1001                    }
1002                    let opp = self.tris[ti].n[e];
1003                    if opp == NONE || !self.tris[opp].alive {
1004                        continue;
1005                    }
1006                    // Apex of ti opposite u-w, and apex of opp.
1007                    let apex = self.tris[ti].v[(e + 2) % 3];
1008                    let q = self.tris[opp].v.iter().copied().find(|&x| x != u && x != w);
1009                    let Some(q) = q else { continue };
1010                    // Flip legal only if quad (u, apex, w, q) is convex, i.e.
1011                    // apex and q are on opposite sides of u-w (always true for
1012                    // a shared edge) AND the new diagonal apex-q stays inside.
1013                    if orient(self.points[u], self.points[apex], self.points[q]) == 0
1014                        || orient(self.points[w], self.points[apex], self.points[q]) == 0
1015                    {
1016                        continue;
1017                    }
1018                    // Convexity: apex and q must straddle line u-w (guaranteed),
1019                    // and u,w must straddle line apex-q for the flip to be valid.
1020                    let s1 = orient(self.points[apex], self.points[q], self.points[u]);
1021                    let s2 = orient(self.points[apex], self.points[q], self.points[w]);
1022                    if s1 == 0 || s2 == 0 || s1 == s2 {
1023                        continue; // non-convex quad; this diagonal can't flip
1024                    }
1025                    let mut tmp = Vec::new();
1026                    self.flip(ti, opp, u, w, apex, q, &mut tmp);
1027                    edges.remove(&ekey(u, w));
1028                    edges.insert(ekey(apex, q));
1029                    // Do NOT legalize here — constraint recovery must not
1030                    // re-introduce the crossing edge. Re-Delaunay happens after
1031                    // all constraints are in (constrained edges stay pinned).
1032                    flipped = true;
1033                    break 'scan;
1034                }
1035            }
1036            if !flipped {
1037                // No flippable crossing edge found — segment already present or
1038                // unrecoverable. Re-check existence at loop top.
1039                if edges.contains(&ekey(a, b)) {
1040                    return Ok(true);
1041                }
1042                RECOVERY_STUCK.fetch_add(1, Ordering::Relaxed);
1043                return Ok(false);
1044            }
1045        }
1046    }
1047
1048    /// Does an alive triangle carry undirected edge `a-b`? Test-only since the
1049    /// recovery loop reads the maintained edge set instead.
1050    #[cfg(test)]
1051    fn edge_exists(&self, a: usize, b: usize) -> bool {
1052        self.tris
1053            .iter()
1054            .any(|t| t.alive && t.edge_of(a, b).is_some())
1055    }
1056
1057    /// Restore the Delaunay property everywhere EXCEPT across constraint edges
1058    /// (constrained Delaunay). Pushes every non-constraint edge once.
1059    fn restore_constrained_delaunay_with_progress(
1060        &mut self,
1061        progress: &mut dyn FnMut() -> Result<(), crate::terrain_cdt::TerrainCdtError>,
1062    ) -> Result<(), crate::terrain_cdt::TerrainCdtError> {
1063        let mut stack: Vec<(usize, usize)> = Vec::new();
1064        for ti in 0..self.tris.len() {
1065            progress()?;
1066            if self.tris[ti].alive {
1067                for e in 0..3 {
1068                    stack.push((ti, e));
1069                }
1070            }
1071        }
1072        self.legalize_with_progress(&mut stack, progress)
1073    }
1074
1075    // ─────────────────────── domain classification ────────────────────────
1076
1077    /// Mark which triangles are INSIDE the domain (outer ring minus holes).
1078    ///
1079    /// Single depth-parity flood from the unbounded outside: seed every alive
1080    /// triangle that touches a super-vertex at depth 0; BFS the whole adjacency
1081    /// graph, incrementing depth when an edge is a CONSTRAINT and keeping it
1082    /// when it is not. A triangle is INSIDE iff its depth is odd. This handles
1083    /// arbitrary nesting (outer ring = depth 1 = inside, holes inside it = depth
1084    /// 2 = outside, islands in holes = depth 3 = inside, …) and never depends on
1085    /// HashMap order. Triangles not reached from the super-vertices (isolated by
1086    /// a fully-constrained shell with no super-vertex contact) are resolved by a
1087    /// second pass that seeds the lowest-index unvisited triangle as outside;
1088    /// for the well-formed rings this path produces it is unreachable, but it
1089    /// keeps the classifier total.
1090    fn inside_flags(&self) -> Vec<bool> {
1091        let n = self.tris.len();
1092        let mut depth: Vec<i32> = vec![-1; n]; // -1 = unvisited
1093        let mut queue: VecDeque<usize> = VecDeque::new();
1094
1095        // Seed depth 0 (outside) from every super-vertex-incident triangle.
1096        for ti in 0..n {
1097            if !self.tris[ti].alive {
1098                continue;
1099            }
1100            if self.tris[ti].v.iter().any(|&x| x >= self.super_base) && depth[ti] == -1 {
1101                depth[ti] = 0;
1102                queue.push_back(ti);
1103            }
1104        }
1105
1106        let bfs = |start_queue: &mut VecDeque<usize>, depth: &mut [i32]| {
1107            while let Some(ti) = start_queue.pop_front() {
1108                let d = depth[ti];
1109                for e in 0..3 {
1110                    let nb = self.tris[ti].n[e];
1111                    if nb == NONE || !self.tris[nb].alive || depth[nb] != -1 {
1112                        continue;
1113                    }
1114                    let a = self.tris[ti].v[e];
1115                    let b = self.tris[ti].v[(e + 1) % 3];
1116                    let nd = if self.cset.contains(&ekey(a, b)) {
1117                        d + 1
1118                    } else {
1119                        d
1120                    };
1121                    depth[nb] = nd;
1122                    start_queue.push_back(nb);
1123                }
1124            }
1125        };
1126        bfs(&mut queue, &mut depth);
1127
1128        // Resolve any component the super-flood couldn't reach (defensive).
1129        for seed in 0..n {
1130            if self.tris[seed].alive && depth[seed] == -1 {
1131                depth[seed] = 0;
1132                let mut q2 = VecDeque::new();
1133                q2.push_back(seed);
1134                bfs(&mut q2, &mut depth);
1135            }
1136        }
1137
1138        depth.iter().map(|&d| d > 0 && d % 2 == 1).collect()
1139    }
1140
1141    // ──────────────────────────── refinement ──────────────────────────────
1142
1143    /// NO-SPLIT refinement step: pull the lowest-index skinny candidate from
1144    /// the maintained worklist.
1145    ///
1146    /// A candidate whose circumcenter encroaches a constraint, or falls outside
1147    /// the domain, is removed PERMANENTLY: the constraint set and the domain
1148    /// partition are immutable (boundary/hole-ring segments are never split —
1149    /// see the module doc), and a triangle's circumcenter is a function of its
1150    /// own (immutable) vertices — the verdict can never change while the
1151    /// triangle slot is unchanged. (A slot rewrite re-evaluates via
1152    /// [`Cdt::track_tri`].)
1153    ///
1154    /// Returns the Steiner point and the alive triangle containing it (the
1155    /// walk-located insertion seed), or `None` when the quality bound is met.
1156    fn next_steiner(&mut self) -> Option<(P2, usize)> {
1157        loop {
1158            let &ti = self.skinny.iter().next()?;
1159            if !self.tris[ti].alive
1160                || !self.inside.get(ti).copied().unwrap_or(false)
1161                || self.tris[ti].v.iter().any(|&x| x >= self.super_base)
1162                || !self.tri_is_skinny(ti, self.cos_min_angle)
1163            {
1164                self.skinny.remove(&ti); // stale slot (killed / rewritten)
1165                continue;
1166            }
1167            let Some(cc) = self.circumcenter(ti) else {
1168                self.skinny.remove(&ti);
1169                continue;
1170            };
1171            if self.is_encroached(cc) {
1172                self.skinny.remove(&ti); // no-split mode: leave it (permanent)
1173                continue;
1174            }
1175            match self.locate_from(ti, cc) {
1176                Some(loc) if self.inside.get(loc).copied().unwrap_or(false) => {
1177                    // ti is NOT removed: cc lies strictly inside ti's
1178                    // circumcircle, so the insertion cavity kills ti and the
1179                    // stale entry drops out on its next pop.
1180                    return Some((cc, loc));
1181                }
1182                _ => {
1183                    self.skinny.remove(&ti);
1184                    continue;
1185                }
1186            }
1187        }
1188    }
1189
1190    /// Incrementally insert Steiner point `p` (known to lie in alive triangle
1191    /// `loc`, per [`Cdt::next_steiner`]) into the LIVE CDT — the no-rebuild
1192    /// refinement step. The point takes the next PRE-RESERVED slot below the
1193    /// super-triangle vertices, so every index invariant holds unchanged
1194    /// (`< n_input` = input / constraint vertex, `>= super_base` = super vertex,
1195    /// emit keeps `< n_real`) and no triangle is renumbered. Reserving the whole
1196    /// budget up front is what removes the old per-point O(T) renumbering pass.
1197    fn insert_steiner(&mut self, p: P2, loc: usize) {
1198        let vi = self.n_real;
1199        if vi >= self.super_base {
1200            self.failed = true; // budget exhausted — caller ear-clips (unreachable: the driver caps first)
1201            return;
1202        }
1203        self.points[vi] = p;
1204        self.n_real += 1;
1205        // Constraints reference input vertices only (< n_input <= vi): unchanged.
1206        let _ = self.insert_point_at_with_progress(vi, loc, &mut || Ok(()));
1207    }
1208
1209    /// Is interior triangle `ti` skinny (smallest angle < the min-angle target)
1210    /// OR over the aspect bound?
1211    ///
1212    /// DETERMINISM: the angle test is done WITHOUT any transcendental. A
1213    /// triangle's smallest angle `θ` is opposite its shortest edge `e0`; by the
1214    /// law of cosines `cos θ = (e1² + e2² − e0²) / (2 e1 e2)`. Since `cos` is
1215    /// strictly decreasing on `[0, π]`, `θ < target` ⟺ `cos θ > cos(target)`,
1216    /// so we compare against the COMPILE-TIME constant `cos_min_angle`. Only
1217    /// `+ − × ÷` and IEEE-754 `sqrt` are used (all correctly-rounded, hence
1218    /// bit-identical across x86_64 / aarch64 / wasm) — no `acos`, whose last-ULP
1219    /// result varies between libm implementations and could flip a borderline
1220    /// skinny decision and desync native vs wasm output.
1221    fn tri_is_skinny(&self, ti: usize, cos_min_angle: f64) -> bool {
1222        let v = self.tris[ti].v;
1223        let a = self.points[v[0]];
1224        let b = self.points[v[1]];
1225        let c = self.points[v[2]];
1226        let la2 = dist2(b, c);
1227        let lb2 = dist2(c, a);
1228        let lc2 = dist2(a, b);
1229        let (mut e0, mut e1, mut e2) = (la2.sqrt(), lb2.sqrt(), lc2.sqrt());
1230        // sort ascending
1231        if e0 > e1 {
1232            std::mem::swap(&mut e0, &mut e1);
1233        }
1234        if e1 > e2 {
1235            std::mem::swap(&mut e1, &mut e2);
1236        }
1237        if e0 > e1 {
1238            std::mem::swap(&mut e0, &mut e1);
1239        }
1240        if e0 <= 1e-15 {
1241            return false; // degenerate; leave it (don't spin on it)
1242        }
1243        // Aspect trigger (longest/shortest edge).
1244        if e2 / e0 > MAX_ASPECT {
1245            return true;
1246        }
1247        // Smallest-angle trigger via cos comparison (no acos — see doc above).
1248        let cos_min = (e1 * e1 + e2 * e2 - e0 * e0) / (2.0 * e1 * e2);
1249        cos_min > cos_min_angle
1250    }
1251
1252    /// Circumcenter of triangle `ti` (plain f64; deterministic). `None` if
1253    /// degenerate.
1254    fn circumcenter(&self, ti: usize) -> Option<P2> {
1255        let v = self.tris[ti].v;
1256        let a = self.points[v[0]];
1257        let b = self.points[v[1]];
1258        let c = self.points[v[2]];
1259        let dx = b[0] - a[0];
1260        let dy = b[1] - a[1];
1261        let ex = c[0] - a[0];
1262        let ey = c[1] - a[1];
1263        let d = 2.0 * (dx * ey - dy * ex);
1264        if d.abs() < 1e-20 {
1265            return None;
1266        }
1267        let b2 = dx * dx + dy * dy;
1268        let c2 = ex * ex + ey * ey;
1269        let ux = (ey * b2 - dy * c2) / d;
1270        let uy = (dx * c2 - ex * b2) / d;
1271        let cc = [a[0] + ux, a[1] + uy];
1272        if !cc[0].is_finite() || !cc[1].is_finite() {
1273            return None;
1274        }
1275        Some(cc)
1276    }
1277
1278    // ──────────────────────────── emit ────────────────────────────────────
1279
1280    /// Emit the interior triangulation as a vertex list + index list. The
1281    /// vertex list is `points[0..n_input]` followed by every Steiner point
1282    /// (`points[n_input..n_real]`); the unused reserved slots and the
1283    /// super-triangle vertices are dropped.
1284    fn emit(&self) -> (Vec<P2>, Vec<usize>) {
1285        let inside = self.inside_flags();
1286        // Compact vertex set: keep input + Steiner (drop super verts AND the
1287        // unused tail of the reserved Steiner region).
1288        let keep_upto = self.n_real;
1289        let out_points: Vec<P2> = self.points[..keep_upto].to_vec();
1290        let mut indices: Vec<usize> = Vec::new();
1291        for ti in 0..self.tris.len() {
1292            if !self.tris[ti].alive || !inside[ti] {
1293                continue;
1294            }
1295            let v = self.tris[ti].v;
1296            // Skip any triangle that (defensively) still references a super
1297            // vertex — it can't be interior, but guard anyway.
1298            if v.iter().any(|&x| x >= keep_upto) {
1299                continue;
1300            }
1301            // Ensure CCW emission (positive area) for a stable winding.
1302            let a = self.points[v[0]];
1303            let b = self.points[v[1]];
1304            let c = self.points[v[2]];
1305            if orient(a, b, c) >= 0 {
1306                indices.extend_from_slice(&[v[0], v[1], v[2]]);
1307            } else {
1308                indices.extend_from_slice(&[v[0], v[2], v[1]]);
1309            }
1310        }
1311        (out_points, indices)
1312    }
1313}
1314
1315/// Run bounded, INTERIOR-ONLY Ruppert/Chew refinement on a planar
1316/// straight-line graph (`points` + `segments`): the constraint set (boundary /
1317/// hole-ring segments) is immutable, so every refinement action is an interior
1318/// circumcenter insertion, applied INCREMENTALLY to the live CDT (never a
1319/// rebuild). This is the only mode the crate uses — the coplanar-consolidation
1320/// production path requires it, because its region boundary is SHARED with
1321/// neighbouring plane buckets triangulated independently; a boundary Steiner
1322/// point would create a T-junction / open edge at the bucket seam.
1323///
1324/// A historical full-Ruppert mode existed that also allowed splitting encroached
1325/// boundary/hole segments (rebuilding a fresh CDT after every single Steiner
1326/// point or split); it was deleted as dead code (D4 dead-code sweep) once both
1327/// production callers were confirmed to always run interior-only. The
1328/// rebuild-per-point driver was O(P²) per rebuild anyway (each rebuild
1329/// re-inserts every point with an O(T) `locate` scan), i.e. O(P³) per
1330/// refinement: 13.8 s for ONE 582-vertex/16-hole slab face, ×2 faces ×16
1331/// re-consolidates = the 155 s advanced_model #798926 many-void cliff.
1332/// Incremental insertion + walk-locate + the maintained skinny worklist
1333/// refines the same face in ~10 ms.
1334///
1335/// Deterministic: the same PSLG yields the same Steiner sequence on every
1336/// platform. Bounded by Steiner budget + iteration cap.
1337fn refine_to_fixpoint(points: Vec<P2>, segments: Vec<(usize, usize)>) -> Option<Cdt> {
1338    let n_input = points.len();
1339    let max_steiner = (n_input * 3).max(32);
1340    let mut steiner = 0usize;
1341    let mut cdt = Cdt::build_from(points, &segments, max_steiner.min(MAX_REFINE_ITERS))?;
1342
1343    cdt.start_refinement(COS_MIN_ANGLE);
1344    while steiner < max_steiner.min(MAX_REFINE_ITERS) {
1345        let Some((p, loc)) = cdt.next_steiner() else {
1346            break;
1347        };
1348        cdt.insert_steiner(p, loc);
1349        if cdt.failed {
1350            return None; // Steiner insertion tripped a topology invariant — ear-clip fallback
1351        }
1352        steiner += 1;
1353        if steiner & 0x3FF == 0 {
1354            crate::progress::tick();
1355        }
1356    }
1357    Some(cdt)
1358}
1359
1360// ─────────────────────────── public entry points ──────────────────────────
1361
1362/// Quality-triangulate a polygon-with-holes, returning the (possibly
1363/// Steiner-augmented) 2D vertex list and triangle indices into it.
1364///
1365/// `outer` is the boundary; `holes` are the holes. The returned vertex list
1366/// begins with exactly the input vertices in input order (`outer ++ holes`),
1367/// followed by any Steiner points; indices reference that combined list.
1368/// Returns `None` if the CDT can't be built (caller should fall back to
1369/// ear-clipping).
1370///
1371/// Refinement is interior-only and NEVER touches the boundary/hole rings —
1372/// required because the boundary is shared with other independently-
1373/// triangulated regions (the coplanar-consolidation path), so seams stay
1374/// watertight / T-junction-free. See [`refine_to_fixpoint`] for why this is
1375/// the only supported mode.
1376pub(crate) fn triangulate_refined(
1377    outer: &[Point2<f64>],
1378    holes: &[Vec<Point2<f64>>],
1379) -> Option<(Vec<Point2<f64>>, Vec<usize>)> {
1380    let mut rings: Vec<Vec<P2>> = Vec::with_capacity(1 + holes.len());
1381    rings.push(outer.iter().map(p2).collect());
1382    for h in holes {
1383        if h.len() >= 3 {
1384            rings.push(h.iter().map(p2).collect());
1385        }
1386    }
1387    let (points, segments) = rings_to_pslg(&rings);
1388    let cdt = refine_to_fixpoint(points, segments)?;
1389    let (pts, idx) = cdt.emit();
1390    if idx.is_empty() {
1391        return None;
1392    }
1393    let out_pts: Vec<Point2<f64>> = pts.iter().map(|p| Point2::new(p[0], p[1])).collect();
1394    Some((out_pts, idx))
1395}
1396
1397/// Constrained Delaunay triangulation of a polygon-with-holes WITHOUT quality
1398/// (Ruppert) refinement — the minimal boundary-conforming triangulation of the
1399/// input vertices, no Steiner points. Returned vertex list is exactly the input
1400/// (`outer ++ holes`) in order; indices reference it. This is the fast,
1401/// slivers-free cap triangulator for the 2D opening-subtraction extrude: unlike
1402/// earcut it never bridges holes (so a many-hole cap stays manifold), and unlike
1403/// [`triangulate_refined`] it never pays the min-angle refinement that dominates
1404/// on a large multi-hole face. `None` if the CDT can't be built (caller falls
1405/// back to earcut).
1406pub(crate) fn triangulate_constrained(
1407    outer: &[Point2<f64>],
1408    holes: &[Vec<Point2<f64>>],
1409) -> Option<(Vec<Point2<f64>>, Vec<usize>)> {
1410    let mut rings: Vec<Vec<P2>> = Vec::with_capacity(1 + holes.len());
1411    rings.push(outer.iter().map(p2).collect());
1412    for h in holes {
1413        if h.len() >= 3 {
1414            rings.push(h.iter().map(p2).collect());
1415        }
1416    }
1417    let (points, segments) = rings_to_pslg(&rings);
1418    let cdt = Cdt::build_from(points, &segments, 0)?;
1419    let (pts, idx) = cdt.emit();
1420    if idx.is_empty() {
1421        return None;
1422    }
1423    let out_pts: Vec<Point2<f64>> = pts.iter().map(|p| Point2::new(p[0], p[1])).collect();
1424    Some((out_pts, idx))
1425}
1426
1427/// Conforming (constrained) Delaunay triangulation of an arbitrary PSLG —
1428/// points plus non-crossing constraint segments — returning EVERY triangle of
1429/// the convex hull, with NO inside/outside domain filtering. The caller
1430/// classifies each output triangle itself (e.g. by a centroid test).
1431///
1432/// Unlike [`triangulate_constrained`] the segments need not form closed rings:
1433/// the prism-cut path passes OPEN seam polylines (host-surface cross-sections)
1434/// as constraints, which would corrupt the ring depth-parity flood that
1435/// [`Cdt::emit`] uses — so this entry point deliberately skips it. Vertices are
1436/// exactly the input points in input order (no Steiner insertion); triangles
1437/// referencing the internal super-triangle are dropped and each triangle is
1438/// emitted CCW. `None` when the CDT cannot be built (degenerate input, crossing
1439/// constraints) — callers fall back to their exact path.
1440pub(crate) fn triangulate_pslg(
1441    points: &[Point2<f64>],
1442    segments: &[(usize, usize)],
1443) -> Option<(Vec<Point2<f64>>, Vec<usize>)> {
1444    let pts: Vec<P2> = points.iter().map(p2).collect();
1445    let cdt = Cdt::build_from(pts, segments, 0)?;
1446    let keep_upto = cdt.super_base;
1447    let mut indices: Vec<usize> = Vec::new();
1448    for tri in &cdt.tris {
1449        if !tri.alive {
1450            continue;
1451        }
1452        let v = tri.v;
1453        if v.iter().any(|&x| x >= keep_upto) {
1454            continue;
1455        }
1456        let a = cdt.points[v[0]];
1457        let b = cdt.points[v[1]];
1458        let c = cdt.points[v[2]];
1459        if orient(a, b, c) >= 0 {
1460            indices.extend_from_slice(&[v[0], v[1], v[2]]);
1461        } else {
1462            indices.extend_from_slice(&[v[0], v[2], v[1]]);
1463        }
1464    }
1465    if indices.is_empty() {
1466        return None;
1467    }
1468    let out_pts: Vec<Point2<f64>> = cdt.points[..keep_upto]
1469        .iter()
1470        .map(|p| Point2::new(p[0], p[1]))
1471        .collect();
1472    Some((out_pts, indices))
1473}
1474
1475pub(crate) fn triangulate_pslg_with_progress(
1476    points: &[Point2<f64>],
1477    segments: &[(usize, usize)],
1478    progress: &mut dyn FnMut() -> Result<(), crate::terrain_cdt::TerrainCdtError>,
1479) -> Result<Option<(Vec<Point2<f64>>, Vec<usize>)>, crate::terrain_cdt::TerrainCdtError> {
1480    let pts: Vec<P2> = points.iter().map(p2).collect();
1481    let Some(cdt) = Cdt::build_from_with_progress(pts, segments, 0, progress)? else {
1482        return Ok(None);
1483    };
1484    let keep_upto = cdt.super_base;
1485    let mut indices: Vec<usize> = Vec::new();
1486    for tri in &cdt.tris {
1487        progress()?;
1488        if !tri.alive {
1489            continue;
1490        }
1491        let v = tri.v;
1492        if v.iter().any(|&x| x >= keep_upto) {
1493            continue;
1494        }
1495        let a = cdt.points[v[0]];
1496        let b = cdt.points[v[1]];
1497        let c = cdt.points[v[2]];
1498        if orient(a, b, c) >= 0 {
1499            indices.extend_from_slice(&[v[0], v[1], v[2]]);
1500        } else {
1501            indices.extend_from_slice(&[v[0], v[2], v[1]]);
1502        }
1503    }
1504    if indices.is_empty() {
1505        return Ok(None);
1506    }
1507    let out_pts: Vec<Point2<f64>> = cdt.points[..keep_upto]
1508        .iter()
1509        .map(|p| Point2::new(p[0], p[1]))
1510        .collect();
1511    Ok(Some((out_pts, indices)))
1512}
1513
1514#[cfg(test)]
1515#[path = "cdt_tests.rs"]
1516mod tests;