Principal curvatures
How a mesh bends at every vertex: principal curvatures and directions, Gaussian and mean curvature, the shape index. Which estimator trueform uses, how it holds up on noisy scans, and what to do with the result.
A curvature map turns a mesh into answers. Where are the sharp edges? Where is the dent? Which regions are convex, which are saddles? Each of those starts with two numbers per vertex, the principal curvatures, and with a choice of how to estimate them from flat triangles. This page covers that choice, how trueform's estimator behaves on noisy scans, and what the numbers are good for.

The numbers, in one minute
At a point on a surface, cut with every plane that holds the normal. Each cut is a curve with its own bend. The largest bend is the first principal curvature, k0. The smallest is the second, k1. The directions they occur in, d0 and d1, are perpendicular to each other and to the normal.
Everything else people search for is built from these two:
- Gaussian curvature,
k0 · k1. Positive on a dome or a dent, negative on a saddle, zero on a cylinder or a plane. - Mean curvature,
(k0 + k1) / 2. The average bend. - Shape index, a number from −1 to 1 that names the kind of shape and ignores its size. A marble and a globe have the same shape index.
The signs of the two curvatures name the shape. Gaussian curvature tells only whether they agree. The shape index places the shape on one scale, and the sign patterns below hold at the centre of each class.

The five patches above are synthetic quadrics, one per class, from cup on the left to cap on the right. At the centre of each, trueform reports the shape index the formula predicts: −1, −0.5, 0, 0.5 and 1.
Signs follow the normals. With outward normals, a convex surface is positive. Flip the faces and every sign flips, so a map that looks inside out usually means the winding is.
Which estimator
A triangle is flat. All of a mesh's bending sits at its edges and vertices, so curvature has to be estimated from a neighbourhood. Four families of estimator are common, and they answer different questions.
Angle defect. Sum the angles around a vertex and subtract from 2π. It gives Gaussian curvature only, from the one-ring, with no directions. Cheap, but as local as it gets, so noise goes straight through.
Cotangent Laplacian. The discrete Laplacian of the positions points along the normal, with a length set by mean curvature. Mean curvature only, again from the one-ring.
Per-face tensors. Estimate a curvature tensor on each face from its normals, then average onto vertices. Values and directions, still from a small neighbourhood.
Surface fitting. Fit a small analytic surface to a neighbourhood around each vertex and read its curvature. Both values and both directions, and the size of the neighbourhood is a parameter, so it can reach past one ring.
trueform fits a surface. For each vertex:
- Take the vertex normal, the average of the normals of its faces, and build a tangent frame.
- Gather the k-ring: every vertex within
kedges, two by default. - Express each neighbour in the frame and fit
z = ax² + bxy + cy² + dx + eyby least squares. The linear terms absorb a normal that is slightly off, so the quadratic part reads the bend, not the tilt. - The principal curvatures are the eigenvalues of the fitted shape operator, and the directions are its eigenvectors, mapped back to 3D.
Where the two curvatures are equal, as on a sphere or a plane, every direction is principal; the estimate stays defined and returns a tangent direction instead of noise.
Noise and the k-ring
Scans carry noise. A local estimate reports the noise, not the shape. The k-ring is the one knob against it.
The three pictures below are the head of the Stanford dragon, coloured by shape index. The first is the clean mesh with the default two rings. The second is the same mesh with synthetic scanner noise: every point moved along its normal by Gaussian noise with a standard deviation of a quarter of the mean edge length. At two rings, the classes break into speckle. The third is the noisy mesh at four rings. The scales on the neck, the ridges of the brow and the grooves of the jaw come back.



The ring counts edges, not millimetres. The right k depends on how dense the mesh is against the size of the features you care about. A wider ring averages out noise and also rounds off features smaller than the ring. Try two values on a region you know and compare.
What you will use it for
Feature lines. Ridges and valleys sit where the shape index crosses a class boundary, such as saddle to ridge at 3/8. One isocontour call on the shape index draws them as curves.
Segmentation by shape. Isobands on the shape index cut the mesh into concave, saddle and convex regions. Each region is its own mesh, with every face mapped back to its source face.
Inspection. A dent or a burr bends the surface far more than the design does. Flag every vertex whose larger absolute curvature passes a threshold, and outline the flagged region with an isocontour.

The panel above is synthetic: a quarter cylinder of radius 1 with a dent and a burr pressed in. Its own curvature is 1. The pink outline is where the larger absolute curvature passes 3.
Feature lines and the inspection above, in Python:
k0, k1 = tf.principal_curvatures(mesh)
si = tf.shape_index(mesh)
ridges, ridge_points = tf.isocontours(mesh, si, 0.375) # saddle to ridge
bend = np.maximum(np.abs(k0), np.abs(k1))
flagged = bend > 3.0 # dents and burrs
outline, outline_points = tf.isocontours(mesh, bend, 3.0) # the pink line
Directions. d0 and d1 follow ridges and grooves. Use them to align hatching, tool paths or fibres with the surface.

