| … | … | @@ -0,0 +1,330 @@ |
| 1 | """ |
| 2 | Proves the point cloud is WIRED — that the operator, the material and the panel |
| 3 | properties reach the data `ifs_points` produces. |
| 4 | |
| 5 | `ifs_points` was complete and tested before any of this existed: it emitted the |
| 6 | right fractal, at the right dimension, agreeing with the still render to 0.976 IoU. |
| 7 | None of that made it reachable. There was no operator, no material and no panel, so |
| 8 | the whole path was Python-only and a user could not get a point cloud at all. |
| 9 | |
| 10 | Everything asserted here is a way for that wiring to fail SILENTLY, which is the |
| 11 | theme 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 | |
| 36 | The document is the Sierpinski tetrahedron from `test_bake_orientation`, so the |
| 37 | cloud is judged against the same analytic ground truth as the bake rather than |
| 38 | against itself. |
| 39 | |
| 40 | --factory-startup is REQUIRED: without it an enabled add-on has already imported |
| 41 | these modules from the INSTALLED copy, and sys.path cannot displace a module that |
| 42 | is 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 | |
| 47 | import importlib.util |
| 48 | import json |
| 49 | import os |
| 50 | import sys |
| 51 | import tempfile |
| 52 | import unittest |
| 53 | |
| 54 | sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) |
| 55 | |
| 56 | from 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. |
| 63 | CAPACITY = 20_000 |
| 64 | |
| 65 | PALETTE = [ |
| 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 | |
| 72 | def _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 | |
| 88 | def _clouds(): |
| 89 | import bpy |
| 90 | return [o for o in bpy.context.scene.objects if o.type == 'POINTCLOUD'] |
| 91 | |
| 92 | |
| 93 | def _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 | |
| 101 | def _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 | |
| 109 | class 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 | |
| 329 | if __name__ == "__main__": |
| 330 | unittest.main(argv=sys.argv[:1], exit=False, verbosity=2) |