Tutorials
October 31, 2026
c++isocontourstutorial

Fast and Robust Isocontours on a Triangle Mesh in C++

Extract isocontours (isolines) of any scalar field on a triangle mesh in C++. One level or many at once, plus isobands that cut the mesh along the curves. Header-only, C++17. 5.9 ms on 1M polygons, 40× faster than VTK.

Žiga Sajovic, Polydera

Also available in Python 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. It is header-only C++17. This tutorial covers single and multiple levels, the curve format, and isobands.

trueform GitHub Documentation

Install

Header-only. It needs C++17 and oneTBB. Add it to your project via CMake FetchContent:

FetchContent_Declare(
  trueform
  GIT_REPOSITORY https://github.com/polydera/trueform
)
FetchContent_MakeAvailable(trueform)
target_link_libraries(my_app PRIVATE tf::trueform)

Also available via conan, nuget, and pip. See the installation guide for all options.

Loading the mesh

#include <trueform/trueform.hpp>

auto mesh_buffer = tf::read_stl("part.stl");
auto mesh = mesh_buffer.polygons();

tf::read_stl returns a tf::polygons_buffer that owns the data. .polygons() is a view on it. tf::read_obj works the same way. If your faces and points already live in your own arrays, wrap them with tf::make_polygons(tf::make_faces<3>(faces), tf::make_points<3>(points)). No copy is made. To follow along without a file, tf::make_sphere_mesh(1.f, 60, 80) gives a unit sphere.

The scalar field

A scalar field is one value per point, in the mesh's coordinate type. Most meshes are float, so most fields are float. Any range works; pass it through tf::make_range.

Height is the simplest field:

tf::buffer<float> field;
field.allocate(mesh.points().size());
tf::parallel_transform(mesh.points(), field,
                       [](auto pt) { return pt[2]; });

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

// signed distance to a plane: x = 0
auto plane = tf::make_plane(tf::make_unit_vector(1.f, 0.f, 0.f),
                            tf::make_point(0.f, 0.f, 0.f));
tf::buffer<float> across;
across.allocate(mesh.points().size());
tf::parallel_transform(mesh.points(), across, tf::distance_f(plane));

// geodesic distance from a point
auto along = tf::make_geodesic_distances(mesh, std::vector<int>{0});

// shape index, from curvature
auto shape = tf::make_shape_index(mesh);

One level

auto contour = tf::make_isocontours(mesh, tf::make_range(field), 0.f);

The result is a tf::curves_buffer: points on the surface, and one index path per curve.

for (auto path : contour.paths()) {
  bool closed = path.front() == path.back();  // a closed loop repeats its first id
  for (auto id : path) {
    auto pt = contour.points()[id];
  }
}

Or walk the points of each curve directly:

for (auto curve : contour.curves()) {
  for (auto pt : curve) {
    // points along one curve
  }
}

A curve that runs off the edge of an open mesh ends at the boundary. A curve on a closed region closes on itself. The flat arrays are there when you need to hand them on: contour.paths_buffer().data_buffer().data() and contour.points_buffer().data_buffer().data().

Many levels

Pass a range of levels. One call extracts them all.

std::vector<float> levels{-0.75f, -0.5f, -0.25f, 0.f, 0.25f, 0.5f, 0.75f};
auto contours = tf::make_isocontours(mesh, tf::make_range(field),
                                     tf::make_range(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:

std::vector<tf::curves_buffer<int, float, 3>> by_level;
for (float t : levels)
  by_level.push_back(tf::make_isocontours(mesh, tf::make_range(field), t));

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::embedded_isocurves cuts the mesh along the isocontours, so the curves become edges, and labels each face by band.

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

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

Keep only some bands with tf::make_isobands:

std::array<int, 2> selected{0, 3};
auto [kept, kept_labels, kept_face_labels] = tf::make_isobands(
    mesh, tf::make_range(field), tf::make_range(cuts),
    tf::make_range(selected));

Get the boundary curves in the same call:

auto [cut_mesh, cut_labels, cut_face_labels, curves] = tf::embedded_isocurves(
    mesh, tf::make_range(field), tf::make_range(cuts), tf::return_curves);

Working with results

Split the bands into separate meshes:

auto [parts, part_labels] = tf::split_into_components(bands.polygons(), labels);
// parts[i] is a mesh for band part_labels[i]

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

std::vector<int> material(mesh.faces().size(), 0);   // your per-face data
std::vector<int> band_material(face_labels.size());
for (std::size_t i = 0; i < face_labels.size(); ++i)
  band_material[i] = material[face_labels[i]];

Write to file:

tf::write_obj(bands.polygons(), "bands.obj");
for (std::size_t i = 0; i < parts.size(); ++i)
  tf::write_stl(parts[i].polygons(),
                "band_" + std::to_string(part_labels[i]) + ".stl");

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

We measured on the Stanford dragon at several sizes, against VTK and libigl.

5.9msIsocontours1,025,766 polygons (VTK: 236.2 ms)
11.6msMany levels480,868 polygons (VTK: 336.6 ms)
18.2msEmbedded isobands1,025,766 polygons (VTK: 740.6 ms)

Apple M4 Max 16 threads

OperationMeshtrueformVTKlibiglIsocontours1,025,766 polygons5.9 ms236.2 ms (40×)736.9 ms (124×)Isocontours, many levels480,868 polygons11.6 ms336.6 ms (29×)963.2 ms (83×)Embedded isobands1,025,766 polygons18.2 ms740.6 ms (41×)no equivalentEmbedded isocurves1,025,766 polygons20.6 ms736.3 ms (36×)no equivalent

Full benchmarks · Documentation

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