Skip to content

Interoperability

Convert a meshio++ Mesh to and from the in-memory objects of the tools you reach for next — without a file round-trip — sharing the underlying numpy buffers wherever the target accepts them as they are.

python
import meshioplusplus as mio

mesh = mio.read("bracket.msh")
grid = mio.to_pyvista(mesh)          # a pyvista.UnstructuredGrid, buffers shared
grid.plot(scalars="T")

Three targets ship today: PyVista, trimesh, and Apache Arrow / Parquet. Open3D and DOLFINx are Phase 2. The same story continues on the GPU handoff page — DLPack export and CuPy transfer, built on this module's payload conventions.

Nothing here is part of the C++ core, which stays dependency-free. This is pure Python over the numpy the readers already return: no format is added, no binding is touched, and every third-party import is lazy.

Installation

ExtraBringsFor
meshioplusplus[pyvista]PyVista (BSD-3-Clause)to_pyvista / from_pyvista
meshioplusplus[trimesh]trimesh (MIT)to_trimesh / from_trimesh
meshioplusplus[arrow]pyarrow (Apache-2.0)to_arrow / write_parquet / …
meshioplusplus[interop]all threeeverything on this page

These are not in meshioplusplus[all]. [all] means "the optional dependencies the formats need" — h5py and netCDF4 — and quietly adding a PyVista-sized dependency there would surprise anyone who installs it to read an XDMF file. [viewer], [kahip] and [codecs] stand apart for the same reason.

Each entry point imports its target inside the function and raises a named error when it is missing, so an install without the extra behaves exactly as before:

ImportError: meshio++: to_pyvista: pyvista is not installed;
             install it with `pip install meshioplusplus[pyvista]`

has_pyvista(), has_trimesh(), has_arrow(), has_open3d() and has_dolfinx() answer the same question without raising.

Architecture: the pure payload layer

src/python/meshioplusplus/_interop.py is split exactly the way _viewer.py is, and for the same reason. The bulk of it is a pure payload layer_to_vtk_payload, _to_triangles_payload, _to_table_payload — which imports no third-party library at all, does not mutate its input, and returns plain numpy plus frozen dataclasses. The public to_* / from_* functions are thin wrappers over it.

That split is what makes the genuinely subtle parts testable with none of the optional libraries installed: block-major cell indexing, which arrays are shared and which are copied, and the routing of non-triangle input through existing operations. tests/python/test_interop.py runs that half in every CI leg; the gated half runs in the dedicated interop job.

Two things are reused rather than transcribed: the meshio↔VTK cell-type map and node orderings come from _vtk_common.py (the same tables the VTU/VTK writers use), and the global block-major cell index comes from _regions.block_bases — the Python owner of the core's detail/cell_index.hpp numbering. Nothing here re-derives a block offset.

The zero-copy contract

A buffer is shared when the target accepts the array as-is: contiguous, supported dtype, right shape. Every to_* takes zero_copy_only:

  • zero_copy_only=False (default) — copies happen, and each is recorded in a notes list surfaced as a warning. Nothing is lost silently.
  • zero_copy_only=True — any step that would copy raises InteropError (a ValueError), naming the array and the reason. pyarrow's own zero_copy_only is the precedent.
python
grid = mio.to_pyvista(mesh, zero_copy_only=True)
# InteropError: meshio++: to_pyvista: connectivity cannot be shared with the
#               target: connectivity dtype int32 is not int64 (VTK's vtkIdType)

What the flag governs

It governs arrays that exist in the Mesh: points, each point_data array, each cell_data array, and — for a single-block mesh — connectivity.

It does not govern derived arrays. VTK's offsets and celltypes have no meshio++ counterpart at all, and a multi-block mesh has no single connectivity array to share, so those are always constructed. Making them fatal would mean the flag rejected every mixed-type mesh, which is the normal case rather than an edge case. Derived arrays are listed on the payload's derived field but are not warnings — they are not a loss, just a difference in data model.

What forces a copy

