Skip to main content

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