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)cellsaabb_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.