Tutorials
October 31, 2026
pythontopologyconnected-componentstutorial

Fast Connected Components in Python

Label the connected components of a triangle mesh, split it into separate parts, and find its holes and open edges as boundary loops, in Python. One pip install, NumPy arrays in and out.

Žiga Sajovic, Polydera

Also available in C++ and JavaScript.

A scanned or exported mesh often holds more than one piece, and some of those pieces have holes. Two questions come first: which faces belong together, and where are the open edges. trueform answers both with one pip install. NumPy arrays in, NumPy arrays out. This page covers labeling connected components, splitting a mesh into parts, keeping the largest part, and extracting boundary loops for holes and open edges.

trueform GitHub Documentation

Install

pip install trueform

See the installation guide for all options.

Loading the mesh

import numpy as np
import trueform as tf

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

tf.read_stl returns (faces, points) as NumPy arrays, with duplicate vertices merged so that faces share edges. tf.read_obj works the same way. If you already have arrays, pass them to tf.Mesh(faces, points).

To build a two-part test file without a scan, join a sphere and a moved box:

sphere = tf.Mesh(*tf.make_sphere_mesh(1.0))
box = tf.Mesh(*tf.make_box_mesh(1.0, 1.0, 1.0))

T = np.eye(4, dtype=np.float32)
T[:3, 3] = [3.0, 0.0, 0.0]
box.transformation = T

tf.write_stl(tf.concatenated([sphere, box]), "parts.stl")
mesh = tf.Mesh(*tf.read_stl("parts.stl"))

Labeling connected components

Two faces belong to the same component when a chain of shared edges connects them. mesh.face_link lists, for each face, the faces across its edges. tf.label_connected_components turns that graph into labels:

n_components, labels = tf.label_connected_components(mesh.face_link)
# n_components == 2
# labels.shape == (772,)  int32, one label per face, 0 .. n_components - 1

mesh.face_link is built on first access and cached on the mesh. Face counts per component are one np.bincount:

sizes = np.bincount(labels)   # array([760,  12])

For components that only touch at a vertex to count as one, label the vertex graph instead. The labels are then per vertex:

n_components, vertex_labels = tf.label_connected_components(mesh.vertex_link)

tf.label_connected_components takes any graph, not only meshes: an (N, K) int array of neighbor indices with -1 for no neighbor, or a tf.OffsetBlockedArray for variable neighbor counts. On a graph with many components, more than 500, pass expected_number_of_components so it uses the sequential algorithm.

Splitting into parts

tf.split_into_components groups faces by label and returns one standalone mesh per label, with unused points dropped and indices renumbered:

components, component_labels = tf.split_into_components(mesh, labels)

for (faces, points), label in zip(components, component_labels):
    print(label, faces.shape, points.shape)
# 0 (760, 3) (382, 3)
# 1 (12, 3) (8, 3)

Keep only the largest part, a common clean-up for scans with floating debris:

largest = np.argmax(np.bincount(labels))
index = int(np.flatnonzero(component_labels == largest)[0])

faces, points = components[index]
tf.write_stl((faces, points), "largest.stl")

The labels do not have to come from connectivity. Any per-face int32 array works, for example the labels a mesh boolean returns.

Finding holes and open edges

A boundary edge belongs to only one face. On a closed mesh there are none. Start with a quick check:

faces, points = tf.make_plane_mesh(4.0, 4.0, 8, 8)

centers = points[faces].mean(axis=1)
keep = np.linalg.norm(centers[:, :2], axis=1) > 0.8   # cut a hole in the middle
sheet = tf.Mesh(faces[keep], points)

tf.is_closed(sheet)   # False

tf.boundary_edges returns the open edges as an (N, 2) array of vertex pairs:

edges = tf.boundary_edges(sheet)   # shape (48, 2)

Boundary loops

tf.boundary_paths chains the open edges into loops. Each loop is a list of mesh vertex indices. A closed loop repeats its first vertex at the end:

paths = tf.boundary_paths(sheet)
len(paths)                    # 2: the outer rim and the hole

for path in paths:
    loop = points[path]       # loop coordinates, first point repeated last

tf.boundary_curves returns the same loops as standalone geometry: a points array with only the boundary vertices, and loops that index into it:

paths, curve_points = tf.boundary_curves(sheet)

for ids in paths:
    loop = curve_points[ids]
    perimeter = np.linalg.norm(np.diff(loop, axis=0), axis=1).sum()
    print(len(ids) - 1, "edges, perimeter", perimeter)
# 32 edges, perimeter 16.0
# 16 edges, perimeter 9.656855

On a single sheet, the longest loop is usually the outer rim and the rest are holes. On a closed part, every loop is a hole.

For more detail per loop, tf.boundary_rims returns each rim's vertex walk, the face along each rim edge, and whether the rim closes:

vertices, rim_faces, closed = tf.boundary_rims(sheet)

Working with results

Labels, paths and points are NumPy arrays or tf.OffsetBlockedArray blocks that index like lists. Write a part with tf.write_stl or tf.write_obj. Common next steps:

Performance

The benchmark labels edge-connected faces on 2,000 production meshes of 100,368 to 938,940 triangles, against trimesh. Building the connectivity is inside the timer for both.

2.9msConnected componentsGeometric mean, 2,000 meshes
41.6mstrimeshSame meshes, same task
15×Faster than trimeshGeometric mean wall time

Apple M4 Max 16 threads

There is no published benchmark for boundary loops, so that part of the page makes no speed claim.

Full benchmarks · Documentation

Cite as
@article{polydera:fast-connected-components-in-python,
  title={Fast Connected Components in Python},
  author={Sajovic, {\v{Z}}iga, Polydera},
  year={2026},
  url={https://polydera.com/tutorials/fast-connected-components-in-python},
  organization={Polydera}
}