CauseWhere
connectivity is not int64 (VTK's vtkIdType)PyVista
a wedge block — meshio's node order (the gmsh Prism) differs from VTK'sPyVista
points are not (P, 3); a 2-D mesh is padded with zerosboth
points are not float64trimesh
any array that is not C-contiguous, or is bool (widened to uint8)all
cell_data spanning more than one block (concatenated block-major)derived
the x/y/z columns — strided slices of a row-major points arrayderived

Arrays that cannot be represented at all — object dtype, complex, strings — are dropped with a note, never an exception.

Lifetime

meshio++'s readers hand back capsule-backed numpy owned by the C++ core, so a wrapper that shares a buffer without holding a reference is a use-after-free rather than a wrong answer. Every returned wrapper carries a _meshioplusplus_refs list holding each shared array, so it stays valid after the source mesh is garbage-collected. (VTK's own numpy_to_vtk(deep=0) also attaches a _numpy_reference, but this does not rely on that.) A test converts, deletes the source Mesh, forces gc.collect(), churns the allocator, and re-reads the values.

PyVista

python
grid = mio.to_pyvista(mesh)
mesh2 = mio.from_pyvista(grid)

PyVista ships its own from_meshio, but it targets the upstream meshio package and does not recognize meshioplusplus — hence these functions.

The blocks are turned into the VTK 9 connectivity / offsets / celltypes triple in block order, so mixed-type meshes are the normal case. point_data and cell_data land on grid.point_data / grid.cell_data, the latter concatenated block-major.

meshio++PyVistaNotes
pointsgrid.pointsshared for float32/float64 (P, 3); a 2-D mesh is padded
cell blocksconnectivity/offsets/celltypesin block order; wedge is reordered
ragged polygon blocksVTK_POLYGON with per-row offsetssupported
polyhedron, custom, unmapped typesdropped, warned by name
point_data / cell_datagrid.point_data / grid.cell_datashared where dtype/contiguity allow
Point / Cell regionsregion:<name> int8 masks + a sidecarsee below
Side regionsdropped: VTK has no (cell, local facet) concept
field_data, mesh.info, gmsh_periodicnot carried

Regions

Point and Cell regions become int8 0/1 mask arrays named region:<name> on grid.point_data / grid.cell_data, which is directly colourable in ParaView. A mask alone cannot express a region's dim and tag, so a JSON sidecar rides in grid.field_data["meshioplusplus:regions"]:

json
[{"name": "inlet", "kind": "point", "dim": 2, "tag": 11},
 {"name": "steel", "kind": "cell",  "dim": 3, "tag": 7}]

from_pyvista reconstructs regions exactly when the sidecar is present, so a gmsh physical group's integer tag survives the round-trip. A grid built by something else carries the masks but not the sidecar; its regions come back with dim = tag = -1 and a warning.

A cell region entry landing in a dropped block is removed from the mask, with a note — the global block-major index advances over skipped blocks, so nothing silently lands on the wrong cell.

Side regions are the one asymmetry: VTK has no (cell, local facet) concept, so they are dropped with a warning naming them, and from_pyvista cannot invent them back.

trimesh

python
tm = mio.to_trimesh(mesh)
mesh2 = mio.from_trimesh(tm)

trimesh is triangles only. Non-triangle input is routed through meshio++'s own operations rather than a reimplementation, in this order, each warned about:

  1. volume cells → extract_surface
  2. higher-order 2-D cells → convert_cells("linearize")
  3. quads and polygons → convert_cells("simplexify")
  4. anything still not a triangle (line, vertex) → dropped with a note

payload.ops records exactly which of these ran.

