axiolid_triangulate/
refine.rs

1// SPDX-License-Identifier: MPL-2.0
2
3//! Bounded Ruppert/Chew quality refinement.
4//!
5//! # What refinement can and cannot promise
6//!
7//! Ruppert's algorithm inserts circumcentres of skinny triangles until every
8//! angle meets a bound. It provably terminates for a minimum angle up to
9//! about 20.7 degrees when the input has no small input angles. Neither
10//! condition holds for arbitrary building geometry: two walls meeting at 10
11//! degrees is a small *input* angle, and no amount of interior insertion can
12//! open it, because the offending angle is pinned by two constraint edges the
13//! algorithm is forbidden to move.
14//!
15//! So this implementation is explicitly *bounded*. It carries a Steiner point
16//! budget and reports which of the two outcomes occurred:
17//!
18//! - [`RefineOutcome::Achieved`] -- every non-constrained angle meets the
19//!   bound,
20//! - [`RefineOutcome::Capped`] -- the budget ran out first; the mesh is still
21//!   a valid constrained Delaunay triangulation, just not as good as asked.
22//!
23//! Returning `Capped` rather than looping is the difference between a slow
24//! call and a hung one, and the difference between a caller who knows the
25//! mesh is rough and one who assumes a guarantee that was never met.
26
27use axiolid_core::Point2;
28
29use crate::build::triangulate;
30use crate::mesh::{Triangulation, TriangulationError};
31use crate::{collinear, Constraint};
32
33/// How much the global worst angle may drop for a single insertion to still
34/// be accepted.
35///
36/// Zero would be ideal but rejects almost everything: retriangulating after
37/// an insertion perturbs unrelated triangles by rounding, so a strictly
38/// non-decreasing test stalls on noise. This tolerance is small enough that
39/// the mesh cannot drift meaningfully worse over a bounded number of
40/// insertions, and the end-to-end monotonicity is asserted by test.
41const DEGRADATION_TOLERANCE_DEGREES: f64 = 1e-6;
42
43/// A quality target for refinement.
44#[derive(Debug, Clone, Copy, PartialEq)]
45pub struct Quality {
46    /// Smallest acceptable interior angle, in degrees.
47    ///
48    /// Values above ~20.7 are not guaranteed to terminate for every input;
49    /// the budget is what keeps the call bounded.
50    pub min_angle_degrees: f64,
51    /// Largest number of Steiner points to insert.
52    ///
53    /// Expressed as an absolute count rather than a multiple of the input so
54    /// a caller can bound memory directly.
55    pub max_steiner_points: usize,
56}
57
58impl Default for Quality {
59    /// 20 degrees and a budget proportional to a typical wall face.
60    ///
61    /// 20 sits just under Ruppert's proven bound, so the common case
62    /// terminates by the theorem rather than by the budget.
63    fn default() -> Self {
64        Self {
65            min_angle_degrees: 20.0,
66            max_steiner_points: 4096,
67        }
68    }
69}
70
71/// What refinement actually achieved.
72///
73/// Not `Eq`: the capped variant carries a measured angle, and comparing
74/// floating-point measurements for exact equality is not a meaningful
75/// operation on a quality report.
76#[derive(Debug, Clone, Copy, PartialEq)]
77#[non_exhaustive]
78pub enum RefineOutcome {
79    /// Every unconstrained angle meets the requested bound.
80    Achieved {
81        /// Steiner points inserted.
82        inserted: usize,
83    },
84    /// The budget was exhausted before the bound was met.
85    ///
86    /// The triangulation is valid and constrained-Delaunay; only the angle
87    /// target is unmet.
88    Capped {
89        /// Steiner points inserted.
90        inserted: usize,
91        /// Worst angle still present, in degrees.
92        worst_angle_degrees: f64,
93    },
94}
95
96impl RefineOutcome {
97    /// Whether the requested quality bound was met.
98    #[must_use]
99    pub const fn achieved(self) -> bool {
100        matches!(self, Self::Achieved { .. })
101    }
102}
103
104/// Build a constrained Delaunay triangulation and refine it toward `quality`.
105///
106/// # Errors
107///
108/// Propagates [`TriangulationError`] from the underlying triangulation.
109pub fn triangulate_refined(
110    points: &[Point2],
111    constraints: &[Constraint],
112    quality: Quality,
113) -> Result<(Triangulation, RefineOutcome), TriangulationError> {
114    let mut tri = triangulate(points, constraints)?;
115    let outcome = refine(&mut tri, quality)?;
116    Ok((tri, outcome))
117}
118
119/// Refine an existing triangulation in place.
120///
121/// # Errors
122///
123/// Propagates [`TriangulationError`] if a re-triangulation step fails.
124pub fn refine(
125    tri: &mut Triangulation,
126    quality: Quality,
127) -> Result<RefineOutcome, TriangulationError> {
128    let threshold = quality.min_angle_degrees.to_radians().cos();
129    let mut inserted = 0usize;
130    // Triangles whose circumcentre cannot be inserted (it falls outside the
131    // hull, or the triangle is degenerate). Skipping them individually is the
132    // difference between "this one sliver is unfixable" and "stop refining":
133    // abandoning the whole loop on the first such triangle left every other
134    // fixable sliver in the mesh untouched.
135    let mut skipped: Vec<usize> = Vec::new();
136
137    while inserted < quality.max_steiner_points {
138        let Some(bad) = worst_triangle(tri, threshold, &skipped) else {
139            break;
140        };
141        let Some(centre) = circumcentre(tri, bad) else {
142            skipped.push(bad);
143            continue;
144        };
145        // Only insert strictly inside the existing hull. A circumcentre
146        // outside it would need boundary splitting, which moves a constraint
147        // edge -- forbidden here, and the reason this stays "interior-only".
148        if !inside_hull(tri, centre) {
149            skipped.push(bad);
150            continue;
151        }
152        let mut points = tri.points.clone();
153        points.push(centre);
154        let constraints = tri.constraints.clone();
155        let candidate = triangulate(&points, &constraints)?;
156        // Refinement must never hand back a worse mesh than it was given.
157        // Inserting the circumcentre of a sliver whose small angle is pinned
158        // by input vertices can create an even thinner triangle beside it --
159        // measured on a real fixture, 0.51 degrees became 0.22.
160        //
161        // The test is "does not degrade", not "improves the global worst
162        // angle". Demanding global improvement rejects every individual
163        // insertion, because fixing one bad triangle usually leaves the
164        // single worst one elsewhere untouched -- which silently turned
165        // refinement into a no-op.
166        let before_worst = worst_angle_degrees(tri);
167        let after_worst = worst_angle_degrees(&candidate);
168        if after_worst < before_worst - DEGRADATION_TOLERANCE_DEGREES {
169            skipped.push(bad);
170            continue;
171        }
172        *tri = candidate;
173        inserted += 1;
174        // Triangle indices are meaningless after a rebuild.
175        skipped.clear();
176    }
177
178    let worst = worst_angle_degrees(tri);
179    if worst >= quality.min_angle_degrees {
180        Ok(RefineOutcome::Achieved { inserted })
181    } else {
182        Ok(RefineOutcome::Capped {
183            inserted,
184            worst_angle_degrees: worst,
185        })
186    }
187}
188
189/// The first triangle whose smallest angle is below the bound, ignoring any
190/// already known to be un-insertable.
191fn worst_triangle(tri: &Triangulation, cos_threshold: f64, skipped: &[usize]) -> Option<usize> {
192    (0..tri.triangle_count()).find(|t| {
193        if skipped.contains(t) {
194            return false;
195        }
196        let (a, b, c) = corners(tri, *t);
197        max_cos(a, b, c) > cos_threshold
198    })
199}
200
201/// Largest cosine among a triangle's three angles.
202///
203/// The largest cosine corresponds to the smallest angle, so one comparison
204/// against `cos(min_angle)` decides the whole triangle.
205fn max_cos(a: Point2, b: Point2, c: Point2) -> f64 {
206    let ab = (b.x - a.x, b.y - a.y);
207    let bc = (c.x - b.x, c.y - b.y);
208    let ca = (a.x - c.x, a.y - c.y);
209    let at = angle_cos((-ca.0, -ca.1), ab);
210    let bt = angle_cos((-ab.0, -ab.1), bc);
211    let ct = angle_cos((-bc.0, -bc.1), ca);
212    at.max(bt).max(ct)
213}
214
215fn angle_cos(u: (f64, f64), v: (f64, f64)) -> f64 {
216    let dot = u.0 * v.0 + u.1 * v.1;
217    let nu = (u.0 * u.0 + u.1 * u.1).sqrt();
218    let nv = (v.0 * v.0 + v.1 * v.1).sqrt();
219    if nu == 0.0 || nv == 0.0 {
220        return 1.0;
221    }
222    (dot / (nu * nv)).clamp(-1.0, 1.0)
223}
224
225/// Smallest interior angle anywhere in the mesh, in degrees.
226fn worst_angle_degrees(tri: &Triangulation) -> f64 {
227    let mut worst: f64 = 180.0;
228    for t in 0..tri.triangle_count() {
229        let (a, b, c) = corners(tri, t);
230        let angle = max_cos(a, b, c).clamp(-1.0, 1.0).acos().to_degrees();
231        worst = worst.min(angle);
232    }
233    worst
234}
235
236fn corners(tri: &Triangulation, t: usize) -> (Point2, Point2, Point2) {
237    (
238        tri.points[tri.triangles[3 * t] as usize],
239        tri.points[tri.triangles[3 * t + 1] as usize],
240        tri.points[tri.triangles[3 * t + 2] as usize],
241    )
242}
243
244/// Circumcentre of triangle `t`, or `None` when it is degenerate.
245fn circumcentre(tri: &Triangulation, t: usize) -> Option<Point2> {
246    let (a, b, c) = corners(tri, t);
247    if collinear(a, b, c) {
248        return None;
249    }
250    let d = 2.0 * (a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y));
251    if d == 0.0 || !d.is_finite() {
252        return None;
253    }
254    let a2 = a.x * a.x + a.y * a.y;
255    let b2 = b.x * b.x + b.y * b.y;
256    let c2 = c.x * c.x + c.y * c.y;
257    let ux = (a2 * (b.y - c.y) + b2 * (c.y - a.y) + c2 * (a.y - b.y)) / d;
258    let uy = (a2 * (c.x - b.x) + b2 * (a.x - c.x) + c2 * (b.x - a.x)) / d;
259    if ux.is_finite() && uy.is_finite() {
260        Some(Point2::new(ux, uy))
261    } else {
262        None
263    }
264}
265
266/// Whether `p` lies inside some existing triangle.
267///
268/// Used as the interior test for candidate Steiner points: a point inside a
269/// triangle is inside the hull by construction, and the mesh is small enough
270/// at refinement scale that the linear scan is not the bottleneck.
271fn inside_hull(tri: &Triangulation, p: Point2) -> bool {
272    (0..tri.triangle_count()).any(|t| {
273        let (a, b, c) = corners(tri, t);
274        let ab = crate::turns_left(a, b, p);
275        let bc = crate::turns_left(b, c, p);
276        let ca = crate::turns_left(c, a, p);
277        ab == bc && bc == ca
278    })
279}