Meshes

The mesh type stores triangulated 3-D surface meshes: cell boundaries from electron microscopy segmentation, brain or organ surfaces from MRI reconstruction, organelle hulls from fluorescence segmentation, or any other closed or open triangulated surface.

ZVF meshes support optional Draco compression for significant size reductions, per-vertex attributes (normals, UV coordinates, scalars), and multi-mesh stores that pack thousands of mesh objects into a single spatially indexed store.

All examples on this page use only the core zarr-vectors API. OBJ/STL/PLY converters live in zarr-vectors-tools; Draco compression requires zarr-vectors[draco].


Writing a mesh

Write from vertices and faces

import numpy as np
from zarr_vectors.types.meshes import write_mesh

# Generate a simple icosphere (demonstration only)
# In practice, load from OBJ, STL, PLY, or a segmentation pipeline
vertices, faces = generate_icosphere(radius=200.0, subdivisions=4)
# vertices: (N, 3) float32   — vertex positions in µm
# faces:    (F, 3) int32     — triangle vertex index triplets (0-indexed, global)

write_mesh(
    "cell.zarrvectors",
    vertices=vertices.astype(np.float32),
    faces=faces.astype(np.int32),
    chunk_shape=(100.0, 100.0, 100.0),
    bin_shape=(25.0, 25.0, 25.0),
    winding_order="ccw",        # counter-clockwise = outward normals
    coordinate_system="RAS",
    axis_units="micrometer",
)

Write with per-vertex attributes

Common mesh attributes include normals, curvature, UV texture coordinates, and scalar overlays (e.g. cortical thickness):

rng = np.random.default_rng(0)
n_verts = len(vertices)

# Compute vertex normals (simplified; use trimesh or open3d in practice)
normals   = compute_vertex_normals(vertices, faces)   # (N, 3) float32
curvature = rng.uniform(-0.1, 0.1, n_verts).astype(np.float32)
thickness = rng.uniform(1.5, 4.5, n_verts).astype(np.float32)

write_mesh(
    "brain_surface.zarrvectors",
    vertices=vertices,
    faces=faces,
    chunk_shape=(10.0, 10.0, 10.0),     # smaller chunks for dense mesh
    bin_shape=(5.0, 5.0, 5.0),
    winding_order="ccw",
    attributes={
        "normal":    normals,           # vector attribute: (N, 3)
        "curvature": curvature,         # scalar attribute: (N,)
        "thickness": thickness,
    },
)

Ingesting from external formats

Format converters for OBJ, STL, and PLY (and the zarr-vectors CLI) live in the companion package zarr-vectors-tools.


Draco compression

Draco is a geometry compression library that exploits vertex-face correlations for significantly better compression than general-purpose codecs on mesh data. Requires zarr-vectors[draco].

Draco compression is enabled when writing a mesh by passing use_draco=True to write_mesh(), or by configuring the mesh codec pipeline directly. Format converters in zarr-vectors-tools accept the same use_draco/draco_quantization keyword arguments.

Compression ratio guidance

draco_quantization

Precision per axis

Typical compression vs float32

8

1 / 256 of bbox

12–18×

11

1 / 2048 of bbox

7–12×

14

1 / 16384 of bbox

4–7×

For nanometre-resolution EM segmentation meshes with a bbox of ~100 µm, 11-bit quantisation gives sub-50 nm precision — more than sufficient for most visualisation and analysis workflows.

# Check if a store uses Draco
from zarr_vectors.core.store import open_store
root = open_store("brain_draco.zarrvectors", mode="r")
print(root.attrs.get("draco_compressed", False))   # True

Important: Draco-compressed stores are not readable without zarr-vectors[draco] installed. Communicate the compression requirement clearly when distributing stores.


Reading a mesh

Read all data

from zarr_vectors.types.meshes import read_mesh

result = read_mesh("brain.zarrvectors")

print(result["vertex_count"])     # int
print(result["face_count"])       # int
print(result["vertices"].shape)   # (N, 3) float32
print(result["faces"].shape)      # (F, 3) int32 — global vertex indices

The returned faces array contains global vertex indices (0-indexed into the vertices array). Face indices are consistent: face k is defined by vertices[faces[k, 0]], vertices[faces[k, 1]], vertices[faces[k, 2]].

