Tutorials
October 31, 2026
pythonisocontourstutorial

Fast and Robust Isocontours on a Triangle Mesh in Python

Extract isocontours (isolines) of any scalar field on a triangle mesh in Python. One level or many at once, plus isobands that cut the mesh along the curves. One pip install, NumPy arrays in and out.

Žiga Sajovic, Polydera

Also available in C++ and JavaScript.

An isocontour is the curve where a scalar field on a mesh takes a given value. Height gives contour lines. Distance from a point gives rings. Curvature gives the borders between shapes. trueform extracts isocontours of any per-vertex field, at one level or many, and cuts the mesh into isobands along them. One pip install. NumPy arrays in, NumPy arrays out. This tutorial covers single and multiple levels, the curve format, and isobands.

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("terrain.stl"))

tf.read_obj works the same way. If you already have NumPy arrays, pass them to tf.Mesh(faces, points). To follow along without a file, make a small wavy terrain:

faces, points = tf.make_plane_mesh(2.0, 2.0, 200, 200)
x, y = points[:, 0], points[:, 1]
points[:, 2] = 0.2 * np.sin(3 * x) * np.cos(3 * y)
mesh = tf.Mesh(faces, points)

The scalar field

A scalar field is one value per point, as a NumPy array of shape (num_points,). It must have the mesh's dtype. Most meshes are float32, so most fields are float32.

Height is the simplest field:

field = mesh.points[:, 2].copy()

Any field works. A few that come from trueform itself:

# signed distance to a plane: x = 0
plane = tf.Plane(np.array([1, 0, 0, 0], dtype=np.float32))
across = tf.distance(tf.Point(mesh.points), plane)

# geodesic distance from a point
along = tf.geodesic_distances(mesh, [len(mesh.points) // 2])

# shape index, from curvature
shape = tf.shape_index(mesh)

If your field is float64 and the mesh is float32, tf.isocontours raises a TypeError. Convert with field.astype(mesh.points.dtype).

One level

paths, curve_points = tf.isocontours(mesh, field, 0.0)

curve_points is an (n, 3) array of points on the surface. paths holds one index list per curve:

for path_ids in paths:
    curve = curve_points[path_ids]        # (m, 3) points along one curve
    closed = path_ids[0] == path_ids[-1]  # a closed loop repeats its first id

A curve that runs off the edge of an open mesh ends at the boundary. A curve on a closed region closes on itself.

paths is an OffsetBlockedArray. If you want flat arrays, paths.offsets and paths.data give them directly. Curve i is paths.data[paths.offsets[i]:paths.offsets[i + 1]].

Many levels

Pass an array of levels. One call extracts them all.

levels = np.linspace(-0.15, 0.15, 7, dtype=np.float32)
paths, curve_points = tf.isocontours(mesh, field, levels)

The result does not say which level each curve belongs to, and the curves are not in level order. When you need that, call once per level:

by_level = {
    float(t): tf.isocontours(mesh, field, float(t)) for t in levels
}

For height, you can also read the level from the curve itself: every point on a height contour has the level as its z.

Isobands

Isobands are the regions between levels. tf.isobands cuts the mesh along the isocontours, so the curves become edges, and labels each face by band.

cuts = np.array([-0.1, 0.0, 0.1], dtype=np.float32)
(band_faces, band_points), labels, face_labels = \
    tf.isobands(mesh, field, cuts)

Three cuts give four bands. Band 0 is below -0.1, band 3 is above 0.1. labels gives the band of each output face. face_labels gives the original face it came from.

Keep only some bands:

(band_faces, band_points), labels, face_labels = \
    tf.isobands(mesh, field, cuts, selected_bands=[0, 3])

Get the boundary curves in the same call:

(band_faces, band_points), labels, face_labels, (paths, curve_points) = \
    tf.isobands(mesh, field, cuts, return_curves=True)

Working with results

Split the bands into separate meshes:

band_mesh = tf.Mesh(band_faces, band_points)
parts, part_labels = tf.split_into_components(band_mesh, labels)
# parts[i] is a (faces, points) tuple for band part_labels[i]

Carry per-face data from the input to the output with face_labels:

material = np.zeros(len(faces), dtype=np.int32)   # your per-face data
band_material = material[face_labels]

Write to file:

tf.write_obj((band_faces, band_points), "bands.obj")

Contours of height are slices. See mesh slicing and cross-sections for layers and sections. Contours of geodesic distance are rings. Contours of the shape index separate cups, saddles, and caps. To cut one mesh by another mesh instead of by a field, use booleans.

Performance

4.3msIsocontours1,025,766 polygons (VTK: 155 ms)
8.7msMany levels480,868 polygons (VTK: 212.5 ms)
32.9msIsobands1,025,766 polygons (VTK: 527.7 ms)

Apple M4 Max 16 threads

OperationMeshtrueformVTKSpeedupIsocontours1,025,766 polygons4.3 ms155 ms36×Isocontours, many levels480,868 polygons8.7 ms212.5 ms24×Embedded isobands1,025,766 polygons32.9 ms527.7 ms16×

Full benchmarks · Documentation

Cite as
@article{polydera:isocontours-on-a-triangle-mesh-in-python,
  title={Fast and Robust Isocontours on a Triangle Mesh in Python},
  author={Sajovic, {\v{Z}}iga, Polydera},
  year={2026},
  url={https://polydera.com/tutorials/isocontours-on-a-triangle-mesh-in-python},
  organization={Polydera}
}