| … | … | @@ -324,8 +324,15 @@ class IFS_OT_bake_volume(Operator): |
| 324 | 324 | def execute(self, context): |
| 325 | 325 | settings = context.scene.ifs |
| 326 | 326 | path = bpy.path.abspath(settings.document) |
| 327 | if not path or not os.path.isfile(path): | |
| 328 | self.report({'ERROR'}, "Pick an .ifsjson file first") | |
| 327 | ||
| 328 | # Prefer what is ON SCREEN. The node graph and the panel sliders both edit the | |
| 329 | # live session's document, and baking from the file threw all of that away - so | |
| 330 | # a bake could only ever reproduce the .ifsjson, and every edit made in Blender | |
| 331 | # was silently ignored. | |
| 332 | live = ifs_viewport.session().doc if ifs_viewport.is_running() else None | |
| 333 | if live is None and (not path or not os.path.isfile(path)): | |
| 334 | self.report({'ERROR'}, | |
| 335 | "Start the live viewport, or pick an .ifsjson file first") | |
| 329 | 336 | return {'CANCELLED'} |
| 330 | 337 | |
| 331 | 338 | out = bpy.path.abspath(settings.volume_path) |
| … | … | @@ -334,9 +341,14 @@ class IFS_OT_bake_volume(Operator): |
| 334 | 341 | os.makedirs(os.path.dirname(out) or ".", exist_ok=True) |
| 335 | 342 | |
| 336 | 343 | try: |
| 337 | written, stats = ifs_volume.bake( | |
| 338 | path, out, size=settings.volume_size, | |
| 339 | dispatches=settings.volume_dispatches) | |
| 344 | if live is not None: | |
| 345 | written, stats = ifs_volume.bake_document( | |
| 346 | live, out, size=settings.volume_size, | |
| 347 | dispatches=settings.volume_dispatches) | |
| 348 | else: | |
| 349 | written, stats = ifs_volume.bake( | |
| 350 | path, out, size=settings.volume_size, | |
| 351 | dispatches=settings.volume_dispatches) | |
| 340 | 352 | except Exception as ex: |
| 341 | 353 | traceback.print_exc() |
| 342 | 354 | self.report({'ERROR'}, f"{type(ex).__name__}: {ex}") |
| … | … | @@ -344,7 +356,11 @@ class IFS_OT_bake_volume(Operator): |
| 344 | 356 | |
| 345 | 357 | bpy.ops.object.volume_import(filepath=written) |
| 346 | 358 | occupied = stats["occupied_voxels"] |
| 347 | settings.status = (f"Baked {occupied} voxels to {os.path.basename(written)}") | |
| 359 | # Says WHICH document was baked: the difference between the live edits and the | |
| 360 | # file on disk is exactly what used to be invisible. | |
| 361 | source = "live viewport" if live is not None else os.path.basename(path) | |
| 362 | settings.status = ( | |
| 363 | f"Baked {occupied} voxels from the {source} to {os.path.basename(written)}") | |
| 348 | 364 | if stats.get("clipped"): |
| 349 | 365 | self.report({'WARNING'}, |
| 350 | 366 | "The fractal overflowed the probe box; the bake is clipped") |
| … | … | @@ -256,12 +256,25 @@ def write_vdb(grid: np.ndarray, bounds, path: str, name: str = "density"): |
| 256 | 256 | return path, peak |
| 257 | 257 | |
| 258 | 258 | |
| 259 | def bake(document_path, out_path, size=256, dispatches=200, bounds=None, | |
| 260 | invocation_iters=500): | |
| 261 | """Full bake: compile, fit bounds, accumulate, write. Returns (path, stats).""" | |
| 259 | def bake_document(doc, out_path, size=256, dispatches=200, bounds=None, | |
| 260 | invocation_iters=500, shader=None): | |
| 261 | """ | |
| 262 | Bakes a document that is ALREADY loaded, which is what makes the bake follow what | |
| 263 | you are looking at. | |
| 264 | ||
| 265 | The node graph and the panel sliders both write into the live viewport session's | |
| 266 | document; `bake` below reloads a fresh copy from disk, so everything edited inside | |
| 267 | Blender was silently discarded and a bake could only ever reproduce whatever the | |
| 268 | .ifsjson already contained. Taking the document as an argument is the whole fix - | |
| 269 | the operator hands over the session's own object. | |
| 270 | ||
| 271 | The document's camera cannot skew the result: volume mode replaces the projection | |
| 272 | block outright (ifs_layout._PLOT_VOLUME), so a live session's viewport camera is | |
| 273 | simply unused here. | |
| 274 | """ | |
| 262 | 275 | gpu.init() |
| 263 | shader, library = build_shader() | |
| 264 | doc = ifs_document.load(document_path, library) | |
| 276 | if shader is None: | |
| 277 | shader, _library = build_shader() | |
| 265 | 278 | ubos = _document_buffers(doc) |
| 266 | 279 | |
| 267 | 280 | alias = ifs_host.build_alias_tables(doc.iterators) |
| … | … | @@ -287,3 +300,19 @@ def bake(document_path, out_path, size=256, dispatches=200, bounds=None, |
| 287 | 300 | "total_voxels": size ** 3, |
| 288 | 301 | "peak_density": peak, |
| 289 | 302 | } |
| 303 | ||
| 304 | ||
| 305 | def bake(document_path, out_path, size=256, dispatches=200, bounds=None, | |
| 306 | invocation_iters=500): | |
| 307 | """ | |
| 308 | Loads a document from disk and bakes it. Returns (path, stats). | |
| 309 | ||
| 310 | Kept as-is for callers that genuinely mean "bake this file" - the CLI, and the | |
| 311 | operator when no viewport session is running. Anything that wants what is on | |
| 312 | screen should call `bake_document` with the session's document instead. | |
| 313 | """ | |
| 314 | gpu.init() | |
| 315 | shader, library = build_shader() | |
| 316 | doc = ifs_document.load(document_path, library) | |
| 317 | return bake_document(doc, out_path, size, dispatches, bounds, | |
| 318 | invocation_iters, shader=shader) |
| … | … | @@ -0,0 +1,111 @@ |
| 1 | """ | |
| 2 | Proves the bake renders the document it is GIVEN, not the file on disk. | |
| 3 | ||
| 4 | `IFS_OT_bake_volume` used to call `ifs_volume.bake(path, ...)`, which reloads the | |
| 5 | .ifsjson from disk. The node graph and the panel sliders both write into the LIVE | |
| 6 | viewport session's document instead, so every edit made inside Blender was discarded | |
| 7 | and a bake could only ever reproduce whatever the file already contained. Nothing | |
| 8 | reported this: you got a valid volume of the wrong fractal. | |
| 9 | ||
| 10 | The assertion that means something is differential. "bake_document exists" or "it | |
| 11 | returned a path" would pass just as happily against a function that ignored its | |
| 12 | argument and reloaded the file, which is the exact bug. So this bakes the same | |
| 13 | document twice and requires an identical result (the chaos game is deterministically | |
| 14 | seeded), then mutates one iterator and requires a DIFFERENT one. | |
| 15 | ||
| 16 | Needs a GPU and openvdb, so it runs inside Blender. --factory-startup is REQUIRED: | |
| 17 | without it the enabled add-on has already imported ifs_volume from the INSTALLED | |
| 18 | copy, and sys.path cannot displace a module that is already in sys.modules - so the | |
| 19 | test silently exercises whatever was last installed rather than the source tree. | |
| 20 | ||
| 21 | blender -b --factory-startup -noaudio --python blender/test_bake_follows_document.py | |
| 22 | """ | |
| 23 | ||
| 24 | import os | |
| 25 | import sys | |
| 26 | import tempfile | |
| 27 | import unittest | |
| 28 | ||
| 29 | sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) | |
| 30 | ||
| 31 | REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) | |
| 32 | TEMPLATE = os.path.join(REPO, "WpfDisplay", "Library", "Templates", | |
| 33 | "spherical_gasket_base.ifsjson") | |
| 34 | ||
| 35 | # Small and short: this asserts WHICH document was baked, not how good the bake is. | |
| 36 | SIZE = 32 | |
| 37 | DISPATCHES = 8 | |
| 38 | ||
| 39 | ||
| 40 | class BakeFollowsDocument(unittest.TestCase): | |
| 41 | @classmethod | |
| 42 | def setUpClass(cls): | |
| 43 | try: | |
| 44 | import gpu | |
| 45 | import openvdb # noqa: F401 | |
| 46 | import ifs_volume # noqa: F401 | |
| 47 | except ImportError as ex: | |
| 48 | raise unittest.SkipTest(f"needs Blender's gpu/openvdb modules ({ex})") | |
| 49 | # build_shader() compiles, and compiling needs a live GPU context. bake() does | |
| 50 | # this itself, so without it here the outcome depends on test ORDER. | |
| 51 | gpu.init() | |
| 52 | if not os.path.isfile(TEMPLATE): | |
| 53 | raise unittest.SkipTest("template document not found") | |
| 54 | ||
| 55 | def _load(self): | |
| 56 | import ifs_document | |
| 57 | import ifs_volume | |
| 58 | _shader, library = ifs_volume.build_shader() | |
| 59 | return ifs_document.load(TEMPLATE, library) | |
| 60 | ||
| 61 | def _bake(self, doc, tag): | |
| 62 | import ifs_volume | |
| 63 | tmp = tempfile.mkdtemp() | |
| 64 | path = os.path.join(tmp, f"{tag}.vdb") | |
| 65 | _written, stats = ifs_volume.bake_document( | |
| 66 | doc, path, size=SIZE, dispatches=DISPATCHES) | |
| 67 | return stats | |
| 68 | ||
| 69 | def test_the_same_document_bakes_the_same_way(self): | |
| 70 | """Establishes the baseline: without this, a difference below proves nothing, | |
| 71 | because it could just be the chaos game's randomness.""" | |
| 72 | doc = self._load() | |
| 73 | first = self._bake(doc, "a") | |
| 74 | second = self._bake(doc, "b") | |
| 75 | self.assertEqual(first["occupied_voxels"], second["occupied_voxels"], | |
| 76 | "the bake is not deterministic, so the mutation test below " | |
| 77 | "cannot distinguish a real change from noise") | |
| 78 | ||
| 79 | def test_editing_the_document_changes_the_bake(self): | |
| 80 | """ | |
| 81 | The actual claim. A bake that reloaded the file would be identical here, | |
| 82 | because the file was never touched. | |
| 83 | """ | |
| 84 | doc = self._load() | |
| 85 | before = self._bake(doc, "before") | |
| 86 | ||
| 87 | # Silence an iterator. Opacity is what the panel and the node graph both drive, | |
| 88 | # and a muted iterator plots nothing, so the occupancy must move. | |
| 89 | self.assertGreater(len(doc.iterators), 1, "template has too few iterators") | |
| 90 | doc.iterators[0].opacity = 0.0 | |
| 91 | after = self._bake(doc, "after") | |
| 92 | ||
| 93 | self.assertNotEqual( | |
| 94 | before["occupied_voxels"], after["occupied_voxels"], | |
| 95 | "muting an iterator changed nothing — the bake is not reading the " | |
| 96 | "document it was handed, which is the bug this exists to catch") | |
| 97 | ||
| 98 | def test_the_file_path_route_still_works(self): | |
| 99 | """`bake` is still the right call for "bake this file"; it must not have been | |
| 100 | broken by the refactor.""" | |
| 101 | import ifs_volume | |
| 102 | tmp = tempfile.mkdtemp() | |
| 103 | path = os.path.join(tmp, "from-file.vdb") | |
| 104 | written, stats = ifs_volume.bake( | |
| 105 | TEMPLATE, path, size=SIZE, dispatches=DISPATCHES) | |
| 106 | self.assertTrue(os.path.isfile(written)) | |
| 107 | self.assertGreater(stats["occupied_voxels"], 0) | |
| 108 | ||
| 109 | ||
| 110 | if __name__ == "__main__": | |
| 111 | unittest.main(argv=sys.argv[:1], exit=False, verbosity=2) |