Read per-vertex attributes

result = read_mesh("brain_surface.zarrvectors",
                   attributes=["curvature", "thickness"])
curvature = result["attributes"]["curvature"]   # (N,)
thickness = result["attributes"]["thickness"]   # (N,)
normals   = result["attributes"]["normal"]      # (N, 3) if stored

Spatial bbox query

A bbox query returns faces whose centroid is within the bounding box, plus all vertices referenced by those faces (which may lie slightly outside the bbox):

result = read_mesh(
    "brain.zarrvectors",
    bbox=(np.array([0., 0., 0.]), np.array([50., 50., 50.])),
)
print(result["face_count"])      # faces with centroid in the bbox region

Multi-mesh stores

For segmentation datasets with thousands of cell objects, a single ZVF store is far more efficient than per-cell OBJ files:

Writing a multi-mesh store

from zarr_vectors.types.meshes import write_mesh

all_vertices = []    # list of (N_i, 3) arrays
all_faces    = []    # list of (F_i, 3) arrays (local vertex indices)
cell_volumes = []    # per-cell scalar attribute

for cell_id, (verts, faces) in enumerate(cell_meshes):
    all_vertices.append(verts)
    all_faces.append(faces)
    cell_volumes.append(compute_volume(verts, faces))

write_mesh(
    "cells.zarrvectors",
    vertices=all_vertices,      # list of per-object vertex arrays
    faces=all_faces,            # list of per-object face arrays
    chunk_shape=(50., 50., 50.),
    object_attributes={
        "volume":       np.array(cell_volumes, dtype=np.float32),
        "cell_type":    np.array(cell_types,   dtype=np.int32),
    },
)

Reading individual cells

from zarr_vectors.types.meshes import read_mesh

result = read_mesh("cells.zarrvectors", object_ids=[42, 107])
print(result["object_ids"])       # [42, 107]
print(result["vertex_count"])     # combined vertex count

Spatial query in a multi-mesh store

result = read_mesh(
    "cells.zarrvectors",
    bbox=(np.array([500., 500., 200.]),
          np.array([600., 600., 300.])),
    return_object_ids=True,
)
print(result["object_ids"])       # IDs of cells with faces in the region
print(result["face_count"])

Exporting

OBJ and PLY exporters live in the companion package zarr-vectors-tools.


Validation

from zarr_vectors.validate import validate

result = validate("brain.zarrvectors", level=4)
print(result.summary())
# Level 4 validation: PASS
#   29 passed, 0 warnings, 0 errors

For closed surfaces, level 4 additionally checks watertightness (every edge shared by exactly two faces). Enable this check by setting closed_surface = true in root .zattrs:

from zarr_vectors.core.store import open_store

root = open_store("cell.zarrvectors", mode="r+")
root.attrs["closed_surface"] = True
# Now validate(cell.zarrvectors, level=4) checks watertightness

Common pitfalls

Face indices are global, not local. When calling write_mesh with a single mesh, faces must use 0-based indices into the vertices array you are passing. When calling with a list of per-object arrays, each face array uses local indices into its own per-object vertices array; the writer handles global index conversion automatically.

Draco changes vertex positions slightly. Draco quantises vertex positions to integers before compression. Even at the highest precision level (14 bits), there is a small rounding error. Do not use Draco if you need exact float32 round-trip fidelity (e.g. for downstream numerical computation on vertex coordinates). For visualisation, 11-bit quantisation is imperceptible.

Winding order inconsistency between files. Different mesh tools use different winding conventions. ingest_obj defaults to CCW (the OBJ standard) but some exporters produce CW meshes without declaring it. If your rendered normals point inward, pass winding_order="cw" at ingest time or flip normals post-hoc:

ingest_obj("inverted.obj", "inverted.zarrvectors",
           chunk_shape=(10., 10., 10.),
           winding_order="cw")

Boundary face resolution requires fetching extra chunks. A face whose centroid is in chunk A but one vertex is in chunk B requires fetching chunk B to resolve the vertex position. The reader does this automatically, but it means a bbox query may issue slightly more chunk reads than the number of chunks in the bbox. This is expected behaviour.