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 std::collections::HashSet;
16use threecrate_core::{Error, Point3f, Result, TriangleMesh, Vector3f};
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 {
77            iterations: 10,
78            lambda: 0.5,
79        }
80    }
81}
82
83/// Laplacian mesh smoothing.
84///
85/// Each vertex is iteratively moved towards the average position of its
86/// one-ring neighbours.  Simple and fast, but causes mesh shrinkage over many
87/// iterations.
88///
89/// # Arguments
90/// * `mesh`   - Input triangle mesh (connectivity unchanged in output)
91/// * `config` - Smoothing parameters
92///
93/// # Returns
94/// A new `TriangleMesh` with smoothed vertex positions and identical faces.
95pub fn smooth_laplacian(
96    mesh: &TriangleMesh,
97    config: &LaplacianSmoothingConfig,
98) -> Result<TriangleMesh> {
99    if mesh.is_empty() {
100        return Err(Error::InvalidData("Mesh is empty".to_string()));
101    }
102    if config.lambda <= 0.0 || config.lambda > 1.0 {
103        return Err(Error::InvalidData("lambda must be in (0, 1]".to_string()));
104    }
105    if config.iterations == 0 {
106        return Ok(mesh.clone());
107    }
108
109    let adj = build_adjacency(mesh);
110    let mut verts = mesh.vertices.clone();
111    for _ in 0..config.iterations {
112        verts = laplacian_step(&verts, &adj, config.lambda);
113    }
114    Ok(TriangleMesh::from_vertices_and_faces(
115        verts,
116        mesh.faces.clone(),
117    ))
118}
119
120// ---------------------------------------------------------------------------
121// Taubin (μ|λ) smoothing
122// ---------------------------------------------------------------------------
123
124/// Configuration for Taubin (μ|λ) smoothing.
125#[derive(Debug, Clone)]
126pub struct TaubinSmoothingConfig {
127    /// Number of full (λ + μ) iterations.
128    pub iterations: usize,
129    /// Positive step factor `λ ∈ (0, 1)`.
130    pub lambda: f32,
131    /// Negative step factor `μ < 0` (typically `μ ≈ −λ/(1 − λ·K)` for pass-band K).
132    /// A safe default: `μ = −0.53` when `λ = 0.5`.
133    pub mu: f32,
134}
135
136impl Default for TaubinSmoothingConfig {
137    fn default() -> Self {
138        Self {
139            iterations: 10,
140            lambda: 0.5,
141            mu: -0.53,
142        }
143    }
144}
145
146/// Taubin (μ|λ) mesh smoothing.
147///
148/// Two alternating Laplacian passes per iteration: a positive (shrinking) λ
149/// pass followed by a negative (expanding) μ pass.  The combination
150/// suppresses low-frequency noise while avoiding the volume shrinkage of
151/// plain Laplacian smoothing.
152///
153/// Reference: G. Taubin, "A Signal Processing Approach To Fair Surface Design" (1995).
154///
155/// # Arguments
156/// * `mesh`   - Input triangle mesh
157/// * `config` - Smoothing parameters (λ > 0, μ < 0, |μ| > λ recommended)
158pub fn smooth_taubin(mesh: &TriangleMesh, config: &TaubinSmoothingConfig) -> Result<TriangleMesh> {
159    if mesh.is_empty() {
160        return Err(Error::InvalidData("Mesh is empty".to_string()));
161    }
162    if config.lambda <= 0.0 || config.lambda >= 1.0 {
163        return Err(Error::InvalidData("lambda must be in (0, 1)".to_string()));
164    }
165    if config.mu >= 0.0 {
166        return Err(Error::InvalidData("mu must be negative".to_string()));
167    }
168    if config.iterations == 0 {
169        return Ok(mesh.clone());
170    }
171
172    let adj = build_adjacency(mesh);
173    let mut verts = mesh.vertices.clone();
174    for _ in 0..config.iterations {
175        verts = laplacian_step(&verts, &adj, config.lambda);
176        verts = laplacian_step(&verts, &adj, config.mu);
177    }
178    Ok(TriangleMesh::from_vertices_and_faces(
179        verts,
180        mesh.faces.clone(),
181    ))
182}
183
184// ---------------------------------------------------------------------------
185// HC (Humphrey's Classes) smoothing
186// ---------------------------------------------------------------------------
187
188/// Configuration for HC (Humphrey's Classes) smoothing.
189#[derive(Debug, Clone)]
190pub struct HcSmoothingConfig {
191    /// Number of smoothing iterations.
192    pub iterations: usize,
193    /// Blend weight toward the *original* positions in the backward step.
194    /// `α = 0`: correction relative to current positions (more smoothing).
195    /// `α = 1`: correction relative to original positions (less drift).
196    pub alpha: f32,
197    /// Balance between per-vertex correction (`β = 1`) and
198    /// neighbour-averaged correction (`β = 0`).  Values near 0.5 work well.
199    pub beta: f32,
200}
201
202impl Default for HcSmoothingConfig {
203    fn default() -> Self {
204        Self {
205            iterations: 10,
206            alpha: 0.0,
207            beta: 0.5,
208        }
209    }
210}
211
212/// HC (Humphrey's Classes) mesh smoothing.
213///
214/// A two-phase algorithm: first a standard Laplacian step, then a backward
215/// correction that biases each vertex back toward a blend of its original and
216/// Laplacian position.  Produces less shrinkage than plain Laplacian while
217/// still effectively reducing noise.
218///
219/// Reference: J. Vollmer, R. Mencl, H. Müller,
220/// "Improved Laplacian Smoothing of Noisy Surface Meshes" (1999).
221///
222/// # Arguments
223/// * `mesh`   - Input triangle mesh
224/// * `config` - Smoothing parameters (α, β both in [0, 1])
225pub fn smooth_hc(mesh: &TriangleMesh, config: &HcSmoothingConfig) -> Result<TriangleMesh> {
226    if mesh.is_empty() {
227        return Err(Error::InvalidData("Mesh is empty".to_string()));
228    }
229    if !(0.0..=1.0).contains(&config.alpha) {
230        return Err(Error::InvalidData("alpha must be in [0, 1]".to_string()));
231    }
232    if !(0.0..=1.0).contains(&config.beta) {
233        return Err(Error::InvalidData("beta must be in [0, 1]".to_string()));
234    }
235    if config.iterations == 0 {
236        return Ok(mesh.clone());
237    }
238
239    let adj = build_adjacency(mesh);
240    let original: Vec<Point3f> = mesh.vertices.clone();
241    let mut q: Vec<Point3f> = original.clone();
242
243    for _ in 0..config.iterations {
244        // --- Phase 1: Laplacian step → q_bar ---
245        let q_bar: Vec<Point3f> = q
246            .iter()
247            .enumerate()
248            .map(|(i, &v)| {
249                let nbrs = &adj[i];
250                if nbrs.is_empty() {
251                    return v;
252                }
253                let sum = nbrs
254                    .iter()
255                    .fold(Vector3f::zeros(), |acc, &j| acc + q[j].coords);
256                Point3f::from(sum / nbrs.len() as f32)
257            })
258            .collect();
259
260        // --- Phase 2: Backward difference vector ---
261        // b[i] = q_bar[i] − (α·p[i] + (1−α)·q[i])
262        let b: Vec<Vector3f> = (0..q.len())
263            .map(|i| {
264                let blend = original[i].coords * config.alpha + q[i].coords * (1.0 - config.alpha);
265                q_bar[i].coords - blend
266            })
267            .collect();
268
269        // --- Phase 3: HC correction ---
270        // q[i] ← q_bar[i] − (β·b[i] + (1−β) · avg_neighbour(b))
271        q = (0..q.len())
272            .map(|i| {
273                let nbrs = &adj[i];
274                let avg_b = if nbrs.is_empty() {
275                    Vector3f::zeros()
276                } else {
277                    nbrs.iter().fold(Vector3f::zeros(), |acc, &j| acc + b[j]) / nbrs.len() as f32
278                };
279                let correction = b[i] * config.beta + avg_b * (1.0 - config.beta);
280                Point3f::from(q_bar[i].coords - correction)
281            })
282            .collect();
283    }
284
285    Ok(TriangleMesh::from_vertices_and_faces(q, mesh.faces.clone()))
286}
287
288// ---------------------------------------------------------------------------
289// Tests
290// ---------------------------------------------------------------------------
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    /// A 3×3 grid mesh (8 triangles) with the centre vertex raised to z = 1.
297    /// All boundary vertices sit at z = 0.
298    fn make_spike_mesh() -> TriangleMesh {
299        let verts = vec![
300            Point3f::new(0.0, 0.0, 0.0), // 0
301            Point3f::new(1.0, 0.0, 0.0), // 1
302            Point3f::new(2.0, 0.0, 0.0), // 2
303            Point3f::new(0.0, 1.0, 0.0), // 3
304            Point3f::new(1.0, 1.0, 1.0), // 4  ← spike
305            Point3f::new(2.0, 1.0, 0.0), // 5
306            Point3f::new(0.0, 2.0, 0.0), // 6
307            Point3f::new(1.0, 2.0, 0.0), // 7
308            Point3f::new(2.0, 2.0, 0.0), // 8
309        ];
310        let faces = vec![
311            [0, 1, 3],
312            [1, 4, 3],
313            [1, 2, 4],
314            [2, 5, 4],
315            [3, 4, 6],
316            [4, 7, 6],
317            [4, 5, 7],
318            [5, 8, 7],
319        ];
320        TriangleMesh::from_vertices_and_faces(verts, faces)
321    }
322
323    /// Compute the centroid z of all vertices.
324    fn centroid_z(mesh: &TriangleMesh) -> f32 {
325        mesh.vertices.iter().map(|v| v.z).sum::<f32>() / mesh.vertices.len() as f32
326    }
327
328    // ---- topology preservation ----
329
330    #[test]
331    fn test_laplacian_preserves_topology() {
332        let mesh = make_spike_mesh();
333        let result = smooth_laplacian(&mesh, &LaplacianSmoothingConfig::default()).unwrap();
334        assert_eq!(result.face_count(), mesh.face_count());
335        assert_eq!(result.vertex_count(), mesh.vertex_count());
336        assert_eq!(result.faces, mesh.faces);
337    }
338
339    #[test]
340    fn test_taubin_preserves_topology() {
341        let mesh = make_spike_mesh();
342        let result = smooth_taubin(&mesh, &TaubinSmoothingConfig::default()).unwrap();
343        assert_eq!(result.faces, mesh.faces);
344    }
345
346    #[test]
347    fn test_hc_preserves_topology() {
348        let mesh = make_spike_mesh();
349        let result = smooth_hc(&mesh, &HcSmoothingConfig::default()).unwrap();
350        assert_eq!(result.faces, mesh.faces);
351    }
352
353    // ---- spike is reduced ----
354
355    #[test]
356    fn test_laplacian_reduces_spike() {
357        let mesh = make_spike_mesh();
358        let before_z = mesh.vertices[4].z; // 1.0
359        let result = smooth_laplacian(&mesh, &LaplacianSmoothingConfig::default()).unwrap();
360        let after_z = result.vertices[4].z;
361        assert!(
362            after_z < before_z,
363            "spike z should decrease: before={before_z}, after={after_z}"
364        );
365    }
366
367    #[test]
368    fn test_taubin_reduces_spike() {
369        let mesh = make_spike_mesh();
370        let before_z = mesh.vertices[4].z;
371        let result = smooth_taubin(&mesh, &TaubinSmoothingConfig::default()).unwrap();
372        let after_z = result.vertices[4].z;
373        assert!(
374            after_z < before_z,
375            "Taubin should reduce spike: {before_z} → {after_z}"
376        );
377    }
378
379    #[test]
380    fn test_hc_reduces_spike() {
381        let mesh = make_spike_mesh();
382        let before_z = mesh.vertices[4].z;
383        let result = smooth_hc(&mesh, &HcSmoothingConfig::default()).unwrap();
384        let after_z = result.vertices[4].z;
385        assert!(
386            after_z < before_z,
387            "HC should reduce spike: {before_z} → {after_z}"
388        );
389    }
390
391    // ---- Taubin shrinks less than pure Laplacian ----
392
393    #[test]
394    fn test_taubin_less_shrinkage_than_laplacian() {
395        // Build a larger, closed box mesh so that the global volume metric is
396        // well-defined and the boundary effects of the small spike mesh do not
397        // dominate the result.
398        let verts = vec![
399            Point3f::new(0.0, 0.0, 0.0),
400            Point3f::new(1.0, 0.0, 0.0),
401            Point3f::new(1.0, 1.0, 0.0),
402            Point3f::new(0.0, 1.0, 0.0),
403            Point3f::new(0.0, 0.0, 1.0),
404            Point3f::new(1.0, 0.0, 1.0),
405            Point3f::new(1.0, 1.0, 1.0),
406            Point3f::new(0.0, 1.0, 1.0),
407        ];
408        let faces = vec![
409            [0, 2, 1],
410            [0, 3, 2],
411            [4, 5, 6],
412            [4, 6, 7],
413            [0, 1, 5],
414            [0, 5, 4],
415            [3, 7, 6],
416            [3, 6, 2],
417            [0, 4, 7],
418            [0, 7, 3],
419            [1, 2, 6],
420            [1, 6, 5],
421        ];
422        let mesh = TriangleMesh::from_vertices_and_faces(verts, faces);
423
424        // Compute average distance of vertices from their centroid (proxy for "size")
425        let avg_dist = |m: &TriangleMesh| {
426            let c: Vector3f = m
427                .vertices
428                .iter()
429                .fold(Vector3f::zeros(), |acc, v| acc + v.coords)
430                / m.vertices.len() as f32;
431            m.vertices
432                .iter()
433                .map(|v| (v.coords - c).magnitude())
434                .sum::<f32>()
435                / m.vertices.len() as f32
436        };
437
438        let original_spread = avg_dist(&mesh);
439
440        let lap = smooth_laplacian(
441            &mesh,
442            &LaplacianSmoothingConfig {
443                iterations: 50,
444                lambda: 0.5,
445            },
446        )
447        .unwrap();
448        let tau = smooth_taubin(
449            &mesh,
450            &TaubinSmoothingConfig {
451                iterations: 50,
452                lambda: 0.5,
453                mu: -0.53,
454            },
455        )
456        .unwrap();
457
458        let lap_spread = avg_dist(&lap);
459        let tau_spread = avg_dist(&tau);
460
461        // Laplacian should shrink the mesh more (lower spread) than Taubin
462        assert!(
463            tau_spread > lap_spread,
464            "Taubin should preserve spread better than Laplacian: \
465             original={original_spread:.4}, lap={lap_spread:.4}, tau={tau_spread:.4}"
466        );
467    }
468
469    // ---- zero iterations returns clone ----
470
471    #[test]
472    fn test_laplacian_zero_iterations() {
473        let mesh = make_spike_mesh();
474        let result = smooth_laplacian(
475            &mesh,
476            &LaplacianSmoothingConfig {
477                iterations: 0,
478                lambda: 0.5,
479            },
480        )
481        .unwrap();
482        for (a, b) in mesh.vertices.iter().zip(result.vertices.iter()) {
483            assert!((a - b).magnitude() < 1e-6);
484        }
485    }
486
487    #[test]
488    fn test_taubin_zero_iterations() {
489        let mesh = make_spike_mesh();
490        let result = smooth_taubin(
491            &mesh,
492            &TaubinSmoothingConfig {
493                iterations: 0,
494                lambda: 0.5,
495                mu: -0.53,
496            },
497        )
498        .unwrap();
499        for (a, b) in mesh.vertices.iter().zip(result.vertices.iter()) {
500            assert!((a - b).magnitude() < 1e-6);
501        }
502    }
503
504    #[test]
505    fn test_hc_zero_iterations() {
506        let mesh = make_spike_mesh();
507        let result = smooth_hc(
508            &mesh,
509            &HcSmoothingConfig {
510                iterations: 0,
511                alpha: 0.0,
512                beta: 0.5,
513            },
514        )
515        .unwrap();
516        for (a, b) in mesh.vertices.iter().zip(result.vertices.iter()) {
517            assert!((a - b).magnitude() < 1e-6);
518        }
519    }
520
521    // ---- error cases ----
522
523    #[test]
524    fn test_laplacian_empty_mesh() {
525        let empty = TriangleMesh::new();
526        assert!(smooth_laplacian(&empty, &LaplacianSmoothingConfig::default()).is_err());
527    }
528
529    #[test]
530    fn test_laplacian_invalid_lambda() {
531        let mesh = make_spike_mesh();
532        assert!(smooth_laplacian(
533            &mesh,
534            &LaplacianSmoothingConfig {
535                iterations: 1,
536                lambda: 0.0
537            }
538        )
539        .is_err());
540        assert!(smooth_laplacian(
541            &mesh,
542            &LaplacianSmoothingConfig {
543                iterations: 1,
544                lambda: 1.5
545            }
546        )
547        .is_err());
548    }
549
550    #[test]
551    fn test_taubin_invalid_params() {
552        let mesh = make_spike_mesh();
553        // lambda out of range
554        assert!(smooth_taubin(
555            &mesh,
556            &TaubinSmoothingConfig {
557                iterations: 1,
558                lambda: 0.0,
559                mu: -0.53
560            }
561        )
562        .is_err());
563        // mu non-negative
564        assert!(smooth_taubin(
565            &mesh,
566            &TaubinSmoothingConfig {
567                iterations: 1,
568                lambda: 0.5,
569                mu: 0.1
570            }
571        )
572        .is_err());
573    }
574
575    #[test]
576    fn test_hc_invalid_params() {
577        let mesh = make_spike_mesh();
578        assert!(smooth_hc(
579            &mesh,
580            &HcSmoothingConfig {
581                iterations: 1,
582                alpha: -0.1,
583                beta: 0.5
584            }
585        )
586        .is_err());
587        assert!(smooth_hc(
588            &mesh,
589            &HcSmoothingConfig {
590                iterations: 1,
591                alpha: 0.5,
592                beta: 1.5
593            }
594        )
595        .is_err());
596    }
597
598    #[test]
599    fn test_taubin_empty_mesh() {
600        let empty = TriangleMesh::new();
601        assert!(smooth_taubin(&empty, &TaubinSmoothingConfig::default()).is_err());
602    }
603
604    #[test]
605    fn test_hc_empty_mesh() {
606        let empty = TriangleMesh::new();
607        assert!(smooth_hc(&empty, &HcSmoothingConfig::default()).is_err());
608    }
609
610    // ---- more iterations = more smoothing ----
611
612    #[test]
613    fn test_laplacian_more_iterations_smoother() {
614        let mesh = make_spike_mesh();
615        let r1 = smooth_laplacian(
616            &mesh,
617            &LaplacianSmoothingConfig {
618                iterations: 1,
619                lambda: 0.5,
620            },
621        )
622        .unwrap();
623        let r5 = smooth_laplacian(
624            &mesh,
625            &LaplacianSmoothingConfig {
626                iterations: 5,
627                lambda: 0.5,
628            },
629        )
630        .unwrap();
631        // More iterations → spike vertex should be lower
632        assert!(r5.vertices[4].z < r1.vertices[4].z);
633    }
634}