| … | … | @@ -264,6 +264,122 @@ class SplatAttributes(unittest.TestCase): |
| 264 | 264 | for name in (ifs_points.COLOR_ATTRIBUTE, 'radius', 'scale', 'rotation'): |
| 265 | 265 | self.assertIn(name, cloud.attributes, f'{name} was lost') |
| 266 | 266 | |
| 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 | |
| 300 | class 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 | |
| 267 | 383 | |
| 268 | 384 | if __name__ == "__main__": |
| 269 | 385 | result = unittest.main(argv=sys.argv[:1], exit=False, verbosity=2).result |