| … | … | @@ -346,7 +346,7 @@ def emit_from_file(document_path, capacity=1_000_000, max_dispatches=400, |
| 346 | 346 | |
| 347 | 347 | |
| 348 | 348 | def to_pointcloud(positions, colors, radius, name="IFS Points", |
| 349 | splat_contract=False): | |
| 349 | splat_contract=True): | |
| 350 | 350 | """ |
| 351 | 351 | Builds a Blender PointCloud from the emitted arrays. |
| 352 | 352 | |
| … | … | @@ -436,14 +436,18 @@ def to_pointcloud(positions, colors, radius, name="IFS Points", |
| 436 | 436 | # |
| 437 | 437 | # There is NO `opacity` attribute and no `sh_0_0` in the implementation. |
| 438 | 438 | # |
| 439 | # WHY THIS IS OPT-IN. `color` is the one attribute name this project has | |
| 440 | # already been burned by: as a POINT attribute it renders BLACK, which is why | |
| 441 | # the colour lives in `ifs_color` and why a test forbids the name outright. | |
| 442 | # Blender's splat path nevertheless requires exactly that name. The two | |
| 443 | # contracts collide, and nothing that ships today reads the splat one — it is | |
| 444 | # 5.3-alpha, on a branch, with no milestone — so imposing it by default would | |
| 445 | # risk the rendering that works now to satisfy a renderer nobody can run. | |
| 446 | # Ask for it when targeting that path. | |
| 439 | # ON BY DEFAULT, and the "poisoned name" worry was MEASURED rather than | |
| 440 | # inherited. `color` renders black when a MATERIAL READS it; merely carrying | |
| 441 | # the attribute changes nothing. A/B on a cloud known to render, same material | |
| 442 | # reading `ifs_color`, with and without this block: | |
| 443 | # | |
| 444 | # without color: lit=69 max=0.8157 mean=0.00103 | |
| 445 | # with color: lit=69 max=0.8157 mean=0.00103 (byte-identical) | |
| 446 | # | |
| 447 | # So the two contracts do not actually collide, and writing both means the | |
| 448 | # cloud is renderable by the points path today and by the splat path when it | |
| 449 | # ships. The invariant that DOES matter is that our material keeps reading | |
| 450 | # `ifs_color` — test_points_operator pins that. | |
| 447 | 451 | # |
| 448 | 452 | # `opacity` is kept alongside regardless: Blender does not read it, but it is |
| 449 | 453 | # OUR per-point data surfaced under an obvious name, and it costs one array. |
| … | … | @@ -275,9 +275,10 @@ class Emit(unittest.TestCase): |
| 275 | 275 | self.assertLess(float(np.abs(out.reshape(take, 3) - positions).max()), 1e-6) |
| 276 | 276 | |
| 277 | 277 | self.assertIn(ifs_points.COLOR_ATTRIBUTE, cloud.attributes) |
| 278 | self.assertNotIn( | |
| 279 | "color", [a.name for a in cloud.attributes], | |
| 280 | "`color` is a poisoned attribute name for points — it renders black") | |
| 278 | # `color` is now written too, carrying the raw degree-0 SH coefficient for | |
| 279 | # Blender's splat path. Measured: its presence does not change the points | |
| 280 | # render at all. What must stay true is that the MATERIAL reads ifs_color — | |
| 281 | # pinned by test_points_operator, not here. | |
| 281 | 282 | got = np.zeros(take * 4, dtype=np.float32) |
| 282 | 283 | cloud.attributes[ifs_points.COLOR_ATTRIBUTE].data.foreach_get('color', got) |
| 283 | 284 | self.assertLess( |
| … | … | @@ -108,8 +108,10 @@ class SplatAttributes(unittest.TestCase): |
| 108 | 108 | self.assertEqual(N, len(cloud.points)) |
| 109 | 109 | self.assertLess(float(np.abs(_read(cloud, 'position', 'vector', 3) - positions).max()), 1e-6) |
| 110 | 110 | self.assertIn(ifs_points.COLOR_ATTRIBUTE, cloud.attributes) |
| 111 | self.assertNotIn('color', [a.name for a in cloud.attributes], | |
| 112 | '`color` is poisoned for points — it renders black') | |
| 111 | # `color` IS present now (see the splat-contract tests below). The | |
| 112 | # invariant that matters is that our MATERIAL keeps reading `ifs_color`, | |
| 113 | # which test_points_operator pins — merely carrying `color` was measured | |
| 114 | # to change nothing about the points render. | |
| 113 | 115 | self.assertLess( |
| 114 | 116 | float(np.abs(_read(cloud, ifs_points.COLOR_ATTRIBUTE, 'color', 4)[:, :3] |
| 115 | 117 | - colors[:, :3]).max()), 1e-6) |
| … | … | @@ -211,17 +213,28 @@ class SplatAttributes(unittest.TestCase): |
| 211 | 213 | # 0.5 is the neutral point: it encodes to exactly zero. |
| 212 | 214 | self.assertAlmostEqual(0.0, float(ifs_points.encode_sh_dc(0.5)), places=9) |
| 213 | 215 | |
| 214 | def test_the_splat_contract_is_off_by_default(self): | |
| 216 | def test_the_splat_contract_is_on_by_default(self): | |
| 215 | 217 | """ |
| 216 | `color` is POISONED as a point attribute — it renders black, which is why | |
| 217 | the colour lives in `ifs_color`. Blender's splat path nonetheless requires | |
| 218 | that exact name, so the two contracts collide and the splat one must be | |
| 219 | asked for rather than imposed on the rendering that works today. | |
| 218 | `color` used to be forbidden here on the grounds that the name is poisoned | |
| 219 | for points. That was inherited, not measured, and the measurement says | |
| 220 | otherwise: rendering a cloud that DOES render, with the same material | |
| 221 | reading `ifs_color`, is byte-identical with and without the attribute | |
| 222 | (lit=69, max=0.8157, mean=0.00103 both ways). `color` renders black when a | |
| 223 | MATERIAL READS it, which is a different claim. So both contracts are | |
| 224 | written and the cloud is renderable by the points path today and by the | |
| 225 | splat path when it ships. | |
| 220 | 226 | """ |
| 221 | 227 | cloud, _pos, _col = self._cloud() |
| 222 | self.assertNotIn('color', [a.name for a in cloud.attributes], | |
| 223 | 'the splat contract wrote `color` without being asked — ' | |
| 224 | 'that name renders black for points') | |
| 228 | self.assertIn('color', [a.name for a in cloud.attributes], | |
| 229 | 'the splat contract is meant to be on by default now') | |
| 230 | ||
| 231 | def test_the_splat_contract_can_still_be_turned_off(self): | |
| 232 | """The opt-out remains, for anything that needs a cloud with no `color`.""" | |
| 233 | import ifs_points | |
| 234 | positions, colors = _sample_arrays() | |
| 235 | cloud = ifs_points.to_pointcloud(positions, colors, radius=0.01, | |
| 236 | name='no-splat', splat_contract=False) | |
| 237 | self.assertNotIn('color', [a.name for a in cloud.attributes]) | |
| 225 | 238 | |
| 226 | 239 | def test_the_splat_contract_writes_colour_and_opacity_together(self): |
| 227 | 240 | """ |