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.
What "broken" means to a slicer
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:
- Watertight: no boundary edges. Every edge of every triangle should be shared by exactly one other triangle.
- Manifold: no edge shared by more than two triangles. If three or more faces meet at an edge, "inside vs outside" stops being well-defined there.
- Consistent winding / single shell where expected: disconnected fragments (common in AI output — dozens of tiny floating shards) confuse a slicer's part detection.
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.
Tier 1: light repair, entirely in the browser
MeshRefinery's free tool runs this in-browser with three.js, no upload required — the file never leaves the client. The pipeline, in order:
- Weld coincident vertices (above).
- Drop degenerate faces — zero-area triangles, detected via the cross-product area test. Harmless but they pollute every downstream step.
- Remove floating fragments — union-find shell detection, drop any shell under ~0.5% of total face count. This is the AI-slop cleanup: keep the model, discard the noise.
- Fan-fill small holes.
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.
Tier 2: when fan-filling isn't enough
For anything harder, the paid path throws away the fan-fill approach entirely and rebuilds the surface from a signed distance field:
-
Generalized winding number (via
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. - Evaluate that at every point on a voxel grid, turn it into a signed distance field (Euclidean distance transform,
outside − inside), Gaussian-smooth it. - Marching cubes over the SDF's zero-isosurface produces a brand-new, watertight, manifold mesh — by construction, not by patching.
- Drop any voxel-noise fragments under the same 0.5% face-count threshold as before, fix normals, and run a few iterations of Taubin smoothing to knock down the voxel staircasing without shrinking the volume the way naive Laplacian smoothing would.
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.
A repair-adjacent problem: color survives the trip too
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.
Where this leaves things
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.
Top comments (0)