Skip to content

Spatial Layout

mudm.layout arranges the features of a collection so they sit side-by-side without overlapping — perfect for previewing or exporting many objects (cells, neurons, regions) together instead of stacking them on top of each other at their original coordinates.

Layout never changes the shape of a feature. It only translates geometries in X/Y/Z. Because muDM geometries are GeoJSON-compatible, a laid-out collection is still valid GeoJSON and still valid muDM — backwards compatibility holds end to end.

When to use this

Microscopy features are usually stored at their acquired coordinates, so many of them overlap when rendered together (every cell segmented from one field of view shares the same origin region, for example). Layout spreads them apart in a row or on a grid so you can:

  • preview a whole collection in a viewer without objects colliding,
  • export an arranged scene to glTF, Neuroglancer, or Arrow/Parquet,
  • build figure-ready "contact sheets" of many spatial objects.

The two building blocks are:

  • compute_collection_offsets — figures out the (dx, dy, dz) translation for each feature without touching geometry.
  • apply_layout — runs the same computation and returns a new collection with the geometries actually translated. The original is left untouched.

Both rely on geometry_bounds to measure each feature.

Layout vs. tiling

Layout is a lightweight, in-memory rearrangement for viewing moderate collections. For multi-resolution tile pyramids over large datasets — and for turning a laid-out scene into glTF, Neuroglancer, or Parquet — use the processing pipelines in the separate mudm-tools package. See 3D tiling and GeoParquet & glTF.

The public surface

Symbol Import Returns
geometry_bounds from mudm import geometry_bounds 3D bounding box, or None
apply_layout from mudm import apply_layout a new MuDMFeatureCollection
compute_collection_offsets from mudm.layout import compute_collection_offsets list[(dx, dy, dz)]

compute_collection_offsets lives in the submodule

geometry_bounds and apply_layout are part of the top-level public API (from mudm import ...). compute_collection_offsets is imported from the submodule: from mudm.layout import compute_collection_offsets.

Measuring a feature: geometry_bounds

geometry_bounds(geom) returns a 3D bounding box in OGC/GeoJSON ordering:

(xmin, ymin, zmin, xmax, ymax, zmax)

It returns None for None or empty geometry. For muDM 3D surface types (TIN, PolyhedralSurface) it uses their bbox3d() method; for ordinary GeoJSON geometries it recursively walks the nested coordinate arrays. Missing Y or Z components default to 0.0.

from geojson_pydantic import Point, Polygon
from mudm import TIN, geometry_bounds

# A 3D point — min == max on every axis
geometry_bounds(Point(type="Point", coordinates=(1.0, 2.0, 3.0)))
# -> (1.0, 2.0, 3.0, 1.0, 2.0, 3.0)

# A 2D polygon — Z defaults to 0.0
geometry_bounds(
    Polygon(type="Polygon", coordinates=[[(0, 0), (10, 0), (10, 5), (0, 5), (0, 0)]])
)
# -> (0.0, 0.0, 0.0, 10.0, 5.0, 0.0)

# A 3D TIN — real Z extent
tin = TIN(type="TIN", coordinates=[[[(0, 0, 0), (10, 0, 0), (5, 10, 5), (0, 0, 0)]]])
geometry_bounds(tin)
# -> (0.0, 0.0, 0.0, 10.0, 10.0, 5.0)

geometry_bounds(None)  # -> None

Computing offsets: compute_collection_offsets

compute_collection_offsets(features, spacing=None, grid_max_x=None, grid_max_y=None, grid_max_z=None) returns a list of (dx, dy, dz) tuples, one per feature, in source coordinates. It modifies nothing — it just tells you where each feature would move.

Two invariants always hold:

  • The first feature stays put. Its offset is always (0.0, 0.0, 0.0); every other feature is placed relative to it.
  • No arguments means no movement. With spacing=None and no grid parameters, every offset is zero — coordinates are kept exactly as-is.
from mudm import TIN
from mudm.model import MuDMFeature
from mudm.layout import compute_collection_offsets


def tin_at(x_start, x_end):
    """A small TIN spanning x_start..x_end along X."""
    mid = (x_start + x_end) / 2
    return TIN(
        type="TIN",
        coordinates=[[[(x_start, 0, 0), (x_end, 0, 0), (mid, 10, 5), (x_start, 0, 0)]]],
    )


