"""
Proves the baked VDB is not transposed.
Two conventions meet in `write_vdb`, and they disagree:
* `GPUTexture.read()` returns a 3D texture as `(z, y, x*4)`, so after the reshape
the array is indexed `[z][y][x][channel]` — which is what the comment in
`accumulate` says, and what `auto_bounds` assumes (it names its locals `zyx_*`
and explicitly reorders to xyz).
* `openvdb`'s `copyFromArray` reads array index `(i, j, k)` as world `(x, y, z)`.
Handing a `[z][y][x]` array straight to `copyFromArray` therefore swaps x and z.
Nothing catches it downstream because a transposed fractal is still a plausible
fractal — it only shows as a bake whose orientation disagrees with the still
render, which is exactly the kind of silent wrongness this project keeps finding.
Both conventions were measured, not reasoned about: a marker written to voxel
(0,0,2) came back at reshaped index [2,0,0], and `array[3,0,0]=1` produced
`grid(3,0,0)=1`.
Needs openvdb, so it runs inside Blender:
blender -b --factory-startup -noaudio --python blender/test_volume_axes.py
"""
import os
import sys
import tempfile
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
# NOT imported at module scope: `ifs_volume` pulls in `gpu`, which only exists
# inside Blender, and a module-level import turns "skip outside Blender" into a
# collection ERROR for the whole headless suite.
S = 8
# Deliberately asymmetric: if any two axes were swapped, at least one index moves.
MARK_Z, MARK_Y, MARK_X = 5, 3, 1
BOUNDS = ((-1.0, -1.0, -1.0), (1.0, 1.0, 1.0))
# Deliberately non-cubic AND off-origin: catches both a missing translation and a
# single shared voxel size. Extents 2, 4, 6 -> voxel sizes 0.25, 0.5, 0.75 at S=8.
NONCUBIC = ((-1.0, -2.0, -3.0), (1.0, 2.0, 3.0))
def _grid_with_marker():
"""
The pair `accumulate` returns: full-res density and HALF-res colour, indexed
[x][y][z].
The kernel stores voxel (x,y,z) at texel (z,y,x) precisely so the readback
arrives in this order and needs no transpose — doing it host-side cost 17.6s
at N=512. So the fixture is built [x][y][z] too.
Colour is premultiplied by density, as the kernel accumulates it, so the
marker's colour voxel carries density x hue and write_vdb divides it back out
using the 2x2x2 density block.
"""
import numpy as np
density = np.zeros((S, S, S), dtype=np.float32)
density[MARK_X, MARK_Y, MARK_Z] = 1.0
colour = np.zeros((S // 2, S // 2, S // 2, 3), dtype=np.float32)
colour[MARK_X // 2, MARK_Y // 2, MARK_Z // 2] = (1.0, 0.0, 0.0)
return density, colour
class BakeAxes(unittest.TestCase):
@classmethod
def setUpClass(cls):
try:
import openvdb # noqa: F401
import numpy # noqa: F401
import ifs_volume # noqa: F401
except ImportError as ex:
raise unittest.SkipTest(f"needs Blender's openvdb/gpu modules ({ex})")
def _write_and_read(self):
import openvdb
import ifs_volume
tmp = tempfile.mkdtemp()
path = os.path.join(tmp, "axes.vdb")
ifs_volume.write_vdb(*_grid_with_marker(), BOUNDS, path)
grids = openvdb.readAllGridMetadata(path)
names = [g.name for g in grids]
out = {}
for name in names:
out[name] = openvdb.read(path, name)
return out
def test_density_voxel_lands_at_the_world_position_it_was_written_to(self):
grids = self._write_and_read()
self.assertIn("density", grids)
acc = grids["density"].getConstAccessor()
correct = acc.getValue((MARK_X, MARK_Y, MARK_Z))
swapped = acc.getValue((MARK_Z, MARK_Y, MARK_X))
self.assertGreater(
correct, 0.5,
"the lit voxel is not at (x, y, z) = (%d, %d, %d); the kernel stores "
"voxel (x,y,z) at texel (z,y,x) so the readback is [x][y][z], which is "
"what copyFromArray reads as (x, y, z) — no transpose should be needed"
% (MARK_X, MARK_Y, MARK_Z))
self.assertLess(
swapped, 0.5,
"the lit voxel is at the x/z-SWAPPED position — the bake is transposed")
def test_colour_grid_uses_the_same_convention_as_density(self):
"""A colour grid transposed differently from density would tint the wrong
parts of the fractal, which is even harder to spot than a global rotation."""
grids = self._write_and_read()
if "color" not in grids:
self.skipTest("colour grid not written (openvdb Vec3SGrid unavailable)")
# Colour is HALF resolution, so the same world point is at half the index.
# The axis ORDER is what this checks, and halving preserves it.
acc = grids["color"].getConstAccessor()
value = acc.getValue((MARK_X // 2, MARK_Y // 2, MARK_Z // 2))
self.assertGreater(value[0], 0.5,
"colour grid disagrees with the density grid's axes")
def test_the_marker_is_asymmetric_enough_to_detect_a_swap(self):
"""
Guards the guard: equal indices would make a transpose invisible.
Checked at BOTH resolutions. Colour is half-res, so a marker whose indices
are distinct but collide when halved (say 4, 5, 6 -> 2, 2, 3) would leave
the colour check unable to see a swap while still looking like a test.
"""
self.assertEqual(3, len({MARK_X, MARK_Y, MARK_Z}))
self.assertEqual(3, len({MARK_X // 2, MARK_Y // 2, MARK_Z // 2}),
"the halved marker indices collide, so the half-res "
"colour grid cannot detect a transpose")
# ------------------------------------------------------- placement and scale
def _write_noncubic(self):
"""`auto_bounds` fits each axis independently, so non-cubic bounds are the
normal case, not an edge case."""
import openvdb
import ifs_volume
tmp = tempfile.mkdtemp()
path = os.path.join(tmp, "placed.vdb")
ifs_volume.write_vdb(*_grid_with_marker(), NONCUBIC, path)
return openvdb.read(path, "density")
def test_voxel_lands_at_its_world_position_not_at_the_origin(self):
"""
The grid must sit inside the bounds `auto_bounds` chose. A transform carrying
only a voxel size leaves the volume at world (0 .. size*voxel) instead — the
fractal renders in the wrong place, still looking like a fractal.
The expected position carries a HALF-VOXEL offset because `indexToWorld(i)`
is the voxel's CENTRE, not the near corner of its cell — measured, not
assumed: a grid with one lit voxel at index (0,0,0) and voxel size 1 imports
into Blender with its bounding box starting at -0.5. `accumulate_voxel` bins
a sample into the cell it falls inside, so voxel i holds the mass from
[lo + i*s, lo + (i+1)*s), whose centre is lo + (i + 0.5)*s.
This test USED to expect `lo + i*s` and passed, because it checked a marker
against the same convention `write_vdb` wrote it with — the two agreed with
each other while both sat half a voxel from where the fractal actually is.
Only a reference outside the voxel path can see that; test_bake_orientation
is that reference.
"""
grid = self._write_noncubic()
lo, hi = NONCUBIC
expected = tuple(lo[a] + ((MARK_X, MARK_Y, MARK_Z)[a] + 0.5)
* (hi[a] - lo[a]) / S
for a in range(3))
actual = grid.transform.indexToWorld((MARK_X, MARK_Y, MARK_Z))
for axis, (e, a) in enumerate(zip(expected, actual)):
self.assertAlmostEqual(
e, a, places=5,
msg=f"axis {axis}: voxel maps to {a}, expected {e} — the grid "
"transform is missing the translation to the bake bounds")
def test_each_axis_gets_its_own_voxel_size(self):
"""
A single voxel size taken from the x extent squashes y and z whenever the
bounds are not cubic, which distorts the fractal's proportions.
"""
grid = self._write_noncubic()
lo, hi = NONCUBIC
origin = grid.transform.indexToWorld((0, 0, 0))
for axis in range(3):
step = [0, 0, 0]
step[axis] = 1
one = grid.transform.indexToWorld(tuple(step))
measured = one[axis] - origin[axis]
expected = (hi[axis] - lo[axis]) / S
self.assertAlmostEqual(
expected, measured, places=5,
msg=f"axis {axis}: voxel size {measured}, expected {expected} — "
"a single scale taken from the x extent distorts the other axes")
if __name__ == "__main__":
unittest.main(argv=sys.argv[:1], exit=False, verbosity=2)