OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux commits / 42730d0

The point cloud was complete and unreachable
ifs_points emitted the right fractal - 0.976 IoU against the still render, the dimension recovered to 1.984 against an exact 2.0 - and none of it was reachable from Blender. No operator, no material, no panel, so the whole path was Python-only. The operator mirrors IFS_OT_bake_volume, including the lesson it was fixed for: prefer the LIVE session's document over the file, because the node graph and the panel sliders write into the session and a reload discards every edit made inside Blender while reporting success. Two traps the material had to avoid, both already paid for elsewhere: * An object with NO material renders at background brightness. That is what made the volume bake look like it had done nothing for a whole session. * The attribute is `ifs_color`, never `color`. That name renders BLACK for points and meshes while being the EXPECTED name for a volume grid, so the correct volume material is exactly the wrong thing to copy. Emission rather than Principled Volume: a point cloud is geometry the renderer sees directly, so none of the volume path's derived exposure applies. ifs_points.py joins RUNTIME_FILES. Without it the INSTALLED add-on fails at import while the repo tests, which read the source tree, stay green. test_points_operator.py, 7 tests. Each covers a way the wiring fails silently: no material, the poisoned attribute name, a multiplier wired to nothing, an untruthful truncation, and the file-not-the-screen bug. It also asserts the properties are DECLARED - assigning an unknown attribute on a PropertyGroup silently creates an IDProperty, so a test that sets one would otherwise pass against a panel with no control at all. Two tolerances are measured rather than chosen. The emit is NOT reproducible: slots in the points image are claimed in GPU atomic order, so which samples survive the capacity differs every run - the derived radius moved 15% across four runs and the centroid by 0.0011. The radius assertion is banded to cover that while still separating 2x from the 1x an unwired property gives, and the live-document test measures the run-to-run noise in-run and requires the mutation to clear it tenfold. It clears it by ~180x. 132 tests green (was 125): 42 GPU tests in Blender 5.2, 90 headless. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
42730d05fd1d49b90355afb278d17c29a30935f4
by plankatron <fleeky@gmail.com> · 2026-08-18T12:51:00.000Z

Changed files (3) +455 -0

