OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux commits / 1744770

A radius per point, calibrated — one value cannot span a fractal
Measured on the shipped templates, the nearest-neighbour distance inside ONE document spans 16-18x between its p10 and p90 point (light_rods 0.90 -> 14.19, nebula 0.0175 -> 0.322). So a single radius is unserveable: sized for the dense filaments the halo bloats, sized for the halo the filaments stay separate beads. That is the second half of "still just discrete fuzzy dots". `local_radius` reads density off a uniform grid — inside a cell of side s holding c samples of an attractor of dimension D the spacing is s / c**(1/D), which is the same reasoning estimate_spacing uses globally, applied locally for the cost of a bincount. The grid is fitted to the 99.5% MASS, not the extent: light_rods spans 3236 units around a body of ~1167, and gridding the full extent puts the whole fractal in one cell. CALIBRATED, and that is the part that took a measurement to find. Uncalibrated the shape was right and the scale was not — median radius came out 0.15x (light_rods) and 0.49x (nebula) of half the true nearest-neighbour spacing, i.e. still too small to touch, while correlating 0.64/0.82 with the real thing. The error is systematic: cells are slabs on a sheet-like fractal, so a cell's geometric-mean side does not describe the spacing inside it. So the grid supplies the variation and an exact NN measurement on a subsample supplies the scale. light_rods median ratio 0.15x -> 1.24x (1.0 = neighbours exactly touch) nebula median ratio 0.49x -> 1.27x correlation 0.64 / 0.82 preserved, 6-7x variation retained `k=0.5` now means what it says instead of being a number to tune by eye. Wired into the viewport emit and into to_pointcloud, which takes a scalar OR a per-point array and feeds BOTH the `radius` attribute Cycles draws spheres from and the `scale` attribute the splat contract carries — a test asserts both, because asserting one would let the other keep a constant. Honest note: the local_radius tests are characterisation, not test-first — the function was written before them. The direction test uses a cloud whose answer is known by construction (dense core in a sparse halo, 11.4x) and is paired with a uniform-cloud control at 1.00x, so "returns noise" fails. 18 tests here; splat/points suites unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
174477076bfe95df93c955822fc90c10e955ab9b
by plankatron <fleeky@gmail.com> · 2026-08-18T15:19:58.000Z

Changed files (3) +156 -3

