Skip to main content

ifc_lite_geometry/kernel/
retriangulate.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//! In-plane constrained re-triangulation — phases A–F.
6//!
7//! Each input triangle `T` crossed by other triangles accumulates intersection
8//! sub-segments lying in its plane; this module re-triangulates `T` into a
9//! conforming, intersection-free fan of sub-triangles whose vertices are
10//! referenced SYMBOLICALLY (via the interner, never a float coordinate), with a
11//! topology that is invariant to insertion order and byte-identical across
12//! platforms.
13//!
14//! PHASE A is the exact projection axis + reference winding; PHASE B the
15//! canonical lex-rank work list; phases C–F (point insertion, segment
16//! insertion, earcut, emit) build on the canonical list produced here.
17
18use super::interner::{Interner, Vid};
19use super::predicates::{cmp_lex, orient2d, orient2d_any};
20use super::retriangulate_recover::enforce_constraint;
21use super::{fixed, interval};
22use super::{DropAxis, ImplicitPoint, Lpi, Sign, Tpi};
23use std::cmp::Ordering;
24use std::collections::{BTreeMap, BTreeSet};
25
26#[inline]
27fn e(p: [f64; 3]) -> ImplicitPoint {
28    ImplicitPoint::Explicit(p)
29}
30
31/// Vid-based exact orient2d — the dominant re-triangulation predicate. Tiers, all
32/// returning the SAME exact sign (faster ones resolve the easy cases first):
33/// all-explicit Shewchuk (f64) → f64 directed-rounding interval from the cached
34/// lambdas → cached-I512 determinant → ImplicitPoint cascade (BigRational tail).
35/// The interval tier carries the non-degenerate majority of implicit-point
36/// predicates in pure f64, so the wasm-emulated I512 path is reached only on a
37/// genuine zero-straddle — the fix for the dense-opening-wall wasm cost.
38#[inline]
39pub(crate) fn orient2d_v(it: &Interner, a: Vid, b: Vid, c: Vid, axis: DropAxis) -> Sign {
40    let (pa, pb, pc) = (it.get(a), it.get(b), it.get(c));
41    // All-explicit: the Shewchuk adaptive predicate is EXACT yet pure f64.
42    if let (ImplicitPoint::Explicit(_), ImplicitPoint::Explicit(_), ImplicitPoint::Explicit(_)) =
43        (pa, pb, pc)
44    {
45        return orient2d(pa, pb, pc, axis);
46    }
47    // f64 INTERVAL from the cached lambdas — pure f64, no wide-int; resolves the
48    // non-degenerate majority and is bit-identical to the exact sign when definite.
49    if let Some(s) = interval::orient2d_from_lam_iv(it.lam_iv(a), it.lam_iv(b), it.lam_iv(c), axis) {
50        return s;
51    }
52    if let (Some(la), Some(lb), Some(lc)) = (it.lam(a), it.lam(b), it.lam(c)) {
53        if let Some(s) = fixed::orient2d_from_lam(la, lb, lc, axis) {
54            return s;
55        }
56    }
57    orient2d_any(pa, pb, pc, axis)
58}
59
60/// Vid-based exact lexicographic compare — f64 interval from the cached lambdas
61/// first, then the cached-I512 compare, then the ImplicitPoint cascade.
62#[inline]
63pub(crate) fn cmp_lex_v(it: &Interner, a: Vid, b: Vid) -> Sign {
64    if let Some(s) = interval::cmp_lex_from_lam_iv(it.lam_iv(a), it.lam_iv(b)) {
65        return s;
66    }
67    if let (Some(la), Some(lb)) = (it.lam(a), it.lam(b)) {
68        if let Some(s) = fixed::cmp_lex_from_lam(la, lb) {
69            return s;
70        }
71    }
72    let (pa, pb) = (it.get(a), it.get(b));
73    cmp_lex(pa, pb)
74}
75
76/// A constraint segment lying in `T`'s plane (endpoints explicit or implicit).
77#[derive(Clone)]
78pub struct Constraint {
79    pub a: ImplicitPoint,
80    pub b: ImplicitPoint,
81}
82
83/// Input to the re-triangulation of one triangle `T`.
84pub struct RetriInput {
85    pub tri: [[f64; 3]; 3],
86    pub constraints: Vec<Constraint>,
87    /// Isolated CONFORMITY VERTICES (no segment): a `TriTri::Point` tangential
88    /// touch of the other operand. When such a point lies exactly ON one of
89    /// `T`'s edges, the NEIGHBOR triangle sees a full crossing SEGMENT ending
90    /// at that point and splits the shared edge there — `T` must split it too
91    /// or the surfaces stop conforming (a T-junction ⇒ exact-coordinate open
92    /// edges: the flush-corner-on-diagonal family, e.g. a window box whose
93    /// corner lands on the host face triangle's diagonal).
94    pub points: Vec<ImplicitPoint>,
95}
96
97#[inline]
98fn normal_idx(a: DropAxis) -> usize {
99    match a {
100        DropAxis::X => 0,
101        DropAxis::Y => 1,
102        DropAxis::Z => 2,
103    }
104}
105
106/// Candidate drop axes, dominant-normal-component first (the f64 magnitude order
107/// is deterministic — no FMA, IEEE-754 cross product; ties broken by axis index).
108/// The CHOICE among candidates is decided exactly by `orient2d != Zero`, so the
109/// f64 magnitude only orders candidates, never decides degeneracy.
110fn axis_candidates(t: &[[f64; 3]; 3]) -> [DropAxis; 3] {
111    let sub = |a: [f64; 3], b: [f64; 3]| [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
112    let u = sub(t[1], t[0]);
113    let v = sub(t[2], t[0]);
114    let n = [
115        u[1] * v[2] - u[2] * v[1],
116        u[2] * v[0] - u[0] * v[2],
117        u[0] * v[1] - u[1] * v[0],
118    ];
119    let mag = [n[0].abs(), n[1].abs(), n[2].abs()];
120    let mut axes = [DropAxis::X, DropAxis::Y, DropAxis::Z];
121    axes.sort_by(|&a, &b| {
122        let (ia, ib) = (normal_idx(a), normal_idx(b));
123        mag[ib]
124            .partial_cmp(&mag[ia])
125            .unwrap_or(Ordering::Equal)
126            .then(ia.cmp(&ib))
127    });
128    axes
129}
130
131/// PHASE A — pick the drop axis whose projected area is EXACTLY nonzero, plus the
132/// reference winding `w0` (the orient2d sign of `T` under that axis, so every
133/// output sub-triangle can be emitted with `T`'s orientation). `None` ⇒ `T` is
134/// degenerate (zero projected area in every axis).
135pub fn projection_axis(t: &[[f64; 3]; 3]) -> Option<(DropAxis, Sign)> {
136    for axis in axis_candidates(t) {
137        let w = orient2d(&e(t[0]), &e(t[1]), &e(t[2]), axis);
138        if w != Sign::Zero {
139            return Some((axis, w));
140        }
141    }
142    None
143}
144
145/// PHASE B output — the canonical, order-independent work list.
146pub struct Canonical {
147    /// `T`'s three corners, interned.
148    pub corners: [Vid; 3],
149    /// Constraint segments, each ordered `lo ≤ hi` by lex-rank; the list itself
150    /// is lex-sorted and deduplicated.
151    pub segments: Vec<(Vid, Vid)>,
152    /// Isolated conformity vertices (tangential touches), lex-sorted, deduped.
153    pub points: Vec<Vid>,
154}
155
156pub(crate) fn lex_cmp(it: &Interner, a: Vid, b: Vid) -> Ordering {
157    match cmp_lex_v(it, a, b) {
158        Sign::Negative => Ordering::Less,
159        Sign::Positive => Ordering::Greater,
160        Sign::Zero => Ordering::Equal, // only when a == b (distinct Vids never coincide)
161    }
162}
163
164/// PHASE B — intern `T`'s corners + every constraint endpoint into the shared
165/// `interner`, order each segment's endpoints by lex-rank, and sort the segment
166/// list canonically (deduplicating). The output is a pure function of the input
167/// geometry, independent of the order constraints arrive in.
168pub fn canonicalize(input: &RetriInput, interner: &mut Interner) -> Canonical {
169    let corners = [
170        interner.intern(e(input.tri[0])),
171        interner.intern(e(input.tri[1])),
172        interner.intern(e(input.tri[2])),
173    ];
174    let mut segments: Vec<(Vid, Vid)> = input
175        .constraints
176        .iter()
177        .filter_map(|c| {
178            let va = interner.intern(c.a.clone());
179            let vb = interner.intern(c.b.clone());
180            if va == vb {
181                None // degenerate: coincident endpoints
182            } else if lex_cmp(interner, va, vb) == Ordering::Greater {
183                Some((vb, va))
184            } else {
185                Some((va, vb))
186            }
187        })
188        .collect();
189    segments.sort_by(|&(a0, a1), &(b0, b1)| {
190        lex_cmp(interner, a0, b0).then_with(|| lex_cmp(interner, a1, b1))
191    });
192    segments.dedup();
193    let mut points: Vec<Vid> = input.points.iter().map(|p| interner.intern(p.clone())).collect();
194    points.sort_by(|&a, &b| lex_cmp(interner, a, b));
195    points.dedup();
196    Canonical { corners, segments, points }
197}
198
199/// A sub-triangle of `T` (interned Vids), oriented to match `w0`.
200pub type SubTri = [Vid; 3];
201
202/// The evolving 2D triangulation of `T` during phases C–E.
203pub struct Mesh2d {
204    pub tris: Vec<SubTri>,
205    pub axis: DropAxis,
206    pub w0: Sign,
207    /// Constraint sub-segments the enforcement fixed point could NOT force as
208    /// edges (degenerate channels the pocket rebuild bails on). Non-zero ⇒ the
209    /// triangulation does not fully CONFORM to the other operand: sub-triangles
210    /// may straddle an intersection line and their centroid classification is
211    /// then unreliable. The batched void path treats this as a hard reject
212    /// (fall back to sequential cuts); the binary path keeps its historical
213    /// graceful-degrade behavior.
214    pub unrecovered: usize,
215    /// Set by [`recover_subsegment`] whenever a recovery attempt could not
216    /// force its edge (any bail path). Gates the final conformity audit in
217    /// [`triangulate`] so the clean common path pays nothing for it.
218    pub audit_needed: bool,
219    /// Per-`Vid` cached 2D f64 coordinate (dropped to `axis`, `None` when the
220    /// implicit point has no finite f64 image). Used ONLY as a conservative
221    /// broadphase prefilter in [`insert_point`] / channel detection — the exact
222    /// predicate still decides every retained triangle, so this never affects
223    /// topology. Cached because the same vertices are re-scanned on every point
224    /// insertion AND on every constraint sub-segment's O(tris) channel scan.
225    pub coords: BTreeMap<Vid, Option<[f64; 2]>>,
226}
227
228enum Locate {
229    Interior,
230    OnEdge,
231    OnVertex,
232    Outside,
233}
234
235/// Classify point `p` against sub-triangle `tri` (oriented `w0`): the three edge
236/// `orient2d` signs say inside (all `w0`), on an edge (one `Zero`), on a vertex,
237/// or outside (any sign opposite `w0`).
238fn locate(it: &Interner, tri: SubTri, p: Vid, axis: DropAxis, w0: Sign) -> Locate {
239    if tri.contains(&p) {
240        return Locate::OnVertex;
241    }
242    let s = [
243        orient2d_v(it, tri[0], tri[1], p, axis),
244        orient2d_v(it, tri[1], tri[2], p, axis),
245        orient2d_v(it, tri[2], tri[0], p, axis),
246    ];
247    if s.iter().any(|&x| x == w0.flip()) {
248        return Locate::Outside;
249    }
250    match s.iter().filter(|&&x| x == Sign::Zero).count() {
251        0 => Locate::Interior,
252        1 => Locate::OnEdge,
253        _ => Locate::OnVertex, // 2+ zeros ⇒ coincident with a vertex
254    }
255}
256
257/// Minimum working-set size before the f64-AABB broadphase prefilters engage.
258/// Below this the exact scan is already short, so the cache/AABB bookkeeping
259/// would be pure overhead (it measurably slowed boolean-dense-but-simple models);
260/// above it the O(N²) exact-predicate blow-up dominates and the prefilter wins.
261/// Purely a performance gate — it never changes which triangles `locate` accepts.
262pub(crate) const PREFILTER_MIN: usize = 32;
263
264/// `p`'s coordinates dropped to the kept 2D plane for projection `axis`.
265#[inline]
266pub(crate) fn project2d(p: [f64; 3], axis: DropAxis) -> [f64; 2] {
267    match axis {
268        DropAxis::X => [p[1], p[2]],
269        DropAxis::Y => [p[0], p[2]],
270        DropAxis::Z => [p[0], p[1]],
271    }
272}
273
274/// Cached 2D f64 image of vertex `v` (dropped to `axis`). `None` when the
275/// implicit point has no finite f64 image (degenerate construction). Used ONLY
276/// by the [`insert_point`] broadphase, never by an exact decision; cached
277/// because the same vertices are re-scanned on every point insertion.
278#[inline]
279pub(crate) fn coord2d_cached(
280    it: &Interner,
281    v: Vid,
282    axis: DropAxis,
283    cache: &mut BTreeMap<Vid, Option<[f64; 2]>>,
284) -> Option<[f64; 2]> {
285    if let Some(&c) = cache.get(&v) {
286        return c;
287    }
288    let c = fixed::point_to_f64(it.get(v)).map(|p3| project2d(p3, axis));
289    cache.insert(v, c);
290    c
291}
292
293/// True when `p2` (a point's 2D f64 image) lies outside `tri`'s f64 AABB widened
294/// by a generous margin ⇒ `tri` provably cannot contain it. The margin (absolute
295/// floor + magnitude-relative term) dwarfs the worst f64 rounding / implicit-
296/// point image error by many orders, so this is a CONSERVATIVE reject: it never
297/// excludes a triangle that genuinely contains the point, on any platform. A
298/// vertex without a finite f64 image disables the reject for `tri` (keep it).
299#[inline]
300fn aabb_excludes(
301    it: &Interner,
302    tri: SubTri,
303    p2: [f64; 2],
304    axis: DropAxis,
305    cache: &mut BTreeMap<Vid, Option<[f64; 2]>>,
306) -> bool {
307    let (a, b, c) = match (
308        coord2d_cached(it, tri[0], axis, cache),
309        coord2d_cached(it, tri[1], axis, cache),
310        coord2d_cached(it, tri[2], axis, cache),
311    ) {
312        (Some(a), Some(b), Some(c)) => (a, b, c),
313        _ => return false,
314    };
315    let min_x = a[0].min(b[0]).min(c[0]);
316    let max_x = a[0].max(b[0]).max(c[0]);
317    let min_y = a[1].min(b[1]).min(c[1]);
318    let max_y = a[1].max(b[1]).max(c[1]);
319    let mx = 1e-6 + p2[0].abs() * 1e-9;
320    let my = 1e-6 + p2[1].abs() * 1e-9;
321    p2[0] < min_x - mx || p2[0] > max_x + mx || p2[1] < min_y - my || p2[1] > max_y + my
322}
323
324/// True when triangle `tri`'s 2D f64 AABB is disjoint (beyond a generous margin)
325/// from the box `bx` = `[min_x, min_y, max_x, max_y]` ⇒ no edge of `tri` can
326/// cross a segment contained in `bx`. Conservative (margin dwarfs the f64 /
327/// implicit-point error); a vertex with no finite f64 image disables the reject.
328#[inline]
329pub(crate) fn tri_aabb_disjoint(
330    it: &Interner,
331    tri: SubTri,
332    bx: [f64; 4],
333    axis: DropAxis,
334    cache: &mut BTreeMap<Vid, Option<[f64; 2]>>,
335) -> bool {
336    let (a, b, c) = match (
337        coord2d_cached(it, tri[0], axis, cache),
338        coord2d_cached(it, tri[1], axis, cache),
339        coord2d_cached(it, tri[2], axis, cache),
340    ) {
341        (Some(a), Some(b), Some(c)) => (a, b, c),
342        _ => return false,
343    };
344    let tmin_x = a[0].min(b[0]).min(c[0]);
345    let tmax_x = a[0].max(b[0]).max(c[0]);
346    let tmin_y = a[1].min(b[1]).min(c[1]);
347    let tmax_y = a[1].max(b[1]).max(c[1]);
348    let m = 1e-6 + bx[2].abs().max(bx[3].abs()).max(tmax_x.abs()).max(tmax_y.abs()) * 1e-9;
349    tmax_x < bx[0] - m || tmin_x > bx[2] + m || tmax_y < bx[1] - m || tmin_y > bx[3] + m
350}
351
352/// PHASE C — insert point `p` (interned), splitting the triangle(s) containing
353/// it. Uniform cavity-fan: gather the triangles that contain `p` (one if
354/// interior, two across a shared edge), take the cavity's boundary edges, and
355/// fan `p` to each. `p` is interior to the cavity, so every boundary edge `u→v`
356/// has `p` on its left ⇒ `[u,v,p]` preserves `w0`. Handles interior (1→3) and
357/// on-edge (→4) uniformly; an already-present vertex is a no-op.
358pub(crate) fn insert_point(mesh: &mut Mesh2d, it: &Interner, p: Vid) {
359    let axis = mesh.axis;
360    let w0 = mesh.w0;
361    // Conservative broadphase prefilter. `pc` is `p`'s f64 image dropped to the
362    // projection axis; for each triangle we skip the (possibly BigRational)
363    // exact `locate` when `p` lies outside that triangle's widened f64 AABB. The
364    // margin guarantees a triangle truly containing `p` (interior / on-edge /
365    // on-vertex) is NEVER skipped on any platform, so the cavity — and the whole
366    // resulting topology — is bit-identical to the unfiltered scan. This
367    // collapses the per-host-face O(points·triangles) exact-predicate blowup on
368    // heavily fragmented faces (many openings in one wall) toward O(points +
369    // triangles) exact calls — the cause of the WASM stall on dense facades.
370    // Engaged only once the triangle set is large enough to amortise the cache.
371    let pc = if mesh.tris.len() > PREFILTER_MIN {
372        coord2d_cached(it, p, axis, &mut mesh.coords)
373    } else {
374        None
375    };
376    let mut cavity = Vec::new();
377    for ti in 0..mesh.tris.len() {
378        let tri = mesh.tris[ti];
379        if let Some(p2) = pc {
380            if aabb_excludes(it, tri, p2, axis, &mut mesh.coords) {
381                continue;
382            }
383        }
384        match locate(it, tri, p, axis, w0) {
385            Locate::OnVertex => return,
386            Locate::Interior | Locate::OnEdge => cavity.push(ti),
387            Locate::Outside => {}
388        }
389    }
390    if cavity.is_empty() {
391        return; // p not inside T
392    }
393    let cavity_set: BTreeSet<usize> = cavity.iter().copied().collect();
394    let mut edges: BTreeSet<(Vid, Vid)> = BTreeSet::new();
395    for &ti in &cavity {
396        let [a, b, c] = mesh.tris[ti];
397        edges.insert((a, b));
398        edges.insert((b, c));
399        edges.insert((c, a));
400    }
401    // Boundary edges = those whose reverse is not also in the cavity, EXCLUDING
402    // any edge `p` lies on (collinear): fanning `p` to an edge it's on would make
403    // a degenerate triangle — that edge is split instead, by the adjacent fans.
404    let boundary: Vec<(Vid, Vid)> = edges
405        .iter()
406        .copied()
407        .filter(|&(u, v)| !edges.contains(&(v, u)))
408        .filter(|&(u, v)| orient2d_v(it, u, v, p, axis) != Sign::Zero)
409        .collect();
410    mesh.tris = mesh
411        .tris
412        .iter()
413        .enumerate()
414        .filter(|(i, _)| !cavity_set.contains(i))
415        .map(|(_, t)| *t)
416        .collect();
417    for (u, v) in boundary {
418        mesh.tris.push([u, v, p]);
419    }
420}
421
422/// Is `p` strictly OUTSIDE the closed triangle `(a,b,c)` (oriented `w0`)? — true
423/// iff some edge has `p` on its far (opposite-`w0`) side. Used by the ear test:
424/// an ear is valid only when every other vertex is strictly outside (a vertex on
425/// the ear's boundary blocks it, else clipping leaves a degenerate sliver).
426fn strictly_outside(it: &Interner, a: Vid, b: Vid, c: Vid, p: Vid, axis: DropAxis, w0: Sign) -> bool {
427    let opp = w0.flip();
428    orient2d_v(it, a, b, p, axis) == opp
429        || orient2d_v(it, b, c, p, axis) == opp
430        || orient2d_v(it, c, a, p, axis) == opp
431}
432
433/// PHASE E — triangulate a simple polygon `ring` (oriented `w0`) by deterministic
434/// ear clipping. An ear is a strictly-convex corner whose triangle contains no
435/// other ring vertex; among all ears we always clip the one with the
436/// lexicographically-least APEX, so the output is a pure function of the ring
437/// (independent of where the ring starts). The two-ears theorem guarantees a
438/// simple polygon always has an ear → termination.
439pub fn earcut(it: &Interner, ring: &[Vid], axis: DropAxis, w0: Sign) -> Vec<SubTri> {
440    let mut poly: Vec<Vid> = ring.to_vec();
441    // f64 2D images of the ring vertices, maintained parallel to `poly`. Used ONLY
442    // as a conservative AABB prefilter in the ear-emptiness test (the dominant
443    // #1109 earcut cost on dense-opening slabs): a vertex outside the candidate
444    // ear's widened f64 AABB is provably outside the ear, so its exact
445    // `strictly_outside` predicate is skipped. The margin dwarfs the f64 /
446    // implicit-point image error, exactly as `tri_aabb_disjoint`, so a vertex
447    // genuinely inside the ear is NEVER skipped — the chosen ear, and thus the
448    // whole triangulation, is byte-identical to the all-exact form (parity).
449    let mut pc: Vec<Option<[f64; 2]>> = poly
450        .iter()
451        .map(|&v| fixed::point_to_f64(it.get(v)).map(|p3| project2d(p3, axis)))
452        .collect();
453    let mut out = Vec::new();
454    while poly.len() > 3 {
455        let n = poly.len();
456        // Below PREFILTER_MIN the exact emptiness scan is already short, so the
457        // AABB bookkeeping would be pure overhead — fall back to all-exact, which
458        // is the identical decision. Above it the O(n) exact scan dominates.
459        let prefilter = n > PREFILTER_MIN;
460        let mut best: Option<usize> = None;
461        for i in 0..n {
462            let (ia, ic) = ((i + n - 1) % n, (i + 1) % n);
463            let a = poly[ia];
464            let b = poly[i];
465            let c = poly[ic];
466            // strictly convex under w0
467            if orient2d_v(it, a, b, c, axis) != w0 {
468                continue;
469            }
470            let ear_box: Option<[f64; 4]> = if prefilter {
471                match (pc[ia], pc[i], pc[ic]) {
472                    (Some(pa), Some(pb), Some(pc2)) => Some([
473                        pa[0].min(pb[0]).min(pc2[0]),
474                        pa[1].min(pb[1]).min(pc2[1]),
475                        pa[0].max(pb[0]).max(pc2[0]),
476                        pa[1].max(pb[1]).max(pc2[1]),
477                    ]),
478                    _ => None,
479                }
480            } else {
481                None
482            };
483            // empty: every other ring vertex is strictly outside the closed ear
484            let empty = (0..n).all(|k| {
485                let v = poly[k];
486                if v == a || v == b || v == c {
487                    return true;
488                }
489                if let (Some(bx), Some(p)) = (ear_box, pc[k]) {
490                    let m = 1e-6
491                        + bx[2].abs().max(bx[3].abs()).max(p[0].abs()).max(p[1].abs()) * 1e-9;
492                    if p[0] < bx[0] - m || p[0] > bx[2] + m || p[1] < bx[1] - m || p[1] > bx[3] + m
493                    {
494                        return true; // provably outside the ear ⇒ skip the exact test
495                    }
496                }
497                strictly_outside(it, a, b, c, v, axis, w0)
498            });
499            if !empty {
500                continue;
501            }
502            best = Some(match best {
503                None => i,
504                Some(j) if cmp_lex_v(it, b, poly[j]) == Sign::Negative => i,
505                Some(j) => j,
506            });
507        }
508        let i = match best {
509            Some(i) => i,
510            None => {
511                // Degenerate pocket (no strictly-convex empty ear — a non-simple or
512                // collinear polygon). Fan-triangulate the remainder rather than
513                // panic: a panic aborts the wasm worker (panic=abort) and stalls
514                // the whole geometry stream. The fan may contain slivers, which
515                // consolidate_coplanar cleans up at the seam.
516                for k in 1..poly.len() - 1 {
517                    out.push([poly[0], poly[k], poly[k + 1]]);
518                }
519                return out;
520            }
521        };
522        let n = poly.len();
523        out.push([poly[(i + n - 1) % n], poly[i], poly[(i + 1) % n]]);
524        poly.remove(i);
525        pc.remove(i);
526    }
527    out.push([poly[0], poly[1], poly[2]]);
528    out
529}
530
531#[inline]
532pub(crate) fn tri_edges(t: SubTri) -> [(Vid, Vid); 3] {
533    [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])]
534}
535
536/// Is there a triangle with both `s` and `t` as vertices? (In a triangle any two
537/// vertices form an edge, so this is exactly "the segment s–t is already an edge".)
538pub(crate) fn edge_exists(mesh: &Mesh2d, s: Vid, t: Vid) -> bool {
539    mesh.tris.iter().any(|tri| tri.contains(&s) && tri.contains(&t))
540}
541
542/// Reverse `ring` if its winding doesn't match `w0`, so earcut sees a CCW polygon.
543/// The lexicographically-least vertex is convex, so its turn gives the winding.
544pub(crate) fn orient_ring(it: &Interner, ring: Vec<Vid>, axis: DropAxis, w0: Sign) -> Vec<Vid> {
545    let n = ring.len();
546    let i = (0..n).min_by(|&x, &y| lex_cmp(it, ring[x], ring[y])).unwrap();
547    let w = orient2d_v(it, ring[(i + n - 1) % n], ring[i], ring[(i + 1) % n], axis);
548    if w == w0 {
549        ring
550    } else {
551        let mut r = ring;
552        r.reverse();
553        r
554    }
555}
556
557
558/// Phases A–D: project, canonicalise, insert all constraint points, then force
559/// every constraint to appear as an edge (chain). `None` ⇒ `T` is degenerate.
560pub fn triangulate(input: &RetriInput, interner: &mut Interner) -> Option<Mesh2d> {
561    let (axis, w0) = projection_axis(&input.tri)?;
562    let mut canon = canonicalize(input, interner);
563    super::retriangulate_cleanup::drop_out_of_plane(&mut canon, &input.tri, interner, axis); // #098
564    let mut mesh = Mesh2d {
565        tris: vec![canon.corners],
566        axis,
567        w0,
568        unrecovered: 0,
569        audit_needed: false,
570        coords: BTreeMap::new(),
571    };
572    let mut pts: BTreeSet<Vid> = BTreeSet::new();
573    for &(lo, hi) in &canon.segments {
574        pts.insert(lo);
575        pts.insert(hi);
576    }
577    pts.extend(canon.points.iter().copied());
578    for &c in &canon.corners {
579        pts.remove(&c);
580    }
581    let mut ordered: Vec<Vid> = pts.into_iter().collect();
582    ordered.sort_by(|&a, &b| lex_cmp(interner, a, b));
583    for p in ordered {
584        // #1109 overshoot guard: a heavily-fragmented host face (a slab cut by
585        // 24+ openings) inserts thousands of constraint points here, each
586        // running exact orient2d in `insert_point`. The per-triangle
587        // `tripped()` check in `retriangulate_each` only fires BETWEEN
588        // triangles, so without this one `triangulate` call ran ~1.7M
589        // escalations (3.3× a 500k cap) — seconds of work — before bailing.
590        // Stop mid-insertion: the caller discards the partial arrangement once
591        // `tripped()`, so the incomplete triangulation is never emitted.
592        if super::budget::tripped() {
593            break;
594        }
595        insert_point(&mut mesh, interner, p);
596    }
597    // Enforce to a FIXED POINT: recovering one constraint deletes the channel
598    // triangles it crosses, which can remove an edge a PREVIOUS constraint had
599    // already been forced into (the pocket earcut is not constraint-aware). One
600    // extra pass re-forces those; iteration is bounded — each pass is a no-op
601    // (`recover_subsegment` early-returns on `edge_exists`) once every
602    // constraint chain is present. The cap keeps a pathological ping-pong from
603    // looping forever (the constraint set is crossing-free by construction —
604    // seg×seg pre-pass for transversal constraints, a planar mesh complex for
605    // coplanar ones — so non-convergence would leave at most an unrecovered
606    // constraint, the pre-existing graceful-bail behavior). Purely a function
607    // of exact predicates ⇒ deterministic, byte-identical native==wasm.
608    let mut converged = false;
609    for _pass in 0..4 {
610        // #1109 overshoot guard: constraint recovery runs exact predicates per
611        // sub-segment; a slab face with thousands of seam segments is the other
612        // heavy escalation site between per-triangle `tripped()` checks.
613        if super::budget::tripped() {
614            break;
615        }
616        let before = mesh.tris.clone();
617        for &(s, t) in &canon.segments {
618            if super::budget::tripped() {
619                break;
620            }
621            enforce_constraint(&mut mesh, interner, s, t);
622        }
623        if mesh.tris == before {
624            converged = true;
625            break;
626        }
627    }
628    // #1109: if the per-element budget tripped while inserting points or recovering
629    // constraints above, the boolean caller discards this entire arrangement
630    // (csg.rs returns the host un-cut → #635 AABB fallback). Skip the conformity
631    // audit below — it runs O(segments × vertices) exact predicates with NO budget
632    // check, so without this a tripped, heavily-fragmented face keeps grinding well
633    // past the cap. The partial triangulation we return is dropped anyway.
634    if super::budget::tripped() {
635        return Some(mesh);
636    }
637    if !converged {
638        // Pass-cap exit: the LAST pass's rebuilds may have broken an edge that
639        // was forced earlier without any later recover attempt re-flagging it,
640        // so the audit-skip soundness argument doesn't hold — audit always.
641        mesh.audit_needed = true;
642    }
643    // Count the constraint sub-segments that stayed unrecovered (see
644    // `Mesh2d::unrecovered`). Same chain decomposition as `enforce_constraint`.
645    // Gated on `audit_needed`: only triangulations where some recovery attempt
646    // bailed pay for the audit — the clean common path skips it entirely.
647    if !mesh.audit_needed {
648        return Some(mesh);
649    }
650    super::retriangulate_audit::audit_and_recover(&mut mesh, interner, &canon, axis);
651    // Deliberate trade-off: return Some even if a constraint stayed unrecovered —
652    // the caller (arrangement.rs) maps None to a FULL passthrough of the input
653    // triangle, dropping ALL constraints, which is strictly worse than a mesh
654    // missing one.
655    Some(mesh)
656}
657
658/// PHASE F — a deterministic TOPOLOGY fingerprint of the triangulation: every
659/// sub-triangle as its sorted Vid triple, the set sorted, FNV-1a-hashed. Vids are
660/// symbolic identities assigned in a deterministic (input-driven, exact-cmp_lex
661/// dedup) order and every geometric decision is exact, so this hash is
662/// byte-identical across x86_64/aarch64/wasm. (We hash Vid CONNECTIVITY, not
663/// coordinates — the determinism bar is topology, not coordinates.)
664pub fn triangulation_topology_hash(input: &RetriInput) -> u64 {
665    let mut interner = Interner::new();
666    let mesh = match triangulate(input, &mut interner) {
667        Some(m) => m,
668        None => return 0,
669    };
670    let mut tris: Vec<[Vid; 3]> = mesh
671        .tris
672        .iter()
673        .map(|&t| {
674            let mut s = t;
675            s.sort_unstable();
676            s
677        })
678        .collect();
679    tris.sort_unstable();
680    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
681    for t in tris {
682        for v in t {
683            h ^= v as u64;
684            h = h.wrapping_mul(0x0000_0100_0000_01b3);
685        }
686    }
687    h
688}
689
690/// Cross-platform re-triangulation determinism manifest: the topology hash of a
691/// fixed fixture (a triangle + constraints with explicit/LPI/TPI endpoints, some
692/// requiring recovery / on-edge), for the wasm/ARM cross-check (analogous to the
693/// predicate sign manifest).
694pub fn retriangulation_manifest() -> u64 {
695    let t = [[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [0.0, 10.0, 0.0]];
696    // LPI at (3,3,0): vertical line ∩ z=0
697    let lpi = ImplicitPoint::Lpi(Lpi {
698        p: [3.0, 3.0, -1.0],
699        q: [3.0, 3.0, 1.0],
700        r: [0.0, 0.0, 0.0],
701        s: [1.0, 0.0, 0.0],
702        t: [0.0, 1.0, 0.0],
703    });
704    // TPI at (3,5,0): planes z=0, x=3, y=5
705    let tpi = ImplicitPoint::Tpi(Tpi {
706        planes: [
707            [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
708            [[3.0, 0.0, 0.0], [3.0, 1.0, 0.0], [3.0, 0.0, 1.0]],
709            [[0.0, 5.0, 0.0], [1.0, 5.0, 0.0], [0.0, 5.0, 1.0]],
710        ],
711    });
712    let x = ImplicitPoint::Explicit;
713    let cons = vec![
714        Constraint { a: x([2.0, 2.0, 0.0]), b: x([6.0, 2.0, 0.0]) },
715        Constraint { a: x([2.0, 2.0, 0.0]), b: x([2.0, 6.0, 0.0]) },
716        Constraint { a: lpi.clone(), b: x([6.0, 2.0, 0.0]) },
717        Constraint { a: tpi, b: x([2.0, 6.0, 0.0]) },
718        Constraint { a: x([5.0, 1.0, 0.0]), b: lpi },
719    ];
720    triangulation_topology_hash(&RetriInput { tri: t, constraints: cons, points: Vec::new() })
721}
722
723#[cfg(test)]
724mod tests {
725    use super::super::rational::point_of;
726    use super::super::retriangulate_recover::recover_subsegment;
727    use super::super::Lpi;
728    use super::*;
729
730    #[test]
731    fn phase_a_picks_a_nonzero_axis_and_winding() {
732        // horizontal triangle (normal +Z) → drop Z
733        let t = [[0., 0., 0.], [1., 0., 0.], [0., 1., 0.]];
734        let (axis, w) = projection_axis(&t).unwrap();
735        assert_eq!(axis, DropAxis::Z);
736        assert_ne!(w, Sign::Zero);
737        // vertical triangle in y=0 (normal +Y) → drop Y
738        let t2 = [[0., 0., 0.], [0., 0., 1.], [1., 0., 0.]];
739        assert_eq!(projection_axis(&t2).unwrap().0, DropAxis::Y);
740        // degenerate (collinear) → None in every projection
741        let t3 = [[0., 0., 0.], [1., 1., 1.], [2., 2., 2.]];
742        assert!(projection_axis(&t3).is_none());
743    }
744
745    #[test]
746    fn phase_a_is_deterministic_on_a_45_degree_face() {
747        // normal ∝ (1,1,0)/√2 — |n_x| == |n_y|; the index tiebreak must pick a
748        // stable axis (X before Y), exactly, on every platform.
749        let t = [[0., 0., 0.], [1., -1., 0.], [1., -1., 2.]];
750        let a = projection_axis(&t);
751        let b = projection_axis(&t);
752        assert_eq!(a.map(|x| x.0), b.map(|x| x.0));
753        assert!(a.is_some());
754    }
755
756    #[test]
757    fn phase_b_canonical_order_is_independent_of_input_order() {
758        let t = [[0., 0., 0.], [4., 0., 0.], [0., 4., 0.]]; // z=0
759        // an LPI at (1,1,0) (in T's plane)
760        let lpi = ImplicitPoint::Lpi(Lpi {
761            p: [1., 1., -1.],
762            q: [1., 1., 1.],
763            r: [0., 0., 0.],
764            s: [1., 0., 0.],
765            t: [0., 1., 0.],
766        });
767        let c1 = Constraint { a: e([2., 0., 0.]), b: e([0., 2., 0.]) };
768        let c2 = Constraint { a: lpi, b: e([3., 0., 0.]) };
769        let materialise = |cons: Vec<Constraint>| {
770            let mut it = Interner::new();
771            let canon = canonicalize(&RetriInput { tri: t, constraints: cons, points: Vec::new() }, &mut it);
772            canon
773                .segments
774                .iter()
775                .map(|&(lo, hi)| (point_of(it.get(lo)), point_of(it.get(hi))))
776                .collect::<Vec<_>>()
777        };
778        let forward = materialise(vec![c1.clone(), c2.clone()]);
779        let backward = materialise(vec![c2.clone(), c1.clone()]);
780        assert_eq!(forward, backward, "canonical segment order depends on input order");
781        // a duplicate constraint is deduplicated
782        let with_dup = materialise(vec![c1.clone(), c1.clone(), c2.clone()]);
783        assert_eq!(with_dup.len(), 2, "duplicate constraint not deduped");
784    }
785
786    #[test]
787    fn phase_e_earcut_covers_a_concave_polygon_deterministically() {
788        use super::super::rational::tri_area2;
789        use num_rational::BigRational;
790        use num_traits::Zero;
791        // concave polygon (reflex at (2,1.5)) in z=0, wound CCW
792        let pts = [[0., 0., 0.], [4., 0., 0.], [4., 3., 0.], [2., 1.5, 0.], [0., 3., 0.]];
793        let mut it = Interner::new();
794        let ring: Vec<Vid> = pts.iter().map(|&p| it.intern(e(p))).collect();
795        let axis = DropAxis::Z;
796        let pt = |v: Vid| point_of(it.get(v));
797        let origin = point_of(&e([0., 0., 0.]));
798        // polygon 2-area (shoelace) + orientation
799        let mut poly2a = BigRational::zero();
800        for i in 0..ring.len() {
801            let j = (i + 1) % ring.len();
802            poly2a += tri_area2(&pt(ring[i]), &pt(ring[j]), &origin, axis);
803        }
804        let w0 = if poly2a > BigRational::zero() { Sign::Positive } else { Sign::Negative };
805        let tris = earcut(&it, &ring, axis, w0);
806        assert_eq!(tris.len(), ring.len() - 2, "wrong triangle count");
807        for &tri in &tris {
808            assert_eq!(
809                orient2d_v(&it, tri[0], tri[1], tri[2], axis),
810                w0,
811                "earcut triangle not oriented w0"
812            );
813        }
814        let area_sum = tris
815            .iter()
816            .fold(BigRational::zero(), |acc, &t| acc + tri_area2(&pt(t[0]), &pt(t[1]), &pt(t[2]), axis));
817        assert_eq!(area_sum, poly2a, "earcut does not exactly cover the polygon");
818        // determinism: rotating the ring's start vertex yields the SAME triangle set
819        let mut rotated = ring.clone();
820        rotated.rotate_left(2);
821        let tris2 = earcut(&it, &rotated, axis, w0);
822        let canon = |ts: &[SubTri]| {
823            let mut v: Vec<_> = ts
824                .iter()
825                .map(|&t| {
826                    let mut s = [pt(t[0]), pt(t[1]), pt(t[2])];
827                    s.sort();
828                    s
829                })
830                .collect();
831            v.sort();
832            v
833        };
834        assert_eq!(canon(&tris), canon(&tris2), "earcut depends on ring start vertex");
835    }
836
837    #[test]
838    fn phase_d_recovers_a_crossing_diagonal() {
839        use super::super::rational::tri_area2;
840        use num_rational::BigRational;
841        use num_traits::Zero;
842        let mut it = Interner::new();
843        let a = it.intern(e([0., 0., 0.]));
844        let b = it.intern(e([2., 0., 0.]));
845        let c = it.intern(e([2., 2., 0.]));
846        let d = it.intern(e([0., 2., 0.]));
847        // a quad split by the diagonal a–c
848        let mut mesh = Mesh2d {
849            tris: vec![[a, b, c], [a, c, d]],
850            axis: DropAxis::Z,
851            w0: Sign::Positive,
852            unrecovered: 0,
853            audit_needed: false,
854            coords: BTreeMap::new(),
855        };
856        let pt = |v: Vid| point_of(it.get(v));
857        let origin = point_of(&e([0., 0., 0.]));
858        let ring = [a, b, c, d];
859        let quad_area = (0..4).fold(BigRational::zero(), |s, i| {
860            s + tri_area2(&pt(ring[i]), &pt(ring[(i + 1) % 4]), &origin, DropAxis::Z)
861        });
862        // recover the OTHER diagonal b–d, which crosses a–c
863        recover_subsegment(&mut mesh, &it, b, d);
864        assert!(
865            mesh.tris.iter().any(|t| t.contains(&b) && t.contains(&d)),
866            "b–d was not recovered as an edge"
867        );
868        assert!(
869            !mesh.tris.iter().any(|t| t.contains(&a) && t.contains(&c)),
870            "the crossed diagonal a–c is still present"
871        );
872        for &tri in &mesh.tris {
873            assert_eq!(
874                orient2d_v(&it, tri[0], tri[1], tri[2], mesh.axis),
875                mesh.w0,
876                "recovered triangle not oriented w0"
877            );
878        }
879        let sum = mesh
880            .tris
881            .iter()
882            .fold(BigRational::zero(), |acc, &t| acc + tri_area2(&pt(t[0]), &pt(t[1]), &pt(t[2]), mesh.axis));
883        assert_eq!(sum, quad_area, "recovery changed the covered area");
884    }
885
886    #[test]
887    fn phase_d_full_triangulate_satisfies_constraints_and_covers_t() {
888        use super::super::rational::tri_area2;
889        use num_rational::BigRational;
890        use num_traits::Zero;
891        let t = [[0., 0., 0.], [6., 0., 0.], [0., 6., 0.]];
892        // an interior quad (1,1)(3,1)(3,3)(1,3); the (3,1)-(1,3) diagonal likely
893        // crosses the (1,1)-(3,3) edge Phase C makes ⇒ exercises recovery.
894        let cons = vec![
895            Constraint { a: e([1., 1., 0.]), b: e([3., 1., 0.]) },
896            Constraint { a: e([3., 1., 0.]), b: e([1., 3., 0.]) },
897            Constraint { a: e([3., 3., 0.]), b: e([1., 3., 0.]) },
898        ];
899        let mut it = Interner::new();
900        let mesh = triangulate(&RetriInput { tri: t, constraints: cons.clone(), points: Vec::new() }, &mut it).unwrap();
901        // intern everything we need (mutable) BEFORE the read-only checks
902        let cverts: Vec<(Vid, Vid)> =
903            cons.iter().map(|c| (it.intern(c.a.clone()), it.intern(c.b.clone()))).collect();
904        let corners = [it.intern(e(t[0])), it.intern(e(t[1])), it.intern(e(t[2]))];
905        // every constraint is now an edge
906        for &(s, tt) in &cverts {
907            assert!(edge_exists(&mesh, s, tt), "constraint {s}-{tt} not satisfied as an edge");
908        }
909        // orientation + exact coverage of T
910        let pt = |v: Vid| point_of(it.get(v));
911        for &tri in &mesh.tris {
912            assert_eq!(
913                orient2d_v(&it, tri[0], tri[1], tri[2], mesh.axis),
914                mesh.w0,
915                "triangle not oriented w0"
916            );
917        }
918        let sum = mesh
919            .tris
920            .iter()
921            .fold(BigRational::zero(), |acc, &tr| acc + tri_area2(&pt(tr[0]), &pt(tr[1]), &pt(tr[2]), mesh.axis));
922        let t_area = tri_area2(&pt(corners[0]), &pt(corners[1]), &pt(corners[2]), mesh.axis);
923        assert_eq!(sum, t_area, "triangulation does not exactly cover T");
924    }
925
926    #[test]
927    fn retriangulation_manifest_is_pinned() {
928        // PHASE F (G2) — the full-triangulation topology fingerprint, byte-identical
929        // across x86_64/aarch64/wasm (re-pin + re-run the wasm cross-check if the
930        // triangulation logic legitimately changes).
931        const PINNED: u64 = 0xef5b_32fd_d838_4776;
932        let m = super::retriangulation_manifest();
933        assert_eq!(m, PINNED, "retriangulation topology manifest changed: 0x{m:016x}");
934    }
935}