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}