{"slug": "diagnosing-a-broken-stl-file-union-find-boundary-loops-and-sdf-voxel-rebuilds", "title": "Diagnosing a Broken STL File: Union-Find, Boundary Loops, and SDF Voxel Rebuilds", "summary": "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.", "body_md": "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.\n\nWe'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.\n\n*Originally published on the MeshRefinery blog.*\n\nA 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:\n\nYou get all of this from one structure: an edge → face-count map.\n\n``` js\nfunction buildEdgeMap(indexArr: ArrayLike<number>) {\n  const edgeMap = new Map<string, { count: number; faces: number[] }>();\n  const faceCount = indexArr.length / 3;\n  for (let f = 0; f < faceCount; f++) {\n    const [a, b, c] = [indexArr[f * 3], indexArr[f * 3 + 1], indexArr[f * 3 + 2]];\n    for (const [v1, v2] of [[a, b], [b, c], [c, a]] as const) {\n      const key = v1 < v2 ? `${v1}_${v2}` : `${v2}_${v1}`;\n      const info = edgeMap.get(key) ?? { count: 0, faces: [] };\n      info.count++;\n      info.faces.push(f);\n      edgeMap.set(key, info);\n    }\n  }\n  return edgeMap;\n}\n```\n\nCount == 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.\n\nOne 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:\n\n``` js\nconst WELD_TOLERANCE_RATIO = 2e-5; // relative to bbox diagonal\nconst tolerance = Math.max(diag * WELD_TOLERANCE_RATIO, 1e-6);\n```\n\nA 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.\n\nMeshRefinery's free tool runs this in-browser with three.js, no upload required — the file never leaves the client. The pipeline, in order:\n\nHole 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`\n\npointers that chain into a loop automatically:\n\n```\nfunction traceLoops(nextMap: Map<number, number>, maxLoopSize: number) {\n  const visited = new Set<number>();\n  const loops: number[][] = [];\n  for (const start of nextMap.keys()) {\n    if (visited.has(start)) continue;\n    const loop = [start];\n    visited.add(start);\n    let cur = nextMap.get(start);\n    while (cur !== undefined && cur !== start) {\n      loop.push(cur);\n      visited.add(cur);\n      cur = nextMap.get(cur);\n    }\n    if (cur === start && loop.length >= 3 && loop.length <= maxLoopSize) loops.push(loop);\n  }\n  return loops;\n}\n```\n\nEach 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.\n\nIt 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.\n\nFor anything harder, the paid path throws away the fan-fill approach entirely and rebuilds the surface from a signed distance field:\n\n`libigl`\n\n) 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`\n\n), 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:\n\n```\nclosest, dist, tri_id = trimesh.proximity.closest_point(src, dst.vertices)\nbary = trimesh.triangles.points_to_barycentric(src.triangles[tri_id], closest)\ncolors = (src_colors[src.faces[tri_id]] * bary[:, :, None]).sum(axis=1)\n```\n\nAnd 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.\n\nThis 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.\n\n``` js\nconst px = Math.min(w - 1, Math.max(0, Math.floor(u * w)));\nconst py = Math.min(h - 1, Math.max(0, Math.floor(v * h)));\nconst p = (py * w + px) * 4;\ncolors[i * 3] = data[p] / 255;     // R\ncolors[i * 3 + 1] = data[p + 1] / 255; // G\ncolors[i * 3 + 2] = data[p + 2] / 255; // B\n```\n\nOne color-space gotcha buried in there: `baseColorTexture`\n\nis sRGB-encoded and 3MF colors are sRGB hex, so the raw `getImageData`\n\nbytes map straight through with zero conversion — but three.js's flat `material.color`\n\nis stored *linear*, so that path needs an explicit `convertLinearToSRGB()`\n\nor 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.\n\n3MF 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>`\n\n, 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.\n\nTwo 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.", "url": "https://wpnews.pro/news/diagnosing-a-broken-stl-file-union-find-boundary-loops-and-sdf-voxel-rebuilds", "canonical_source": "https://dev.to/xxzhou/diagnosing-a-broken-stl-file-union-find-boundary-loops-and-sdf-voxel-rebuilds-4a3o", "published_at": "2026-07-14 14:07:24+00:00", "updated_at": "2026-07-14 14:30:29.749407+00:00", "lang": "en", "topics": ["developer-tools", "computer-vision", "ai-tools"], "entities": ["MeshRefinery", "three.js"], "alternates": {"html": "https://wpnews.pro/news/diagnosing-a-broken-stl-file-union-find-boundary-loops-and-sdf-voxel-rebuilds", "markdown": "https://wpnews.pro/news/diagnosing-a-broken-stl-file-union-find-boundary-loops-and-sdf-voxel-rebuilds.md", "text": "https://wpnews.pro/news/diagnosing-a-broken-stl-file-union-find-boundary-loops-and-sdf-voxel-rebuilds.txt", "jsonld": "https://wpnews.pro/news/diagnosing-a-broken-stl-file-union-find-boundary-loops-and-sdf-voxel-rebuilds.jsonld"}}