STL Boolean Studio - boolean cutting for triangle meshes.
Repair-and-boolean pipeline for triangle meshes, built around the workflow of trimming a CATIA/scanner STL against a box: inspect, make watertight, run the boolean, then drop the sliver bodies the boolean leaves behind.
Ships as a Qt desktop app with an embedded 3D viewer, plus a CLI that runs the identical pipeline for scripting and batch work.
Windows / PowerShell:
.\setup.ps1Linux / macOS / Git Bash:
./setup.shEither script creates .venv and installs requirements.txt. Python 3.10+
(developed and tested on 3.11).
.\run.ps1 # GUI
.venv\Scripts\python.exe app.py # GUI, equivalent.venv/bin/python app.py # GUICLI:
.venv/Scripts/python.exe -m stlstudio.cli c2.stl box.stl -o b_minus_a -w cavity.stl-o / the GUI dropdown. A and B are the two loaded meshes, in order.
| key | meaning |
|---|---|
union |
A ∪ B, merge both into one solid |
intersection |
A ∩ B, keep only the overlap — trims A to B |
a_minus_b |
A − B, cut B out of A |
b_minus_a |
B − A, cut A out of B — the cavity / mould case |
b_minus_a is the default because it is the one that needs a tool: cutting a
scanned part out of a stock block.
1. Inspect. Face and vertex counts, watertightness, winding consistency, body count, volume, area, bounds, open edges grouped into boundary loops, and degenerate faces. The GUI flags a mesh that is not ready for a boolean.
2. Repair (core.repair), because manifold3d needs closed solids:
- drop stray disconnected bodies, keeping the largest
- merge coincident vertices, drop degenerate and duplicate faces
- cap every open boundary loop
- orient normals outward
Hole capping projects each boundary loop onto its own best-fit plane (SVD) and
triangulates it with mapbox-earcut, which reuses only existing boundary
vertices. No new points are introduced, so a ragged scan boundary is preserved
exactly rather than flattened. trimesh.repair.fill_holes handles only
triangle- and quad-sized gaps and silently leaves a 2000-vertex loop open,
which is why it is not used here.
3. Boolean via manifold3d through trimesh — about 1 s on 800k faces.
4. Body filtering. See below.
5. Verify. Volume accounting and a watertightness check on the result. The CLI exits non-zero if the result is not watertight.
A boolean between nearly-coincident surfaces leaves detached wafers. On the
reference case, box − c2 returns two bodies: the cavity block, and a
0.2534 mm-thick slab spanning the whole footprint where the box floor sat just
below the part.
Two independent tests catch these, because either alone misses real cases:
- volume — under
--min-volume-fracof the total (default0.01) - thickness — smallest bounding-box dimension under
--min-thickness(default0.5, in model units)
The thickness test is the one that matters. That wafer carries 2.09 % of the volume, so a 1 % volume threshold sails right past it, but at 0.25 mm it is thinner than any nozzle will print.
The largest body is never dropped. Set either threshold to 0 to disable
it, --keep-slivers to disable both, or --keep-largest to ignore the
heuristics and keep only body 0.
In the GUI every body is listed with faces, volume, share and minimum thickness, with the flagged ones pre-unticked — nothing is removed without showing you what and why.
Embedded PyVista/VTK. A is blue, B is orange, result is green. Operands default
to 35 % opacity so you can see through a stock block to the part inside, and are
auto-hidden once a result exists; the show checkboxes bring them back. Left
drag orbits, scroll zooms, middle drag pans.
c2.stl (811,512 faces, open along the bottom, one stray triangle) against
box.stl (12 faces, watertight), after repairing c2 to 68714.218 mm³:
| operation | volume | bodies | note |
|---|---|---|---|
intersection |
65328.578 | 1 | part trimmed to the box |
a_minus_b |
3385.640 | 1 | the trimmed-off rim |
union |
192455.640 | 1 | = 189070.000 + 3385.640 |
b_minus_a |
121151.674 | 1 | cavity, after dropping the wafer |
Reproduce with _tests/smoke_test.py (GUI path, asserts the b_minus_a volume
and body count) or by running the CLI for each op.
.venv/Scripts/python.exe _tests/test_core.py # fast, synthetic cubes, no files needed
.venv/Scripts/python.exe _tests/smoke_test.py # full GUI run on the c2/box reference pairtest_core.py covers the four booleans, hole capping, stray-body removal,
inverted normals and the sliver filter. smoke_test.py skips itself if
_origin/c2.stl is missing.
_origin/box.stl (12 faces) is tracked in the repo. The reference part
c2.stl (~40 MB) and c2_trimmed.stl are git-ignored because of their size;
place your own scanner/CATIA STL in _origin/ to reproduce the results above.
app.py GUI entry point
_tests/ test_core.py (synthetic, fast) and smoke_test.py (GUI end-to-end)
_origin/ sample meshes
requirements.txt
setup.ps1 / setup.sh create .venv and install
run.ps1 launch the GUI from .venv
stlstudio/
core.py analysis, repair, capping, booleans, body filtering (no GUI imports)
gui.py Qt window, worker thread, PyVista viewer
cli.py headless runner
core.py has no GUI dependency — import it directly to build your own batch
scripts:
import trimesh
from stlstudio import core
part = core.repair(trimesh.load("c2.stl", force="mesh"))
stock = trimesh.load("box.stl", force="mesh")
cavity = core.boolean(part, stock, "b_minus_a")
parts = core.bodies(cavity)
keep = [i for i in range(len(parts)) if i not in core.spurious_indices(parts)]
core.keep_bodies(parts, keep).export("cavity.stl")| package | why |
|---|---|
trimesh |
mesh container, IO, boolean front-end |
manifold3d |
the boolean engine |
mapbox-earcut |
hole capping without inventing vertices |
networkx |
boundary-loop tracing |
numpy, scipy |
numerics and trimesh accelerators |
PySide6, qtpy |
GUI toolkit |
pyvista, pyvistaqt |
VTK 3D viewer embedded in Qt |
- Heavy work runs on a
QThread, so the window stays responsive; the log streams progress live. - Repair of an 800k-face mesh takes ~5 s. It is not
trimesh.repair.fix_normalsbound: caps are wound directly from the existing boundary half-edges, which is milliseconds against the ~80 s thatfix_normalscosts on a mesh that size.fix_normalsremains a fallback for genuinely inconsistent winding. - A capped bottom becomes interior surface in a cavity result. If the underside
should stay open, run with
--no-cap— but the boolean then needs closed input by some other route. - Units are whatever the STL carries; STL is unitless, so
--min-thicknessis in the same units as the file (mm for these).
MIT - see LICENSE.