xuvdb 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
xuvdb/__init__.py ADDED
@@ -0,0 +1,64 @@
1
+ """XUVDB(太虚): genesis' own sparse-volume format, built on the quadrants kernel.
2
+
3
+ The name reads 太虚 (tàixū) - from《庄子·知北游》「不游乎太虚」and 张载《正蒙》「太虚无形,
4
+ 气之本体」: the closest Chinese rendering of an unbounded, sparse index domain - unbounded (the
5
+ root hash domain has no extent), formless (unallocated space has no shape; sampling it returns the
6
+ background). The name lives at the concept layer only (docs, logs, visualization labels); API
7
+ identifiers stay English (`xuvdb.prune()`, never `xuvdb.sunyi()`), and the native extension
8
+ `.xuvdb` never uses `.vdb` - every major volume tool parses that suffix as OpenVDB and fails
9
+ opaquely on foreign bytes. Interop with OpenVDB is an explicit, separate export.
10
+
11
+ `VdbGrid` is a mutable sparse voxel tree (dictionary of dense leaf blocks) that fills the gap this
12
+ module was designed for: OpenVDB trees are CPU-bound and NanoVDB grids are GPU read-only, while an
13
+ XUVDB grid can be structurally edited from Python (allocate/fill/stamp/CSG/prune leaves at any time)
14
+ and its packed value buffer can additionally be *written* by quadrants kernels
15
+ (`GpuVolume.write_voxels`) without a host round trip.
16
+
17
+ Interoperability with OpenVDB is first-class and needs no OpenVDB installation:
18
+
19
+ - `write_vdb` / `read_vdb` read and write real `.vdb` files (the published stream format, verified
20
+ against the OpenVDB sources; file version 224, active-mask compression, zip readable, blosc not)
21
+ - `to_openvdb` / `from_openvdb` convert live grids through `pyopenvdb` when the bindings exist
22
+ (Houdini hython, Blender Python, conda-forge)
23
+ - `save` / `load` use the native compact `.xuvdb` format
24
+
25
+ Typical flows against the engine's existing dense solvers:
26
+
27
+ - rigid SDF (落点 1): bake `geom.sdf_val` dense grids with `VdbGrid.from_dense`, edit/CSG them
28
+ sparsely, sample on device via `GpuVolume.sample` / `sdf_normal` and cast rays with
29
+ `ray_surface_hit`
30
+ - MPM / smoke (落点 2, 3): pull density or velocity fields off the dense `qd.field` after a step,
31
+ sparsify, and hand `.vdb` files to Houdini/Blender for volume rendering, or bring Houdini caches
32
+ back in for initial conditions
33
+ - liquid & oil (落点 4, SPH): rasterize particles to fog density volumes with `scatter_particles`
34
+ (unit-integral SPH kernels; one grid per phase gives mixture fractions) or build particle
35
+ level-set surface proxies with `union_spheres`; import Houdini container/initial-surface `.vdb`
36
+ caches as collision/initialization SDFs sampled on device
37
+
38
+ Kernels run on any quadrants backend; call `init_runtime()` once before the first kernel call if
39
+ `gs.init()` has not run (mirroring `genesis.occ`).
40
+ """
41
+
42
+ from .gpu import GpuVolume
43
+ from .io import load, save
44
+ from .openvdb_bridge import from_openvdb, to_openvdb
45
+ from .openvdb_file import read_vdb, write_vdb
46
+ from .ray import ray_surface_hit
47
+ from .runtime import init_runtime
48
+ from .tree import Leaf, VdbGrid
49
+
50
+ __version__ = "0.1.0"
51
+
52
+ __all__ = [
53
+ "GpuVolume",
54
+ "Leaf",
55
+ "VdbGrid",
56
+ "from_openvdb",
57
+ "init_runtime",
58
+ "load",
59
+ "ray_surface_hit",
60
+ "read_vdb",
61
+ "save",
62
+ "to_openvdb",
63
+ "write_vdb",
64
+ ]
xuvdb/gpu.py ADDED
@@ -0,0 +1,96 @@
1
+ """Host-side packing of a `VdbGrid` into flat buffers consumed by `kernels.py`.
2
+
3
+ A `GpuVolume` is the read-mostly, kernel-writable view of a scalar grid - the XUVDB counterpart of
4
+ a NanoVDB device buffer, except that values are mutable from kernels. Topology is frozen at pack
5
+ time; call `sync_to_host()` to pull kernel-written values back into the host tree, and re-create
6
+ the volume after structural edits (leaf allocation, prune, load).
7
+ """
8
+
9
+ import numpy as np
10
+
11
+ from . import kernels
12
+ from .runtime import init_runtime
13
+ from .tree import VdbGrid
14
+
15
+ _KEY_OFF = 1 << 19
16
+
17
+
18
+ def _pack_key(kx, ky, kz):
19
+ return (int(kx) + _KEY_OFF) * (1 << 40) + (int(ky) + _KEY_OFF) * (1 << 20) + (int(kz) + _KEY_OFF)
20
+
21
+
22
+ class GpuVolume:
23
+ """Flat, key-sorted leaf table of a scalar grid plus kernel entry points."""
24
+
25
+ def __init__(self, grid):
26
+ if not isinstance(grid, VdbGrid):
27
+ raise TypeError("GpuVolume wraps an xuvdb.VdbGrid")
28
+ if grid.is_vec or grid.type_code == 1:
29
+ raise TypeError("kernel packing currently supports float32 scalar grids")
30
+ if grid.n_leaves == 0:
31
+ raise ValueError("grid has no leaves")
32
+ init_runtime()
33
+ self.grid = grid
34
+ leaves = grid.leaves()
35
+ keys = np.array([_pack_key(*key) for key in sorted(grid._leaves)], dtype=np.int64)
36
+ if not np.all(keys[:-1] < keys[1:]):
37
+ raise ValueError("leaf keys out of range or duplicated")
38
+ per_leaf = grid.leaf_dim**3
39
+ values = np.empty(len(leaves) * per_leaf, dtype=np.float32)
40
+ for i, leaf in enumerate(leaves):
41
+ values[i * per_leaf:(i + 1) * per_leaf] = leaf.values.ravel() # z-fastest C order
42
+ self.keys = keys
43
+ self.values = values
44
+ self.background = float(grid.background)
45
+ self._dirty = False
46
+
47
+ # ------------------------------------------------------------------ queries
48
+
49
+ def sample(self, points, linear=False):
50
+ """Sample the packed volume at world-space points (N,3)."""
51
+ points = np.ascontiguousarray(points, dtype=np.float32).reshape(-1, 3)
52
+ out = np.empty(len(points), dtype=np.float32)
53
+ kernel = kernels.kernel_sample_linear if linear else kernels.kernel_sample_nearest
54
+ kernel(
55
+ self.keys, self.values, int(self.grid.n_leaves), int(self.grid.leaf_log2), float(self.background),
56
+ *(float(v) for v in self.grid.origin_world), *(float(v) for v in self.grid.voxel_size),
57
+ points, out,
58
+ )
59
+ return out
60
+
61
+ def sdf_normal(self, points):
62
+ """Central-difference SDF gradient (world-space, normalized) at world-space points."""
63
+ points = np.ascontiguousarray(points, dtype=np.float32).reshape(-1, 3)
64
+ out = np.empty((len(points), 3), dtype=np.float32)
65
+ kernels.kernel_sdf_normal(
66
+ self.keys, self.values, int(self.grid.n_leaves), int(self.grid.leaf_log2), float(self.background),
67
+ *(float(v) for v in self.grid.origin_world), *(float(v) for v in self.grid.voxel_size),
68
+ points, out,
69
+ )
70
+ return out
71
+
72
+ # ------------------------------------------------------------------ mutation
73
+
74
+ def write_voxels(self, points, new_values):
75
+ """Overwrite voxel values from kernels; call `sync_to_host` to publish into the tree."""
76
+ points = np.ascontiguousarray(points, dtype=np.float32).reshape(-1, 3)
77
+ new_values = np.ascontiguousarray(new_values, dtype=np.float32).reshape(-1)
78
+ if len(new_values) != len(points):
79
+ raise ValueError("new_values must have one entry per point")
80
+ kernels.kernel_write_voxels(
81
+ self.keys, self.values, int(self.grid.n_leaves), int(self.grid.leaf_log2),
82
+ *(float(v) for v in self.grid.origin_world), *(float(v) for v in self.grid.voxel_size),
83
+ points, new_values,
84
+ )
85
+ self._dirty = True
86
+ return self
87
+
88
+ def sync_to_host(self):
89
+ """Copy kernel-written values back into the host grid's leaves."""
90
+ per_leaf = self.grid.leaf_dim**3
91
+ for i, leaf in enumerate(self.grid.leaves()):
92
+ leaf.values[...] = self.values[i * per_leaf:(i + 1) * per_leaf].reshape(
93
+ self.grid.leaf_dim, self.grid.leaf_dim, self.grid.leaf_dim
94
+ )
95
+ self._dirty = False
96
+ return self.grid
xuvdb/io.py ADDED
@@ -0,0 +1,173 @@
1
+ """Serialization of XUVDB grids to the native `.xuvdb` binary format.
2
+
3
+ The extension is deliberately not `.vdb`: Houdini, Blender, Cycles and Arnold all parse `.vdb` as
4
+ OpenVDB, and foreign bytes under that suffix fail silently or crash - `save()` refuses such paths
5
+ outright. Interop with OpenVDB is an explicit, separate export (`openvdb_file.write_vdb`), which
6
+ emits a real OpenVDB stream.
7
+
8
+ Format (little-endian throughout; `u` = unsigned, `i` = signed, `f` = IEEE float):
9
+
10
+ magic 5 bytes "XUVDB"
11
+ version u8 (currently 1)
12
+ flags u8 (0)
13
+ n_grids u16
14
+
15
+ per grid:
16
+ name u32 length + utf-8 bytes
17
+ type_code u8 (0 = float32, 1 = float64, 2 = vec3 float32)
18
+ leaf_log2 u8
19
+ class_code u8 (0 unknown, 1 level set, 2 fog volume, 3 staggered)
20
+ reserved u8
21
+ voxel_size f64[3]
22
+ origin_world f64[3]
23
+ background f32 / f64 / f32[3] (per type_code)
24
+ n_leaves u32
25
+ per leaf, sorted by leaf origin (i, then j, then k):
26
+ origin i32[3] (in voxels, of the leaf's (0,0,0) corner)
27
+ mask u64[dim^3 / 64] (active bits, z-fastest bit order)
28
+ values value_type[dim^3] (full dense leaf, z-fastest order)
29
+
30
+ The z-fastest voxel order (linear index `n = x*dim^2 + y*dim + z`) matches the OpenVDB leaf layout,
31
+ so round trips through `openvdb_file.py` are transpose-free. Like OpenVDB leaf nodes, a leaf's full
32
+ dense buffer is stored - inactive values (e.g. a level set's -background interior) survive a save /
33
+ load cycle.
34
+ """
35
+
36
+ import struct
37
+
38
+ import numpy as np
39
+
40
+ from .tree import GRID_CLASSES, VdbGrid
41
+
42
+ MAGIC = b"XUVDB"
43
+ VERSION = 1
44
+
45
+ _TYPE_BY_CODE = {0: np.float32, 1: np.float64}
46
+ _CODE_BY_TYPE = {np.dtype(np.float32): 0, np.dtype(np.float64): 1}
47
+
48
+
49
+ def _pack_str(s):
50
+ b = s.encode("utf-8")
51
+ return struct.pack("<I", len(b)) + b
52
+
53
+
54
+ def _mask_words(active):
55
+ """Pack a bool (dim,dim,dim) mask into u64 words, bit n = voxel n (z-fastest C order)."""
56
+ bits = np.packbits(active.reshape(-1), bitorder="little")
57
+ return np.pad(bits, (0, (-len(bits)) % 8)).view(np.uint64)
58
+
59
+
60
+ def _mask_from_words(words, count):
61
+ bits = words.view(np.uint8)
62
+ active = np.unpackbits(bits[: (count + 7) // 8], count=count, bitorder="little")
63
+ return active.astype(bool)
64
+
65
+
66
+ def save(path, grids):
67
+ """Write one or more grids to a `.xuvdb` file."""
68
+ path = str(path)
69
+ if path.lower().endswith(".vdb"):
70
+ raise ValueError(
71
+ f"refusing to write the XUVDB native format to {path!r}: every major volume tool "
72
+ "(Houdini, Blender, Cycles, Arnold) parses '.vdb' as OpenVDB and fails opaquely on "
73
+ "foreign bytes. Use the '.xuvdb' extension, or export a real OpenVDB stream with "
74
+ "write_vdb()."
75
+ )
76
+ grids = list(grids)
77
+ buf = bytearray()
78
+ buf += MAGIC
79
+ buf += struct.pack("<BBI", VERSION, 0, len(grids))
80
+ for grid in grids:
81
+ buf += _pack_str(grid.name)
82
+ bg = np.asarray(grid.background, dtype=np.float64).reshape(-1)
83
+ buf += struct.pack("<BBBB", grid.type_code, grid.leaf_log2, GRID_CLASSES.index(grid.grid_class), 0)
84
+ buf += grid.voxel_size.astype("<f8").tobytes()
85
+ buf += grid.origin_world.astype("<f8").tobytes()
86
+ if grid.type_code == 0:
87
+ buf += struct.pack("<f", float(bg[0]))
88
+ elif grid.type_code == 1:
89
+ buf += struct.pack("<d", float(bg[0]))
90
+ else:
91
+ buf += bg.astype("<f4").tobytes()
92
+ leaves = grid.leaves()
93
+ buf += struct.pack("<I", len(leaves))
94
+ for leaf in leaves:
95
+ buf += leaf.origin.astype("<i4").tobytes()
96
+ mask = _mask_words(leaf.active)
97
+ buf += mask.astype("<u8").tobytes()
98
+ vals = leaf.values.reshape(-1) # z-fastest C order
99
+ if grid.type_code == 1:
100
+ buf += vals.astype("<f8").tobytes()
101
+ else:
102
+ buf += vals.astype("<f4").tobytes()
103
+ with open(path, "wb") as f:
104
+ f.write(buf)
105
+ return len(buf)
106
+
107
+
108
+ class _Reader:
109
+ def __init__(self, data):
110
+ self.data = data
111
+ self.pos = 0
112
+
113
+ def read(self, n):
114
+ if self.pos + n > len(self.data):
115
+ raise ValueError("truncated .xuvdb stream")
116
+ out = self.data[self.pos:self.pos + n]
117
+ self.pos += n
118
+ return out
119
+
120
+
121
+ def load(path):
122
+ """Read all grids from a `.xuvdb` file."""
123
+ with open(path, "rb") as f:
124
+ data = f.read()
125
+ r = _Reader(data)
126
+ if r.read(len(MAGIC)) != MAGIC:
127
+ raise ValueError(f"{path} is not an XUVDB file")
128
+ version, flags, n_grids = struct.unpack("<BBI", r.read(6))
129
+ if version != VERSION:
130
+ raise ValueError(f"unsupported XUVDB version {version}")
131
+ if flags != 0:
132
+ raise ValueError(f"unsupported XUVDB flags {flags}")
133
+ grids = []
134
+ for _ in range(n_grids):
135
+ (name_len,) = struct.unpack("<I", r.read(4))
136
+ name = r.read(name_len).decode("utf-8")
137
+ type_code, leaf_log2, class_code, _res = struct.unpack("<BBBB", r.read(4))
138
+ voxel_size = np.frombuffer(r.read(24), dtype="<f8")
139
+ origin_world = np.frombuffer(r.read(24), dtype="<f8")
140
+ if type_code == 0:
141
+ (background,) = struct.unpack("<f", r.read(4))
142
+ elif type_code == 1:
143
+ (background,) = struct.unpack("<d", r.read(8))
144
+ else:
145
+ background = np.frombuffer(r.read(12), dtype="<f4").copy()
146
+ grid = VdbGrid(
147
+ dtype=_TYPE_BY_CODE.get(type_code, None) if type_code != 2
148
+ else np.dtype([("x", np.float32), ("y", np.float32), ("z", np.float32)]),
149
+ background=background,
150
+ voxel_size=voxel_size,
151
+ origin_world=origin_world,
152
+ leaf_log2=leaf_log2,
153
+ name=name,
154
+ grid_class=GRID_CLASSES[class_code],
155
+ )
156
+ (n_leaves,) = struct.unpack("<I", r.read(4))
157
+ dim = 1 << leaf_log2
158
+ words = dim**3 // 64
159
+ per_voxel = 3 if type_code == 2 else 1
160
+ item = np.dtype("<f4") if type_code != 1 else np.dtype("<f8")
161
+ for _leaf in range(n_leaves):
162
+ origin = np.frombuffer(r.read(12), dtype="<i4").astype(np.int64)
163
+ mask_words = np.frombuffer(r.read(words * 8), dtype="<u8")
164
+ active = _mask_from_words(mask_words, dim**3)
165
+ values_flat = np.frombuffer(r.read(dim**3 * per_voxel * item.itemsize), dtype=item)
166
+ leaf = grid._get_or_create_leaf((origin[0] >> leaf_log2, origin[1] >> leaf_log2, origin[2] >> leaf_log2))
167
+ if grid.is_vec:
168
+ leaf.values[...] = values_flat.reshape(dim, dim, dim, 3)
169
+ else:
170
+ leaf.values[...] = values_flat.reshape(dim, dim, dim)
171
+ leaf.active[...] = active.reshape(dim, dim, dim)
172
+ grids.append(grid)
173
+ return grids
xuvdb/kernels.py ADDED
@@ -0,0 +1,258 @@
1
+ """Quadrants kernels for GPU-side sampling and mutation of packed XUVDB volumes.
2
+
3
+ The host packs a scalar grid's leaves into a flat, key-sorted table (`gpu.GpuVolume`): sorted
4
+ `int64` leaf keys, one flat float32 value buffer, and the world-space transform scalars. Kernels
5
+ resolve a world point to its leaf by packing the leaf coordinates into a 60-bit key and binary
6
+ searching the sorted key array - no hash table, so the same buffer is valid on CPU and GPU
7
+ backends.
8
+
9
+ Value mutation (`kernel_write_voxels`) writes the packed buffer in place: unlike NanoVDB grids,
10
+ values are writable from kernels. Topology stays fixed for the lifetime of a packed volume; the
11
+ host re-packs after structural edits (leaf allocation, prune, load).
12
+ """
13
+
14
+ import quadrants as qd
15
+
16
+ _KEY_OFF = 1 << 19 # per-axis leaf coordinate offset; supports +-524288 leaves per axis
17
+
18
+
19
+ @qd.func
20
+ def xuvdb_pack_key(kx: qd.i64, ky: qd.i64, kz: qd.i64) -> qd.i64:
21
+ return (kx + qd.i64(_KEY_OFF)) * qd.i64(1 << 40) + (ky + qd.i64(_KEY_OFF)) * qd.i64(1 << 20) + (
22
+ kz + qd.i64(_KEY_OFF)
23
+ )
24
+
25
+
26
+ @qd.func
27
+ def xuvdb_find_leaf(
28
+ keys: qd.types.ndarray(ndim=1),
29
+ n_leaves: int,
30
+ kx: qd.i64,
31
+ ky: qd.i64,
32
+ kz: qd.i64,
33
+ ) -> int:
34
+ key = xuvdb_pack_key(kx, ky, kz)
35
+ lo = 0
36
+ hi = n_leaves
37
+ while lo < hi:
38
+ mid = (lo + hi) // 2
39
+ if keys[mid] < key:
40
+ lo = mid + 1
41
+ else:
42
+ hi = mid
43
+ found = lo
44
+ if lo >= n_leaves or keys[lo] != key:
45
+ found = -1
46
+ return found
47
+
48
+
49
+ @qd.func
50
+ def xuvdb_voxel_value(
51
+ keys: qd.types.ndarray(ndim=1),
52
+ values: qd.types.ndarray(ndim=1),
53
+ n_leaves: int,
54
+ leaf_log2: int,
55
+ background: float,
56
+ xi: qd.i64,
57
+ yi: qd.i64,
58
+ zi: qd.i64,
59
+ ) -> float:
60
+ dim = 1 << leaf_log2
61
+ leaf_idx = xuvdb_find_leaf(keys, n_leaves, xi >> leaf_log2, yi >> leaf_log2, zi >> leaf_log2)
62
+ lx = xi & qd.i64(dim - 1)
63
+ ly = yi & qd.i64(dim - 1)
64
+ lz = zi & qd.i64(dim - 1)
65
+ n = lx * qd.i64(dim * dim) + ly * qd.i64(dim) + lz
66
+ value = background
67
+ if leaf_idx >= 0:
68
+ value = values[leaf_idx * qd.i64(dim * dim * dim) + n]
69
+ return value
70
+
71
+
72
+ @qd.func
73
+ def xuvdb_sample_nearest(
74
+ keys: qd.types.ndarray(ndim=1),
75
+ values: qd.types.ndarray(ndim=1),
76
+ n_leaves: int,
77
+ leaf_log2: int,
78
+ background: float,
79
+ o_x: float,
80
+ o_y: float,
81
+ o_z: float,
82
+ s_x: float,
83
+ s_y: float,
84
+ s_z: float,
85
+ x: float,
86
+ y: float,
87
+ z: float,
88
+ ) -> float:
89
+ xi = qd.i64(qd.floor((x - o_x) / s_x + 0.5))
90
+ yi = qd.i64(qd.floor((y - o_y) / s_y + 0.5))
91
+ zi = qd.i64(qd.floor((z - o_z) / s_z + 0.5))
92
+ return xuvdb_voxel_value(keys, values, n_leaves, leaf_log2, background, xi, yi, zi)
93
+
94
+
95
+ @qd.func
96
+ def xuvdb_sample_linear(
97
+ keys: qd.types.ndarray(ndim=1),
98
+ values: qd.types.ndarray(ndim=1),
99
+ n_leaves: int,
100
+ leaf_log2: int,
101
+ background: float,
102
+ o_x: float,
103
+ o_y: float,
104
+ o_z: float,
105
+ s_x: float,
106
+ s_y: float,
107
+ s_z: float,
108
+ x: float,
109
+ y: float,
110
+ z: float,
111
+ ) -> float:
112
+ cx = (x - o_x) / s_x
113
+ cy = (y - o_y) / s_y
114
+ cz = (z - o_z) / s_z
115
+ bx = qd.i64(qd.floor(cx))
116
+ by = qd.i64(qd.floor(cy))
117
+ bz = qd.i64(qd.floor(cz))
118
+ fx = cx - qd.floor(cx)
119
+ fy = cy - qd.floor(cy)
120
+ fz = cz - qd.floor(cz)
121
+ out = qd.f32(0.0)
122
+ for dx in range(2):
123
+ wx = fx if dx == 1 else 1.0 - fx
124
+ for dy in range(2):
125
+ wy = fy if dy == 1 else 1.0 - fy
126
+ for dz in range(2):
127
+ wz = fz if dz == 1 else 1.0 - fz
128
+ out += qd.f32(
129
+ wx * wy * wz
130
+ ) * qd.f32(
131
+ xuvdb_voxel_value(keys, values, n_leaves, leaf_log2, background,
132
+ bx + qd.i64(dx), by + qd.i64(dy), bz + qd.i64(dz))
133
+ )
134
+ return out
135
+
136
+
137
+ @qd.kernel
138
+ def kernel_sample_nearest(
139
+ keys: qd.types.ndarray(ndim=1),
140
+ values: qd.types.ndarray(ndim=1),
141
+ n_leaves: int,
142
+ leaf_log2: int,
143
+ background: float,
144
+ o_x: float,
145
+ o_y: float,
146
+ o_z: float,
147
+ s_x: float,
148
+ s_y: float,
149
+ s_z: float,
150
+ points: qd.types.ndarray(ndim=2),
151
+ out: qd.types.ndarray(ndim=1),
152
+ ):
153
+ for i in range(points.shape[0]):
154
+ out[i] = xuvdb_sample_nearest(
155
+ keys, values, n_leaves, leaf_log2, background, o_x, o_y, o_z, s_x, s_y, s_z,
156
+ points[i, 0], points[i, 1], points[i, 2],
157
+ )
158
+
159
+
160
+ @qd.kernel
161
+ def kernel_sample_linear(
162
+ keys: qd.types.ndarray(ndim=1),
163
+ values: qd.types.ndarray(ndim=1),
164
+ n_leaves: int,
165
+ leaf_log2: int,
166
+ background: float,
167
+ o_x: float,
168
+ o_y: float,
169
+ o_z: float,
170
+ s_x: float,
171
+ s_y: float,
172
+ s_z: float,
173
+ points: qd.types.ndarray(ndim=2),
174
+ out: qd.types.ndarray(ndim=1),
175
+ ):
176
+ for i in range(points.shape[0]):
177
+ out[i] = xuvdb_sample_linear(
178
+ keys, values, n_leaves, leaf_log2, background, o_x, o_y, o_z, s_x, s_y, s_z,
179
+ points[i, 0], points[i, 1], points[i, 2],
180
+ )
181
+
182
+
183
+ @qd.kernel
184
+ def kernel_sdf_normal(
185
+ keys: qd.types.ndarray(ndim=1),
186
+ values: qd.types.ndarray(ndim=1),
187
+ n_leaves: int,
188
+ leaf_log2: int,
189
+ background: float,
190
+ o_x: float,
191
+ o_y: float,
192
+ o_z: float,
193
+ s_x: float,
194
+ s_y: float,
195
+ s_z: float,
196
+ points: qd.types.ndarray(ndim=2),
197
+ out: qd.types.ndarray(ndim=2),
198
+ ):
199
+ """Central-difference surface normal of the SDF field at each point (world-space, normalized)."""
200
+ for i in range(points.shape[0]):
201
+ x = points[i, 0]
202
+ y = points[i, 1]
203
+ z = points[i, 2]
204
+ nx = xuvdb_sample_linear(keys, values, n_leaves, leaf_log2, background, o_x, o_y, o_z, s_x, s_y, s_z,
205
+ x + s_x, y, z) - xuvdb_sample_linear(keys, values, n_leaves, leaf_log2,
206
+ background, o_x, o_y, o_z, s_x, s_y, s_z,
207
+ x - s_x, y, z)
208
+ ny = xuvdb_sample_linear(keys, values, n_leaves, leaf_log2, background, o_x, o_y, o_z, s_x, s_y, s_z,
209
+ x, y + s_y, z) - xuvdb_sample_linear(keys, values, n_leaves, leaf_log2,
210
+ background, o_x, o_y, o_z, s_x, s_y, s_z,
211
+ x, y - s_y, z)
212
+ nz = xuvdb_sample_linear(keys, values, n_leaves, leaf_log2, background, o_x, o_y, o_z, s_x, s_y, s_z,
213
+ x, y, z + s_z) - xuvdb_sample_linear(keys, values, n_leaves, leaf_log2,
214
+ background, o_x, o_y, o_z, s_x, s_y, s_z,
215
+ x, y, z - s_z)
216
+ norm = qd.sqrt(nx * nx + ny * ny + nz * nz)
217
+ if norm > 1e-12:
218
+ out[i, 0] = nx / norm
219
+ out[i, 1] = ny / norm
220
+ out[i, 2] = nz / norm
221
+ else:
222
+ out[i, 0] = 0.0
223
+ out[i, 1] = 0.0
224
+ out[i, 2] = 0.0
225
+
226
+
227
+ @qd.kernel
228
+ def kernel_write_voxels(
229
+ keys: qd.types.ndarray(ndim=1),
230
+ values: qd.types.ndarray(ndim=1),
231
+ n_leaves: int,
232
+ leaf_log2: int,
233
+ o_x: float,
234
+ o_y: float,
235
+ o_z: float,
236
+ s_x: float,
237
+ s_y: float,
238
+ s_z: float,
239
+ points: qd.types.ndarray(ndim=2),
240
+ new_values: qd.types.ndarray(ndim=1),
241
+ ):
242
+ """Overwrite the value of the voxel nearest each point (in-place; topology unchanged).
243
+
244
+ The active mask is host-side state and is not touched: deactivating/reactivating voxels or
245
+ allocating leaves requires re-packing on the host.
246
+ """
247
+ dim = 1 << leaf_log2
248
+ for i in range(points.shape[0]):
249
+ xi = qd.i64(qd.floor((points[i, 0] - o_x) / s_x + 0.5))
250
+ yi = qd.i64(qd.floor((points[i, 1] - o_y) / s_y + 0.5))
251
+ zi = qd.i64(qd.floor((points[i, 2] - o_z) / s_z + 0.5))
252
+ leaf_idx = xuvdb_find_leaf(keys, n_leaves, xi >> leaf_log2, yi >> leaf_log2, zi >> leaf_log2)
253
+ if leaf_idx >= 0:
254
+ lx = xi & qd.i64(dim - 1)
255
+ ly = yi & qd.i64(dim - 1)
256
+ lz = zi & qd.i64(dim - 1)
257
+ n = lx * qd.i64(dim * dim) + ly * qd.i64(dim) + lz
258
+ values[leaf_idx * qd.i64(dim * dim * dim) + n] = new_values[i]
@@ -0,0 +1,101 @@
1
+ """Optional in-memory bridge to `pyopenvdb` (the OpenVDB Python bindings).
2
+
3
+ Used when the bindings are importable (Houdini's hython, Blender's bundled Python, conda-forge).
4
+ The file-level interop path that needs no bindings at all is `openvdb_file.py`.
5
+ """
6
+
7
+ import numpy as np
8
+
9
+ try:
10
+ import pyopenvdb
11
+ except ImportError: # the module stays importable; the converters raise on use
12
+ pyopenvdb = None
13
+
14
+
15
+ def _require_bindings():
16
+ if pyopenvdb is None:
17
+ raise ImportError(
18
+ "pyopenvdb is not installed; use xuvdb.openvdb_file.read_vdb/write_vdb for "
19
+ "file-level interop instead"
20
+ )
21
+
22
+
23
+ def _openvdb_grid_class(name):
24
+ _require_bindings()
25
+ return {
26
+ "unknown": pyopenvdb.GridClass.UNKNOWN,
27
+ "level set": pyopenvdb.GridClass.LEVEL_SET,
28
+ "fog volume": pyopenvdb.GridClass.FOG_VOLUME,
29
+ "staggered": pyopenvdb.GridClass.STAGGERED,
30
+ }[name]
31
+
32
+
33
+ def _xuvdb_grid_class(ovdb_value):
34
+ _require_bindings()
35
+ for name, value in (
36
+ ("unknown", pyopenvdb.GridClass.UNKNOWN),
37
+ ("level set", pyopenvdb.GridClass.LEVEL_SET),
38
+ ("fog volume", pyopenvdb.GridClass.FOG_VOLUME),
39
+ ("staggered", pyopenvdb.GridClass.STAGGERED),
40
+ ):
41
+ if int(value) == int(ovdb_value):
42
+ return name
43
+ return "unknown"
44
+
45
+
46
+ def to_openvdb(grid):
47
+ """Convert a `VdbGrid` into a live `pyopenvdb` grid (FloatGrid / DoubleGrid / Vec3SGrid)."""
48
+ _require_bindings()
49
+ if grid.is_vec:
50
+ ovdb = pyopenvdb.Vec3SGrid(background=tuple(float(v) for v in grid.background))
51
+ elif grid.type_code == 1:
52
+ ovdb = pyopenvdb.DoubleGrid(background=float(grid.background))
53
+ else:
54
+ ovdb = pyopenvdb.FloatGrid(background=float(grid.background))
55
+ ovdb.name = grid.name
56
+ ovdb.gridClass = _openvdb_grid_class(grid.grid_class)
57
+
58
+ matrix = np.eye(4)
59
+ matrix[:3, :3] = np.diag(grid.voxel_size)
60
+ matrix[:3, 3] = grid.origin_world
61
+ ovdb.transform = pyopenvdb.createLinearTransform(matrix=matrix)
62
+
63
+ acc = ovdb.getAccessor()
64
+ for coord, value in grid.iter_voxels():
65
+ acc.setValueOn(coord, tuple(float(v) for v in value) if grid.is_vec else float(value))
66
+ return ovdb
67
+
68
+
69
+ def from_openvdb(ovdb_grid, leaf_log2=3):
70
+ """Convert a `pyopenvdb` grid into a `VdbGrid` (active tiles are materialized into leaves)."""
71
+ _require_bindings()
72
+ from .tree import VdbGrid
73
+
74
+ value_type = ovdb_grid.valueType
75
+ if value_type == "vec3s":
76
+ dtype = np.dtype([("x", np.float32), ("y", np.float32), ("z", np.float32)])
77
+ background = np.asarray(ovdb_grid.backgroundValue, dtype=np.float64).reshape(3)
78
+ elif value_type == "double":
79
+ dtype = np.float64
80
+ background = float(ovdb_grid.backgroundValue)
81
+ else:
82
+ dtype = np.float32
83
+ background = float(ovdb_grid.backgroundValue)
84
+
85
+ matrix = np.array(ovdb_grid.transform.matrix)
86
+ voxel_size = np.array([matrix[0, 0], matrix[1, 1], matrix[2, 2]])
87
+ origin_world = np.array([matrix[0, 3], matrix[1, 3], matrix[2, 3]])
88
+ grid_class = _xuvdb_grid_class(ovdb_grid.gridClass)
89
+
90
+ grid = VdbGrid(dtype=dtype, background=background, voxel_size=voxel_size, origin_world=origin_world,
91
+ leaf_log2=leaf_log2, name=ovdb_grid.name or "grid", grid_class=grid_class)
92
+
93
+ acc = ovdb_grid.getConstAccessor()
94
+ for bbox in ovdb_grid.iterActiveTiles():
95
+ lo, hi = np.array(bbox.min), np.array(bbox.max)
96
+ value = acc.getValue(tuple(int(v) for v in lo))
97
+ grid.fill_box(lo, hi, value, active=True)
98
+ for coord in ovdb_grid.iterActiveVoxels():
99
+ value = acc.getValue(tuple(coord))
100
+ grid.set_value(tuple(coord), tuple(float(v) for v in value) if grid.is_vec else float(value))
101
+ return grid