A PyVista accessor for Manifold, a fast and reliable boolean / CSG library for triangle meshes.
Every frame is a real
tpms.manifold.intersection(sphere)against a gyroid iso-surface — the wireframe is the live cutter, the gold is the result. Three function calls build the whole thing:level_setfor the gyroid field,pv.Spherefor the cutter,mesh.manifold.intersectionto combine them.
From left: a machined aluminum bracket built by chaining
unionanddifference; a real mesh intersected with a gyroid TPMS lattice; a cube fractured by repeated plane cuts. The gold sphere lives in the animation above.
Once the package is installed, every pv.PolyData exposes a .manifold accessor. There is nothing to import.
import pyvista as pv
cube = pv.Cube()
sphere = pv.Sphere(radius=0.7, center=(0.4, 0.4, 0.4))
cube.manifold.difference(sphere).plot()PyVista's built-in boolean filters wrap VTK's vtkBooleanOperationPolyDataFilter, which produces non-manifold or self-intersecting output on non-trivial inputs. Manifold solves the same problem with exact arithmetic and topology tracking. This package is the smallest reasonable bridge between the two: a single .manifold accessor that converts on demand, caches the default Manifold conversion on the dataset accessor, and always returns a fresh pv.PolyData.
pip install pyvista-manifoldRequires Python 3.10+ and PyVista 0.48+. The accessor registers itself via PyVista's plugin entry-point system; you don't import the package to use it.
import pyvista as pv
# Boolean ops chain through PyVista's filter pipeline
cube = pv.Cube(x_length=2.0, y_length=2.0, z_length=2.0)
sphere = pv.Sphere(radius=0.9)
diff = cube.manifold.difference(sphere)
print(diff.manifold.volume, diff.manifold.is_valid)
# Drill three orthogonal cylinders out of a cube in one call
holes = [
pv.Cylinder(radius=0.4, height=3, direction=d)
for d in [(1, 0, 0), (0, 1, 0), (0, 0, 1)]
]
from pyvista_manifold import OpType
drilled = cube.manifold.batch_boolean(holes, op=OpType.Subtract)
# Intersect with an iso-surface from a callable scalar field
import math
from pyvista_manifold import level_set
def gyroid(x, y, z):
return -(math.sin(2*x)*math.cos(2*y)
+ math.sin(2*y)*math.cos(2*z)
+ math.sin(2*z)*math.cos(2*x))
iso = level_set(gyroid, bounds=(-2, -2, -2, 2, 2, 2), edge_length=0.1)
infilled = pv.Sphere(radius=1.5).manifold.intersection(iso)
# Anything you build chains naturally with PyVista filters
finished = drilled.clean().smooth(n_iter=20).compute_normals()A worked walkthrough lives in examples/showcase.ipynb: mechanical CSG, TPMS infill of a real mesh, topographic slicing, Voronoi-style fracture, Minkowski filleting.
mesh.manifold is a per-instance accessor that converts the PolyData into a manifold3d.Manifold on demand, caches the default clean=True conversion until the dataset is modified, runs the operation, and converts the result back. The input is left untouched.
mesh.manifold # accessor instance, cached on the dataset
mesh.manifold.to_manifold() # raw manifold3d.Manifold (drop down when needed)
mesh.manifold.<operation>(...) # any method below; always returns pv.PolyDataThe conversion runs pyvista.PolyData.clean() and triangulates the input by default, so PyVista primitives like pv.Cube and pv.Cylinder (which ship with seam-duplicated vertices) work directly. Pass clean=False to mesh.manifold.to_manifold() if you need to preserve every input vertex.
If your mesh isn't a closed manifold solid, the conversion still returns a Manifold, but downstream operations may misbehave. Check mesh.manifold.is_valid (returns True when Manifold's status is NoError).
| Method | Result |
|---|---|
union(other) |
self joined with other |
difference(other) |
self with other subtracted |
intersection(other) |
overlap of self and other |
batch_boolean(others, op=OpType.Add) |
n-ary union, difference, or intersection |
other is either a pv.PolyData or a manifold3d.Manifold. Mixing is fine.
| Method | Notes |
|---|---|
translate(t) |
3-vector |
rotate(r) |
XYZ Euler angles in degrees |
scale(s) |
scalar or 3-vector |
mirror(normal) |
reflect about a plane through the origin |
transform(matrix) |
3x4 column-major affine |
warp(f, batch=False) |
per-vertex callback (or vectorized with batch=True) |
| Method | Notes |
|---|---|
hull() |
convex hull of this mesh's vertices |
hull_with(*others) |
convex hull of this plus other meshes |
| Method | Notes |
|---|---|
refine(n) |
subdivide every edge into n segments |
refine_to_length(length) |
adaptive subdivision until every edge is shorter than length |
refine_to_tolerance(tol) |
refine until geometric error is below tol |
smooth_out(min_sharp_angle=60, min_smoothness=0) |
smooth without explicit normals |
smooth_by_normals(normal_idx) |
smooth using stored vertex normals |
calculate_normals(normal_idx=0) |
compute and store per-vertex normals as point_data['Normals'] |
calculate_curvature(gaussian_idx=0, mean_idx=1) |
store Gaussian + Mean curvature as point arrays |
| Method | Returns |
|---|---|
split(cutter) |
(inside, outside) PolyData pair |
split_by_plane(normal, offset=0) |
(positive, negative) PolyData pair |
trim_by_plane(normal, offset=0) |
the half-space on the side normal points toward |
decompose() |
list of disconnected components |
| Method | Notes |
|---|---|
minkowski_sum(other) |
self offset outward by other (rounded edges) |
minkowski_difference(other) |
self eroded inward by other |
| Method | Returns |
|---|---|
slice_z(z=0) |
closed polylines at height z (PolyData with lines) |
project() |
silhouette projected onto the XY plane (PolyData with lines) |
| Property / method | Returns |
|---|---|
volume |
signed volume |
surface_area |
total surface area |
genus |
topological genus (number of handles) |
bounds |
(xmin, xmax, ymin, ymax, zmin, zmax), matching PyVista order |
num_vert, num_edge, num_tri |
geometry counts after Manifold reconstruction |
is_empty, is_valid, status |
empty check, manifold validity, raw Error enum |
tolerance |
numerical tolerance Manifold is using |
original_id |
Manifold's tracking ID, or -1 |
min_gap(other, search_length) |
closest distance to another solid, capped at search_length |
| Method | Notes |
|---|---|
simplify(tolerance) |
coarsen while keeping geometry within tolerance |
set_tolerance(tol) |
new mesh with updated tolerance |
set_properties(num_prop, f) |
rewrite per-vertex property channels via callback |
as_original() |
mark the result as a fresh original (assigns a new tracking ID) |
compose_with(*others) |
disjointly combine with other meshes (no boolean) |
For things that don't start from an existing mesh:
from pyvista_manifold import level_set, extrude, revolve, hull_points
# Iso-surface from a scalar field
iso = level_set(f, bounds=(xmin, ymin, zmin, xmax, ymax, zmax), edge_length=0.1)
# Extrude / revolve a 2D polygon
solid = extrude(polygons, height, n_divisions=0, twist_degrees=0, scale_top=(1, 1))
solid = revolve(polygons, segments=0, revolve_degrees=360.0)
# Convex hull of a raw point cloud
hull = hull_points(points) # (N, 3) arraypolygons is a single (N, 2) array or a list of such arrays representing a polygon-with-holes set.
For everything that has an obvious PyVista equivalent (pv.Cube, pv.Sphere, pv.Cylinder, etc.), use PyVista directly and chain through .manifold.
The accessor handles conversion automatically. Reach for these only when the accessor isn't enough:
import pyvista as pv
from pyvista_manifold import to_manifold, from_manifold
m = to_manifold(polydata, point_data_keys=['scalar']) # PolyData -> Manifold
poly = from_manifold(m, property_names=['scalar']) # Manifold -> PolyDataPer-vertex point arrays can be passed through Manifold as extra property channels via point_data_keys. Manifold linearly interpolates them across boolean cuts, and from_manifold unpacks them back into point_data.
- Inputs must be manifold solids (closed, non-self-intersecting). Run
pv.PolyData.clean()and checkmesh.manifold.is_validif you're unsure. PyVista's downloaded example meshes vary:download_cow,download_horse,download_armadilloare manifold;download_bunny(the Stanford scan) is not. - All faces are triangulated and merged during conversion. The roundtrip preserves vertex coordinates for triangulated, deduplicated input but does not preserve cell-data arrays.
- Coordinates are
float32inside Manifold. For double precision, callto_manifold().to_mesh64()directly. - Manifold has no built-in I/O. Use PyVista's readers and writers on the resulting PolyData.
git clone https://github.com/pyvista/pyvista-manifold
cd pyvista-manifold
just sync # uv sync --extra dev
just test # pytest with coverage
just lint # pre-commit run --all-files
just typecheck # mypyImage-regression tests run via pytest-pyvista. To re-seed the cache after intentional visual changes:
uv run pytest tests/test_image_regression.py --reset_image_cacheThe hero images at the top of this README are produced by assets/render_hero.py.
- Manifold by Emmett Lalish and contributors.
- PyVista for the accessor system and the rest of the visualization stack.
MIT.






