Algorithms
· Updated
isoscalar-fieldsoverview

Isocontours on a mesh

Contour lines and isobands of any scalar field on a triangle mesh. Height, stress, temperature or distance in; closed, connected curves and band meshes out, in C++, Python and JavaScript.

Žiga Sajovic, Polydera

A contour map starts with one number per point: height on a terrain, stress on a part, temperature on a shell, distance from a seed. You choose the levels. trueform returns the lines where the field crosses each level as connected polylines, and it can cut the mesh into bands between them. The bands come back as meshes, not as coloured pixels. Scroll a stack of slicing planes, the contours of height, through a dragon in the live example.

The terrain above is synthetic: three hills, a saddle between two of them and a hollow, on a 126,000-triangle grid. Its 40 contour levels came from two calls, one for the minor lines and one for every fifth, the index contours. They make 75 curves. The 37 that close on themselves ring the hills and the hollow. The rest run off the edge of the grid.

How a level crosses a triangle

Inside one triangle the field is linear: it blends the three corner values. So a level crosses a triangle in a straight segment, or not at all. Walk the three edges. Where one end is below the level and the other above, the level crosses that edge once, at the fraction t = (level − low) / (high − low) of the way from the low end. A triangle has two such edges or none.

One level, two trianglesone point per crossed edge
Level on a vertexone point, not six

Take the left picture, with made-up values. The top triangle has corners 1, 4 and 6; contour it at 3. The edge from 1 to 6 is crossed 2/5 of the way up, the edge from 1 to 4 at 2/3, and the edge from 4 to 6 not at all. The bottom triangle, with corners 1, 4 and 2, is crossed on the edge it shares with the top one, at the same 2/3, and on the edge from 2 to 4, halfway. Two segments, three points, one curve.

This is marching squares cut down to triangles. On a square cell the four corners can leave a saddle, and the method must guess how the lines join. A linear triangle has no saddle, so there is nothing to guess.

When the level hits a vertex

Terrain heights are often rounded, and simulation results often repeat. Then a level lands exactly on vertex values, and naive code breaks. The vertex is counted once per triangle around it, and the curve doubles, splits or leaves a gap there.

trueform counts an edge as crossing a level when the level lies in the half-open interval from the edge's lower value to its higher one. A level that lands on a vertex is recorded once, at the vertex, and every face around it uses that point, as in the right picture. A vertex that only touches the level, a peak or a pit exactly at it, starts no curve. Round the terrain above to steps of 0.02 and contour it at 0.1: every one of the curve's 804 points is a mesh vertex, and it still comes back as one closed curve.

Curves you can use

A contour is only useful as a curve. You measure its length, offset it, export it to CAD, fill it or hand it to a toolpath. All of that needs ordered points, not a heap of loose segments.

The textbook method visits each triangle and emits its segment on its own. VTK's contour filter returns those segments, and a second filter, vtkStripper, joins them. libigl's isolines returns vertices and edges and leaves the joining to you. On a million faces that is a second problem.

trueform joins them inside the call. The result is one array of curve points and one index list per curve:

  • Closed loops close. A loop repeats its first index at the end, so path[0] == path[-1] tells you it is closed.
  • Open curves end at the boundary. On an open mesh, such as a terrain tile or a scanned patch, a curve that leaves the surface stops at its edge.
  • Every crossing is one point. A crossing point is keyed by its mesh edge and its level, so the two triangles on either side of an edge share it. Curves do not break between triangles.

Pass one level or an array; one call extracts them all, and repeated levels are dropped. Space levels evenly for a map or for layers, use the class limits for categories, and use the distances that matter for rings. A many-level call returns its curves in no particular level order, so when each curve's level matters, as for the heavier index contours above, make one call per level or per style.

Isobands are meshes

Isobands, or filled contours, are the regions between levels. Plotting tools paint them: each pixel gets the colour of its band, and the picture is all you get. VTK's banded contour filter returns polygons per band. trueform cuts the mesh itself. Every triangle the contours cross is split along them, so the contours become edges of the output mesh.

The same terrain, cut at six levels into seven bands. Each band was split off by its label and lifted to show that it is a mesh of its own. Every output triangle carries two numbers:

  • The band label. Six cuts make seven bands: band 0 is below the first cut, band 6 above the last. Split by label to get one mesh per band.
  • The input face. The index of the triangle it was cut from. Use it to move any per-face data onto the bands: material, region, colour.

Each crossed face is re-triangulated by a constrained Delaunay triangulation on an exact integer grid, with the contour pieces as its constraints. Because neighbouring faces share each crossing point, the bands fit back together without cracks.

You can keep only some bands, such as everything above a stress limit:

(faces, points), labels, face_labels = tf.isobands(mesh, stress, [limit], selected_bands=[1])

You can ask for the contour curves in the same call as the bands, so both come from one pass.

Fields worth contouring

