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;