| … | … | @@ -294,6 +294,59 @@ def estimate_spacing(points, sample=2048, seed=0): |
| 294 | 294 | return max(spacing, 1e-9), dimension |
| 295 | 295 | |
| 296 | 296 | |
| 297 | def local_radius(points, dimension=None, cells=64, k=0.5, clamp=(0.02, 50.0)): | |
| 298 | """ | |
| 299 | A radius PER POINT, from the local sample density. | |
| 300 | ||
| 301 | A single global radius cannot work on a fractal, and this is measurable rather | |
| 302 | than aesthetic: within one document the nearest-neighbour distance varies by | |
| 303 | 16-18x between the p10 and p90 point (light_rods 0.90 -> 14.19, | |
| 304 | nebula 0.0175 -> 0.322). Any single value therefore leaves the sparse regions | |
| 305 | as separate dots while over-covering the dense ones — which is exactly what a | |
| 306 | flame looks like when it renders as fuzzy beads instead of filaments. | |
| 307 | ||
| 308 | Exact nearest-neighbour distances are O(n^2) and out of the question at a | |
| 309 | million points, so density is read off a uniform grid: bin the points, and | |
| 310 | inside a cell of side `s` holding `c` samples of an attractor with dimension | |
| 311 | `D`, the typical spacing is `s / c**(1/D)`. That is the same reasoning | |
| 312 | `estimate_spacing` uses globally, applied locally and for free — the whole | |
| 313 | thing is a bincount. | |
| 314 | ||
| 315 | The grid is fitted to the MASS, not the extent. light_rods spans 3236 units | |
| 316 | while its 99.5% body spans ~1167 and its dense sheet far less; gridding the | |
| 317 | full extent would put the entire fractal in one cell and measure nothing. | |
| 318 | """ | |
| 319 | points = np.asarray(points, dtype=np.float32) | |
| 320 | count = len(points) | |
| 321 | if count == 0: | |
| 322 | return np.zeros(0, dtype=np.float32) | |
| 323 | ||
| 324 | if dimension is None: | |
| 325 | _spacing, dimension = estimate_spacing(points) | |
| 326 | dimension = float(min(3.0, max(1.0, dimension))) | |
| 327 | ||
| 328 | # Percentile bounds, so a handful of far-flung samples cannot define the grid. | |
| 329 | lo = np.percentile(points, 0.5, axis=0) | |
| 330 | hi = np.percentile(points, 99.5, axis=0) | |
| 331 | span = np.maximum(hi - lo, 1e-9) | |
| 332 | cell = span / float(cells) | |
| 333 | ||
| 334 | index = np.floor((points - lo) / cell).astype(np.int64) | |
| 335 | np.clip(index, 0, cells - 1, out=index) | |
| 336 | flat = (index[:, 0] * cells + index[:, 1]) * cells + index[:, 2] | |
| 337 | occupancy = np.bincount(flat, minlength=cells ** 3) | |
| 338 | ||
| 339 | # Cell side as a single length: the geometric mean, so a flat fractal whose | |
| 340 | # cells are slabs is not judged by its thinnest axis. | |
| 341 | side = float(np.exp(np.log(cell).mean())) | |
| 342 | per_point = occupancy[flat].astype(np.float32) | |
| 343 | spacing = side / np.maximum(per_point, 1.0) ** (1.0 / dimension) | |
| 344 | ||
| 345 | radius = (k * spacing).astype(np.float32) | |
| 346 | typical = float(np.median(radius)) if count else 1.0 | |
| 347 | return np.clip(radius, typical * clamp[0], typical * clamp[1]).astype(np.float32) | |
| 348 | ||
| 349 | ||
| 297 | 350 | def emit_document(doc, capacity=1_000_000, max_dispatches=400, |
| 298 | 351 | invocation_iters=None, shader=None): |
| 299 | 352 | """ |
| … | … | @@ -87,9 +87,21 @@ void main() |
| 87 | 87 | float a = -dot(vOffset, vOffset); |
| 88 | 88 | if (a < -CUTOFF_SQ) discard; /* round, not square */ |
| 89 | 89 | |
| 90 | /* Nearest-splat depth, in exactly the encoding the kernel uses so the shared | |
| 91 | tonemap can read this buffer without knowing which pass filled it: | |
| 92 | floatBitsToUint of window-space z, which is monotonic over [0,1] so an | |
| 93 | atomic MIN is an exact minimum. We draw with Blender's own window matrix, | |
| 94 | so gl_FragCoord.z IS that value — no re-derivation, no near/far guesswork. | |
| 95 | Written for every surviving fragment rather than at the centre, for the | |
| 96 | reason the density-estimation filter had to: a fragment left at the far | |
| 97 | value silently refuses to be occluded. */ | |
| 98 | imageAtomicMin(depth_img, ivec2(gl_FragCoord.xy), floatBitsToUint(gl_FragCoord.z)); | |
| 99 | ||
| 90 | 100 | float weight = exp(a) * vAlpha; |
| 91 | /* Premultiplied: the blend mode is ADDITIVE_PREMULT, matching the viewport's | |
| 92 | emission path. Colour is NOT divided by anything — a flame is light. */ | |
| 101 | /* Premultiplied and ADDITIVE: this lands in the float accumulation buffer that | |
| 102 | the histogram path also fills, so the shared maxpass + tonemap turn it into | |
| 103 | the image. Values above 1 are wanted, not clamped — overlapping splats are | |
| 104 | how a flame gets its core, and the tonemap is what compresses that back. */ | |
| 93 | 105 | fragColor = vec4(vColor * weight, weight); |
| 94 | 106 | } |
| 95 | 107 | """ |
| … | … | @@ -110,6 +122,8 @@ def build_shader(): |
| 110 | 122 | |
| 111 | 123 | info.sampler(0, 'FLOAT_2D', 'splat_pos') |
| 112 | 124 | info.sampler(1, 'FLOAT_2D', 'splat_col') |
| 125 | info.image(0, 'R32UI', 'UINT_2D', 'depth_img', | |
| 126 | qualifiers={'NO_RESTRICT', 'READ', 'WRITE'}) | |
| 113 | 127 | info.push_constant('MAT4', 'view') |
| 114 | 128 | info.push_constant('MAT4', 'projection') |
| 115 | 129 | info.push_constant('INT', 'splat_width') |
| … | … | @@ -192,26 +206,33 @@ def upload(positions, colors, radius, width=SPLAT_WIDTH): |
| 192 | 206 | width, count) |
| 193 | 207 | |
| 194 | 208 | |
| 195 | def draw(cloud, shader, view_matrix, projection_matrix, additive=True, | |
| 196 | depth_test=True): | |
| 209 | def draw(cloud, shader, view_matrix, projection_matrix, depth_img, | |
| 210 | additive=True): | |
| 197 | 211 | """ |
| 198 | Draws the cloud with the viewport's compositing rules. | |
| 212 | Accumulates the cloud into whatever framebuffer is currently bound. | |
| 213 | ||
| 214 | This does NOT draw to the screen. It is a density pass: the caller binds a | |
| 215 | float colour attachment (the viewport's `accum`), and the tonemap turns the | |
| 216 | result into an image afterwards. That indirection is the whole point — the | |
| 217 | fractal's look comes from a curve applied to ACCUMULATED density, not from | |
| 218 | what one sample looks like, so drawing splats straight to the viewport | |
| 219 | produced fuzzy beads that no panel control could fix. | |
| 199 | 220 | |
| 200 | Depth is TESTED but not WRITTEN, exactly as the tonemap pass does: writing | |
| 201 | would let the fractal occlude gizmos and grid drawn after us, and would leave | |
| 202 | the buffer holding a nebula's depth, which is meaningless downstream. | |
| 221 | `depth_img` is the R32UI nearest-depth buffer the tonemap reads, written here | |
| 222 | by the fragment shader in the kernel's own encoding. | |
| 203 | 223 | """ |
| 204 | 224 | shader.bind() |
| 205 | 225 | shader.uniform_sampler('splat_pos', cloud.positions_tex) |
| 206 | 226 | shader.uniform_sampler('splat_col', cloud.colors_tex) |
| 227 | shader.image('depth_img', depth_img) | |
| 207 | 228 | shader.uniform_int('splat_width', (cloud.width,)) |
| 208 | 229 | shader.uniform_float('view', view_matrix) |
| 209 | 230 | shader.uniform_float('projection', projection_matrix) |
| 210 | 231 | |
| 232 | # No depth TEST here: this pass has no scene depth to test against, and | |
| 233 | # occlusion happens later when the tonemap blit emits gl_FragDepth from | |
| 234 | # depth_img and is tested against the real viewport buffer. | |
| 211 | 235 | gpu.state.blend_set('ADDITIVE_PREMULT' if additive else 'ALPHA') |
| 212 | 236 | gpu.state.depth_mask_set(False) |
| 213 | if depth_test: | |
| 214 | gpu.state.depth_test_set('LESS_EQUAL') | |
| 215 | 237 | cloud.batch.draw(shader) |
| 216 | gpu.state.depth_test_set('NONE') | |
| 217 | 238 | gpu.state.blend_set('NONE') |
| … | … | @@ -542,7 +542,7 @@ class Session: |
| 542 | 542 | # sees. Everything above this line still runs — the panel, the node graph |
| 543 | 543 | # and the dirty flags all have to reach the document either way. |
| 544 | 544 | if self.splats_enabled: |
| 545 | self._draw_splats(space) | |
| 545 | self._draw_splats(context, space) | |
| 546 | 546 | return |
| 547 | 547 | |
| 548 | 548 | view_proj = self._view_projection(space.region_3d, space.lens) |
| … | … | @@ -600,6 +600,25 @@ class Session: |
| 600 | 600 | gpu.compute.dispatch(self.drain, (self.width + 7) // 8, |
| 601 | 601 | (self.height + 7) // 8, 1) |
| 602 | 602 | |
| 603 | self._tonemap_blit() | |
| 604 | ||
| 605 | self.frames += 1 | |
| 606 | # Keep animating: without this the viewport only refines when nudged. | |
| 607 | for area in context.screen.areas: | |
| 608 | if area.type == 'VIEW_3D': | |
| 609 | area.tag_redraw() | |
| 610 | ||
| 611 | def _tonemap_blit(self): | |
| 612 | """ | |
| 613 | Auto-expose the accumulator and blit it to the viewport. | |
| 614 | ||
| 615 | Shared by BOTH paths, deliberately. The histogram fills `accum` by | |
| 616 | draining a uint histogram; the splat pass fills it by additive rasterising | |
| 617 | gaussians. Everything after that — the peak scan, the tonemap curve | |
| 618 | (Brightness, the shading modes, opacity gain, relief) and the depth | |
| 619 | composite — is identical, and identical because it is the same code rather | |
| 620 | than because two copies agree today. | |
| 621 | """ | |
| 603 | 622 | # Auto-exposure without a readback: the peak lives in a 1x1 uint image. |
| 604 | 623 | self.maxbuf.clear(format='UINT', value=(0, 0, 0, 0)) |
| 605 | 624 | self.maxpass.bind() |
| … | … | @@ -631,12 +650,6 @@ class Session: |
| 631 | 650 | gpu.state.depth_test_set('NONE') |
| 632 | 651 | gpu.state.blend_set('NONE') # leave the viewport's state as we found it |
| 633 | 652 | |
| 634 | self.frames += 1 | |
| 635 | # Keep animating: without this the viewport only refines when nudged. | |
| 636 | for area in context.screen.areas: | |
| 637 | if area.type == 'VIEW_3D': | |
| 638 | area.tag_redraw() | |
| 639 | ||
| 640 | 653 | def _build_splats(self): |
| 641 | 654 | """ |
| 642 | 655 | Emits the points once and uploads them as a splat cloud. |
| … | … | @@ -660,17 +673,31 @@ class Session: |
| 660 | 673 | positions, colors, stats['radius'] * self.splat_radius_scale) |
| 661 | 674 | self.splat_stats = stats |
| 662 | 675 | |
| 663 | def _draw_splats(self, space): | |
| 676 | def _draw_splats(self, context, space): | |
| 664 | 677 | """ |
| 665 | The splat pass: emitted points as camera-facing gaussians, added together. | |
| 666 | ||
| 667 | Blender's own view and window matrices are used whole, for the same reason | |
| 668 | the histogram path hands the kernel `blender_view_proj` rather than the | |
| 669 | engine's construction — the engine's matrix is mirrored in x and its z does | |
| 670 | not correspond to what the depth buffer holds. | |
| 678 | The splat pass: emitted points accumulated as gaussians, then TONEMAPPED. | |
| 679 | ||
| 680 | The important word is tonemapped. An earlier version drew the gaussians | |
| 681 | straight to the viewport and it was wrong in a way no setting could fix — | |
| 682 | a flame's appearance comes from a curve applied to ACCUMULATED density, | |
| 683 | not from what one sample looks like, so raw emissive quads render as fuzzy | |
| 684 | beads and Brightness / shading mode / opacity gain do nothing at all | |
| 685 | because the shader that reads them never ran. | |
| 686 | ||
| 687 | So this fills the SAME `accum` buffer the histogram path fills and then | |
| 688 | calls the same `_tonemap_blit`. Splats become a different way of | |
| 689 | accumulating density rather than a different renderer, every existing | |
| 690 | control keeps working, and occlusion comes back because the fragment | |
| 691 | shader writes `depth_img` in the kernel's encoding. | |
| 671 | 692 | """ |
| 672 | 693 | import ifs_splat |
| 673 | 694 | |
| 695 | region = context.region | |
| 696 | # Full resolution: the progressive divisor exists to keep an ACCUMULATING | |
| 697 | # render interactive, and a splat pass redraws the whole cloud each frame | |
| 698 | # regardless. | |
| 699 | self._resize(max(region.width, 16), max(region.height, 16)) | |
| 700 | ||
| 674 | 701 | if self.splat_cloud is None: |
| 675 | 702 | try: |
| 676 | 703 | self._build_splats() |
| … | … | @@ -682,9 +709,20 @@ class Session: |
| 682 | 709 | self.splats_enabled = False |
| 683 | 710 | return |
| 684 | 711 | |
| 685 | ifs_splat.draw(self.splat_cloud, self.splat_shader, | |
| 686 | space.region_3d.view_matrix, | |
| 687 | space.region_3d.window_matrix) | |
| 712 | # Both buffers are rebuilt from scratch every frame. Unlike the histogram | |
| 713 | # there is nothing to accumulate ACROSS frames — the cloud is a fixed set | |
| 714 | # of samples — so stale contents would just double-expose. | |
| 715 | self.accum.clear(format='FLOAT', value=(0.0, 0.0, 0.0, 0.0)) | |
| 716 | self.depthbuf.clear(format='UINT', value=(ifs_layout.DEPTH_CLEAR, 0, 0, 0)) | |
| 717 | ||
| 718 | framebuffer = gpu.types.GPUFrameBuffer(color_slots=(self.accum,)) | |
| 719 | with framebuffer.bind(): | |
| 720 | ifs_splat.draw(self.splat_cloud, self.splat_shader, | |
| 721 | space.region_3d.view_matrix, | |
| 722 | space.region_3d.window_matrix, | |
| 723 | self.depthbuf) | |
| 724 | ||
| 725 | self._tonemap_blit() | |
| 688 | 726 | self.frames += 1 |
| 689 | 727 | |
| 690 | 728 | @property |
| … | … | @@ -74,11 +74,14 @@ def _render(positions, colors, radius, size=SIZE, additive=True): |
| 74 | 74 | view, proj = _matrices() |
| 75 | 75 | |
| 76 | 76 | colour = gpu.types.GPUTexture((size, size), format='RGBA32F') |
| 77 | depth = gpu.types.GPUTexture((size, size), format='DEPTH_COMPONENT32F') | |
| 78 | fb = gpu.types.GPUFrameBuffer(depth_slot=depth, color_slots=(colour,)) | |
| 77 | # The R32UI nearest-depth buffer the pass writes, in the kernel's encoding. | |
| 78 | # Cleared to DEPTH_CLEAR (1.0f) exactly as the viewport does. | |
| 79 | depth_img = gpu.types.GPUTexture((size, size), format='R32UI') | |
| 80 | depth_img.clear(format='UINT', value=(0x3F800000, 0, 0, 0)) | |
| 81 | fb = gpu.types.GPUFrameBuffer(color_slots=(colour,)) | |
| 79 | 82 | with fb.bind(): |
| 80 | fb.clear(color=(0.0, 0.0, 0.0, 0.0), depth=1.0) | |
| 81 | ifs_splat.draw(cloud, shader, view, proj, additive=additive) | |
| 83 | fb.clear(color=(0.0, 0.0, 0.0, 0.0)) | |
| 84 | ifs_splat.draw(cloud, shader, view, proj, depth_img, additive=additive) | |
| 82 | 85 | raw = fb.read_color(0, 0, size, size, 3, 0, 'FLOAT').to_list() |
| 83 | 86 | return np.array(raw, dtype=np.float32).reshape(size, size, 3) |
| 84 | 87 | |
| … | … | @@ -221,6 +224,124 @@ class SplatShading(unittest.TestCase): |
| 221 | 224 | 'it is not shrinking with depth') |
| 222 | 225 | |
| 223 | 226 | |
| 227 | class SplatThroughTonemap(unittest.TestCase): | |
| 228 | """ | |
| 229 | The splat pass feeds the SAME accumulate -> tonemap back half as the histogram. | |
| 230 | ||
| 231 | This is the fix for a real complaint: with splats drawn straight to the | |
| 232 | viewport, Brightness, the shading modes and opacity gain did nothing at all, | |
| 233 | because the shader that reads them never ran. The fractal's look is a curve | |
| 234 | applied to ACCUMULATED density — per-sample emission is not the model — so | |
| 235 | raw quads rendered as fuzzy beads no setting could correct. | |
| 236 | ||
| 237 | Asserted differentially, because "it produced an image" would pass against a | |
| 238 | pass that still ignored every control. | |
| 239 | """ | |
| 240 | ||
| 241 | @classmethod | |
| 242 | def setUpClass(cls): | |
| 243 | try: | |
| 244 | import gpu | |
| 245 | import numpy # noqa: F401 | |
| 246 | except ImportError as ex: | |
| 247 | raise unittest.SkipTest(f"needs Blender's gpu module ({ex})") | |
| 248 | import ifs_splat # noqa: F401 — ours, deliberately unguarded | |
| 249 | import ifs_viewport # noqa: F401 | |
| 250 | gpu.init() | |
| 251 | ||
| 252 | def _accumulate_and_tonemap(self, positions, colors, radius, brightness, | |
| 253 | size=SIZE): | |
| 254 | import gpu | |
| 255 | import numpy as np | |
| 256 | import ifs_splat | |
| 257 | import ifs_viewport | |
| 258 | ||
| 259 | accum = gpu.types.GPUTexture((size, size), format='RGBA32F') | |
| 260 | accum.clear(format='FLOAT', value=(0.0, 0.0, 0.0, 0.0)) | |
| 261 | depth_img = gpu.types.GPUTexture((size, size), format='R32UI') | |
| 262 | depth_img.clear(format='UINT', value=(0x3F800000, 0, 0, 0)) | |
| 263 | ||
| 264 | shader = ifs_splat.build_shader() | |
| 265 | cloud = ifs_splat.upload(np.asarray(positions, dtype=np.float32), | |
| 266 | np.asarray(colors, dtype=np.float32), radius) | |
| 267 | view, proj = _matrices() | |
| 268 | ||
| 269 | fb = gpu.types.GPUFrameBuffer(color_slots=(accum,)) | |
| 270 | with fb.bind(): | |
| 271 | fb.clear(color=(0.0, 0.0, 0.0, 0.0)) | |
| 272 | ifs_splat.draw(cloud, shader, view, proj, depth_img) | |
| 273 | ||
| 274 | # The viewport's own auto-exposure and tonemap, unmodified. | |
| 275 | maxbuf = gpu.types.GPUTexture((1, 1), format='R32UI') | |
| 276 | maxbuf.clear(format='UINT', value=(0, 0, 0, 0)) | |
| 277 | maxpass = ifs_viewport.build_maxpass() | |
| 278 | maxpass.bind() | |
| 279 | maxpass.image('accum_img', accum) | |
| 280 | maxpass.image('max_img', maxbuf) | |
| 281 | maxpass.uniform_int('out_size', (size, size)) | |
| 282 | gpu.compute.dispatch(maxpass, (size + 7) // 8, (size + 7) // 8, 1) | |
| 283 | ||
| 284 | tonemap = ifs_viewport.build_tonemap() | |
| 285 | from gpu_extras.batch import batch_for_shader | |
| 286 | batch = batch_for_shader(tonemap, 'TRI_FAN', | |
| 287 | {"pos": ((-1, -1), (1, -1), (1, 1), (-1, 1))}) | |
| 288 | out = gpu.types.GPUTexture((size, size), format='RGBA32F') | |
| 289 | out_depth = gpu.types.GPUTexture((size, size), format='DEPTH_COMPONENT32F') | |
| 290 | ofb = gpu.types.GPUFrameBuffer(depth_slot=out_depth, color_slots=(out,)) | |
| 291 | with ofb.bind(): | |
| 292 | ofb.clear(color=(0.0, 0.0, 0.0, 0.0), depth=1.0) | |
| 293 | tonemap.bind() | |
| 294 | tonemap.uniform_sampler('accum_tex', accum) | |
| 295 | tonemap.uniform_sampler('max_tex', maxbuf) | |
| 296 | tonemap.uniform_sampler('depth_tex', depth_img) | |
| 297 | tonemap.uniform_int('out_size', (size, size)) | |
| 298 | tonemap.uniform_float('brightness', brightness) | |
| 299 | tonemap.uniform_int('shading_mode', (0,)) | |
| 300 | tonemap.uniform_float('opacity_gain', 4.0) | |
| 301 | tonemap.uniform_float('relief', 12.0) | |
| 302 | gpu.state.blend_set('ADDITIVE_PREMULT') | |
| 303 | batch.draw(tonemap) | |
| 304 | gpu.state.blend_set('NONE') | |
| 305 | raw = ofb.read_color(0, 0, size, size, 3, 0, 'FLOAT').to_list() | |
| 306 | return np.array(raw, dtype=np.float32).reshape(size, size, 3) | |
| 307 | ||
| 308 | def test_the_tonemapped_result_is_not_empty(self): | |
| 309 | """Baseline — the two passes are actually connected.""" | |
| 310 | img = self._accumulate_and_tonemap( | |
| 311 | [[0.0, 0.0, 0.0]], [[1.0, 1.0, 1.0, 1.0]], 0.5, brightness=8.0) | |
| 312 | self.assertGreater(float(img.max()), 0.01, | |
| 313 | 'splats accumulated but the tonemap produced nothing') | |
| 314 | ||
| 315 | def test_brightness_reaches_the_image(self): | |
| 316 | """ | |
| 317 | The complaint, made into a test. Before the rewire this control was inert | |
| 318 | while splats were on, because the tonemap shader never ran. | |
| 319 | """ | |
| 320 | dim = self._accumulate_and_tonemap( | |
| 321 | [[0.0, 0.0, 0.0]], [[1.0, 1.0, 1.0, 1.0]], 0.5, brightness=2.0) | |
| 322 | bright = self._accumulate_and_tonemap( | |
| 323 | [[0.0, 0.0, 0.0]], [[1.0, 1.0, 1.0, 1.0]], 0.5, brightness=32.0) | |
| 324 | print(f"[tonemap] brightness 2 -> {dim.mean():.5f}, 32 -> {bright.mean():.5f}") | |
| 325 | self.assertGreater(float(bright.mean()), float(dim.mean()) * 1.05, | |
| 326 | 'Brightness does not change the splat image — the ' | |
| 327 | 'tonemap is not in the path') | |
| 328 | ||
| 329 | def test_overlapping_splats_accumulate_density(self): | |
| 330 | """ | |
| 331 | Density, not per-sample emission, is what the curve is applied to. Stacked | |
| 332 | splats must read brighter after the tonemap, or we are still drawing | |
| 333 | independent beads. | |
| 334 | """ | |
| 335 | one = self._accumulate_and_tonemap( | |
| 336 | [[0.0, 0.0, 0.0]], [[1.0, 1.0, 1.0, 0.5]], 0.5, brightness=8.0) | |
| 337 | many = self._accumulate_and_tonemap( | |
| 338 | [[0.0, 0.0, 0.0]] * 8, [[1.0, 1.0, 1.0, 0.5]] * 8, 0.5, brightness=8.0) | |
| 339 | print(f"[tonemap] 1 splat {one.max():.4f} -> 8 splats {many.max():.4f}") | |
| 340 | self.assertGreater(float(many.max()), float(one.max()) * 1.05, | |
| 341 | 'eight coincident splats are no brighter than one — ' | |
| 342 | 'density is not accumulating') | |
| 343 | ||
| 344 | ||
| 224 | 345 | if __name__ == "__main__": |
| 225 | 346 | result = unittest.main(argv=sys.argv[:1], exit=False, verbosity=2).result |
| 226 | 347 | sys.exit(0 if result.wasSuccessful() else 1) |