The field is a plain array, one value per vertex, in the mesh's float type. Anything that gives a number per vertex works:

  • Height. Contour lines on terrain and scans. Height contours are also slices: see mesh slicing and cross-sections for layers and sections.
  • Simulation results. Stress, strain, temperature or pressure stored per vertex become contour plots, and isobands become the regions above or below a limit.
  • Geodesic distance. Contours of geodesic distance are rings of equal distance along the surface. One band is the patch within a given reach.
  • Curvature. Contours of the shape index from principal curvatures separate cups, saddles and caps.
  • Distance to a plane or a point. Sections and spherical shells.

To cut one mesh by another mesh, not by a field, use mesh booleans.

Isocontours in code

Each example contours height at one level, then cuts the terrain into bands at three. The tutorials cover many levels, selected bands, curves with bands and splitting bands into parts.

#include <trueform/trueform.hpp>

auto mesh_buffer = tf::read_stl("terrain.stl");
auto mesh = mesh_buffer.polygons();
tf::buffer<float> field;                       // height: the z of every point
field.allocate(mesh.points().size());
tf::parallel_transform(mesh.points(), field, [](auto pt) { return pt[2]; });

auto contour = tf::make_isocontours(mesh, tf::make_range(field), 0.f);
for (auto curve : contour.curves())
  for (auto pt : curve) { /* points along one curve */ }

std::array<float, 3> cuts{-0.1f, 0.f, 0.1f};
auto [bands, labels, face_labels] = tf::embedded_isocurves(
    mesh, tf::make_range(field), tf::make_range(cuts));

The field must have the mesh's type. In Python, a float64 field on a float32 mesh raises a TypeError; convert it with astype first. In JavaScript, both calls also have an async form under tf.async.

Preparing the field

  • Per-face values. The field lives on the vertices and is linear across each face. A per-face result, such as element stress from a solver, goes to the vertices first: average it over the faces around each vertex.
  • Values from a coarse simulation mesh. A contour is as fine as the mesh under it, one straight piece per triangle. Contour the mesh the values live on, not a finer display mesh with interpolated values.
  • The field's type. It must match the mesh, float32 with float32, float64 with float64; see the code notes above.

How it compares

What each library hands back, from its documentation and, for VTK and libigl, from the benchmark harness below.

LibraryLanguageContour linesIsobandstrueformC++, Python, JavaScriptjoined curves; closed loops repeat their first pointtriangle meshes, with a band label and the source face per triangleVTKC++, Pythonline segments; vtkStripper joins thempolygons per band, with a band scalarlibiglC++vertices and edges, not joinedno band functionmatplotlibPythonpaths on a flat 2D triangulation, for plottingfilled paths, for plotting

Speed

The 1.03M-face Stanford dragon, 16 levels. Apple M4 Max, 16 threads, trueform 0.10.10.

LanguageContour curves, 16 levelsIsobands, 16 levelsC++4.50 ms (VTK 211 ms, 47×; libigl 713 ms, 159×)8.17 ms (VTK 584 ms, 71×)Python4.37 ms (VTK 156 ms, 36×)18.4 ms (VTK 340 ms, 18×)JavaScript (WebAssembly)7.08 ms (no npm library found for fields on a mesh)11.8 ms (none found)
Language
Isocontours · C++1M faces in 4.50 ms
Isocontours benchmark, C++
Across 16 scalar levels on the 1.03M-face dragon, trueform returns connected contour paths in 4.50 ms: 47× faster than VTK and 159× faster than libigl. VTK and libigl return unconnected line segments; trueform also connects them into paths. Each library’s mesh and the field are built before the clock. trueform and VTK parallel, libigl serial. Apple M4 Max, 16 threads, trueform 0.10.10.
Embedded isocontours · C++1M faces in 8.17 ms
Embedded isocontours benchmark, C++
Embedding 16 scalar levels into the 1.03M-face dragon, trueform returns the band-labeled mesh in 8.17 ms versus 584 ms for VTK: 71× faster. With the connected contour curves also returned, 9.88 ms versus 582 ms: 59×. trueform and VTK parallel. Apple M4 Max, 16 threads, trueform 0.10.10.

Every trueform benchmark, by module and language, is on the trueform benchmarks page.

Further

Tutorials. C++, Python and JavaScript: the field, one level, many levels, isobands and working with the results.

Documentation. The iso module, in C++, Python and TypeScript.

Live. Slicing and cross-section on the Stanford dragon.

Related. Mesh slicing and cross-sections, geodesic distances, principal curvatures, and constrained Delaunay triangulation, which cuts the bands.

Isocontours are one module of trueform. The same library does booleans, spatial queries, topology and remeshing, in C++, Python and the browser.

trueform GitHub Documentation Try it live
Cite as
@article{polydera:isocontours,
  title={Isocontours on a mesh},
  author={Sajovic, {\v{Z}}iga, Polydera},
  year={2026},
  url={https://polydera.com/algorithms/isocontours},
  organization={Polydera}
}