| … | … | @@ -0,0 +1,159 @@ |
| 1 | """ |
| 2 | Proves the baked VDB is not transposed. |
| 3 | |
| 4 | Two conventions meet in `write_vdb`, and they disagree: |
| 5 | |
| 6 | * `GPUTexture.read()` returns a 3D texture as `(z, y, x*4)`, so after the reshape |
| 7 | the array is indexed `[z][y][x][channel]` — which is what the comment in |
| 8 | `accumulate` says, and what `auto_bounds` assumes (it names its locals `zyx_*` |
| 9 | and explicitly reorders to xyz). |
| 10 | * `openvdb`'s `copyFromArray` reads array index `(i, j, k)` as world `(x, y, z)`. |
| 11 | |
| 12 | Handing a `[z][y][x]` array straight to `copyFromArray` therefore swaps x and z. |
| 13 | Nothing catches it downstream because a transposed fractal is still a plausible |
| 14 | fractal — it only shows as a bake whose orientation disagrees with the still |
| 15 | render, which is exactly the kind of silent wrongness this project keeps finding. |
| 16 | |
| 17 | Both conventions were measured, not reasoned about: a marker written to voxel |
| 18 | (0,0,2) came back at reshaped index [2,0,0], and `array[3,0,0]=1` produced |
| 19 | `grid(3,0,0)=1`. |
| 20 | |
| 21 | Needs openvdb, so it runs inside Blender: |
| 22 | blender -b --factory-startup -noaudio --python blender/test_volume_axes.py |
| 23 | """ |
| 24 | |
| 25 | import os |
| 26 | import sys |
| 27 | import tempfile |
| 28 | import unittest |
| 29 | |
| 30 | sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) |
| 31 | |
| 32 | # NOT imported at module scope: `ifs_volume` pulls in `gpu`, which only exists |
| 33 | # inside Blender, and a module-level import turns "skip outside Blender" into a |
| 34 | # collection ERROR for the whole headless suite. |
| 35 | |
| 36 | S = 8 |
| 37 | # Deliberately asymmetric: if any two axes were swapped, at least one index moves. |
| 38 | MARK_Z, MARK_Y, MARK_X = 5, 3, 1 |
| 39 | BOUNDS = ((-1.0, -1.0, -1.0), (1.0, 1.0, 1.0)) |
| 40 | # Deliberately non-cubic AND off-origin: catches both a missing translation and a |
| 41 | # single shared voxel size. Extents 2, 4, 6 -> voxel sizes 0.25, 0.5, 0.75 at S=8. |
| 42 | NONCUBIC = ((-1.0, -2.0, -3.0), (1.0, 2.0, 3.0)) |
| 43 | |
| 44 | |
| 45 | def _grid_with_marker(): |
| 46 | """A (z, y, x, 4) accumulator with one lit voxel, as `accumulate` returns.""" |
| 47 | import numpy as np |
| 48 | grid = np.zeros((S, S, S, 4), dtype=np.float32) |
| 49 | grid[MARK_Z, MARK_Y, MARK_X, 3] = 1.0 # density |
| 50 | grid[MARK_Z, MARK_Y, MARK_X, :3] = (1.0, 0.0, 0.0) # colour, pre-divided by density |
| 51 | return grid |
| 52 | |
| 53 | |
| 54 | class BakeAxes(unittest.TestCase): |
| 55 | @classmethod |
| 56 | def setUpClass(cls): |
| 57 | try: |
| 58 | import openvdb # noqa: F401 |
| 59 | import numpy # noqa: F401 |
| 60 | import ifs_volume # noqa: F401 |
| 61 | except ImportError as ex: |
| 62 | raise unittest.SkipTest(f"needs Blender's openvdb/gpu modules ({ex})") |
| 63 | |
| 64 | def _write_and_read(self): |
| 65 | import openvdb |
| 66 | import ifs_volume |
| 67 | tmp = tempfile.mkdtemp() |
| 68 | path = os.path.join(tmp, "axes.vdb") |
| 69 | ifs_volume.write_vdb(_grid_with_marker(), BOUNDS, path) |
| 70 | grids = openvdb.readAllGridMetadata(path) |
| 71 | names = [g.name for g in grids] |
| 72 | out = {} |
| 73 | for name in names: |
| 74 | out[name] = openvdb.read(path, name) |
| 75 | return out |
| 76 | |
| 77 | def test_density_voxel_lands_at_the_world_position_it_was_written_to(self): |
| 78 | grids = self._write_and_read() |
| 79 | self.assertIn("density", grids) |
| 80 | acc = grids["density"].getConstAccessor() |
| 81 | |
| 82 | correct = acc.getValue((MARK_X, MARK_Y, MARK_Z)) |
| 83 | swapped = acc.getValue((MARK_Z, MARK_Y, MARK_X)) |
| 84 | |
| 85 | self.assertGreater( |
| 86 | correct, 0.5, |
| 87 | "the lit voxel is not at (x, y, z) = (%d, %d, %d); the array is indexed " |
| 88 | "[z][y][x] and copyFromArray reads (i, j, k) as (x, y, z), so it needs " |
| 89 | "transposing before the write" % (MARK_X, MARK_Y, MARK_Z)) |
| 90 | self.assertLess( |
| 91 | swapped, 0.5, |
| 92 | "the lit voxel is at the x/z-SWAPPED position — the bake is transposed") |
| 93 | |
| 94 | def test_colour_grid_uses_the_same_convention_as_density(self): |
| 95 | """A colour grid transposed differently from density would tint the wrong |
| 96 | parts of the fractal, which is even harder to spot than a global rotation.""" |
| 97 | grids = self._write_and_read() |
| 98 | if "color" not in grids: |
| 99 | self.skipTest("colour grid not written (openvdb Vec3SGrid unavailable)") |
| 100 | acc = grids["color"].getConstAccessor() |
| 101 | value = acc.getValue((MARK_X, MARK_Y, MARK_Z)) |
| 102 | self.assertGreater(value[0], 0.5, |
| 103 | "colour grid disagrees with the density grid's axes") |
| 104 | |
| 105 | def test_the_marker_is_asymmetric_enough_to_detect_a_swap(self): |
| 106 | """Guards the guard: equal indices would make a transpose invisible.""" |
| 107 | self.assertEqual(3, len({MARK_X, MARK_Y, MARK_Z})) |
| 108 | |
| 109 | # ------------------------------------------------------- placement and scale |
| 110 | |
| 111 | def _write_noncubic(self): |
| 112 | """`auto_bounds` fits each axis independently, so non-cubic bounds are the |
| 113 | normal case, not an edge case.""" |
| 114 | import openvdb |
| 115 | import ifs_volume |
| 116 | tmp = tempfile.mkdtemp() |
| 117 | path = os.path.join(tmp, "placed.vdb") |
| 118 | ifs_volume.write_vdb(_grid_with_marker(), NONCUBIC, path) |
| 119 | return openvdb.read(path, "density") |
| 120 | |
| 121 | def test_voxel_lands_at_its_world_position_not_at_the_origin(self): |
| 122 | """ |
| 123 | The grid must sit inside the bounds `auto_bounds` chose. A transform carrying |
| 124 | only a voxel size leaves the volume at world (0 .. size*voxel) instead — the |
| 125 | fractal renders in the wrong place, still looking like a fractal. |
| 126 | """ |
| 127 | grid = self._write_noncubic() |
| 128 | lo, hi = NONCUBIC |
| 129 | expected = tuple(lo[a] + (MARK_X, MARK_Y, MARK_Z)[a] * (hi[a] - lo[a]) / S |
| 130 | for a in range(3)) |
| 131 | actual = grid.transform.indexToWorld((MARK_X, MARK_Y, MARK_Z)) |
| 132 | for axis, (e, a) in enumerate(zip(expected, actual)): |
| 133 | self.assertAlmostEqual( |
| 134 | e, a, places=5, |
| 135 | msg=f"axis {axis}: voxel maps to {a}, expected {e} — the grid " |
| 136 | "transform is missing the translation to the bake bounds") |
| 137 | |
| 138 | def test_each_axis_gets_its_own_voxel_size(self): |
| 139 | """ |
| 140 | A single voxel size taken from the x extent squashes y and z whenever the |
| 141 | bounds are not cubic, which distorts the fractal's proportions. |
| 142 | """ |
| 143 | grid = self._write_noncubic() |
| 144 | lo, hi = NONCUBIC |
| 145 | origin = grid.transform.indexToWorld((0, 0, 0)) |
| 146 | for axis in range(3): |
| 147 | step = [0, 0, 0] |
| 148 | step[axis] = 1 |
| 149 | one = grid.transform.indexToWorld(tuple(step)) |
| 150 | measured = one[axis] - origin[axis] |
| 151 | expected = (hi[axis] - lo[axis]) / S |
| 152 | self.assertAlmostEqual( |
| 153 | expected, measured, places=5, |
| 154 | msg=f"axis {axis}: voxel size {measured}, expected {expected} — " |
| 155 | "a single scale taken from the x extent distorts the other axes") |
| 156 | |
| 157 | |
| 158 | if __name__ == "__main__": |
| 159 | unittest.main(argv=sys.argv[:1], exit=False, verbosity=2) |