"""
Proves the emitted point cloud IS the fractal — same ground truth as the bake.
`test_bake_orientation` established a document whose attractor is known
analytically rather than measured: four Affine iterators `p := 0.5p + 0.5F_i`, so
the attractor is the Sierpinski tetrahedron with vertices `F_i`, its convex hull is
exactly that tetrahedron and its centroid is exactly `mean(F)`. This reuses it, so
the volume path and the points path are judged against one reference instead of
each against its own.
The tetrahedron pays a second dividend here. Four maps at ratio 0.5 give it a
Hausdorff dimension of exactly `ln 4 / ln 2 = 2`, which is a real prediction for
`estimate_spacing` — the radius derivation measures the attractor's dimension on
the way to a spacing, so there is a known number for it to hit rather than a
plausible-looking one to accept.
Three failure modes this is built to catch, none of which look like a failure:
* **A truncated cloud.** The array fills and the counter keeps going; a cloud of the
first million of twelve million samples is still a perfectly convincing fractal.
* **Uninitialised texels.** Reading past the counter yields (0, 0, 0), and the origin
sits INSIDE this attractor's hull, so a hull test alone would not notice. The
kernel treats `dot(pos, pos) == 0` as an invalid point and never emits it, so an
exact zero in the output can only be a slot that was never written.
* **A cloud that is not what the renderer draws.** The emitted points are projected
through the engine's own camera and compared to the actual still render.
Needs the GPU, so it runs inside Blender:
blender -b --factory-startup -noaudio --python blender/test_points_emit.py
"""
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,
STILL_SIZE,
_cpu_attractor,
_document_dict,
_mean,
)
CAPACITY = 200_000
SMALL_CAPACITY = 8192 # exactly two rows of the points image
# The single white key in the orientation document would make every point the same
# colour, which cannot tell "colour is carried" from "colour is a constant".
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 _outside_hull(points, vertices, tolerance):
"""How many points fall outside the tetrahedron, and by how far at worst."""
import numpy as np
verts = np.array(vertices, dtype=np.float64)
outside, worst = 0, 0.0
for corner in range(4):
face = np.array([verts[j] for j in range(4) if j != corner])
normal = np.cross(face[1] - face[0], face[2] - face[0])
normal = normal / np.linalg.norm(normal)
offset = float(normal @ face[0])
# Orient the plane so "inside" is the side the fourth vertex is on.
inside = 1.0 if (normal @ verts[corner] - offset) > 0 else -1.0
signed = inside * ((points @ normal) - offset)
outside += int((signed < -tolerance).sum())
worst = min(worst, float(signed.min()))
return outside, worst
class Emit(unittest.TestCase):
@classmethod
def setUpClass(cls):
try:
import numpy # noqa: F401
import ifs_points # noqa: F401
except ImportError as ex:
raise unittest.SkipTest(f"needs Blender's gpu module ({ex})")
import ifs_document
import ifs_points
import ifs_render
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)
library = ifs_render.load_library(
os.path.join(ifs_render.REPO, "Transforms"),
os.path.join(ifs_render.REPO, "WpfDisplay", "Library", "Includes"))
cls.doc = ifs_document.load(cls.doc_path, library)
cls.positions, cls.colors, cls.stats = ifs_points.emit_document(
cls.doc, capacity=CAPACITY)
print(f"[points] {cls.stats}")
# ------------------------------------------------- is it the right fractal
def test_the_points_land_on_the_attractor(self):
import numpy as np
expected = np.array([_mean(FIXED_POINTS, a) for a in range(3)])
measured = self.positions.mean(axis=0).astype(np.float64)
print(f"[points] centroid {np.round(measured, 5)} expected "
f"{np.round(expected, 5)}")
for axis in range(3):
self.assertAlmostEqual(
expected[axis], measured[axis], delta=0.01,
msg=f"axis {axis}: emitted centroid {measured[axis]:.5f} against "
f"the analytic {expected[axis]:.5f}")
def test_no_point_is_outside_the_convex_hull(self):
"""
The attractor is contained in the tetrahedron by construction, so anything
outside it is not a sample of this fractal. The tolerance is float slop, not
latitude — the maps are exact contractions toward the vertices.
"""
import numpy as np
outside, worst = _outside_hull(
self.positions.astype(np.float64), FIXED_POINTS, 1e-4)
print(f"[points] outside hull: {outside} of {len(self.positions)}, "
f"worst {worst:.2e}")
self.assertEqual(0, outside,
f"{outside} points lie outside the attractor's hull, "
f"the worst by {abs(worst):.4f}")
def test_no_point_sits_at_the_origin(self):
"""
`ivec3` of an unwritten texel is (0, 0, 0), and the origin is INSIDE this
hull, so a slot never written would pass every other check here. The kernel
rejects `dot(pos, pos) == 0` as an invalid point, so a zero can only mean the
readback ran past the counter.
"""
import numpy as np
zeros = int((np.abs(self.positions).sum(axis=1) == 0.0).sum())
self.assertEqual(0, zeros,
f"{zeros} points are exactly at the origin — the readback "
"is including slots the kernel never wrote")
def test_the_cloud_reproduces_the_still_render(self):
"""
The checkpoint the plan asked for: does the emitted cloud have the same shape,
in the same place, as what the renderer draws? Projected through the engine's
own camera and compared to an actual GPU still render.
"""
import numpy as np
from test_bake_orientation import projected_density, still_density
rendered = still_density(self.doc)
# A subsample keeps the pure-Python projection loop honest about runtime;
# 60k points over a 192x192 frame still covers every lit pixel many times.
index = np.random.default_rng(7).choice(
len(self.positions), size=min(60_000, len(self.positions)),
replace=False)
predicted = projected_density(
self.doc.camera.view_proj, self.positions[index])
def centroid(image):
ys, xs = np.nonzero(image)
weights = image[ys, xs]
total = weights.sum()
return np.array([(xs * weights).sum() / total,
(ys * weights).sum() / total])
got, want = centroid(predicted), centroid(rendered)
overlap = np.logical_and(rendered > 0, predicted > 0).sum()
union = np.logical_or(rendered > 0, predicted > 0).sum()
iou = overlap / float(union)
print(f"[points] projected centroid {np.round(got, 2)} vs still render "
f"{np.round(want, 2)} px, IoU {iou:.3f}")
for axis, name in enumerate(("x", "y")):
self.assertLess(
abs(got[axis] - want[axis]), 4.0,
f"screen {name}: the cloud projects to {got[axis]:.1f} px and the "
f"still render sits at {want[axis]:.1f} px")
self.assertGreater(
iou, 0.5,
f"only {iou:.1%} of lit pixels agree — the cloud is not the shape the "
"renderer draws")
# --------------------------------------------------- is it carried correctly
def test_colour_is_carried_per_point(self):
import numpy as np
rgb = self.colors[:, :3]
self.assertTrue(np.isfinite(self.colors).all(), "colour has NaN or inf")
self.assertGreaterEqual(float(rgb.min()), 0.0)
self.assertLessEqual(float(rgb.max()), 1.0)
spread = float(rgb.std(axis=0).max())
print(f"[points] colour spread {spread:.4f}, mean {np.round(rgb.mean(0), 3)}")
self.assertGreater(
spread, 0.01,
"every point has the same colour — the palette is not reaching the emit")
# The w channel carries the iterator's opacity, which is 1.0 throughout here.
self.assertAlmostEqual(1.0, float(self.colors[:, 3].min()), places=5)
def test_the_dimension_estimate_recovers_the_known_value(self):
"""
Four maps at ratio 0.5 put the Sierpinski tetrahedron at ln4/ln2 = 2 exactly.
The radius derivation measures dimension on its way to a spacing, so there is
a right answer for it to land on — which is the difference between a derived
number and a plausible one.
"""
import ifs_points
spacing, dimension = ifs_points.estimate_spacing(self.positions)
print(f"[points] spacing {spacing:.6f}, dimension {dimension:.3f} "
f"(exact 2.0)")
self.assertAlmostEqual(
2.0, dimension, delta=0.3,
msg=f"measured dimension {dimension:.3f} against the tetrahedron's "
"exact 2.0")
self.assertGreater(spacing, 0.0)
# A spacing wider than the fractal itself would mean one blob per point.
self.assertLess(spacing, 0.1)
# ------------------------------------------------------ capacity and honesty
def test_capacity_is_respected_and_overflow_reported(self):
"""
A truncated cloud looks exactly like a complete one, so the host has to say
so. The counter runs past the end on purpose to make that possible.
"""
import ifs_points
_positions, _colors, stats = ifs_points.emit_document(
self.doc, capacity=SMALL_CAPACITY)
print(f"[points] small emit {stats}")
self.assertEqual(SMALL_CAPACITY, stats["capacity"])
self.assertEqual(SMALL_CAPACITY, stats["points"])
self.assertEqual(SMALL_CAPACITY, len(_positions))
self.assertTrue(stats["filled"])
self.assertGreaterEqual(stats["requested"], stats["capacity"])
def test_the_arrays_agree_with_the_reported_count(self):
self.assertEqual(self.stats["points"], len(self.positions))
self.assertEqual(self.stats["points"], len(self.colors))
self.assertEqual(3, self.positions.shape[1])
self.assertEqual(4, self.colors.shape[1])
def test_the_emitted_cloud_matches_a_cpu_chaos_game(self):
"""Per-axis mass distribution against an independent CPU run of the same IFS."""
import numpy as np
reference = _cpu_attractor()
for axis in range(3):
for q in (5, 50, 95):
got = float(np.percentile(self.positions[:, axis], q))
want = float(np.percentile(reference[:, axis], q))
self.assertAlmostEqual(
want, got, delta=0.02,
msg=f"axis {axis} p{q:02d}: emitted {got:+.4f} against the CPU "
f"attractor's {want:+.4f}")
# ------------------------------------------------------------------ ingest
def test_the_pointcloud_ingests_into_blender(self):
import numpy as np
import ifs_points
take = 5000
positions = self.positions[:take]
colors = self.colors[:take]
cloud = ifs_points.to_pointcloud(positions, colors, radius=0.001,
name="IFS Points Test")
self.assertEqual(take, len(cloud.points))
out = np.zeros(take * 3, dtype=np.float32)
cloud.attributes['position'].data.foreach_get('vector', out)
self.assertLess(float(np.abs(out.reshape(take, 3) - positions).max()), 1e-6)
self.assertIn(ifs_points.COLOR_ATTRIBUTE, cloud.attributes)
# `color` is now written too, carrying the raw degree-0 SH coefficient for
# Blender's splat path. Measured: its presence does not change the points
# render at all. What must stay true is that the MATERIAL reads ifs_color —
# pinned by test_points_operator, not here.
got = np.zeros(take * 4, dtype=np.float32)
cloud.attributes[ifs_points.COLOR_ATTRIBUTE].data.foreach_get('color', got)
self.assertLess(
float(np.abs(got.reshape(take, 4)[:, :3] - colors[:, :3]).max()), 1e-6)
radii = np.zeros(take, dtype=np.float32)
cloud.attributes['radius'].data.foreach_get('value', radii)
self.assertAlmostEqual(0.001, float(radii.min()), places=6)
if __name__ == "__main__":
result = unittest.main(argv=sys.argv[:1], exit=False, verbosity=2).result
sys.exit(0 if result.wasSuccessful() else 1)