Skip to main content

ogeom_mesh/
curvature.rs

1//! The curvature of a triangle mesh at its vertices, read from a local
2//! quadric fitted to the vertices about each one.
3//!
4//! # The local quadric
5//!
6//! About a vertex `p`, the neighbourhood is the [`NEIGHBOURS`] vertices
7//! nearest it within `size`, `p` itself among them. Its radius `r` is the
8//! distance to the next vertex out, the first left out, or `size` where
9//! fewer are that near. Each neighbour at distance `d` weighs
10//! `(1 − (d/r)²)²`: one at `p`, falling smoothly to zero at `r`, so a
11//! vertex entering or leaving the neighbourhood as the mesh moves changes
12//! nothing abruptly.
13//!
14//! Each vertex has a normal: the sum of the area normals of the faces at
15//! it, as they wind. The neighbourhood's normal `n` is the weighted sum of
16//! its vertices' normals. Over the plane through `p` normal to `n`, with
17//! coordinates `x`, `y` along two directions in it, the height of each
18//! neighbour along `n` is fitted by a quadric,
19//! `h(x, y) = a + b x + c y + d x² + e xy + f y²`, by weighted least
20//! squares. The fit has linear terms, so a neighbourhood normal off the
21//! surface's by a small angle tilts the frame and not the surface it finds.
22//!
23//! The quadric's surface above `p` is the point `p + a n`. Its curvature
24//! there is that of the graph of `h` at the origin: the shape operator from
25//! the first and second fundamental forms of `h`, exact for the quadric
26//! however the frame is tilted. Its eigenvalues are the principal
27//! curvatures, and its eigenvectors, lifted onto the graph, the principal
28//! directions.
29//!
30//! A neighbourhood whose vertices do not fix the six coefficients, fewer
31//! than six with weight or all on one conic of the plane, has no quadric:
32//! the normal equations are taken as singular where a Cholesky pivot is no
33//! more than the rounding of the largest diagonal entry.
34//!
35//! # Sign
36//!
37//! Curvature is positive where the surface bends away from its outward
38//! normal, as everywhere on a sphere wound outward: a sphere of radius `R`
39//! reads `1/R` for both principal curvatures.
40
41use ogeom_core::{OgeomResult, ogeom_bail};
42use ogeom_math::{Point, Vector};
43use ogeom_topo::Triangulation;
44
45use crate::point_index::{Neighbour, PointIndex};
46
47/// How many vertices nearest each one its local quadric is fitted to.
48///
49/// The quadric has six coefficients; its height at the vertex, which
50/// smoothing moves the vertex to, is drawn from about this many
51/// independent heights, so the noise in them falls about as the square
52/// root of their number.
53pub const NEIGHBOURS: usize = 100;
54
55/// The curvature of a mesh's surface at one vertex.
56#[derive(Debug, Clone, Copy, PartialEq)]
57pub struct Curvature {
58    /// The mean of the principal curvatures.
59    pub mean: f64,
60    /// The product of the principal curvatures.
61    pub gaussian: f64,
62    /// The larger principal curvature.
63    pub k1: f64,
64    /// The smaller principal curvature.
65    pub k2: f64,
66    /// The unit direction along which the surface bends by `k1`.
67    pub dir1: Vector,
68    /// The unit direction along which the surface bends by `k2`: the
69    /// surface normal crossed with `dir1`.
70    pub dir2: Vector,
71}
72
73/// The curvature of `mesh` at each of its vertices, from the local quadric
74/// over the vertices within `size` of it, as the [module
75/// documentation](self) describes. Computed in parallel; the answers are
76/// the same at any thread count.
77///
78/// `None` for a vertex no triangle uses, and for one whose neighbourhood
79/// fixes no quadric. The mesh's own normals, if it has any, are not read:
80/// the faces' winding gives the outward side.
81///
82/// # Errors
83///
84/// [`OgeomError::Numeric`](ogeom_core::OgeomError::Numeric) if `size` is
85/// not positive and finite or a position is not finite;
86/// [`OgeomError::Range`](ogeom_core::OgeomError::Range) if a triangle names
87/// a vertex the mesh does not have.
88pub fn curvature(mesh: &Triangulation, size: f64) -> OgeomResult<Vec<Option<Curvature>>> {
89    check_size(size)?;
90    let count = mesh.positions.len();
91    if let Some(&k) = mesh
92        .triangles
93        .iter()
94        .flatten()
95        .find(|&&k| k as usize >= count)
96    {
97        ogeom_bail!(Range, "a triangle names vertex {k} of {count}");
98    }
99    let normals = vertex_normals(&mesh.positions, mesh.triangles.iter().copied());
100    let members: Vec<u32> = (0..count)
101        .filter(|&v| normals[v].is_some())
102        .map(|v| u32::try_from(v).unwrap_or(u32::MAX))
103        .collect();
104    let cloud = Cloud::build(&mesh.positions, &normals, members)?;
105    let vertices: Vec<u32> = (0..count)
106        .map(|v| u32::try_from(v).unwrap_or(u32::MAX))
107        .collect();
108    Ok(in_parallel(&vertices, |v| {
109        normals[v as usize]?;
110        let p = mesh.positions[v as usize];
111        let near = cloud.near(p, size);
112        cloud.fit(p, &near, |_| true)?.curvature()
113    }))
114}
115
116/// Refuse a neighbourhood size that is not a positive finite length.
117pub(crate) fn check_size(size: f64) -> OgeomResult<()> {
118    if !size.is_finite() || size <= 0.0 {
119        ogeom_bail!(Numeric, "a neighbourhood size of {size} is not a length");
120    }
121    Ok(())
122}
123
124/// `f` over `items` in parallel, in batches, answers in item order.
125pub(crate) fn in_parallel<R: Send>(items: &[u32], f: impl Fn(u32) -> R + Sync) -> Vec<R> {
126    let batches: Vec<&[u32]> = items.chunks(crate::bvh::BATCH).collect();
127    ogeom_core::parallel::map_ordered(&batches, |_, batch| {
128        batch.iter().map(|&v| f(v)).collect::<Vec<R>>()
129    })
130    .into_iter()
131    .flatten()
132    .collect()
133}
134
135/// The unit normal at each vertex: the sum of the area normals of the
136/// faces at it, as they wind; `None` where no face, or none with area,
137/// meets it.
138pub(crate) fn vertex_normals(
139    positions: &[Point],
140    faces: impl Iterator<Item = [u32; 3]>,
141) -> Vec<Option<Vector>> {
142    let mut sum = vec![Vector::new(0.0, 0.0, 0.0); positions.len()];
143    for [a, b, c] in faces {
144        let (pa, pb, pc) = (
145            positions[a as usize],
146            positions[b as usize],
147            positions[c as usize],
148        );
149        let n = (pb - pa).cross(pc - pa);
150        for v in [a, b, c] {
151            sum[v as usize] += n;
152        }
153    }
154    sum.into_iter()
155        .map(|n| {
156            let length = n.magnitude();
157            (length > 0.0 && length.is_finite()).then(|| n / length)
158        })
159        .collect()
160}
161
162/// The vertices a neighbourhood is drawn from, with their normals.
163pub(crate) struct Cloud<'a> {
164    index: PointIndex,
165    /// The vertex at each point of the index.
166    members: Vec<u32>,
167    positions: &'a [Point],
168    normals: &'a [Option<Vector>],
169}
170
171/// A vertex's neighbourhood: the vertices in it, by their slot in the
172/// cloud, and its radius.
173pub(crate) struct Near {
174    pub(crate) found: Vec<Neighbour>,
175    pub(crate) radius: f64,
176}
177
178impl<'a> Cloud<'a> {
179    /// The cloud of `members`, vertices of `positions` with a normal.
180    pub(crate) fn build(
181        positions: &'a [Point],
182        normals: &'a [Option<Vector>],
183        members: Vec<u32>,
184    ) -> OgeomResult<Self> {
185        let points: Vec<Point> = members.iter().map(|&v| positions[v as usize]).collect();
186        Ok(Self {
187            index: PointIndex::build(&points)?,
188            members,
189            positions,
190            normals,
191        })
192    }
193
194    /// The vertex at a slot.
195    pub(crate) fn vertex(&self, slot: u32) -> u32 {
196        self.members[slot as usize]
197    }
198
199    /// The neighbourhood of `p`: the [`NEIGHBOURS`] vertices nearest it
200    /// within `size`, its radius the distance to the next one out, or
201    /// `size` where there is none.
202    pub(crate) fn near(&self, p: Point, size: f64) -> Near {
203        let mut found = self.index.k_nearest(p, NEIGHBOURS + 1, size);
204        let radius = if found.len() > NEIGHBOURS {
205            found.pop().map_or(size, |n| n.distance)
206        } else {
207            size
208        };
209        Near { found, radius }
210    }
211
212    /// The local quadric about `p` over the vertices of `near` that `keep`
213    /// accepts, or `None` where they fix none.
214    pub(crate) fn fit(&self, p: Point, near: &Near, keep: impl Fn(u32) -> bool) -> Option<Local> {
215        let r = near.radius;
216        if r.is_nan() || r <= 0.0 {
217            return None;
218        }
219        let taken: Vec<(Point, Vector, f64)> = near
220            .found
221            .iter()
222            .filter_map(|n| {
223                let v = self.vertex(n.index);
224                let q = n.distance / r;
225                let w = (1.0 - q * q).powi(2);
226                let normal = self.normals[v as usize]?;
227                (q < 1.0 && keep(v)).then(|| (self.positions[v as usize], normal, w))
228            })
229            .collect();
230        let normal = taken
231            .iter()
232            .fold(Vector::new(0.0, 0.0, 0.0), |acc, &(_, n, w)| acc + n * w);
233        let length = normal.magnitude();
234        if length.is_nan() || length <= 0.0 {
235            return None;
236        }
237        let n = normal / length;
238        let (t1, t2) = tangents(n);
239        let mut m = [[0.0; 6]; 6];
240        let mut rhs = [0.0; 6];
241        for &(q, _, w) in &taken {
242            let d = q - p;
243            let (u, v, h) = (d.dot(t1) / r, d.dot(t2) / r, d.dot(n) / r);
244            let row = [1.0, u, v, u * u, u * v, v * v];
245            for i in 0..6 {
246                rhs[i] += w * row[i] * h;
247                for j in 0..=i {
248                    m[i][j] += w * row[i] * row[j];
249                }
250            }
251        }
252        let c = cholesky_solve(&mut m, rhs)?;
253        Some(Local {
254            origin: p,
255            frame: [t1, t2, n],
256            height: c[0] * r,
257            gradient: [c[1], c[2]],
258            hessian: [2.0 * c[3] / r, c[4] / r, 2.0 * c[5] / r],
259        })
260    }
261}
262
263/// Two unit directions making a right-handed frame with the unit `n`.
264fn tangents(n: Vector) -> (Vector, Vector) {
265    let (ax, ay, az) = (n.x.abs(), n.y.abs(), n.z.abs());
266    let axis = if ax <= ay && ax <= az {
267        Vector::new(1.0, 0.0, 0.0)
268    } else if ay <= az {
269        Vector::new(0.0, 1.0, 0.0)
270    } else {
271        Vector::new(0.0, 0.0, 1.0)
272    };
273    let t1 = axis - n * axis.dot(n);
274    let t1 = t1 / t1.magnitude();
275    (t1, n.cross(t1))
276}
277
278/// Solve `m c = rhs` for the symmetric `m`, given by its lower triangle,
279/// by Cholesky; `None` where a pivot is no more than the rounding of the
280/// largest diagonal entry, as for a singular `m`.
281fn cholesky_solve(m: &mut [[f64; 6]; 6], rhs: [f64; 6]) -> Option<[f64; 6]> {
282    let largest = (0..6).map(|i| m[i][i]).fold(0.0_f64, f64::max);
283    let floor = 6.0 * f64::EPSILON * largest;
284    for j in 0..6 {
285        let row = m[j];
286        let pivot = row[..j].iter().fold(row[j], |acc, &x| acc - x * x);
287        if pivot.is_nan() || pivot <= floor {
288            return None;
289        }
290        let root = pivot.sqrt();
291        m[j][j] = root;
292        let above = m[j];
293        for below in &mut m[j + 1..] {
294            let s = below[..j]
295                .iter()
296                .zip(&above[..j])
297                .fold(below[j], |acc, (&a, &b)| acc - a * b);
298            below[j] = s / root;
299        }
300    }
301    let mut y = rhs;
302    for i in 0..6 {
303        for k in 0..i {
304            y[i] -= m[i][k] * y[k];
305        }
306        y[i] /= m[i][i];
307    }
308    for i in (0..6).rev() {
309        for k in i + 1..6 {
310            y[i] -= m[k][i] * y[k];
311        }
312        y[i] /= m[i][i];
313    }
314    y.iter().all(|c| c.is_finite()).then_some(y)
315}
316
317/// A local quadric height field over a vertex's tangent plane.
318pub(crate) struct Local {
319    origin: Point,
320    /// The frame's two tangent directions and its normal.
321    frame: [Vector; 3],
322    /// The quadric's height above the origin.
323    height: f64,
324    /// Its slope there, along the two tangent directions.
325    gradient: [f64; 2],
326    /// Its second derivatives there: `h_xx`, `h_xy`, `h_yy`.
327    hessian: [f64; 3],
328}
329
330impl Local {
331    /// The point of the quadric's surface above the origin, along the
332    /// frame's normal.
333    pub(crate) fn projected(&self) -> Point {
334        self.origin + self.frame[2] * self.height
335    }
336
337    /// The curvature of the quadric's surface above the origin, or `None`
338    /// where it does not come out finite.
339    pub(crate) fn curvature(&self) -> Option<Curvature> {
340        let [t1, t2, n] = self.frame;
341        let [fx, fy] = self.gradient;
342        let [fxx, fxy, fyy] = self.hessian;
343        let w = (1.0 + fx * fx + fy * fy).sqrt();
344        // First and second fundamental forms of the graph, the second
345        // taken against the normal facing away from the frame's, so that a
346        // surface bending away from its normal reads positive.
347        let (e, f, g) = (1.0 + fx * fx, fx * fy, 1.0 + fy * fy);
348        let (l, m, nn) = (-fxx / w, -fxy / w, -fyy / w);
349        let det = e * g - f * f;
350        // The shape operator: the first form's inverse times the second.
351        let a = (g * l - f * m) / det;
352        let b = (g * m - f * nn) / det;
353        let c = (e * m - f * l) / det;
354        let d = (e * nn - f * m) / det;
355        let mean = 0.5 * (a + d);
356        let gaussian = (l * nn - m * m) / det;
357        // Self-adjoint under the first form, the operator has real
358        // eigenvalues; rounding can leave the discriminant just below zero.
359        let spread = (mean * mean - gaussian).max(0.0).sqrt();
360        let (k1, k2) = (mean + spread, mean - spread);
361        // An eigenvector for `k1` in the plane's coordinates: the larger of
362        // the two the operator's rows give.
363        let (p, q) = ((b, k1 - a), (k1 - d, c));
364        let (u, v) = if p.0.hypot(p.1) >= q.0.hypot(q.1) {
365            p
366        } else {
367            q
368        };
369        let (u, v) = if u == 0.0 && v == 0.0 {
370            (1.0, 0.0)
371        } else {
372            (u, v)
373        };
374        let lifted = t1 * u + t2 * v + n * (fx * u + fy * v);
375        let dir1 = lifted / lifted.magnitude();
376        let normal = (n - t1 * fx - t2 * fy) / w;
377        let dir2 = normal.cross(dir1);
378        let dir2 = dir2 / dir2.magnitude();
379        let all = [
380            mean, gaussian, k1, k2, dir1.x, dir1.y, dir1.z, dir2.x, dir2.y, dir2.z,
381        ];
382        all.iter().all(|x| x.is_finite()).then_some(Curvature {
383            mean,
384            gaussian,
385            k1,
386            k2,
387            dir1,
388            dir2,
389        })
390    }
391}