OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux commits / fa7f6f5

The bake reloaded the file, so every edit made in Blender was discarded
IFS_OT_bake_volume called ifs_volume.bake(path, ...), which reloads the .ifsjson from disk. The node graph and the panel sliders both write into the LIVE viewport session's document - a different object entirely - so nothing edited inside Blender ever reached a bake. It produced a valid volume of the wrong fractal and said nothing, which made the node graph decorative twice over: once for xaos links, and again for anything anyone wanted to bake. bake() keeps its name, signature and return shape for callers that genuinely mean "bake this file" - the CLI, and the operator when no session is running. Its body moved to bake_document(doc, ...), which takes an already-loaded document, and the operator hands over the session's own object when the viewport is live. The status line now names the source, because which document was baked is exactly what used to be invisible. The session's viewport camera cannot contaminate the result: volume mode replaces the projection block outright (ifs_layout._PLOT_VOLUME), so the camera is unused there. The test is differential, because a weaker one passes against the very bug - asserting bake_document exists, or returns a path, would hold just as well for a function that ignored its argument and reloaded the file. So it bakes one document twice and requires identical occupancy, establishing the chaos game is deterministically seeded, then mutes an iterator and requires the occupancy to MOVE. The file is never touched, so a file-reloading bake cannot pass. Two traps are recorded in the docstring because both cost a wrong result here: --factory-startup is REQUIRED, since an enabled add-on has already imported ifs_volume from the INSTALLED copy and sys.path cannot displace a module already in sys.modules - the first run failed with "no attribute 'bake_document'" against code that was already written. And gpu.init() belongs in setUpClass, or the outcome depends on test order: bake() initialises the context as a side effect, so whichever test ran first would error and the rest would pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fa7f6f59ebbf5c0b480cf2523935055a283ff2b2
by plankatron <fleeky@gmail.com> · 2026-08-17T20:24:36.000Z

Changed files (3) +167 -11

blender/__init__.py +22 -6
……@@ -324,8 +324,15 @@ class IFS_OT_bake_volume(Operator):
324324 def execute(self, context):
325325 settings = context.scene.ifs
326326 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")
329336 return {'CANCELLED'}
330337
331338 out = bpy.path.abspath(settings.volume_path)
……@@ -334,9 +341,14 @@ class IFS_OT_bake_volume(Operator):
334341 os.makedirs(os.path.dirname(out) or ".", exist_ok=True)
335342
336343 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)
340352 except Exception as ex:
341353 traceback.print_exc()
342354 self.report({'ERROR'}, f"{type(ex).__name__}: {ex}")
……@@ -344,7 +356,11 @@ class IFS_OT_bake_volume(Operator):
344356
345357 bpy.ops.object.volume_import(filepath=written)
346358 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)}")
348364 if stats.get("clipped"):
349365 self.report({'WARNING'},
350366 "The fractal overflowed the probe box; the bake is clipped")
blender/ifs_volume.py +34 -5
……@@ -256,12 +256,25 @@ def write_vdb(grid: np.ndarray, bounds, path: str, name: str = "density"):
256256 return path, peak
257257
258258
259def 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)."""
259def 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 """
262275 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()
265278 ubos = _document_buffers(doc)
266279
267280 alias = ifs_host.build_alias_tables(doc.iterators)
……@@ -287,3 +300,19 @@ def bake(document_path, out_path, size=256, dispatches=200, bounds=None,
287300 "total_voxels": size ** 3,
288301 "peak_density": peak,
289302 }
303
304
305def 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)
blender/test_bake_follows_document.py +111 -0
……@@ -0,0 +1,111 @@
1"""
2Proves 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
6viewport session's document instead, so every edit made inside Blender was discarded
7and a bake could only ever reproduce whatever the file already contained. Nothing
8reported this: you got a valid volume of the wrong fractal.
9
10The assertion that means something is differential. "bake_document exists" or "it
11returned a path" would pass just as happily against a function that ignored its
12argument and reloaded the file, which is the exact bug. So this bakes the same
13document twice and requires an identical result (the chaos game is deterministically
14seeded), then mutates one iterator and requires a DIFFERENT one.
15
16Needs a GPU and openvdb, so it runs inside Blender. --factory-startup is REQUIRED:
17without it the enabled add-on has already imported ifs_volume from the INSTALLED
18copy, and sys.path cannot displace a module that is already in sys.modules - so the
19test 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
24import os
25import sys
26import tempfile
27import unittest
28
29sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
30
31REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
32TEMPLATE = 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.
36SIZE = 32
37DISPATCHES = 8
38
39
40class 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
110if __name__ == "__main__":
111 unittest.main(argv=sys.argv[:1], exit=False, verbosity=2)