cd /news/developer-tools/diagnosing-a-broken-stl-file-union-f… Β· home β€Ί topics β€Ί developer-tools β€Ί article
[ARTICLE Β· art-58989] src=dev.to β†— pub= topic=developer-tools verified=true sentiment=Β· neutral

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

MeshRefinery, a mesh repair and format-conversion tool, addresses the common problem of broken STL files that load visually but fail in slicers. The tool uses an edge-to-face-count map to detect holes and non-manifold edges, and employs a union-find algorithm to identify disconnected components, which often indicate AI-generated mesh debris. A key technique involves welding coincident vertices with a tolerance scaled to the model's bounding box diagonal, enabling accurate boundary loop tracing for hole filling.

read7 min views1 publishedJul 14, 2026

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, 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.

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:

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.

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 and the format converters are free to try.

── more in #developer-tools 4 stories Β· sorted by recency
── more on @meshrefinery 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/diagnosing-a-broken-…] indexed:0 read:7min 2026-07-14 Β· β€”