Tutorials
October 31, 2026
javascripttypescripttopologyconnected-componentstutorialwebassembly

Fast Connected Components in JavaScript

Label the connected components of a triangle mesh, split it into separate parts, and find its holes and open edges as boundary loops, in JavaScript and TypeScript. WebAssembly-powered, runs in Node.js and the browser.

Žiga Sajovic, Polydera

Also available in Python and C++.

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 in JavaScript and TypeScript with one npm install. It is WebAssembly-powered and runs in Node.js and the browser with the same API. Typed arrays in, typed 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

npm install @polydera/trueform

The package ships TypeScript types. The WebAssembly binary loads on first use. In the browser, multithreading needs SharedArrayBuffer headers; see the installation guide.

Loading the mesh

import * as tf from "@polydera/trueform";
import { readFileSync } from "node:fs";

const mesh = tf.readStl(readFileSync("scan.stl"));

In the browser, pass the bytes from fetch or a file input. tf.readObj works the same way.

To follow along without a file, build a mesh with three parts: a closed sphere, a closed box, and an open plane. tf.concatenateMeshes applies each part's transformation as it joins them:

const box = tf.boxMesh(1, 1, 1);
box.transformation = tf.makeTranslation(3, 0, 0);

const mesh = tf.concatenateMeshes(
  tf.sphereMesh(1, 32, 32),
  box,
  tf.planeMesh(2, 2, 10, 10));

Labeling connected components

tf.connectedComponents gives every face a component id:

const { labels, nComponents } = tf.connectedComponents(mesh, "edge");
// nComponents: 3
// labels:      NDArrayInt32 [F], one id per face

The second argument picks the connectivity:

TypeFaces are connected when
"edge"they share any edge
"manifoldEdge"they share an edge that has exactly two faces
"vertex"labels vertices instead, connected by shared edges

Use "manifoldEdge" to cut a mesh apart at its non-manifold edges.

Splitting into parts

tf.splitIntoComponents turns the labels into separate meshes:

const { components, labels: ids } = tf.splitIntoComponents(mesh, labels);

for (let i = 0; i < components.length; i++) {
  console.log(`component ${ids.data[i]}: ${components[i].numberOfFaces} faces`);
}

ids is an NDArrayInt32, so read it through .data.

To keep only the largest part, count faces per label and filter by mask:

const counts = tf.bincount(labels);            // faces per component
const largest = counts.argmax();               // its label
const main = tf.reindexedByMask(mesh, labels.eq(largest));

This is the usual way to drop floating debris from a scan.

Finding holes and open edges

A closed mesh has no boundary edges. Check it first:

tf.isClosed(mesh);              // false, the plane is open
tf.isManifold(mesh);            // true
tf.eulerCharacteristic(mesh);   // V - E + F

tf.boundaryEdges lists every edge that has only one face, as vertex pairs:

const edges = tf.boundaryEdges(mesh);   // NDArrayInt32 [N, 2]

Edges with three or more faces are a different problem. tf.nonManifoldEdges(mesh) and tf.nonManifoldVertices(mesh) find those.

Boundary loops

tf.boundaryPaths joins the boundary edges into loops of vertex ids:

const paths = tf.boundaryPaths(mesh);   // OffsetBlockedBuffer

for (const path of paths) {
  const ids = path.data;                // Int32Array of vertex ids
  console.log(`loop of ${ids.length - 1} edges`);
}

A closed loop repeats its first vertex at the end, so a loop of 40 edges holds 41 ids. The loop's points are one gather:

const loopPoints = mesh.points.take(paths.get(0));   // [41, 3]

tf.boundaryRims reads the same boundary with more detail. For each rim it gives the vertex walk, the face that carries each rim edge, and whether the rim closes. Each rim lists every vertex once:

const { vertices, faces, closed } = tf.boundaryRims(mesh);

for (let i = 0; i < vertices.length; i++) {
  const walk = vertices.get(i);       // vertex ids in walk order
  const carriers = faces.get(i);      // one face per rim edge
  const isClosed = closed.data[i] !== 0;
}

Rims are what the hole filler tf.fillHoles takes; see the Fill module docs.

Working with results

The face labels are per-face data. Map them to colors for display, or pass them to the remeshers as preserveRegions to keep the parts apart. To draw the boundary, turn the loops into tubes:

const curves = tf.curves(paths, mesh.points);
const tubes = tf.tubeMesh(curves, 0.01, 8);   // radius, radial segments

tubes is a regular mesh. Its faces.data and points.data go straight into a Three.js BufferGeometry.

Every call here has an async twin under tf.async that runs off the main thread:

const { labels, nComponents } = await tf.async.connectedComponents(mesh, "edge");
const { components } = await tf.async.splitIntoComponents(mesh, labels);
const rims = await tf.async.boundaryRims(mesh);

Performance

We measured connected components on 100 Thingi10K meshes (100,368 to 938,940 faces) and the dragon at five sizes. Each run labels every face by edge connectivity.

20msComponents, 1,025,766 facesLabels for every face, adjacency built included
1.9msComponents, 58,298 facesLabels for every face, adjacency built included
3.2msComponents, typical of 100 meshesGeometric mean

Apple M4 Max 16 threads Node.js 25 · WebAssembly

FacesMeshesComponents100,368–145,418401.86 ms153,248–242,390263.09 ms255,508–498,974165.02 ms587,688–938,940187.45 msDragon, 58,29811.87 msDragon, 141,96613.35 msDragon, 244,02615.92 msDragon, 480,868111.23 msDragon, 1,025,766120.13 ms

The Thingi10K rows are geometric means over the meshes in each size range. The times start from the vertex and index arrays. They include building the mesh and its face adjacency, and copying the labels out as typed arrays. trueform ran on its WebAssembly thread pool of 16 workers. No peer library in JavaScript was timed.

Best of the timed runs after warm-ups, measured 2026-11-01 with trueform 0.10.8; harness and raw results: benchmarks/tutorials/javascript-connected-components.

Full benchmarks · Documentation

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