Metadata-Version: 2.4
Name: tinyvdb
Version: 0.9.1
Summary: OpenVDB and NanoVDB I/O, sparse volume processing, mesh conversion, and CPU autograd
Author: Syoyo Fujita
License-Expression: Apache-2.0
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Programming Language :: C
Classifier: Programming Language :: C++
Classifier: Topic :: Scientific/Engineering
Project-URL: Homepage, https://github.com/syoyo/tinyvdb
Project-URL: Repository, https://github.com/syoyo/tinyvdb
Requires-Python: >=3.11
Description-Content-Type: text/markdown

# tinyvdb

Lightweight Python bindings for [TinyVDB](https://github.com/syoyo/tinyvdb) — a lightweight C/C++ library for OpenVDB and NanoVDB file I/O, mesh-to-SDF conversion, and grid operations. No OpenVDB dependency required.

Built with the Python C Stable API (abi3) for broad compatibility: one wheel per platform works across CPython 3.11+.

The 0.9.1 release adds owning tree-maintenance results and sparse
rebuild, resampling, meshing, and Poisson methods. Python views raise errors
after their source is closed, reloaded, or replaced.

## Installation

```bash
pip install tinyvdb
```

Pre-built wheels are available for:

| Platform | Architectures |
|----------|--------------|
| Linux | x86_64, aarch64 |
| macOS | x86_64, arm64 |
| Windows | AMD64 |

Default wheels use portable scalar CPU kernels and do not require AVX2, OpenMP,
Vulkan, or CUDA. NumPy is optional for array convenience functions. To enable
SIMD when compiling from source for a compatible machine:

```bash
pip install --no-binary=tinyvdb tinyvdb -Ccmake.define.TINYVDB_SIMD=ON
```

## Quick start

### Read a VDB file

```python
import tinyvdb

with tinyvdb.open("smoke.vdb") as f:
    print(f.grid_count, "grids")
    print(f.header)

    f.read_grids()
    grid = f.grid(0)
    print(grid.name, grid.type_name)
    print(grid.transform)
    print(grid.metadata)

    # Access tree structure
    tree = grid.tree
    node = tree.node(0)
    print(node.type, node.origin)
```

### Read a NanoVDB file

```python
from contextlib import closing
from tinyvdb import nanovdb

with closing(nanovdb.NanoVDBFile("sphere.nvdb")) as f:
    print(f.grid_count(), "grids")
    for i in range(f.grid_count()):
        print(f"  Grid {i}: {f.grid_name(i)}")
        print(f"  Type: {f.grid_type(i)}")
        print(f"  Class: {f.grid_class(i)}")
        print(f"  Voxel size: {f.voxel_size(i)}")
        print(f"  BBox: {f.bbox(i)}")
        print(f"  World BBox: {f.world_bbox(i)}")
        print(f"  Node counts: {f.node_counts(i)}")
        print(f"  Active voxels: {f.active_voxel_count(i)}")
```

### NanoVDB utilities

```python
from tinyvdb import nanovdb

# Node sizes
leaf_size = nanovdb.leaf_node_size()          # Default: Float
lower_size = nanovdb.lower_node_size()         # Default: Float
upper_size = nanovdb.upper_node_size()         # Default: Float

# Value sizes
float_size = nanovdb.value_size()              # 4 bytes
vec3f_size = nanovdb.value_size(nanovdb.GRID_TYPE_VEC3F)  # 12 bytes
double_size = nanovdb.value_size(nanovdb.GRID_TYPE_DOUBLE) # 8 bytes

# Grid type names
name = nanovdb.grid_type_name(nanovdb.GRID_TYPE_FLOAT)  # "Float"
```

### Load from bytes / save round-trip

```python
data = open("input.vdb", "rb").read()
f = tinyvdb.from_bytes(data)
f.read_grids()

# Serialize back
output = f.to_bytes(compression=tinyvdb.COMPRESS_ZIP, level=5)
open("output.vdb", "wb").write(output)
f.close()
```

### Mesh to SDF and back

```python
import struct
import tinyvdb

# Vertices as flat float32 buffer, faces as flat uint32 buffer
vertices = struct.pack("9f", 0,0,0, 1,0,0, 0,1,0)
faces = struct.pack("3I", 0, 1, 2)

# Convert mesh to signed distance field
sdf = tinyvdb.mesh_to_sdf(vertices, faces, voxel_size=0.05, band_width=3.0)
print(sdf.shape)  # (nx, ny, nz)

# Extract mesh via marching cubes
mesh = tinyvdb.sdf_to_mesh(sdf, isovalue=0.0)
print(mesh.num_vertices, mesh.num_faces)
```

### Level-set primitives & SDF utilities

Generate analytic narrow-band SDFs (parallels OpenVDB's `LevelSetSphere` /
`LevelSetPlatonic` / `LevelSetTubes`) as `DenseGrid`s, then operate on them with
the dense ops (CSG, filters, marching cubes) or write them to `.vdb`. Every voxel
holds the true signed distance clamped to `±half_width * voxel_size`; negative is
inside. The grid is sized to enclose the primitive plus the band margin.

```python
import numpy as np
import tinyvdb

sphere  = tinyvdb.level_set_sphere(radius=1.0, center=(0, 0, 0), voxel_size=0.05)
box     = tinyvdb.level_set_box(half_extents=(0.5, 0.4, 0.6))
torus   = tinyvdb.level_set_torus(major_radius=1.0, minor_radius=0.3)   # XZ plane, axis Y
capsule = tinyvdb.level_set_capsule(p0=(-0.5, 0, 0), p1=(0.5, 0, 0), radius=0.25)

# Platonic solids (radius = circumradius). Either the generic form or a named
# wrapper; the field is the convex half-space SDF (exact isosurface).
ico   = tinyvdb.level_set_icosahedron(radius=1.0)
solid = tinyvdb.level_set_platonic(face_count=12, radius=1.0)   # 4/6/8/12/20

print(sphere.shape, sphere.voxel_size, sphere.origin)
arr = np.array(sphere, copy=False)        # zero-copy float32 view

# Combine and re-mesh
both = tinyvdb.csg_union(sphere, box)
mesh = tinyvdb.sdf_to_mesh(both, isovalue=0.0)

# SDF utilities
fog  = tinyvdb.sdf_to_fog_volume(sphere)             # density = clamp(-sdf/(hw*voxel), 0, 1)
mask = tinyvdb.sdf_interior_mask(sphere, isovalue=0.0)  # 1.0 inside, 0.0 outside

# Split disjoint objects; extract sealed cavities
segments = tinyvdb.sdf_segmentation(grid, isovalue=0.0, connectivity=6)  # list[DenseGrid]
voids    = tinyvdb.sdf_extract_enclosed_regions(grid)  # 1.0 mask of interior cavities

# Topology measures of the isosurface
chi   = tinyvdb.level_set_euler_characteristic(torus)   # 0.0  (2 for a sphere)
genus = tinyvdb.level_set_genus(torus)                  # 1    (0 for a sphere)

# Rebuild a clean SDF from the isosurface (renormalize and/or resample)
clean = tinyvdb.level_set_rebuild(damaged, isovalue=0.0, voxel_size=0.05)
```

> **Note:** `level_set_rebuild` (and `mesh_to_sdf`) use a brute-force
> mesh-to-SDF (O(voxels × triangles)); keep the voxel size coarse for large
> surfaces.

All generators take `voxel_size=` and `half_width=` (band width in voxels,
default 3, matching OpenVDB's `LEVEL_SET_HALF_WIDTH`).

### Statistics & diagnostics

```python
s = tinyvdb.grid_statistics(grid)        # {min, max, mean, stddev, sum, count}
h = tinyvdb.grid_histogram(grid, -1.0, 1.0, nbins=32)   # list of bin counts

# A valid narrow-band SDF has |grad| == 1 in the band:
chk = tinyvdb.check_level_set(grid, band_width=0.1, tol=0.1)   # {mean_grad_mag, ...}
# A valid fog volume has all values in [0, 1]:
ok = tinyvdb.check_fog_volume(grid)["valid"]
```

### Resampling & advection

```python
# Resample to any voxel size over the same world AABB (order 0/1/2).
coarse = tinyvdb.resample_grid(grid, 0.1, order=1)

# Restore interior signs of a level set after an unsigned operation.
clean = tinyvdb.signed_flood_fill(grid, band_width=0.15)

# Higher-order advection through a steady velocity field (DenseVecGrid):
out = tinyvdb.advect(field, velocity, dt, scheme=tinyvdb.ADVECT_MACCORMACK, clamp=1)
#   scheme: ADVECT_RK1..ADVECT_RK4, ADVECT_MACCORMACK, ADVECT_BFECC
```

### Points & coordinate queries

```python
# Point cloud -> unique occupied voxel coords / occupancy mask.
coords = tinyvdb.voxelize_points(points, voxel_size=0.05)
mask   = tinyvdb.points_to_mask(points, 0.05)            # DenseGrid

# Membership / index queries against an active coord set.
inside = tinyvdb.points_in_grid(points, coords, 0.05)
idx    = tinyvdb.ijk_to_index(coords, query_coords)      # -1 if absent
codes  = tinyvdb.morton_encode(coords)                   # Z-order

# Higher-order sampling and point scatter.
vals = tinyvdb.sample_quadratic(grid, pts.tobytes())     # smoother than trilinear
pts  = tinyvdb.scatter_points_in_sdf(sdf, n=1000)         # uniform interior points

# Emission-absorption volume render (pinhole camera) -> (H, W) float image.
img = tinyvdb.volume_render(density, eye=(0, 0, 4), center=(0, 0, 0),
                            width=256, height=256, sigma=20.0)
```

### Operators & filters

```python
# Vector-grid operators (GridOperators)
mag  = tinyvdb.magnitude(vec)            # DenseVecGrid -> DenseGrid (|v|)
unit = tinyvdb.normalize(vec)            # DenseVecGrid -> DenseVecGrid (v/|v|)
near = tinyvdb.cpt(sdf)                  # closest-point transform of an SDF

# Per-voxel composite of two same-shape grids
u = tinyvdb.comp_max(a, b)   # also comp_min / comp_sum / comp_mult

# In-place-style filters (return a new DenseGrid)
m = tinyvdb.median_filter(grid, radius=1, iterations=1)
s = tinyvdb.mean_curvature_flow(grid, dt=0.0003, iterations=8)   # keep dt small
```

### Write a dense grid to `.vdb`

Write an existing dense field (e.g. an SDF you already computed) to a fresh `.vdb`
file. `world = voxel_size * index + origin` (per-axis `ScaleTranslateMap`).

The grid value type is selected from the numpy array's dtype:

| array | grid type written |
| --- | --- |
| `(nx, ny, nz)` `float32` | `Tree_float_5_4_3` |
| `(nx, ny, nz)` `float64` | `Tree_double_5_4_3` |
| `(nx, ny, nz)` `int32` | `Tree_int32_5_4_3` |
| `(nx, ny, nz)` `int64` | `Tree_int64_5_4_3` |
| `(nx, ny, nz)` `bool` | `Tree_bool_5_4_3` (see note) |
| `(nx, ny, nz, 3)` `float32` | `Tree_vec3s_5_4_3` (vec3f) |

```python
import numpy as np
import tinyvdb

sdf = np.random.randn(32, 32, 24).astype(np.float32)   # your dense (nx, ny, nz) field

# numpy convenience (round-trips bit-exactly, dtype preserved):
tinyvdb.write_dense_grid("sdf.vdb", sdf, voxel_size=(0.05, 0.06, 0.07),
                         origin=(1.0, 2.0, 3.0), name="sdf")
arr, voxel_size, origin = tinyvdb.read_dense_grid("sdf.vdb")
assert np.array_equal(arr, sdf)

# other scalar types and vec3f, same call:
labels = np.random.randint(0, 10, (16, 16, 16)).astype(np.int32)
tinyvdb.write_dense_grid("labels.vdb", labels)              # Tree_int32_5_4_3
velocity = np.random.randn(16, 16, 16, 3).astype(np.float32)
tinyvdb.write_dense_grid("vel.vdb", velocity)              # Tree_vec3s_5_4_3

# numpy-free raw API for float (values is an nx*ny*nz float32 buffer in C order):
import struct
buf = struct.pack("24f", *range(24))
tinyvdb.write_float_grid("grid.vdb", buf, 2, 3, 4,
                         voxel_size=(0.1, 0.1, 0.1), origin=(0.0, 0.0, 0.0))
```

> **Note on `bool`:** tinyvdb stores BOOL grids as 1 byte/voxel rather than
> OpenVDB's bit-packed 1-bit format. Such files round-trip self-consistently
> through `read_dense_grid` but are **not** byte-compatible with OpenVDB and will
> not load in DCCs (Houdini/Blender/etc).

### Write a sparse grid to `.vdb`

When you only have a set of active voxels (not a full dense block), write them
directly with `write_sparse_grid` — same dtype rules as `write_dense_grid`
(scalar `(N,)` arrays, or `(N, 3)` `float32` for vec3f). Coords are world-voxel
indices and need not be contiguous.

```python
import numpy as np, tinyvdb

coords = np.array([[0, 0, 0], [10, 2, 3], [100, 100, 100]], dtype=np.int32)
values = np.array([1.0, 2.0, 3.0], dtype=np.float32)
tinyvdb.write_sparse_grid("sparse.vdb", coords, values, voxel_size=0.1, name="pts")

coords_out, values_out, voxel_size, origin = tinyvdb.read_sparse_grid("sparse.vdb")
```

### CSG operations

```python
union = tinyvdb.csg_union(sdf_a, sdf_b)
intersection = tinyvdb.csg_intersection(sdf_a, sdf_b)
difference = tinyvdb.csg_difference(sdf_a, sdf_b)
```

### Morphology and filtering

```python
dilated = tinyvdb.dilate(sdf, iterations=2)
eroded = tinyvdb.erode(sdf, iterations=1)
smoothed = tinyvdb.gaussian_filter(sdf, width=1, iterations=3)
```

### Measurement

```python
area = tinyvdb.surface_area(sdf)
vol = tinyvdb.volume(sdf)
```

### Ray casting

```python
hit = tinyvdb.ray_cast_sdf(sdf,
                            origin=(0, 0, -5),
                            direction=(0, 0, 1),
                            max_t=100.0)
if hit:
    print(hit["t"], hit["position"], hit["normal"])
```

### Differential operators

```python
grad = tinyvdb.gradient(sdf)          # -> DenseVecGrid
div  = tinyvdb.divergence(grad)       # -> DenseGrid
lap  = tinyvdb.laplacian(sdf)         # -> DenseGrid
c    = tinyvdb.curl(vec_field)        # -> DenseVecGrid
```

### Advection and Poisson solver

```python
advected = tinyvdb.advect(field, velocity, dt=0.01)
solution, iterations = tinyvdb.solve_poisson(rhs, max_iters=500, tolerance=1e-6)
```

### Particles and fracture

```python
# Rasterize particles into SDF
sdf = tinyvdb.particles_to_sdf(positions, radii,
                                voxel_size=0.05, band_width=3.0)

# Fill volume interior with spheres
result = tinyvdb.volume_to_spheres(sdf, min_radius=0.01, max_spheres=500)

# Fracture volume with cutter SDFs
pieces = tinyvdb.fracture(volume, [cutter1, cutter2])
```

### Manifold repair

```python
mesh = tinyvdb.make_manifold(vertices, faces,
                              resolution=50, isovalue=0.55)
```

## numpy interop

`DenseGrid` supports the buffer protocol, so zero-copy access works with numpy:

```python
import numpy as np

grid = tinyvdb.mesh_to_sdf(verts, faces, 0.1, 3.0)
arr = np.frombuffer(grid, dtype=np.float32).reshape(grid.shape)
```

## API reference

### Types

| Type | Description |
|------|-------------|
| `VDBFile` | VDB file handle (context manager) |
| `VDBGrid` | Grid accessor (name, type, transform, metadata, tree) |
| `VDBTree` | Tree structure with node access |
| `VDBNode` | Tree node (root, internal, or leaf) |
| `DenseGrid` | 3D scalar grid with buffer protocol |
| `DenseVecGrid` | 3D vector field (3-component) with buffer protocol |
| `TriangleMesh` | Triangle mesh (vertices + faces as bytes) |
| `VDBError` | Exception type (subclass of RuntimeError) |

### Constants

#### Compression

| Constant | Value | Description |
|----------|-------|-------------|
| `COMPRESS_NONE` | 0 | No compression |
| `COMPRESS_ZIP` | 1 | ZIP (zlib/miniz) |
| `COMPRESS_ACTIVE_MASK` | 2 | Compress node masks |
| `COMPRESS_BLOSC` | 4 | BLOSC (LZ4) |
| `SIGN_FLOOD_FILL` | 0 | Exterior flood fill sign method |
| `SIGN_SWEEP` | 1 | Directional sweep sign method |

NanoVDB constants below are available through `tinyvdb.nanovdb`.

#### NanoVDB Codecs

| Constant | Value | Description |
|----------|-------|-------------|
| `CODEC_NONE` | 0 | No compression |
| `CODEC_ZIP` | 1 | ZIP compression |
| `CODEC_BLOSC` | 2 | BLOSC compression |

#### NanoVDB Grid Types

| Constant | Value | Description |
|----------|-------|-------------|
| `GRID_TYPE_FLOAT` | 1 | 32-bit float |
| `GRID_TYPE_DOUBLE` | 2 | 64-bit double |
| `GRID_TYPE_INT32` | 4 | 32-bit integer |
| `GRID_TYPE_INT64` | 5 | 64-bit integer |
| `GRID_TYPE_VEC3F` | 6 | 3D float vector |
| `GRID_TYPE_VEC3D` | 7 | 3D double vector |

#### NanoVDB Grid Classes

| Constant | Value | Description |
|----------|-------|-------------|
| `GRID_CLASS_LEVEL_SET` | 1 | Level set |
| `GRID_CLASS_FOG_VOLUME` | 2 | Fog volume |
| `GRID_CLASS_POINT_DATA` | 6 | Point data |

## Loaded-tree maintenance and ownership

```python
with tinyvdb.open("sphere.vdb") as f:
    f.read_grids()
    grid = f.grid(0)
    report = grid.diagnose()  # generic finite-value/structural/transform checks
    level_set = grid.diagnose(kind="level_set", check_gradient=True, tolerance=0.1)
    result = grid.signed_flood_fill().prune()

# The result owns an independent tree and remains usable after f closes.
result.save("maintained.vdb")
payload = result.to_bytes()
```

`diagnose(kind="generic" | "level_set" | "fog", check_gradient=False,
tolerance=0.1)` returns `valid`, `flags`, active voxel/tile counts, exact int64
`active_bbox` (exclusive maximum, or `None`), `nonfinite_values`, first bad node
and coordinate, and gradient statistics (`band_count`, `mean_grad_mag`,
`max_grad_error`, `bad_fraction`). Gradient checks require `kind="level_set"`.
Flags match `tinyvdb_tree.h`: nonfinite=1, transform=2, background=4, active
tiles=8, range=16, inactive=32, gradient=64, class=128. Numeric defects are reports;
unsupported or structurally invalid trees raise `VDBError`.

`signed_flood_fill(outside_width=None, inside_width=None)` defaults to the root
background and its negation. Widths are finite positive/negative float world
distances. Use only on closed narrow bands; it propagates signs rather than
reconstructing distance. `prune()` is exact, preserving value bits and active
states; mixed signs/states never collapse. Both methods are float-tree only,
reject nonfinite data, return independently owned `VDBGrid` objects and preserve
the source. Flood fill rejects active tiles; pruning can create them. `to_sparse`
and `to_sparse_typed` reject active tiles rather than silently omitting their
volume. `save` and `to_bytes` serialize one grid using the existing compression
arguments (`compression`, `level`; `save` also accepts `use_mmap`). Borrowed
instance grids must first be made independent through maintenance.

Borrowed grid/tree/node views keep their file owner alive, but explicit `close`,
`read_grids`, `replace_grid_from_sparse`, and `extend_grid_from_sparse` invalidate
old views. Reacquire views after a reload or replacement. A reload invalidates
views even when it fails; a failed replacement does not. Any successful grid
replacement invalidates all views in that file. Access through a stale view
raises `ValueError`. Competing owner/view calls while heavy work releases the GIL
raise `RuntimeError`; serialize operations on the same owner. Dense constructors
validate dimensions/geometry and refuse reinitialization, preserving exported
buffers. Raw float/index input buffers require complete four-byte elements;
mesh buffers require complete triples.

## Sparse tracking, affine sampling, meshing and PDE

```python
with tinyvdb.open("sphere.vdb") as f:
    f.read_grids()
    source = f.grid(0).signed_flood_fill()
    tracked = source.track_level_set(outside_width=3, inside_width=3)
    rebuilt = source.rebuild_level_set(isovalue=0.2, outside_width=3, inside_width=3)
    mesh = tracked.volume_to_mesh(adaptivity=0.05)
    target = [[0, -0.5, 0.1, 0], [0.5, 0, 0, 0],
              [0, 0, 0.75, 0], [0, 0, 0, 1]]
    sampled = tracked.resample(target, sampler="linear")

# All results remain usable independently after file closure.
tracked.save("tracked.vdb")
```

`rebuild_level_set(isovalue=0, outside_width=background,
inside_width=background, max_voxels=0)` reconstructs a world-distance band around
a closed, resolved isosurface. `track_level_set` has the same width/budget
arguments and uses isovalue zero. Both widths are positive magnitudes. These
methods change active values and topology, require orthogonal isotropic axes and
positive exterior background, and use geometric redistancing rather than
iterative advection/tracking. A nonzero isovalue must still lie below background.
They cannot repair unresolved/open surfaces.

`resample(matrix, sampler="linear", max_voxels=0)` accepts four matrix rows or
sixteen row-major values, mapping integer target indices to world coordinates.
Nearest sampling uses `floor(x+0.5)` per source axis; linear sampling uses eight
weighted taps. All finite invertible affine transforms are supported. Stored
inactive values and tiles contribute; only nonzero-weight active taps activate
the output. Values retain their scalar units; no redistancing is performed.

`volume_to_mesh(isovalue=0, adaptivity=0, max_cells=0)` returns an owning
`TriangleMesh`. Stored inactive values also determine the surface. Positive
adaptivity bounds vertex displacement in world units, using global clustering;
it does not guarantee topology preservation or manifoldness. Reflection-aware
winding is outward for the phase below isovalue. No dense bounding box is built.

`rhs.solve_poisson(max_iterations=500, tolerance=1e-6,
boundary="dirichlet", boundary_value=0, coefficient=1, max_voxels=0)` returns
`(solution, report)` for `-div(k grad u)=rhs` over active voxels. The report has
`iterations`, `converged`, `initial_residual_norm` and `final_residual_norm`.
Python exposes constant conductivity and uniform Dirichlet/Neumann data; C callers
can supply per-face boundary and coefficient callbacks. Dirichlet values are at
missing neighbor centers; Neumann values are outward flux at half-cell faces.
Pure-Neumann components require compatible summed RHS/flux and get separate
zero means. Orthogonal anisotropic axes are supported; shear is rejected. The
solver starts at zero, uses double PCG, and checks residuals after float rounding.
A finite iterate is returned on iteration exhaustion with `converged=False`.

These methods preserve the source, produce independent libc-owned results and
use the existing stale-view/busy-owner guards. Zero budgets select 1,000,000
coordinates/cells; exceeding a working-set or scan budget raises `VDBError`.
Active tiles can expand within these budgets. Sparse methods are CPU-only.

## Supported VDB versions

Reads and writes OpenVDB files version 220 through 225 (OpenVDB 3.x to 10.x+).

## License

Apache License 2.0
