# Diagnosing a Broken STL File: Union-Find, Boundary Loops, and SDF Voxel Rebuilds

> Source: <https://dev.to/xxzhou/diagnosing-a-broken-stl-file-union-find-boundary-loops-and-sdf-voxel-rebuilds-4a3o>
> Published: 2026-07-14 14:07:24+00:00

A lot of 3D files are quietly broken. Not "won't open" broken — they load fine in a viewer, look like a normal model, and then a slicer either rejects them outright or silently produces garbage: missing walls, phantom internal cavities, infill leaking out through a gap you can't see. This happens constantly with AI-generated meshes and phone LiDAR scans, and it's rare enough with hand-modeled CAD files that most tutorials don't cover it well.

We've spent the last few months building [MeshRefinery](https://meshrefinery.com), a mesh repair and format-conversion tool, and the interesting part turned out to be less "how do you fix a hole in a mesh" and more "how do you even *define* broken, and how do you fix it without a full watertight-reconstruction pipeline when you don't need one." This post walks through both the cheap, entirely-client-side repair pass and the expensive, guaranteed-correct one, plus a color-preservation trick that isn't really about repair at all.

*Originally published on the MeshRefinery blog.*

A mesh is a soup of triangles. A slicer needs it to describe a closed volume — for every point in space, "inside" or "outside" has to be unambiguous. Three properties matter:

You get all of this from one structure: an edge → face-count map.

``` js
function buildEdgeMap(indexArr: ArrayLike<number>) {
  const edgeMap = new Map<string, { count: number; faces: number[] }>();
  const faceCount = indexArr.length / 3;
  for (let f = 0; f < faceCount; f++) {
    const [a, b, c] = [indexArr[f * 3], indexArr[f * 3 + 1], indexArr[f * 3 + 2]];
    for (const [v1, v2] of [[a, b], [b, c], [c, a]] as const) {
      const key = v1 < v2 ? `${v1}_${v2}` : `${v2}_${v1}`;
      const info = edgeMap.get(key) ?? { count: 0, faces: [] };
      info.count++;
      info.faces.push(f);
      edgeMap.set(key, info);
    }
  }
  return edgeMap;
}
```

Count == 1 → boundary edge (a hole). Count > 2 → non-manifold. Count == 2 → fine. Run a union-find over faces connected by manifold edges and you get shell count for free — which doubles as an AI-slop detector: a model with 40+ disconnected components is almost never a real 40-part assembly, it's floating debris from generation noise.

One catch that eats a surprising amount of debugging time: **STL doesn't share vertices**. Every triangle stores three fresh corner points, so two triangles that meet at an edge in space have geometrically-identical-but-distinct vertex indices. Run edge analysis on raw STL data and everything looks like a boundary edge, because nothing is technically shared. You have to weld coincident vertices *first*, within a tolerance — and the tolerance has to scale with the model, not be a fixed epsilon:

``` js
const WELD_TOLERANCE_RATIO = 2e-5; // relative to bbox diagonal
const tolerance = Math.max(diag * WELD_TOLERANCE_RATIO, 1e-6);
```

A fixed epsilon either welds legitimate close-but-distinct vertices on a tiny model, or fails to weld numerically-fuzzy duplicates on a large one. Scaling to the bounding-box diagonal fixes both.

MeshRefinery's free tool runs this in-browser with three.js, no upload required — the file never leaves the client. The pipeline, in order:

Hole filling is the fiddly part. You can't just connect arbitrary boundary vertices — you need the *loop*, walked in the winding order the surrounding surface already uses, so the new triangles come out facing the right way. The trick: build a *directed* boundary-edge map instead of an undirected one. Each boundary edge belongs to exactly one face, so record it in that face's winding direction; the resulting map is a set of `v1 -> v2`

pointers that chain into a loop automatically:

