"""
Proves the point cloud is WIRED — that the operator, the material and the panel
properties reach the data `ifs_points` produces.
`ifs_points` was complete and tested before any of this existed: it emitted the
right fractal, at the right dimension, agreeing with the still render to 0.976 IoU.
None of that made it reachable. There was no operator, no material and no panel, so
the whole path was Python-only and a user could not get a point cloud at all.
Everything asserted here is a way for that wiring to fail SILENTLY, which is the
theme of every bug the add-on has produced so far:
* **No material.** `_assign_volume_material` exists because an object with no
material renders at background brightness — the volume bake looked like it had
done nothing for exactly this reason. A PointCloud has the same failure, and it
looks identical to a bad emit.
* **The wrong colour attribute.** `color` is POISONED for points and meshes in
Blender: a per-point attribute of that name renders black. It is simultaneously
the EXPECTED name for a volume grid, which is what makes it a trap — the volume
material reads `color` and is correct to. A points material that copied it would
render a black cloud, which reads as a lighting problem, not a naming one.
* **A property wired to nothing.** #7's recurring failure was controls that succeed
into a void — a node property with no `update=`, a tree with no editor, a bake
reading the wrong document. A radius multiplier that never reaches the cloud is
the same shape, so it is asserted by driving it and measuring the result.
* **Silent truncation.** The emitter deliberately counts PAST the capacity so the
host can say how many points a run wanted. A cloud truncated at 20k of 600k
samples is still a convincing fractal; saying nothing would be the same quiet
wrongness as a transposed bake.
* **Baking the file instead of the screen.** The lesson `test_bake_follows_document`
exists for. The node graph and the panel sliders write into the LIVE session's
document, so an operator that reloads the .ifsjson discards every edit made
inside Blender and reports success. Asserted differentially — the same claim,
the same shape of proof.
The document is the Sierpinski tetrahedron from `test_bake_orientation`, so the
cloud is judged against the same analytic ground truth as the bake rather than
against itself.
--factory-startup is REQUIRED: without it an enabled add-on has already imported
these modules from the INSTALLED copy, and sys.path cannot displace a module that
is already in sys.modules, so the test would exercise whatever was last installed.
blender -b --factory-startup -noaudio --python blender/test_points_operator.py
"""
import importlib.util
import json
import os
import sys
import tempfile
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from test_bake_orientation import FIXED_POINTS, _document_dict
_HERE = os.path.dirname(os.path.abspath(__file__))
# Small: this asserts the wiring, not the quality of the cloud. 20k still spans
# several rows of the points image, and the emitter wants ~600k, so the truncation
# path is exercised too.
CAPACITY = 20_000
PALETTE = [
{"X": 1.0, "Y": 0.2, "Z": 0.1, "W": 1.0},
{"X": 0.2, "Y": 1.0, "Z": 0.3, "W": 1.0},
{"X": 0.1, "Y": 0.3, "Z": 1.0, "W": 1.0},
]
def _load_addon():
"""
Imports `__init__.py` as a module and registers it.
Under --factory-startup the add-on is not enabled, which is the point: this
registers the SOURCE tree's operators rather than an installed copy.
"""
spec = importlib.util.spec_from_file_location(
"ifs_fractal_under_test", os.path.join(_HERE, "__init__.py"))
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
module.register()
return module
def _clouds():
import bpy
return [o for o in bpy.context.scene.objects if o.type == 'POINTCLOUD']
def _positions(obj):
import numpy as np
count = len(obj.data.attributes['position'].data)
flat = np.empty(count * 3, dtype=np.float32)
obj.data.attributes['position'].data.foreach_get('vector', flat)
return flat.reshape(-1, 3)
def _radii(obj):
import numpy as np
count = len(obj.data.attributes['radius'].data)
flat = np.empty(count, dtype=np.float32)
obj.data.attributes['radius'].data.foreach_get('value', flat)
return flat
class PointsOperator(unittest.TestCase):
@classmethod
def setUpClass(cls):
try:
import bpy # noqa: F401
import gpu
import numpy # noqa: F401
import ifs_points # noqa: F401
except ImportError as ex:
raise unittest.SkipTest(f"needs Blender's gpu module ({ex})")
gpu.init()
document = _document_dict()
document["Palette"] = {"Colors": PALETTE}
cls.tmp = tempfile.mkdtemp()
cls.doc_path = os.path.join(cls.tmp, "points.ifsjson")
with open(cls.doc_path, "w", encoding="utf-8") as handle:
json.dump(document, handle)
cls.addon = _load_addon()
@classmethod
def tearDownClass(cls):
import ifs_viewport
ifs_viewport.stop()
try:
cls.addon.unregister()
except Exception:
pass
def setUp(self):
import bpy
import ifs_viewport
ifs_viewport.stop()
for obj in _clouds():
bpy.data.objects.remove(obj, do_unlink=True)
settings = bpy.context.scene.ifs
settings.document = self.doc_path
settings.points_capacity = CAPACITY
settings.points_radius = 1.0
def _emit(self):
import bpy
result = bpy.ops.ifs.emit_points()
self.assertEqual({'FINISHED'}, result,
"the operator did not finish; see the console traceback")
clouds = _clouds()
self.assertEqual(1, len(clouds),
f"expected exactly one PointCloud in the scene, got {len(clouds)}")
return clouds[0]
# ------------------------------------------------------- the controls are real
def test_the_properties_are_declared_rather_than_conjured(self):
"""
Assigning an unknown attribute on a PropertyGroup silently creates an
IDProperty, so `settings.points_capacity = N` in setUp SUCCEEDS whether or not
the property was ever declared — and the operator would then read the value
this test just invented while the panel had no control at all. Only an entry
in the RNA means there is something a user can actually reach.
"""
import bpy
declared = bpy.context.scene.ifs.bl_rna.properties.keys()
for name in ("points_capacity", "points_radius"):
self.assertIn(name, declared,
f"`{name}` is not a declared property, so nothing draws it "
"in the panel — the tests are supplying it themselves")
# --------------------------------------------------------- it reaches the scene
def test_the_operator_puts_a_pointcloud_in_the_scene(self):
"""The baseline. Without an object in the scene nothing below means anything,
and this is precisely what `ifs_points` could not do on its own."""
import numpy as np
obj = self._emit()
points = _positions(obj)
self.assertGreater(len(points), 0, "the cloud is empty")
centroid = points.mean(axis=0).astype(np.float64)
expected = np.array(FIXED_POINTS, dtype=np.float64).mean(axis=0)
print(f"[operator] {len(points)} points, centroid {np.round(centroid, 4)} "
f"expected {np.round(expected, 4)}")
for axis in range(3):
self.assertAlmostEqual(
expected[axis], centroid[axis], delta=0.02,
msg=f"axis {axis}: the object in the scene is not the attractor")
# ------------------------------------------------------------------- material
def test_the_cloud_has_a_material(self):
"""
An object with NO material renders at background brightness — measured during
the volume work, where it read as "the bake did nothing" for a whole session.
"""
obj = self._emit()
self.assertTrue(
len(obj.data.materials) >= 1 and obj.data.materials[0] is not None,
"the point cloud has no material, so it renders at background "
"brightness and looks like a failed emit")
def test_the_material_reads_ifs_color_and_never_color(self):
"""
`color` renders BLACK as a point attribute while being the correct name for a
volume grid. A material that reached for the volume's name would produce a
black cloud that looks like a lighting bug.
"""
import ifs_points
obj = self._emit()
material = obj.data.materials[0]
self.assertTrue(material.use_nodes, "the material has no node tree")
names = [n.attribute_name for n in material.node_tree.nodes
if n.type == 'ATTRIBUTE']
print(f"[operator] material attributes {names}")
self.assertIn(ifs_points.COLOR_ATTRIBUTE, names,
"the material does not read the per-point colour attribute, so "
"the palette never reaches the render")
self.assertNotIn("color", names,
"`color` is poisoned for points — it renders black; the "
"attribute is called " + ifs_points.COLOR_ATTRIBUTE)
# --------------------------------------------------------------------- radius
def test_the_radius_multiplier_reaches_the_cloud(self):
"""
A multiplier wired to nothing is #7's recurring failure — a control that
succeeds into a void. Differential, because asserting "radius > 0" would pass
against a property that is never read.
The band is wide ON PURPOSE and the width is measured, not chosen to make the
test pass. 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,
and `estimate_spacing` measures the cloud it is actually given. Over four runs
of this document the derived radius spanned 0.00143 to 0.00164 — a 15% spread
— so a doubling can land anywhere in roughly [1.74, 2.30]. +/-0.4 covers that
with headroom while still separating 2.0 from the 1.0 a disconnected property
would produce, which is the failure this exists to catch.
"""
import bpy
import numpy as np
first = float(np.mean(_radii(self._emit())))
bpy.context.scene.ifs.points_radius = 2.0
for obj in _clouds():
bpy.data.objects.remove(obj, do_unlink=True)
second = float(np.mean(_radii(self._emit())))
ratio = second / first
print(f"[operator] radius {first:.6g} -> {second:.6g} at 2x (ratio {ratio:.3f})")
self.assertAlmostEqual(
2.0, ratio, delta=0.4,
msg=f"doubling the multiplier moved the radius by {ratio:.3f}x, not ~2x, "
"so the property is not reaching to_pointcloud")
# ----------------------------------------------------------------- truncation
def test_truncation_is_reported(self):
"""
The emitter counts PAST capacity on purpose so the host can say how many
points the run wanted. A cloud of the first 20k of ~600k samples is still a
convincing fractal, so saying nothing would hide it completely.
"""
import bpy
obj = self._emit()
status = bpy.context.scene.ifs.status
print(f"[operator] status: {status}")
self.assertIn(str(len(_positions(obj))), status,
"the status does not say how many points were stored")
self.assertIn("truncat", status.lower(),
f"the run wanted far more than the {CAPACITY} capacity and the "
"status does not mention that the cloud was truncated")
# ------------------------------------------------- it follows the LIVE document
def test_it_emits_the_live_document_not_the_file(self):
"""
The `test_bake_follows_document` claim, for points. An operator that reloaded
the .ifsjson would return an IDENTICAL cloud here, because the file on disk is
never touched — which is exactly the bug.
The emit is NOT reproducible — slots are claimed in GPU atomic order, so two
runs of the same document keep different samples. That makes a bare "the
result changed" assertion meaningless, so the control MEASURES the run-to-run
noise in this very run and the mutation has to clear it by an order of
magnitude. Calibrating against the noise beats hard-coding a tolerance: over
four runs the centroid wandered by up to 0.0011, and a threshold chosen from
one machine's figure would be the kind of tolerance that hides the failure it
was not sized for.
"""
import bpy
import numpy as np
import ifs_viewport
from_file = _positions(self._emit()).mean(axis=0).astype(np.float64)
# Control: the same document twice, to size the noise before trusting a change.
for obj in _clouds():
bpy.data.objects.remove(obj, do_unlink=True)
again = _positions(self._emit()).mean(axis=0).astype(np.float64)
noise = float(np.linalg.norm(again - from_file))
# Now mute an iterator in the LIVE session's document only.
for obj in _clouds():
bpy.data.objects.remove(obj, do_unlink=True)
ifs_viewport.start(self.doc_path)
doc = ifs_viewport.session().doc
self.assertGreater(len(doc.iterators), 1, "document has too few iterators")
doc.iterators[0].opacity = 0.0
live = _positions(self._emit()).mean(axis=0).astype(np.float64)
shift = float(np.linalg.norm(live - from_file))
threshold = max(0.01, 10.0 * noise)
print(f"[operator] centroid from file {np.round(from_file, 4)}, "
f"live {np.round(live, 4)}, shift {shift:.4f} against "
f"run-to-run noise {noise:.4f} (threshold {threshold:.4f})")
self.assertGreater(
shift, threshold,
f"muting an iterator in the live session moved the centroid by {shift:.4f}, "
f"no more than the {noise:.4f} two identical runs differ by — the operator "
"is reloading the file and discarding every edit made inside Blender")
if __name__ == "__main__":
unittest.main(argv=sys.argv[:1], exit=False, verbosity=2)