features = [
    MuDMFeature(type="Feature", geometry=tin_at(0, 100), properties=None),
    MuDMFeature(type="Feature", geometry=tin_at(0, 80), properties=None),
]

# Default: no layout requested -> everything stays where it is
compute_collection_offsets(features)
# -> [(0.0, 0.0, 0.0), (0.0, 0.0, 0.0)]

The spacing argument

spacing controls the gap between features:

spacing value Behavior
None (default) No layout — all offsets zero (unless a grid is requested).
0.0 Auto gap: 20% of the largest feature extent.
> 0 Fixed gap (row) or fixed center-to-center cell size (grid).

Row layout (side-by-side along X)

When you pass spacing (and no grid_max_*), features are laid out in a single row along the X axis. Each feature is shifted so its xmin lands at the running cursor plus the gap; the cursor then advances to that feature's new xmax.

With spacing=0.0, the gap is auto-computed as 20% of the widest feature:

# spacing=0.0 -> auto gap; second feature is pushed past the first's xmax (100)
offsets = compute_collection_offsets(features, spacing=0.0)
# -> [(0.0, 0.0, 0.0), (120.0, 0.0, 0.0)]
# offsets[1][0] > 100.0   (only X moves; dy == dz == 0)

With a positive spacing, the gap is exact. Here the second feature's xmin (originally 0) is placed at the first feature's xmax (100) plus the gap (50):

offsets = compute_collection_offsets(features, spacing=50.0)
# -> [(0.0, 0.0, 0.0), (150.0, 0.0, 0.0)]

Grid layout (wraps X → Y → Z)