blender/__init__.py +124 -0
……@@ -48,6 +48,7 @@ if _HERE not in sys.path:
4848
4949import ifs_codegen
5050import ifs_document
51import ifs_points
5152import ifs_render
5253import ifs_viewport
5354import ifs_volume
……@@ -193,6 +194,26 @@ class IFSSceneSettings(PropertyGroup):
193194 default=1.0, min=0.0, soft_max=100.0)
194195 volume_path: StringProperty(
195196 name="VDB", subtype='FILE_PATH', default="//fractal.vdb")
197
198 # Points buy detail LINEARLY where a voxel grid buys it cubically, so the
199 # trade-off here is nothing like the volume's. Two RGBA32F images carry the
200 # cloud off the GPU, which is 32 bytes per point: 1M is ~32MB and 16M ~512MB,
201 # against 113MB for a 192-voxel grid that is already mush on thin filaments.
202 points_capacity: IntProperty(
203 name="Points", default=1_000_000, min=1024, max=16_000_000,
204 description="How many samples to keep. Costs 32 bytes each on the GPU: "
205 "1M is ~32MB. A run that wants more is truncated, and says so")
206 points_radius: FloatProperty(
207 name="Radius x", default=1.0, min=0.0, soft_max=10.0,
208 description="Multiplies the radius derived from the cloud's OWN measured "
209 "nearest-neighbour spacing, so 1.0 makes neighbouring points "
210 "just touch. How big a point should look also depends on how "
211 "close the camera is, which is why this exists")
212 points_emission: FloatProperty(
213 name="Emission", default=1.0, min=0.0, soft_max=100.0,
214 description="Emission strength of the point material. Unlike the volume's "
215 "exposure this needs no derivation - a point is a surface the "
216 "renderer sees directly, not a density integrated along a ray")
196217 iterators: CollectionProperty(type=IFSIteratorItem)
197218 active_iterator: IntProperty(default=0)
198219 status: StringProperty(default="")
……@@ -428,6 +449,101 @@ class IFS_OT_bake_volume(Operator):
428449 return {'FINISHED'}
429450
430451
452def _assign_points_material(obj, strength):
453 """
454 Gives the point cloud an emission material tinted by the per-point colour.
455
456 The same lesson as `_assign_volume_material`, and it is worth repeating because
457 the symptom is indistinguishable from a broken emit: an object with NO material
458 renders at background brightness, so a perfectly good cloud looks like the
459 operator did nothing.
460
461 The attribute is `ifs_color`, NEVER `color`. That name is poisoned for points and
462 meshes in Blender - a per-point attribute called `color` renders black - while
463 being the EXPECTED name for a volume grid, which is what the volume material
464 above correctly reads. Copying that name here would produce a black cloud that
465 reads as a lighting problem rather than a naming one.
466
467 Emission rather than Principled: a fractal flame is light, and a point cloud is
468 geometry the renderer sees directly, so there is no density-along-a-ray to
469 correct for and none of the volume path's derived exposure is needed.
470 """
471 material = bpy.data.materials.new("IFS Points")
472 material.use_nodes = True
473 tree = material.node_tree
474 tree.nodes.clear()
475
476 output = tree.nodes.new('ShaderNodeOutputMaterial')
477 emission = tree.nodes.new('ShaderNodeEmission')
478 emission.inputs['Strength'].default_value = strength
479
480 colour = tree.nodes.new('ShaderNodeAttribute')
481 colour.attribute_name = ifs_points.COLOR_ATTRIBUTE
482 tree.links.new(colour.outputs['Color'], emission.inputs['Color'])
483 tree.links.new(emission.outputs['Emission'], output.inputs['Surface'])
484
485 obj.data.materials.append(material)
486 return material
487
488
489class IFS_OT_emit_points(Operator):
490 """Emit the fractal as a point cloud that Cycles and EEVEE both render"""
491
492 bl_idname = "ifs.emit_points"
493 bl_label = "Emit Point Cloud"
494
495 def execute(self, context):
496 settings = context.scene.ifs
497 path = bpy.path.abspath(settings.document)
498
499 # Prefer what is ON SCREEN, for the reason the volume bake does: the node
500 # graph and the panel sliders write into the live session's document, so
501 # reading the file would silently discard every edit made inside Blender.
502 live = None
503 if ifs_viewport.is_running():
504 ifs_viewport.session().apply_pending_graph()
505 live = ifs_viewport.session().doc
506 if live is None and (not path or not os.path.isfile(path)):
507 self.report({'ERROR'},
508 "Start the live viewport, or pick an .ifsjson file first")
509 return {'CANCELLED'}
510
511 try:
512 if live is not None:
513 positions, colors, stats = ifs_points.emit_document(
514 live, capacity=settings.points_capacity)
515 else:
516 positions, colors, stats = ifs_points.emit_from_file(
517 path, capacity=settings.points_capacity)
518 except Exception as ex:
519 traceback.print_exc()
520 self.report({'ERROR'}, f"{type(ex).__name__}: {ex}")
521 return {'CANCELLED'}
522
523 cloud = ifs_points.to_pointcloud(
524 positions, colors, stats["radius"] * settings.points_radius)
525 obj = bpy.data.objects.new("IFS Points", cloud)
526 context.collection.objects.link(obj)
527 _assign_points_material(obj, settings.points_emission)
528
529 stored = stats["points"]
530 source = "live viewport" if live is not None else os.path.basename(path)
531 settings.status = f"Emitted {stored} points from the {source}"
532
533 # Say so when the cloud is a prefix of the run. A cloud truncated at a
534 # million of twelve million samples is still a convincing fractal, so this
535 # is invisible unless it is reported - the emitter counts PAST the capacity
536 # for exactly this reason.
537 requested = stats.get("requested", 0)
538 if stats.get("filled") and requested > stored:
539 settings.status += f" - truncated from the {requested} it wanted"
540 self.report({'WARNING'},
541 f"The capacity held {stored} of {requested} samples; raise "
542 "Points for the rest")
543 self.report({'INFO'}, settings.status)
544 return {'FINISHED'}
545
546
431547class IFS_OT_render(Operator):
432548 """Render the loaded fractal and show it in the Image Editor"""
433549
……@@ -543,6 +659,13 @@ class IFS_PT_main(Panel):
543659 box.prop(settings, "volume_path")
544660 box.operator("ifs.bake_volume", icon='OUTLINER_OB_VOLUME')
545661
662 box = layout.box()
663 box.label(text="Point Cloud (Cycles and EEVEE)")
664 box.prop(settings, "points_capacity")
665 box.prop(settings, "points_radius")
666 box.prop(settings, "points_emission")
667 box.operator("ifs.emit_points", icon='OUTLINER_OB_POINTCLOUD')
668
546669
547670class IFS_PT_iterators(Panel):
548671 bl_label = "Iterators"
……@@ -601,6 +724,7 @@ _CLASSES = (
601724 IFS_OT_render,
602725 IFS_OT_viewport,
603726 IFS_OT_bake_volume,
727 IFS_OT_emit_points,
604728 IFS_UL_iterators,
605729 IFS_PT_main,
606730 IFS_PT_iterators,
blender/build_addon.py +1 -0
……@@ -40,6 +40,7 @@ RUNTIME_FILES = (
4040 "ifs_render.py",
4141 "ifs_viewport.py",
4242 "ifs_volume.py",
43 "ifs_points.py",
4344 "ifs_nodes.py",
4445)
4546
blender/test_points_operator.py +330 -0
……@@ -0,0 +1,330 @@
1"""
2Proves the point cloud is WIRED — that the operator, the material and the panel
3properties reach the data `ifs_points` produces.
4
5`ifs_points` was complete and tested before any of this existed: it emitted the
6right fractal, at the right dimension, agreeing with the still render to 0.976 IoU.
7None of that made it reachable. There was no operator, no material and no panel, so
8the whole path was Python-only and a user could not get a point cloud at all.
9
10Everything asserted here is a way for that wiring to fail SILENTLY, which is the
11theme of every bug the add-on has produced so far:
12
13* **No material.** `_assign_volume_material` exists because an object with no
14 material renders at background brightness — the volume bake looked like it had
15 done nothing for exactly this reason. A PointCloud has the same failure, and it
16 looks identical to a bad emit.
17* **The wrong colour attribute.** `color` is POISONED for points and meshes in
18 Blender: a per-point attribute of that name renders black. It is simultaneously
19 the EXPECTED name for a volume grid, which is what makes it a trap — the volume
20 material reads `color` and is correct to. A points material that copied it would
21 render a black cloud, which reads as a lighting problem, not a naming one.
22* **A property wired to nothing.** #7's recurring failure was controls that succeed
23 into a void — a node property with no `update=`, a tree with no editor, a bake
24 reading the wrong document. A radius multiplier that never reaches the cloud is
25 the same shape, so it is asserted by driving it and measuring the result.
26* **Silent truncation.** The emitter deliberately counts PAST the capacity so the
27 host can say how many points a run wanted. A cloud truncated at 20k of 600k
28 samples is still a convincing fractal; saying nothing would be the same quiet
29 wrongness as a transposed bake.
30* **Baking the file instead of the screen.** The lesson `test_bake_follows_document`
31 exists for. The node graph and the panel sliders write into the LIVE session's
32 document, so an operator that reloads the .ifsjson discards every edit made
33 inside Blender and reports success. Asserted differentially — the same claim,
34 the same shape of proof.
35
36The document is the Sierpinski tetrahedron from `test_bake_orientation`, so the
37cloud is judged against the same analytic ground truth as the bake rather than
38against itself.
39
40--factory-startup is REQUIRED: without it an enabled add-on has already imported
41these modules from the INSTALLED copy, and sys.path cannot displace a module that
42is already in sys.modules, so the test would exercise whatever was last installed.
43
44 blender -b --factory-startup -noaudio --python blender/test_points_operator.py
45"""
46
47import importlib.util
48import json
49import os
50import sys
51import tempfile
52import unittest
53
54sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
55
56from test_bake_orientation import FIXED_POINTS, _document_dict
57
58_HERE = os.path.dirname(os.path.abspath(__file__))
59
60# Small: this asserts the wiring, not the quality of the cloud. 20k still spans
61# several rows of the points image, and the emitter wants ~600k, so the truncation
62# path is exercised too.
63CAPACITY = 20_000
64
65PALETTE = [
66 {"X": 1.0, "Y": 0.2, "Z": 0.1, "W": 1.0},
67 {"X": 0.2, "Y": 1.0, "Z": 0.3, "W": 1.0},
68 {"X": 0.1, "Y": 0.3, "Z": 1.0, "W": 1.0},
69]
70
71
72def _load_addon():
73 """
74 Imports `__init__.py` as a module and registers it.
75
76 Under --factory-startup the add-on is not enabled, which is the point: this
77 registers the SOURCE tree's operators rather than an installed copy.
78 """
79 spec = importlib.util.spec_from_file_location(
80 "ifs_fractal_under_test", os.path.join(_HERE, "__init__.py"))
81 module = importlib.util.module_from_spec(spec)
82 sys.modules[spec.name] = module
83 spec.loader.exec_module(module)
84 module.register()
85 return module
86
87
88def _clouds():
89 import bpy
90 return [o for o in bpy.context.scene.objects if o.type == 'POINTCLOUD']
91
92
93def _positions(obj):
94 import numpy as np
95 count = len(obj.data.attributes['position'].data)
96 flat = np.empty(count * 3, dtype=np.float32)
97 obj.data.attributes['position'].data.foreach_get('vector', flat)
98 return flat.reshape(-1, 3)
99
100
101def _radii(obj):
102 import numpy as np
103 count = len(obj.data.attributes['radius'].data)
104 flat = np.empty(count, dtype=np.float32)
105 obj.data.attributes['radius'].data.foreach_get('value', flat)
106 return flat
107
108
109class PointsOperator(unittest.TestCase):
110 @classmethod
111 def setUpClass(cls):
112 try:
113 import bpy # noqa: F401
114 import gpu
115 import numpy # noqa: F401
116 import ifs_points # noqa: F401
117 except ImportError as ex:
118 raise unittest.SkipTest(f"needs Blender's gpu module ({ex})")
119 gpu.init()
120
121 document = _document_dict()
122 document["Palette"] = {"Colors": PALETTE}
123 cls.tmp = tempfile.mkdtemp()
124 cls.doc_path = os.path.join(cls.tmp, "points.ifsjson")
125 with open(cls.doc_path, "w", encoding="utf-8") as handle:
126 json.dump(document, handle)
127
128 cls.addon = _load_addon()
129
130 @classmethod
131 def tearDownClass(cls):
132 import ifs_viewport
133 ifs_viewport.stop()
134 try:
135 cls.addon.unregister()
136 except Exception:
137 pass
138
139 def setUp(self):
140 import bpy
141 import ifs_viewport
142 ifs_viewport.stop()
143 for obj in _clouds():
144 bpy.data.objects.remove(obj, do_unlink=True)
145 settings = bpy.context.scene.ifs
146 settings.document = self.doc_path
147 settings.points_capacity = CAPACITY
148 settings.points_radius = 1.0
149
150 def _emit(self):
151 import bpy
152 result = bpy.ops.ifs.emit_points()
153 self.assertEqual({'FINISHED'}, result,
154 "the operator did not finish; see the console traceback")
155 clouds = _clouds()
156 self.assertEqual(1, len(clouds),
157 f"expected exactly one PointCloud in the scene, got {len(clouds)}")
158 return clouds[0]
159
160 # ------------------------------------------------------- the controls are real
161
162 def test_the_properties_are_declared_rather_than_conjured(self):
163 """
164 Assigning an unknown attribute on a PropertyGroup silently creates an
165 IDProperty, so `settings.points_capacity = N` in setUp SUCCEEDS whether or not
166 the property was ever declared — and the operator would then read the value
167 this test just invented while the panel had no control at all. Only an entry
168 in the RNA means there is something a user can actually reach.
169 """
170 import bpy
171 declared = bpy.context.scene.ifs.bl_rna.properties.keys()
172 for name in ("points_capacity", "points_radius"):
173 self.assertIn(name, declared,
174 f"`{name}` is not a declared property, so nothing draws it "
175 "in the panel — the tests are supplying it themselves")
176
177 # --------------------------------------------------------- it reaches the scene
178
179 def test_the_operator_puts_a_pointcloud_in_the_scene(self):
180 """The baseline. Without an object in the scene nothing below means anything,
181 and this is precisely what `ifs_points` could not do on its own."""
182 import numpy as np
183 obj = self._emit()
184 points = _positions(obj)
185 self.assertGreater(len(points), 0, "the cloud is empty")
186 centroid = points.mean(axis=0).astype(np.float64)
187 expected = np.array(FIXED_POINTS, dtype=np.float64).mean(axis=0)
188 print(f"[operator] {len(points)} points, centroid {np.round(centroid, 4)} "
189 f"expected {np.round(expected, 4)}")
190 for axis in range(3):
191 self.assertAlmostEqual(
192 expected[axis], centroid[axis], delta=0.02,
193 msg=f"axis {axis}: the object in the scene is not the attractor")
194
195 # ------------------------------------------------------------------- material
196
197 def test_the_cloud_has_a_material(self):
198 """
199 An object with NO material renders at background brightness — measured during
200 the volume work, where it read as "the bake did nothing" for a whole session.
201 """
202 obj = self._emit()
203 self.assertTrue(
204 len(obj.data.materials) >= 1 and obj.data.materials[0] is not None,
205 "the point cloud has no material, so it renders at background "
206 "brightness and looks like a failed emit")
207
208 def test_the_material_reads_ifs_color_and_never_color(self):
209 """
210 `color` renders BLACK as a point attribute while being the correct name for a
211 volume grid. A material that reached for the volume's name would produce a
212 black cloud that looks like a lighting bug.
213 """
214 import ifs_points
215 obj = self._emit()
216 material = obj.data.materials[0]
217 self.assertTrue(material.use_nodes, "the material has no node tree")
218 names = [n.attribute_name for n in material.node_tree.nodes
219 if n.type == 'ATTRIBUTE']
220 print(f"[operator] material attributes {names}")
221 self.assertIn(ifs_points.COLOR_ATTRIBUTE, names,
222 "the material does not read the per-point colour attribute, so "
223 "the palette never reaches the render")
224 self.assertNotIn("color", names,
225 "`color` is poisoned for points — it renders black; the "
226 "attribute is called " + ifs_points.COLOR_ATTRIBUTE)
227
228 # --------------------------------------------------------------------- radius
229
230 def test_the_radius_multiplier_reaches_the_cloud(self):
231 """
232 A multiplier wired to nothing is #7's recurring failure — a control that
233 succeeds into a void. Differential, because asserting "radius > 0" would pass
234 against a property that is never read.
235
236 The band is wide ON PURPOSE and the width is measured, not chosen to make the
237 test pass. The emit is NOT reproducible: slots in the points image are claimed
238 in GPU atomic order, so which samples survive the capacity differs every run,
239 and `estimate_spacing` measures the cloud it is actually given. Over four runs
240 of this document the derived radius spanned 0.00143 to 0.00164 — a 15% spread
241 — so a doubling can land anywhere in roughly [1.74, 2.30]. +/-0.4 covers that
242 with headroom while still separating 2.0 from the 1.0 a disconnected property
243 would produce, which is the failure this exists to catch.
244 """
245 import bpy
246 import numpy as np
247 first = float(np.mean(_radii(self._emit())))
248
249 bpy.context.scene.ifs.points_radius = 2.0
250 for obj in _clouds():
251 bpy.data.objects.remove(obj, do_unlink=True)
252 second = float(np.mean(_radii(self._emit())))
253
254 ratio = second / first
255 print(f"[operator] radius {first:.6g} -> {second:.6g} at 2x (ratio {ratio:.3f})")
256 self.assertAlmostEqual(
257 2.0, ratio, delta=0.4,
258 msg=f"doubling the multiplier moved the radius by {ratio:.3f}x, not ~2x, "
259 "so the property is not reaching to_pointcloud")
260
261 # ----------------------------------------------------------------- truncation
262
263 def test_truncation_is_reported(self):
264 """
265 The emitter counts PAST capacity on purpose so the host can say how many
266 points the run wanted. A cloud of the first 20k of ~600k samples is still a
267 convincing fractal, so saying nothing would hide it completely.
268 """
269 import bpy
270 obj = self._emit()
271 status = bpy.context.scene.ifs.status
272 print(f"[operator] status: {status}")
273 self.assertIn(str(len(_positions(obj))), status,
274 "the status does not say how many points were stored")
275 self.assertIn("truncat", status.lower(),
276 f"the run wanted far more than the {CAPACITY} capacity and the "
277 "status does not mention that the cloud was truncated")
278
279 # ------------------------------------------------- it follows the LIVE document
280
281 def test_it_emits_the_live_document_not_the_file(self):
282 """
283 The `test_bake_follows_document` claim, for points. An operator that reloaded
284 the .ifsjson would return an IDENTICAL cloud here, because the file on disk is
285 never touched — which is exactly the bug.
286
287 The emit is NOT reproducible — slots are claimed in GPU atomic order, so two
288 runs of the same document keep different samples. That makes a bare "the
289 result changed" assertion meaningless, so the control MEASURES the run-to-run
290 noise in this very run and the mutation has to clear it by an order of
291 magnitude. Calibrating against the noise beats hard-coding a tolerance: over
292 four runs the centroid wandered by up to 0.0011, and a threshold chosen from
293 one machine's figure would be the kind of tolerance that hides the failure it
294 was not sized for.
295 """
296 import bpy
297 import numpy as np
298 import ifs_viewport
299
300 from_file = _positions(self._emit()).mean(axis=0).astype(np.float64)
301
302 # Control: the same document twice, to size the noise before trusting a change.
303 for obj in _clouds():
304 bpy.data.objects.remove(obj, do_unlink=True)
305 again = _positions(self._emit()).mean(axis=0).astype(np.float64)
306 noise = float(np.linalg.norm(again - from_file))
307
308 # Now mute an iterator in the LIVE session's document only.
309 for obj in _clouds():
310 bpy.data.objects.remove(obj, do_unlink=True)
311 ifs_viewport.start(self.doc_path)
312 doc = ifs_viewport.session().doc
313 self.assertGreater(len(doc.iterators), 1, "document has too few iterators")
314 doc.iterators[0].opacity = 0.0
315
316 live = _positions(self._emit()).mean(axis=0).astype(np.float64)
317 shift = float(np.linalg.norm(live - from_file))
318 threshold = max(0.01, 10.0 * noise)
319 print(f"[operator] centroid from file {np.round(from_file, 4)}, "
320 f"live {np.round(live, 4)}, shift {shift:.4f} against "
321 f"run-to-run noise {noise:.4f} (threshold {threshold:.4f})")
322 self.assertGreater(
323 shift, threshold,
324 f"muting an iterator in the live session moved the centroid by {shift:.4f}, "
325 f"no more than the {noise:.4f} two identical runs differ by — the operator "
326 "is reloading the file and discarding every edit made inside Blender")
327
328
329if __name__ == "__main__":
330 unittest.main(argv=sys.argv[:1], exit=False, verbosity=2)