Skip to main content

threecrate_algorithms/
mesh_smoothing.rs

1//! Mesh smoothing algorithms
2//!
3//! Three progressively more sophisticated approaches are provided:
4//!
5//! | Algorithm | Volume preservation | Speed |
6//! |-----------|---------------------|-------|
7//! | Laplacian | Poor (shrinks mesh) | Fast  |
8//! | Taubin    | Good               | Fast  |
9//! | HC        | Good               | Fast  |
10//!
11//! All algorithms share the same pattern: build a one-ring adjacency list from
12//! the mesh faces, then iteratively update vertex positions while keeping the
13//! face connectivity unchanged.
14
15use threecrate_core::{TriangleMesh, Result, Point3f, Vector3f, Error};
16use std::collections::HashSet;
17
18// ---------------------------------------------------------------------------
19// Shared helpers
20// ---------------------------------------------------------------------------
21
22/// Build a one-ring adjacency list from the mesh faces.
23/// `adj[i]` contains the indices of all vertices directly connected to `i` by an edge.
24fn build_adjacency(mesh: &TriangleMesh) -> Vec<Vec<usize>> {
25    let n = mesh.vertices.len();
26    let mut adj: Vec<HashSet<usize>> = vec![HashSet::new(); n];
27    for face in &mesh.faces {
28        let [a, b, c] = *face;
29        adj[a].insert(b);
30        adj[a].insert(c);
31        adj[b].insert(a);
32        adj[b].insert(c);
33        adj[c].insert(a);
34        adj[c].insert(b);
35    }
36    adj.into_iter().map(|s| s.into_iter().collect()).collect()
37}
38
39/// Apply one uniform Laplacian displacement step.
40/// Each vertex moves by `factor × (centroid_of_neighbours − vertex)`.
41/// Isolated vertices (no neighbours) are left unchanged.
42fn laplacian_step(vertices: &[Point3f], adj: &[Vec<usize>], factor: f32) -> Vec<Point3f> {
43    vertices
44        .iter()
45        .enumerate()
46        .map(|(i, &v)| {
47            let nbrs = &adj[i];
48            if nbrs.is_empty() {
49                return v;
50            }
51            let sum = nbrs
52                .iter()
53                .fold(Vector3f::zeros(), |acc, &j| acc + vertices[j].coords);
54            let centroid = Point3f::from(sum / nbrs.len() as f32);
55            v + (centroid - v) * factor
56        })
57        .collect()
58}
59
60// ---------------------------------------------------------------------------
61// Laplacian smoothing
62// ---------------------------------------------------------------------------
63
64/// Configuration for Laplacian smoothing.
65#[derive(Debug, Clone)]
66pub struct LaplacianSmoothingConfig {
67    /// Number of smoothing iterations.
68    pub iterations: usize,
69    /// Per-iteration blend factor `λ ∈ (0, 1]`.
70    /// Larger values = more aggressive smoothing per iteration.
71    pub lambda: f32,
72}
73
74impl Default for LaplacianSmoothingConfig {
75    fn default() -> Self {
76        Self { iterations: 10, lambda: 0.5 }
77    }
78}
79
80/// Laplacian mesh smoothing.
81///
82/// Each vertex is iteratively moved towards the average position of its
83/// one-ring neighbours.  Simple and fast, but causes mesh shrinkage over many
84/// iterations.
85///
86/// # Arguments
87/// * `mesh`   - Input triangle mesh (connectivity unchanged in output)
88/// * `config` - Smoothing parameters
89///
90/// # Returns
91/// A new `TriangleMesh` with smoothed vertex positions and identical faces.
92pub fn smooth_laplacian(
93    mesh: &TriangleMesh,
94    config: &LaplacianSmoothingConfig,
95) -> Result<TriangleMesh> {
96    if mesh.is_empty() {
97        return Err(Error::InvalidData("Mesh is empty".to_string()));
98    }
99    if config.lambda <= 0.0 || config.lambda > 1.0 {
100        return Err(Error::InvalidData("lambda must be in (0, 1]".to_string()));
101    }
102    if config.iterations == 0 {
103        return Ok(mesh.clone());
104    }
105
106    let adj = build_adjacency(mesh);
107    let mut verts = mesh.vertices.clone();
108    for _ in 0..config.iterations {
109        verts = laplacian_step(&verts, &adj, config.lambda);
110    }
111    Ok(TriangleMesh::from_vertices_and_faces(verts, mesh.faces.clone()))
112}
113
114// ---------------------------------------------------------------------------
115// Taubin (μ|λ) smoothing
116// ---------------------------------------------------------------------------
117
118/// Configuration for Taubin (μ|λ) smoothing.
119#[derive(Debug, Clone)]
120pub struct TaubinSmoothingConfig {
121    /// Number of full (λ + μ) iterations.
122    pub iterations: usize,
123    /// Positive step factor `λ ∈ (0, 1)`.
124    pub lambda: f32,
125    /// Negative step factor `μ < 0` (typically `μ ≈ −λ/(1 − λ·K)` for pass-band K).
126    /// A safe default: `μ = −0.53` when `λ = 0.5`.
127    pub mu: f32,
128}
129
130impl Default for TaubinSmoothingConfig {
131    fn default() -> Self {
132        Self { iterations: 10, lambda: 0.5, mu: -0.53 }
133    }
134}
135
136/// Taubin (μ|λ) mesh smoothing.
137///
138/// Two alternating Laplacian passes per iteration: a positive (shrinking) λ
139/// pass followed by a negative (expanding) μ pass.  The combination
140/// suppresses low-frequency noise while avoiding the volume shrinkage of
141/// plain Laplacian smoothing.
142///
143/// Reference: G. Taubin, "A Signal Processing Approach To Fair Surface Design" (1995).
144///
145/// # Arguments
146/// * `mesh`   - Input triangle mesh
147/// * `config` - Smoothing parameters (λ > 0, μ < 0, |μ| > λ recommended)
148pub fn smooth_taubin(
149    mesh: &TriangleMesh,
150    config: &TaubinSmoothingConfig,
151) -> Result<TriangleMesh> {
152    if mesh.is_empty() {
153        return Err(Error::InvalidData("Mesh is empty".to_string()));
154    }
155    if config.lambda <= 0.0 || config.lambda >= 1.0 {
156        return Err(Error::InvalidData("lambda must be in (0, 1)".to_string()));
157    }
158    if config.mu >= 0.0 {
159        return Err(Error::InvalidData("mu must be negative".to_string()));
160    }
161    if config.iterations == 0 {
162        return Ok(mesh.clone());
163    }
164
165    let adj = build_adjacency(mesh);
166    let mut verts = mesh.vertices.clone();
167    for _ in 0..config.iterations {
168        verts = laplacian_step(&verts, &adj, config.lambda);
169        verts = laplacian_step(&verts, &adj, config.mu);
170    }
171    Ok(TriangleMesh::from_vertices_and_faces(verts, mesh.faces.clone()))
172}
173
174// ---------------------------------------------------------------------------
175// HC (Humphrey's Classes) smoothing
176// ---------------------------------------------------------------------------
177
178/// Configuration for HC (Humphrey's Classes) smoothing.
179#[derive(Debug, Clone)]
180pub struct HcSmoothingConfig {
181    /// Number of smoothing iterations.
182    pub iterations: usize,
183    /// Blend weight toward the *original* positions in the backward step.
184    /// `α = 0`: correction relative to current positions (more smoothing).
185    /// `α = 1`: correction relative to original positions (less drift).
186    pub alpha: f32,
187    /// Balance between per-vertex correction (`β = 1`) and
188    /// neighbour-averaged correction (`β = 0`).  Values near 0.5 work well.
189    pub beta: f32,
190}
191
192impl Default for HcSmoothingConfig {
193    fn default() -> Self {
194        Self { iterations: 10, alpha: 0.0, beta: 0.5 }
195    }
196}
197
198/// HC (Humphrey's Classes) mesh smoothing.
199///
200/// A two-phase algorithm: first a standard Laplacian step, then a backward
201/// correction that biases each vertex back toward a blend of its original and
202/// Laplacian position.  Produces less shrinkage than plain Laplacian while
203/// still effectively reducing noise.
204///
205/// Reference: J. Vollmer, R. Mencl, H. Müller,
206/// "Improved Laplacian Smoothing of Noisy Surface Meshes" (1999).
207///
208/// # Arguments
209/// * `mesh`   - Input triangle mesh
210/// * `config` - Smoothing parameters (α, β both in [0, 1])
211pub fn smooth_hc(
212    mesh: &TriangleMesh,
213    config: &HcSmoothingConfig,
214) -> Result<TriangleMesh> {
215    if mesh.is_empty() {
216        return Err(Error::InvalidData("Mesh is empty".to_string()));
217    }
218    if !(0.0..=1.0).contains(&config.alpha) {
219        return Err(Error::InvalidData("alpha must be in [0, 1]".to_string()));
220    }
221    if !(0.0..=1.0).contains(&config.beta) {
222        return Err(Error::InvalidData("beta must be in [0, 1]".to_string()));
223    }
224    if config.iterations == 0 {
225        return Ok(mesh.clone());
226    }
227
228    let adj = build_adjacency(mesh);
229    let original: Vec<Point3f> = mesh.vertices.clone();
230    let mut q: Vec<Point3f> = original.clone();
231
232    for _ in 0..config.iterations {
233        // --- Phase 1: Laplacian step → q_bar ---
234        let q_bar: Vec<Point3f> = q
235            .iter()
236            .enumerate()
237            .map(|(i, &v)| {
238                let nbrs = &adj[i];
239                if nbrs.is_empty() {
240                    return v;
241                }
242                let sum = nbrs.iter().fold(Vector3f::zeros(), |acc, &j| acc + q[j].coords);
243                Point3f::from(sum / nbrs.len() as f32)
244            })
245            .collect();
246
247        // --- Phase 2: Backward difference vector ---
248        // b[i] = q_bar[i] − (α·p[i] + (1−α)·q[i])
249        let b: Vec<Vector3f> = (0..q.len())
250            .map(|i| {
251                let blend =
252                    original[i].coords * config.alpha + q[i].coords * (1.0 - config.alpha);
253                q_bar[i].coords - blend
254            })
255            .collect();
256
257        // --- Phase 3: HC correction ---
258        // q[i] ← q_bar[i] − (β·b[i] + (1−β) · avg_neighbour(b))
259        q = (0..q.len())
260            .map(|i| {
261                let nbrs = &adj[i];
262                let avg_b = if nbrs.is_empty() {
263                    Vector3f::zeros()
264                } else {
265                    nbrs.iter().fold(Vector3f::zeros(), |acc, &j| acc + b[j])
266                        / nbrs.len() as f32
267                };
268                let correction = b[i] * config.beta + avg_b * (1.0 - config.beta);
269                Point3f::from(q_bar[i].coords - correction)
270            })
271            .collect();
272    }
273
274    Ok(TriangleMesh::from_vertices_and_faces(q, mesh.faces.clone()))
275}
276
277// ---------------------------------------------------------------------------
278// Tests
279// ---------------------------------------------------------------------------
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    /// A 3×3 grid mesh (8 triangles) with the centre vertex raised to z = 1.
286    /// All boundary vertices sit at z = 0.
287    fn make_spike_mesh() -> TriangleMesh {
288        let verts = vec![
289            Point3f::new(0.0, 0.0, 0.0), // 0
290            Point3f::new(1.0, 0.0, 0.0), // 1
291            Point3f::new(2.0, 0.0, 0.0), // 2
292            Point3f::new(0.0, 1.0, 0.0), // 3
293            Point3f::new(1.0, 1.0, 1.0), // 4  ← spike
294            Point3f::new(2.0, 1.0, 0.0), // 5
295            Point3f::new(0.0, 2.0, 0.0), // 6
296            Point3f::new(1.0, 2.0, 0.0), // 7
297            Point3f::new(2.0, 2.0, 0.0), // 8
298        ];
299        let faces = vec![
300            [0, 1, 3], [1, 4, 3],
301            [1, 2, 4], [2, 5, 4],
302            [3, 4, 6], [4, 7, 6],
303            [4, 5, 7], [5, 8, 7],
304        ];
305        TriangleMesh::from_vertices_and_faces(verts, faces)
306    }
307
308    /// Compute the centroid z of all vertices.
309    fn centroid_z(mesh: &TriangleMesh) -> f32 {
310        mesh.vertices.iter().map(|v| v.z).sum::<f32>() / mesh.vertices.len() as f32
311    }
312
313    // ---- topology preservation ----
314
315    #[test]
316    fn test_laplacian_preserves_topology() {
317        let mesh = make_spike_mesh();
318        let result = smooth_laplacian(&mesh, &LaplacianSmoothingConfig::default()).unwrap();
319        assert_eq!(result.face_count(), mesh.face_count());
320        assert_eq!(result.vertex_count(), mesh.vertex_count());
321        assert_eq!(result.faces, mesh.faces);
322    }
323
324    #[test]
325    fn test_taubin_preserves_topology() {
326        let mesh = make_spike_mesh();
327        let result = smooth_taubin(&mesh, &TaubinSmoothingConfig::default()).unwrap();
328        assert_eq!(result.faces, mesh.faces);
329    }
330
331    #[test]
332    fn test_hc_preserves_topology() {
333        let mesh = make_spike_mesh();
334        let result = smooth_hc(&mesh, &HcSmoothingConfig::default()).unwrap();
335        assert_eq!(result.faces, mesh.faces);
336    }
337
338    // ---- spike is reduced ----
339
340    #[test]
341    fn test_laplacian_reduces_spike() {
342        let mesh = make_spike_mesh();
343        let before_z = mesh.vertices[4].z; // 1.0
344        let result = smooth_laplacian(&mesh, &LaplacianSmoothingConfig::default()).unwrap();
345        let after_z = result.vertices[4].z;
346        assert!(
347            after_z < before_z,
348            "spike z should decrease: before={before_z}, after={after_z}"
349        );
350    }
351
352    #[test]
353    fn test_taubin_reduces_spike() {
354        let mesh = make_spike_mesh();
355        let before_z = mesh.vertices[4].z;
356        let result = smooth_taubin(&mesh, &TaubinSmoothingConfig::default()).unwrap();
357        let after_z = result.vertices[4].z;
358        assert!(after_z < before_z, "Taubin should reduce spike: {before_z} → {after_z}");
359    }
360
361    #[test]
362    fn test_hc_reduces_spike() {
363        let mesh = make_spike_mesh();
364        let before_z = mesh.vertices[4].z;
365        let result = smooth_hc(&mesh, &HcSmoothingConfig::default()).unwrap();
366        let after_z = result.vertices[4].z;
367        assert!(after_z < before_z, "HC should reduce spike: {before_z} → {after_z}");
368    }
369
370    // ---- Taubin shrinks less than pure Laplacian ----
371
372    #[test]
373    fn test_taubin_less_shrinkage_than_laplacian() {
374        // Build a larger, closed box mesh so that the global volume metric is
375        // well-defined and the boundary effects of the small spike mesh do not
376        // dominate the result.
377        let verts = vec![
378            Point3f::new(0.0, 0.0, 0.0), Point3f::new(1.0, 0.0, 0.0),
379            Point3f::new(1.0, 1.0, 0.0), Point3f::new(0.0, 1.0, 0.0),
380            Point3f::new(0.0, 0.0, 1.0), Point3f::new(1.0, 0.0, 1.0),
381            Point3f::new(1.0, 1.0, 1.0), Point3f::new(0.0, 1.0, 1.0),
382        ];
383        let faces = vec![
384            [0,2,1],[0,3,2], [4,5,6],[4,6,7],
385            [0,1,5],[0,5,4], [3,7,6],[3,6,2],
386            [0,4,7],[0,7,3], [1,2,6],[1,6,5],
387        ];
388        let mesh = TriangleMesh::from_vertices_and_faces(verts, faces);
389
390        // Compute average distance of vertices from their centroid (proxy for "size")
391        let avg_dist = |m: &TriangleMesh| {
392            let c: Vector3f = m.vertices.iter().fold(Vector3f::zeros(), |acc, v| acc + v.coords)
393                / m.vertices.len() as f32;
394            m.vertices.iter().map(|v| (v.coords - c).magnitude()).sum::<f32>()
395                / m.vertices.len() as f32
396        };
397
398        let original_spread = avg_dist(&mesh);
399
400        let lap = smooth_laplacian(
401            &mesh,
402            &LaplacianSmoothingConfig { iterations: 50, lambda: 0.5 },
403        ).unwrap();
404        let tau = smooth_taubin(
405            &mesh,
406            &TaubinSmoothingConfig { iterations: 50, lambda: 0.5, mu: -0.53 },
407        ).unwrap();
408
409        let lap_spread = avg_dist(&lap);
410        let tau_spread = avg_dist(&tau);
411
412        // Laplacian should shrink the mesh more (lower spread) than Taubin
413        assert!(
414            tau_spread > lap_spread,
415            "Taubin should preserve spread better than Laplacian: \
416             original={original_spread:.4}, lap={lap_spread:.4}, tau={tau_spread:.4}"
417        );
418    }
419
420    // ---- zero iterations returns clone ----
421
422    #[test]
423    fn test_laplacian_zero_iterations() {
424        let mesh = make_spike_mesh();
425        let result = smooth_laplacian(
426            &mesh,
427            &LaplacianSmoothingConfig { iterations: 0, lambda: 0.5 },
428        )
429        .unwrap();
430        for (a, b) in mesh.vertices.iter().zip(result.vertices.iter()) {
431            assert!((a - b).magnitude() < 1e-6);
432        }
433    }
434
435    #[test]
436    fn test_taubin_zero_iterations() {
437        let mesh = make_spike_mesh();
438        let result = smooth_taubin(
439            &mesh,
440            &TaubinSmoothingConfig { iterations: 0, lambda: 0.5, mu: -0.53 },
441        )
442        .unwrap();
443        for (a, b) in mesh.vertices.iter().zip(result.vertices.iter()) {
444            assert!((a - b).magnitude() < 1e-6);
445        }
446    }
447
448    #[test]
449    fn test_hc_zero_iterations() {
450        let mesh = make_spike_mesh();
451        let result =
452            smooth_hc(&mesh, &HcSmoothingConfig { iterations: 0, alpha: 0.0, beta: 0.5 })
453                .unwrap();
454        for (a, b) in mesh.vertices.iter().zip(result.vertices.iter()) {
455            assert!((a - b).magnitude() < 1e-6);
456        }
457    }
458
459    // ---- error cases ----
460
461    #[test]
462    fn test_laplacian_empty_mesh() {
463        let empty = TriangleMesh::new();
464        assert!(smooth_laplacian(&empty, &LaplacianSmoothingConfig::default()).is_err());
465    }
466
467    #[test]
468    fn test_laplacian_invalid_lambda() {
469        let mesh = make_spike_mesh();
470        assert!(smooth_laplacian(&mesh, &LaplacianSmoothingConfig { iterations: 1, lambda: 0.0 }).is_err());
471        assert!(smooth_laplacian(&mesh, &LaplacianSmoothingConfig { iterations: 1, lambda: 1.5 }).is_err());
472    }
473
474    #[test]
475    fn test_taubin_invalid_params() {
476        let mesh = make_spike_mesh();
477        // lambda out of range
478        assert!(smooth_taubin(&mesh, &TaubinSmoothingConfig { iterations: 1, lambda: 0.0, mu: -0.53 }).is_err());
479        // mu non-negative
480        assert!(smooth_taubin(&mesh, &TaubinSmoothingConfig { iterations: 1, lambda: 0.5, mu: 0.1 }).is_err());
481    }
482
483    #[test]
484    fn test_hc_invalid_params() {
485        let mesh = make_spike_mesh();
486        assert!(smooth_hc(&mesh, &HcSmoothingConfig { iterations: 1, alpha: -0.1, beta: 0.5 }).is_err());
487        assert!(smooth_hc(&mesh, &HcSmoothingConfig { iterations: 1, alpha: 0.5, beta: 1.5 }).is_err());
488    }
489
490    #[test]
491    fn test_taubin_empty_mesh() {
492        let empty = TriangleMesh::new();
493        assert!(smooth_taubin(&empty, &TaubinSmoothingConfig::default()).is_err());
494    }
495
496    #[test]
497    fn test_hc_empty_mesh() {
498        let empty = TriangleMesh::new();
499        assert!(smooth_hc(&empty, &HcSmoothingConfig::default()).is_err());
500    }
501
502    // ---- more iterations = more smoothing ----
503
504    #[test]
505    fn test_laplacian_more_iterations_smoother() {
506        let mesh = make_spike_mesh();
507        let r1 = smooth_laplacian(&mesh, &LaplacianSmoothingConfig { iterations: 1, lambda: 0.5 }).unwrap();
508        let r5 = smooth_laplacian(&mesh, &LaplacianSmoothingConfig { iterations: 5, lambda: 0.5 }).unwrap();
509        // More iterations → spike vertex should be lower
510        assert!(r5.vertices[4].z < r1.vertices[4].z);
511    }
512}