Pass any of grid_max_x, grid_max_y, grid_max_z to switch to grid layout. These are cell counts per axis, not sizes. Features fill the grid in order, wrapping from columns (X) to rows (Y) to layers (Z):

  • col = i % grid_max_x
  • row = (i // grid_max_x) % grid_max_y
  • layer = i // (grid_max_x * grid_max_y)

Axes you leave as None are unconstrained: rows and layers expand as needed to hold every feature, so a grid with only grid_max_x set can never overflow.

Features are placed by center: each feature's bounding-box center is moved to the target cell center, relative to feature 0's center.

Cell size

spacing Cell size (center-to-center)
> 0 Fixed: spacing on every axis, regardless of feature extent.
<= 0 (e.g. 0.0) Auto: per-axis max extent + 20% gap of the largest feature.
from mudm.model import MuDMFeature
from mudm.layout import compute_collection_offsets

# Four identical TINs, 2 columns -> wraps into 2 rows
feats = [MuDMFeature(type="Feature", geometry=tin_at(0, 100), properties=None)
         for _ in range(4)]

compute_collection_offsets(feats, spacing=10.0, grid_max_x=2)
# -> [(0.0, 0.0, 0.0),    # feature 0: reference, no move
#     (10.0, 0.0, 0.0),   # feature 1: col 1, same row  -> +X
#     (0.0, 10.0, 0.0),   # feature 2: col 0, row 1      -> +Y
#     (10.0, 10.0, 0.0)]  # feature 3: col 1, row 1      -> +X, +Y

Add grid_max_y to cap rows and force wrapping into Z layers:

# 5 features in a 2x2 grid -> the 5th wraps to layer 1 (col 0, row 0, +Z)
feats = [MuDMFeature(type="Feature", geometry=tin_at(0, 100), properties=None)
         for _ in range(5)]
offsets = compute_collection_offsets(feats, spacing=10.0, grid_max_x=2, grid_max_y=2)
# offsets[4] -> (0.0, 0.0, 10.0)   (dx == 0, dy == 0, dz > 0)

Grid capacity

When every axis is constrained, the grid has a fixed capacity of grid_max_x * grid_max_y * grid_max_z cells. Exceeding it raises ValueError:

feats = [MuDMFeature(type="Feature", geometry=tin_at(0, 100), properties=None)
         for _ in range(10)]

compute_collection_offsets(
    feats, spacing=10.0, grid_max_x=2, grid_max_y=2, grid_max_z=2,
)
# ValueError: Cannot fit 10 features in grid (2 cols x 2 rows x 2 layers = 8 cells)...

Avoiding overflow

Leave at least one axis unconstrained (typically grid_max_z=None) and the layout can always grow to hold every feature. Constrain all three only when you deliberately want a hard cap.

Applying the layout: apply_layout

apply_layout(collection, spacing=None, grid_max_x=None, grid_max_y=None, grid_max_z=None) runs compute_collection_offsets and returns a new MuDMFeatureCollection with each geometry translated by its offset. The input collection is never mutated.

from mudm import MuDMFeatureCollection, apply_layout
from mudm.model import MuDMFeature

coll = MuDMFeatureCollection(
    type="FeatureCollection",
    features=[
        MuDMFeature(type="Feature", geometry=tin_at(0, 100), properties={"id": "a"}),
        MuDMFeature(type="Feature", geometry=tin_at(0, 80), properties={"id": "b"}),
    ],
)

result = apply_layout(coll, spacing=50.0)

# First feature unchanged; second feature's xmin vertex shifted to 100 + 50 = 150
result.features[0].geometry.coordinates[0][0][0][0]  # -> 0.0
result.features[1].geometry.coordinates[0][0][0][0]  # -> 150.0

# The original is untouched
coll.features[1].geometry.coordinates[0][0][0][0]    # -> 0.0

# Properties are carried through unchanged
result.features[0].properties  # -> {"id": "a"}

The same call works for grid layout — only the arguments change:

coll = MuDMFeatureCollection(
    type="FeatureCollection",
    features=[MuDMFeature(type="Feature", geometry=tin_at(0, 100), properties=None)
              for _ in range(4)],
)
result = apply_layout(coll, spacing=10.0, grid_max_x=2)

result.features[0].geometry.coordinates[0][0][0][0]  # -> 0.0 (reference)
# Feature 2 lands in col 0, row 1: X unchanged, Y shifted up
result.features[2].geometry.coordinates[0][0][0][1]  # -> 10.0  (> 0)

Behavior worth knowing

  • Zero offsets skip copying. A feature whose offset is effectively zero (within 1e-12) is passed through to the new collection unchanged.
  • Null geometries are preserved. A feature with geometry=None is kept as-is and never raises.
  • Any geometry type works. Layout uses translate_geometry under the hood, so Point, Polygon, TIN, PolyhedralSurface, and other GeoJSON types all translate correctly.
from geojson_pydantic import Point
from mudm import MuDMFeatureCollection, apply_layout
from mudm.model import MuDMFeature

coll = MuDMFeatureCollection(
    type="FeatureCollection",
    features=[
        MuDMFeature(type="Feature", geometry=Point(type="Point", coordinates=(0, 0, 0)), properties=None),
        MuDMFeature(type="Feature", geometry=Point(type="Point", coordinates=(0, 0, 0)), properties=None),
    ],
)
result = apply_layout(coll, spacing=10.0)
result.features[1].geometry.coordinates[0]  # -> 10.0  (second point shifted along X)

Layout for visualization

A laid-out MuDMFeatureCollection is the natural input to the mudm-tools exporters. Run layout first to spread your features apart, then hand the result to a converter or tiling engine:

Because the laid-out collection is still valid muDM (and valid GeoJSON), nothing downstream needs to know layout happened — the coordinates are simply translated.

Where to next

  • Coordinate Transformstranslate_geometry, AffineTransform, and voxel↔physical conversion. Layout is built on translate_geometry.
  • Tile Metadata — the TileJSON/TileModel surface for referencing tiled geometry.
  • Worked examples — end-to-end recipes that combine layout with the rest of the model.
  • Core data-model APIMuDMFeature, MuDMFeatureCollection, TIN, and PolyhedralSurface.
  • mudm-tools docs — pipelines, tiling engines, and format converters that consume a laid-out collection.

API reference

geometry_bounds

geometry_bounds(geom: Any) -> Bounds | None

Return 3-D bounding box as (xmin, ymin, zmin, xmax, ymax, zmax).

Uses .bbox3d() for 3D types (TIN, PolyhedralSurface) and recursive coordinate extraction for GeoJSON types.

Returns None if the geometry is empty or None.

Source code in src/mudm/layout.py
def geometry_bounds(geom: Any) -> Bounds | None:
    """Return 3-D bounding box as (xmin, ymin, zmin, xmax, ymax, zmax).

    Uses ``.bbox3d()`` for 3D types (TIN,
    PolyhedralSurface) and recursive coordinate extraction
    for GeoJSON types.

    Returns None if the geometry is empty or None.
    """
    if geom is None:
        return None

    # 3D types with bbox3d() — returns (xmin, ymin, zmin, xmax, ymax, zmax)
    if hasattr(geom, "bbox3d"):
        try:
            return geom.bbox3d()
        except (ValueError, IndexError):
            return None

    # GeometryCollection — union of its children's bounds
    if hasattr(geom, "geometries"):
        child = [geometry_bounds(g) for g in geom.geometries]
        child = [b for b in child if b is not None]
        if not child:
            return None
        return (
            min(b[0] for b in child),
            min(b[1] for b in child),
            min(b[2] for b in child),
            max(b[3] for b in child),
            max(b[4] for b in child),
            max(b[5] for b in child),
        )

    # GeoJSON types with .coordinates
    if not hasattr(geom, "coordinates"):
        return None

    xs: list[float] = []
    ys: list[float] = []
    zs: list[float] = []
    _collect_xyz_from_coords(geom.coordinates, xs, ys, zs)

    if not xs:
        return None
    return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs))

