Skip to main content

PyVista accessor for the Manifold 3D geometry library, with solid Booleans, hulls, refinement, and more.

Project description

pyvista-manifold

A PyVista accessor for Manifold, a fast and reliable boolean / CSG library for triangle meshes.

rotating gold gyroid TPMS sphere

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_set for the gyroid field, pv.Sphere for the cutter, mesh.manifold.intersection to combine them.

pyvista-manifold examples banner

From left: a machined aluminum bracket built by chaining union and difference; 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()

Why

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.

Install

pip install pyvista-manifold

Requires 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.

Quick start

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.

Gallery

Mechanical CSG. Stack union and difference to build a real-looking part. machined bracket
TPMS lattice infill. Intersect a closed mesh with a gyroid field, the standard 3D-printer infill, computed in two lines. cow with gyroid infill
Topographic slicing. slice_z at many heights stacks into a contour map of the silhouette. horse topographic slices
Iso-surface from a callable. level_set extracts a TPMS surface from a Python function, no marching-cubes plumbing. gyroid TPMS sphere
Voronoi-style fracture. Repeated split_by_plane calls turn a cube into a stack of polyhedral cells. fractured cube

The accessor

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.PolyData

The 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).

Boolean operations

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.

Transforms

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)

Hulls

Method Notes
hull() convex hull of this mesh's vertices
hull_with(*others) convex hull of this plus other meshes

Refinement and smoothing

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

Splits and decomposition

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

Minkowski

Method Notes
minkowski_sum(other) self offset outward by other (rounded edges)
minkowski_difference(other) self eroded inward by other

3D to 2D

Method Returns
slice_z(z=0) closed polylines at height z (PolyData with lines)
project() silhouette projected onto the XY plane (PolyData with lines)

Properties and queries

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

Tolerance, simplification, properties

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)

Module-level helpers

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) array

polygons 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.

Conversion utilities

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 -> PolyData

Per-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.

Caveats

  • Inputs must be manifold solids (closed, non-self-intersecting). Run pv.PolyData.clean() and check mesh.manifold.is_valid if you're unsure. PyVista's downloaded example meshes vary: download_cow, download_horse, download_armadillo are 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 float32 inside Manifold. For double precision, call to_manifold().to_mesh64() directly.
  • Manifold has no built-in I/O. Use PyVista's readers and writers on the resulting PolyData.

Development

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     # mypy

Image-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_cache

The hero images at the top of this README are produced by assets/render_hero.py.

Acknowledgements

  • Manifold by Emmett Lalish and contributors.
  • PyVista for the accessor system and the rest of the visualization stack.

License

MIT.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

pyvista_manifold-0.1.1.tar.gz (3.1 MB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

pyvista_manifold-0.1.1-py3-none-any.whl (19.3 kB view details)

Uploaded Python 3

File details

Details for the file pyvista_manifold-0.1.1.tar.gz.

File metadata

  • Download URL: pyvista_manifold-0.1.1.tar.gz
  • Upload date:
  • Size: 3.1 MB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for pyvista_manifold-0.1.1.tar.gz
Algorithm Hash digest
SHA256 604c52eaa9223801ea5c474b5123ebf2c3015ec0677f7b81748058bf4d39fe72
MD5 d4acbf9f8f6c8c345ac66b2c5478ead9
BLAKE2b-256 0c4cfe1277d15618c4ac1f3102bcfd1a60de1f928d09b1450a0d937ff8c9616f

See more details on using hashes here.

Provenance

The following attestation bundles were made for pyvista_manifold-0.1.1.tar.gz:

Publisher: ci.yml on pyvista/pyvista-manifold

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file pyvista_manifold-0.1.1-py3-none-any.whl.

File metadata

File hashes

Hashes for pyvista_manifold-0.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 00fe815ed6be02b5477f7937c2f71bcb5794a87096f89be38165134a3f36b1ef
MD5 1051fb7641628e66db859f447798e3ba
BLAKE2b-256 302a336052b452815e425f63f81c02d31f66946a80e789b383946713f361fc5a

See more details on using hashes here.

Provenance

The following attestation bundles were made for pyvista_manifold-0.1.1-py3-none-any.whl:

Publisher: ci.yml on pyvista/pyvista-manifold

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page