Tutorials
October 31, 2026
pythoncurvaturetutorial

Fast and Robust Principal Curvatures of a Mesh in Python

Compute principal curvatures, principal directions, Gaussian and mean curvature, and the shape index of a triangle mesh in Python. One pip install, NumPy arrays in and out.

Žiga Sajovic, Polydera

Also available in C++ and JavaScript.

Principal curvatures tell you how a surface bends at each point. k0 is the largest bend, k1 the smallest. From them you get Gaussian curvature, mean curvature, and the shape index. trueform computes all of it per vertex. One pip install. NumPy arrays in, NumPy arrays out. This tutorial covers principal curvatures, principal directions, the shape index, and how to read the signs.

trueform GitHub Documentation Try it live

Install

pip install trueform

See the installation guide for all options.

Loading the mesh

import trueform as tf
import numpy as np

mesh = tf.Mesh(*tf.read_stl("part.stl"))

tf.read_obj works the same way. If you already have NumPy arrays, pass them to tf.Mesh(faces, points). Every function below also takes a plain (faces, points) tuple.

To follow along without a file, make a sphere of radius 2. Its curvature is 1/2 everywhere, so you can check the numbers:

faces, points = tf.make_sphere_mesh(2.0, 100, 200)
mesh = tf.Mesh(faces, points)

Principal curvatures

k0, k1 = tf.principal_curvatures(mesh)
# k0, k1: np.ndarray, shape (num_points,)
# k0 >= k1 at every point

On the sphere both are close to 0.5. Each vertex fits a quadric to its neighborhood. The neighborhood is the k-ring, two rings by default. A larger ring smooths out noise:

k0, k1 = tf.principal_curvatures(mesh, k=3)

Gaussian and mean curvature follow directly:

gaussian = k0 * k1
mean = 0.5 * (k0 + k1)

On the sphere, Gaussian curvature is close to 0.25 and mean curvature close to 0.5.

Principal directions

Ask for the directions too:

k0, k1, d0, d1 = tf.principal_curvatures(mesh, directions=True)
# d0, d1: np.ndarray, shape (num_points, 3)

d0 is the direction of k0, d1 the direction of k1. Both are unit vectors, perpendicular to each other and to the vertex normal. You can check:

n = tf.point_normals(mesh)
np.abs((d0 * d1).sum(axis=1)).max()   # close to 0
np.abs((d0 * n).sum(axis=1)).max()    # close to 0

Directions are useful for drawing hatching along a surface, for aligning tool paths, and for finding ridges and valleys.

Shape index

The shape index folds the two curvatures into one number in -1, 1. It describes the local shape, not its size. A small ball and a large ball have the same shape index.

si = tf.shape_index(mesh)          # shape (num_points,)
si = tf.shape_index(mesh, k=3)     # larger neighborhood

RangeShape
[-1, -5/8)Cup
[-5/8, -3/8)Trough
[-3/8, 3/8)Saddle
[3/8, 5/8)Ridge
5/8, 1Cap

Classify every vertex:

names = ["cup", "trough", "saddle", "ridge", "cap"]
cls = np.digitize(si, [-5/8, -3/8, 3/8, 5/8])
counts = dict(zip(names, np.bincount(cls, minlength=5)))

On the sphere every vertex is a cap.

Reading the signs

Signs follow the face orientation. With outward-facing normals, a convex surface has positive curvature. On the sphere k0 and k1 are positive and the shape index is near 1. Flip the faces and the signs flip with them:

flipped = tf.Mesh(faces[:, ::-1].copy(), points)
tf.shape_index(flipped)   # near -1 on the same sphere

If your results look inverted, check the winding first.

A height field shows all the shapes. Take a flat grid with +z normals and bend it:

faces, points = tf.make_plane_mesh(2.0, 2.0, 100, 100)
x, y = points[:, 0], points[:, 1]

points[:, 2] = x**2 - y**2          # saddle: shape index 0 at the center
saddle = tf.Mesh(faces, points.copy())

points[:, 2] = -(x**2)              # bends away from the normal: ridge, +0.5
ridge = tf.Mesh(faces, points.copy())

points[:, 2] = x**2                 # bends toward the normal: trough, -0.5
trough = tf.Mesh(faces, points.copy())

Working with results

All results are NumPy arrays with one value per point, in the mesh's dtype. Use them as colors, as weights, or as fields for other tools.

Draw the lines where the surface changes from saddle to ridge with isocontours:

paths, curve_points = tf.isocontours(mesh, si, 3/8)

Cut the mesh into regions by shape with isobands:

cuts = np.array([-3/8, 3/8], dtype=si.dtype)
(region_faces, region_points), labels, face_labels = \
    tf.isobands(mesh, si, cuts)
# labels: 0 concave, 1 saddle, 2 convex

Flag sharp spots for inspection:

sharp = np.flatnonzero(np.maximum(np.abs(k0), np.abs(k1)) > 10.0)

Combine curvature with geodesic distances to look only at the neighborhood of a point.

Performance

27.7msPrincipal curvatures1M faces, Python
70×Faster than libigl1M faces, Python (1,941 ms)
51×Faster than libigl1M faces, C++

Apple M4 Max 16 threads

1M facestrueformlibiglSpeedupPython27.7 ms1,941 ms70×C++30.1 ms1,520 ms51×

Full benchmarks · Documentation

Cite as
@article{polydera:principal-curvatures-of-a-mesh-in-python,
  title={Fast and Robust Principal Curvatures of a Mesh in Python},
  author={Sajovic, {\v{Z}}iga, Polydera},
  year={2026},
  url={https://polydera.com/tutorials/principal-curvatures-of-a-mesh-in-python},
  organization={Polydera}
}