compute_collection_offsets

compute_collection_offsets(features: list[MuDMFeature], spacing: float | None = None, grid_max_x: int | None = None, grid_max_y: int | None = None, grid_max_z: int | None = None) -> list[Offset3]

Compute 3-D translation for each feature in a collection.

Returns a list of (dx, dy, dz) offsets in source coordinates. The first feature always stays at its original position.

When spacing is None and no grid parameters are set, all offsets are zero (coordinates are kept as-is).

Source code in src/mudm/layout.py
def compute_collection_offsets(
    features: list[MuDMFeature],
    spacing: float | None = None,
    grid_max_x: int | None = None,
    grid_max_y: int | None = None,
    grid_max_z: int | None = None,
) -> list[Offset3]:
    """Compute 3-D translation for each feature in a collection.

    Returns a list of (dx, dy, dz) offsets in source coordinates.
    The first feature always stays at its original position.

    When *spacing* is ``None`` and no grid parameters are set, all
    offsets are zero (coordinates are kept as-is).
    """
    n = len(features)
    if n <= 1:
        return [(0.0, 0.0, 0.0)] * n

    has_grid = (
        grid_max_x is not None
        or grid_max_y is not None
        or grid_max_z is not None
    )

    # No layout requested — keep coordinates as-is
    if spacing is None and not has_grid:
        return [(0.0, 0.0, 0.0)] * n

    effective_spacing = spacing if spacing is not None else 0.0

    bounds = [
        geometry_bounds(f.geometry) if f.geometry else None
        for f in features
    ]

    if has_grid:
        return _grid_layout(bounds, effective_spacing, grid_max_x, grid_max_y, grid_max_z, n)
    return _row_layout(bounds, effective_spacing)

apply_layout

apply_layout(collection: MuDMFeatureCollection, spacing: float | None = None, grid_max_x: int | None = None, grid_max_y: int | None = None, grid_max_z: int | None = None) -> MuDMFeatureCollection

Apply spatial layout to a collection, returning a new collection.

Computes offsets and translates each feature's geometry coordinates directly. The returned collection is a copy — the original is not modified.

Source code in src/mudm/layout.py
def apply_layout(
    collection: MuDMFeatureCollection,
    spacing: float | None = None,
    grid_max_x: int | None = None,
    grid_max_y: int | None = None,
    grid_max_z: int | None = None,
) -> MuDMFeatureCollection:
    """Apply spatial layout to a collection, returning a new collection.

    Computes offsets and translates each feature's geometry coordinates
    directly. The returned collection is a copy — the original is not
    modified.
    """
    features = list(collection.features)
    offsets = compute_collection_offsets(
        features, spacing, grid_max_x, grid_max_y, grid_max_z,
    )

    new_features = []
    for feat, (dx, dy, dz) in zip(features, offsets):
        if abs(dx) < 1e-12 and abs(dy) < 1e-12 and abs(dz) < 1e-12:
            new_features.append(feat)
            continue

        if feat.geometry is None:
            new_features.append(feat)
            continue

        # Skip geometries we cannot measure (e.g. tiled meshes with no inline
        # coordinates): leave them untranslated rather than crashing.
        if geometry_bounds(feat.geometry) is None:
            new_features.append(feat)
            continue

        new_geom = translate_geometry(feat.geometry, dx, dy, dz)
        # Build a new feature with translated geometry
        new_feat = feat.model_copy(update={"geometry": new_geom})
        new_features.append(new_feat)

    return collection.model_copy(update={"features": new_features})