meshio++trimeshNotes
pointstm.verticesshared for float64 (P, 3)
triangle blockstm.facesshared for a single int64 block
point_datatm.vertex_attributes
cell_datatm.face_attributesfor the surviving triangle blocks
color/rgb/*_color/*_rgbtrimesh.visual.ColorVisualsmatched by name, never inferred from a [0, 1] range
Point / Cell regionsregion:<name> masks + tm.metadata sidecaronly what the composed operations preserve
Side regionsdropped

trimesh.Trimesh(..., process=False) is used deliberately: the default process=True merges coincident vertices, which silently renumbers the mesh and would invalidate every index-based attribute.

Regions follow the composed operations' documented behaviour and get no second policy here. In practice: a pure-triangle input keeps its Point/Cell regions; anything routed through extract_surface loses them, because extract_surface drops regions (its output cells and points are newly created and have no correspondence with the input's). convert_cells remaps them.

Arrow and Parquet

python
table = mio.to_arrow(mesh, location="cell")   # a pyarrow.Table
mio.write_parquet(mesh, "data.parquet", location="point")
arrays = mio.read_parquet("data.parquet")     # {name: ndarray}

This is not a mesh format.

It exports point_data / cell_data for analytics — pandas, polars, DuckDB — and does not round-trip geometry. It is deliberately not registered in meshio++'s format registry, so meshioplusplus convert mesh.vtu out.parquet does not work and will not be made to.

Point tablex/y/z (as many as the mesh has) plus one column per point_data array.

Cell tableblock (the block index), cell_type, cell (the global block-major cell index, the same numbering regions and partition_labels use), plus one column per cell_data array, concatenated block-major.

Multi-component arrays

A (N, 3) array becomes a single Arrow fixed_size_list<double>[3] column, not three v_0 / v_1 / v_2 columns. Flattening loses the shape, and from_arrow restores (N, 3) exactly:

python
arrays = mio.from_arrow(mio.to_arrow(mesh))
arrays["v"].shape        # (N, 3)

Schema metadata

The table is self-describing — the metadata survives a Parquet round-trip:

KeyValue
meshioplusplus:versionthe meshio++ version that wrote it
meshioplusplus:locationpoint or cell
meshioplusplus:num_points / :num_cellsthe mesh's counts
meshioplusplus:cell_typesJSON [[type, count], …] in block order
meshioplusplus:regionsJSON [{name, kind, dim, tag}, …]
python
arrays, metadata = mio.read_parquet("data.parquet", return_metadata=True)
metadata["meshioplusplus:cell_types"]   # '[["triangle",1],["quad",1],["tetra",2]]'

Arrow's zero-copy is genuine for numeric buffers, and the implementation asserts it rather than assuming it — note that ChunkedArray.combine_chunks() copies even for a single chunk, so from_arrow takes the chunk directly.

CLI

bash
meshioplusplus data export mesh.vtu out.parquet --location cell

A sub-verb of the data group. It exists in the Python CLI only — the native C++ CLI has no counterpart, since pyarrow is a Python library. Same caveat as above: it exports data, not a mesh.

Phase 2: Open3D and DOLFINx

has_open3d() and has_dolfinx() return False, and to_open3d / to_dolfinx raise NotImplementedError naming the phase. Both slot into the same pure-payload seam; the constraints are recorded here so the design does not have to be rediscovered.

Open3D holds a TriangleMesh (surface) or a TetraMesh (tets), so _to_triangles_payload covers the first and a _to_tetra_payload would cover the second. Two things make it unlike the targets above: Vector3dVector typically copies, so the zero-copy contract has to be stated per structure rather than globally, and the wheel is ~400 MB, which is a lot to put behind an extra people might install by reflex.

DOLFINx is the harder one. It accepts single-cell-type meshes only, needs a ufl/basix domain object alongside the arrays, is built around an MPI communicator rather than a process-local object, and — the actual work — requires a node-ordering permutation from VTK/gmsh ordering to DOLFINx/basix ordering. dolfinx.io.gmshio's cell_perm_gmsh is the reference implementation. A _to_dolfinx_payload would own that permutation, keeping it testable without DOLFINx installed, exactly as the VTK payload is testable without VTK. DOLFINx is conda/apt-only, so it can never be a pip extra and its CI leg cannot be a plain pip install.

Released under the MIT License.