OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux / blender / test_volume_axes.py

198 lines · python ref:
"""
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)