You finish the CAD, export an STL, upload it for a quote, and go to lunch. Two days later you get an email: "The file isn't watertight and the walls are under our minimum — can you re-export?"
You re-export. Two days after that: "The part is 60 mm long, but the file says 0.06 — is this metres?"
That's four days lost to two problems a computer could have found in one second. This post builds the script that finds them.
Everything here runs on trimesh, is about 150 lines, and exits non-zero on failure so you can drop it straight into CI.
Why STL files break so quietly
STL is the DXF of the 3D world: universally supported, structurally naive, and still what most manufacturing portals want.
The entire format is a flat list of triangles. Each one stores three vertex coordinates and a normal vector. That's it. There is no scene graph, no material, no unit declaration, and — this is the important part — no topology. The file never says "these two triangles share an edge." Adjacency is inferred later by whoever loads it, by matching vertex coordinates.
If you want to confirm that rather than take my word for it, the Library of Congress maintains format descriptions for both the ASCII and binary variants. Worth noting from those pages: nobody has been able to locate a copy of the original 3D Systems specification, so the format is documented mostly by example and convention. That is exactly why so much software disagrees about it.
Three consequences follow, and they're the source of nearly every rejected upload:
A hole is not an error. If your exporter drops a triangle, the file is still perfectly valid STL. It's just no longer a solid, and a slicer or CAM package can't tell what's inside versus outside.
Units don't exist. 60.0 might be 60 mm, 60 cm, or 60 inches. The receiving system guesses, usually by assuming millimetres. If your CAD was set to metres, your 60 mm bracket arrives as a 0.06 mm speck.
Direction is a convention, not a rule. Normals are supposed to point outward. Nothing enforces it. Flip them and the geometry looks identical in a viewer while the slicer tries to fill the entire universe outside your part.
Your CAD package won't warn you about any of this, because inside the CAD package the model is a proper B-rep solid. The damage happens during tessellation on export.
Setup
pip install trimesh numpy rtree embreex
Everything here is trimesh, which needs Python 3.10+ as of the 5.x line. Only numpy is a hard dependency; the other two earn their place:
- rtree backs the spatial index. The fallback ray intersector precomputes an R-tree over the mesh triangles, and without the package the wall-thickness check below raises ModuleNotFoundError.
- embreex provides Intel Embree bindings and swaps in a faster intersector with the same API. Trimesh's own ray example puts the speedup at roughly 50x. Optional, but you want it.
python
`import numpy as np
import trimesh
from trimesh import grouping
mesh = trimesh.load("bracket.stl", force="mesh")`
force="mesh" matters. Some STLs parse as a Scene with multiple geometries, and this flattens them into one Trimesh so the rest of your code has a single type to deal with.
Check 1: is it actually a solid?
The manufacturing term is watertight — every edge is shared by exactly two faces, so the surface fully encloses a volume with no gaps.
trimesh gives you mesh.is_watertight as a boolean — the implementation checks that every edge is included in exactly two faces — but a boolean is useless in a bug report. Count the open edges instead:
python
open_edges = len(grouping.group_rows(mesh.edges_sorted, require_count=1))
edges_sorted lists every edge of every triangle with vertex indices sorted, so the same edge always looks identical regardless of winding direction. Grouping identical rows and keeping only groups of size one leaves you the edges that belong to a single face — the boundary of a hole.
Zero is what you want. Any other number tells you how much repair work is coming, which is far more actionable than False.
While you're here, two related checks:
python
mesh.is_winding_consistent # do neighbouring faces agree on direction?
mesh.volume # negative => normals point inward
The signed volume trick is the cheapest inverted-normal detector there is. It comes from the divergence theorem: integrate over a closed surface whose normals point outward and you get a positive volume. Flip every normal and the same integral comes back negative. One float comparison catches a failure mode that is completely invisible in a preview render.
Note that volume is only meaningful once the mesh is watertight — the API docs are blunt that the surface-integral result is garbage otherwise — so gate it:
python
if mesh.is_watertight and mesh.volume < 0:
fail("normals point inward")
Check 2: degenerate and duplicate triangles
Zero-area triangles come from CAD kernels collapsing tiny features during tessellation. They carry no geometric information, they have undefined normals, and they make some slicers segfault.
python
degenerate = int((mesh.area_faces < 1e-8).sum())
Duplicates usually mean two bodies were exported over the top of each other:
python
unique = grouping.unique_rows(np.sort(mesh.faces, axis=1))[0]
duplicates = len(mesh.faces) - len(unique)
Sorting each row before comparing means [1,2,3] and [2,3,1] — the same triangle, different winding — count as one.
Check 3: units and build volume
This is the dumbest check in the script and it will save you the most time.
python
`extents = mesh.extents # bounding box as [x, y, z]
longest = float(extents.max())
if longest < 1.0:
fail("file is almost certainly in metres or inches, not millimetres")`
Nobody manufactures a part whose longest dimension is under a millimetre. If the bounding box says 0.06, the CAD was in metres. If it says 2.36 for something you know is 60 mm, someone exported in inches.
Fitting inside the machine envelope is the same idea, with one wrinkle — the part can be rotated, so compare sorted dimensions against sorted envelope dimensions:
python
fits = np.all(np.sort(extents) <= np.sort(np.array([250, 250, 300])))
That's an axis-aligned approximation. A long thin part can sometimes be placed diagonally when this says it won't fit, so treat a failure as "call the shop," not "impossible."
Check 4: minimum wall thickness
This is the one nobody automates, and it's the most common rejection after watertightness. Every process has a floor: FDM is limited by nozzle diameter, SLA by resin green-strength, SLS by depowdering access, injection moulding by flow length before the melt freezes off.
Those floors are published, and they're worth reading before you pick a number. Formlabs' wall thickness guide makes the FDM point precisely — with a 0.4 mm nozzle your wall should be a multiple of 0.4, so 1.2 mm beats a nominal 1.0 mm minimum. Protolabs publishes 0.762 mm for SLS and 0.508 mm for MJF. Protolabs Network's thin-wall DFM notes cover what to do when you can't hit the number — ribs at 50–75% of wall thickness, internal fillets of 0.5 mm or more.
There's no thickness attribute on a mesh, so measure it. Fire a ray inward from each face and see how far it travels before it hits the other side, using intersects_location:
python
`def min_wall_thickness(mesh, samples=20000):
idx = np.arange(len(mesh.faces))
if len(idx) > samples:
idx = np.random.default_rng(0).choice(idx, samples, replace=False)
# nudge the origin just inside the surface so we don't re-hit the source face
origins = mesh.triangles_center[idx] - mesh.face_normals[idx] * 1e-4
directions = -mesh.face_normals[idx]
locations, ray_idx, _ = mesh.ray.intersects_location(
origins, directions, multiple_hits=False)
if len(locations) == 0:
return None
return np.linalg.norm(locations - origins[ray_idx], axis=1)`
Three details worth understanding rather than copying:
The 1e-4 nudge. Start the ray exactly on the face and floating-point error means it sometimes immediately intersects the face it started from, returning a thickness of zero. Offsetting inward by a hair along the inverted normal avoids it.
multiple_hits=False. You want the first surface the ray reaches — the opposite side of this wall — not every surface along the whole length of the part.
Sampling. One ray per face is exact but slow on dense meshes. Twenty thousand random faces with a seeded RNG gives a stable answer and keeps the whole script around a second. Seed it, or your CI will produce different numbers on identical input.
The method measures thickness along the surface normal, which is the right definition for a wall and the wrong one for a thin tapering rib, where the true minimum sits off-normal. It's a screen, not a metrology tool.
Putting it together
The full script wires these into a report with FAIL / WARN / PASS per check and an exit code. Running it on a spherical enclosure with a 1.5 mm shell, against a 1.6 mm process minimum:
`$ python stl_check.py enclosure.stl --min-wall 1.6 --build-volume 250,250,300
[ OK ] watertight no boundary edges
[ OK ] winding consistent
[ OK ] normals outward facing
[WARN] bodies 2 disconnected shells - confirm this is intentional
[ OK ] degenerate_faces none
[ OK ] duplicate_faces none
[ OK ] resolution 40,960 triangles
[ OK ] scale 50.0 x 50.0 x 50.0 mm
[ OK ] build_volume fits
[FAIL] wall_thickness 100.0% of sampled faces are under 1.6 mm (thinnest 1.499 mm)
REJECTED`
That run took 1.07 seconds. It found a 0.1 mm shortfall that a human eye cannot see in any viewer, and it found it before the file left the building rather than two days into a quote cycle.
The disconnected-bodies warning is correct and expected here: a hollow shell is genuinely two surfaces, an outer and an inner. That's why it's a WARN, not a FAIL — the check exists to catch stray geometry you forgot to delete, and it can't tell the difference on its own.
Wiring it into CI
If your CAD lives in git — and if you're generating parts from OpenSCAD, CadQuery or Build123d, it should — this becomes a pre-merge gate:
yaml
`name: validate-parts
on:
pull_request:
paths: ['parts/**.stl']
jobs:
stl:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with: { python-version: '3.13' }
- run: pip install trimesh numpy rtree embreex
- name: Validate STLs
run: |
for f in parts/*.stl; do
echo "== $f"
python stl_check.py "$f" --min-wall 1.2 --build-volume 250,250,300
done`
The paths filter uses glob wildcards, so this only fires when an STL actually changes — see the workflow syntax reference. Pin your action versions; actions/setup-python has moved through several majors, and an unpinned action can break the job when its author ships a change.
The loop propagates the failure because the last non-zero exit wins in a bash -e step. If you want every file checked before the job dies, collect exit codes and fail at the end.
Set --min-wall per process, not per project, using the published guidelines above rather than a habit. Ask whoever is making the part.
What this will not catch
Worth being blunt, because a green check mark that means less than you think is worse than no check at all.
- Self-intersections. Two parts of the surface passing through each other. trimesh has no reliable native test. manifold3d is the strongest option — it guarantees manifold output, rejects non-manifold input outright, and interoperates with trimesh directly. PyMeshLab exposes MeshLab's whole filter set from Python if you want repair as well as detection. Self-intersections are common after boolean operations, which is worth knowing since trimesh's own booleans are backed by manifold3d.
- Undercuts and draft angles. Anything moulded needs to release from the tool. That's a directional analysis this script doesn't attempt.
- Tolerances and fits. An STL has no dimensions, no datums, and no GD&T. If a hole must be H7, that information cannot exist in this file and belongs on a drawing or in a STEP file.
- Support-free overhangs, warp risk, sink marks, anisotropy. All process-dependent, all still a conversation with a human.
- Whether the part is any good. It only says the file is manufacturable, not that the design works.
The honest framing: this script eliminates the file problems, which are the boring, repeatable, embarrassing 80%. Design-for-manufacture review is still a conversation with someone who runs the machines every day.
The full script
`#!/usr/bin/env python3
"""stl_check.py - pre-flight validation for STL files headed to a manufacturer."""
import argparse
import json
import sys
import numpy as np
import trimesh
from trimesh import grouping
FAIL, WARN, PASS = "FAIL", "WARN", "PASS"
class Report:
def init(self):
self.checks = []
def add(self, name, status, detail):
self.checks.append({"check": name, "status": status, "detail": detail})
@property
def failed(self):
return any(c["status"] == FAIL for c in self.checks)
def load_mesh(path):
"""Load an STL as a single concatenated Trimesh, or raise."""
obj = trimesh.load(path, force="mesh")
if not isinstance(obj, trimesh.Trimesh) or len(obj.faces) == 0:
raise ValueError("file did not parse into a mesh with faces")
return obj
def check_topology(mesh, rep):
open_edges = len(grouping.group_rows(mesh.edges_sorted, require_count=1))
if open_edges:
rep.add("watertight", FAIL,
f"{open_edges} boundary edges - the mesh has holes and no "
f"defined inside/outside")
else:
rep.add("watertight", PASS, "no boundary edges")
if not mesh.is_winding_consistent:
rep.add("winding", FAIL, "face winding is inconsistent between neighbours")
else:
rep.add("winding", PASS, "consistent")
if mesh.is_watertight and mesh.volume < 0:
rep.add("normals", FAIL,
f"signed volume is negative ({mesh.volume:.2f}) - normals point inward")
else:
rep.add("normals", PASS, "outward facing")
bodies = mesh.body_count
if bodies > 1:
rep.add("bodies", WARN,
f"{bodies} disconnected shells - confirm this is intentional "
f"(hollow part) and not stray geometry")
else:
rep.add("bodies", PASS, "single connected body")
def check_faces(mesh, rep, area_eps=1e-8):
degenerate = int((mesh.area_faces < area_eps).sum())
if degenerate:
rep.add("degenerate_faces", FAIL,
f"{degenerate} zero-area triangles")
else:
rep.add("degenerate_faces", PASS, "none")
dupes = len(mesh.faces) - len(grouping.unique_rows(np.sort(mesh.faces, axis=1))[0])
if dupes:
rep.add("duplicate_faces", WARN, f"{dupes} duplicate triangles")
else:
rep.add("duplicate_faces", PASS, "none")
n = len(mesh.faces)
if n < 100:
rep.add("resolution", WARN,
f"only {n} triangles - curved surfaces will look faceted")
elif n > 2_000_000:
rep.add("resolution", WARN,
f"{n:,} triangles - consider decimating before upload")
else:
rep.add("resolution", PASS, f"{n:,} triangles")
def check_size(mesh, rep, build_volume):
ext = mesh.extents
dims = " x ".join(f"{v:.1f}" for v in ext)
longest = float(ext.max())
if longest < 1.0:
rep.add("scale", FAIL,
f"longest dimension is {longest:.3f} - the file is almost "
f"certainly in metres or inches, not millimetres")
elif longest > 3000:
rep.add("scale", WARN,
f"longest dimension is {longest:.0f} - check the export units")
else:
rep.add("scale", PASS, f"{dims} mm")
if build_volume is not None:
fits = np.all(np.sort(ext) <= np.sort(np.array(build_volume)))
if not fits:
bv = " x ".join(str(v) for v in build_volume)
rep.add("build_volume", FAIL,
f"{dims} mm does not fit in {bv} mm even when rotated")
else:
rep.add("build_volume", PASS, "fits")
def min_wall_thickness(mesh, samples=20000):
"""Ray cast inward from face centroids; the first hit distance is the
local wall thickness."""
idx = np.arange(len(mesh.faces))
if len(idx) > samples:
idx = np.random.default_rng(0).choice(idx, samples, replace=False)
origins = mesh.triangles_center[idx] - mesh.face_normals[idx] * 1e-4
directions = -mesh.face_normals[idx]
locations, ray_idx, _ = mesh.ray.intersects_location(
origins, directions, multiple_hits=False)
if len(locations) == 0:
return None, 0.0
distances = np.linalg.norm(locations - origins[ray_idx], axis=1)
return distances, len(distances) / len(idx)
def check_walls(mesh, rep, min_wall):
if not mesh.is_watertight:
rep.add("wall_thickness", WARN, "skipped - mesh is not watertight")
return
distances, coverage = min_wall_thickness(mesh)
if distances is None:
rep.add("wall_thickness", WARN, "no interior hits; could not measure")
return
thin = int((distances < min_wall).sum())
pct = 100.0 * thin / len(distances)
if thin:
rep.add("wall_thickness", FAIL,
f"{pct:.1f}% of sampled faces are under {min_wall} mm "
f"(thinnest {distances.min():.3f} mm)")
else:
rep.add("wall_thickness", PASS,
f"minimum sampled thickness {distances.min():.2f} mm "
f"(coverage {coverage:.0%})")
def main():
ap = argparse.ArgumentParser(description="Validate an STL before quoting.")
ap.add_argument("path")
ap.add_argument("--min-wall", type=float, default=1.0,
help="minimum acceptable wall thickness in mm")
ap.add_argument("--build-volume", default=None,
help="printer envelope as X,Y,Z in mm e.g. 250,250,300")
ap.add_argument("--json", action="store_true", help="emit JSON")
args = ap.parse_args()
build_volume = None
if args.build_volume:
build_volume = [float(v) for v in args.build_volume.split(",")]
rep = Report()
try:
mesh = load_mesh(args.path)
except Exception as exc:
rep.add("load", FAIL, str(exc))
emit(rep, args.json)
return 1
check_topology(mesh, rep)
check_faces(mesh, rep)
check_size(mesh, rep, build_volume)
check_walls(mesh, rep, args.min_wall)
emit(rep, args.json)
return 1 if rep.failed else 0
def emit(rep, as_json):
if as_json:
print(json.dumps({"ok": not rep.failed, "checks": rep.checks}, indent=2))
return
icon = {FAIL: "FAIL", WARN: "WARN", PASS: " OK "}
for c in rep.checks:
print(f"[{icon[c['status']]}] {c['check']:<18} {c['detail']}")
print("\n" + ("REJECTED" if rep.failed else "READY TO QUOTE"))
if name == "main":
sys.exit(main())`
Two changes worth making before you use it in anger: swap the ray-cast wall check for manifold3d if you also need self-intersection detection, and add a --units flag with an explicit scale factor rather than relying on the heuristic if your team regularly works in inches.
References
Everything above was written against trimesh 5.0.0 on Python 3.12, and every claim is checkable here:
Library and API
trimesh documentation — quick start, trimesh.base (volume, extents, body_count), trimesh.graph (watertightness, winding), ray queries
trimesh on GitHub — read the source when the docs are thin, which for grouping they are
Format
- Library of Congress format descriptions: STL ASCII, STL binary
Going further
- manifold3d — guaranteed-manifold booleans and self-intersection handling
- PyMeshLab — MeshLab's repair filters from Python
- GitHub Actions workflow syntax
If you're building hardware and hitting the wall between "it prints on my desktop machine" and "I need 500 of these in a real engineering material," the DFM conversation is where the actual money is saved — the file checks just stop you wasting a week getting to it.
Written by the engineering team at Zeal 3D Printing, an Australian additive manufacturing and low-volume production service. We wrote this because a rejected file helps nobody. Every one of these failures costs you days and costs us a quote we'd rather be building.
Top comments (0)