Working with Grids

A grid defines where in space the scalar field is sampled and stores the sampled values for extraction. There are two grid types: UniformGrid samples a dense regular lattice, and SparseGrid stores only the cells you add, which saves memory when the surface occupies a small part of a large volume.

import torch
import isoext
from isoext import viewer

UniformGrid

A UniformGrid divides a 3D bounding box into a regular lattice of cells.

Creating a Grid

UniformGrid(shape, aabb_min=[-1,-1,-1], aabb_max=[1,1,1])
  • shape: Number of sample points in each dimension [nx, ny, nz], giving (nx-1) * (ny-1) * (nz-1) cells

  • aabb_min / aabb_max: Axis-aligned bounding box corners

The grid always spans its bounding box, whatever the shape: a non-cubic shape on the default [-1, 1] box gives stretched, non-cubic cells. Pass a box with matching proportions when the cells should be cubes.

# Create a 64³ grid spanning [-1, 1]³ (default bounds)
grid = isoext.UniformGrid([64, 64, 64])

print(f"Cells: {grid.get_num_cells():,}")
print(f"Points: {grid.get_num_points():,}")
Cells: 250,047
Points: 262,144

Getting Point Positions

get_points() returns the coordinates of all grid points as a PyTorch tensor on the GPU. Evaluate your field at these points.

points = grid.get_points()

print(f"Shape: {points.shape}")  # (nx, ny, nz, 3)
print(f"Device: {points.device}")
print(f"Dtype: {points.dtype}")

# Points range from aabb_min to aabb_max
print(f"\nMin corner: {points[0, 0, 0]}")
print(f"Max corner: {points[-1, -1, -1]}")
Shape: torch.Size([64, 64, 64, 3])
Device: cuda:0
Dtype: torch.float32

Min corner: tensor([-1., -1., -1.], device='cuda:0')
Max corner: tensor([1., 1., 1.], device='cuda:0')

Setting Values

set_values() stores your scalar field on the grid. The values can come from anywhere: a formula, an SDF, a network, a simulation. The convention is that negative values are inside the surface and positive values outside, so the surface sits where the values cross the level, zero by default.

# Example 1: Simple sphere using raw PyTorch
# No SDF classes needed — just compute distance from origin minus radius
points = grid.get_points()
radius = 0.7
values = points.norm(dim=-1) - radius  # Signed distance to sphere

grid.set_values(values)
v, f = isoext.marching_cubes(grid)
print(f"Sphere: {v.shape[0]:,} vertices")
viewer.embed(v, f)
Sphere: 9,168 vertices
# Example 2: Gyroid — a triply periodic minimal surface
points = grid.get_points()
x, y, z = points[..., 0], points[..., 1], points[..., 2]
scale = 6.0

gyroid = (
    torch.sin(scale * x) * torch.cos(scale * y)
    + torch.sin(scale * y) * torch.cos(scale * z)
    + torch.sin(scale * z) * torch.cos(scale * x)
)

grid.set_values(gyroid)
v, f = isoext.marching_cubes(grid)
print(f"Gyroid: {v.shape[0]:,} vertices")
viewer.embed(v, f, color="gold")
Gyroid: 38,760 vertices

Seeing the Grid

For small grids, the viewer can overlay the grid itself: gray lines are the cell edges, red dots are corners inside the surface, blue dots corners outside. Pass the grid to viewer.embed or viewer.show:

small = isoext.UniformGrid([6, 6, 6])
small.set_values(small.get_points().norm(dim=-1) - 0.7)

v, f = isoext.marching_cubes(small)
viewer.embed(v, f, grid=small, wireframe=True, height=360)

Using Neural Networks

Since get_points() returns a standard PyTorch tensor on CUDA, you can feed it directly to a neural network:

# Pseudocode for neural SDF extraction:
#
# grid = isoext.UniformGrid([128, 128, 128])
# points = grid.get_points()  # (128, 128, 128, 3)
#
# # Reshape for batch processing
# points_flat = points.reshape(-1, 3)  # (N, 3)
#
# # Query your neural network
# with torch.no_grad():
#     values_flat = model(points_flat)  # (N, 1) or (N,)
#
# # Reshape back and set on grid
# values = values_flat.reshape(points.shape[:-1])
# grid.set_values(values)
#
# vertices, faces = isoext.marching_cubes(grid)

