You know the story: it's late, you slice the model, start the print, go to bed — and wake up to spaghetti, a detached first layer, or a shape that has a hole in it that the slicer silently "fixed" by creating a mess.

Almost every failed print I've had traces back to one of five problems that are detectable before slicing. Here's the checklist I run on every mesh now, with the Python one-liners for each. Total time: about 10 minutes, often less.

All snippets use trimesh (pip install trimesh numpy).

1. Is the mesh watertight?

A mesh with holes will confuse your slicer: it has to guess where the surface is, and some slicers guess badly.

import trimesh m = trimesh.load("part.stl", force="mesh") print(m.is_watertight) # False = the slicer is going to improvise 

If it's not watertight, don't panic — most holes from bad boolean ops or exports can be closed automatically:

from trimesh.repair import fill_holes for _ in range(4): if m.is_watertight: break fill_holes(m); m.merge_vertices(); m.remove_unreferenced_vertices() print(m.is_watertight) 

2. Are the normals consistent — and pointing outward?

Inconsistent winding makes slicers produce walls of zero thickness. Flipped normals make the slicer think your solid is the void.

from trimesh.repair import fix_winding, fix_normals fix_winding(m) # make all faces agree on direction fix_normals(m, multibody=True) # orient normals outward print(m.volume) # must be POSITIVE now; negative = inside-out mesh 

If the volume is negative after this, invert: m.invert().

3. Is the scale sane?

STL is unitless. A model exported in meters prints 1000x too small; one from some CAD tools comes in at 0.001mm and the slicer refuses it or scales it "helpfully".

import numpy as np bbox = m.bounds[1] - m.bounds[0] print([round(v, 2) for v in bbox]) # expecting something like [40.0, 25.0, 12.0] mm 

Rule of thumb: for typical FDM parts, the largest dimension should be between ~5 and ~300 mm. Outside that range, suspect units.

4. How much overhang are you signing up for?

Face normals pointing more than ~45° away from "up" will need support material (or clever orientation). A quick estimate:

up = np.array([0, 0, 1.0]) cos = np.abs(m.face_normals @ up) overhang_area = m.area_faces[cos < 0.707].sum() frac = overhang_area / m.area_faces.sum() print(f"{frac:.1%} of the surface needs support") 

Above ~3–5%: either rotate the part (try several orientations and pick the smallest fraction) or plan for supports. This single check has saved me the most filament.

5. Degenerate faces and duplicate geometry

Exports from CAD boolean operations often contain zero-area faces and duplicated triangles. They don't always break the print, but they make every downstream tool slower and some slicers choke on them.

m.update_faces(~np.isclose(m.area_faces, 0.0)) m.merge_vertices() m.remove_unreferenced_vertices() 

Wrapping it into one command

After running these by hand a dozen times, I packaged the whole checklist into a small CLI: Mesh Rescue — one command prints the full diagnostic report, auto-fixes what's safely fixable (holes, winding, duplicates, orientation), and scores your print-bed orientation with a support estimate. It also refuses to output anything when the mesh is too damaged to trust, rather than producing a silently-bad file.

For the step after slicing, G-code Tuner post-processes G-code (z-hop cleanup, fan speed floors, speed caps) with sanity-checked edits — the kind of tweaks that otherwise mean hand-editing 40,000 lines of G-code.

The honest caveats

  • Automatic hole-filling works for simple gaps; big structural holes need manual repair.
  • The overhang estimate is area-based — it doesn't know about bridges (which are often printable) or tiny cosmetic overhangs (which are fine unsupported).
  • Always dry-run the fixed mesh through your slicer before committing to a long print.

Print the checklist, not the spaghetti. If you end up building anything on top of these snippets, I'd love to hear what you added.