The strokes are d0, the direction of the larger curvature, on a spaced subset of vertices at three rings. On the body they wrap around it, because the body bends most around its girth. d1 runs along the body. Strokes are drawn only where k0 and k1 clearly differ. Where they are nearly equal, every direction is principal and a stroke would mean nothing.
Local questions. Combine curvature with geodesic distances to look only at the surface within a distance of a point.
Boundaries, creases and flat regions
A quadric fit models a smooth, full patch. Three kinds of vertex are not one, and each has a call that finds it. Run them next to the curvature and combine the results.
Boundary vertices. At an open edge the ring is one-sided, and the fit extrapolates the missing half. Ignore the estimate there. The boundary edges name the vertices.
Sharp creases. A machined edge has no finite curvature. Find the creases by their dihedral angle and set the curvature there to infinity. The faces between the creases keep their fitted values.
auto [k0, k1] = tf::make_principal_curvatures(mesh);
constexpr float inf = std::numeric_limits<float>::infinity();
constexpr float nan = std::numeric_limits<float>::quiet_NaN();
for (auto edge : tf::make_sharp_edges(mesh, tf::deg(30.f)))
for (auto v : edge) k0[v] = k1[v] = inf; // creases: infinite
for (auto edge : tf::make_boundary_edges(mesh))
for (auto v : edge) k0[v] = k1[v] = nan; // boundary: no estimate
import numpy as np
k0, k1 = tf.principal_curvatures(mesh)
edges, angles = tf.dihedral_angles(mesh)
creases = np.unique(edges[angles > np.radians(30)])
k0[creases] = k1[creases] = np.inf # creases: infinite
boundary = np.unique(tf.boundary_edges(mesh))
k0[boundary] = k1[boundary] = np.nan # boundary: no estimate
const { k0, k1 } = tf.principalCurvatures(mesh);
const c0 = k0.data, c1 = k1.data;
for (const v of tf.sharpEdges(mesh, 30).data)
c0[v] = c1[v] = Infinity; // creases: infinite
for (const v of tf.boundaryEdges(mesh).data)
c0[v] = c1[v] = NaN; // boundary: no estimate
The boundary runs last, so a crease that reaches an open edge reads as no estimate there.
Nearly flat regions. The shape index ignores size. On a plane both curvatures are zero and the shape index is 0, the same as a saddle, so on a nearly flat scan the class comes from the noise. Mask by curvedness, sqrt((k0² + k1²) / 2), and read a class only where it passes a threshold.
Uneven meshes. The ring counts edges, so its size follows the local edge length. Where the density varies a lot, remesh to even edges first with isotropic_remeshed, then fit.
Checking the result
Curvature fails silently, so test on shapes with a known answer. On a sphere of radius r, both principal curvatures are 1/r and every vertex is a cap. On a flat grid bent into z = x² − y², the centre is a saddle with shape index 0. z = −x² bends away from a +z normal and gives a ridge at +0.5; z = x² gives a trough at −0.5. The directions should be perpendicular to each other and to the vertex normal, to within rounding. The tutorials run each check in their language.
Principal curvatures in code
Each example computes k0 and k1 at every vertex, then the directions with a wider ring.
#include <trueform/trueform.hpp>
auto mesh_buffer = tf::read_stl("part.stl");
auto mesh = mesh_buffer.polygons();
auto [k0, k1] = tf::make_principal_curvatures(mesh);
// k0, k1: tf::buffer<float>, one value per point; k0 >= k1
auto [c0, c1, d0, d1] = tf::make_principal_directions(mesh);
// d0, d1: tf::unit_vectors_buffer<float, 3>, one direction per point
import trueform as tf
mesh = tf.Mesh(*tf.read_stl("part.stl"))
k0, k1 = tf.principal_curvatures(mesh) # k = 2 rings by default
k0, k1, d0, d1 = tf.principal_curvatures(mesh, k=4, directions=True)
gaussian = k0 * k1
mean = 0.5 * (k0 + k1)
si = tf.shape_index(mesh, k=4)
import * as tf from "@polydera/trueform";
import { readFileSync } from "node:fs";
const mesh = tf.readStl(readFileSync("part.stl"));
const { k0, k1 } = tf.principalCurvatures(mesh); // k = 2 by default
const gaussian = k0.mul(k1);
const { d0, d1 } = tf.principalDirections(mesh, 4); // a wider ring
// d0, d1: NDArray, shape [numberOfPoints, 3]
Results come back with one value per vertex, in the mesh's own type, float or double. In JavaScript every call also has an async form that runs off the main thread.
Speed
Principal curvature values and directions on sphere meshes from 57K to 1.02M faces. Apple M4 Max, 16 threads, trueform 0.10.10.

Every trueform benchmark, by module and language, is on the trueform benchmarks page.
Where to go next
Tutorials. C++, Python and JavaScript. Each covers values, directions, the shape index, signs, and working with the results.
Related operations. Curves and bands of equal value with isocontours. Distance along the surface with geodesic distances.
Documentation. Curvature analysis in the geometry module, in C++, Python and TypeScript.
Curvature is one part of trueform. The same engine does mesh booleans, spatial queries, topology and remeshing, in C++, Python and the browser.
@article{polydera:principal-curvatures,
title={Principal curvatures},
author={Sajovic, {\v{Z}}iga, Polydera},
year={2026},
url={https://polydera.com/algorithms/principal-curvatures},
organization={Polydera}
}