blender/ifs_points.py +32 -2
……@@ -342,6 +342,32 @@ def local_radius(points, dimension=None, cells=64, k=0.5, clamp=(0.02, 50.0)):
342342 per_point = occupancy[flat].astype(np.float32)
343343 spacing = side / np.maximum(per_point, 1.0) ** (1.0 / dimension)
344344
345 # CALIBRATE the magnitude against measured nearest-neighbour distances.
346 #
347 # The grid gets the SHAPE of the density field right and the scale wrong, and
348 # both halves were measured. Uncalibrated, the median radius came out 0.15x
349 # (light_rods) and 0.49x (nebula) of half the true nearest-neighbour spacing —
350 # i.e. still too small to touch, still beads — while correlating 0.64/0.82 with
351 # the real thing. The error is systematic, not random: cells are slabs on a
352 # sheet-like fractal, so a cell's geometric-mean side does not describe the
353 # spacing inside it.
354 #
355 # So the grid supplies the variation and an exact measurement supplies the
356 # scale. `k` is then what it claims to be — 0.5 means neighbouring splats just
357 # touch — rather than a number to tune by eye.
358 if count >= 16:
359 sample = min(2000, count)
360 pick = np.random.default_rng(0).choice(count, size=sample, replace=False)
361 chosen = points[pick].astype(np.float64)
362 square = (chosen ** 2).sum(axis=1)
363 distance = square[:, None] + square[None, :] - 2.0 * (chosen @ chosen.T)
364 np.fill_diagonal(distance, np.inf)
365 measured = np.sqrt(np.maximum(distance.min(axis=1), 0.0))
366 target = float(np.median(measured))
367 here = float(np.median(spacing[pick]))
368 if here > 0 and np.isfinite(target) and target > 0:
369 spacing = spacing * (target / here)
370
345371 radius = (k * spacing).astype(np.float32)
346372 typical = float(np.median(radius)) if count else 1.0
347373 return np.clip(radius, typical * clamp[0], typical * clamp[1]).astype(np.float32)
……@@ -436,8 +462,12 @@ def to_pointcloud(positions, colors, radius, name="IFS Points",
436462 rgba[:, 3] = 1.0
437463 colour.data.foreach_set('color', rgba.ravel())
438464
465 # Scalar or per-point. `local_radius` returns one value per sample, because a
466 # single radius cannot span the 16-18x density range inside one fractal.
467 radius_per_point = np.broadcast_to(
468 np.asarray(radius, dtype=np.float32), (count,)).astype(np.float32)
439469 radii = attribute('radius', 'FLOAT')
440 radii.data.foreach_set('value', np.full(count, radius, dtype=np.float32))
470 radii.data.foreach_set('value', radius_per_point)
441471
442472 # ── the gaussian-splat attributes ───────────────────────────────────────
443473 #
……@@ -466,7 +496,7 @@ def to_pointcloud(positions, colors, radius, name="IFS Points",
466496 attribute('opacity', 'FLOAT').data.foreach_set('value', opacity)
467497
468498 scale = np.empty((count, 3), dtype=np.float32)
469 scale[:] = radius
499 scale[:] = radius_per_point[:, None]
470500 attribute('scale', 'FLOAT_VECTOR').data.foreach_set('vector', scale.ravel())
471501
472502 # (w, x, y, z) — Blender's quaternion order is w-first, and so is the 3DGS
blender/ifs_viewport.py +8 -1
……@@ -669,8 +669,15 @@ class Session:
669669
670670 positions, colors, stats = ifs_points.emit_document(
671671 self.doc, capacity=self.splat_capacity, shader=self.splat_emit_shader)
672
673 # A radius PER POINT. The global estimate cannot serve a fractal: measured
674 # on the shipped templates the nearest-neighbour distance spans 16-18x
675 # within one document, so one value leaves the filaments as separate beads
676 # while bloating the halo. `dimension` is reused from the emit rather than
677 # re-estimated — it is the same cloud.
678 radius = ifs_points.local_radius(positions, dimension=stats.get('dimension'))
672679 self.splat_cloud = ifs_splat.upload(
673 positions, colors, stats['radius'] * self.splat_radius_scale)
680 positions, colors, radius * self.splat_radius_scale)
674681 self.splat_stats = stats
675682
676683 def _draw_splats(self, context, space):
blender/test_points_splat_attributes.py +116 -0
……@@ -264,6 +264,122 @@ class SplatAttributes(unittest.TestCase):
264264 for name in (ifs_points.COLOR_ATTRIBUTE, 'radius', 'scale', 'rotation'):
265265 self.assertIn(name, cloud.attributes, f'{name} was lost')
266266
267 def test_a_per_point_radius_reaches_the_cloud(self):
268 """
269 Wiring. `to_pointcloud` took a scalar; a per-point array has to survive
270 into BOTH the `radius` attribute Cycles draws spheres from and the `scale`
271 attribute the splat contract carries — asserting one would let the other
272 silently keep a constant.
273 """
274 import numpy as np
275 import ifs_points
276 positions, colors = _sample_arrays()
277 radius = np.linspace(0.01, 0.05, len(positions)).astype(np.float32)
278 cloud = ifs_points.to_pointcloud(positions, colors, radius=radius,
279 name='per-point-radius')
280
281 got = _read(cloud, 'radius', 'value', 1)
282 self.assertLess(float(np.abs(got - radius).max()), 1e-6,
283 'the radius attribute did not take the per-point array')
284 scale = _read(cloud, 'scale', 'vector', 3)
285 for axis in range(3):
286 self.assertLess(float(np.abs(scale[:, axis] - radius).max()), 1e-6,
287 f'scale axis {axis} is not the per-point radius')
288
289 def test_a_scalar_radius_still_works(self):
290 """The old call shape is used by the operator and the tests; keep it."""
291 import numpy as np
292 import ifs_points
293 positions, colors = _sample_arrays()
294 cloud = ifs_points.to_pointcloud(positions, colors, radius=0.02,
295 name='scalar-radius')
296 got = _read(cloud, 'radius', 'value', 1)
297 self.assertLess(float(np.abs(got - 0.02).max()), 1e-6)
298
299
300class LocalRadius(unittest.TestCase):
301 """
302 A radius per point, from local density.
303
304 Measured on the shipped templates, the nearest-neighbour distance inside ONE
305 document spans 16-18x between its p10 and p90 point (light_rods 0.90 -> 14.19,
306 nebula 0.0175 -> 0.322). So no single radius can be right: pick one that makes
307 the dense filaments continuous and the sparse halo becomes bloated blobs; pick
308 one that suits the halo and the filaments stay as separate beads. That is why
309 the cloud reads as fuzzy dots rather than structure.
310 """
311
312 @classmethod
313 def setUpClass(cls):
314 try:
315 import numpy # noqa: F401
316 except ImportError as ex:
317 raise unittest.SkipTest(f"needs numpy ({ex})")
318 import ifs_points # noqa: F401 — ours, unguarded
319
320 def _two_density_cloud(self):
321 """A tight core inside a sparse halo, so the right answer is known."""
322 import numpy as np
323 rng = np.random.default_rng(0)
324 core = rng.normal(0.0, 0.02, (6000, 3)).astype(np.float32)
325 halo = rng.normal(0.0, 1.0, (1500, 3)).astype(np.float32)
326 return core, halo, np.concatenate([core, halo])
327
328 def test_the_dense_region_gets_a_smaller_radius(self):
329 """
330 THE claim. A function returning varying-but-arbitrary values would satisfy
331 "it varies", so this asserts the DIRECTION against a cloud whose answer is
332 known by construction.
333 """
334 import numpy as np
335 import ifs_points
336 core, halo, points = self._two_density_cloud()
337 radius = ifs_points.local_radius(points)
338
339 self.assertEqual(len(points), len(radius), 'one radius per point')
340 core_r = float(np.median(radius[:len(core)]))
341 halo_r = float(np.median(radius[len(core):]))
342 print(f"[local_radius] core {core_r:.6f} halo {halo_r:.6f} "
343 f"ratio {halo_r / max(core_r, 1e-12):.1f}x")
344 self.assertGreater(halo_r, core_r * 2.0,
345 f'the sparse halo got {halo_r:.6f} and the dense core '
346 f'{core_r:.6f} — density is not driving the radius')
347
348 def test_a_uniform_cloud_gets_a_near_uniform_radius(self):
349 """
350 The control. Without it, a function that simply returned noise would pass
351 the direction test half the time.
352 """
353 import numpy as np
354 import ifs_points
355 rng = np.random.default_rng(1)
356 points = rng.uniform(-1.0, 1.0, (20000, 3)).astype(np.float32)
357 radius = ifs_points.local_radius(points)
358 spread = float(np.percentile(radius, 90) / max(np.percentile(radius, 10), 1e-12))
359 print(f"[local_radius] uniform cloud p90/p10 = {spread:.2f}x")
360 self.assertLess(spread, 2.5,
361 f'a uniform cloud produced a {spread:.1f}x radius spread — '
362 'the estimate is reading noise, not density')
363
364 def test_every_radius_is_finite_and_positive(self):
365 """A zero or NaN radius is a splat that renders as nothing, silently."""
366 import numpy as np
367 import ifs_points
368 _core, _halo, points = self._two_density_cloud()
369 radius = ifs_points.local_radius(points)
370 self.assertTrue(bool(np.isfinite(radius).all()), 'non-finite radius')
371 self.assertGreater(float(radius.min()), 0.0, 'a zero radius draws nothing')
372
373 def test_it_survives_a_degenerate_cloud(self):
374 """Every sample in one spot — a document that produces nothing must not
375 crash the draw handler."""
376 import numpy as np
377 import ifs_points
378 points = np.zeros((500, 3), dtype=np.float32)
379 radius = ifs_points.local_radius(points)
380 self.assertEqual(500, len(radius))
381 self.assertTrue(bool(np.isfinite(radius).all()))
382
267383
268384if __name__ == "__main__":
269385 result = unittest.main(argv=sys.argv[:1], exit=False, verbosity=2).result