OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux commits / ef1beec

A flat fractal got a box fitted to the probe's rounding, not to itself
auto_bounds can only resolve an axis to the PROBE's voxel size. When a fractal is flat - spherical_gasket_base is planar, 2 probe voxels thick out of 64 - both edges of that axis are rounding artifacts, so the fitted box came out barely thicker than one probe voxel AND offset by up to one. The plane ended up 20 voxels off centre in a 64-voxel bake, two thirds of the way up its own box: the resolution went on empty space and the fractal was pressed against a face. The arithmetic reproduces the observed bounds exactly - probe voxel 8/64 = 0.125, hits in voxels 31..32, so [-0.125, 0.0], then the 15% margin gives [-0.144, 0.019], which is what the bake reported - so it was never noise. Collapsed axes are now re-probed inside the fitted box, where the voxel size is smaller by the ratio of the two spans. The percentile fit moved out to _fit(hit, box, ...) because it was written against a symmetric +/-extent cube and a refinement pass has to refit inside a tighter one. Collapsed axes are padded by one probe voxel of the box just used before re-probing: the fitted edges are themselves quantised, so re-probing the fit exactly would clip whatever sits in the rounding. A truly planar attractor would refine forever, each pass halving the thickness toward a zero-size voxel, so refinement stops once an axis is thinner than one voxel of the LARGEST axis - past that it is planar in any sense the bake can represent. Measured, centre offset in bake voxels: gasket 20.0 -> 1.0, freeform 3.5 -> 0.0, nebula 0.5 -> 0.5 (fully 3D, correctly untouched). The gasket now spans z 3..62 instead of 41..62. This was NOT an axis-ordering bug, and the tests say so: nebula and plastic come out fully 3D through the same code path, which is what proves the gasket's flat z is the document rather than the fitter. I flagged it as an anomaly first and was wrong. Three tests: the planar bake must be centred; refinement must be WHAT centres it, since otherwise the first test would pass without the fix doing anything; and a fully 3D bake must be identical with and without refinement, proving it skips. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ef1beec590be6d2eab701927fd2bdda3b6ef29f4
by plankatron <fleeky@gmail.com> · 2026-08-17T20:46:06.000Z

Changed files (2) +179 -8

