Skip to main content

axiolid_refine/
fair.rs

1//! Fairing: move chosen vertices to the smoothest surface their fixed
2//! surroundings allow.
3//!
4//! Smoothing ([`crate::smooth`]) relaxes vertices a step at a time and never
5//! arrives anywhere in particular. Fairing *solves* for the arrival: the
6//! free vertices are placed where the discrete Laplacian (harmonic, a
7//! membrane) or bi-Laplacian (biharmonic, a thin plate) of the surface
8//! vanishes, with every other vertex held fixed. The biharmonic solution
9//! also matches the fixed surface's first derivatives across the border,
10//! which is what makes a filled hole continue its surroundings tangentially
11//! instead of meeting them at a crease. This is the fairing step of Liepa
12//! (2003) and of CGAL's `Polygon_mesh_processing::fair`.
13//!
14//! # The system
15//!
16//! With symmetric edge weights `w_ij` the Laplacian `L` (`L_ij = w_ij`,
17//! `L_ii = -sum_j w_ij`) is symmetric, and with a positive diagonal mass `M`
18//! the biharmonic conditions `(L M^-1 L x)_i = 0` for the free vertices `F`
19//! read
20//!
21//! ```text
22//! (L_{:,F})^T M^-1 L_{:,F} x_F = -(L_{:,F})^T M^-1 L_{:,C} x_C
23//! ```
24//!
25//! whose matrix is a Gram matrix: symmetric positive definite whenever each
26//! connected group of free vertices touches a fixed one. It is solved for
27//! each coordinate by [`axiolid_numeric::conjugate_gradient`]; the harmonic
28//! case solves `-L_FF x_F = L_FC x_C` the same way. Only rows of `L` at the
29//! free vertices and their neighbours enter, so the cost follows the faired
30//! region, not the mesh.
31//!
32//! Weights are computed once, on the positions before fairing.
33//! [`FairingWeights::Cotangent`] uses `(cot a + cot b) / 2` with the
34//! barycentric (one-third) vertex area as mass, the standard linear finite
35//! element discretisation; [`FairingWeights::Uniform`] uses unit weights and
36//! the valence as mass (Kobbelt's umbrella operator), so it ignores the
37//! shape of the triangles and also works on polygonal faces.
38//!
39//! # Exactness
40//!
41//! Rows of `L` sum to zero, so every affine function of position is
42//! reproduced: fixed vertices that all lie in one plane keep the free ones in
43//! it, up to the solver tolerance, and in an axis-aligned plane exactly.
44
45use axiolid_core::{Point3, Scalar, Vec3};
46use axiolid_mesh::halfedge::{FaceId, HalfedgeMesh, VertexId};
47use axiolid_numeric::{conjugate_gradient, ConjugateGradientOptions, NumericError, SparseMatrix};
48
49/// Edge weights of the discrete Laplacian.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
51#[non_exhaustive]
52pub enum FairingWeights {
53    /// Cotangent weights with barycentric vertex areas as mass: follows the
54    /// geometry, needs triangles with non-zero area around the faired
55    /// region.
56    #[default]
57    Cotangent,
58    /// Unit weights, valence as mass: connectivity only, any polygon.
59    Uniform,
60}
61
62/// Which derivative vanishes on the faired region.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
64#[non_exhaustive]
65pub enum FairingOrder {
66    /// `L x = 0`: a membrane, continuous with the border (C0).
67    Harmonic,
68    /// `L M^-1 L x = 0`: a thin plate whose tangent planes continue the
69    /// border's (G1).
70    #[default]
71    Biharmonic,
72}
73
74/// Options of [`fair`].
75#[derive(Debug, Clone, Copy, PartialEq)]
76pub struct FairOptions {
77    /// Edge weights.
78    pub weights: FairingWeights,
79    /// Harmonic or biharmonic.
80    pub order: FairingOrder,
81    /// Conjugate-gradient stopping rule: relative residual of each
82    /// coordinate's system.
83    pub relative_tolerance: Scalar,
84    /// Conjugate-gradient iteration budget per coordinate; `None` uses the
85    /// solver's default (`10 n + 100` for `n` free vertices).
86    pub max_iterations: Option<usize>,
87}
88
89impl Default for FairOptions {
90    fn default() -> Self {
91        Self {
92            weights: FairingWeights::Cotangent,
93            order: FairingOrder::Biharmonic,
94            relative_tolerance: 1e-12,
95            max_iterations: None,
96        }
97    }
98}
99
100/// What [`fair`] did.
101#[derive(Debug, Clone, PartialEq)]
102pub struct FairReport {
103    /// Vertices moved (distinct).
104    pub vertices: usize,
105    /// Conjugate-gradient iterations for `x`, `y` and `z`.
106    pub iterations: [usize; 3],
107    /// Largest relative residual of the three solves, recomputed from the
108    /// system matrix.
109    pub relative_residual: Scalar,
110}
111
112/// Why [`fair`] refused. On every refusal the mesh is unchanged.
113#[derive(Debug, Clone, PartialEq, thiserror::Error)]
114#[non_exhaustive]
115pub enum FairError {
116    /// The vertex id names no live vertex.
117    #[error("vertex {vertex} is not a live vertex")]
118    RemovedVertex {
119        /// The vertex.
120        vertex: VertexId,
121    },
122    /// The vertex is free and so is every vertex connected to it through
123    /// free vertices: nothing fixes where that group goes.
124    #[error("vertex {vertex} is not connected to any fixed vertex")]
125    Unanchored {
126        /// A vertex of the unanchored group.
127        vertex: VertexId,
128    },
129    /// Cotangent weights need triangles here.
130    #[error("face {face} has {degree} sides; cotangent weights need triangles")]
131    NotATriangle {
132        /// The face.
133        face: FaceId,
134        /// Its number of sides.
135        degree: usize,
136    },
137    /// A triangle near the faired region has (numerically) zero area, so its
138    /// cotangent weights are undefined.
139    #[error("face {face} is degenerate; its cotangent weights are undefined")]
140    DegenerateTriangle {
141        /// The face.
142        face: FaceId,
143    },
144    /// The relative tolerance is negative or not finite.
145    #[error("relative tolerance {0} is not a finite non-negative number")]
146    InvalidTolerance(Scalar),
147    /// A coordinate's system did not reach the tolerance within the budget.
148    #[error("fairing solve for coordinate {coordinate} stopped at relative residual {relative_residual}")]
149    NotConverged {
150        /// 0, 1 or 2 for `x`, `y`, `z`.
151        coordinate: usize,
152        /// Residual reached.
153        relative_residual: Scalar,
154    },
155    /// The linear solve refused its system, which happens only with
156    /// cotangent weights on badly shaped triangles that make it indefinite
157    /// to working precision.
158    #[error("fairing solve refused: {0}")]
159    Numeric(NumericError),
160}
161
162/// Smallest `|sin|` of a corner angle accepted for a cotangent.
163const MIN_SINE: Scalar = 1e-12;
164
165/// Fair the given vertices of a mesh in place; every other vertex is fixed.
166///
167/// Duplicates in `vertices` are ignored. Fixed vertices are bit-identical
168/// afterwards. The output is deterministic: it depends on the mesh and the
169/// set of vertices, not on their order.
170///
171/// # Errors
172///
173/// [`FairError`]: a removed vertex, a free group with no fixed neighbour,
174/// a non-triangle or degenerate triangle near the region with cotangent
175/// weights, a bad tolerance, and a solve that does not converge.
176pub fn fair(
177    mesh: &mut HalfedgeMesh,
178    vertices: &[VertexId],
179    options: &FairOptions,
180) -> Result<FairReport, FairError> {
181    if !options.relative_tolerance.is_finite() || options.relative_tolerance < 0.0 {
182        return Err(FairError::InvalidTolerance(options.relative_tolerance));
183    }
184    let mut free: Vec<VertexId> = vertices.to_vec();
185    free.sort_unstable();
186    free.dedup();
187    for &v in &free {
188        if !mesh.contains_vertex(v) {
189            return Err(FairError::RemovedVertex { vertex: v });
190        }
191    }
192    if free.is_empty() {
193        return Ok(FairReport {
194            vertices: 0,
195            iterations: [0; 3],
196            relative_residual: 0.0,
197        });
198    }
199    let slot_count = mesh.positions().len();
200    let mut unknown = vec![usize::MAX; slot_count];
201    for (k, &v) in free.iter().enumerate() {
202        unknown[v.index()] = k;
203    }
204    require_anchored(mesh, &free, &unknown)?;
205
206    // Rows of L that touch a free vertex: the free vertices and their
207    // neighbours, in id order.
208    let mut in_rows = vec![false; slot_count];
209    for &v in &free {
210        in_rows[v.index()] = true;
211        for u in mesh.vertex_vertices(v) {
212            in_rows[u.index()] = true;
213        }
214    }
215    let rows: Vec<VertexId> = (0..slot_count)
216        .filter(|&i| in_rows[i])
217        .map(|i| VertexId::new(i as u32))
218        .collect();
219
220    // Translate to the centroid of the fixed vertices next to the region so
221    // the solve sees coordinates of the region's size, not of its distance
222    // from the origin. Fixed vertices only: a coordinate that is zero on all
223    // of them then stays exactly zero.
224    let anchors: Vec<VertexId> = rows
225        .iter()
226        .copied()
227        .filter(|v| unknown[v.index()] == usize::MAX)
228        .collect();
229    let origin = anchors
230        .iter()
231        .fold(Vec3::ZERO, |s, &v| s + mesh.position(v))
232        / anchors.len() as Scalar;
233    let local = |v: VertexId| mesh.position(v) - origin;
234
235    let n = free.len();
236    let mut triplets: Vec<(usize, usize, Scalar)> = Vec::new();
237    let mut rhs = [vec![0.0; n], vec![0.0; n], vec![0.0; n]];
238    match options.order {
239        FairingOrder::Harmonic => {
240            for (a, &v) in free.iter().enumerate() {
241                let (diagonal, neighbours) = laplacian_row(mesh, v, options.weights)?;
242                triplets.push((a, a, -diagonal));
243                for (u, w) in neighbours {
244                    let b = unknown[u.index()];
245                    if b == usize::MAX {
246                        let p = local(u);
247                        for (axis, r) in rhs.iter_mut().enumerate() {
248                            r[a] += w * p[axis];
249                        }
250                    } else {
251                        triplets.push((a, b, -w));
252                    }
253                }
254            }
255        }
256        FairingOrder::Biharmonic => {
257            for &k in &rows {
258                let (diagonal, neighbours) = laplacian_row(mesh, k, options.weights)?;
259                let mass = vertex_mass(mesh, k, options.weights)?;
260                let mut free_part: Vec<(usize, Scalar)> = Vec::new();
261                let mut fixed_part = Vec3::ZERO;
262                for (u, w) in core::iter::once((k, diagonal)).chain(neighbours) {
263                    let b = unknown[u.index()];
264                    if b == usize::MAX {
265                        fixed_part += w * local(u);
266                    } else {
267                        free_part.push((b, w));
268                    }
269                }
270                for &(a, wa) in &free_part {
271                    for &(b, wb) in &free_part {
272                        triplets.push((a, b, wa * wb / mass));
273                    }
274                    for (axis, r) in rhs.iter_mut().enumerate() {
275                        r[a] -= wa * fixed_part[axis] / mass;
276                    }
277                }
278            }
279        }
280    }
281    let matrix = SparseMatrix::from_triplets(n, n, &triplets).map_err(FairError::Numeric)?;
282    let cg = ConjugateGradientOptions {
283        relative_tolerance: options.relative_tolerance,
284        max_iterations: options.max_iterations,
285    };
286    let mut solved = [vec![], vec![], vec![]];
287    let mut iterations = [0usize; 3];
288    let mut relative_residual: Scalar = 0.0;
289    for axis in 0..3 {
290        let initial: Vec<Scalar> = free.iter().map(|&v| local(v)[axis]).collect();
291        let solution = conjugate_gradient(&matrix, &rhs[axis], Some(&initial), cg)
292            .map_err(FairError::Numeric)?;
293        if solution.status != axiolid_numeric::Status::Converged {
294            return Err(FairError::NotConverged {
295                coordinate: axis,
296                relative_residual: solution.relative_residual,
297            });
298        }
299        iterations[axis] = solution.iterations;
300        relative_residual = relative_residual.max(solution.relative_residual);
301        solved[axis] = solution.x;
302    }
303    for (k, &v) in free.iter().enumerate() {
304        let p = Point3::new(solved[0][k], solved[1][k], solved[2][k]) + origin;
305        mesh.set_position(v, p);
306    }
307    Ok(FairReport {
308        vertices: n,
309        iterations,
310        relative_residual,
311    })
312}
313
314/// Refuse a group of free vertices that no fixed vertex touches.
315fn require_anchored(
316    mesh: &HalfedgeMesh,
317    free: &[VertexId],
318    unknown: &[usize],
319) -> Result<(), FairError> {
320    let mut seen = vec![false; free.len()];
321    for start in 0..free.len() {
322        if seen[start] {
323            continue;
324        }
325        seen[start] = true;
326        let mut stack = vec![free[start]];
327        let mut anchored = false;
328        while let Some(v) = stack.pop() {
329            for u in mesh.vertex_vertices(v) {
330                let k = unknown[u.index()];
331                if k == usize::MAX {
332                    anchored = true;
333                } else if !seen[k] {
334                    seen[k] = true;
335                    stack.push(u);
336                }
337            }
338        }
339        if !anchored {
340            return Err(FairError::Unanchored {
341                vertex: free[start],
342            });
343        }
344    }
345    Ok(())
346}
347
348/// Row `v` of the Laplacian: the diagonal `-sum w` and each neighbour's
349/// weight, neighbours in circulation order.
350fn laplacian_row(
351    mesh: &HalfedgeMesh,
352    v: VertexId,
353    weights: FairingWeights,
354) -> Result<(Scalar, Vec<(VertexId, Scalar)>), FairError> {
355    let mut row = Vec::new();
356    let mut sum = 0.0;
357    for h in mesh.outgoing_halfedges(v) {
358        let w = match weights {
359            FairingWeights::Uniform => 1.0,
360            FairingWeights::Cotangent => {
361                let mut w = 0.0;
362                for g in [h, mesh.opposite(h)] {
363                    if let Some(f) = mesh.face(g) {
364                        let opposite = triangle_apex(mesh, g, f)?;
365                        w += 0.5 * cotangent(mesh, f, opposite, mesh.source(g), mesh.target(g))?;
366                    }
367                }
368                w
369            }
370        };
371        sum += w;
372        row.push((mesh.target(h), w));
373    }
374    Ok((-sum, row))
375}
376
377/// The corner of triangle `f` opposite its halfedge `g`.
378fn triangle_apex(
379    mesh: &HalfedgeMesh,
380    g: axiolid_mesh::halfedge::HalfedgeId,
381    f: FaceId,
382) -> Result<VertexId, FairError> {
383    let degree = mesh.face_degree(f);
384    if degree != 3 {
385        return Err(FairError::NotATriangle { face: f, degree });
386    }
387    Ok(mesh.target(mesh.next(g)))
388}
389
390/// Cotangent of the angle at `apex` in the triangle `apex, a, b` of face `f`.
391fn cotangent(
392    mesh: &HalfedgeMesh,
393    f: FaceId,
394    apex: VertexId,
395    a: VertexId,
396    b: VertexId,
397) -> Result<Scalar, FairError> {
398    let p = mesh.position(apex);
399    let u = mesh.position(a) - p;
400    let v = mesh.position(b) - p;
401    let cross = u.cross(v).length();
402    if cross.is_nan() || cross <= MIN_SINE * u.length() * v.length() {
403        return Err(FairError::DegenerateTriangle { face: f });
404    }
405    Ok(u.dot(v) / cross)
406}
407
408/// Mass of a vertex: its valence for uniform weights, a third of the incident
409/// triangles' area for cotangent weights.
410fn vertex_mass(
411    mesh: &HalfedgeMesh,
412    v: VertexId,
413    weights: FairingWeights,
414) -> Result<Scalar, FairError> {
415    match weights {
416        FairingWeights::Uniform => Ok(mesh.degree(v) as Scalar),
417        FairingWeights::Cotangent => {
418            let mut area = 0.0;
419            for f in mesh.vertex_faces(v) {
420                let corners: Vec<Point3> =
421                    mesh.face_vertices(f).map(|c| mesh.position(c)).collect();
422                if corners.len() != 3 {
423                    return Err(FairError::NotATriangle {
424                        face: f,
425                        degree: corners.len(),
426                    });
427                }
428                area += 0.5
429                    * (corners[1] - corners[0])
430                        .cross(corners[2] - corners[0])
431                        .length();
432            }
433            // Every incident triangle passed the cotangent test, so the sum
434            // is positive unless the vertex has no face at all.
435            if area > 0.0 {
436                Ok(area / 3.0)
437            } else {
438                Err(FairError::Unanchored { vertex: v })
439            }
440        }
441    }
442}