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}