```
function traceLoops(nextMap: Map<number, number>, maxLoopSize: number) {
  const visited = new Set<number>();
  const loops: number[][] = [];
  for (const start of nextMap.keys()) {
    if (visited.has(start)) continue;
    const loop = [start];
    visited.add(start);
    let cur = nextMap.get(start);
    while (cur !== undefined && cur !== start) {
      loop.push(cur);
      visited.add(cur);
      cur = nextMap.get(cur);
    }
    if (cur === start && loop.length >= 3 && loop.length <= maxLoopSize) loops.push(loop);
  }
  return loops;
}
```

Each closed loop under the size cap gets a centroid vertex and a triangle fan. Cheap, and it's honestly all you need for the vast majority of "AI mesh has a few small gaps" cases.

It is explicitly **not** a general watertight guarantee. Fan-filling from a centroid works for small, roughly-planar loops. It does nothing useful for a hole with saddle topology, or one large enough that a flat fan would self-intersect, or a mesh that's non-manifold in ways no amount of hole-filling touches. Being honest about that boundary in the product copy — "this cleans up the common stuff, it will not rescue a genuinely broken mesh" — mattered more to user trust than we expected going in.

For anything harder, the paid path throws away the fan-fill approach entirely and rebuilds the surface from a signed distance field:

`libigl`

) to classify points as inside/outside — this is the part that makes the whole approach robust to non-manifold and disconnected input; unlike ray casting, it degrades gracefully instead of returning garbage on bad topology.`outside − inside`

), Gaussian-smooth it.The tradeoff is real: you lose the original vertex positions and any UV/vertex-color data, because you have an entirely new mesh. So the last step is transferring color back — for every new vertex, find the nearest point on the *original* surface, get its barycentric coordinates within that triangle, and interpolate the original triangle's vertex colors:

```
closest, dist, tri_id = trimesh.proximity.closest_point(src, dst.vertices)
bary = trimesh.triangles.points_to_barycentric(src.triangles[tri_id], closest)
colors = (src_colors[src.faces[tri_id]] * bary[:, :, None]).sum(axis=1)
```

And because "trust me, it's fine" isn't a great answer for something as lossy-sounding as a full SDF rebuild, the API also reports fidelity: sample points on the new surface, measure distance back to the original, express it as a percentage of the model's bounding-box diagonal. Mean error is typically well under 1% of diagonal — small enough not to matter for FDM printing, and now a number instead of a vibe.

This one isn't repair, it's format translation, but it hits the same "the naive version is wrong" pattern. GLB/glTF store color as a PBR base-color *texture*, not per-vertex color — so a plain geometry load silently drops all color. To carry it into a vertex-colored format, bake the texture: sample the base-color image at each vertex's UV coordinate.

``` js
const px = Math.min(w - 1, Math.max(0, Math.floor(u * w)));
const py = Math.min(h - 1, Math.max(0, Math.floor(v * h)));
const p = (py * w + px) * 4;
colors[i * 3] = data[p] / 255;     // R
colors[i * 3 + 1] = data[p + 1] / 255; // G
colors[i * 3 + 2] = data[p + 2] / 255; // B
```

One color-space gotcha buried in there: `baseColorTexture`

is sRGB-encoded and 3MF colors are sRGB hex, so the raw `getImageData`

bytes map straight through with zero conversion — but three.js's flat `material.color`

is stored *linear*, so that path needs an explicit `convertLinearToSRGB()`

or flat-colored meshes come out visibly wrong while textured ones look fine. Easy to miss because it only breaks one of the two code paths.

3MF itself is worth a mention: it's just a zip (an OPC package, the same container format DOCX/XLSX use), holding three tiny XML parts — a content-types manifest, a relationships file, and the actual model. Colors go in an `<m:colorgroup>`

, deduped into a palette and referenced per-vertex-per-triangle — that's the exact structure Bambu Studio, PrusaSlicer, and Cura read to drive full-color/AMS printing.

Two tiers, two very different engineering budgets: a fast, honest, client-side pass that fixes the common cases for free with zero upload, and a heavier server-side SDF rebuild for the cases that actually need a real watertight guarantee — with a measured fidelity number instead of a promise. If you're dealing with broken meshes yourself, both [the diagnosis logic](https://meshrefinery.com/stl-repair) and the [format converters](https://meshrefinery.com/glb-to-3mf) are free to try.
