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}