SparseGrid

A SparseGrid stores only the cells you add. When the surface occupies a thin shell of a large volume – a high-resolution scene, an adaptive refinement loop – that shell is all that costs memory and time.

# Create a sparse grid with the same logical resolution
sparse_grid = isoext.SparseGrid([64, 64, 64])

print(f"Initially: {sparse_grid.get_num_cells()} active cells")

# Add cells near the surface we want to extract
# Cell indices are linearized: idx = x + y*nx + z*nx*ny
cell_indices = torch.arange(0, 1000, device="cuda", dtype=torch.int32)
sparse_grid.add_cells(cell_indices)

print(f"After adding: {sparse_grid.get_num_cells()} active cells")
Initially: 0 active cells
After adding: 1000 active cells

SparseGrid Workflow

Cells are managed explicitly: add candidate cells, evaluate the field at their corner points, keep the cells the surface actually crosses, set the values, and extract. The example below does one pass of that:

# Complete SparseGrid example
sparse_grid = isoext.SparseGrid([1024, 1024, 1024])

# For this demo, we'll use get_potential_cell_indices to get all possible cells in chunks
# In practice, you'd use spatial hashing or other methods to find relevant cells
# If you know the active indices, just set them using sparse_grid.add_cells(active)
chunks = sparse_grid.get_potential_cell_indices(100000)
print(f"Processing {len(chunks)} chunks")

for chunk in chunks:
    # Get corner positions for these cells
    points = sparse_grid.get_points_by_cell_indices(chunk)  # (N, 8, 3)

    # Compute values at corners (sphere SDF)
    values = points.norm(dim=-1) - 0.7  # (N, 8)

    # Filter to cells that cross the surface (have both + and - values)
    active = sparse_grid.filter_cell_indices(chunk, values, level=0.0)

    if active is not None and active.numel() > 0:
        sparse_grid.add_cells(active)

print(f"Active cells: {sparse_grid.get_num_cells()}")

# Recompute values for the cells we're keeping
points = sparse_grid.get_points()
values = points.norm(dim=-1) - 0.7
# Note: set_values expects (num_cells, 8) tensor
# We set values for all currently active cells
sparse_grid.set_values(values)

v, f = isoext.marching_cubes(sparse_grid)
print(f"Extracted: {v.shape[0]:,} vertices")

# Embed a coarser preview: the full-resolution mesh would weigh ~30 MB as a
# static scene file, which is too heavy for a documentation page.
preview = isoext.UniformGrid([128, 128, 128])
preview.set_values(preview.get_points().norm(dim=-1) - 0.7)
v_lo, f_lo = isoext.marching_cubes(preview)
viewer.embed(v_lo, f_lo, color="steelblue")
Processing 10738 chunks
Active cells: 2416778
Extracted: 2,416,776 vertices

Seeing Active Cells

The overlay works for sparse grids too and draws only the active cells, which makes the sparseness itself visible: the cells hug the surface and the rest of the volume is empty.

shell = isoext.SparseGrid([8, 8, 8])
for chunk in shell.get_potential_cell_indices(100000):
    points = shell.get_points_by_cell_indices(chunk)
    near = shell.filter_cell_indices(chunk, points.norm(dim=-1) - 0.7)
    if len(near) > 0:
        shell.add_cells(near)
shell.set_values(shell.get_points().norm(dim=-1) - 0.7)

v, f = isoext.marching_cubes(shell)
print(f"{shell.get_num_cells()} active cells of {7**3} possible")
viewer.embed(v, f, grid=shell, wireframe=True, height=360)
98 active cells of 343 possible

Summary

Evaluate your field at get_points(), store it with set_values(), and extract; values below the level count as inside. UniformGrid is the default choice, and SparseGrid pays off when the surface is small compared to the volume.