Skip to main content

fast_mesh_fields/
curvature.rs

1//! Per-vertex signed mean curvature of a mesh.
2//!
3//! [`curvatures`] writes, for every vertex slot, the discrete signed mean
4//! curvature at that vertex: positive on domes (convex as seen from the
5//! normal side), negative in bowls and dents, zero on flat spots. On a
6//! sphere of radius `r` with outward normals every vertex reads about
7//! `1 / r`; on a plane every vertex reads 0; a saddle reads the sign of
8//! its dominant bend (zero where the two bends cancel, as at the middle of
9//! a symmetric saddle).
10//!
11//! The estimate is the cotangent Laplace one (Meyer et al. 2003): with
12//! `w` the half-cotangent edge weights, `A` the barycentric area (a third
13//! of the adjacent triangles' area) and `n` the area-weighted smooth normal,
14//! `H = -(sum_j w_ij (x_j - x_i)) . n / (2 A)`. The sign follows the
15//! normals: flipping every triangle negates the values.
16//! The `. n` projection keeps flat regions at exactly 0 however irregular
17//! their triangulation is (the raw Laplace vector has a spurious tangential
18//! part on irregular flat meshes).
19//!
20//! Units are 1/local-unit: a mesh scaled by `s` reads `H / s` (the scene
21//! applies this for uniform placements, and bakes non-uniform ones into a
22//! world-frame copy first, so its fields read 1/world-unit).
23//!
24//! Boundary vertices use their one-sided fan like any other vertex. Dead
25//! slots, alive vertices no alive triangle uses, vertices with a non-finite
26//! position, and degenerate stencils (zero area or zero normal) read NaN.
27//!
28//! Nothing is allocated per vertex, and the neighbour sums run in ascending
29//! vertex order, so the values are bit-identical however they are threaded.
30
31use fast_mesh_store::Mesh;
32
33use crate::FieldsError;
34use crate::Result;
35
36/// Per-vertex signed mean curvature of `mesh`: `out[v]` gets the value for
37/// vertex slot `v` (see the module docs), in 1/local-units. `out` must hold
38/// one entry per vertex slot.
39///
40/// # Errors
41/// [`FieldsError::InvalidInput`] when `out` is not one entry per vertex
42/// slot. On error `out` is unchanged.
43pub fn curvatures(mesh: &Mesh, out: &mut [f32]) -> Result<()> {
44    if out.len() != mesh.vertex_slots() {
45        return Err(FieldsError::InvalidInput(
46            "out must hold one entry per vertex slot",
47        ));
48    }
49    let slots = mesh.vertex_slots();
50    // Half-cotangent weights by edge, kept per endpoint in ascending
51    // neighbour order (deterministic sums); barycentric areas; smooth
52    // (area-weighted) normals.
53    let mut neighbours: Vec<Vec<(u32, f64)>> = Vec::new();
54    neighbours
55        .try_reserve_exact(slots)
56        .map_err(|_| FieldsError::OutOfMemory)?;
57    neighbours.resize(slots, Vec::new());
58    let mut areas = vec![0.0f64; slots];
59    let mut normals = vec![[0.0f64; 3]; slots];
60    for t in 0..mesh.triangle_slots() as u32 {
61        let Some(tri) = mesh.triangle(t) else {
62            continue;
63        };
64        if !mesh.is_triangle_alive(t) {
65            continue;
66        }
67        let p = mesh.positions();
68        let [a, b, c] = tri.map(|v| p[v as usize]);
69        if ![a, b, c].iter().all(|p| p.iter().all(|c| c.is_finite())) {
70            continue;
71        }
72        let fa = a.map(f64::from);
73        let fb = b.map(f64::from);
74        let fc = c.map(f64::from);
75        let (ab, ac, bc) = (sub(fb, fa), sub(fc, fa), sub(fc, fb));
76        let n = cross(ab, ac);
77        let area2 = len(n);
78        // Zero-area (or empty) triangles contribute nothing; positions are
79        // finite, so areas are finite too and `<=` is exact here.
80        if area2 <= 0.0 {
81            continue;
82        }
83        for v in tri {
84            areas[v as usize] += area2 / 6.0;
85            for k in 0..3 {
86                normals[v as usize][k] += n[k];
87            }
88        }
89        // Cotangents at a, b, c; each weighs the opposite edge by half.
90        let ca = dot(ab, ac) / area2;
91        let cb = dot([-ab[0], -ab[1], -ab[2]], bc) / area2;
92        let cc = dot([-ac[0], -ac[1], -ac[2]], [-bc[0], -bc[1], -bc[2]]) / area2;
93        add(&mut neighbours, tri[0], tri[1], cc / 2.0)?;
94        add(&mut neighbours, tri[1], tri[2], ca / 2.0)?;
95        add(&mut neighbours, tri[2], tri[0], cb / 2.0)?;
96    }
97    for list in &mut neighbours {
98        list.sort_by_key(|&(v, _)| v);
99    }
100    let pos = mesh.positions();
101    for (v, o) in out.iter_mut().enumerate() {
102        let alive = mesh.is_vertex_alive(v as u32)
103            && mesh.corner_count(v as u32) > 0
104            && pos[v].iter().all(|c| c.is_finite());
105        let area = areas[v];
106        let n = normals[v];
107        let nlen = len(n);
108        if !alive || area <= 0.0 || nlen <= 0.0 {
109            *o = f32::NAN;
110            continue;
111        }
112        let x = pos[v].map(f64::from);
113        let mut lap = [0.0; 3];
114        for &(u, w) in &neighbours[v] {
115            let y = pos[u as usize].map(f64::from);
116            for k in 0..3 {
117                lap[k] += w * (y[k] - x[k]);
118            }
119        }
120        let proj = (lap[0] * n[0] + lap[1] * n[1] + lap[2] * n[2]) / nlen;
121        *o = (-proj / (2.0 * area)) as f32;
122    }
123    Ok(())
124}
125
126/// Adds `w` to the weights between `a` and `b`, both ways round.
127fn add(neighbours: &mut [Vec<(u32, f64)>], a: u32, b: u32, w: f64) -> Result<()> {
128    for (v, u) in [(a, b), (b, a)] {
129        let list = &mut neighbours[v as usize];
130        if let Some(slot) = list.iter_mut().find(|(x, _)| *x == u) {
131            slot.1 += w;
132        } else {
133            list.try_reserve(1).map_err(|_| FieldsError::OutOfMemory)?;
134            list.push((u, w));
135        }
136    }
137    Ok(())
138}
139
140fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
141    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
142}
143
144fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
145    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
146}
147
148fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
149    [
150        a[1] * b[2] - a[2] * b[1],
151        a[2] * b[0] - a[0] * b[2],
152        a[0] * b[1] - a[1] * b[0],
153    ]
154}
155
156fn len(a: [f64; 3]) -> f64 {
157    dot(a, a).sqrt()
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use crate::test_util::*;
164
165    fn run(mesh: &Mesh) -> Vec<f32> {
166        let mut out = vec![f32::NAN; mesh.vertex_slots()];
167        curvatures(mesh, &mut out).unwrap();
168        out
169    }
170
171    /// A flat `(n+1) x (n+1)` grid in `z = 0`, `h` apart.
172    fn plane(n: u32, h: f32) -> Mesh {
173        let mut positions = Vec::new();
174        for y in 0..=n {
175            for x in 0..=n {
176                positions.push([x as f32 * h, y as f32 * h, 0.0]);
177            }
178        }
179        let mut tris = Vec::new();
180        for y in 0..n {
181            for x in 0..n {
182                let a = y * (n + 1) + x;
183                tris.extend([[a, a + 1, a + n + 2], [a, a + n + 2, a + n + 1]]);
184            }
185        }
186        mesh(&positions, &tris)
187    }
188
189    /// A saddle `z = (2 x^2 - y^2) / s` over `[-1, 1]^2`, `n` cells a side,
190    /// facing up: smooth mean curvature `-1 / s` at the middle (the bend
191    /// toward the normal wins).
192    fn saddle(n: u32, s: f32) -> Mesh {
193        let mut positions = Vec::new();
194        for y in 0..=n {
195            for x in 0..=n {
196                let (fx, fy) = (
197                    x as f32 / n as f32 * 2.0 - 1.0,
198                    y as f32 / n as f32 * 2.0 - 1.0,
199                );
200                positions.push([fx, fy, (2.0 * fx * fx - fy * fy) / s]);
201            }
202        }
203        let mut tris = Vec::new();
204        for y in 0..n {
205            for x in 0..n {
206                let a = y * (n + 1) + x;
207                tris.extend([[a, a + 1, a + n + 2], [a, a + n + 2, a + n + 1]]);
208            }
209        }
210        mesh(&positions, &tris)
211    }
212
213    #[test]
214    fn sphere_reads_one_over_r() {
215        let m = icosphere();
216        let out = run(&m);
217        assert_eq!(out.len(), m.vertex_slots());
218        for &v in &out {
219            assert!(v.is_finite(), "{out:?}");
220            assert!((v - 1.0).abs() < 0.2, "{v}");
221        }
222        // Twice the radius reads half the curvature, bit for bit up to the
223        // float rounding of the doubled positions.
224        let (p, t) = (m.positions().to_vec(), m.triangles().to_vec());
225        let big = mesh(
226            &p.iter()
227                .map(|q| [q[0] * 2.0, q[1] * 2.0, q[2] * 2.0])
228                .collect::<Vec<_>>(),
229            &t,
230        );
231        for (&v, &w) in out.iter().zip(run(&big).iter()) {
232            assert!((w - v / 2.0).abs() < 1e-6, "{w} vs {v}");
233        }
234    }
235
236    #[test]
237    fn plane_reads_zero() {
238        let m = plane(4, 0.25);
239        let out = run(&m);
240        for &v in &out {
241            assert!(v.is_finite(), "{out:?}");
242            assert!(v.abs() < 1e-5, "{v}");
243        }
244    }
245
246    #[test]
247    fn saddle_reads_negative() {
248        // The middle reads about -1/4 (the asymmetric bend wins); the rim
249        // uses its one-sided fan and stays finite.
250        let m = saddle(32, 4.0);
251        let c = (16 * 33 + 16) as usize;
252        let out = run(&m);
253        assert!((out[c] + 0.25).abs() < 0.1, "{:?}", out[c]);
254        assert!(out.iter().all(|v| v.is_finite()));
255        assert!(out.iter().any(|&v| v < -0.1));
256    }
257
258    #[test]
259    fn cube_corners_read_positive() {
260        // Sharp convex corners read positive (domes from outside).
261        let m = cube();
262        for &v in &run(&m) {
263            assert!(v.is_finite(), "{v}");
264            assert!(v > 0.3, "{v}");
265        }
266    }
267
268    #[test]
269    fn flipped_winding_negates() {
270        // The sign follows the normals: flipping every triangle negates it.
271        let m = icosphere();
272        let t = m
273            .triangles()
274            .iter()
275            .map(|&[a, b, c]| [a, c, b])
276            .collect::<Vec<_>>();
277        let flipped = mesh(m.positions(), &t);
278        let (a, b) = (run(&m), run(&flipped));
279        for (&x, &y) in a.iter().zip(&b) {
280            assert!((x + y).abs() < 1e-6, "{x} vs {y}");
281        }
282    }
283
284    #[test]
285    fn dead_and_unused_slots_read_nan() {
286        let (mut positions, tris) = cube_buffers();
287        positions.push([5.0, 5.0, 5.0]);
288        let m = mesh(&positions, &tris);
289        assert_eq!(m.vertex_slots(), 9);
290        assert!(m.is_vertex_alive(8));
291        assert_eq!(m.corner_count(8), 0);
292        let mut out = vec![7.0f32; m.vertex_slots()];
293        curvatures(&m, &mut out).unwrap();
294        assert!(out[8].is_nan(), "{out:?}");
295        assert!(out[..8].iter().all(|v| v.is_finite()), "{out:?}");
296    }
297
298    #[test]
299    fn invalid_input_writes_nothing() {
300        let m = cube();
301        let mut out = vec![7.0f32; m.vertex_slots()];
302        assert!(matches!(
303            curvatures(&m, &mut out[..4]),
304            Err(FieldsError::InvalidInput(_))
305        ));
306        assert_eq!(out, vec![7.0; m.vertex_slots()]);
307    }
308}