blender/ifs_volume.py +63 -8
……@@ -128,7 +128,8 @@ def accumulate(shader, doc, ubos, alias_tex, size, bounds, dispatches,
128128
129129
130130def auto_bounds(shader, doc, ubos, alias_tex, probe_size=64, dispatches=8,
131 percentile=99.5, margin=0.15, start_extent=4.0, max_extent=256.0):
131 percentile=99.5, margin=0.15, start_extent=4.0, max_extent=256.0,
132 refine_passes=2, collapse_voxels=8):
132133 """
133134 Fits the bake box to where the fractal actually is.
134135
……@@ -164,19 +165,73 @@ def auto_bounds(shader, doc, ubos, alias_tex, probe_size=64, dispatches=8,
164165 extent *= 2.0
165166
166167 clipped = edge > 0.01
168 box = ((-extent, -extent, -extent), (extent, extent, extent))
169 lo_w, hi_w = _fit(hit, box, probe_size, percentile, margin)
170
171 # Re-probe any axis that collapsed to a handful of probe voxels.
172 #
173 # The fit can only resolve an axis to the PROBE's voxel size, so a flat fractal -
174 # spherical_gasket_base is planar, 2 voxels thick out of 64 - gets a box barely
175 # thicker than one probe voxel AND offset by up to one, because both edges are
176 # rounding artifacts. That put the plane at ~88% of the box height instead of the
177 # middle, so the bake spent its resolution on empty space and pressed the fractal
178 # against a face. Re-probing inside the fitted box gives that axis a voxel size
179 # smaller by the ratio of the two spans, which is where the precision comes from.
180 #
181 # A truly planar attractor would refine forever, each pass halving the thickness
182 # toward a zero-size voxel, so this stops when an axis is thinner than one voxel
183 # of the LARGEST axis: past that it is planar in any sense the bake can represent.
184 for _ in range(refine_passes):
185 occupied = [len(np.unique(hit[:, 2 - axis])) for axis in range(3)]
186 span = hi_w - lo_w
187 floor = float(span.max()) / probe_size
188 collapsed = [a for a in range(3)
189 if occupied[a] < collapse_voxels and span[a] > floor]
190 if not collapsed:
191 break
192
193 # Pad the collapsed axes by a probe voxel of the box we just used: the fitted
194 # edges are themselves quantised, so re-probing exactly the fit would clip
195 # whatever sits in the rounding.
196 pad = np.zeros(3)
197 for a in collapsed:
198 pad[a] = (box[1][a] - box[0][a]) / probe_size
199 probe_lo = tuple(float(lo_w[a] - pad[a]) for a in range(3))
200 probe_hi = tuple(float(hi_w[a] + pad[a]) for a in range(3))
201
202 finer = accumulate(shader, doc, ubos, alias_tex, probe_size,
203 (probe_lo, probe_hi), dispatches)
204 finer_hit = np.argwhere(finer[:, :, :, 3] > 0)
205 if finer_hit.size == 0:
206 break # the tighter box found nothing; keep what we had
207
208 box = (probe_lo, probe_hi)
209 hit = finer_hit
210 lo_w, hi_w = _fit(hit, box, probe_size, percentile, margin)
211
212 return (tuple(float(v) for v in lo_w), tuple(float(v) for v in hi_w),
213 {"probe_extent": extent, "edge_fraction": float(edge),
214 "clipped": bool(clipped)})
215
216
217def _fit(hit, box, probe_size, percentile, margin):
218 """
219 Percentile-fits world bounds to probe hits, for an ARBITRARY probe box.
220
221 `hit` columns are (z, y, x) - the readback order - so they are reversed here into
222 world xyz. This was inline against a symmetric +/-extent cube; it takes the box as
223 an argument so a refinement pass can re-fit inside a tighter one.
224 """
225 lo, hi = np.array(box[0], dtype=float), np.array(box[1], dtype=float)
167226 zyx_lo = np.percentile(hit, 100 - percentile, axis=0)
168227 zyx_hi = np.percentile(hit, percentile, axis=0)
169228 lo_n = np.array([zyx_lo[2], zyx_lo[1], zyx_lo[0]]) / probe_size
170229 hi_n = np.array([zyx_hi[2], zyx_hi[1], zyx_hi[0]]) / probe_size
171230
172 lo_w = -extent + lo_n * 2.0 * extent
173 hi_w = -extent + hi_n * 2.0 * extent
231 lo_w = lo + lo_n * (hi - lo)
232 hi_w = lo + hi_n * (hi - lo)
174233 span = np.maximum(hi_w - lo_w, 1e-3)
175 lo_w -= span * margin
176 hi_w += span * margin
177 return (tuple(float(v) for v in lo_w), tuple(float(v) for v in hi_w),
178 {"probe_extent": extent, "edge_fraction": float(edge),
179 "clipped": bool(clipped)})
234 return lo_w - span * margin, hi_w + span * margin
180235
181236
182237def write_vdb(grid: np.ndarray, bounds, path: str, name: str = "density"):
blender/test_auto_bounds.py +116 -0
……@@ -0,0 +1,116 @@
1"""
2Proves the bake box is fitted to the fractal, not to the probe's rounding.
3
4`auto_bounds` can only resolve an axis to the PROBE's voxel size. When a fractal is
5flat - spherical_gasket_base is planar, 2 probe voxels thick out of 64 - both edges of
6that axis are rounding artifacts, so the fitted box came out barely thicker than one
7probe voxel AND offset by up to one. Measured, the plane ended up at ~88% of the box
8height: the bake spent its z resolution on empty space and pressed the fractal against
9a face. The arithmetic reproduces it exactly, so it was never random.
10
11That is invisible in review, because a fractal squashed against one side of its box is
12still a fractal. It shows only as a bake whose proportions and placement disagree with
13the still render.
14
15The fix re-probes collapsed axes inside the fitted box, where the voxel size is smaller
16by the ratio of the two spans. A truly planar attractor would refine forever, each pass
17halving the thickness toward a zero-size voxel, so refinement stops once an axis is
18thinner than one voxel of the largest axis.
19
20Needs a GPU. --factory-startup is REQUIRED, or an enabled add-on has already imported
21ifs_volume from the INSTALLED copy and sys.path cannot displace it:
22
23 blender -b --factory-startup -noaudio --python blender/test_auto_bounds.py
24"""
25
26import os
27import sys
28import unittest
29
30sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
31
32REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
33TPL = os.path.join(REPO, "WpfDisplay", "Library", "Templates")
34
35PLANAR = "spherical_gasket_base.ifsjson" # 2 probe voxels thick in z
36SOLID = "nebula_sample.ifsjson" # fully 3D in all axes
37SIZE = 32
38DISPATCHES = 10
39
40
41class AutoBoundsCentring(unittest.TestCase):
42 @classmethod
43 def setUpClass(cls):
44 try:
45 import gpu
46 import numpy # noqa: F401
47 import ifs_volume
48 except ImportError as ex:
49 raise unittest.SkipTest(f"needs Blender's gpu module ({ex})")
50 if not os.path.isdir(TPL):
51 raise unittest.SkipTest("templates not found")
52 gpu.init()
53 cls.shader, cls.library = ifs_volume.build_shader()
54
55 def _occupancy(self, template, refine_passes):
56 """Returns the z extent of the baked density, in bake voxels."""
57 import gpu
58 import numpy as np
59 import ifs_document
60 import ifs_host
61 import ifs_volume
62
63 doc = ifs_document.load(os.path.join(TPL, template), self.library)
64 ubos = ifs_volume._document_buffers(doc)
65 alias = ifs_host.build_alias_tables(doc.iterators)
66 n = len(doc.iterators)
67 tex = gpu.types.GPUTexture((n, n), format='RGBA32F',
68 data=gpu.types.Buffer('FLOAT', len(alias), alias))
69 lo, hi, _fit = ifs_volume.auto_bounds(
70 self.shader, doc, ubos, tex, refine_passes=refine_passes)
71 grid = ifs_volume.accumulate(self.shader, doc, ubos, tex, SIZE,
72 (lo, hi), DISPATCHES)
73 hit = np.argwhere(grid[:, :, :, 3] > 0) # columns are (z, y, x)
74 self.assertGreater(hit.size, 0, f"{template} produced an empty bake")
75 z = hit[:, 0]
76 return int(z.min()), int(z.max())
77
78 def _offset(self, lo, hi):
79 """How far the fractal's centre sits from the box's, in bake voxels."""
80 return abs((lo + hi) / 2.0 - (SIZE - 1) / 2.0)
81
82 def test_a_planar_fractal_is_centred_in_its_box(self):
83 """
84 The one that matters. Without refinement the gasket's plane sat 20 voxels off
85 centre in a 64-voxel bake - two thirds of the way up its own box.
86 """
87 lo, hi = self._occupancy(PLANAR, refine_passes=2)
88 offset = self._offset(lo, hi)
89 self.assertLess(offset, SIZE * 0.10,
90 f"the plane sits {offset:.1f} voxels off centre; the box is "
91 "still fitted to the probe's rounding rather than the fractal")
92
93 def test_refinement_is_what_centres_it(self):
94 """
95 Guards the guard: if the unrefined fit were already centred, the test above
96 would pass without the fix doing anything.
97 """
98 before = self._offset(*self._occupancy(PLANAR, refine_passes=0))
99 after = self._offset(*self._occupancy(PLANAR, refine_passes=2))
100 self.assertGreater(before, after * 2.0,
101 f"refinement changed little (before {before:.1f}, after "
102 f"{after:.1f}) - this test is not exercising the fix")
103
104 def test_a_solid_fractal_is_left_alone(self):
105 """
106 Refinement must not disturb a fractal that already fills its box - no axis
107 collapses, so nothing should trigger.
108 """
109 before = self._occupancy(SOLID, refine_passes=0)
110 after = self._occupancy(SOLID, refine_passes=2)
111 self.assertEqual(before, after,
112 "refinement altered a fully 3D bake, which it should skip")
113
114
115if __name__ == "__main__":
116 unittest.main(argv=sys.argv[:1], exit=False, verbosity=2)