Skip to content

Developer guide

Building from source

Needs a Rust toolchain, 1.74 or newer (rustup). Then:

pip install .                   # builds release and installs

To work on the code, an editable build that you re-run after any Rust change:

uv sync
uv run maturin develop --release

Always pass --release. maturin develop defaults to debug, which is ~24× slower: 0.147 s to mesh a 128³ volume against 0.006 s.

To produce a wheel:

maturin build --release         # target/wheels/*.whl

abi3-py39 means one wheel per platform covers CPython 3.9 and up.

Layout

The crate is a pipeline, one module per stage. Data flows top to bottom.

module responsibility
src/orient.rs Axis order and handedness, as index arithmetic
src/grid.rs Zero-copy view over the caller's array
src/tables.rs Compile-time cube tables
src/place.rs Where a cell's vertex goes, plus relaxation
src/extract.rs The single pass, and its parallel banding
src/mesh.rs Physical coordinates, triangulation, normals
src/python.rs PyO3 bindings — the only module that knows about Python
python/serra_mesh/ Mesher front end, Mesh container, file formats

Two rules keep this tidy. Orientation is applied once, on output, so the hot loop never thinks about axis order. And Python appears in exactly one module, so the core is testable as plain Rust (cargo test --no-default-features).

The algorithm

A cell is a 2×2×2 block of voxels. A cell whose eight corners are not all the same label contributes a vertex. A quad is dual to a voxel edge whose two voxels differ, and is built from the four cells surrounding that edge.

One traversal is enough

The four cells around the edge leaving voxel p along axis a have minimum corners at p − du·e_u − dv·e_v for du, dv ∈ {0,1}. None exceeds p, so by the time the loop reaches p every cell it needs already exists. Only two cell layers stay live.

On a real 512³ volume with 2524 labels, 16.7% of cells are on a boundary and 84.8% of those carry exactly two labels, so the early-out on uniform cells does most of the work. Cost tracks boundary area, not object count.

Vertex placement

tables::CENTROID maps the 12-bit pattern of which cube edges cross a label boundary to a position, in 1/256-voxel fixed point. Integer positions are what make two chunks agree bit-for-bit on a shared seam: there is no floating-point association order to get wrong.

Manifoldness

An object's dual surface is the boundary of its voxel set. Non-manifold edges and vertices appear exactly where that set touches itself only diagonally. tables::TABLES.split partitions a cell's corners into 6-connected components, and the cell emits one vertex per component, which separates those sheets. At most four components, hit by the two checkerboard masks.

Recording per cell which vertex each corner belongs to makes the quad step a direct lookup — the corner index of p inside each neighbour is known from (du, dv, a) — instead of re-deriving component ids for four cells per quad.

Triangulating a quad

The shorter diagonal wins. The subtlety: a wall between two labels is emitted twice, once per label with the ring reversed, and reversing swaps which index pair names each diagonal. The rule is therefore stated over unordered point pairs, so both copies pick the same diagonal and stay exactly coincident.

Parallelism

The volume is split into bands of cell layers along axis 2, one per worker, each with a private label table.

A band cannot emit its own first layer's quads, since those read the layer below which belongs to the previous band. Rather than duplicating that layer, each band skips them and a serial pass produces them afterwards from the two neighbouring bands' exported layers.

Ordering, not just geometry

That seam pass must splice its quads between the bands they sit between, not append them at the end. Appending produces geometrically correct meshes whose face order depends on the band count — so output would differ between machines with different core counts. This was a real bug during development; tests/test_determinism.py compares fingerprints across eight thread counts to catch a regression.

Testing

cargo test --no-default-features        # tables, grid, orientation, relaxation
uv run maturin develop --release
uv run pytest tests -q                  # everything else
file covers
tests/test_api.py Binding surface, dtypes, serialization
tests/test_analytic_geometry.py Volume, area, normals against exact shapes
tests/test_manifold.py Self-contact, multi-label junctions, banding
tests/test_seams.py The chunked-meshing contract
tests/test_determinism.py Order, dtype, threads, strides
tests/test_relaxation.py Smoothing quality and its deviation bound

Helpers live in tests/conftest.py, including a proper non-manifold vertex check. An edge-only check is not enough: two blocks meeting at a single corner use every edge exactly twice, yet the shared vertex is a pinch point. The link of each vertex must be a single cycle.

Axis conventions in tests

Masks are built with z, y, x = np.ogrid[...], so x varies along array axis 2. Anything not axis-symmetric — an ellipsoid's semi-axes, an analytic normal — must respect that. Getting it wrong once made a correct mesh look like it had a 52° normal error.

Benchmarks

uv sync --group bench                        # pyvista/VTK are not installed by default
python bench/compare_zmesh.py serra          # one implementation per process
python bench/compare_zmesh.py zmesh --zmesh ../zmesh
python bench/render_comparison.py --zmesh ../zmesh --out docs/images

Run each backend in its own process so peak RSS reflects only that implementation.