From 44018894da5e367ec08da94d2dc09ee86e09e135 Mon Sep 17 00:00:00 2001 From: Kevin Yamauchi Date: Mon, 5 Oct 2026 19:24:28 +0200 Subject: [PATCH 01/24] fix camera angle bug --- src/cellier/render/_frustum.py | 13 ++ .../render/shaders/_label_multiscale.py | 6 +- src/cellier/render/shaders/_label_volume.py | 6 +- .../shaders/_multiscale_volume_brick.py | 6 +- .../render/shaders/wgsl/box_winding.wgsl | 19 ++ .../render/shaders/wgsl/label_volume.wgsl | 15 +- .../shaders/wgsl/label_volume_brick.wgsl | 12 +- .../shaders/wgsl/multiscale_volume_brick.wgsl | 15 +- tests/render/test_frustum.py | 62 +++++ .../test_volume_proxy_single_shading.py | 217 ++++++++++++++++++ tests/render/test_volume_ray_direction.py | 130 +++++++++++ 11 files changed, 478 insertions(+), 23 deletions(-) create mode 100644 src/cellier/render/shaders/wgsl/box_winding.wgsl create mode 100644 tests/render/test_volume_proxy_single_shading.py create mode 100644 tests/render/test_volume_ray_direction.py diff --git a/src/cellier/render/_frustum.py b/src/cellier/render/_frustum.py index 76be0379..d0c58eb2 100644 --- a/src/cellier/render/_frustum.py +++ b/src/cellier/render/_frustum.py @@ -69,6 +69,15 @@ def frustum_planes_from_corners(corners: np.ndarray) -> np.ndarray: A point ``p`` is *inside* the frustum when ``dot(plane[:3], p) + plane[3] >= 0`` for all 6 planes. Normals all point *inward*. + + Notes + ----- + The corner winding gives inward normals only for a right-handed set + of corners. Callers map the corners from world to data space first, + and a mirroring transform (negative determinant) reverses the winding, + which would turn every normal outward and reject every brick. Each + plane is therefore oriented toward the centroid of the corners, which + is inside the frustum whatever the handedness. """ n = corners[0] # near: lb, rb, rt, lt f = corners[1] # far: lb, rb, rt, lt @@ -80,6 +89,10 @@ def frustum_planes_from_corners(corners: np.ndarray) -> np.ndarray: planes[3] = _compute_plane_parameters(n[1], n[2], f[2]) # right planes[4] = _compute_plane_parameters(n[2], n[3], f[3]) # top planes[5] = _compute_plane_parameters(n[0], n[1], f[1]) # bottom + + centroid = np.asarray(corners, dtype=np.float64).reshape(-1, 3).mean(axis=0) + outward = planes[:, :3] @ centroid + planes[:, 3] < 0.0 + planes[outward] *= -1.0 return planes diff --git a/src/cellier/render/shaders/_label_multiscale.py b/src/cellier/render/shaders/_label_multiscale.py index 094da78e..48ea1da3 100644 --- a/src/cellier/render/shaders/_label_multiscale.py +++ b/src/cellier/render/shaders/_label_multiscale.py @@ -277,7 +277,11 @@ def get_bindings(self, wobject, shared, scene): def get_pipeline_info(self, wobject, shared): return { "primitive_topology": wgpu.PrimitiveTopology.triangle_list, - "cull_mode": wgpu.CullMode.none, + # One face per pixel: both faces would march the same ray, and a + # material that does not write depth would blend it twice. The + # back faces are the ones kept, as in pygfx's volume shader, so + # the box still draws with the camera inside it. + "cull_mode": wgpu.CullMode.front, } def get_render_info(self, wobject, shared): diff --git a/src/cellier/render/shaders/_label_volume.py b/src/cellier/render/shaders/_label_volume.py index b28e8030..71a90ccc 100644 --- a/src/cellier/render/shaders/_label_volume.py +++ b/src/cellier/render/shaders/_label_volume.py @@ -166,7 +166,11 @@ def get_bindings(self, wobject, shared, scene): def get_pipeline_info(self, wobject, shared): return { "primitive_topology": wgpu.PrimitiveTopology.triangle_list, - "cull_mode": wgpu.CullMode.none, + # One face per pixel: both faces would march the same ray, and a + # material that does not write depth would blend it twice. The + # back faces are the ones kept, as in pygfx's volume shader, so + # the box still draws with the camera inside it. + "cull_mode": wgpu.CullMode.front, } def get_render_info(self, wobject, shared): diff --git a/src/cellier/render/shaders/_multiscale_volume_brick.py b/src/cellier/render/shaders/_multiscale_volume_brick.py index c77a63ec..649c0b64 100644 --- a/src/cellier/render/shaders/_multiscale_volume_brick.py +++ b/src/cellier/render/shaders/_multiscale_volume_brick.py @@ -591,7 +591,11 @@ def get_bindings(self, wobject, shared, scene): def get_pipeline_info(self, wobject, shared): return { "primitive_topology": wgpu.PrimitiveTopology.triangle_list, - "cull_mode": wgpu.CullMode.none, + # One face per pixel: both faces would march the same ray, and a + # material that does not write depth would blend it twice. The + # back faces are the ones kept, as in pygfx's volume shader, so + # the box still draws with the camera inside it. + "cull_mode": wgpu.CullMode.front, } def get_render_info(self, wobject, shared): diff --git a/src/cellier/render/shaders/wgsl/box_winding.wgsl b/src/cellier/render/shaders/wgsl/box_winding.wgsl new file mode 100644 index 00000000..9c47e868 --- /dev/null +++ b/src/cellier/render/shaders/wgsl/box_winding.wgsl @@ -0,0 +1,19 @@ +// Winding of a ray-marched volume's proxy box. +// +// The box's pipeline culls front faces: every fragment marches the whole +// ray from the near plane, so one face per pixel is all that may draw, and +// the back faces are the ones still on screen when the camera is inside +// the box. Which faces are "front" is decided by screen-space winding, +// and a mirroring world or camera transform reverses it, which would cull +// the back faces instead (and draw nothing from inside the box). +// +// Maps a triangle-list vertex index to the index to read, reversing each +// triangle's order under a mirroring transform so that the outward faces +// stay counter-clockwise. Same rule as pygfx's mesh shader. Needs +// ``u_wobject`` and ``u_stdinfo``. +fn box_winding_index(vertex_index: u32) -> u32 { + let mirrored = determinant(u_wobject.world_transform) + * determinant(u_stdinfo.cam_transform) < 0.0; + let corner = vertex_index % 3u; + return select(vertex_index, vertex_index + 2u - 2u * corner, mirrored); +} diff --git a/src/cellier/render/shaders/wgsl/label_volume.wgsl b/src/cellier/render/shaders/wgsl/label_volume.wgsl index 7ab08889..f228ffca 100644 --- a/src/cellier/render/shaders/wgsl/label_volume.wgsl +++ b/src/cellier/render/shaders/wgsl/label_volume.wgsl @@ -113,6 +113,8 @@ fn intersect_aabb( } // ── Vertex shader ───────────────────────────────────────────────────────── +{$ include 'cellier.box_winding.wgsl' $} + struct VertexInput { @builtin(vertex_index) vertex_index: u32 }; @vertex @@ -133,7 +135,8 @@ fn vs_main(in: VertexInput) -> Varyings { vec3(lo.x, hi.y, hi.z), vec3(hi.x, hi.y, hi.z), ); - // 12 triangles (36 indices), cull_mode = none. + // 12 triangles (36 indices), outward faces counter-clockwise. The + // pipeline culls front faces (cellier.box_winding.wgsl). let face_idx = array( 0u,2u,1u, 1u,2u,3u, 4u,5u,6u, 5u,7u,6u, @@ -143,17 +146,15 @@ fn vs_main(in: VertexInput) -> Varyings { 1u,3u,5u, 3u,7u,5u, ); - let local_pos = corners[face_idx[in.vertex_index]]; + let local_pos = corners[face_idx[box_winding_index(in.vertex_index)]]; let world_pos = u_wobject.world_transform * vec4(local_pos, 1.0); let ndc_pos = u_stdinfo.projection_transform * u_stdinfo.cam_transform * world_pos; // Pass near/far clip-plane positions in local space for ray reconstruction. let ndc_to_local = u_wobject.world_transform_inv * u_stdinfo.cam_transform_inv * u_stdinfo.projection_transform_inv; - let cam_sign = sign( - u_stdinfo.cam_transform[0][0] * - u_stdinfo.cam_transform[1][1] * - u_stdinfo.cam_transform[2][2] - ); + // The determinant, not the product of the diagonal: that product is + // negative or zero for some pure rotations, which reverses the ray. + let cam_sign = sign(determinant(u_stdinfo.cam_transform)); var varyings: Varyings; varyings.position = vec4(ndc_pos); diff --git a/src/cellier/render/shaders/wgsl/label_volume_brick.wgsl b/src/cellier/render/shaders/wgsl/label_volume_brick.wgsl index df5fc61b..f43011c4 100644 --- a/src/cellier/render/shaders/wgsl/label_volume_brick.wgsl +++ b/src/cellier/render/shaders/wgsl/label_volume_brick.wgsl @@ -473,6 +473,8 @@ $$ endif // ── Vertex shader ───────────────────────────────────────────────────────── +{$ include 'cellier.box_winding.wgsl' $} + struct VertexInput { @builtin(vertex_index) vertex_index: u32, }; @@ -504,7 +506,7 @@ fn vs_main(in: VertexInput) -> Varyings { 1u,3u,5u, 3u,7u,5u, ); - let norm_pos = corners[indices[in.vertex_index]]; + let norm_pos = corners[indices[box_winding_index(in.vertex_index)]]; let world_pos = u_wobject.world_transform * vec4(norm_pos, 1.0); let ndc_pos = u_stdinfo.projection_transform * u_stdinfo.cam_transform @@ -514,11 +516,9 @@ fn vs_main(in: VertexInput) -> Varyings { * u_stdinfo.cam_transform_inv * u_stdinfo.projection_transform_inv; - let cam_sign = sign( - u_stdinfo.cam_transform[0][0] * - u_stdinfo.cam_transform[1][1] * - u_stdinfo.cam_transform[2][2] - ); + // The determinant, not the product of the diagonal: that product is + // negative or zero for some pure rotations, which reverses the ray. + let cam_sign = sign(determinant(u_stdinfo.cam_transform)); var varyings: Varyings; varyings.position = vec4(ndc_pos); diff --git a/src/cellier/render/shaders/wgsl/multiscale_volume_brick.wgsl b/src/cellier/render/shaders/wgsl/multiscale_volume_brick.wgsl index 9b83eada..2bcdae7b 100644 --- a/src/cellier/render/shaders/wgsl/multiscale_volume_brick.wgsl +++ b/src/cellier/render/shaders/wgsl/multiscale_volume_brick.wgsl @@ -456,6 +456,8 @@ $$ endif // ── Vertex shader ───────────────────────────────────────────────────────── +{$ include 'cellier.box_winding.wgsl' $} + struct VertexInput { @builtin(vertex_index) vertex_index: u32, }; @@ -479,7 +481,8 @@ fn vs_main(in: VertexInput) -> Varyings { vec3( h.x, h.y, h.z), ); - // 12 triangles (36 indices), cull_mode = none. + // 12 triangles (36 indices), outward faces counter-clockwise. The + // pipeline culls front faces (cellier.box_winding.wgsl). let indices = array( 0u,2u,1u, 1u,2u,3u, // -Z face 4u,5u,6u, 5u,7u,6u, // +Z face @@ -489,7 +492,7 @@ fn vs_main(in: VertexInput) -> Varyings { 1u,3u,5u, 3u,7u,5u, // +X face ); - let norm_pos = corners[indices[in.vertex_index]]; + let norm_pos = corners[indices[box_winding_index(in.vertex_index)]]; let world_pos = u_wobject.world_transform * vec4(norm_pos, 1.0); let ndc_pos = u_stdinfo.projection_transform * u_stdinfo.cam_transform @@ -499,11 +502,9 @@ fn vs_main(in: VertexInput) -> Varyings { * u_stdinfo.cam_transform_inv * u_stdinfo.projection_transform_inv; - let cam_sign = sign( - u_stdinfo.cam_transform[0][0] * - u_stdinfo.cam_transform[1][1] * - u_stdinfo.cam_transform[2][2] - ); + // The determinant, not the product of the diagonal: that product is + // negative or zero for some pure rotations, which reverses the ray. + let cam_sign = sign(determinant(u_stdinfo.cam_transform)); var varyings: Varyings; varyings.position = vec4(ndc_pos); diff --git a/tests/render/test_frustum.py b/tests/render/test_frustum.py index 5614efa9..f6cb70f4 100644 --- a/tests/render/test_frustum.py +++ b/tests/render/test_frustum.py @@ -134,6 +134,68 @@ def test_frustum_planes_reject_exterior_points(): assert not _inside(planes, outside) +def _mirrored_x_corners(lo: float, hi: float, offset: float) -> np.ndarray: + """Corners of the box frustum ``[lo, hi]^3`` after ``x -> offset - x``. + + This is what a data->world transform with a negative determinant does + to the frustum corners when they are mapped into data space: the same + eight points, with the winding of every face reversed. + """ + near = np.array([[lo, lo, hi], [hi, lo, hi], [hi, hi, hi], [lo, hi, hi]]) + far = np.array([[lo, lo, lo], [hi, lo, lo], [hi, hi, lo], [lo, hi, lo]]) + corners = np.stack([near, far]).astype(np.float64) + corners[..., 0] = offset - corners[..., 0] + return corners + + +def test_frustum_planes_inward_for_mirrored_corners(): + # x -> 15 - x maps the box [-4, 20]^3 to x in [-5, 19]. + planes = frustum_planes_from_corners(_mirrored_x_corners(-4.0, 20.0, 15.0)) + assert _inside(planes, np.array([7.0, 8.0, 8.0])) + for outside in [ + np.array([30.0, 8.0, 8.0]), + np.array([-30.0, 8.0, 8.0]), + np.array([7.0, 30.0, 8.0]), + np.array([7.0, 8.0, -30.0]), + ]: + assert not _inside(planes, outside) + + +#: Stored result: the level-1 bricks of a 2x2x2 grid of 8-voxel bricks, plus +#: one brick far outside, that survive a frustum enclosing the grid. Rows +#: are ``[level, gz, gy, gx]``. +EXPECTED_MIRRORED_SURVIVORS = [ + [1, 0, 0, 0], + [1, 0, 0, 1], + [1, 0, 1, 0], + [1, 0, 1, 1], + [1, 1, 0, 0], + [1, 1, 0, 1], + [1, 1, 1, 0], + [1, 1, 1, 1], +] + + +@pytest.mark.parametrize("mirrored", [False, True]) +def test_bricks_in_frustum_arr_mirrored_corners_select_same_bricks(mirrored): + """A mirrored frustum keeps the bricks the unmirrored one keeps. + + Regression: with the corners mirrored, every plane normal pointed + outward and no brick survived, so a 3D multiscale visual with a + negative-determinant transform loaded nothing. + """ + if mirrored: + corners = _mirrored_x_corners(-4.0, 20.0, 15.0) + else: + corners = _mirrored_x_corners(-4.0, 20.0, 0.0) + corners[..., 0] *= -1.0 + planes = frustum_planes_from_corners(corners) + grid = [[1, gz, gy, gx] for gz in (0, 1) for gy in (0, 1) for gx in (0, 1)] + arr = np.array([*grid, [1, 9, 9, 9]], dtype=np.int32) + visible, _ = bricks_in_frustum_arr(arr, block_size=8, frustum_planes=planes) + assert visible.tolist() == EXPECTED_MIRRORED_SURVIVORS + + # --------------------------------------------------------------------------- # compute_brick_aabb_corners # --------------------------------------------------------------------------- diff --git a/tests/render/test_volume_proxy_single_shading.py b/tests/render/test_volume_proxy_single_shading.py new file mode 100644 index 00000000..16b8199d --- /dev/null +++ b/tests/render/test_volume_proxy_single_shading.py @@ -0,0 +1,217 @@ +"""A ray-marched volume shades each pixel once. + +The proxy box of a ray-marched volume covers most pixels twice, with a front +and a back face, and every fragment marches the whole ray from the near +plane. Drawing both faces is invisible while the material writes depth (the +second fragment mostly fails the depth test) and doubles the result when it +does not: a composite of overlapping channels adds each channel twice, and +translucent labels blend twice. The pipelines cull front faces, and the +vertex shader keeps the winding under a mirroring transform, so the back +faces are the ones kept and the box still draws with the camera inside it. + +Each case draws a uniform volume with a material that does not write depth +and compares the centre pixel with a stored value. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from cellier.data.image._zarr_multiscale_store import MultiscaleZarrDataStore +from cellier.data.label._label_memory_store import LabelMemoryStore +from cellier.scene import spatial_axes +from cellier.visuals import ( + InMemoryLabelsAppearance, + MultiscaleImageAppearance, + MultiscaleImageChannelAppearance, + ProgressiveLoadingConfig, +) +from cellier.visuals._image import MultiscaleImageRenderConfig +from cellier.visuals._labels import ( + MultiscaleLabelRenderConfig, + MultiscaleLabelsAppearance, +) +from tests._gpu_budget import SMALL_BUDGETS +from tests._v2 import bound +from tests.render.conftest import _write_multiscale_zarr + +_CZYX = [("c", "channel"), *spatial_axes("z", "y", "x")] +#: Every voxel of both channels. Each channel maps it to 0.25 of its colour; +#: two shaded fragments per pixel would add up to 0.5. +VALUE = 0.25 +#: Labels opacity. Blended once it is the frame's alpha; twice gives 0.75. +OPACITY = 0.5 + +#: Stored results: the centre pixel of the frame, uint8, drawn over a hidden +#: background. Image: the red and the green channel, each at 0.25 (both +#: faces drawn gave 90). Labels: the frame's alpha (both faces gave 143). +EXPECTED_IMAGE_RGB = (64, 64, 0) +EXPECTED_LABELS_ALPHA = 64 +TOLERANCE = 3 + +VIEWS = ["outside", "inside", "mirrored_outside", "mirrored_inside"] + + +def _image(controller, tmp_path, mirrored): + _write_multiscale_zarr( + tmp_path, + levels=[("s0", (2, 16, 16, 16)), ("s1", (2, 8, 8, 8))], + fill=lambda arr: arr.fill(VALUE), + ) + store = MultiscaleZarrDataStore.from_scale_and_translation( + zarr_path=str(tmp_path), + scale_names=["s0", "s1"], + level_scales=[(1.0, 1.0, 1.0, 1.0), (1.0, 2.0, 2.0, 2.0)], + level_translations=[(0.0, 0.0, 0.0, 0.0), (0.0, 0.5, 0.5, 0.5)], + name="czyx", + ) + scene = controller.add_scene(coordinate_system=_CZYX, dim="3d", name="scene") + controller.add_image_multiscale( + store, + scene.id, + appearance=MultiscaleImageAppearance(force_level=1), + render_config=MultiscaleImageRenderConfig( + **SMALL_BUDGETS, + block_size=8, + loading=ProgressiveLoadingConfig(backstop=False), + ), + transform=_mirror(controller, scene, store, 4) if mirrored else None, + channel_axis=0, + composite=True, + channels={ + 0: MultiscaleImageChannelAppearance( + color_map="red", clim=(0.0, 1.0), render_mode="mip" + ), + 1: MultiscaleImageChannelAppearance( + color_map="green", clim=(0.0, 1.0), render_mode="mip" + ), + }, + ) + return scene + + +def _labels_appearance(cls, **fields): + return cls( + **fields, + render_mode="flat_categorical", + opacity=OPACITY, + depth_write=False, + transparency_mode="blend", + ) + + +def _labels_multiscale(controller, tmp_path, mirrored): + _write_multiscale_zarr( + tmp_path, + levels=[("s0", (16, 16, 16)), ("s1", (8, 8, 8))], + fill=lambda arr: arr.fill(3), + dtype="int32", + ) + store = MultiscaleZarrDataStore.from_scale_and_translation( + zarr_path=str(tmp_path), + scale_names=["s0", "s1"], + level_scales=[(1.0, 1.0, 1.0), (2.0, 2.0, 2.0)], + level_translations=[(0.0, 0.0, 0.0), (0.5, 0.5, 0.5)], + name="labels", + ) + scene = controller.add_scene(dim="3d", name="scene") + appearance = _labels_appearance(MultiscaleLabelsAppearance, force_level=1) + controller.add_labels_multiscale( + data=store, + scene_id=scene.id, + appearance=appearance, + render_config=MultiscaleLabelRenderConfig( + **SMALL_BUDGETS, + block_size=8, + loading=ProgressiveLoadingConfig(backstop=False), + ), + transform=_mirror(controller, scene, store, 3) if mirrored else None, + ) + return scene + + +def _labels_memory(controller, tmp_path, mirrored): + store = LabelMemoryStore(data=np.full((16, 16, 16), 3, dtype=np.int32)) + scene = controller.add_scene(dim="3d", name="scene") + controller.add_labels( + data=store, + scene_id=scene.id, + appearance=_labels_appearance(InMemoryLabelsAppearance), + transform=_mirror(controller, scene, store, 3) if mirrored else None, + ) + return scene + + +def _mirror(controller, scene, store, ndim): + """A data -> world transform that mirrors the last axis in place.""" + scale = [1.0] * (ndim - 1) + [-1.0] + translation = [0.0] * (ndim - 1) + [15.0] + return bound(controller, scene.id, store, scale, translation) + + +BUILDERS = { + "image": _image, + "labels_multiscale": _labels_multiscale, + "labels_memory": _labels_memory, +} + + +@pytest.mark.parametrize("view", VIEWS) +@pytest.mark.parametrize("kind", list(BUILDERS)) +async def test_each_pixel_is_shaded_once( + controller, render_scene, offscreen_renderer, reslice, tmp_path, kind, view +): + scene = BUILDERS[kind](controller, tmp_path, view.startswith("mirrored")) + controller.add_canvas(scene_id=scene.id) + await reslice(controller, scene.id) + render_scene(controller, scene.id) # hides the background, fits the camera + canvas = controller._render_manager._canvases[ + controller.get_canvas_ids(scene.id)[0] + ] + gfx_scene = canvas._get_scene_fn(scene.id) + camera = canvas.camera + if view.endswith("inside"): + # At the centre of the 16-voxel box, looking off-axis at a wall. + camera.local.position = (7.5, 7.5, 7.5) + camera.look_at((20.0, 11.0, 10.0)) + camera.depth_range = (0.1, 100.0) + + frame = offscreen_renderer(gfx_scene, camera, (64, 64)) + centre = frame[32, 32].astype(int) + + if kind == "image": + assert centre[:3] == pytest.approx(EXPECTED_IMAGE_RGB, abs=TOLERANCE) + else: + assert centre[3] == pytest.approx(EXPECTED_LABELS_ALPHA, abs=TOLERANCE) + + +#: Stored result: bricks resident in the 3D block cache after one reslice +#: with frustum culling on. The 16-voxel volume is a 2x2x2 grid of 8-voxel +#: bricks at level 1; the image caches that grid once per channel. +EXPECTED_RESIDENT_BRICKS = {"image": 16, "labels_multiscale": 8} + + +@pytest.mark.parametrize("mirrored", [False, True]) +@pytest.mark.parametrize("kind", list(EXPECTED_RESIDENT_BRICKS)) +async def test_frustum_cull_selects_bricks_under_mirroring( + controller, reslice, tmp_path, kind, mirrored +): + """Frustum culling keeps the same bricks under a mirroring transform. + + Regression: the frustum corners are mapped into data space before the + planes are built, and a negative-determinant transform reversed their + winding, so every plane faced outward and no brick was selected. + """ + scene = BUILDERS[kind](controller, tmp_path, mirrored) + controller.add_canvas(scene_id=scene.id) + await reslice(controller, scene.id) + + (gfx_visual,) = controller._render_manager._scenes[scene.id]._visuals.values() + owners = gfx_visual.slots if kind == "image" else (gfx_visual,) + resident = sum( + len(owner._block_cache_3d.tile_manager.tilemap) + for owner in owners + if owner._block_cache_3d is not None + ) + assert resident == EXPECTED_RESIDENT_BRICKS[kind] diff --git a/tests/render/test_volume_ray_direction.py b/tests/render/test_volume_ray_direction.py new file mode 100644 index 00000000..56e976ec --- /dev/null +++ b/tests/render/test_volume_ray_direction.py @@ -0,0 +1,130 @@ +"""A ray-marched volume marches its rays away from the camera, however rolled. + +The brick and label volume shaders build each ray from a point on the near +plane and one on the far plane, and swap the two for a camera that mirrors +an axis. They used to detect that from the product of the camera matrix's +diagonal, which is also negative for some pure rotations (a camera looking +along z and rolled a quarter turn, about): the ray then ran from the far +plane towards the camera and the first hit was the far side of the data. +The orbit controller keeps the camera level and never gets there; a rotation +set in code does. + +The volume is two labels stacked along z, seen from the low-z side. The +centre pixel is compared with the stored colour of the near label. +""" + +from __future__ import annotations + +import numpy as np +import pylinalg as la +import pytest + +from cellier.data.image._zarr_multiscale_store import MultiscaleZarrDataStore +from cellier.data.label._label_memory_store import LabelMemoryStore +from cellier.visuals import InMemoryLabelsAppearance, ProgressiveLoadingConfig +from cellier.visuals._labels import ( + MultiscaleLabelRenderConfig, + MultiscaleLabelsAppearance, +) +from tests._gpu_budget import SMALL_BUDGETS +from tests.render.conftest import _write_multiscale_zarr + +NEAR_LABEL = 3 # low z +FAR_LABEL = 7 # high z +#: Stored results: the flat colour of each label, RGB uint8. +NEAR_COLOUR = (245, 31, 253) +FAR_COLOUR = (226, 64, 222) +TOLERANCE = 3 + +#: Camera rotations, as (axis, degrees). All look along +z, or within a few +#: degrees of it. The diagonal product is +1 for the first and negative or +#: zero (to rounding) for the others; the determinant is +1 for all. +ROTATIONS = { + "level": ((1.0, 0.0, 0.0), 180.0), + "rolled_170": ((1.0, 1.0, 0.0), 170.0), + "rolled_190": ((1.0, 1.0, 0.0), 190.0), + "rolled_180": ((1.0, 1.0, 0.0), 180.0), +} + + +def _fill(arr: np.ndarray) -> None: + arr[: arr.shape[0] // 2] = NEAR_LABEL + arr[arr.shape[0] // 2 :] = FAR_LABEL + + +def _labels_memory(controller, tmp_path): + labels = np.empty((16, 16, 16), dtype=np.int32) + _fill(labels) + scene = controller.add_scene(dim="3d", name="scene") + controller.add_labels( + data=LabelMemoryStore(data=labels), + scene_id=scene.id, + appearance=InMemoryLabelsAppearance(render_mode="flat_categorical"), + ) + return scene + + +def _labels_multiscale(controller, tmp_path): + _write_multiscale_zarr( + tmp_path, + levels=[("s0", (16, 16, 16)), ("s1", (8, 8, 8))], + fill=_fill, + dtype="int32", + ) + store = MultiscaleZarrDataStore.from_scale_and_translation( + zarr_path=str(tmp_path), + scale_names=["s0", "s1"], + level_scales=[(1.0, 1.0, 1.0), (2.0, 2.0, 2.0)], + level_translations=[(0.0, 0.0, 0.0), (0.5, 0.5, 0.5)], + name="labels", + ) + scene = controller.add_scene(dim="3d", name="scene") + controller.add_labels_multiscale( + data=store, + scene_id=scene.id, + appearance=MultiscaleLabelsAppearance( + render_mode="flat_categorical", force_level=1 + ), + render_config=MultiscaleLabelRenderConfig( + **SMALL_BUDGETS, + block_size=8, + loading=ProgressiveLoadingConfig(backstop=False), + ), + ) + return scene + + +BUILDERS = { + "labels_memory": _labels_memory, + "labels_multiscale": _labels_multiscale, +} + + +@pytest.mark.parametrize("rotation", list(ROTATIONS)) +@pytest.mark.parametrize("kind", list(BUILDERS)) +async def test_the_near_label_is_drawn_whatever_the_roll( + controller, render_scene, offscreen_renderer, reslice, tmp_path, kind, rotation +): + scene = BUILDERS[kind](controller, tmp_path) + controller.add_canvas(scene_id=scene.id) + await reslice(controller, scene.id) + render_scene(controller, scene.id) # hides the background + canvas = controller._render_manager._canvases[ + controller.get_canvas_ids(scene.id)[0] + ] + gfx_scene = canvas._get_scene_fn(scene.id) + camera = canvas.camera + axis, degrees = ROTATIONS[rotation] + quaternion = la.quat_from_axis_angle( + np.asarray(axis) / np.linalg.norm(axis), np.deg2rad(degrees) + ) + forward = la.vec_transform_quat((0.0, 0.0, -1.0), quaternion) + camera.local.rotation = quaternion + camera.local.position = np.full(3, 7.5) - 40.0 * forward + camera.depth_range = (1.0, 200.0) + + frame = offscreen_renderer(gfx_scene, camera, (64, 64)) + + centre = frame[32, 32, :3].astype(int) + assert centre == pytest.approx(NEAR_COLOUR, abs=TOLERANCE) + assert centre != pytest.approx(FAR_COLOUR, abs=TOLERANCE) From 107b9377c28767f741618b66e212257ced82f559 Mon Sep 17 00:00:00 2001 From: Kevin Yamauchi Date: Tue, 6 Oct 2026 11:20:12 +0200 Subject: [PATCH 02/24] add orthographic lod support --- src/cellier/render/_level_of_detail.py | 75 +++++++++++ src/cellier/render/_requests.py | 5 +- src/cellier/render/canvas_view.py | 31 ++++- src/cellier/render/visuals/_image.py | 22 +++ .../render/visuals/_label_multiscale.py | 22 +++ tests/_planning.py | 8 +- tests/render/test_level_of_detail.py | 52 ++++++++ tests/render/test_lod_orthographic_3d.py | 125 ++++++++++++++++++ 8 files changed, 331 insertions(+), 9 deletions(-) create mode 100644 tests/render/test_lod_orthographic_3d.py diff --git a/src/cellier/render/_level_of_detail.py b/src/cellier/render/_level_of_detail.py index 661eb914..53b45b52 100644 --- a/src/cellier/render/_level_of_detail.py +++ b/src/cellier/render/_level_of_detail.py @@ -28,6 +28,8 @@ from cellier.render._level_mapping import brick_centre_data, implied_power_of_two if TYPE_CHECKING: + from collections.abc import Callable + from cellier.render.lut_indirection import BlockLayout3D # Pre-computed (8, 3) offset table for AABB corner construction. @@ -238,6 +240,79 @@ def select_levels_from_cache( return np.concatenate(parts, axis=0) +def select_level_orthographic( + level_scale_factors: list[float], + voxels_per_pixel: float, + lod_bias: float = 1.0, +) -> int: + """Select the one LOD level an orthographic 3D view draws. + + An orthographic screen pixel covers the same world size everywhere, so + distance bands do not apply: the whole visual takes the level whose voxel + size best matches the pixel. The transition rule is ``select_lod_2d``'s: + switch levels at the geometric mean of consecutive scale factors. + + Parameters + ---------- + level_scale_factors : list[float] + Effective isotropic scale factor per level (geometric mean of the + per-axis scales). ``level_scale_factors[0]`` is the finest. + voxels_per_pixel : float + Level-0 voxels covered by one screen pixel. + lod_bias : float + Multiplicative bias. 1.0 = neutral (default); higher is coarser. + + Returns + ------- + level : int + 1-indexed level, as ``select_levels_arr_forced`` takes it. + """ + biased = voxels_per_pixel * max(lod_bias, 1e-6) + level = 1 + for k in range(len(level_scale_factors) - 1): + threshold = float(np.sqrt(level_scale_factors[k] * level_scale_factors[k + 1])) + if biased < threshold: + break + level = k + 2 + return level + + +def orthographic_voxels_per_pixel( + to_level0: Callable[[np.ndarray], np.ndarray], + camera_pos_world: np.ndarray, + view_height_world: float, + screen_height_px: float, +) -> float: + """Level-0 voxels covered by one screen pixel of an orthographic view. + + Parameters + ---------- + to_level0 : callable + Maps ``(N, 3)`` world points to level-0 voxel coordinates. + camera_pos_world : np.ndarray + Camera world-space position ``(x, y, z)``; the scale is measured + there. + view_height_world : float + Visible height in world units. + screen_height_px : float + Viewport height in pixels. + + Returns + ------- + voxels_per_pixel : float + The geometric mean over the three axes, so anisotropic voxels are + weighed as ``level_scale_factors`` weighs them. ``0.0`` when the + view or the map is degenerate. + """ + if view_height_world <= 0 or screen_height_px <= 0: + return 0.0 + origin = np.asarray(camera_pos_world, dtype=np.float64).reshape(1, 3) + points = np.concatenate([origin, origin + np.eye(3)]) + mapped = np.asarray(to_level0(points), dtype=np.float64) + voxels_per_world = abs(np.linalg.det(mapped[1:] - mapped[0])) ** (1.0 / 3.0) + return float(voxels_per_world * view_height_world / screen_height_px) + + def select_levels_arr_forced( base_layout: BlockLayout3D, force_level: int, diff --git a/src/cellier/render/_requests.py b/src/cellier/render/_requests.py index dd743fed..55d387e2 100644 --- a/src/cellier/render/_requests.py +++ b/src/cellier/render/_requests.py @@ -37,8 +37,9 @@ class ReslicingRequest(NamedTuple): screen_size_px : tuple[float, float] Logical ``(width, height)`` in pixels, baked in at snapshot time. world_extent : tuple[float, float] - Visible ``(width, height)`` in world units for orthographic cameras. - ``(0.0, 0.0)`` for perspective cameras. + Visible ``(width, height)`` in world units for orthographic cameras, + a 3D camera at a field of view of 0 included. ``(0.0, 0.0)`` for + perspective cameras. dims_state : DimsState Current dimension display state. selection : RegionSelection or None diff --git a/src/cellier/render/canvas_view.py b/src/cellier/render/canvas_view.py index 6317e435..7b589ec9 100644 --- a/src/cellier/render/canvas_view.py +++ b/src/cellier/render/canvas_view.py @@ -614,15 +614,36 @@ def _capture_perspective( screen_w: float, screen_h: float, ) -> ReslicingRequest: - """Build a ReslicingRequest for a perspective camera.""" - frustum = np.asarray(self._camera.frustum, dtype=np.float64) + """Build a ReslicingRequest for a perspective camera. + + At a field of view of 0 the camera is orthographic, and the request + carries the visible world extent that level selection needs. It is + worked out from the canvas size, as for the 2D camera, and not read + off the frustum: the frustum only takes the canvas's aspect once a + frame has been drawn. + """ + cam = self._camera + frustum = np.asarray(cam.frustum, dtype=np.float64) + fov = float(cam.fov) + if fov == 0 and screen_w > 0 and screen_h > 0: + canvas_aspect = screen_w / screen_h + zoom = cam.zoom if cam.zoom > 0 else 1.0 + if not cam.maintain_aspect: + world_width, world_height = cam.width, cam.height + elif canvas_aspect >= cam.width / cam.height: + world_width, world_height = cam.height * canvas_aspect, cam.height + else: + world_width, world_height = cam.width, cam.width / canvas_aspect + world_extent = (float(world_width / zoom), float(world_height / zoom)) + else: + world_extent = (0.0, 0.0) return ReslicingRequest( camera_type="perspective", - camera_pos=np.array(self._camera.world.position, dtype=np.float64), + camera_pos=np.array(cam.world.position, dtype=np.float64), frustum_corners=frustum.copy(), - fov_y_rad=float(np.radians(self._camera.fov)), + fov_y_rad=float(np.radians(fov)), screen_size_px=(float(screen_w), float(screen_h)), - world_extent=(0.0, 0.0), + world_extent=world_extent, dims_state=dims_state, selection=selection, request_id=uuid4(), diff --git a/src/cellier/render/visuals/_image.py b/src/cellier/render/visuals/_image.py index 00eab83e..9775e06b 100644 --- a/src/cellier/render/visuals/_image.py +++ b/src/cellier/render/visuals/_image.py @@ -25,6 +25,8 @@ from cellier.render._level_mapping import base_cell_range from cellier.render._level_of_detail import ( build_level_grids, + orthographic_voxels_per_pixel, + select_level_orthographic, select_levels_arr_forced, select_levels_from_cache, sort_arr_by_distance, @@ -2125,6 +2127,7 @@ def _plan_bricks( screen_height_px: float, lod_bias: float, force_level: int | None, + view_height_world: float = 0.0, ) -> np.ndarray: """Run LOD selection, distance sort, frustum cull, and budget truncation. @@ -2147,6 +2150,10 @@ def _plan_bricks( levels; values < 1 prefer finer. Clamped to a minimum of 1e-6. force_level : int or None Override level; ``None`` lets LOD selection choose. + view_height_world : float + Visible height in world units of an orthographic view + (``fov_y_rad`` of 0), which selects one level for the whole + visual. Unused with a perspective camera. Returns ------- @@ -2186,6 +2193,20 @@ def _plan_bricks( frustum_planes = None # 1. LOD selection + if force_level is None and fov_y_rad <= 0: + # Orthographic: a pixel covers the same world size everywhere, so + # the whole visual takes one level, by the 2D transition rule. + voxels_per_pixel = orthographic_voxels_per_pixel( + self._to_level0_displayed, + camera_pos_world, + view_height_world, + screen_height_px, + ) + if voxels_per_pixel > 0: + force_level = select_level_orthographic( + geo._level_scale_factors, voxels_per_pixel, lod_bias + ) + if force_level is not None: brick_arr = select_levels_arr_forced( geo.base_layout, force_level, geo._level_grids @@ -3459,6 +3480,7 @@ def plan( request.screen_size_px[1], config.lod_bias, config.force_level, + view_height_world=request.world_extent[1], ) backstop = planner._plan_backstop_3d( request.camera_pos, request.frustum_corners, loading diff --git a/src/cellier/render/visuals/_label_multiscale.py b/src/cellier/render/visuals/_label_multiscale.py index 684b5185..cce85770 100644 --- a/src/cellier/render/visuals/_label_multiscale.py +++ b/src/cellier/render/visuals/_label_multiscale.py @@ -17,6 +17,8 @@ from cellier.render._gpu_lifetime import destroy_textures from cellier.render._level_mapping import base_cell_range from cellier.render._level_of_detail import ( + orthographic_voxels_per_pixel, + select_level_orthographic, select_levels_arr_forced, select_levels_from_cache, sort_arr_by_distance, @@ -842,11 +844,16 @@ def _plan_bricks_3d( dims_state: DimsState | None = None, force_level: int | None = None, selection: RegionSelection | None = None, + view_height_world: float = 0.0, ) -> np.ndarray: """LOD selection, distance sort and frustum cull. Pure with respect to the GPU (design 5.3): adopts the region, then returns the planned ``[level, g0, g1, g2]`` rows, nearest first. + + An orthographic view (``fov_y_rad`` of 0) takes one level for the + whole visual, from ``view_height_world``, its visible height in world + units. """ t_plan_start = time.perf_counter() self._frame_number += 1 @@ -900,6 +907,20 @@ def _plan_bricks_3d( ) t0 = time.perf_counter() + if force_level is None and fov_y_rad <= 0: + # Orthographic: a pixel covers the same world size everywhere, so + # the whole visual takes one level, by the 2D transition rule. + voxels_per_pixel = orthographic_voxels_per_pixel( + self._to_level0_displayed, + camera_pos_world, + view_height_world, + screen_height_px, + ) + if voxels_per_pixel > 0: + force_level = select_level_orthographic( + geo._level_scale_factors, voxels_per_pixel, lod_bias + ) + if force_level is not None: brick_arr = select_levels_arr_forced( geo.base_layout, force_level, geo._level_grids @@ -1061,6 +1082,7 @@ def plan( dims_state=request.dims_state, force_level=config.force_level, selection=request.selection, + view_height_world=request.world_extent[1], ) else: self._adopt_request_3d(request) diff --git a/tests/_planning.py b/tests/_planning.py index 34512d3b..91e0a5ce 100644 --- a/tests/_planning.py +++ b/tests/_planning.py @@ -36,6 +36,7 @@ def planned_requests_3d( force_level: int | None = None, selection: Any = None, backstop: bool = False, + view_height_world: float = 0.0, ) -> list[ChunkRequest]: """Every store request a 3D plan of *visual* wants, in load order. @@ -43,7 +44,9 @@ def planned_requests_3d( or a bare ``_MultiscaleImageSlot``; any other visual's own ``build_slice_request`` answers. The arguments are ``build_slice_request``'s, plus *backstop*: whether the plan includes - the coarse backstop (off by default, so the requests are the target's). + the coarse backstop (off by default, so the requests are the target's), + and *view_height_world*: the visible world height of an orthographic + view (``fov_y_rad`` of 0). """ from cellier.render.scheduling import is_chunked_visual from cellier.render.visuals._image import _MultiscaleImageSlot @@ -75,6 +78,7 @@ def planned_requests_3d( screen_height_px, lod_bias, force_level, + view_height_world=view_height_world, ) desired = [visual.desired_set_3d(brick_arr)] else: @@ -84,7 +88,7 @@ def planned_requests_3d( frustum_corners=frustum_corners_world, fov_y_rad=fov_y_rad, screen_size_px=(screen_height_px, screen_height_px), - world_extent=(0.0, 0.0), + world_extent=(view_height_world, view_height_world), dims_state=dims_state, selection=selection, request_id=uuid4(), diff --git a/tests/render/test_level_of_detail.py b/tests/render/test_level_of_detail.py index 59d7b192..8c8d74d9 100644 --- a/tests/render/test_level_of_detail.py +++ b/tests/render/test_level_of_detail.py @@ -12,6 +12,8 @@ from cellier.render._level_of_detail import ( build_level_grids, + orthographic_voxels_per_pixel, + select_level_orthographic, select_levels_arr_forced, select_levels_from_cache, sort_arr_by_distance, @@ -379,3 +381,53 @@ def test_sort_arr_stable_for_equidistant(): arr[1, 0] = 1 out = sort_arr_by_distance(arr, np.array([4.0, 4.0, 4.0]), BLOCK_SIZE) np.testing.assert_array_equal(out, arr) + + +# --------------------------------------------------------------------------- +# Orthographic level selection +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("voxels_per_pixel", "lod_bias", "expected"), + [ + (0.01, 1.0, 1), + (1.0, 1.0, 1), + (1.41, 1.0, 1), + (1.42, 1.0, 2), + # Non power-of-two factors (1, 2, 6): sqrt(12) = 3.46... + (3.4, 1.0, 2), + (3.5, 1.0, 3), + (1000.0, 1.0, 3), + # A higher bias is coarser, a lower one finer. + (1.0, 2.0, 2), + (2.0, 0.5, 1), + ], +) +def test_select_level_orthographic_switches_at_geometric_mean( + voxels_per_pixel, lod_bias, expected +): + assert select_level_orthographic([1.0, 2.0, 6.0], voxels_per_pixel, lod_bias) == ( + expected + ) + + +def test_select_level_orthographic_single_level(): + assert select_level_orthographic([1.0], 50.0) == 1 + + +def test_orthographic_voxels_per_pixel_uses_geometric_mean_scale(): + # World units per voxel of (4, 1, 2) along (x, y, z): 0.5 voxels per + # world unit in the geometric mean, with an offset that must not matter. + def to_level0(points): + return (points - 3.0) / np.array([4.0, 1.0, 2.0]) + + result = orthographic_voxels_per_pixel( + to_level0, np.array([10.0, 20.0, 30.0]), 300.0, 100.0 + ) + assert result == pytest.approx(1.5) + + +@pytest.mark.parametrize(("height", "screen"), [(0.0, 100.0), (300.0, 0.0)]) +def test_orthographic_voxels_per_pixel_degenerate_view_is_zero(height, screen): + assert orthographic_voxels_per_pixel(lambda p: p, np.zeros(3), height, screen) == 0 diff --git a/tests/render/test_lod_orthographic_3d.py b/tests/render/test_lod_orthographic_3d.py new file mode 100644 index 00000000..bdef4354 --- /dev/null +++ b/tests/render/test_lod_orthographic_3d.py @@ -0,0 +1,125 @@ +"""An orthographic 3D camera picks one level from the screen pixel size. + +At a field of view of 0 the 3D planners had no distance thresholds and fell +back to multiples of the level-0 volume diagonal, so any camera within one +diagonal planned the finest level however far the view was zoomed out. A +pixel of an orthographic view covers the same world size everywhere, so the +whole visual takes the level whose voxels best match it, by the 2D rule. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from cellier.visuals import MultiscaleImageSingleAppearance +from cellier.visuals._image import ( + MultiscaleImageAppearance, + MultiscaleImageRenderConfig, +) +from cellier.visuals._labels import ( + MultiscaleLabelRenderConfig, + MultiscaleLabelsAppearance, +) +from tests._planning import planned_requests_3d + +SCREEN_HEIGHT_PX = 100.0 +# Level 0 is 16^3 at unit spacing and level 1 is 8^3 at twice the spacing, +# so the levels switch at sqrt(1 * 2) level-0 voxels per pixel. +SWITCH_HEIGHT_WORLD = SCREEN_HEIGHT_PX * 2.0**0.5 + + +def _add(controller, kind, store): + scene = controller.add_scene(dim="3d", name=kind) + if kind == "image": + visual = controller.add_image_multiscale( + data=store, + scene_id=scene.id, + appearance=MultiscaleImageAppearance(), + render_config=MultiscaleImageRenderConfig(block_size=8), + single=MultiscaleImageSingleAppearance(color_map="viridis"), + ) + else: + visual = controller.add_labels_multiscale( + data=store, + scene_id=scene.id, + appearance=MultiscaleLabelsAppearance(), + render_config=MultiscaleLabelRenderConfig(block_size=8), + ) + controller.add_canvas(scene_id=scene.id) + return scene, visual + + +def _planned_levels(controller, scene, visual, view_height_world, lod_bias=1.0): + """The set of levels (0 is the finest) an orthographic plan asks for.""" + gfx = controller._render_manager._scenes[scene.id].get_visual(visual.id) + canvas_id = controller.get_canvas_ids(scene.id)[0] + requests = planned_requests_3d( + gfx, + # Inside one level-0 diagonal of the volume, where the fallback + # distance bands plan the finest level. + camera_pos_world=np.array([8.0, 8.0, 20.0]), + frustum_corners_world=None, + fov_y_rad=0.0, + screen_height_px=SCREEN_HEIGHT_PX, + lod_bias=lod_bias, + dims_state=scene.dims.to_state(), + selection=controller._selections_for_scene(scene.id)[canvas_id], + view_height_world=view_height_world, + ) + assert requests, f"nothing planned at view_height_world={view_height_world}" + return {request.scale_index for request in requests} + + +@pytest.mark.parametrize("kind", ["image", "labels"]) +async def test_orthographic_3d_takes_one_level_from_the_pixel_size( + controller, multiscale_image_store, multiscale_labels_store, kind +): + store = multiscale_image_store if kind == "image" else multiscale_labels_store + scene, visual = _add(controller, kind, store) + controller.fit_camera(scene.id) + + # Zoomed in: a pixel is a fraction of a level-0 voxel. + assert _planned_levels(controller, scene, visual, 16.0) == {0} + # Either side of the transition. + assert _planned_levels(controller, scene, visual, SWITCH_HEIGHT_WORLD * 0.99) == {0} + assert _planned_levels(controller, scene, visual, SWITCH_HEIGHT_WORLD * 1.01) == {1} + # Zoomed out: a pixel covers many level-0 voxels. + assert _planned_levels(controller, scene, visual, 1600.0) == {1} + + +@pytest.mark.parametrize("kind", ["image", "labels"]) +async def test_orthographic_3d_lod_bias_is_coarser_when_higher( + controller, multiscale_image_store, multiscale_labels_store, kind +): + store = multiscale_image_store if kind == "image" else multiscale_labels_store + scene, visual = _add(controller, kind, store) + controller.fit_camera(scene.id) + + # One level-0 voxel per pixel: the bias alone decides. + assert _planned_levels(controller, scene, visual, 100.0, lod_bias=1.0) == {0} + assert _planned_levels(controller, scene, visual, 100.0, lod_bias=2.0) == {1} + assert _planned_levels(controller, scene, visual, 200.0, lod_bias=0.5) == {0} + + +async def test_3d_request_carries_the_world_extent_only_at_zero_fov( + controller, multiscale_image_store +): + scene, _ = _add(controller, "image", multiscale_image_store) + controller.fit_camera(scene.id) + canvas_id = controller.get_canvas_ids(scene.id)[0] + view = controller._render_manager._canvases[canvas_id] + selection = controller._selections_for_scene(scene.id)[canvas_id] + camera = view._camera + + request = view.capture_reslicing_request(scene.dims.to_state(), selection, None) + assert request.fov_y_rad > 0 + assert request.world_extent == (0.0, 0.0) + + camera.fov = 0.0 + request = view.capture_reslicing_request(scene.dims.to_state(), selection, None) + assert request.fov_y_rad == 0.0 + # The test canvas is square, so the view shows the camera's larger side + # both ways. + side = max(camera.width, camera.height) + assert request.world_extent == pytest.approx((side, side)) From ec887f3bebc230a1085632332f70b5f1c4c5747b Mon Sep 17 00:00:00 2001 From: Kevin Yamauchi Date: Tue, 6 Oct 2026 11:36:05 +0200 Subject: [PATCH 03/24] fix orthographic camera size setting --- src/cellier/render/canvas_view.py | 79 +++++++- tests/render/test_canvas_orthographic_3d.py | 189 ++++++++++++++++++++ 2 files changed, 261 insertions(+), 7 deletions(-) create mode 100644 tests/render/test_canvas_orthographic_3d.py diff --git a/src/cellier/render/canvas_view.py b/src/cellier/render/canvas_view.py index 7b589ec9..9d418653 100644 --- a/src/cellier/render/canvas_view.py +++ b/src/cellier/render/canvas_view.py @@ -219,6 +219,12 @@ def __init__( # Both camera/controller pairs are created upfront so toggling only # requires enabling/disabling — no construction or destruction. self._camera_3d = gfx.PerspectiveCamera(fov, 16 / 9, depth_range=depth_range) + # The configured 3D depth range, and the one last written to the + # camera; they differ at a field of view of 0. See + # ``_sync_depth_range_3d``. + self._depth_range_3d: tuple[float, float] | None = depth_range + self._applied_depth_range_3d: tuple[float, float] | None = depth_range + self._sync_depth_range_3d() # ``auto_update=False``: the controllers still turn input into # actions, but ``_draw_frame`` ticks them and applies the camera # state, and ``_on_controller_input`` requests the draws. @@ -623,6 +629,9 @@ def _capture_perspective( frame has been drawn. """ cam = self._camera + # Ahead of the frustum: a positive near plane at a field of view of + # 0 would cull the bricks on the camera's side of the scene centre. + self._sync_depth_range_3d() frustum = np.asarray(cam.frustum, dtype=np.float64) fov = float(cam.fov) if fov == 0 and screen_w > 0 and screen_h > 0: @@ -700,15 +709,48 @@ def _capture_orthographic( target_visual_ids=target_visual_ids, ) + def _sync_depth_range_3d(self) -> None: + """Give the 3D camera the depth range its field of view needs. + + pygfx places a camera with a field of view of 0 *at* the centre of + what it is fitted to, and not in front of it, so the near plane has + to be behind the camera: a positive one cuts away everything on the + camera's side of the centre. At a field of view of 0 the configured + range ``(near, far)`` is therefore widened to ``(-far, far)``; above + 0 it is used as given. The configured range is what + :meth:`capture_camera_state` reports, so a round trip through the + camera model brings the same range back when the field of view + changes. + + A range written straight to the pygfx camera since the last call is + taken as the configured one. + """ + camera = self._camera_3d + current = camera.depth_range + if current is not None: + current = (float(current[0]), float(current[1])) + if current != self._applied_depth_range_3d: + self._depth_range_3d = current + effective = self._depth_range_3d + if effective is not None and float(camera.fov) == 0: + near, far = effective + effective = (min(near, -far), far) + if current != effective: + camera.depth_range = effective + self._applied_depth_range_3d = effective + def set_depth_range(self, depth_range: tuple[float, float]) -> None: """Set the active camera near/far clip distances. + On the 3D camera this is the configured range, which is widened + while the field of view is 0; see :meth:`_sync_depth_range_3d`. + Parameters ---------- depth_range : tuple[float, float] ``(near, far)`` clip distances in world units. """ - self._camera.depth_range = depth_range + self.set_depth_range_for_dim(self._dim, depth_range) def set_depth_range_for_dim( self, dim: str, depth_range: tuple[float, float] @@ -730,8 +772,12 @@ def set_depth_range_for_dim( depth_range : tuple[float, float] ``(near, far)`` clip distances in world units. """ - camera = self._camera_2d if dim == "2d" else self._camera_3d - camera.depth_range = depth_range + if dim == "2d": + self._camera_2d.depth_range = depth_range + return + self._sync_depth_range_3d() + self._depth_range_3d = (float(depth_range[0]), float(depth_range[1])) + self._sync_depth_range_3d() def show_object(self, scene: gfx.Scene) -> bool: """Fit the camera to the scene bounding box, if there is one. @@ -1051,6 +1097,8 @@ def set_camera_state(self, state: CameraState) -> None: Only the fields of the active camera's kind are applied: a perspective camera takes ``fov``, an orthographic one ``extent``. + A perspective camera with a ``fov`` of 0 takes ``extent`` as well, + where the state has one: it is what an orthographic zoom changes. ``up`` is not applied: it is the up direction the rotation already gives. The caller reports the move; see :meth:`accept_camera_state`. @@ -1075,10 +1123,13 @@ def set_camera_state(self, state: CameraState) -> None: camera.width, camera.height = state.extent else: camera.fov = state.fov + # A state saved before the 3D extent was recorded has none. + if state.fov == 0 and state.extent[0] > 0 and state.extent[1] > 0: + camera.width, camera.height = state.extent camera.world.position = state.position camera.world.rotation = state.rotation camera.zoom = state.zoom - camera.depth_range = state.depth_range + self.set_depth_range_for_dim(self._dim, state.depth_range) def set_event_bus(self, event_bus: EventBus) -> None: """Wire the EventBus after construction.""" @@ -1231,8 +1282,16 @@ def set_scene_extent(self, diagonal: float) -> None: self._ssao_pass.set_scene_extent(diagonal) def capture_camera_state(self) -> CameraState: - """Snapshot the current pygfx camera into a CameraState NamedTuple.""" + """Snapshot the current pygfx camera into a CameraState NamedTuple. + + For the 3D camera ``depth_range`` is the configured range, not the + one widened for a field of view of 0 (see + :meth:`_sync_depth_range_3d`), and ``extent`` is the camera's width + and height at a field of view of 0 and ``(0.0, 0.0)`` above it. + """ cam = self._camera + if self._dim != "2d": + self._sync_depth_range_3d() pos = cam.world.position rot = cam.world.rotation # quaternion (x, y, z, w) dr = cam.depth_range @@ -1251,14 +1310,20 @@ def capture_camera_state(self) -> CameraState: ) else: up = cam.world.up + fov = float(cam.fov) + if self._depth_range_3d is not None: + depth_range = self._depth_range_3d + # An orthographic zoom changes the width and height and nothing + # else, so they are part of the state there. + extent = (float(cam.width), float(cam.height)) if fov == 0 else (0.0, 0.0) return CameraState( camera_type="perspective", position=(float(pos[0]), float(pos[1]), float(pos[2])), rotation=(float(rot[0]), float(rot[1]), float(rot[2]), float(rot[3])), up=(float(up[0]), float(up[1]), float(up[2])), - fov=float(cam.fov), + fov=fov, zoom=float(cam.zoom), - extent=(0.0, 0.0), + extent=extent, depth_range=depth_range, ) diff --git a/tests/render/test_canvas_orthographic_3d.py b/tests/render/test_canvas_orthographic_3d.py new file mode 100644 index 00000000..502ac956 --- /dev/null +++ b/tests/render/test_canvas_orthographic_3d.py @@ -0,0 +1,189 @@ +"""A 3D canvas whose camera has a field of view of 0 (orthographic). + +pygfx puts such a camera at the centre of what it is fitted to, so the near +plane has to be behind it; and its controller zooms by changing the camera's +width and height and nothing else. The canvas has to allow for both. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from cellier.visuals import MultiscaleImageSingleAppearance +from cellier.visuals._image import ( + MultiscaleImageAppearance, + MultiscaleImageRenderConfig, +) + +DEPTH_RANGE = (1.0, 8000.0) + + +def _setup(controller, store): + """A fitted 3D canvas on a 16^3 image; returns (scene, canvas id, view).""" + scene = controller.add_scene(dim="3d", name="scene") + controller.add_image_multiscale( + data=store, + scene_id=scene.id, + appearance=MultiscaleImageAppearance(), + render_config=MultiscaleImageRenderConfig(block_size=8), + single=MultiscaleImageSingleAppearance(color_map="viridis"), + ) + controller.add_canvas(scene_id=scene.id, depth_range_3d=DEPTH_RANGE) + canvas_id = controller.get_canvas_ids(scene.id)[0] + view = controller._render_manager._canvases[canvas_id] + controller.fit_camera(scene.id) + return scene, canvas_id, view + + +def _go_orthographic(controller, scene, view) -> None: + view.camera.fov = 0.0 + controller.fit_camera(scene.id) + + +def _near_side_point(view) -> np.ndarray: + """A world point between the camera's side of the scene and its centre.""" + camera = view.camera + forward = np.asarray(camera.world.forward, dtype=np.float64) + radius = 0.5 * float(camera.width) + # Fitted at a field of view of 0, the camera sits at the scene's centre. + return np.asarray(camera.world.position, dtype=np.float64) - 0.5 * radius * forward + + +def _clip_depth(view, point: np.ndarray) -> float: + """The clip-space depth of *point*; inside the clip volume is 0 to 1.""" + clip = np.asarray(view.camera.camera_matrix) @ np.append(point, 1.0) + return float(clip[2] / clip[3]) + + +def _inside(corners: np.ndarray, point: np.ndarray) -> bool: + """Whether *point* is between the near and far faces of a frustum.""" + near_centre = corners[0].mean(axis=0) + far_centre = corners[1].mean(axis=0) + axis = far_centre - near_centre + t = float(np.dot(point - near_centre, axis) / np.dot(axis, axis)) + return 0.0 <= t <= 1.0 + + +async def test_near_side_of_the_scene_is_inside_the_clip_volume_at_zero_fov( + controller, multiscale_image_store +): + scene, canvas_id, view = _setup(controller, multiscale_image_store) + _go_orthographic(controller, scene, view) + point = _near_side_point(view) + + # What the planners cull bricks against. + selection = controller._selections_for_scene(scene.id)[canvas_id] + request = view.capture_reslicing_request(scene.dims.to_state(), selection, None) + assert _inside(request.frustum_corners, point) + + # What the renderer clips against. + view._draw_frame() + assert 0.0 <= _clip_depth(view, point) <= 1.0 + near, far = view.camera.depth_range + assert (near, far) == (-DEPTH_RANGE[1], DEPTH_RANGE[1]) + + +async def test_configured_depth_range_returns_above_zero_fov( + controller, multiscale_image_store +): + scene, _canvas_id, view = _setup(controller, multiscale_image_store) + perspective_fov = float(view.camera.fov) + _go_orthographic(controller, scene, view) + view._draw_frame() + + view.camera.fov = perspective_fov + view._draw_frame() + + assert tuple(view.camera.depth_range) == DEPTH_RANGE + + +async def test_depth_range_survives_a_round_trip_through_the_camera_state( + controller, multiscale_image_store +): + scene, canvas_id, view = _setup(controller, multiscale_image_store) + perspective_fov = float(view.camera.fov) + _go_orthographic(controller, scene, view) + + # The state, and so the camera model, holds the configured range. + state = view.capture_camera_state() + assert state.depth_range == DEPTH_RANGE + + controller.set_camera_state(canvas_id, state) + assert tuple(view.camera.depth_range) == (-DEPTH_RANGE[1], DEPTH_RANGE[1]) + + controller.set_camera_state(canvas_id, state._replace(fov=perspective_fov)) + assert tuple(view.camera.depth_range) == DEPTH_RANGE + + +async def test_set_depth_range_is_widened_at_zero_fov( + controller, multiscale_image_store +): + scene, canvas_id, view = _setup(controller, multiscale_image_store) + _go_orthographic(controller, scene, view) + + controller.set_camera_depth_range(canvas_id, (2.0, 500.0)) + + assert tuple(view.camera.depth_range) == (-500.0, 500.0) + assert view.capture_camera_state().depth_range == (2.0, 500.0) + + +async def test_orthographic_zoom_is_a_camera_change( + monkeypatch, controller, multiscale_image_store +): + scene, _canvas_id, view = _setup(controller, multiscale_image_store) + _go_orthographic(controller, scene, view) + view._draw_frame() # settle: the fit is a camera change of its own + + resets: list[int] = [] + original = view._accum_pass.reset + monkeypatch.setattr( + view._accum_pass, "reset", lambda: (resets.append(1), original())[1] + ) + events = [] + controller.on_camera_changed(scene.id, events.append, owner_id=controller._id) + + view._draw_frame() + assert not resets, "a still camera keeps its history" + assert not events + + # What pygfx's orbit controller does to zoom at a field of view of 0. + camera = view.camera + extent = (0.5 * float(camera.width), 0.5 * float(camera.height)) + camera.width, camera.height = extent + view._draw_frame() + + assert resets, "an orthographic zoom must discard the accumulation history" + assert len(events) == 1, "an orthographic zoom must be reported" + assert events[0].camera_state.extent == pytest.approx(extent) + + +async def test_camera_state_restores_the_orthographic_extent( + controller, multiscale_image_store +): + scene, canvas_id, view = _setup(controller, multiscale_image_store) + _go_orthographic(controller, scene, view) + camera = view.camera + state = view.capture_camera_state() + assert state.extent == (float(camera.width), float(camera.height)) + + camera.width, camera.height = 3.0, 3.0 + controller.set_camera_state(canvas_id, state) + assert (float(camera.width), float(camera.height)) == state.extent + + # A state saved before the extent was recorded leaves the camera's alone. + controller.set_camera_state(canvas_id, state._replace(extent=(0.0, 0.0))) + assert (float(camera.width), float(camera.height)) == state.extent + + +async def test_perspective_camera_state_has_no_extent( + controller, multiscale_image_store +): + _scene, _canvas_id, view = _setup(controller, multiscale_image_store) + + state = view.capture_camera_state() + + assert state.fov > 0 + assert state.extent == (0.0, 0.0) + assert state.depth_range == DEPTH_RANGE + assert tuple(view.camera.depth_range) == DEPTH_RANGE From 72f398fa528a4e87aeabfda01ccbc162036a814f Mon Sep 17 00:00:00 2001 From: Kevin Yamauchi Date: Tue, 6 Oct 2026 20:07:06 +0200 Subject: [PATCH 04/24] add planner --- docs/Explanations/events.md | 31 +- docs/Explanations/interaction_tracker.md | 8 +- docs/Explanations/multiscale_brick_lookup.md | 2 +- docs/Explanations/slicing.md | 6 +- docs/How_To/clipping_planes.md | 8 +- .../multiscale_clipping_planes_viewer.py | 2 +- ...ultiscale_clipping_planes_viewer_marimo.py | 2 +- .../convenience/multiscale_image_viewer.ipynb | 4 +- .../convenience/multiscale_image_viewer.py | 4 +- .../multiscale_image_viewer_marimo.py | 4 +- src/cellier/clipping/_gizmo_controller.py | 6 +- src/cellier/controller.py | 95 ++--- src/cellier/convenience/_ortho_clipping.py | 10 +- src/cellier/convenience/_ortho_viewer.py | 2 +- src/cellier/convenience/_viewer.py | 6 +- .../convenience/gui/_appearance_widgets.py | 2 +- .../convenience/gui/_appearance_widgets_qt.py | 2 +- .../convenience/gui/_controls_config.py | 10 +- src/cellier/convenience/layout/_shared.py | 10 +- src/cellier/events/__init__.py | 4 +- src/cellier/events/_bus.py | 6 +- src/cellier/events/_events.py | 13 +- src/cellier/events/_update_events.py | 5 +- src/cellier/gui/_appearance_fields.py | 2 +- src/cellier/gui/_image_controls.py | 2 +- src/cellier/gui/_loading.py | 7 +- .../gui/anywidget/visuals/_lod_bias.py | 18 +- .../gui/anywidget/visuals/static/lod_bias.js | 8 +- src/cellier/gui/qt/visuals/_loading_config.py | 7 +- src/cellier/gui/qt/visuals/_lod_bias.py | 14 +- src/cellier/render/_config.py | 5 +- src/cellier/render/_level_of_detail.py | 105 ++++- src/cellier/render/_scene_config.py | 14 +- src/cellier/render/scene_manager.py | 4 +- src/cellier/render/visuals/_chunked.py | 6 +- src/cellier/render/visuals/_image.py | 23 +- .../render/visuals/_label_multiscale.py | 15 +- src/cellier/visuals/_base_visual.py | 9 +- src/cellier/visuals/_image.py | 44 ++- src/cellier/visuals/_labels.py | 43 +- src/cellier/visuals/_loading.py | 42 +- src/cellier/visuals/_mesh_memory.py | 5 +- src/cellier/visuals/_removed.py | 49 +++ tests/_plane_fixtures.py | 161 ++++++++ tests/_planning.py | 49 ++- tests/convenience/test_appearance_specs.py | 13 +- .../test_controls_config_validation.py | 14 +- tests/convenience/test_non_image_controls.py | 16 +- .../test_ortho_appearance_controls.py | 6 +- tests/convenience/test_ortho_clipping.py | 30 +- tests/convenience/test_ortho_dims.py | 4 +- .../test_qt_appearance_acceptance.py | 6 +- tests/convenience/test_qt_renderer.py | 4 +- tests/data/plane_golden/plans.npz | Bin 0 -> 73254 bytes tests/data/plane_golden/wgsl.json | 74 ++++ tests/gui/anywidget/test_lod_bias.py | 16 +- tests/gui/qt/test_qt_lod_bias.py | 8 +- tests/gui/test_appearance_control_types.py | 2 +- tests/gui/test_visual_id_groups.py | 13 +- .../scheduling/test_backstop_integration.py | 30 +- tests/render/scheduling/test_dims_drag.py | 85 ++-- .../scheduling/test_loading_config_api.py | 60 +-- .../test_loading_config_controls.py | 33 +- .../render/scheduling/test_progress_events.py | 17 +- tests/render/test_backstop.py | 10 +- tests/render/test_camera_interaction.py | 27 +- tests/render/test_clipping_interaction.py | 42 +- .../test_clipping_plane_gizmo_controller.py | 22 +- tests/render/test_cull_first_planner.py | 247 ++++++++++++ tests/render/test_level_offsets_uploaded.py | 6 +- tests/render/test_lod_bias_direction.py | 2 +- tests/render/test_mesh_dims_drag.py | 4 +- .../test_multiscale_labels_2d_reference.py | 5 +- .../render/test_multiscale_labels_3d_pick.py | 2 - .../test_multiscale_non_integer_pyramid.py | 7 +- tests/render/test_plane_golden_replay.py | 370 ++++++++++++++++++ tests/render/test_ray_steps_per_voxel.py | 10 +- tests/render/test_render_manager.py | 6 +- .../test_volume_proxy_single_shading.py | 7 +- tests/render/test_volume_ray_direction.py | 3 +- tests/test_plane_rendering_renames.py | 209 ++++++++++ tests/v2/test_anywidget.py | 6 +- tests/v2/test_controller.py | 16 +- tests/v2/test_viewer_model.py | 2 +- tests/v2/visuals/test_image.py | 4 +- .../visuals/test_multiscale_label_visual.py | 6 +- 86 files changed, 1805 insertions(+), 513 deletions(-) create mode 100644 src/cellier/visuals/_removed.py create mode 100644 tests/_plane_fixtures.py create mode 100644 tests/data/plane_golden/plans.npz create mode 100644 tests/data/plane_golden/wgsl.json create mode 100644 tests/render/test_cull_first_planner.py create mode 100644 tests/render/test_plane_golden_replay.py create mode 100644 tests/test_plane_rendering_renames.py diff --git a/docs/Explanations/events.md b/docs/Explanations/events.md index 18816448..03078367 100644 --- a/docs/Explanations/events.md +++ b/docs/Explanations/events.md @@ -498,7 +498,7 @@ class AppearanceChangedEvent(NamedTuple): The new value. Typed as ``Any``; consumers cast as needed. requires_reslice : ``True`` for fields that invalidate the current brick set - (``lod_bias``, ``force_level``, ``frustum_cull``). + (``settled_lod_bias``, ``force_level``, ``frustum_cull``). ``False`` for pure GPU-side changes (``color_map``, ``clim``). """ @@ -681,11 +681,12 @@ is put back on the model and emits nothing. The controller reslices the visual when its request depends on the planes. ```python -class ClippingInteractionEvent(NamedTuple): - """A clipping plane drag started or ended on a visual. +class PlaneInteractionEvent(NamedTuple): + """A drag of a visual's planes started or ended. - A drag is a run of ``clipping_planes`` changes made inside - ``CellierController.clipping_interaction`` (a gizmo opens one for the + One tracker per visual serves its planes; today those are its clipping + planes. A drag is a run of ``clipping_planes`` changes made inside + ``CellierController.plane_interaction`` (a gizmo opens one for the length of a drag). The start is emitted with the first change, ahead of its ``ClippingPlanesChangedEvent``. No event is emitted per change. @@ -712,9 +713,9 @@ class ClippingInteractionEvent(NamedTuple): reason: Literal["release", "settle", "jump", "cancel"] | None = None ``` -The scope is opened with `controller.clipping_interaction(visual_id)` (a -`with` block) or `begin_clipping_interaction` / `end_clipping_interaction`. -Outside a scope a change is not a drag and emits no `ClippingInteractionEvent`. +The scope is opened with `controller.plane_interaction(visual_id)` (a +`with` block) or `begin_plane_interaction` / `end_plane_interaction`. +Outside a scope a change is not a drag and emits no `PlaneInteractionEvent`. With no event loop there is no stillness timer, so every change inside a scope is a drag of its own: one `start` and one `end` (`"settle"`). @@ -1272,7 +1273,7 @@ CellierEventTypes = ( | LoadingConfigChangedEvent | LodConfigChangedEvent | ClippingPlanesChangedEvent - | ClippingInteractionEvent + | PlaneInteractionEvent | ClippingPlaneGizmoChangedEvent | ResliceCancelledEvent | FrameRenderedEvent @@ -1451,7 +1452,7 @@ _ENTITY_FIELD: dict[type, str] = { LoadingConfigChangedEvent: "visual_id", LodConfigChangedEvent: "visual_id", ClippingPlanesChangedEvent: "visual_id", - ClippingInteractionEvent: "visual_id", + PlaneInteractionEvent: "visual_id", ClippingPlaneGizmoChangedEvent: "canvas_id", ResliceCancelledEvent: "visual_id", FrameRenderedEvent: "canvas_id", @@ -2039,7 +2040,7 @@ object registration time. | Subscriber | Event type | `entity_id` filter | Action | |---|---|---|---| -| Controller | `DimsChangedEvent` | *(none — all scenes)* | Call `reslice_scene` for the changed scene (during a scrub, backstop-only for `dims_drag="backstop"` visuals; the scrub's end plans them in full) | +| Controller | `DimsChangedEvent` | *(none — all scenes)* | Call `reslice_scene` for the changed scene (during a scrub, backstop-only for visuals with `coarsest_while_moving` on for the view; the scrub's end plans them in full) | | Controller | `CameraChangedEvent` | *(none — all canvases)* | Update camera model; tick the canvas's camera tracker (a motion's end, or a jump, reslices) | | Controller | `_CameraControllerEvent` (internal) | *(none — all canvases)* | Open or close the camera controller's scope on the canvas's camera tracker | | Controller | `FrameRenderedEvent` | *(none — all canvases)* | With no event loop, run the camera reslices the frame queued | @@ -2049,7 +2050,7 @@ object registration time. | `ClippingPlaneGizmoController` | `ClippingPlanesChangedEvent` | `visual_id` | Move the gizmo to its plane's new pose; close when the plane is gone or can no longer have a gizmo | | `ClippingPlaneGizmoController` | `VisualRemovedEvent` | `scene_id` | Close the gizmo when its visual is removed | | `OrthoClippingController` | `ClippingPlanesChangedEvent` | `visual_id` (each panel visual) | Write the tuple to the panel visuals linked to it whose tuple differs (`set_clipping_planes`, same `source_id`) | -| `OrthoClippingController` | `ClippingInteractionEvent` | `visual_id` (each panel visual) | Open a clipping interaction scope on the linked panel visuals while one is dragged | +| `OrthoClippingController` | `PlaneInteractionEvent` | `visual_id` (each panel visual) | Open a plane interaction scope on the linked panel visuals while one is dragged | | `OrthoClippingController` | `VisualRemovedEvent` | `visual_id` (each panel visual) | Close the scopes forwarded from the visual, drop its subscriptions, take it out of its group | | External callback | `DimsInteractionEvent` | `scene_id` | Fire `on_dims_interaction` user callback | | External callback | `CameraInteractionEvent` | `scene_id` | Fire `on_camera_interaction` user callback | @@ -2072,7 +2073,7 @@ object registration time. | Clipping planes control | `ClippingPlanesChangedEvent` | `visual_id` (each visual it edits) | Show the planes (`QtClippingPlanesControls`, `AnywidgetClippingPlanesControls`); edits go out as one `ClippingPlanesUpdateEvent` per visual on the incoming bus | | Clipping planes control | `ClippingPlaneGizmoChangedEvent` | `canvas_id` | With a gizmo target: show which plane has the canvas's gizmo; the toggle goes out as `ClippingPlaneGizmoUpdateEvent` | | External callback | `ClippingPlanesChangedEvent` | `visual_id` | Fire `on_clipping_planes_changed` user callback | -| External callback | `ClippingInteractionEvent` | `visual_id` | Fire `on_clipping_interaction` user callback | +| External callback | `PlaneInteractionEvent` | `visual_id` | Fire `on_plane_interaction` user callback | | External callback | `ClippingPlaneGizmoChangedEvent` | `canvas_id` | Fire `on_clipping_plane_gizmo_changed` user callback | | External callback | `CanvasMouse{Press,Move,Release}{2D,3D}Event` | `canvas_id` | Fire `on_mouse_*` user callback | | External callback | `{Image,Labels,Points,Lines,Mesh,Graph}PickEvent` | `canvas_id` | Fire `on_pick` user callback; enables pick-detail extraction | @@ -2195,7 +2196,7 @@ initiated — that is the controller's responsibility for `requires_reslice=True On the next call to `renderer.render()` in `CanvasView._draw_frame`, pygfx detects the dirty material and uploads the updated parameters to the GPU. -### The reslice path: `lod_bias`, `force_level`, `frustum_cull` +### The reslice path: `settled_lod_bias`, `force_level`, `frustum_cull` When a field in `_RESLICE_FIELDS` changes, `AppearanceChangedEvent` carries `requires_reslice=True`. The `GFX*Visual` handler does nothing for these fields. The @@ -2247,7 +2248,7 @@ from cellier.events._events import ( CanvasPickInfo, CellierEventTypes, ChannelAppearanceChangedEvent, - ClippingInteractionEvent, + PlaneInteractionEvent, ClippingPlaneGizmoChangedEvent, ClippingPlanesChangedEvent, DataStoreContentsChangedEvent, diff --git a/docs/Explanations/interaction_tracker.md b/docs/Explanations/interaction_tracker.md index 1332f577..eb573998 100644 --- a/docs/Explanations/interaction_tracker.md +++ b/docs/Explanations/interaction_tracker.md @@ -126,11 +126,11 @@ Only a **sliced** axis ticks. A scene keeps a position for every world axis, dis ### What a scrub does -Visuals do not subscribe to the tracker. Each visual model answers one question through its own explicit config, `BaseVisual.plans_coarse_on_scrub`: +Visuals do not subscribe to the tracker. Each visual model answers one question through its own explicit config, `BaseVisual.plans_coarse_while_moving(n_displayed_dims)`, asked with the scene's view (2D or 3D): -| Visual | `plans_coarse_on_scrub` | +| Visual | `plans_coarse_while_moving` | |---|---| -| Multiscale image, multiscale labels | `render_config.loading.dims_drag == "backstop"` (the default is `"eager"`, which is `False`) | +| Multiscale image, multiscale labels | `appearance.coarsest_while_moving_3d` in a 3D view, `appearance.coarsest_while_moving_2d` in a 2D view (both default to `False`) | | Multiscale mesh | `lod.dims_drag == "coarse"` (the default, which is `True`), when the mesh has more than one level | | Everything else | `False` | @@ -371,6 +371,6 @@ A loop that wants every position at full resolution does not open a scope. Each | Camera: `_on_camera_changed`, `_on_camera_transition`, `_after_programmatic_camera_move`, `camera_interaction`, `set_camera_state` | `cellier/controller.py` | | Frame-by-frame camera detection, `accept_camera_state`, `camera_moving` | `cellier/render/canvas_view.py` | | The events | `cellier/events/_events.py`, `cellier/events/_update_events.py` | -| `plans_coarse_on_scrub` | `cellier/visuals/_base_visual.py` | +| `plans_coarse_while_moving` | `cellier/visuals/_base_visual.py` | | Slider press and release | `cellier/gui/qt/_scene.py`, `cellier/gui/anywidget/_dims_panel.py` | | Ortho scope forwarding | `cellier/convenience/_ortho_dims.py` | diff --git a/docs/Explanations/multiscale_brick_lookup.md b/docs/Explanations/multiscale_brick_lookup.md index bea24d0e..1ca3736f 100644 --- a/docs/Explanations/multiscale_brick_lookup.md +++ b/docs/Explanations/multiscale_brick_lookup.md @@ -51,7 +51,7 @@ data, the padding, or the next slot in the atlas. On the light-sheet organoid dataset (`scripts/v2/transforms_v2/lightsheet_viewer.py`), in 3D: -- At startup a strip of the volume was missing. Lowering `lod_bias` (finer +- At startup a strip of the volume was missing. Lowering `settled_lod_bias` (finer levels) filled it; raising it brought it back. - While scrubbing time, and after zooming, blocks of the volume showed content from elsewhere in the image, and the blocks changed as bricks re-fetched. diff --git a/docs/Explanations/slicing.md b/docs/Explanations/slicing.md index 2f453558..d59b767f 100644 --- a/docs/Explanations/slicing.md +++ b/docs/Explanations/slicing.md @@ -158,7 +158,7 @@ An interaction ends **on release or on stillness, whichever comes first**. Holdi | Events | `DimsInteractionEvent`; `DimsChangedEvent.interactive` | `CameraInteractionEvent`; `CameraChangedEvent.interactive` | | State | `controller.dims_interaction_state(scene_id)` | `controller.camera_interaction_state(canvas_id)` | -Visuals do not subscribe to these events. Each visual model says through its own explicit config what it does during a scrub (`BaseVisual.plans_coarse_on_scrub`, which a multiscale image or labels visual answers from `render_config.loading.dims_drag == "backstop"`), and the controller turns transitions into plan modes and reslices. The events are for GUIs (a "loading full resolution" indicator) and tests. +Visuals do not subscribe to these events. Each visual model says through its own explicit config what it does during a scrub (`BaseVisual.plans_coarse_while_moving(n_displayed_dims)`, which a multiscale image or labels visual answers from `appearance.coarsest_while_moving_3d` in a 3D view and `appearance.coarsest_while_moving_2d` in a 2D one), and the controller turns transitions into plan modes and reslices. The events are for GUIs (a "loading full resolution" indicator) and tests. ### What a script gets @@ -207,10 +207,10 @@ A loop that wants every position at full resolution does not open a scope. That 1. **`CellierController.update_slice_indices`**: if a *sliced* axis moves, ticks the scene's dims tracker **before** writing the positions, so a scrub's start event reaches its listeners ahead of the tick's own consequences. Then writes the new positions onto the dims model (a psygnal field). 2. **`CellierController._make_dims_handler`**: the controller event bridge emits a `DimsChangedEvent` on the outgoing bus, with `interactive` set for a scrub tick. 3. **`CellierController._on_dims_changed_bus`**: calls `reslice_scene` for the whole scene. A displayed-axes change cancels a scrub first. A region change that did not come through `update_slice_indices` (a thickness) is a tick with no `interactive` flag. -4. **`CellierController._render_config_for`**: the one place a plan mode is decided. While the scene's tracker is `ACTIVE`, a visual with `plans_coarse_on_scrub` gets `PlanMode.BACKSTOP_ONLY` and joins the scene's pending set. +4. **`CellierController._render_config_for`**: the one place a plan mode is decided. While the scene's tracker is `ACTIVE`, a visual whose `plans_coarse_while_moving` is true for the scene's view gets `PlanMode.BACKSTOP_ONLY` and joins the scene's pending set. 5. **`CellierController._on_dims_transition`**: at a scrub's end by release or stillness, plans the pending set in full with `target_visual_ids`. -`dims_drag="eager"` (the default) plans in full on every tick. `"backstop"` saves most of a scrub's reads and is recommended for remote stores. Both show the slider's slice, blurry, about one read behind it. +`coarsest_while_moving_3d=False` and `coarsest_while_moving_2d=False` (the defaults) plan in full on every tick. `True` loads the backstop only on each tick, and the target when the scrub ends; it saves most of a scrub's reads and is recommended for remote stores. Both show the slider's slice, blurry, about one read behind it. A slider sends more than ticks. On press it opens a scope and on release it closes it, so the target loads on release instead of 0.15 s later. The release first flushes the slider's throttle: the end plans in full, and it must plan the final position. The anywidget panel's release message carries the final position itself, because in a notebook a custom message can overtake the traitlet sync sent before it. diff --git a/docs/How_To/clipping_planes.md b/docs/How_To/clipping_planes.md index f04b39ea..ba99a8cb 100644 --- a/docs/How_To/clipping_planes.md +++ b/docs/How_To/clipping_planes.md @@ -245,10 +245,10 @@ The target is read when the control is built, so build the canvas first: the layout flows do, and a dock built before its canvas raises. A canvas added later neither adds nor removes the toggle. -A drag is announced by `ClippingInteractionEvent` (`controller. -on_clipping_interaction`): one start and one end, with the plane changes +A drag is announced by `PlaneInteractionEvent` (`controller. +on_plane_interaction`): one start and one end, with the plane changes between them. A script can group its own changes the same way with -`with controller.clipping_interaction(visual.id): ...`. The plane a view's +`with controller.plane_interaction(visual.id): ...`. The plane a view's gizmo is on is announced by `ClippingPlaneGizmoChangedEvent` (`controller.on_clipping_plane_gizmo_changed`). @@ -291,7 +291,7 @@ ortho.clipping_controller.mode = "2d" # at any time the 3D panel's planes win, because it has the gizmo. From `None` to `"2d"` there is no 3D panel among them and the xy panel's planes win. - A drag is forwarded: while a plane of one panel is dragged, each panel - linked to it reports the drag too (`ClippingInteractionEvent`, one start + linked to it reports the drag too (`PlaneInteractionEvent`, one start and one end). When the dragged panel's drag ends because the handle was held still (`"settle"`), the linked panels end with `"release"`. - The gizmo (`ortho.add_clipping_plane_gizmo`) edits a plane of the 3D diff --git a/examples/clipping_planes/multiscale_clipping_planes_viewer.py b/examples/clipping_planes/multiscale_clipping_planes_viewer.py index a5ad5393..16a31a28 100644 --- a/examples/clipping_planes/multiscale_clipping_planes_viewer.py +++ b/examples/clipping_planes/multiscale_clipping_planes_viewer.py @@ -188,7 +188,7 @@ def beside(store, column: int) -> AffineTransform: transform=beside(image_store, 0), clipping_planes=half(image_store, (0, 0, 1)), controls=MultiscaleImageControlsConfig( - appearance=["visible", "render_mode", "iso_threshold", "lod_bias"], + appearance=["visible", "render_mode", "iso_threshold", "settled_lod_bias"], clipping_controls=True, # How much of the plan is loaded: watch it while a plane moves. loading_indicator=True, diff --git a/examples/clipping_planes/multiscale_clipping_planes_viewer_marimo.py b/examples/clipping_planes/multiscale_clipping_planes_viewer_marimo.py index 0c8e4709..ddb8ee25 100644 --- a/examples/clipping_planes/multiscale_clipping_planes_viewer_marimo.py +++ b/examples/clipping_planes/multiscale_clipping_planes_viewer_marimo.py @@ -331,7 +331,7 @@ def beside(store, column: int): transform=beside(image_store, 0), clipping_planes=half(image_store, (0, 0, 1)), controls=MultiscaleImageControlsConfig( - appearance=["visible", "render_mode", "iso_threshold", "lod_bias"], + appearance=["visible", "render_mode", "iso_threshold", "settled_lod_bias"], clipping_controls=True, loading_indicator=True, ), diff --git a/examples/convenience/multiscale_image_viewer.ipynb b/examples/convenience/multiscale_image_viewer.ipynb index 32fd0571..0130221f 100644 --- a/examples/convenience/multiscale_image_viewer.ipynb +++ b/examples/convenience/multiscale_image_viewer.ipynb @@ -174,7 +174,7 @@ " clim=(0.0, 1.0),\n", " render_mode=\"iso\",\n", " iso_threshold=0.45,\n", - " lod_bias=1.0,\n", + " settled_lod_bias=1.0,\n", " attenuation=1.0,\n", " ),\n", " controls=MultiscaleImageControlsConfig(\n", @@ -187,7 +187,7 @@ " \"render_mode\",\n", " \"iso_threshold\",\n", " \"attenuation\",\n", - " \"lod_bias\",\n", + " \"settled_lod_bias\",\n", " ],\n", " colormap_names=[\n", " \"grays\",\n", diff --git a/examples/convenience/multiscale_image_viewer.py b/examples/convenience/multiscale_image_viewer.py index 96b96471..562c16ac 100644 --- a/examples/convenience/multiscale_image_viewer.py +++ b/examples/convenience/multiscale_image_viewer.py @@ -124,7 +124,7 @@ def _write_zarr3(base_path, name, data): viewer.add_image_multiscale( store, appearance=MultiscaleImageAppearance( - lod_bias=1.0, + settled_lod_bias=1.0, attenuation=1.0, ), single=MultiscaleImageSingleAppearance( @@ -143,7 +143,7 @@ def _write_zarr3(base_path, name, data): "render_mode", "iso_threshold", "attenuation", - "lod_bias", + "settled_lod_bias", ], colormap_names=[ "grays", diff --git a/examples/convenience/multiscale_image_viewer_marimo.py b/examples/convenience/multiscale_image_viewer_marimo.py index 6ed80eff..0a8db495 100644 --- a/examples/convenience/multiscale_image_viewer_marimo.py +++ b/examples/convenience/multiscale_image_viewer_marimo.py @@ -182,7 +182,7 @@ def _( viewer.add_image_multiscale( store, appearance=MultiscaleImageAppearance( - lod_bias=1.0, + settled_lod_bias=1.0, attenuation=1.0, ), single=MultiscaleImageSingleAppearance( @@ -201,7 +201,7 @@ def _( "render_mode", "iso_threshold", "attenuation", - "lod_bias", + "settled_lod_bias", ], colormap_names=[ "grays", diff --git a/src/cellier/clipping/_gizmo_controller.py b/src/cellier/clipping/_gizmo_controller.py index cca09ab0..8c9c84d5 100644 --- a/src/cellier/clipping/_gizmo_controller.py +++ b/src/cellier/clipping/_gizmo_controller.py @@ -117,7 +117,7 @@ class ClippingPlaneGizmoController: - A plane changed from anywhere else (a slider, an assignment) moves the gizmo. - A drag is one clipping interaction on the visual - (``ClippingInteractionEvent``). + (``PlaneInteractionEvent``). - It closes itself when its plane or its visual is removed, when the canvas leaves 3D, and when the plane gains a component on an axis the view does not show. @@ -271,7 +271,7 @@ def _on_gizmo(self, event: PlaneGizmoMovedEvent) -> None: self._changes_plane = handle_changes_plane( event.handle_kind, event.handle_axis ) - self._controller.begin_clipping_interaction( + self._controller.begin_plane_interaction( self._visual_id, source_id=self._id ) elif event.phase == "move": @@ -300,7 +300,7 @@ def _assign(self, point: Any, normal: Any) -> None: def _end_scope(self) -> None: self._dragging = False - self._controller.end_clipping_interaction(self._visual_id, source_id=self._id) + self._controller.end_plane_interaction(self._visual_id, source_id=self._id) # -- the model -> the gizmo ---------------------------------------------- diff --git a/src/cellier/controller.py b/src/cellier/controller.py index bb6123b6..da709c9c 100644 --- a/src/cellier/controller.py +++ b/src/cellier/controller.py @@ -40,7 +40,6 @@ CanvasSizeChangedEvent, ChannelAppearanceChangedEvent, ChannelAppearanceUpdateEvent, - ClippingInteractionEvent, ClippingPlaneGizmoChangedEvent, ClippingPlaneGizmoUpdateEvent, ClippingPlanesChangedEvent, @@ -65,6 +64,7 @@ OverlayChangedEvent, OverlayUpdateEvent, PickWriteChangedEvent, + PlaneInteractionEvent, RenderConfigChangedEvent, RenderConfigUpdateEvent, ResliceCompletedEvent, @@ -248,7 +248,9 @@ # Appearance fields that require a reslice (not just a GPU material update). -_RESLICE_FIELDS: frozenset[str] = frozenset({"lod_bias", "force_level", "frustum_cull"}) +_RESLICE_FIELDS: frozenset[str] = frozenset( + {"settled_lod_bias", "force_level", "frustum_cull"} +) #: Visuals that load nothing while they draw nothing; showing one reslices it. #: The scheduled ones are retired while hidden: their reads stop. @@ -361,7 +363,7 @@ def _visual_render_config(visual: BaseVisual) -> VisualRenderConfig: ) if isinstance(visual, (MultiscaleImageVisual, MultiscaleLabelVisual)): return VisualRenderConfig( - lod_bias=visual.appearance.lod_bias, + settled_lod_bias=visual.appearance.settled_lod_bias, force_level=visual.appearance.force_level, frustum_cull=visual.appearance.frustum_cull, slicing_enabled=slicing_enabled, @@ -847,9 +849,9 @@ def __init__( self._dims_ticks: dict[UUID, bool] = {} # Clipping plane drags, one tracker per visual (gizmo design 6). It # announces a drag's start and end and drives nothing else. - self._clip_driver = _InteractionDriver( + self._plane_interaction_driver = _InteractionDriver( lambda: self._render_manager.config.scheduler.dims_settle_s, - self._on_clip_transition, + self._on_plane_transition, ) # The clipping plane gizmo of each canvas that has one: at most one # per canvas (gizmo design 7). @@ -3942,12 +3944,15 @@ def _render_config_for( The one place a plan mode is decided, so every reslice path agrees: while the scene's dims are being scrubbed, a visual with - ``plans_coarse_on_scrub`` plans ``BACKSTOP_ONLY`` and joins the - scene's pending set, which the scrub's end plans in full. A visual - that scrub handed to a camera end stays ``BACKSTOP_ONLY`` until then. + ``plans_coarse_while_moving`` for the scene's view (2D or 3D) plans + ``BACKSTOP_ONLY`` and joins the scene's pending set, which the + scrub's end plans in full. A visual that scrub handed to a camera + end stays ``BACKSTOP_ONLY`` until then. """ config = _visual_render_config(visual) - if visual.plans_coarse_on_scrub and self._dims_driver.is_active(scene_id): + if self._dims_driver.is_active(scene_id) and visual.plans_coarse_while_moving( + self._n_displayed_dims(scene_id) + ): self._dims_scrub_pending.setdefault(scene_id, set()).add(visual.id) return dataclasses.replace(config, plan_mode=PlanMode.BACKSTOP_ONLY) if visual.id in self._camera_handoff.get(scene_id, ()): @@ -4407,6 +4412,10 @@ def _dims_state_for_scene(self, scene_id: UUID) -> DimsState: """Derive a DimsState from the scene's DimsManager.""" return self._model.scenes[scene_id].dims.to_state() + def _n_displayed_dims(self, scene_id: UUID) -> int: + """How many dimensions the scene's views display: 2 or 3.""" + return len(self._model.scenes[scene_id].dims.selection.displayed_axes) + # ------------------------------------------------------------------ # psygnal bridges # ------------------------------------------------------------------ @@ -4775,7 +4784,7 @@ def _on_clipping_planes(planes: tuple) -> None: # A tick of a drag while a scope is open; otherwise a jump. The # tracker hears of it first, so a drag's start is announced ahead # of its first change. - self._clip_driver.tick(visual_id, source_id, interactive=False) + self._plane_interaction_driver.tick(visual_id, source_id, interactive=False) self._outgoing_events.emit( ClippingPlanesChangedEvent( source_id=source_id, @@ -4788,7 +4797,7 @@ def _on_clipping_planes(planes: tuple) -> None: self.reslice_visual(visual_id) self._request_draw_for_visual(visual_id) # No event loop means no timer: the drag ends here. - self._clip_driver.settle_without_loop(visual_id) + self._plane_interaction_driver.settle_without_loop(visual_id) visual.events.clipping_planes.connect(_on_clipping_planes) self._visual_psygnal_handlers.setdefault(visual_id, []).append( @@ -5314,9 +5323,8 @@ def set_loading_config( widgets pass ``source_id=self._id`` so their own subscription can ignore the echo. **fields : - ``ProgressiveLoadingConfig`` fields: ``backstop``, - ``backstop_level``, ``backstop_extent``, - ``backstop_max_slot_fraction``, ``dims_drag``. + ``ProgressiveLoadingConfig`` fields: ``backstop_level``, + ``backstop_extent``, ``backstop_max_slot_fraction``. Returns ------- @@ -5329,8 +5337,8 @@ def set_loading_config( If the visual is not a multiscale image or labels visual. ValueError If a field name is unknown, or the merged config is invalid - (e.g. ``dims_drag="backstop"`` with ``backstop=False``). The - visual is left unchanged; nothing is corrected. + (e.g. ``backstop_level=0``). The visual is left unchanged; + nothing is corrected. """ visual = self._get_visual_model(visual_id) if not isinstance(visual, (MultiscaleImageVisual, MultiscaleLabelVisual)): @@ -5570,7 +5578,7 @@ def _make_appearance_handler(self, visual_id: UUID) -> Callable: Routes field changes to one of two bus events: ``visible`` field changes become ``VisualVisibilityChangedEvent``; all other fields become ``AppearanceChangedEvent`` with ``requires_reslice=True`` for fields in - ``_RESLICE_FIELDS`` (``lod_bias``, ``force_level``, ``frustum_cull``). + ``_RESLICE_FIELDS`` (``settled_lod_bias``, ``force_level``, ``frustum_cull``). """ def _on_appearance_psygnal(info: EmissionInfo) -> None: @@ -5892,7 +5900,7 @@ def _on_dims_changed_bus(self, event: DimsChangedEvent) -> None: The plan modes come from the scene's dims tracker (see :meth:`_render_config_for`): during a scrub, visuals with - ``plans_coarse_on_scrub`` plan backstop-only and the scrub's end + ``plans_coarse_while_moving`` plan backstop-only and the scrub's end plans them in full; otherwise everything plans in full at once. A tick made through :meth:`update_slice_indices` has already been @@ -6092,7 +6100,7 @@ def update_slice_indices( once, and a scrub in progress on the scene ends. With ``interactive=True``, or inside an open scope (:meth:`dims_interaction`), it is a tick of a **scrub**: visuals - that opted in (``dims_drag="backstop"``) plan coarse, and plan in + that opted in (``coarsest_while_moving_3d`` or ``_2d``) plan coarse, and plan in full when the scrub ends, on release or after ``SchedulerConfig.dims_settle_s`` of stillness. A call that moves no sliced axis is neither. @@ -6895,10 +6903,10 @@ def set_clipping_plane( ) return moved - def _on_clip_transition(self, visual_id: UUID, transition: Transition) -> None: - """Announce a clipping plane drag's start or end. Nothing else.""" + def _on_plane_transition(self, visual_id: UUID, transition: Transition) -> None: + """Announce the start or end of a drag of a visual's planes. Nothing else.""" self._outgoing_events.emit( - ClippingInteractionEvent( + PlaneInteractionEvent( source_id=transition.source_id, visual_id=visual_id, phase=transition.phase, @@ -6906,12 +6914,12 @@ def _on_clip_transition(self, visual_id: UUID, transition: Transition) -> None: ) ) - def begin_clipping_interaction(self, visual_id: UUID, *, source_id: UUID) -> None: - """Open a clipping interaction scope on a visual. + def begin_plane_interaction(self, visual_id: UUID, *, source_id: UUID) -> None: + """Open a plane interaction scope on a visual. While any scope is open, every change of the visual's ``clipping_planes`` is a tick of one drag, announced by a - ``ClippingInteractionEvent`` at its start and its end. Opening a + ``PlaneInteractionEvent`` at its start and its end. Opening a scope starts nothing by itself; the first change does. Holding still for ``SchedulerConfig.dims_settle_s`` ends a drag, and the next change starts a new one. @@ -6920,7 +6928,7 @@ def begin_clipping_interaction(self, visual_id: UUID, *, source_id: UUID) -> Non does outside one. A clipping plane gizmo calls this when a handle is grabbed. - Scripts normally use :meth:`clipping_interaction`. + Scripts normally use :meth:`plane_interaction`. Parameters ---------- @@ -6936,10 +6944,10 @@ def begin_clipping_interaction(self, visual_id: UUID, *, source_id: UUID) -> Non """ if visual_id not in self._visual_to_scene: raise KeyError(f"No visual with id {visual_id}") - self._clip_driver.begin_scope(visual_id, source_id) + self._plane_interaction_driver.begin_scope(visual_id, source_id) - def end_clipping_interaction(self, visual_id: UUID, *, source_id: UUID) -> None: - """Close a clipping interaction scope opened by *source_id*. + def end_plane_interaction(self, visual_id: UUID, *, source_id: UUID) -> None: + """Close a plane interaction scope opened by *source_id*. Closing the last open scope ends a drag at once (``"release"``). Closing a scope that is not open does nothing. @@ -6951,16 +6959,16 @@ def end_clipping_interaction(self, visual_id: UUID, *, source_id: UUID) -> None: source_id : UUID The source that opened it. """ - self._clip_driver.end_scope(visual_id, source_id) + self._plane_interaction_driver.end_scope(visual_id, source_id) @contextmanager - def clipping_interaction(self, visual_id: UUID) -> Generator[None, None, None]: + def plane_interaction(self, visual_id: UUID) -> Generator[None, None, None]: """Drag a visual's clipping planes for the length of a ``with`` block. Every change of ``clipping_planes`` inside the block belongs to one drag:: - with controller.clipping_interaction(visual.id): + with controller.plane_interaction(visual.id): for offset in offsets: controller.set_clipping_plane(visual.id, plane_id, ...) @@ -6970,13 +6978,13 @@ def clipping_interaction(self, visual_id: UUID) -> Generator[None, None, None]: The visual whose planes are dragged. """ source_id = uuid4() - self.begin_clipping_interaction(visual_id, source_id=source_id) + self.begin_plane_interaction(visual_id, source_id=source_id) try: yield finally: - self.end_clipping_interaction(visual_id, source_id=source_id) + self.end_plane_interaction(visual_id, source_id=source_id) - def clipping_interaction_state(self, visual_id: UUID) -> Literal["idle", "active"]: + def plane_interaction_state(self, visual_id: UUID) -> Literal["idle", "active"]: """Whether *visual_id*'s clipping planes are being dragged. Parameters @@ -6988,7 +6996,7 @@ def clipping_interaction_state(self, visual_id: UUID) -> Literal["idle", "active ------- {"idle", "active"} """ - return self._clip_driver.state(visual_id).value + return self._plane_interaction_driver.state(visual_id).value # ------------------------------------------------------------------ # Clipping plane gizmo @@ -8427,7 +8435,7 @@ def remove_visual(self, visual_id: UUID) -> None: # cancel its pick value reads, press and release included. self._outgoing_events.unsubscribe_all(visual_id) self._cancel_visual_pick_reads(visual_id) - self._clip_driver.drop(visual_id) + self._plane_interaction_driver.drop(visual_id) # 4. Remove from controller lookup maps. self._visual_to_scene.pop(visual_id) @@ -9930,7 +9938,7 @@ def close(self) -> None: self._cancel_queued_camera_reslices() self._camera_handoff.clear() self._dims_driver.close() - self._clip_driver.close() + self._plane_interaction_driver.close() self._dims_scrub_pending.clear() self._dims_scrub_axes.clear() for task in self._store_reslice_tasks.values(): @@ -10037,10 +10045,10 @@ def on_clipping_plane_gizmo_changed( weak=weak, ) - def on_clipping_interaction( + def on_plane_interaction( self, visual_id: UUID, - callback: Callable[[ClippingInteractionEvent], None], + callback: Callable[[PlaneInteractionEvent], None], *, owner_id: UUID, weak: bool = False, @@ -10052,7 +10060,7 @@ def on_clipping_interaction( visual_id : The visual to watch. callback : - Called with the ``ClippingInteractionEvent`` on each start and + Called with the ``PlaneInteractionEvent`` on each start and end. owner_id : UUID under which this subscription is registered. @@ -10064,7 +10072,7 @@ def on_clipping_interaction( SubscriptionHandle """ return self._outgoing_events.subscribe( - ClippingInteractionEvent, + PlaneInteractionEvent, callback, entity_id=visual_id, owner_id=owner_id, @@ -10305,8 +10313,7 @@ def on_backstop_complete( """Register a callback fired when a multiscale visual's backstop is loaded. From then on the view shows the current slice everywhere, possibly - blurry, while finer data keeps loading. Fired once per plan that - has a backstop (``render_config.loading.backstop``). + blurry, while finer data keeps loading. Fired once per plan. Parameters ---------- diff --git a/src/cellier/convenience/_ortho_clipping.py b/src/cellier/convenience/_ortho_clipping.py index fb0b34d5..126ca726 100644 --- a/src/cellier/convenience/_ortho_clipping.py +++ b/src/cellier/convenience/_ortho_clipping.py @@ -15,8 +15,8 @@ from cellier.controller import CellierController from cellier.events import ( - ClippingInteractionEvent, ClippingPlanesChangedEvent, + PlaneInteractionEvent, VisualRemovedEvent, ) @@ -227,7 +227,7 @@ def add_group( controller.on_clipping_planes_changed( visual_id, self._on_planes, owner_id=owner_id ) - controller.on_clipping_interaction( + controller.on_plane_interaction( visual_id, self._on_interaction, owner_id=owner_id ) controller.on_visual_removed(visual_id, self._on_removed, owner_id=owner_id) @@ -294,7 +294,7 @@ def _on_planes(self, event: ClippingPlanesChangedEvent) -> None: # Drag forwarding # ------------------------------------------------------------------ - def _on_interaction(self, event: ClippingInteractionEvent) -> None: + def _on_interaction(self, event: PlaneInteractionEvent) -> None: """Hold a scope on the linked visuals for as long as one is dragged.""" origin = event.visual_id if event.phase == "end": @@ -313,14 +313,14 @@ def _on_interaction(self, event: ClippingInteractionEvent) -> None: return self._forwarded[origin] = others for other in others: - self._controller.begin_clipping_interaction( + self._controller.begin_plane_interaction( other, source_id=self._scope_ids[origin] ) def _end_forwarded(self, origin: UUID) -> None: source_id = self._scope_ids.get(origin) for other in self._forwarded.pop(origin, ()): - self._controller.end_clipping_interaction(other, source_id=source_id) + self._controller.end_plane_interaction(other, source_id=source_id) # ------------------------------------------------------------------ # Removal diff --git a/src/cellier/convenience/_ortho_viewer.py b/src/cellier/convenience/_ortho_viewer.py index 1e977bc6..3ec4dc42 100644 --- a/src/cellier/convenience/_ortho_viewer.py +++ b/src/cellier/convenience/_ortho_viewer.py @@ -1944,7 +1944,7 @@ def set_loading(self, visual: object, **fields: Any) -> ProgressiveLoadingConfig visual : UUID, visual model, or dict Any panel's multiscale visual; see :meth:`image_group`. **fields : - ``ProgressiveLoadingConfig`` fields, e.g. ``dims_drag="backstop"``. + ``ProgressiveLoadingConfig`` fields, e.g. ``backstop_extent="view"``. Returns ------- diff --git a/src/cellier/convenience/_viewer.py b/src/cellier/convenience/_viewer.py index fe091938..431e895b 100644 --- a/src/cellier/convenience/_viewer.py +++ b/src/cellier/convenience/_viewer.py @@ -627,7 +627,7 @@ def set_loading(self, visual: object, **fields: Any) -> ProgressiveLoadingConfig visual : visual model or UUID A multiscale image or labels visual. **fields : - ``ProgressiveLoadingConfig`` fields, e.g. ``dims_drag="backstop"``. + ``ProgressiveLoadingConfig`` fields, e.g. ``backstop_extent="view"``. Returns ------- @@ -1052,8 +1052,8 @@ def set_slice_positions( By default a **jump**: every visual loads in full at once. With ``interactive=True``, or inside :meth:`dims_interaction`, it is a - tick of a **scrub**: visuals in ``dims_drag="backstop"`` mode load - their coarse backstop only, and load in full when the scrub ends. + tick of a **scrub**: visuals with ``coarsest_while_moving`` on for the + view load their coarse backstop only, and load in full when the scrub ends. See ``CellierController.update_slice_indices``. Parameters diff --git a/src/cellier/convenience/gui/_appearance_widgets.py b/src/cellier/convenience/gui/_appearance_widgets.py index 967822c6..7407ef9a 100644 --- a/src/cellier/convenience/gui/_appearance_widgets.py +++ b/src/cellier/convenience/gui/_appearance_widgets.py @@ -195,7 +195,7 @@ def _any_trail(spec: ControlSpec, visual_ids, controller=None): ANYWIDGET_BUILDERS = { "image": _any_image, - "lod_bias": _any_lod_bias, + "settled_lod_bias": _any_lod_bias, "trail": _any_trail, "aabb": _any_aabb, "loading": _any_loading, diff --git a/src/cellier/convenience/gui/_appearance_widgets_qt.py b/src/cellier/convenience/gui/_appearance_widgets_qt.py index 7911189c..2d1bef0e 100644 --- a/src/cellier/convenience/gui/_appearance_widgets_qt.py +++ b/src/cellier/convenience/gui/_appearance_widgets_qt.py @@ -204,7 +204,7 @@ def _qt_trail(spec, visual_ids, controller): QT_BUILDERS = { "image": _qt_image, - "lod_bias": _qt_lod_bias, + "settled_lod_bias": _qt_lod_bias, "trail": _qt_trail, "aabb": _qt_aabb, "loading": _qt_loading, diff --git a/src/cellier/convenience/gui/_controls_config.py b/src/cellier/convenience/gui/_controls_config.py index 15397f83..d91613ce 100644 --- a/src/cellier/convenience/gui/_controls_config.py +++ b/src/cellier/convenience/gui/_controls_config.py @@ -26,7 +26,7 @@ "render_mode", "iso_threshold", "attenuation", - "lod_bias", + "settled_lod_bias", # Labels ("render_mode" is shared with the image models, and means a # different set of values there -- see BaseControlsConfig.APPEARANCE_CONTROLS) "salt", @@ -279,7 +279,7 @@ class MultiscaleImageControlsConfig(InMemoryImageControlsConfig): appearance : list[str] or False Appearance fields in display order, e.g. ``["color_map", "clim", "render_mode", "iso_threshold", - "attenuation", "lod_bias"]``. + "attenuation", "settled_lod_bias"]``. colormap_names : list[str] or None Names available in the colormap dropdown. clim_range : tuple[float, float] or None @@ -306,7 +306,7 @@ class MultiscaleImageControlsConfig(InMemoryImageControlsConfig): APPEARANCE_CONTROLS: ClassVar[dict[str, str]] = { **InMemoryImageControlsConfig.APPEARANCE_CONTROLS, "attenuation": "image", - "lod_bias": "lod_bias", + "settled_lod_bias": "settled_lod_bias", } loading_indicator: bool = True @@ -349,7 +349,7 @@ class LabelsControlsConfig(BaseControlsConfig): class MultiscaleLabelsControlsConfig(LabelsControlsConfig): """Controls configuration for multiscale label visuals. - Adds ``lod_bias``, mirroring the image pair. + Adds ``settled_lod_bias``, mirroring the image pair. Parameters ---------- @@ -365,7 +365,7 @@ class MultiscaleLabelsControlsConfig(LabelsControlsConfig): APPEARANCE_CONTROLS: ClassVar[dict[str, str]] = { **LabelsControlsConfig.APPEARANCE_CONTROLS, - "lod_bias": "lod_bias", + "settled_lod_bias": "settled_lod_bias", } loading_indicator: bool = True diff --git a/src/cellier/convenience/layout/_shared.py b/src/cellier/convenience/layout/_shared.py index 6050de99..0511f7dc 100644 --- a/src/cellier/convenience/layout/_shared.py +++ b/src/cellier/convenience/layout/_shared.py @@ -35,7 +35,7 @@ class ControlSpec: ---------- kind : str Which control to build: ``color_map``, ``clim``, ``render``, - ``lod_bias``, ``aabb``, ``loading``, ``loading_config``, + ``settled_lod_bias``, ``aabb``, ``loading``, ``loading_config``, ``mesh_section``, ``clipping_planes``, ``lod_config`` or ``dataset_info``. A renderer with no builder for a kind skips it. @@ -78,7 +78,7 @@ class AppearanceSpecs(NamedTuple): _CONTROL_TITLES = { "image": "Image", - "lod_bias": "LOD bias", + "settled_lod_bias": "LOD bias", "aabb": "Bounding box", "trail": "Trail", "loading": LOADING_TITLE, @@ -228,7 +228,7 @@ def appearance_specs( # A field is honoured when the config class has a control for it *and* the # visual's model actually carries it. Both halves matter: a config can be # paired with a visual whose model is narrower (a multiscale config on an - # in-memory image has no ``lod_bias`` to drive). + # in-memory image has no ``settled_lod_bias`` to drive). # An image's mode-dependent fields live on ``single`` (unified image # design 3.1), not on the shared appearance. single = getattr(visual, "single", None) @@ -264,7 +264,9 @@ def _image_values(): values_for = { "image": _image_values, - "lod_bias": lambda: {"initial_lod_bias": float(getattr(app, "lod_bias", 1.0))}, + "settled_lod_bias": lambda: { + "initial_lod_bias": float(getattr(app, "settled_lod_bias", 1.0)) + }, } specs: list[ControlSpec] = [] diff --git a/src/cellier/events/__init__.py b/src/cellier/events/__init__.py index 7f8c6d9e..3713e46f 100644 --- a/src/cellier/events/__init__.py +++ b/src/cellier/events/__init__.py @@ -22,7 +22,6 @@ CanvasSizeChangedEvent, CellierEventTypes, ChannelAppearanceChangedEvent, - ClippingInteractionEvent, ClippingPlaneGizmoChangedEvent, ClippingPlanesChangedEvent, DataStoreContentsChangedEvent, @@ -50,6 +49,7 @@ PickEvent, PickWriteChangedEvent, PlaneGizmoMovedEvent, + PlaneInteractionEvent, PointsPickEvent, PointsPickInfo, RenderConfigChangedEvent, @@ -119,7 +119,6 @@ "CellierUpdateEventTypes", "ChannelAppearanceChangedEvent", "ChannelAppearanceUpdateEvent", - "ClippingInteractionEvent", "ClippingPlaneGizmoChangedEvent", "ClippingPlaneGizmoUpdateEvent", "ClippingPlanesChangedEvent", @@ -158,6 +157,7 @@ "PickEvent", "PickWriteChangedEvent", "PlaneGizmoMovedEvent", + "PlaneInteractionEvent", "PointsPickEvent", "PointsPickInfo", "RenderConfigChangedEvent", diff --git a/src/cellier/events/_bus.py b/src/cellier/events/_bus.py index eda38481..6b5c0f98 100644 --- a/src/cellier/events/_bus.py +++ b/src/cellier/events/_bus.py @@ -24,7 +24,6 @@ CanvasMouseRelease2DEvent, CanvasMouseRelease3DEvent, ChannelAppearanceChangedEvent, - ClippingInteractionEvent, ClippingPlaneGizmoChangedEvent, ClippingPlanesChangedEvent, DataStoreContentsChangedEvent, @@ -44,6 +43,7 @@ OverlayChangedEvent, PickWriteChangedEvent, PlaneGizmoMovedEvent, + PlaneInteractionEvent, PointsPickEvent, ResliceCancelledEvent, ResliceCompletedEvent, @@ -86,7 +86,7 @@ LoadingConfigChangedEvent: "visual_id", LodConfigChangedEvent: "visual_id", ClippingPlanesChangedEvent: "visual_id", - ClippingInteractionEvent: "visual_id", + PlaneInteractionEvent: "visual_id", ClippingPlaneGizmoChangedEvent: "canvas_id", PlaneGizmoMovedEvent: "gizmo_id", ResliceCancelledEvent: "visual_id", @@ -523,7 +523,7 @@ def get_subscribers( ``LoadingConfigChangedEvent``, ``LodConfigChangedEvent``, ``ClippingPlanesChangedEvent``, - ``ClippingInteractionEvent``, + ``PlaneInteractionEvent``, ``ResliceCancelledEvent`` - Canvas (keyed by ``canvas_id``) — ``FrameRenderedEvent``, ``CanvasConnectedEvent``, diff --git a/src/cellier/events/_events.py b/src/cellier/events/_events.py index 3cb00220..7d74bae4 100644 --- a/src/cellier/events/_events.py +++ b/src/cellier/events/_events.py @@ -344,7 +344,7 @@ class LoadingProgress(NamedTuple): Every wanted chunk is resident or given up. target_deferred : bool The latest plan was the backstop only: a tick of a dims scrub with - ``loading.dims_drag="backstop"``. The target is planned once the + ``coarsest_while_moving`` on for the view. The target is planned once the scrub ends, so ``complete`` here does not mean full detail. """ @@ -485,11 +485,12 @@ class ClippingPlanesChangedEvent(NamedTuple): clipping_planes: Any -class ClippingInteractionEvent(NamedTuple): - """A clipping plane drag started or ended on a visual. +class PlaneInteractionEvent(NamedTuple): + """A drag of a visual's planes started or ended. - A drag is a run of ``clipping_planes`` changes made inside - ``CellierController.clipping_interaction`` (a gizmo opens one for the + One tracker per visual serves its planes; today those are its clipping + planes. A drag is a run of ``clipping_planes`` changes made inside + ``CellierController.plane_interaction`` (a gizmo opens one for the length of a drag). The start is emitted with the first change, ahead of its ``ClippingPlanesChangedEvent``. No event is emitted per change. @@ -1569,7 +1570,7 @@ class _CanvasRawPointerEvent(NamedTuple): | LoadingConfigChangedEvent | LodConfigChangedEvent | ClippingPlanesChangedEvent - | ClippingInteractionEvent + | PlaneInteractionEvent | ClippingPlaneGizmoChangedEvent | PlaneGizmoMovedEvent | ResliceCancelledEvent diff --git a/src/cellier/events/_update_events.py b/src/cellier/events/_update_events.py index f9cbd552..3d88a820 100644 --- a/src/cellier/events/_update_events.py +++ b/src/cellier/events/_update_events.py @@ -330,8 +330,7 @@ class LoadingConfigUpdateEvent(NamedTuple): Only multiscale image and labels visuals load progressively (``render_config.loading``). The controller merges the field into the visual's current config and validates the result, so an invalid - combination (``dims_drag="backstop"`` with ``backstop=False``) raises - rather than being corrected. + value (``backstop_level=0``) raises rather than being corrected. Fields ------ @@ -341,7 +340,7 @@ class LoadingConfigUpdateEvent(NamedTuple): visual_id : Target visual. field : - A ``ProgressiveLoadingConfig`` field name, e.g. ``"dims_drag"``. + A ``ProgressiveLoadingConfig`` field name, e.g. ``"backstop_level"``. value : New value for the field. """ diff --git a/src/cellier/gui/_appearance_fields.py b/src/cellier/gui/_appearance_fields.py index 0dd1e26c..f7403939 100644 --- a/src/cellier/gui/_appearance_fields.py +++ b/src/cellier/gui/_appearance_fields.py @@ -347,7 +347,7 @@ def as_rgba(value: Any) -> tuple[float, float, float, float]: ``test_the_shared_widget_table_agrees_with_the_field_classes``. Not listed: the multi-field image controls (``color_map``, ``clim``, -``render``, ``lod_bias``) and ``aabb``/``dataset_info``, which predate this +``render``, ``settled_lod_bias``) and ``aabb``/``dataset_info``, which predate this design and keep their bespoke builders. """ diff --git a/src/cellier/gui/_image_controls.py b/src/cellier/gui/_image_controls.py index 3e5d4ab1..344e8bd3 100644 --- a/src/cellier/gui/_image_controls.py +++ b/src/cellier/gui/_image_controls.py @@ -338,7 +338,7 @@ def image_control_values( from cellier.gui._appearance_fields import literal_choices appearance = visual.appearance - multiscale = hasattr(appearance, "lod_bias") + multiscale = hasattr(appearance, "settled_lod_bias") shared = { "visible": bool(appearance.visible), "transparency_mode": to_widget_value( diff --git a/src/cellier/gui/_loading.py b/src/cellier/gui/_loading.py index b0861d49..f0b47681 100644 --- a/src/cellier/gui/_loading.py +++ b/src/cellier/gui/_loading.py @@ -82,7 +82,8 @@ def indicator_state( - then the target: ``"Detail: 12 / 40"``; - done: ``"Loaded"``, or ``"Loaded, 2 failed"``; - done, but the plan was the backstop only (a slider drag with - ``dims_drag="backstop"``): ``"Overview ready. Detail on stop."``. + ``coarsest_while_moving`` on for the view): + ``"Overview ready. Detail on stop."``. Chunks the plan dropped to fit the cache (``truncated_target``) are reported after either, since they will never load at this cache size. @@ -190,11 +191,9 @@ def on_progress(self, event: ResliceProgressEvent) -> None: #: The row label of each ``ProgressiveLoadingConfig`` field, in display order. _LOADING_CONFIG_LABELS: dict[str, str] = { - "backstop": "Backstop", "backstop_level": "Backstop level", "backstop_extent": "Backstop extent", "backstop_max_slot_fraction": "Backstop max slots", - "dims_drag": "Dims drag", } #: Shown for ``backstop_level=None``: the coarsest level. @@ -289,8 +288,6 @@ def from_control_value(field: str, value: Any) -> Any: return None if int(value) == 0 else int(value) if field == "backstop_max_slot_fraction": return float(value) - if field == "backstop": - return bool(value) return value diff --git a/src/cellier/gui/anywidget/visuals/_lod_bias.py b/src/cellier/gui/anywidget/visuals/_lod_bias.py index ab6094da..60e85abf 100644 --- a/src/cellier/gui/anywidget/visuals/_lod_bias.py +++ b/src/cellier/gui/anywidget/visuals/_lod_bias.py @@ -28,7 +28,7 @@ class AnywidgetLodBiasSlider(VisualIdGroup, anywidget.AnyWidget): """Single-value LOD-bias slider wired to the cellier v2 bus. - Mirrors ``QtLodBiasSlider``. Because changing ``lod_bias`` triggers a + Mirrors ``QtLodBiasSlider``. Because changing ``settled_lod_bias`` triggers a reslice, the JS emits only on settled ``change`` (not on every drag ``input``), so only one reslice fires per drag interaction. @@ -42,11 +42,11 @@ class AnywidgetLodBiasSlider(VisualIdGroup, anywidget.AnyWidget): Parameters ---------- visual_id : - UUID of the visual whose ``lod_bias`` field this widget controls. + UUID of the visual whose ``settled_lod_bias`` field this widget controls. A sequence drives every listed visual in lock-step -- the ``OrthoViewer``'s four panel siblings (design section 8.1). initial_lod_bias : - Starting value -- typically ``visual_model.appearance.lod_bias``. + Starting value -- typically ``visual_model.appearance.settled_lod_bias``. """ _esm = _STATIC / "lod_bias.js" @@ -72,7 +72,7 @@ class AnywidgetLodBiasSlider(VisualIdGroup, anywidget.AnyWidget): stack controls and stop. """ - lod_bias = traitlets.Float(1.0).tag(sync=True) + settled_lod_bias = traitlets.Float(1.0).tag(sync=True) def __init__( self, @@ -81,11 +81,11 @@ def __init__( initial_lod_bias: float = 1.0, **kwargs, ) -> None: - super().__init__(lod_bias=float(initial_lod_bias), **kwargs) + super().__init__(settled_lod_bias=float(initial_lod_bias), **kwargs) self._id = uuid4() self._init_visual_ids(visual_id) self._applying = False - self.observe(self._on_trait_change, names="lod_bias") + self.observe(self._on_trait_change, names="settled_lod_bias") # ── Public interface ───────────────────────────────────────────────────── @@ -116,9 +116,9 @@ def subscription_specs(self) -> list[SubscriptionSpec]: def _on_appearance_changed(self, event: AppearanceChangedEvent) -> None: if event.source_id == self._id: return # echo from our own change; ignore - if event.field_name != "lod_bias": + if event.field_name != "settled_lod_bias": return - self._set_field("lod_bias", event.new_value) + self._set_field("settled_lod_bias", event.new_value) def _set_field(self, name: str, value) -> None: self._applying = True @@ -132,4 +132,4 @@ def _set_field(self, name: str, value) -> None: def _on_trait_change(self, change) -> None: if self._applying: return - self._emit_group(AppearanceUpdateEvent, "lod_bias", change["new"]) + self._emit_group(AppearanceUpdateEvent, "settled_lod_bias", change["new"]) diff --git a/src/cellier/gui/anywidget/visuals/static/lod_bias.js b/src/cellier/gui/anywidget/visuals/static/lod_bias.js index 7d26033d..099f3c21 100644 --- a/src/cellier/gui/anywidget/visuals/static/lod_bias.js +++ b/src/cellier/gui/anywidget/visuals/static/lod_bias.js @@ -21,7 +21,7 @@ function render({ model, el }) { label.textContent = model.get("title") || "LOD bias"; }); - const initVal = model.get("lod_bias") ?? 1.0; + const initVal = model.get("settled_lod_bias") ?? 1.0; const inp = document.createElement("input"); inp.type = "range"; inp.min = 0.001; @@ -36,7 +36,7 @@ function render({ model, el }) { inp.addEventListener("change", () => { if (guard) return; readout.textContent = Number(inp.value).toFixed(2); - model.set("lod_bias", parseFloat(inp.value)); + model.set("settled_lod_bias", parseFloat(inp.value)); model.save_changes(); }); // Live readout while dragging, without emitting to the bus. @@ -44,10 +44,10 @@ function render({ model, el }) { readout.textContent = Number(inp.value).toFixed(2); }); - model.on("change:lod_bias", () => { + model.on("change:settled_lod_bias", () => { guard = true; try { - const v = model.get("lod_bias") ?? 1.0; + const v = model.get("settled_lod_bias") ?? 1.0; inp.value = v; readout.textContent = Number(v).toFixed(2); } finally { diff --git a/src/cellier/gui/qt/visuals/_loading_config.py b/src/cellier/gui/qt/visuals/_loading_config.py index a37e21bf..2edbb4dd 100644 --- a/src/cellier/gui/qt/visuals/_loading_config.py +++ b/src/cellier/gui/qt/visuals/_loading_config.py @@ -26,11 +26,10 @@ class QtLoadingConfigControls(VisualIdGroup): """Every ``ProgressiveLoadingConfig`` field of a multiscale visual. - One row per field: the backstop on or off, its level ("coarsest" for - ``None``), its extent and slot cap, and what a dims slider tick loads. + One row per field: the backstop's level ("coarsest" for ``None``), its + extent and its slot cap. An edit is sent as ``LoadingConfigUpdateEvent`` and applied at once. - An invalid combination (``dims_drag="backstop"`` with the backstop off) - is refused by the controller: the rows show the settings again and the + An invalid value is refused by the controller: the rows show the settings again and the reason below them; nothing is corrected. Wire to the controller after construction:: diff --git a/src/cellier/gui/qt/visuals/_lod_bias.py b/src/cellier/gui/qt/visuals/_lod_bias.py index 80d1b091..7e6e8bd0 100644 --- a/src/cellier/gui/qt/visuals/_lod_bias.py +++ b/src/cellier/gui/qt/visuals/_lod_bias.py @@ -23,10 +23,10 @@ class QtLodBiasSlider(VisualIdGroup): """Single-value LOD-bias slider wired to the cellier v2 bus. Wraps a ``superqt.QLabeledDoubleSlider`` and keeps it in sync with - ``MultiscaleImageAppearance.lod_bias`` (or the equivalent labels field) + ``MultiscaleImageAppearance.settled_lod_bias`` (or the equivalent labels field) via ``AppearanceChangedEvent``. - Because changing ``lod_bias`` triggers a reslice, the ``AppearanceUpdateEvent`` + Because changing ``settled_lod_bias`` triggers a reslice, the ``AppearanceUpdateEvent`` is emitted on ``sliderReleased`` rather than on every ``valueChanged`` tick, so only one reslice fires per drag interaction. @@ -40,11 +40,11 @@ class QtLodBiasSlider(VisualIdGroup): Parameters ---------- visual_id : - UUID of the visual whose ``lod_bias`` field this widget controls. + UUID of the visual whose ``settled_lod_bias`` field this widget controls. A sequence drives every listed visual in lock-step -- the ``OrthoViewer``'s four panel siblings (design section 8.1). initial_lod_bias : - Starting value — typically ``visual_model.appearance.lod_bias``. + Starting value — typically ``visual_model.appearance.settled_lod_bias``. lod_range : ``(min, max)`` for the slider range. Defaults to ``(1e-6, 5.0)``. title : @@ -128,14 +128,16 @@ def subscription_specs(self) -> list[SubscriptionSpec]: def _on_visual_changed(self, event) -> None: if event.source_id == self._id: return - if event.field_name != "lod_bias": + if event.field_name != "settled_lod_bias": return self._set_value(event.new_value) # ── Cellier layer: widget → model ──────────────────────────────────────── def _on_slider_released(self) -> None: - self._emit_group(AppearanceUpdateEvent, "lod_bias", self._slider.value()) + self._emit_group( + AppearanceUpdateEvent, "settled_lod_bias", self._slider.value() + ) # ── Qt seam: push value without re-firing signals ──────────────────────── diff --git a/src/cellier/render/_config.py b/src/cellier/render/_config.py index f4d7b22f..38876bfc 100644 --- a/src/cellier/render/_config.py +++ b/src/cellier/render/_config.py @@ -56,8 +56,9 @@ class SchedulerConfig(BaseModel): not drawing, or a slow client). dims_settle_s : float The dims tracker's stillness time: seconds without a slice-position - change after which a dims scrub ends, and visuals in - ``dims_drag="backstop"`` mode load their target. A scrub also ends, + change after which a dims scrub ends, and visuals planned coarse + while moving (``coarsest_while_moving_3d`` or ``_2d``) load their + target. A scrub also ends, sooner, when its slider is released. store_change_max_hz : float Most reslices per second a changing store triggers. A store that diff --git a/src/cellier/render/_level_of_detail.py b/src/cellier/render/_level_of_detail.py index 53b45b52..afb9ce8e 100644 --- a/src/cellier/render/_level_of_detail.py +++ b/src/cellier/render/_level_of_detail.py @@ -86,6 +86,8 @@ def build_level_grids( World-space ``(x, y, z)`` centre of each coarse brick. ``half_extents`` : ndarray, shape (3,), dtype float64 Half-brick-width per axis in world space ``(x, y, z)``. + ``centre_abs_max`` : ndarray, shape (3,), dtype float64 + Largest absolute centre coordinate per axis, for ``cull_mask``. """ bs = base_layout.block_size gd, gh, gw = base_layout.grid_dims @@ -126,11 +128,92 @@ def build_level_grids( centres = brick_centre_data(np.stack([gx_c, gy_c, gz_c], axis=1), bs, sv, tv) half_extents = (bs * sv / 2.0).astype(np.float64) - grids.append({"arr": arr, "centres": centres, "half_extents": half_extents}) + grids.append( + { + "arr": arr, + "centres": centres, + "half_extents": half_extents, + "centre_abs_max": np.abs(centres).max(axis=0), + } + ) return grids +# --------------------------------------------------------------------------- +# Cull first: drop the bricks no half-space keeps before ranking them +# --------------------------------------------------------------------------- + + +def _reaches_rows(grid: dict, rows: np.ndarray) -> np.ndarray: + """Mask of a level's bricks whose box reaches the kept side of every row. + + A box reaches ``n . p + d >= 0`` when its farthest corner along ``n`` + does: ``n . centre + d + |n| . half_extents >= 0``. The test is on the + cached centres, so it carries a small slack and never drops a brick the + corner test of ``bricks_in_frustum_arr`` keeps. + """ + rows = np.asarray(rows, dtype=np.float64) + normals = rows[:, :3] + abs_normals = np.abs(normals) + reach = abs_normals @ grid["half_extents"] + signed = grid["centres"] @ normals.T + rows[:, 3] + magnitude = abs_normals @ grid["centre_abs_max"] + reach + np.abs(rows[:, 3]) + return (signed + reach >= -1e-9 * magnitude).all(axis=1) + + +def cull_mask( + grid: dict, + clip_rows: np.ndarray | None = None, + plane_row_sets: list[np.ndarray] | None = None, +) -> np.ndarray | None: + """Mask of one level's bricks worth ranking, or ``None`` to keep all. + + Run before level selection so a clipped visual ranks and sorts only the + bricks it can draw. The mask is conservative: the exact half-space test + still runs on what is left. + + Parameters + ---------- + grid : dict + One level of ``build_level_grids``. + clip_rows : ndarray, shape (N, 4), or None + Half-spaces ``n . p + d >= 0`` in the space of ``grid["centres"]``. + A brick is kept when its box reaches the kept side of every row. + plane_row_sets : list of ndarray, shape (N_i, 4), or None + One set of rows per plane. A brick is kept when it reaches every + row of at least one set: a union, which a row list cannot express. + + Returns + ------- + keep : ndarray of bool, shape (M_k,), or None + ``None`` with nothing to cull by. + """ + if clip_rows is None and plane_row_sets is None: + return None + if clip_rows is None: + keep = np.ones(len(grid["arr"]), dtype=bool) + else: + keep = _reaches_rows(grid, clip_rows) + if plane_row_sets is not None: + on_a_plane = np.zeros(len(keep), dtype=bool) + for rows in plane_row_sets: + on_a_plane |= _reaches_rows(grid, rows) + keep &= on_a_plane + return keep + + +def cull_masks( + level_grids: list[dict], + clip_rows: np.ndarray | None = None, + plane_row_sets: list[np.ndarray] | None = None, +) -> list[np.ndarray] | None: + """``cull_mask`` for every level, or ``None`` with nothing to cull by.""" + if clip_rows is None and plane_row_sets is None: + return None + return [cull_mask(grid, clip_rows, plane_row_sets) for grid in level_grids] + + # --------------------------------------------------------------------------- # Hot path: per-update LOD selection using the precomputed cache # --------------------------------------------------------------------------- @@ -142,6 +225,7 @@ def select_levels_from_cache( camera_pos: np.ndarray, thresholds: list[float] | None = None, base_layout: BlockLayout3D | None = None, + keep: list[np.ndarray] | None = None, ) -> np.ndarray: """Select LOD levels using precomputed coarse grid data. @@ -174,6 +258,9 @@ def select_levels_from_cache( diagonal (measured from ``level_grids``), so they honour anisotropic scale/translation. When None, ``thresholds`` stays empty and all bricks fall to the finest level. + keep : list of ndarray of bool, or None + Output of ``cull_masks``: per level, the bricks to rank. The result + is the unculled result with the other rows removed, in order. Returns ------- @@ -203,7 +290,8 @@ def select_levels_from_cache( # ``n_levels >= 2`` case, where the per-level loop below would otherwise # index an empty threshold list; it also handles an explicit ``[]``. if not thresholds: - return level_grids[0]["arr"] + arr0 = level_grids[0]["arr"] + return arr0 if keep is None else arr0[keep[0]] parts: list[np.ndarray] = [] @@ -211,6 +299,12 @@ def select_levels_from_cache( grid = level_grids[level - 1] centres = grid["centres"] # (M_k, 3) — precomputed, no alloc arr_k = grid["arr"] # (M_k, 4) + if keep is not None: + kept = keep[level - 1] + if not kept.any(): + continue + centres = centres[kept] + arr_k = arr_k[kept] diff = centres - cam dist = np.sqrt((diff * diff).sum(axis=1)) @@ -317,6 +411,7 @@ def select_levels_arr_forced( base_layout: BlockLayout3D, force_level: int, level_grids: list[dict] | None = None, + keep: list[np.ndarray] | None = None, ) -> np.ndarray: """Return the full coarse grid for a forced single LOD level. @@ -339,12 +434,16 @@ def select_levels_arr_forced( level_grids : list[dict] or None If provided, returns ``level_grids[level - 1]["arr"]`` directly (zero-copy view). + keep : list of ndarray of bool, or None + Output of ``cull_masks``; needs ``level_grids``. Only the kept rows + of the level are returned. """ level = max(force_level, 1) if level_grids is not None: level = min(level, len(level_grids)) - return level_grids[level - 1]["arr"] + arr = level_grids[level - 1]["arr"] + return arr if keep is None else arr[keep[level - 1]] # The uncached branch has no ``n_levels`` to clamp against, so only the # lower bound applies here. diff --git a/src/cellier/render/_scene_config.py b/src/cellier/render/_scene_config.py index cdb5e4a7..f573bdea 100644 --- a/src/cellier/render/_scene_config.py +++ b/src/cellier/render/_scene_config.py @@ -18,11 +18,10 @@ class VisualRenderConfig: Parameters ---------- - lod_bias : float - Multiplier applied to LOD distance thresholds. Values greater - than 1.0 favour finer (higher-resolution) levels at a given - camera distance; values less than 1.0 favour coarser levels. - Default ``1.0`` (no bias). + settled_lod_bias : float + Level-of-detail bias of a settled plan: a divisor on the LOD + thresholds, so values greater than 1.0 favour coarser levels and + values less than 1.0 finer ones. Default ``1.0`` (no bias). force_level : int or None When set, all bricks are assigned this 1-based LOD level, bypassing distance-based selection entirely. ``None`` restores @@ -41,10 +40,11 @@ class VisualRenderConfig: plan_mode : PlanMode What a multiscale visual plans this reslice: ``FULL`` (default), or ``BACKSTOP_ONLY`` for any reslice while the scene's dims are being - scrubbed, when the visual opted in (``dims_drag="backstop"``). + scrubbed, when the visual opted in (its ``coarsest_while_moving`` + setting for the view, or a mesh's ``lod.dims_drag="coarse"``). """ - lod_bias: float = 1.0 + settled_lod_bias: float = 1.0 force_level: int | None = None frustum_cull: bool = True slicing_enabled: bool = True diff --git a/src/cellier/render/scene_manager.py b/src/cellier/render/scene_manager.py index 1cb539f5..c0e9530c 100644 --- a/src/cellier/render/scene_manager.py +++ b/src/cellier/render/scene_manager.py @@ -483,7 +483,7 @@ def _build_slice_requests_3d( frustum_corners_world=frustum_corners_world, fov_y_rad=request.fov_y_rad, screen_height_px=screen_height_px, - lod_bias=cfg.lod_bias, + lod_bias=cfg.settled_lod_bias, dims_state=request.dims_state, force_level=cfg.force_level, selection=request.selection, @@ -537,7 +537,7 @@ def _build_slice_requests_2d( view_min_world=view_min if cfg.frustum_cull else None, view_max_world=view_max if cfg.frustum_cull else None, dims_state=request.dims_state, - lod_bias=cfg.lod_bias, + lod_bias=cfg.settled_lod_bias, force_level=cfg.force_level, use_culling=cfg.frustum_cull, selection=request.selection, diff --git a/src/cellier/render/visuals/_chunked.py b/src/cellier/render/visuals/_chunked.py index 341b986c..e08493f1 100644 --- a/src/cellier/render/visuals/_chunked.py +++ b/src/cellier/render/visuals/_chunked.py @@ -155,7 +155,7 @@ def desired_bricks( ``(N, 4)`` rows ``[level, g0, g1, g2]`` (3D) or ``(N, 3)`` rows ``[level, g0, g1]`` (2D), each in load order (nearest the camera or canvas centre first). ``None`` is empty: no target - (``PlanMode.BACKSTOP_ONLY``), or the backstop is off. + (``PlanMode.BACKSTOP_ONLY``), or no geometry to plan a backstop on. backstop_cap : int At most this many backstop keys (``backstop_max_slot_fraction``). fill : dict[int, int] or None @@ -210,8 +210,8 @@ def backstop_cap_for( loading: ProgressiveLoadingConfig | None, residency: ImageResidency3D | ImageResidency2D, ) -> int: - """The backstop's slot cap on *residency*'s atlas; 0 when it is off.""" - if loading is None or not loading.backstop: + """The backstop's slot cap on *residency*'s atlas; 0 with no *loading*.""" + if loading is None: return 0 return backstop_cap(loading, residency.n_slots) diff --git a/src/cellier/render/visuals/_image.py b/src/cellier/render/visuals/_image.py index 9775e06b..5bbf1d04 100644 --- a/src/cellier/render/visuals/_image.py +++ b/src/cellier/render/visuals/_image.py @@ -25,6 +25,7 @@ from cellier.render._level_mapping import base_cell_range from cellier.render._level_of_detail import ( build_level_grids, + cull_masks, orthographic_voxels_per_pixel, select_level_orthographic, select_levels_arr_forced, @@ -1262,11 +1263,11 @@ def _plan_backstop_3d( ) -> np.ndarray | None: """The backstop bricks for this view, nearest first (design 5.9). - ``None`` when the backstop is off. Extent ``"view"`` culls to the + ``None`` before the 3D geometry exists. Extent ``"view"`` culls to the request's frustum whether or not the target is frustum-culled. """ geo = self._volume_geometry - if not loading.backstop or geo is None: + if geo is None: return None camera_pos_data = self._to_level0_displayed( np.asarray(camera_pos_world).reshape(1, -1) @@ -1296,12 +1297,12 @@ def _plan_backstop_2d( ) -> np.ndarray | None: """The backstop tiles for this view, centre first (design 5.9). - ``None`` when the backstop is off. Extent ``"view"`` culls to the + ``None`` before the 2D geometry exists. Extent ``"view"`` culls to the viewport plus one backstop tile, whether or not the target is viewport-culled. """ geo2d = self._image_geometry_2d - if not loading.backstop or geo2d is None: + if geo2d is None: return None camera_pos, view_min, view_max, _ = self._view_2d( camera_pos_world, world_width, view_min_world, view_max_world @@ -2207,9 +2208,15 @@ def _plan_bricks( geo._level_scale_factors, voxels_per_pixel, lod_bias ) + # Cull first: a clipped visual ranks and sorts only the bricks its + # clipping planes keep. The exact test still runs in the frustum + # cull, so the plan is the same rows in the same order. + clip_rows = self._clip_rows() + keep = cull_masks(geo._level_grids, clip_rows) + if force_level is not None: brick_arr = select_levels_arr_forced( - geo.base_layout, force_level, geo._level_grids + geo.base_layout, force_level, geo._level_grids, keep=keep ) else: brick_arr = select_levels_from_cache( @@ -2218,6 +2225,7 @@ def _plan_bricks( camera_pos_data, thresholds=thresholds, base_layout=geo.base_layout, + keep=keep, ) # 2. Distance sort @@ -2231,7 +2239,6 @@ def _plan_bricks( # 3. Frustum cull, with the clipping planes as further half-spaces. # They apply with no frustum too: a clipped brick is never drawn. - clip_rows = self._clip_rows() if clip_rows is not None: frustum_planes = ( clip_rows @@ -3453,7 +3460,7 @@ def plan( world_width=request.world_extent[0], view_min_world=view_min if config.frustum_cull else None, view_max_world=view_max if config.frustum_cull else None, - lod_bias=config.lod_bias, + lod_bias=config.settled_lod_bias, force_level=config.force_level, use_culling=config.frustum_cull, ) @@ -3478,7 +3485,7 @@ def plan( request.frustum_corners if config.frustum_cull else None, request.fov_y_rad, request.screen_size_px[1], - config.lod_bias, + config.settled_lod_bias, config.force_level, view_height_world=request.world_extent[1], ) diff --git a/src/cellier/render/visuals/_label_multiscale.py b/src/cellier/render/visuals/_label_multiscale.py index cce85770..a85d9344 100644 --- a/src/cellier/render/visuals/_label_multiscale.py +++ b/src/cellier/render/visuals/_label_multiscale.py @@ -17,6 +17,7 @@ from cellier.render._gpu_lifetime import destroy_textures from cellier.render._level_mapping import base_cell_range from cellier.render._level_of_detail import ( + cull_masks, orthographic_voxels_per_pixel, select_level_orthographic, select_levels_arr_forced, @@ -921,9 +922,15 @@ def _plan_bricks_3d( geo._level_scale_factors, voxels_per_pixel, lod_bias ) + # Cull first: a clipped visual ranks and sorts only the bricks its + # clipping planes keep. The exact test still runs in the frustum + # cull, so the plan is the same rows in the same order. + clip_rows = self._clip_rows() + keep = cull_masks(geo._level_grids, clip_rows) + if force_level is not None: brick_arr = select_levels_arr_forced( - geo.base_layout, force_level, geo._level_grids + geo.base_layout, force_level, geo._level_grids, keep=keep ) else: brick_arr = select_levels_from_cache( @@ -932,6 +939,7 @@ def _plan_bricks_3d( camera_pos_data, thresholds=thresholds, base_layout=geo.base_layout, + keep=keep, ) lod_select_ms = (time.perf_counter() - t0) * 1000 @@ -952,7 +960,6 @@ def _plan_bricks_3d( frustum_cull_ms = 0.0 # The clipping planes are further half-spaces, with or without a # frustum: a clipped brick is never drawn (clipping planes 5.1). - clip_rows = self._clip_rows() if clip_rows is not None: frustum_planes = ( clip_rows @@ -1078,7 +1085,7 @@ def plan( request.frustum_corners if config.frustum_cull else None, request.fov_y_rad, request.screen_size_px[1], - lod_bias=config.lod_bias, + lod_bias=config.settled_lod_bias, dims_state=request.dims_state, force_level=config.force_level, selection=request.selection, @@ -1189,7 +1196,7 @@ def _plan_2d( view_min_world=view_min_world if use_culling else None, view_max_world=view_max_world if use_culling else None, dims_state=request.dims_state, - lod_bias=config.lod_bias, + lod_bias=config.settled_lod_bias, force_level=config.force_level, use_culling=use_culling, selection=request.selection, diff --git a/src/cellier/visuals/_base_visual.py b/src/cellier/visuals/_base_visual.py index 361df768..bfa4d4fa 100644 --- a/src/cellier/visuals/_base_visual.py +++ b/src/cellier/visuals/_base_visual.py @@ -191,13 +191,18 @@ def __setattr__(self, name: str, value: object) -> None: value = validate_clipping_planes(value) super().__setattr__(name, value) - @property - def plans_coarse_on_scrub(self) -> bool: + def plans_coarse_while_moving(self, n_displayed_dims: int) -> bool: """Whether an interactive dims tick plans this visual ``BACKSTOP_ONLY``. While the scene's dims are being scrubbed, the controller plans a visual that returns ``True`` coarse only, and plans it in full once when the scrub ends. A subclass derives the answer from its own explicit config; the base answer is ``False``. + + Parameters + ---------- + n_displayed_dims : int + Displayed dimensions of the view being planned (2 or 3): a + visual may have a setting for each. """ return False diff --git a/src/cellier/visuals/_image.py b/src/cellier/visuals/_image.py index ac940c08..2bbe8d69 100644 --- a/src/cellier/visuals/_image.py +++ b/src/cellier/visuals/_image.py @@ -1,8 +1,8 @@ """Multiscale image visual models (unified image design 3.1).""" -from typing import Literal +from typing import Any, Literal -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator from cellier.transform import AffineTransform from cellier.visuals._image_memory import ( @@ -11,6 +11,10 @@ BaseImageVisual, ) from cellier.visuals._loading import ProgressiveLoadingConfig +from cellier.visuals._removed import ( + REMOVED_APPEARANCE_FIELDS, + refuse_removed_fields, +) class MultiscaleImageAppearance(BaseImageAppearance): @@ -22,9 +26,17 @@ class MultiscaleImageAppearance(BaseImageAppearance): ---------- attenuation : float Depth attenuation coefficient for ``"attenuated_mip"``. Default 1.0. - lod_bias : float - Divisor on the screen-space LOD threshold: higher is coarser. - Default 1.0. + settled_lod_bias : float + Level-of-detail bias of a settled view, 2D and 3D. Divisor on the + screen-space LOD threshold: higher is coarser. Default 1.0. + coarsest_while_moving_3d : bool + In a 3D view, plan no target while the visual moves (its scene's + dims are being scrubbed): each tick loads the coarse backstop only, + and the target once the motion ends. ``False`` (the default) plans + in full on every tick. + coarsest_while_moving_2d : bool + The same, for a 2D view. ``True`` saves most of a scrub's reads and + is the setting for a slow store. Default ``False``. force_level : int or None Overrides automatic LOD selection when set. Default None. frustum_cull : bool @@ -38,8 +50,15 @@ class MultiscaleImageAppearance(BaseImageAppearance): ghost border. Default 1.0. """ + @model_validator(mode="before") + @classmethod + def _refuse_removed_fields(cls, data: Any) -> Any: + return refuse_removed_fields(data, REMOVED_APPEARANCE_FIELDS, cls.__name__) + attenuation: float = 1.0 - lod_bias: float = 1.0 + settled_lod_bias: float = 1.0 + coarsest_while_moving_3d: bool = False + coarsest_while_moving_2d: bool = False force_level: int | None = None frustum_cull: bool = True ray_steps_per_voxel: float = Field(default=1.0, ge=0.5, le=8.0) @@ -139,7 +158,12 @@ class MultiscaleImageVisual(BaseImageVisual): ) requires_camera_reslice: bool = Field(default=True, frozen=True) - @property - def plans_coarse_on_scrub(self) -> bool: - """``True`` when ``render_config.loading.dims_drag`` is ``"backstop"``.""" - return self.render_config.loading.dims_drag == "backstop" + def plans_coarse_while_moving(self, n_displayed_dims: int) -> bool: + """The moving setting of the view being planned. + + ``appearance.coarsest_while_moving_3d`` for a 3D view, + ``appearance.coarsest_while_moving_2d`` for a 2D one. + """ + if n_displayed_dims == 3: + return self.appearance.coarsest_while_moving_3d + return self.appearance.coarsest_while_moving_2d diff --git a/src/cellier/visuals/_labels.py b/src/cellier/visuals/_labels.py index 0053d8b0..5de90d4a 100644 --- a/src/cellier/visuals/_labels.py +++ b/src/cellier/visuals/_labels.py @@ -1,12 +1,16 @@ """Visuals for representing multiscale label data.""" -from typing import Literal +from typing import Any, Literal -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator from cellier.transform import AffineTransform from cellier.visuals._label_memory import BaseLabelsAppearance, BaseLabelsVisual from cellier.visuals._loading import ProgressiveLoadingConfig +from cellier.visuals._removed import ( + REMOVED_APPEARANCE_FIELDS, + refuse_removed_fields, +) class MultiscaleLabelsAppearance(BaseLabelsAppearance): @@ -28,8 +32,17 @@ class MultiscaleLabelsAppearance(BaseLabelsAppearance): render_mode : str Overrides ``BaseLabelsAppearance`` to widen the Literal to include ``"gradient_debug"`` and ``"smooth_iso"``. Not frozen — live-mutable. - lod_bias : float - Multiplier on the screen-space LOD threshold. Default 1.0. + settled_lod_bias : float + Level-of-detail bias of a settled view, 2D and 3D. Divisor on the + screen-space LOD threshold: higher is coarser. Default 1.0. + coarsest_while_moving_3d : bool + In a 3D view, plan no target while the visual moves (its scene's + dims are being scrubbed): each tick loads the coarse backstop only, + and the target once the motion ends. ``False`` (the default) plans + in full on every tick. + coarsest_while_moving_2d : bool + The same, for a 2D view. ``True`` saves most of a scrub's reads and + is the setting for a slow store. Default ``False``. force_level : int | None Overrides automatic LOD selection when set. Default None. frustum_cull : bool @@ -41,10 +54,17 @@ class MultiscaleLabelsAppearance(BaseLabelsAppearance): overshoot the brick's ghost border. Default 1.0. """ + @model_validator(mode="before") + @classmethod + def _refuse_removed_fields(cls, data: Any) -> Any: + return refuse_removed_fields(data, REMOVED_APPEARANCE_FIELDS, cls.__name__) + render_mode: Literal[ "iso_categorical", "flat_categorical", "gradient_debug", "smooth_iso" ] = "iso_categorical" - lod_bias: float = 1.0 + settled_lod_bias: float = 1.0 + coarsest_while_moving_3d: bool = False + coarsest_while_moving_2d: bool = False force_level: int | None = None frustum_cull: bool = True ray_steps_per_voxel: float = Field(default=1.0, ge=0.5, le=8.0) @@ -99,7 +119,12 @@ class MultiscaleLabelVisual(BaseLabelsVisual): ) requires_camera_reslice: bool = Field(default=True, frozen=True) - @property - def plans_coarse_on_scrub(self) -> bool: - """``True`` when ``render_config.loading.dims_drag`` is ``"backstop"``.""" - return self.render_config.loading.dims_drag == "backstop" + def plans_coarse_while_moving(self, n_displayed_dims: int) -> bool: + """The moving setting of the view being planned. + + ``appearance.coarsest_while_moving_3d`` for a 3D view, + ``appearance.coarsest_while_moving_2d`` for a 2D one. + """ + if n_displayed_dims == 3: + return self.appearance.coarsest_while_moving_3d + return self.appearance.coarsest_while_moving_2d diff --git a/src/cellier/visuals/_loading.py b/src/cellier/visuals/_loading.py index c73693b8..f7acbb16 100644 --- a/src/cellier/visuals/_loading.py +++ b/src/cellier/visuals/_loading.py @@ -6,25 +6,27 @@ from __future__ import annotations -from typing import Literal +from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field, model_validator +from cellier.visuals._removed import REMOVED_LOADING_FIELDS, refuse_removed_fields + class ProgressiveLoadingConfig(BaseModel): """How a multiscale visual loads: a coarse backstop, then the target. + A coarse *backstop* level is always loaded ahead of the target level, so + the view is blurry rather than blank (or showing the previous slice) + while the target loads. Backstop reads go before every target read, + from every visual. + Every setting is explicit, with a fixed default, and never changed by the library at runtime. Replacing a visual's ``render_config`` with one that differs only here reslices the visual without reallocating its atlases. Parameters ---------- - backstop : bool - Load a coarse *backstop* level ahead of the target level, so the view - is blurry rather than blank (or showing the previous slice) while - the target loads. Backstop reads go before every target read, from - every visual. ``False`` loads the target only. Default ``True``. backstop_level : int or None 1-based level of the backstop, like ``force_level`` (1 is the finest). Clamped to the pyramid. ``None`` (the default) is the @@ -39,36 +41,18 @@ class ProgressiveLoadingConfig(BaseModel): pyramids reach it; when it truncates, one INFO line per visual on ``cellier.render.cache`` names the settings that would avoid it. Default ``0.1``. - dims_drag : {"eager", "backstop"} - What a tick of a dims scrub loads: a slider being moved, or a - programmatic move marked ``interactive=True`` or made inside - ``dims_interaction``. ``"eager"`` (default): the full plan, - so the target starts loading at once; best on local disk. - ``"backstop"``: the backstop only, and the target once the scrub - ends, when the slider is released or has been still for - ``SchedulerConfig.dims_settle_s``; this saves most of - a scrub's reads, and is recommended for remote stores. Both show the - slider's slice (blurry) about one read behind it. Needs - ``backstop=True``. A plain programmatic move is a jump and loads in - full either way. """ model_config = ConfigDict(frozen=True) - backstop: bool = True + @model_validator(mode="before") + @classmethod + def _refuse_removed_fields(cls, data: Any) -> Any: + return refuse_removed_fields(data, REMOVED_LOADING_FIELDS, cls.__name__) + backstop_level: int | None = Field(default=None, ge=1) backstop_extent: Literal["full", "view"] = "full" backstop_max_slot_fraction: float = Field(default=0.1, gt=0, le=0.5) - dims_drag: Literal["eager", "backstop"] = "eager" - - @model_validator(mode="after") - def _drag_needs_a_backstop(self) -> ProgressiveLoadingConfig: - if self.dims_drag == "backstop" and not self.backstop: - raise ValueError( - "dims_drag='backstop' needs backstop=True: a backstop-only " - "slider tick would plan nothing." - ) - return self class GeometryLodConfig(BaseModel): diff --git a/src/cellier/visuals/_mesh_memory.py b/src/cellier/visuals/_mesh_memory.py index 42393a6b..5592f533 100644 --- a/src/cellier/visuals/_mesh_memory.py +++ b/src/cellier/visuals/_mesh_memory.py @@ -198,9 +198,8 @@ class MultiscaleMeshVisual(BaseMeshVisual): visual_type: Literal["mesh_multiscale"] = "mesh_multiscale" lod: GeometryLodConfig = Field(default_factory=GeometryLodConfig) - @property - def plans_coarse_on_scrub(self) -> bool: - """``True`` when ``lod.dims_drag`` is ``"coarse"``. + def plans_coarse_while_moving(self, n_displayed_dims: int) -> bool: + """``True`` when ``lod.dims_drag`` is ``"coarse"``, in 2D and 3D. While the scene's dims are scrubbed the mesh then loads its coarse level only, and the finest once when the scrub ends. diff --git a/src/cellier/visuals/_removed.py b/src/cellier/visuals/_removed.py new file mode 100644 index 00000000..ced7549e --- /dev/null +++ b/src/cellier/visuals/_removed.py @@ -0,0 +1,49 @@ +"""Field names removed by a hard break, and their refusal. + +A removed name is not aliased and not mapped at load time (plane rendering +design v3 7.6): passing one raises, naming the replacement, so it cannot +pass silently as an ignored extra field. +""" + +from __future__ import annotations + +from typing import Any + +#: On ``MultiscaleImageAppearance`` and ``MultiscaleLabelsAppearance``. +REMOVED_APPEARANCE_FIELDS: dict[str, str] = { + "lod_bias": "use settled_lod_bias", +} + +#: On ``ProgressiveLoadingConfig``. +REMOVED_LOADING_FIELDS: dict[str, str] = { + "dims_drag": ( + "use appearance.coarsest_while_moving_3d and " + "appearance.coarsest_while_moving_2d (True for 'backstop', False for " + "'eager')" + ), + "backstop": "the backstop always loads", +} + + +def refuse_removed_fields(data: Any, removed: dict[str, str], model: str) -> Any: + """Raise if *data*, a model's input, names a removed field. + + Parameters + ---------- + data : Any + The input of a ``model_validator(mode="before")``. + removed : dict[str, str] + Removed field name to what replaces it. + model : str + The model's name, for the message. + + Raises + ------ + ValueError + Naming the first removed field found and its replacement. + """ + if isinstance(data, dict): + for name, replacement in removed.items(): + if name in data: + raise ValueError(f"{model}.{name} was removed: {replacement}.") + return data diff --git a/tests/_plane_fixtures.py b/tests/_plane_fixtures.py new file mode 100644 index 00000000..1c2c1b8e --- /dev/null +++ b/tests/_plane_fixtures.py @@ -0,0 +1,161 @@ +"""CI-sized multiscale stores for the plane rendering tests. + +``plans/plane_rendering_design_v3.md`` 14.0.2. The measured store +(``ExpA_VIP_ASLM_on.zarr``, 2 GB) is not in CI, so these small pyramids +reproduce what matters about it: a level pyramid that does not downsample +every axis (``aniso_pyramid``), an isotropic one, one with non-integer level +ratios, one with a time axis, an integer label one, and a channel stack. + +Every voxel of every level holds a value of its own where a pixel test +compares voxels, so a pixel can name the voxel (and level) it was drawn from. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import numpy as np +import tensorstore as ts + +from cellier.data import MultiscaleZarrDataStore +from cellier.transform import Axis, DataCoordinateSystem + +if TYPE_CHECKING: + from pathlib import Path + +AXES_ZYX = ("z", "y", "x") + + +@dataclass(frozen=True) +class PyramidSpec: + """How to build a pyramid: level-0 shape, per-level factors, voxel size.""" + + shape0: tuple[int, ...] + #: ``factors[k][axis]``: level k's voxel size in level-0 voxels. + factors: tuple[tuple[float, ...], ...] + #: Physical voxel size of level 0 per axis (the visual's transform). + voxel_size: tuple[float, ...] + dtype: str = "uint16" + axes: tuple[str, ...] = AXES_ZYX + + @property + def n_levels(self) -> int: + return len(self.factors) + + def level_shape(self, level: int) -> tuple[int, ...]: + return tuple( + max(1, math.ceil(n / f)) + for n, f in zip(self.shape0, self.factors[level], strict=True) + ) + + +def halving(n_levels: int, down: tuple[int, ...]) -> tuple[tuple[float, ...], ...]: + """Factors halving the axes in *down* (1 = halve) each level.""" + return tuple( + tuple(float(2**k) if d else 1.0 for d in down) for k in range(n_levels) + ) + + +#: z is never downsampled, as in the measured store. +ANISO = PyramidSpec( + shape0=(64, 256, 256), + factors=halving(5, (0, 1, 1)), + voxel_size=(5.0, 6.55, 6.55), +) +ISO = PyramidSpec( + shape0=(128, 128, 128), + factors=halving(3, (1, 1, 1)), + voxel_size=(1.0, 1.0, 1.0), +) +#: Ratios near 2 that are not exactly 2 (round up each time). +NONPOW2 = PyramidSpec( + shape0=(97, 151, 131), + factors=( + (1.0, 1.0, 1.0), + (97 / 49, 151 / 76, 131 / 66), + (97 / 25, 151 / 38, 131 / 33), + (97 / 13, 151 / 19, 131 / 17), + ), + voxel_size=(1.0, 1.0, 1.0), +) +TZYX = PyramidSpec( + shape0=(6, 32, 64, 64), + factors=tuple((1.0, 2.0**k, 2.0**k, 2.0**k) for k in range(3)), + voxel_size=(1.0, 2.0, 1.0, 1.0), + axes=("t", "z", "y", "x"), +) + + +def _fill_unique(shape: tuple[int, ...], level: int, dtype: str) -> np.ndarray: + """A value per voxel that names the voxel (wrapping at the dtype's range).""" + index = np.indices(shape, dtype=np.int64) + weights = (1, 31, 977, 13007)[: len(shape)][::-1] + total = sum(w * i for w, i in zip(weights, index, strict=True)) + total = total + 101 * level + info = np.iinfo(np.dtype(dtype)) + return (total % (info.max - 1) + 1).astype(dtype) + + +def write_pyramid(root: Path, spec: PyramidSpec, *, labels: bool = False) -> None: + """Write the pyramid as zarr v3 arrays ``s0`` .. ``sN`` under *root*.""" + dtype = "int32" if labels else spec.dtype + for level in range(spec.n_levels): + shape = spec.level_shape(level) + zspec = { + "driver": "zarr3", + "kvstore": {"driver": "file", "path": str(root / f"s{level}")}, + "metadata": { + "shape": list(shape), + "data_type": dtype, + "chunk_grid": { + "name": "regular", + "configuration": {"chunk_shape": [min(s, 32) for s in shape]}, + }, + }, + "create": True, + "delete_existing": True, + } + array = ts.open(zspec).result() + if labels: + data = (_fill_unique(shape, level, "uint16").astype(np.int32) % 40) + 1 + data[..., ::7] = 0 # a background stripe + else: + data = _fill_unique(shape, level, dtype) + array[...].write(data).result() + + +def open_pyramid(root: Path, spec: PyramidSpec, *, name: str = "pyramid"): + """Open the pyramid at *root* as a store; returns ``(store, voxel_size)``. + + Level-k voxel centres sit at ``(factor - 1) / 2`` level-0 voxels, as the + measured store's loader does. + """ + system = DataCoordinateSystem( + name="data", + datastore_id=__import__("uuid").uuid4(), + axes=tuple( + Axis(name=n, axis_type="time" if n == "t" else "space") for n in spec.axes + ), + ) + store = MultiscaleZarrDataStore.from_scale_and_translation( + zarr_path=str(root), + scale_names=[f"s{k}" for k in range(spec.n_levels)], + level_scales=[tuple(f) for f in spec.factors], + level_translations=[tuple((v - 1.0) / 2.0 for v in f) for f in spec.factors], + data_coordinate_system=system, + name=name, + ) + return store, spec.voxel_size + + +def channel_spec(n_channels: int, base: PyramidSpec = ANISO) -> PyramidSpec: + """*base* with a leading channel axis that no level downsamples.""" + return PyramidSpec( + shape0=(n_channels, *base.shape0), + factors=tuple((1.0, *f) for f in base.factors), + voxel_size=(1.0, *base.voxel_size), + dtype=base.dtype, + axes=("c", *base.axes), + ) diff --git a/tests/_planning.py b/tests/_planning.py index 91e0a5ce..4ea464c7 100644 --- a/tests/_planning.py +++ b/tests/_planning.py @@ -11,6 +11,7 @@ from __future__ import annotations +from contextlib import contextmanager from itertools import zip_longest from typing import TYPE_CHECKING, Any from uuid import uuid4 @@ -19,12 +20,39 @@ from cellier.render._requests import ReslicingRequest from cellier.render._scene_config import VisualRenderConfig -from cellier.visuals import ProgressiveLoadingConfig if TYPE_CHECKING: from cellier.data.image import ChunkRequest +@contextmanager +def _target_alone(): + """Plan with no backstop: the target only, with every slot to itself. + + The backstop cannot be switched off on a visual (plane rendering design + v3, D-P45), so a test that wants the target's requests alone plans with + the backstop planners and the backstop's slot cap stubbed out. + """ + from cellier.render.visuals import _image, _label_multiscale + + planner = _image.MultiscaleRegionPlanner + saved = [ + (planner, "_plan_backstop_3d", planner._plan_backstop_3d), + (planner, "_plan_backstop_2d", planner._plan_backstop_2d), + (_image, "backstop_cap_for", _image.backstop_cap_for), + (_label_multiscale, "backstop_cap_for", _label_multiscale.backstop_cap_for), + ] + planner._plan_backstop_3d = lambda self, *args, **kwargs: None + planner._plan_backstop_2d = lambda self, *args, **kwargs: None + _image.backstop_cap_for = lambda loading, residency: 0 + _label_multiscale.backstop_cap_for = lambda loading, residency: 0 + try: + yield + finally: + for owner, name, value in saved: + setattr(owner, name, value) + + def planned_requests_3d( visual: Any, camera_pos_world: np.ndarray, @@ -43,10 +71,12 @@ def planned_requests_3d( *visual* is a ``GFXMultiscaleImageVisual``, a ``GFXMultiscaleLabelVisual`` or a bare ``_MultiscaleImageSlot``; any other visual's own ``build_slice_request`` answers. The arguments are - ``build_slice_request``'s, plus *backstop*: whether the plan includes - the coarse backstop (off by default, so the requests are the target's), - and *view_height_world*: the visible world height of an orthographic - view (``fov_y_rad`` of 0). + ``build_slice_request``'s, plus *backstop*: whether the requests include + the coarse backstop's (off by default, so the requests are the + target's: the backstop always loads, so the target is planned alone + here, as into an atlas with no backstop in it), and + *view_height_world*: the visible world height of an orthographic view + (``fov_y_rad`` of 0). """ from cellier.render.scheduling import is_chunked_visual from cellier.render.visuals._image import _MultiscaleImageSlot @@ -97,12 +127,15 @@ def planned_requests_3d( target_visual_ids=None, ) config = VisualRenderConfig( - lod_bias=lod_bias, + settled_lod_bias=lod_bias, force_level=force_level, frustum_cull=frustum_corners_world is not None, - loading=ProgressiveLoadingConfig(backstop=backstop), ) - desired = visual.plan(request, config) + if backstop: + desired = visual.plan(request, config) + else: + with _target_alone(): + desired = visual.plan(request, config) groups = [ds.build_request(ds.keys) for ds in desired if ds is not None] return [r for group in zip_longest(*groups) for r in group if r is not None] diff --git a/tests/convenience/test_appearance_specs.py b/tests/convenience/test_appearance_specs.py index b7968067..8c7c221d 100644 --- a/tests/convenience/test_appearance_specs.py +++ b/tests/convenience/test_appearance_specs.py @@ -84,11 +84,11 @@ def test_every_multiscale_field_maps_to_a_control(): "render_mode", "iso_threshold", "attenuation", - "lod_bias", + "settled_lod_bias", ] ), ) - assert kinds(result) == ["image", "lod_bias", "aabb", "loading"] + assert kinds(result) == ["image", "settled_lod_bias", "aabb", "loading"] assert result.specs[0].values["fields"] == [ "color_map", "clim", @@ -133,14 +133,15 @@ def test_order_is_the_config_maps_order_not_the_callers(): def test_a_field_missing_from_the_visuals_model_is_skipped(): """A multiscale config on an in-memory visual: the model is narrower. - The config class knows ``lod_bias``; the model does not carry it. Both + The config class knows ``settled_lod_bias``; the model does not carry it. Both halves of the predicate have to hold. """ result = appearance_specs( - _in_memory(), MultiscaleImageControlsConfig(appearance=["clim", "lod_bias"]) + _in_memory(), + MultiscaleImageControlsConfig(appearance=["clim", "settled_lod_bias"]), ) assert kinds(result) == ["image", "aabb"] - assert result.skipped == ["lod_bias"] + assert result.skipped == ["settled_lod_bias"] @pytest.mark.parametrize("appearance", [False, []]) @@ -306,7 +307,7 @@ def test_titles_are_shared_by_both_front_ends(): result = appearance_specs( _multiscale(), MultiscaleImageControlsConfig( - appearance=["color_map", "clim", "render_mode", "lod_bias"] + appearance=["color_map", "clim", "render_mode", "settled_lod_bias"] ), ) assert [spec.title for spec in result.specs] == [ diff --git a/tests/convenience/test_controls_config_validation.py b/tests/convenience/test_controls_config_validation.py index 3de3fd27..5e2e306b 100644 --- a/tests/convenience/test_controls_config_validation.py +++ b/tests/convenience/test_controls_config_validation.py @@ -29,7 +29,7 @@ ) _IN_MEMORY_FIELDS = ["color_map", "clim", "render_mode", "iso_threshold"] -_MULTISCALE_ONLY = ["attenuation", "lod_bias"] +_MULTISCALE_ONLY = ["attenuation", "settled_lod_bias"] # --------------------------------------------------------------------------- @@ -62,7 +62,7 @@ def test_a_whole_valid_list_is_accepted(): def test_multiscale_only_fields_are_rejected_on_the_in_memory_config(field): """The likelier mistake (section 9.1): a real name, wrong config. - ``lod_bias`` and ``attenuation`` exist on ``MultiscaleImageAppearance`` + ``settled_lod_bias`` and ``attenuation`` exist on ``MultiscaleImageAppearance`` and not on ``InMemoryImageAppearance``. A closed ``Enum`` would not catch this -- the member exists either way -- which is why the vocabulary is per config class and not one global set. @@ -73,7 +73,7 @@ def test_multiscale_only_fields_are_rejected_on_the_in_memory_config(field): def test_the_error_names_the_config_class_and_its_valid_set(): with pytest.raises(ValueError) as excinfo: - InMemoryImageControlsConfig(appearance=["lod_bias"]) + InMemoryImageControlsConfig(appearance=["settled_lod_bias"]) message = str(excinfo.value) assert "InMemoryImageControlsConfig" in message @@ -97,7 +97,7 @@ def test_a_typo_is_rejected_with_the_nearest_match(): [ ("clims", "clim"), ("render_modes", "render_mode"), - ("lodbias", "lod_bias"), + ("lodbias", "settled_lod_bias"), ], ) def test_near_misses_suggest_the_right_field(typo, expected): @@ -201,7 +201,7 @@ def test_a_field_valid_for_the_config_but_absent_from_the_model_is_reported(): """Validation cannot catch this; only the renderer sees both halves. A ``MultiscaleImageControlsConfig`` on an in-memory visual: every name is - valid for the config class, and the model still has no ``lod_bias``. + valid for the config class, and the model still has no ``settled_lod_bias``. ``appearance_specs`` reports it and the renderers warn (section 9.5 step 4). """ @@ -211,11 +211,11 @@ def test_a_field_valid_for_the_config_but_absent_from_the_model_is_reported(): visual = ImageVisual(name="v0", data_store_id="store") specs, skipped = appearance_specs( - visual, MultiscaleImageControlsConfig(appearance=["clim", "lod_bias"]) + visual, MultiscaleImageControlsConfig(appearance=["clim", "settled_lod_bias"]) ) assert [spec.kind for spec in specs] == ["image", "aabb"] - assert skipped == ["lod_bias"] + assert skipped == ["settled_lod_bias"] # --------------------------------------------------------------------------- diff --git a/tests/convenience/test_non_image_controls.py b/tests/convenience/test_non_image_controls.py index 2231477a..e627ace5 100644 --- a/tests/convenience/test_non_image_controls.py +++ b/tests/convenience/test_non_image_controls.py @@ -124,7 +124,7 @@ def test_add_labels_multiscale_takes_the_multiscale_config(multiscale_labels_sto from cellier.visuals._labels import MultiscaleLabelsAppearance viewer = Viewer(spatial_axes("z", "y", "x")) - config = MultiscaleLabelsControlsConfig(appearance=["lod_bias"]) + config = MultiscaleLabelsControlsConfig(appearance=["settled_lod_bias"]) visual = viewer.add_labels_multiscale( multiscale_labels_store, @@ -454,11 +454,11 @@ def test_a_field_from_another_visual_family_is_rejected(kind, field): def test_lod_bias_is_multiscale_labels_only(): - with pytest.raises(ValueError, match="lod_bias"): - LabelsControlsConfig(appearance=["lod_bias"]) - assert MultiscaleLabelsControlsConfig(appearance=["lod_bias"]).appearance == [ - "lod_bias" - ] + with pytest.raises(ValueError, match="settled_lod_bias"): + LabelsControlsConfig(appearance=["settled_lod_bias"]) + assert MultiscaleLabelsControlsConfig( + appearance=["settled_lod_bias"] + ).appearance == ["settled_lod_bias"] def test_colormap_mode_is_not_in_the_labels_vocabulary(): @@ -538,7 +538,7 @@ def test_composite_default_titles_match_the_shared_vocabulary(): composites = { "image": (QtImageControls, AnywidgetImageControls), - "lod_bias": (QtLodBiasSlider, AnywidgetLodBiasSlider), + "settled_lod_bias": (QtLodBiasSlider, AnywidgetLodBiasSlider), "aabb": (QtAABBWidget, AnywidgetAABBWidget), "trail": (QtTrailControls, AnywidgetTrailControls), "loading": (QtLoadingIndicator, AnywidgetLoadingIndicator), @@ -577,7 +577,7 @@ def test_every_valid_field_name_has_a_widget(): from cellier.convenience.gui import _controls_config from cellier.gui._appearance_fields import APPEARANCE_FIELD_WIDGETS - bespoke = {"image", "lod_bias"} + bespoke = {"image", "settled_lod_bias"} config_classes = [ value for value in vars(_controls_config).values() diff --git a/tests/convenience/test_ortho_appearance_controls.py b/tests/convenience/test_ortho_appearance_controls.py index 28f1d34c..da07901a 100644 --- a/tests/convenience/test_ortho_appearance_controls.py +++ b/tests/convenience/test_ortho_appearance_controls.py @@ -101,7 +101,7 @@ def test_add_image_multiscale_records_the_config_and_the_group(multiscale_image_ visuals = ortho.add_image_multiscale( multiscale_image_store, appearance=MultiscaleImageAppearance(), - controls=MultiscaleImageControlsConfig(appearance=["lod_bias"]), + controls=MultiscaleImageControlsConfig(appearance=["settled_lod_bias"]), single=MultiscaleImageSingleAppearance(color_map="viridis"), render_config=MultiscaleImageRenderConfig(**SMALL_BUDGETS), ) @@ -476,10 +476,10 @@ def test_the_renderer_warns_about_a_field_the_model_does_not_have(qtbot): _store(), appearance=_appearance(), # Valid for the multiscale config class; absent from the in-memory model. - controls=MultiscaleImageControlsConfig(appearance=["clim", "lod_bias"]), + controls=MultiscaleImageControlsConfig(appearance=["clim", "settled_lod_bias"]), ) - with pytest.warns(UserWarning, match="lod_bias"): + with pytest.warns(UserWarning, match="settled_lod_bias"): container = render_dock(AppearanceControls(), ortho, QtLayoutHost(), []) assert container is not None diff --git a/tests/convenience/test_ortho_clipping.py b/tests/convenience/test_ortho_clipping.py index c1b28531..b9832027 100644 --- a/tests/convenience/test_ortho_clipping.py +++ b/tests/convenience/test_ortho_clipping.py @@ -98,7 +98,7 @@ def __init__(self, controller, visuals, prefix="") -> None: lambda e, n=name: self.planes.append((n, e.source_id)), owner_id=owner, ) - controller.on_clipping_interaction( + controller.on_plane_interaction( visual.id, lambda e, n=name: self.drags.append((n, e.phase, e.reason)), owner_id=owner, @@ -318,7 +318,7 @@ async def test_a_drag_is_reported_by_every_linked_visual(make_ortho, mode): visuals, _ = _add(ortho) events = _Events(controller, visuals) - with controller.clipping_interaction(visuals["xz"].id): + with controller.plane_interaction(visuals["xz"].id): for x in (5.0, 6.0, 7.0): controller.set_clipping_planes(visuals["xz"].id, _moved(visuals["xz"], x)) linked = LINKED[mode]["xz"] @@ -330,7 +330,7 @@ async def test_a_drag_is_reported_by_every_linked_visual(make_ortho, mode): # The origin starts first, so the scopes are open for the first tick. assert events.drags[0] == ("xz", "start", None) assert all( - controller.clipping_interaction_state(v.id) == "idle" for v in visuals.values() + controller.plane_interaction_state(v.id) == "idle" for v in visuals.values() ) assert ortho.clipping_controller._forwarded == {} @@ -342,7 +342,7 @@ def test_without_an_event_loop_every_change_is_a_drag_on_every_linked_visual( controller = ortho.controller visuals, _ = _add(ortho) events = _Events(controller, visuals) - with controller.clipping_interaction(visuals["vol"].id): + with controller.plane_interaction(visuals["vol"].id): for x in (5.0, 6.0): controller.set_clipping_planes(visuals["vol"].id, _moved(visuals["vol"], x)) # No stillness timer: every visual settles at once, after each change. @@ -368,7 +368,7 @@ async def test_a_mode_change_in_a_drag_closes_the_forwarded_scopes(make_ortho): visuals, _ = _add(ortho) events = _Events(controller, visuals) - with controller.clipping_interaction(visuals["vol"].id): + with controller.plane_interaction(visuals["vol"].id): controller.set_clipping_planes(visuals["vol"].id, _moved(visuals["vol"], 5.0)) assert set(linker._forwarded) == {visuals["vol"].id} linker.mode = "2d" @@ -379,7 +379,7 @@ async def test_a_mode_change_in_a_drag_closes_the_forwarded_scopes(make_ortho): assert _at(visuals, 6.0) == {"vol"} assert _at(visuals, 5.0) == {"xy", "xz", "yz"} assert all( - controller.clipping_interaction_state(v.id) == "idle" for v in visuals.values() + controller.plane_interaction_state(v.id) == "idle" for v in visuals.values() ) @@ -406,11 +406,11 @@ def copy(event) -> None: def forward(event) -> None: if event.phase == "start": - controller.begin_clipping_interaction(b["xy"].id, source_id=user) + controller.begin_plane_interaction(b["xy"].id, source_id=user) else: - controller.end_clipping_interaction(b["xy"].id, source_id=user) + controller.end_plane_interaction(b["xy"].id, source_id=user) - controller.on_clipping_interaction(a["xy"].id, forward, owner_id=user) + controller.on_plane_interaction(a["xy"].id, forward, owner_id=user) return user @@ -449,7 +449,7 @@ async def test_a_drag_reaches_the_second_dataset_only_if_the_user_forwards_it( events_b = _Events(controller, b) origin = a[_ORIGIN[mode]] - with controller.clipping_interaction(origin.id): + with controller.plane_interaction(origin.id): for x in (5.0, 6.0, 7.0): controller.set_clipping_planes(origin.id, _moved(origin, x)) linked = LINKED[mode]["xy"] @@ -468,7 +468,7 @@ async def test_a_drag_reaches_the_second_dataset_only_if_the_user_forwards_it( assert sorted(events_b.drags) == expected_b assert ortho.clipping_controller._forwarded == {} assert all( - controller.clipping_interaction_state(v.id) == "idle" + controller.plane_interaction_state(v.id) == "idle" for v in (*a.values(), *b.values()) ) @@ -508,7 +508,7 @@ async def test_removal_in_a_drag_leaves_no_scope_open(make_ortho): visuals, _ = _add(ortho) # A forward target goes: the drag carries on for the others. - with controller.clipping_interaction(visuals["vol"].id): + with controller.plane_interaction(visuals["vol"].id): controller.set_clipping_planes(visuals["vol"].id, _moved(visuals["vol"], 5.0)) controller.remove_visual(visuals.pop("yz").id) controller.set_clipping_planes(visuals["vol"].id, _moved(visuals["vol"], 6.0)) @@ -517,14 +517,14 @@ async def test_removal_in_a_drag_leaves_no_scope_open(make_ortho): # The origin goes: the scopes it forwarded are closed. vol = visuals.pop("vol") - controller.begin_clipping_interaction(vol.id, source_id=(source := uuid4())) + controller.begin_plane_interaction(vol.id, source_id=(source := uuid4())) controller.set_clipping_planes(vol.id, _moved(vol, 7.0)) assert set(linker._forwarded) == {vol.id} controller.remove_visual(vol.id) - controller.end_clipping_interaction(vol.id, source_id=source) + controller.end_plane_interaction(vol.id, source_id=source) assert linker._forwarded == {} assert all( - controller.clipping_interaction_state(v.id) == "idle" for v in visuals.values() + controller.plane_interaction_state(v.id) == "idle" for v in visuals.values() ) # The two that are left are still linked. controller.set_clipping_planes(visuals["xy"].id, _moved(visuals["xy"], 8.0)) diff --git a/tests/convenience/test_ortho_dims.py b/tests/convenience/test_ortho_dims.py index 5dc45241..d10f891f 100644 --- a/tests/convenience/test_ortho_dims.py +++ b/tests/convenience/test_ortho_dims.py @@ -350,9 +350,7 @@ async def test_with_forwarding_no_mirrored_panel_plans_in_full(monkeypatch): _add_image(viewer) controller = viewer.controller # Every visual opts in, so the mode each panel would plan is visible. - monkeypatch.setattr( - ImageVisual, "plans_coarse_on_scrub", property(lambda self: True) - ) + monkeypatch.setattr(ImageVisual, "plans_coarse_while_moving", lambda self, n: True) keys = {scene.id: key for key, scene in viewer.scenes.items()} modes: dict[str, list] = {key: [] for key in viewer.scenes} render_manager = controller._render_manager diff --git a/tests/convenience/test_qt_appearance_acceptance.py b/tests/convenience/test_qt_appearance_acceptance.py index 88179002..d9f31e3f 100644 --- a/tests/convenience/test_qt_appearance_acceptance.py +++ b/tests/convenience/test_qt_appearance_acceptance.py @@ -45,14 +45,14 @@ "render_mode", "iso_threshold", "attenuation", - "lod_bias", + "settled_lod_bias", ] def test_multiscale_panel_control_names_in_order(qtbot, multiscale_image_store): """Every field requested, so this is the maximal panel Qt builds today. - ``lod_bias`` and the three render fields are multiscale-only; the render + ``settled_lod_bias`` and the three render fields are multiscale-only; the render trio collapses into the single "Render mode" group. The bounding box is not requested by name -- ``aabb`` is on ``BaseVisual`` with a default factory, so every configured panel gets it, on both toolkits. @@ -77,7 +77,7 @@ def test_multiscale_panel_control_names_in_order(qtbot, multiscale_image_store): def test_in_memory_panel_control_names_in_order(qtbot, image_store): - """The in-memory model has no ``lod_bias``/``attenuation``. + """The in-memory model has no ``settled_lod_bias``/``attenuation``. This is the one drop stage 3's validation cannot catch, and so the reason ``appearance_specs`` still reports ``skipped``: a **multiscale** config is diff --git a/tests/convenience/test_qt_renderer.py b/tests/convenience/test_qt_renderer.py index fcf16062..dcf62dad 100644 --- a/tests/convenience/test_qt_renderer.py +++ b/tests/convenience/test_qt_renderer.py @@ -104,7 +104,9 @@ def test_appearance_controls_multiscale_render_and_lod(qtbot, multiscale_image_s viewer.add_image_multiscale( multiscale_image_store, appearance=MultiscaleImageAppearance(), - controls=MultiscaleImageControlsConfig(appearance=["render_mode", "lod_bias"]), + controls=MultiscaleImageControlsConfig( + appearance=["render_mode", "settled_lod_bias"] + ), single=MultiscaleImageSingleAppearance(color_map="viridis", render_mode="mip"), ) diff --git a/tests/data/plane_golden/plans.npz b/tests/data/plane_golden/plans.npz new file mode 100644 index 0000000000000000000000000000000000000000..a48e47a03a2e7b08a98190784121487fefa26a2f GIT binary patch literal 73254 zcmeFZ2~?Bk^FLZ;6H!o6Q9)8grAj3#RR~K`YZaABR8+J=QngBzN`Qb#Ad7&AiV_tS z5lFPCs3@sYg+LZI4G1JEVniSdgaimAfslQ>;oH({`)~XEpZh(x=iYPvnd5n7Ntow( z=QA_UeCD0)VK%laKl$X7Db|0BKFPj2$g%wPTKdWCPyD`zA3c5`<}ff05qB7P@aO^L zYTs4BBS(`016RRg4`YzNaq*Z*pUn8=)%g3ZuSb)Ay%t?v9ryOT8JRIJ)Zb3+ct{k) zt>60ir!zBt+Pu->!|C*M7aA8|obhK#!2iaXEZ|;V7 z5lPo>AmLa$#!d~CM6RKJ9Wy4?PQxzCV`P;syer=nGSGE3xG-2;H3JAbud}sEOIIOcG^3bI)8iwtTY;}!iM3uuUwR7?&`;_*e_&L$ml%DKFx(iTKS-aypKMwL zF9@cb{>j@pqyCl4n366Yno6wCMK(BD?z#8dN7?5kt_qlz(*g|~eFW0R-L76)l@?cX zIDWo|bEx=rUz9g-LD^LErj$^A4x<>+?_GgT<}_rHGsjLWb<1E0thNlieTAPgx0g z(Q0v5_IA$@@E1_cMoc0iiZ;~L4s|ZWiwpGaC32k%u>B7hpq`o9K7oNH{}Tod{rJB? z;M-pj@JYdt+o1c5rpE0rwm%|W{LHJQfB4Tn zCBX4ly_zR{N^VHp?DaD?l|Dedn01M`sv})=b7LKG%Cf~a$G`gW)JocvC8t7TzfQjx zkiOLFRIs*yG_UwmA(Q=fQ&K$2x7;rw@JcDE1Ug=MOtYu20LzETD15*(PS$WX6j~8e zudZ(wN`*bm)ur`)c%6B)N2$V_@urc3A2_{PL~?LYYSKF6JhMUb<=5vL<9rY9x^s@( zyN0l0G*6$E;lH3@2(zdUas!`CF*?C8m1yhLK{l`i9Gfb9|Z9l7rr3gWveIBcav4v zJB)W;1U7fw?dzT%1_2{Ps|-3+F|@BshwZ~s4j2ULm~W0~Kb< zYl7-4zG>Z{*c>-HjZwE7y&CTBk9iDWk2Z0M7Ya;H#>xsBMRfjxt5UMkp>~77ohF(W zmFb2fzc5e7A^JPl5I>C0K%XWGazWma!WCWf>i3Z@ll|7kr8vUUvw9Ao=V2|pOWDHL zeFl*+-LlL8UP_5px*BbB>Kx<5JNNsh-zEl)4&D`C^=$IT&L+0(SIh{oXH^zXX0f{8 zqcOnrlA6eOrIz`p?=1vf9r7{E|28~5?_t+gK5Pz z!NSx~*h0`;{SZn+y-FMG$;B)OV-V>Tfcv&ji-ghchO3fmr1_(*|mg-G_kvJbs zBqru6YFE>vQPK*{t^sd(8|#TU-+UFjf(N@gsI>eEB6N!rW&$FAA9W`n@{!A#K*;|8 z8o=Uf|KM__&HXc|KlVA^f9rE5H2(LU&R60qyonwCd;Jdmh(P`?UguNSUpI5I-LKc8 zYpdhF*l6`SFRP`|otwdRN9>!@TxWbbIrRIRTc(`Zo6VoHqyf=!#&*&0%soAmJ%o^S zV$G$!P&&7u_jb2mNHjmS-A~Qu<29{gxEFvCNozGL4?spRx_DSsv&6rmvw+NlSjYk-h4Dk<5;k7pe;C-!tD6Jb@Y<4)=hoq08`Ws482((ex9ZSOz&C zypgmR$|VdMB%^YJ{sWE->NW_Y^17-S%s|~{Y?7y(j9mg2`DmvC&*m}YN^kQi$jDr@6Rouuq~pq% z0LEnadh=qYX`8X%PcdIHJ6IoP90*c`5M!0(V0EAdvk>M=*#eksPG>5vYLA)kYobX; z2Xhcb12x-N(&RN2ys8{d5zT>@S`?MCu0+ySsPGK&CP_xTGzJuU8*R{g#4D!uC^;S6 zv@iZc-c4Y1sHgtt1Og`rQ-*a0xNAamE=K7s#dfU}hpG?jxdW0J^mXz)q#hX;PXE>- zTB}%q-a_jKDY|ssWH79fC%+&Kap4gb;-=_t5*xDQF@n_sU)mCw$hXo-mR?P5p#nUo zx};n!5ge84KTL<8r8E!(aCjP!vrSM;zp=scPH9Nsayk9{s~o1YSv?r2PZ2@2uH##? z06h#wlfe(iZoxq~j_7T!3P`iIn5)(iJvIwHfWN`Pe@n*4b+MGCE+GYcB$nITk@Z^A5X$GDC>k}L zV1wRQ^1%JE3#>PxJCs~Pl!!~<%|{D~HHVOmboVaD3Q%^T9jdNKw$rQjzE0Gl_xx%H2St8)58*DO=9?sEZPy{}~OG&e!iOUMlR@CfgYQ*Z`>lwu+31UC=cNRnsj`i>j`@8n416 zz}d|^fn&YX_@RuG9PpChQG433S72m(++2Mz!i}d%J>YENCo<~=p9M^XoFzAe_G}=h zaZZ*CKCi5z=<`pLYoHga8@rQR2S5pM3on%G!bwxMfu_)!uEr;uspgqSI(uJ8OlIkU z1)h^#)M0=$;-^;4$Yd&U+hgDu=2ob_>N3h7?aGr`f+2IOr3ts7O!8}0J+qk&@&+ke zK<7NvONHMA+mVK11b0Bin1h|YTY266`|HT)LGz#zU)Xk%$%KwVGav#Nt&JJ;@CTAP z+3+Gyu@oE9gF9T--H)j0Hb`^LpZDFckE~e=t; z){}^zGF2J+^+i90*B&qXG;&6nfkpi$BoVyR8GDzL>xbn@qBvE54pw@H%`OwJ_3zWo z-(BlR(Q3l8#{Rd_>h86FN|4WgoGn|w_%iIDqSt4C8@+yC!oMHBI#lnB6MOvE3BfAg zwSRRCule-Xy+!?Y951@QEiP)eHF&MqVyP}#I@a>(;Thkh&#)n0PYXU!bLQ)YDNAfu zI;>v58J50u-NmJ2vuLMa>3M>suVjIe6E9X_XlCkeRS{AiE8)u-7WH7(d1jA18^x6q zs;Jsy$PpC<4x@dDG8$Q}WPb#~GD0jsaiJ7~QeC0pW^wtJ(G#h-N{gtR3&)xV+ae1H z>ns}P#;=33D+8l`s$9lJSCJrKx@8R;psy0+TTMC!C59oR%lVq*zCa>0*;%fnK9xhx z2W~9tRbjalpw666<`co^tfDvIS2Wq)A1|Q=m zn}U-g%Lov0m{olKBhplEP-{U;WDD2Sr7|d4wM(0N(a~>M=Tuz0`3m$hXQb+hY~!S<~94kVZPUf@u)$ zzMQHV(5GOyQZ^L7ROcO)UZ`5#2x+XgN=0J@Xg`rQ7hq2*JH)K6^%7oEIBD&$v%sUD zp&NY!?PR{XDq=LM!$tQnnuy`tt4MN|Fovb)3-_oo5Or8hHS)1FqXjn|7Ip#bC8x>@ zr-moGR60c2l;IF`li)`DFu14_7iNih*WqHGWI35I%xrMoGbI$R7>RK5zJZD~^0K!oJ@&jaRqIOX6F3pM%38|AnvN)gIGcysXP$~j$Q zixrCnd*YQ*Q)vB~VV08Z(Uf{32s^F?rm>l!K4e*D3;|4ri=q|2m2;yEJcfX_%3&0u zSb?5{oeC!Pra?$fGI>LGLq_kerjP3!=)V|^l2WV`7dK$W$0#Rz7CWm94 z7{gDgyd34?@eii2l8%%dX2iqyvy>mGrVw#g^$2zfZ7O__qoRrO|5Q4Bpx2o(^!oMif9U>Y#Z<*du-O08OVe4#PF26-5Q_FHQiR#^3y zHuZ|u3)4t;q-3}_BPL>$lReGtA$SN6j!t>`Z9X4COx&5Di}g8RxD90R{X^E^QS+C5G;%#ZkyzR(tx_6QXE+v zsjDRn2Wl%;c?aKkc{GRwNQ<%RG}pYujCQueC&Lnhbh+9#3CScFXo-SaRkyQ#tR}|U zn?JT((S%&4xG!%toY3ZKd#SUu#nu&_9p9vJ3l~J0y9e~zb^eiLi+E7plZV@eI}vB` z{26TiMr5yXi_AdPR)>5+sTOYQu?oEJw0{H@jeltey~eI~huOgftlu?wn5YW*SB5SZ z%4Eu9#AL(=ADyth4y#C9<`VoZsD!}WZ#jT=Lze(D0d}197-5(oj3Y6cadLgiahdj6 zk|)9CI=3mS!NGdpH&`WubG3$Yl1X#oksQu?C;A3d_$NZfmk0{XBS3Cs!#-re81p4x z_GBBK%-wmxP=+0&sag=NovnR~$+vVXmXB=V*&#R=8r-bb*vwgO%P{)IKF07+8JhI1-)2E3Lch*Y!@Yh0|(B%bMw zt%fV9?gogkND+=UV5gu_ki^=Od?h~zmmhyEtoDvzr{Wv*GLmiZR8Nvqn%gAJ$!e=t ziH4H$GGc1lOnHh#fg}YD=Jvi!c{)UhCmA) zcrj&JKtJUuHlEk{AFmrvut86iM)oqjGSI8wrW5$-0lB!i%J>ijc-OYBn_+n?buwYYA%X#AaYKg2z@i zHR-Z-+1Bp`L(^#~3zducZsLTs#;v0Rs@C2_(M*vL?=m`N^s~`^d}~&4iMnDT&lVI= zf?R492V_v_>MZM}d3Am>=p0C&Dq8iJpiF=%6FimU|6w$*hOZ(kZLRX2@P=txYLz)i zTjYEAYqD;csRQ~N${7e|#}e4F7E-o)3;*eSlbxv}^iAl_l6w(jm#i{>>}dB-aRef` z89$cfH)y9yPuW6ecuJ3J`K=MAn+(-u>(@WQc2FoRJ!@mNi{S~_BuX$%hlAF+KLk7^ zj|;Z4a``*UJ=sWVZFa3DU6U(c$~XK#?^V5L5&l@9ET?$(Ph>NHT~PffoB1eToJeMn zzvYX=jgOtvSIyffe4?M-jsGU_-sh(R$`3@%yZ(*W(i=0vzU}=AI~6|fOu17rh@Xh$ zNOna^mhAwp-74)_yZKfhn)CdJ0W8|yP^o=Ybtmr`F-|se#KGT^#B6px5<9k^@^s5v zF6FmF|9}3X1u^-L3;DR}@z<&EM+^D)vqp>|pm$=Q|6aX`qRhwa&BRW>{XK15ALoIa z8CrTQnDjWr(=esnY4g5$?>}v*^Em++J#FXXF=Cf?O8e0E@%qi+ua@`(g3lV4M*6g`h{P*-iDt0ikqWDok5MCVbT#5V;?D2pkJ`Ahg_bBKzt5Zr9q zL-cc4&)&jz%==VsuGU><-RJG(--iWn_r^SAULcPSO5vwNxyOsxGn22GaP!HoFvq;~ z7(N$t)9i{&nFrfqPRxy{tEI}!!-fQLu)tk0$dEN=7B=GxAqL%=sHwh*+bU;Axt586 znv)r{JWc_koG>Qg3U?_IAdUejz`J4d27W=FFkkUaWinm#WF6eFh`Sb3)>4aEoOPja zC^ss+iC|izoUgbRT3b)O93%N2!X8$(#MVFrJTk&i)tW$XvZfeLBs-|4`sAUy9e5ck zaXETS)3Slmcu0zTfoHT}9X!X9!GMRIn8z@5Vch}c8e<+4A*@>CU0HZhI?x=C^mWB& zx=f0pUz8-`4Z$#5&wxBx=_I-M7=VRaC)gYRxiz12jkBvLlip=ARXWou!;jqcGCp-# zRFUQ+qOO9w8USh{7e0&ct|IJn(IH5utCgPUH^>+|yNG(-A&RXy=){^sy^X8so9!8+ zeGHOy%G}ZbX)cpAW>46UIEDw+8(UbCX``M(bvJy1!&~|IE&!r2l2P$? zQH5c|nynDU5~cdf9Cigv7hLH@r1Y*Atgi_XlHzj-briwf@es+X%Q_VUhx*27%QH3h z;G^BpddSFf#h2#Mw(Od$nv6blPN;-Z4-)dZ^?lRK5}B)bJtKB>W#aoT7k*vtNu+Q? z-G1PQFhhMdlAnucY?nL%H%VWy{P;jw4By3gG%lsYp+Y1?b{YlxcY`~#}s{@cdBA({LR=v>7He|KkM}ETn1%eHvyR+>g zF>^hC(TLFyo~_YU=7<0RSx)2_aXP?_QokQE87SHlFO`lc8fbmVpT_Vlh{iV1Gq^Eu zHI%C_L}LUV#|xIn@cZ@s@}8sPCllF!QZ`9lag7&l zGj_A}B;ZtL$F0k)$vs(O*d%C9?9v(3H>wmrWRbnar-2*cL0DQ%>6F9~&2m$m0Cg9r zcG6s+3GfdzsNUUz2yS*>Ng~yqk?H0rsmmsXf(GpHMZInMBbDATTWi+7adW1FBb+{< zS7___I}%U!ZUrh7bsASgL&j4op92;KTa8fWsA|M}^L-s$=15^Uv%S@pRkZHV$ugN2J*R8T#?vP;0BLza3Bxk53!#&tfz~3+bG%}3 z78wPGw(bT99-kYz3F?~8sdLce)|Aw^QRhZ=ho+b$BkSW5S5!K5*^owEv>HAjE4J<_ zvbOoDuD3lC_L}1i04idoh6~C`Tvy2_h;{KDLhgK6l-}@Vj(<74QtrU8?V1{HhXA1f z(B2D-MksJHK+j-bGGB-AhK%$FO!P1HoMy?$BE=$WR(Q?&;keZ?#>$535WH*zndM(y zBHR_fB47^qbLo(3HX?jTb0EOp!zO1EtIuqZ80|D6R2=Rp<;X7+l7I;>%(i?~ggeFd}eCCq)+5!0@D~#6JJNyAFWUx4< zHbq$FY~w7vBScautt$qa~MZ+HsUwR*avDJN$blmP-|mjdH#NR?9jSJ zO%;O5LS`Melq-C%xyn6&w)41Vh@wv`Ihm_-)1e5X7Y(NPEdln{b2!c-$uBt-T3Z6? z?eHY}s%%_ND3}d-PeFBQ#4Km(a=Z$&!Iyl>y8VPnFmK6X%MuP-enfj6 zB}s)~q~*%*NLze2n%9#O`>O=iS%wak=mVcn*d=mRtcFW!X=7`f{F_P}OB*8~xyB4* zMsRLnif604XDar6EdmupO%4G=vAp_$m$>JW=iQ?1qIqMFB8#}KqAMbI(N87%`Jxz4 zHs<1n%Ns67T#UFFMv)sHQlp6keXsEZEWWGz8_P!iM!tBq=&kgH=(V}O+Q1)1zXDzN z(#ZkKvKSPmds&H#WNCj;zt4creWub|;v;!3xdy&$ksTQX4*Ke+3|8>KP;#Cc8W$-# z(TBWXwt9G|b82Ehg^Rv3m$mU5nSSnD_qTa(cf7s&5dOB4Tg_}X#0_p995jngT-R5T zr1x3L8OgZ@H|Y&1CG~o8ZgOY${jRLjlj_x_y}AFU$5 z>7;HZ(^HD2L?De0=75PTpr_O>22&kpLcv-_Q=h(Z9zv=|318)We3V z6XaM;^$|-7WYA3o<(5YC;jJb;Rr5%aB7=_s^=>A^pxM+IAzD6~WRc+Ui!5bw%0nqq zhYKgS$BBGK=M*}|)FoGyJt_h4{Wv5XLvlkyi3aFXTHqpo#HC1f8{;ZY^%Xx>Q;^!p z;lu#W64|}V4l8THNT(<+qX#4mh|=3QPMZjZljxsXUh1obK8iV3dL@Y8Km^;6CqZkA znGIxy4ZlSC(pb*o#T3;mZlJe`XDHl^nUsb|B{UiZGPoE~Ff!+NE{-T~<=M#TP~VV( zHs#T9G?C=WDUpW0`-qDhC`<=gxwyc*?|i#MJ;gsiV@QgExr{90)8rUS8X;u1=Tt&8 zD#*&H-RQj~C+iG-H*`_gBPePz-5zEGJnI(XKnh~2@_X+XO!Ne%q*ju(I_o=U{bsBd zXYXkvs|)MdXyxGqzLf>dh1J4aikp#`sU&q7W!TCfZnct!syW)xE?e-2QqULzLV(B& z=5$@O!F)hb5vf_t^&-@f71o{M*2HpZB`v}zfc|Qm{G0HQ4!)e5(sKN4g_BM}Ta(4G zzORl-JrSoq5GT=hIup(^mqcD_)-8>~`=^cfa``CkSV^Ip;=GzLP8*ipW^O?qU&x_= z$9}|fMsRR9z>7jxfC<}%dnxZ+EBS61FS$uMYNha09yl@xcAbn_S&{&rOl)1GIT7#O zCH_2dZkHqKvXx^(u5&=b29GWvO@@*RYH~g7vLv4=-YJuY<>#d)!E*Fem$O-5ix|mP zA7aO#!HM2oc!x=dx=xMCe>x$Jc@iRo8tu%YZRk$PHtrUJq*W9AAA2GI87w2GugjTz zs;z5dT!5nby_G+m!VJE5!>v zThfqgwacEBig$cEHN-_M2>x6ZFrAnd8nTpB2=7_m6fN=%aAlwJ5^f%yjrow8#JB!N zAITn7voKzR`jmSjerXgA_OKbVZ!i<{xdJD0(iU#0^Fq{gf`3BVNyOg}T87_9%&_bA z7Q{XTBw%Vz9kj=Lk;2p7v6J81eCQjGo}UXpm+A+oNWsuIZ$CXp5V{oEG9t%?q0 z-KBVkF1EM8*{bsxNwK7Bec9qX$>)MWz8m#!?VR|0zow4J;G#}Mka&hjt{F??+`*Jw z@^6e~FDbzVQgn){ixPax%0_SK8y3|LH0(O&XhnTCV+;8hf2+3qKW<+; za$5Wky(E8T=T=RfH}6mFFdvs${+gZ}k7&QI;olEwyW*o^6Px^pME2XKXBOCe2^op!T?hvbpT$dU)oky+0px7+vPHr(yA?PZzCu;88BoGdOJ?ko+! zGCKM43OCk(?shned0hJOj;aDZjLT;RKrNTZyef2QT@b@$KgwAq~)9U@l3 z_IQ_uk@A8mIeV;xcnpN_bBYX1%+F=#@7L?@C(aJPU&?$(IL)9pt$kUS%(@ZUR4iHz z;F6Dlm%{INzcAitI)=(M?D)_$2rA4pct~L{VJ}RFOn!;Jg4;No)D@}U`jWjP{rjj5 zNcILgu?BbLLkdi`sGFDwd1<=Rbrg6Qc$iWHyK@5Z4J1Zx&wrOJ^Xrp;dIa89?TqiS z4Um;;10w}fJu+Pddz0ZDA=nI747_+br*g7?jkN)^yV*2{U_DoAc2`a++nu;p`?O^# zsYXe+4L^BEGqWjZ4@5G>qn59KocJTpdi-_ys+ ziHUL#<}(V_7O)X;9R*Mx#_usBUObF6zND(DIUOube9=#BSTOM_LYEmuDv_iXQxprt zF@2WfErCl*Zbo7+WHSzj3sGNz#YYZOB_zX9M*J1!pNgYBoZQC~w)BVM=wI2=M{u1m zr9J;GTu+N%{54!}PWva`{149MuXO$QVYw51{IBbue^75C6aF~S{K4keT@0GQ<)z2r z4XdV|F1p>c@+33vp?L~+Mam|K(SGQX zb{)4q3OC5JEn_cIGypC-)n_c+_vBPKuJ;C-e;z zK9SgK94j}}EL>L!tugza1G3g|E-RvE=hPD(FGupe7(xm0jj~$)y)6Ji3wryDd{OccI zm7RM3+4?W0EDt@?U>7hs^u1U7l4ZSo$ymXQCnma_xK?K}*H;URJ6#Oast!-EJd&bs zmsaPMCNuSkObdgLH6$^a9V7|DAMVn56kpjr^en#8j65Pij}a_=McyB=;Y zIwtvG4v`1NTE32c1??!urnu4H$NGX*Zl-kndge{Q zZf%5qxpukM(XMzw^2Y!)N;n@bQg)Yj=ZEjl4wI^*t50KtFQAnsTmCFuq^5{~F zl2!*7!k-{mEq$V?MXQk@s;K;8bJHB%3|Fr_=NKjX+2yEu29G&ym5!m*KkC3q>2K zy+va+k=kq3W5w9fnKVS>hDRIpeo1|P=u?2v>M2EwOk1J+v|ACc9P#J{DuMKR)yty- zFUuT@*YJQhK8yd_1wKgG$$9Okof<5(iK5ed6z;~JwF*1f2nd@{K}y}>E}ny}r@<6< zSnUDG$W$OxxB+?WFk~bk*qO;=(du2Eu0aH8t3{4HU*sv@r<1r*@uSBTTR63$i2h^Y z5aDzz&EL4a^bVpoI*s+-jkhJ#$mVspr;hZSqAWIl1aOif63;~$XxyT1QEly5FNcWPoo|Joyd#36nRyVjopCy5*@exQuH@VdcdyBe7&d-AM`qL@srwbe|GD%orJZu zp`NtTbJ{6^<1F2&cUa5M@U=EO<*i0*I~EHrhtS+1Ri+roGl?6QOnFA}@AT)Jd7j6g z!F?%adZ0nl*4M6kt~6A{@F~E%8ZMl3!mUjSATadO{=U~@iI`yd)-UzIy71@egS%48 zN=qab?z5DZKt-^E_913i1>@)~i(*T_P!960WuJe)g>}QV1@yKZ$ACX!a`m`@DM_}0 ziA;6KwX!d{w@!==JdJ~=*w8yPzWjwCB7_4oHfelWGLgvfhEVs_!=@Sa)mj+LOUCq%tGyP)1%;Hp4s zK~dA=CRIFqlR<3X_AWDV2On4Do>f}kEg+fh$ZxT))`uiNV6yv;tB;G7eF{|K>Hul* z0^@YvB>721jX&~TF#0L#Y9#v-&+xLoslJKR$Z2FXg<@rnQOrZ6q?*Iq0a$g* zzO76n@B8SQl3YesmG>rg(+1^jC+A04-tMySvX@I6H-g-)vH&DjmW?7u!=gv;8_x1z zCe79IOXZicWyIVPS$1y8=+x0Snq|pbI7j<_5Z&`g1bC#h)>^KVUMjs5c`9z0;4$cR8#dukDd^oz)chD(s zF1-6_-%*i3Bv3@+H{myVVn~~en_a;BvFjAq6BE%6G#lPNRuwcHT$QznbRWTH3GWx4 zFPuiIirEC#kwA~Ibq5k6sPm}bKM!S=rV2zi4TnWbW!82L#2#y3NyAFPZF&ABqU-{j zSsx^I#BA}#yBnt+DvHf2uLt=UN`f2wbgjne=6!u+M%dW2A-JI8wdwnCPT`SA{_>0p zhMzq&qoj7Zpg%sT@-SfF`*76G;OC)H$<_{^a!(}wMb9Nn(L2mqw5|Dhn_zid&kauh zyng=8iuu{(O53g^d@_4%;b!pIQC1*i)LpUJx94+Qzr4TgbxcoB{49rrHO3O(G3STy zH7LwZ&0EECa0e>GxyUskA0xtTKtL@ABCw+U*oNnVPnj6_d+zHJ_hyP#1Qr8dtxdQ{ zV4?OTC65&uZ5heoqWw`KsK+$R=zMLPX%$uyn4}up|A@-%sb!>TRtA1}jI}fO1#vi4 z#+TNb5w($@&__~LVto)gB-WjA*&k{D&i{~Zs?iodnS7coT5V{-ltXo4zApmR$zw+c zsxNx#r|mKK`Mh%1Hk(?cPd!7LJ4%e70a!%#BjEMY=c|?U4zkV)imHW>H^cqYlLia75R<|vS~1v%ofBRJ*pMZ5 zNbx8-?K$!W(};T$`xv1_ z_SYwwk38E)fnvh39rtM*dBJ9E zJ`=O_jN@0Mvo5|rY3&96-VyfIwTnx?L*Yt-y>s{Q8PU5XL6T=mxdy<*mAdwe8doeJO?$7{)K1k!9Xxz$juA-&f8tjHWcIp6{poQ-Jr%E8jIGw;M_Z z#rmg=h)7(a{y~8>brA4?(uTnERza2Dnu^Ri<1Xa)(c+_JDMi#L{2Yn(30?hKvL8{L zG&V0#EKR4n4;+wfstTu4)<+^&#O*`uPdTrXsOHk0#(tsP2~|o0tK9m7q%HVMz&Sa_Qse_Yt@xdLtvP1wPH!V4R5bs2D}oR4h)$~Diu zCD(xQ2q8}x=4A29J2@T=#qo{-B8FqQ^yRHuYix{f0XaY4Piyd3)zi~bQ4c-t#2E+S z=O}ElwhxciX#E78pYj#Af@YIXLTfN({*6#v=!xg8f+O(-(rNneZ7mL*6Ol#_hJ9#4 z6QlEG-slZX;R*y5Zth_XUrxpLLz77ntjCq-inS|ZdJ3x)x1MKt0vxai0a&1n|E$cp z;$&(4ZfYxD8z4ATu{_S%KP!t}8eprpiFpPZ~B#*dpMU^YdUfvq@iOVscEU(>0L;4!s70Hi3 zw?0hD2xxMscNp1K_IwK{2_0z7N+yN&afHXMsfY+P3$At1b_*j=a9W}>E=$$jkheoZ z*e`+WZSbdIeak?PvWfNWf?FOd2=@L7uRv?Zg7gc)R?`Vpb1lnzo<5{4_Ycf#abR6TDJT){m;e;;=Rfvb=;TSTAcC;%s(FOm*gDi065(ydBzukwgy8=C;^V)j|Q z|CJ3|l=KGVdeEx_EfkCr6qq`Y)j6eN2h+bFAY5H@;N%9ch5hb=*WV^Y^3G!}=n6sU z)IxJDjSA<->ge}Dh18v7Y{4-`Vaz_|J<4nDeZbfp@=(xjcnL*si~Ax=-y-!_nw#v! z^VdqQ8fcaycq{kM){r^UwmWcjq^u#~0XQ5`4BZYCr$h|wEV>!a%g#0aWWLbX)w@d$ zGGyRxq{<9`VsN)otFpv!hO0XD@ivhAOO_RaFTrS^a&)4N{q$z9PsxUO#L-ei1N=tn36p z7M|uNpp|_ods#xWP+|JnpdJy%S8yZ<6r5;C!W(G?>H%F}=|~FhX%4f8V2M7ndFhia z8?P+5cUd~PoGCw?{H#iZX;Kb;pDdJ^hva}quoQNL__n5NzutfpepYCm=kh`4r0+al zwqq_2wE}#O*mDTPBlzOa@uR9Q^sUWpk1OC5|B~At7U0AIU8P>3o;R@AKt)u|7Nxy- zm-q`HlW3)@QqM{XSx<;HVDX14VCrVW4t4QRj5-{-+$whB8?mTErnN|a(KDU|6k+yk zD2z4n93TmkJvh4)i+tkktWyo*AEuP0qH_qIqNHLvKAI@a1L6;qz6&i|gr0)UM94#8_G2f5uk|vt z-eq;WX|_OqhF}r;y;<-Jk3+klT$fp*m~ABs1rFM0StR)+&uyP0?E<1!enOka9>5XG9|qazc5VUv02rsDDiZ9H=+?S5Mc~F;H+Qq7<2fSZ|f2L zPD2|o2rFn*jPhYB-Trd>plZ`0ad1m|(yz)33)h|%@CiqY19<9XC7wkET z27d!+Q=0}>4YRH>yBP~vAOg1IT>_p=f=89H_Y4G?45+WZ&bil;_W4iiZ_hO$N`X!9 z=DK!F4~^IlKZeQ163dPi>D^6pO-Jf=R30#CmEspd-EFg$-K=I`;7WI>Pg*{)%uLOE2zJB{DjiLU%!btmDf?K@ z7<=0*y3%UgLeg`FdTU|;@BI(*^J8oJH#M{^r9}l6%G`BLW!93Fd zjL-WlHAdY#OS<e!{9^2x-V{YMu* zac4gQ%fyY1_;0~-dehy%0Ly9cKLKUMKLKSz*?%7?;)9OABjq1e{L2LQ)o(#@^Y1}1 zB+m7KwGa=Uyf4+QDfE5Z`ox)&?7o^^*8gSa_U#9<=cS$6{JqzLQzEauJ(EFS#Q9dLuC~b zeL-u5>BK!b^T)V~4Q5e?(yh6qS&cIAAxOW}@7}r8PJMm$M2^ZtW&W)9?J2~D^X}mf z%H}291@+yCWE+RB8=MTRy?~!!+cZB>s?^KLGPnOo=v(7z)$Fm^V-2Rxe)vnvqQR@` z2SCdpvLx^}Bb|{BWtH^Cp7$Ge1<-RzUdWV^Ld}KIh=%oq^~|xkV=pBEkqH$&uwu|a zbg1QZL#=Q|;JX3-2oPciv2%9L>Zi1o^tK5PDOPLe1s>ur!psYvR%BPCH-XI0n+1pa zb9B|BUd7DuIY_7!KkN|RjZ zHf~wX^8nIrzDN*%(aH#nohzx0NU;_omhnemd^gR4)@|ShmcVtU>8iSsH5J^jRsJ5* z?;-Kzl_2UX#x}tTgu(1`eTnG1_=_-H#c5_GGaP4!vjdFe&<5M`gke?FyWbq`KX0C6 z3EdUSDb&YvsnY)Qps}@Yfr{k&hFowcSvE(VCEKlcH@Ll_zBIis3tmfL!$|dW(lvP= z?M+m3bdMq&-0Pfh;;NF39oj*r!G10|?DF_1l6^fpBpVwedhO3>TTa9rJl^7Jne@kM z>Z3&&)f1TiM{oYSjeZ3B2@~D---3SAi)DWS`d>$pom}-#(D(T#=>M+szmNSop>@E{ zZ?C`grQ<34Uko|CIqTQ`{J;B=gW|ro_9I&!Ou7rGpwLgTr(L%3-t6_Y;LNnQP2&y8 zR_MK1cIdMkyo*L;%*CwwSJR<+-SMqeKSm zx5~h{P|6k}Wd7DKD1(@LlIA@1EJZ+B?BKjJ5WOiMS|h1+W(Og+2IPm;EfvmspfQ4U68Oqv691fptK({JP0n++{f91Cy)2^x@sR=$MxVG;VSdC=Qt-$ zt*Ry_DOlfq5itzz(%>FB1xznfQZ?C%=%_-j;dyf1;XVeS6DjDhPMA^q(wr!DvC%<% zhP4T~G+Z-`3p2S(Iw}Z4xOS8=H8>SuM;cx|(vQ41h>Qk%gWu|(hZ*KFhJ8kMj|hHA zz-zxjek%UbI*({BT;s%ljluZ_aBgW12s#b>S~3*?t&4G~IoRc(=dolybQq$5cSRF9 z5SGTH3QT+0&5t0H55i6%gw=aA2z-tUbB@Wmtd}bJ$~sfh-qV#xa#-zB7u)kxoE$j< z)-aHC#lCzO{53_Pwb|K*c$U?0sONE65zEkmyHPG3W1L_@LmX;7Yx7NV z#*BajCXbf&f)E9an>WYeN27Fm8?Pzm6z7rUq(N+j(yTpC+qqggfP<>{Y1AphX&my>jIQKU zr4pFJpL@>FFazAe>9Vs95WoY5Vml=l#=Du>;(eyVMD~p!KCuhFL;h0L5_JrY@2Aei zww4=b#XOGnbc9+5;{}t`$pSIvq1KOhvOusbV3+4;4wji@Qe4-E>%-BzJhbOaLLgTO zVxc*kEI9EY_<^}Vyj`9flhEPu0KXQn1NBBT5@;e!l0O1galVZqu7~hKWyPa90~NS~ z&`y^!zJ`~ZpouWWM~zYL#pCO-S9?W}_jJ*`tVX|`cM3Rc#JDaMBeeFdCUEknggzS+5g z`JhUI)$D5h!CL6al0SniGc=nS7RA_rL}H+n7!deWf@M{=WpqTxk+2|mz1AokP#Y?U zOhQVP**w5ync9-gx=1+_tL-_UkJ0l93;a@Jp3$AJ&37&Ce!+nu_>m_{MlwfwdR3{u zz1c)L3}K*HuTJWfu%5yvaS3OYYa~}!GkJqBCb`$xpgT+pijddoyx$ewZ#FMYrUYWc zO__sX@=SUEzMH){Rj9YoKgA?C)8FL28jx&uNv7y;SG35(3$t>T2A1iYHB-Ciq~sy{ zPcZ!qzQST7j6Q&h_Nxl-{|c3y1Dy*;~EStl-v_ydW56;r%>B z9AF1ck8YZnRq>lXjhn}^XBpv;s!XiJ2YDhu^~~Z`c*pr_i zXtw?dBj#+SZ{iWfR>i1E(zrW4;&cILfd3^kMU@Fo8oR|c*l2PX=Su|F1-H7;mXgLT z=@g?6(9yZ4l%}or+YysfI6tZ@OP0^`)>afW=GQK-uV+I0!C&K(0MqgI{uI>t)aAbO zVUEtS)P=#%o2WzSxrxPvXG7~^P8ZZ}G)B3Z=JiL|@JD8NH0&Y>;F9OT4&)&a`t)8-p?-YyZ1g5b^%!Z zU8^~abm@9cv#o5NZHRVcqN7NB?=DtvE9(W~L|n@X<6&O6G==}WW^?dM1~Qb#e{;9h zG?6&glw?iMU?a9B*eQ3Yi*bd_9dVqq?6dH`1(6epOpdf$2|I8NBi@vH(M-@Ng#}2z7%eFPEY$lM(W3=gU`VfsxzFK$IM%c zyt0%wi?MB-=N{+jt%V70v&LG-A9yg@>cNfi&aHEsj`)TQbG_=@F(!X7Dva}D;Jo9- z1rkH>kF4NedP>0>ZhJcNabw(ig1GyPC_AdPyQEflFgucoI{3#yclLJ1fyM- zIy5}(NAwyeLTQkxMz~Psz>%(hff>R&iV4?b)GUc5KPfrH6`oEV%$kFfVg=LSSHWRN zf&Wwp+$ieO^&Mo!-9^Y_P74j2x?aryt~o~Fxk-J~6(=)d{jO#cYp$Z2NEy{)UV6Hn zZgo;ckT4sB>#$ezaFXhv+0VzqP{M1Z&6B{p;fjcwo#0(?%%xL($0RAx`?PE5bs&~? z*L}*LbRRoaG4V8VPQ+Wc-$34!ld!iwk!0;>n}~3i=k-}Q#^w+hyA0)2EB5@ZVtRDm<$D+|3 z^r@3$T)oaaI1HP*GGz3m(qRKe|MKhLF$3R)R!@yNzvsx9A7b8*f;9K&_|U@p1#&aV z?GFPnEXo^dBb&)l*5V`b?%LhaNe{EBo0ZC9Stz6cPWlB|q1UI^H}B&%(}Q=P!C|=# z_01^<5O*kDJle?7r>0kDy~~T;Xzb;=uOi;!)qpc&)P>PfHKI~6htLIM6Ogj?TLRTF zVmDE}-oJ87SW-Zyy)k-L6;66;5T%`26hm@|9M9d{R(LO@v7o!pUwv>s{HJ?|Gpjel zUjacy80v)@I{z;Fh=0ou{BGeUp&K>FJ7ZCX(n~$ii4&8Pf=Wgu^D2A53w+$ki^vsK zZ<&i|PclQSbm^kkyw1Dyim2`xbhEp$$h~#SkTiAUB;L=vn9Q8|64g+jI9u%--g(}dyi#=*;*kRCX`fmpZ<&+PtsBA;bidn3(WYOgwZhM8x)B~NmNI`Z zt}{0v6O&)lx-R9d%PlcQZz$@FKH~K!c_`^(1aGzJGlpJri@VIiE}vMyUt^bk;%DKO zEq`D9gkKcDAC_5tMsEMyqED>duaW!x1yEUM^s~(9H%a`WwR;Olr+W8~Y+2b%d=NZ# z!>CoXl7jv8wCS|_Y0KBeTp6&k<+QC}w$=DkCr7V2xzhI9@-_Nl37Q!EK-zgkabK#u zDRH-gTiz5cF4J-Ja%_rSqAc66J@6m~-n*3^SDQiy`=g7ybhs6CUNfi9M`u+0;0T--Y+g)nD%+RtS{fhIx^?T>cC8!M5OXsiTu zsi3UEOH_~Jl&i5@;IH5{?U&T@bd-we&yiQbrFMov=DU@=Ez~2<{I%wk9!@!2XbcF{ z3v>n$8$XWd{JM8tx;g@@YG+E=N(cpNtdQ!dQ0s~bVf3lFLtd1z4c zGIwpwLbfrkYpmuvceZFd1j_S|Z;~Tn1i@zPU7&l?l_?#wGonINs+P;Q<4AG5u(U@B zb=i!rmp5Dkm~ugfw7z@DyN{(X#OG?Ar5oH|tw%!m)I7jzWC5R}T`qIW^EC2%dlS=} zH`5AzfIGe#dB~08Dd-X$E5K3%<~z8l%iA1qo)A25n&=6h`l7DOH=fJl0ijttojz~` z4kT0@@KZkTve#GVJDq&I8%LhEE}N??n3IkMA+NBRlsA(p@?k1 z^6q*@msbBLg0;FbLTO`IMAr)=XpDP!!2!XJI^(Z!?YheGW}Yq~qnabB#Bqa$Oa2t# zr(tJULnf#Q))59)y9_JvmMog(<3iR7meBH9WY3^xJ5`#UP;R=yj-$}y5=LR_d^d0r~m!NDVF@=FZOCW3m6BVF6;KkkWZFKKgW=d zRDXXEp;p$;vCQmOVfmeD|Ai&e`ycX6d@cDV6lm}i|8d9TC=(#&nlWz|4%u9~>OW?L zT%8*@VyWxKRX^UHI%Gu3`Ll~p44A)s*;4+j$YIEVl)_nihBU|NV5rRlTuK2m8`1nxYn^6@2pG=juk`V_1%$OEH6YCEifr&7O&& z+iE=tr3?G4@X2L3{Z?AQJrl|XQ<##M=8Vl?}z7!ORT-eVhX27hcN0( zR*9p9(e=i#C?oK&z)r)_h3_A|Fu6{d5m&o6%Z<%Z z%8i}g%z1*OM5ome9pQ88vVf{V^@*v9#U2vZo9GI zf?Me%!D9dOF6!j5S;rxQ1yDJV zlxP>~K3G{>>*I)jf>WmYV4mYW;wsPPg}~)nvq`5x5XESi*-Y{VrJ6ZJI&CZbGa%I% z!navK>Z_hxKalDN{uYe-`w1W?zPf)DXw=s~cq$n18TtM7z*n_bKk~o77I@$?(#JBv z-=y$MzMN%>BP?p*4`^`P)^V#}xSYN|^K{La8pwbEk5xyOpMSC?e{k@|>z5ZUvK=$E zbm-ypvxo9$O)MSq@ZDY+U}bSIc-o(02x3!7#USNVeVV+_$E1z}vDv!YS*ANCm_9OG zE$^*&Z_LJ!qOpfrPo~yOm;Lh~Zfsis@yR;q@F9 z$vi*v&gCt&wL5PgeYE>te0O)F>E<0I5UZz!-K(e9)0;_xOA;%djflZ&cN96*Nf}TP z^bbZ8X>Q)~;PcS|=&%q|&(lPRoxlxyxOId_c;zW~eg%PpCVF5wu<*(HeCIgE5bD&7 zX{6niD_ZO{@@Q3$x6BR}gWxB6d&v|tF}8J0NN*Y=1m+N~nM|*SLZTYZ;6f@|4pPrh zoML?V3E}V%DkJ1rc+1`v43l)YoG_1A@`&I{O%~zlDO%u@2YT!Cq!moCAVwnHGt;hV zr!B*pUg#nn&KO8715pEqqed#*1!F?ba9Nv1o!P=$-ILj^OG31QRM-MtbDiJ@b;< zGP3RE9lJX9>w+1!+!(CtH=63t7Cdi<{j+xu`47f4}uI)r$VaJ$G5c);{Gg0U!t#Otnz{!0F$}{MGc1C3( zX+3Yh&Z#4OJ$n%0aY`#C)&JSMX$p5`+NxH7_Vd(XI;Wz{2mVnV0_HqMXJ*~1zgS7% znh?&gRo$QCgmWU?>&#BF<1AsGRV-<+H|^(1WYxuM$TJ8EUBI}?@{BkuNC56A^vXAM93{H{* zOGF?wQaCYM|6MG3*eq;&bm*qavLK-!WtX2g(XGq3vM_ZmTmlv?!u)P3O+5uk9&u<)DHTAFP1JFVG7od05%IVfs zQ?C~sTXSHl@2|tQO|}*3XG8a{Mpv3s1I$FdxI#tPM7&7rE;Ck?6zkge{nb0VexI?k z>#szkmadRIQZ9+9kIIH~d3bK1ge*@Ki8z&1>OJB;lOD5S-Ckdy5cHVTk!YL;h2*5l z*oC8rj^c{&twv~Wqqk3JWoudyIR^+mn6a)I3>sY5&W%>4f{cUAg>Wfnli^^+Sivya zb#sImt)j_@Sy|Egf)X^W7QdWTLlJr9k`yEH%3=61&O9e6;%Q4ZqtwXPLlUv$FhJ*o z%Pns9?pr=Y0cf5oED1#zNULd_o#mb(53ES=mQ-YrA+>3D-bEgU8v&&8cKm3b)h+bY0j#4nZD31FW;ZKIPBdOc)4?7}Q+8-!_aRNbzMzBB<(n%+$^$ z9X{MDC{%Y4ps413#3TCl3VKnh!0B0argO;k@&gy3=Uik|YE+lC`F?Yh>}W{k1JtwV%$rmFY6 zD42H&tPqzkCm)aIEq7#N!nIywI0X+C9Th=vc>^Ml=aY6@Hi)J94;oB(v+jzM(aYFb z+l62)hejRJCD8rJh8R?)D~stV7=icHro6O?6vq-|HMKr4*ffxFD=ccH;e4FmAn?;- z%+t<8j1Yap*_)pR0j*^h=FmM;eCYAy%rYKAbfZ#q}}(4FjFfXczT8=1vku*<~{ zVXhUs_GD4PmzORssg^xHOSj8tUvN;~-M1^P6;GHfnq#8%<_AV|PjI9X`apgRZ1}OR zTW=qx=BST!p{pm?=In!Xc(clGS`l_396ii(i0&BdD!8JIsT(e|5gJqFzrwV}^ZI$< z@+tP^jZO_5>YtmGJ@OT-L4uoFUUn|I8jDYO) z)BcPsuZR8WcPYGluiZuGsbs^Sl@R$hSnvFw!#yL#=}*1EK)s&)@bO<&3a=;m*+o~ceZOomTEv| zvt;nHLC1a=btirL-t3#vWZ|jFxAu2&l-D*f?YRZldqY{N=iDBE@B0PsZ+ajeAUXi@ zKnZhmMkZN@IY6Fh@mqBpJ#G5(aO)TN=SqzX$7*TPpv`aisZ+2y*+)iDzjVz-eb7@qgUfsbsn(Qc~^JEw#$ zW4+3lLH~i^bur-e_=C&f<>{N+-=_90L-tweuA|~Etnj?UjMF}5_UJ#WYWK(ZVLw{l z#MeQ_@+SImwB<$o{C7F}kDKooojxf``K=(qCozss_}N0{_h;y9^XE*mOyk$-`>0I% zbM*ZWou)!9#@+>ZeD$(%&v_|HJr6P+iUOCdd(iOP@lmVhdY+#$eD2cUVt*O-AQLeE zt~*S7SK8lUnh$@?TOu0+z;<;xLqeB#_ZGOfMdwIVK?F9O=zSNcf9!7uev`7(*x4uT zy|34`^o6i#5|o7HpsqId$;Er&X|(1h?g1+11g)54j&dtlG$mu_sm`BL&}K9dwauJ@ zJ5}FlDoE*AicC+igRdV472T4^NX0K*7d0 zA48Pz7J0SYaAx55n#)-sluHMd3DRlovgiqHd?Bwj<2LR`k!$7piZ($4?-*}oR<3=P zFeJ<)?_5*afxe_?*txiosIDE_bA}U~V~BRFER(hiTn40CGu$b!W2-3%Gk~M+MYm#{ zE~vAlqoiYnb|Cx@*jZR>AgdbjL$#64z*SQ^iyBY+>-e4__Y6mAk!|J3O-e_Gp*wma zY((=VzMI}rGBT?7vEU?xepSPOM{+ar2f>xK3LyG zeG!L{GdFiI{m=+T*l|Qi{b^P#g=*LfkGh-NZrqn#e3rseoHY*S7ESU<2|ahH%e3h8 zY&-4-Zm@-uzYfhGIk_JqSQz>5Z@=2T`pfIke^oF0mMQm%H~R@Rd?fh$W5boXts^Zn z`elgi7v1cS3|EG~$5x{S84?!)1#3sGESR$QxmyBxLGtp3U#$iY9qloF5M3MoP$pla{v5{7<43qDI zMnlWFc+!%BiMvslH|#h5aCq}wIwixH;}2h27D|%$?tA1wqp>1Qvex=%Qf#Dx?hcf# zJrnxi*NClN_l(9)V~x)1O;DDjK23$$CP8?%nI2T8(ex`|2(hm-yRAu(^=`>n&G5UP zBPv*-g%CxO?F#3dc8SK?q)jutl3=|hXE-e{C7>`7OUCyKJJr~7*hB8^JraK}?4><> zA-RZ_zXQ+T#}5WYMTAY(rg|@@D&U*Ba=q{urkD0;z;faYOjhgLEy2#%uI4uZsf@1T z(t9{6O+Oy8m=jpRdEqFq4YOwM;ogqE1xrosdRBTnZwk|cDR9WC^%ah@hcpIN>`*TU z7C#1StJQAduc6x$_M_BqJ1RO}bo!{Db*zAT{B_J>M*$5jfmJ5qRs8_0vtQU!LC@2I zO!M(7N>yA{TvSL@h|F3mHw+|%#S=UT9n|aMJ0{R=FzaBmUAv8})4WrW4y#E_a4SvD zYe_!P&DtaO3pT<>5G!h5Bte8BFF>r-xTkbnAynutb3vbX6a4_|E37Z7a+pg=bm4Ja zD6)yTiz}LL4??++l&8&?>Pk~YV3)Y!Nm4IE9vn=Jsr5xy|?mC6-zlyTbVOyQ9tJ7T&5JwAeEqq$=1vXw8KBpdd3R0M@6^5; zGd=C9p*^U>n)osizcp_}V0&U#*czXv6Y*EareZ5QqnY+OR8IG2JI05hOf?pn`m!C~ zPp0}|ltrZezQ?(G_BSvJ*dYF`Ei+%n)qWIze~?-UjksW$(65tcF|fXbQvdP6GEYl{ zzuPJkhaNIVpYd|^W4ASP=3QBKa{743J%IUgU^Gw{=Yhk@*z;k>)&vv+9UMM1a=4FW z@p4NlO7+|nErG`}>VeWYed�J+xqZGoD+Hw99tqAuC)@)Kj`EI2E#zjP8osE{&mA zrmyId8id_pOiu$z*QzZ$gSIcGwXjLdWaz>vcJIpYdcDamY18GU_1^aC8l;5nFJ`UQ zJCThy!?)$!mE7Sd7`R)+JV`u3T?_93DQNr37=Pd@9t%C?M{zF~LNQCskHb75e@f;c zV9uOr?^XrVWbKS4xYByDOHLv}>lTMUpFWl>YJQuON(@a!$ zO+-0eAkB0~MtLs@$-%alNk>P%#6@=X)lu<-VTUl)#ra}-%iacF+r4K#1@qyuR%scz ztya1>@+U-Ocl%frlOcY& zSadHsNHdB=-BE*0SNBRiokp;Z_q)E74zc*H6?d1Lx4f z=Hv)na)gQW8Q64O>jo+g=k9S9@5k!#rMjh)SvD0k3eZUqHiNd%{n8XcSlBL5`{b92 z3B{!eS_Cc?Ckql(d84U1YvU5$C5&REPymq~%D}a{=^(~b+;i_XhRl;t1F!3B>}>j3 z^pFRk8;kNEWQqLJOrOWv`vcY2mA^%<{^gMB%f8Gzq_U9w{h{hGX;2tb9#|2Z@0V2rtI+RQ^c9Rx|O@_21%xMY5(gbOz<|6skJ)!`W zpOC3L1lT+e>!c$Q6Kt8pSo3_-CCqhej0>_ew!83=%!VjD=N7)c7Auv})eGE1DIMBzeVERFajUkj%CCDhh0|Mf!Q7!Nd(kU^Jk{M^U7 zlq7bwaPZfa?MDvoj|v|c_oylg0;M*s*v1!?ybdOKkDNdcP?^<*&>#C-O=uXY_9OskoNkN3Z(9W^2!~?43n( z9~Tb2{=?Y=PiOyCJ0PpMEpUn zi0|Wfq>Xi&9&VLYxB;lHcUvE2tL~G;Aei^%%TbzvD4_h_A9WJ2FG4iOn=?ZY8vkU2 zY_ROog`6-Fr#IY0%n-U8dce{_hPz#a36Gr=$J_8R;E?gbTLUKmW&h+r7{0C z(C{jtY!&zhqTLh-H0>JCDzuN(BCqg9iH`*6lHq)?#>O;&L~<@d^z~^O;CgI=@ED|Q zq3`61$%l5UXL3XlZ|pu@!^#}<=f^@g3;sLI(@!uhyz@Vh1M}tJ$C_`2L>K*csQ&)a zS+{_C+ajI+9H|!TqQCMU`e6rExJ5h@pngx6jW`Z%OWt;BuFoacsW&c-ANk|B&4;cm z9=G|x(;+c`weJDewWa~Behof<) zwDQJ`3|n%8h*;eP?`X`3Drfs0lyFy6x`_bR-c-#|w3oh;Z`Lo?4JANi zvvj(boTymH2x?L4TY43$>r&9~ez(Ghc_xG9r)|U6qi}=c2l}j_h1JwdFok zw@@>*Vg0~{!9Nat-rX~X(f7o~XXWr=$JVTwmh$VX(Supb0GqdCDCB}qGcTpyC&dT5 z#h_uU5l7 z74k&wDrbEZZ-jndvzjWB--p-JdJroA2=i4nP?E_|V@%o34lxm!81K3W5jG84!BSH+ zzF|Wku>q@msAw1w4Y$@AD$!g70k*Z*;B4x3@9R#zLGMF`@08RSRmR(IIMzT*0iXm) zj89>)_5yww<%sV+AbS*E3JMw9G&tv)y$0h=;>m&1F6O6fHm0z|+X|83{33`r04|@v zII~m*54lUAhWF$R35buGnB98RqS?wC5*xoCxS0me)^jv}F3>P*MjFT3CB$HADq z@MeF-q)#&rp0HQx$`E&iv3@FU&dUkZ=!olde$ z@_!9;{i1mNW$@C-_p$sMOO!0hD|iQb)N1ymGYJ~in3{z{Y6h>_X`R(PE2Qm7()_2x zf17%8*qk*%6JL!P{QIEo?-D^mh6N0yjOwb?o?zCFaK%B$;X<>LhmjBodYFRF%7QgF z8HokD_AsSRURGP%-bDuIn46GhV@p|YMoI>U7hsOSh8qlGPPek6Pg_DGMMWrEyJU^+ zSo8Xs&_u3N?1hOdq0ib|I`yLMVi(s)q?FOLB~>2|*Xs{HOO`uOyByde=5aO;n|8f& z6eUH6e+gu0km?9CfulxZEFh?p6ic2?!U+Lu);Qa^m0P02x9S9t%Fzm%0u^C|MW8F$ zz1pL|apA9{t6u9TA0nS6-vYN&8a1zw1D#f+zAiayuwj#0$4V!)mPa5`$M^{^gsjcA zYMM)yOD^vybZ$wM+Gwl-ta6U+Sfws@DAS*|SG6(XU168FrKU@N_yG*G5t;((Y<$ubhKuy=@_k$OqdDl@zvZ> z*CnYPsBY>(wBz!HlGLb8oR&O^3$k#p5SBwg9+R}T=EK0^v94n|#<$c#9M#3hKd~)= zk>iow^~%G&OQhQgQNgqwI$A+xmeI!igu>avuLn9-A0VId;jO5p>1EKq2d#E-s`>av z|JF=d(O$7m3K7S=9n`X~j z@!7crARehw0zE{EOpao$=h1#cy(^b@P2>(2#qo~e(Bh!R=xO*NFrXZsk0kc3HSp7( zPZKyP`eS8J1Eh;iv{gj|HS7Se1nTh3HM2P3{AP8`+r-Po@`DZUu3U_iIh}-#0!jh0 zte-lK2o*$EF!)iSJnnJzX(|ud#L439<=3l^ytRp5&zNl*LwaUK9?Kai8jYMyRX>oR zAK1eru$&l4A%>4ViPoe=!6MJKENDBJ2y+u9dA0J89_=*7P z`r71kymf}bPI&Tgl<+_n(DW1Np{^OuN}}a)_|YtJbUC9Oz;DJFA~#yHt8fl~dhSVW zY#x(LMU;zhPrZM0s>%W=XqyqXjnpYpa$vmI}oK`IG zn6t%Sc~6qMqnE@sw-l)16M*_}Tzq7dR*RD>jlvWjtI4|wL)2;vB6%AKPQaU_hoVir zZ4xk|P6TGd#MCS~f~_%R#&Mj)<~%cx%`F#~b5Yje&!6nc=@Tm-n@%+c{)R6xqzTb) zP#6hH6kcxMv*SJ{v{Id>?#{ggB(x$HqGTr71!b2`mKqUcUf5ltOBIc)oRd1XyM|5O zBpNANO!2ORxUcSVPc7K&*9E@C3lU!k&%$-d+hgRrWehF@siwV7Pw>U1dPf|1QFnn3 z*5|`-3^{4rD)&b$d@rY?TxzlNk3-qxf z2(ymRJj-aGJF?RAaLD73B?8oig4pb7r07!M%dMwVy=08OQ@(l6{7WU$@gAv75A0Yc znwl3vY+z5P1;^&ZLyj@07TM zVdWZXkDo*x0WUg5MpC7($|$*gHElOy3z~0Q$L7F5(wZW8z=gWLU!~7<5Pg7jw5auV z@d`3!r=OjbZQKs^6Y0j1mW%2J={4d6jf417x7R2$1m_3~eU=y5Azk2*$Sw?&21)O* zt^DFP;q$E{%w0>g-KHdb>#pPM0jNP#Ps2k!L$bk+1m#9Cl$y@I@b>xAQw4T^BZP`+us#o`5lHPxEetncFN@CnslvpX^Ld?LwS`Q;F|Aw-*Jh>cFtcsSglH?2B#u zQ&EjQba8Bvqh9qQP&Ah4FLOqPM=j7ez~UPDXGJA~`BD#t6;cWBYKge)2CRM=H=x=a z4Dqt#C8JUM8FZfbkvO}^zx!UFcW^en^^6jt|Aims5fslJMoMSANM!jK?jnHujH2r{ zlu88lY>yyE_I`$6K~G9in*bYj2b@nE;xF(NK)1k$;~ZIU*%gMa71P)Z7n zIvJ(*%WDrL+GsDLoZ7vF`S1{g^j-_H7~1=qp1>)+Di5+1Mft&Brpr?uWE(vYhSn|6 zjCd}Xk&)Wq|4SVs{_}8bf298B3_J^V{A)t>f9)#1gKEn&e1EL|@42<_X#HsY_Xlf> z+wyO>_Wy6U_7eu_Cy^HZ_*ZpnKcV}7K?-knaV!>-(|aEr$Sc@&ARHWw3EuUkiUzMf zEI!?{tez$<46gYV=#27QwS9Q~T{1s-`ML+!PL8^^W|egVke76>bULutW9i8up~GE) z0PK%IBuOw7GVrk|+N@fcu-ep{tY*5iFYqutW<3k5-wCg)(>Idilx4)fvXgLZ)1hz; zF;}N*5r!~nl_(T}*-RGY(C8p@C^YCnTYEtj@mRw(9X_9^tR_z0`Em zyHWrZkD7&y-K7oUn{w`i6>l~j`r~pq3U9^Qs76p}g?TH{if*6^`D_X+F4OU*WHq$2 zDakw4TbMrz3^}VE3>@W6>GVQ>ep%m=I@Kcme!P2m;4MV>iB2a)byJT&NS7X;o}O0} z&^bB(gx{66j^AqoLifb0ic$|JN;0o@OwpZSrPCNXqCb8e_|;f)(TucZ&iv5GKc6)2 zVdd6l7+k}V1O=wUOP9QZW}Jpf@ur`+=4=~V{K`%tX9|0UlZz9>pW2vj0U1sKLnvVj zJVwNqBRpmIAe!Lo1@nQTD}}p4COO#p2&>uyoXL~3hMQJL`S9Xd>6i}ZoX7~y(>U63 z#o<}@u_b(9i&y-S;+Bv@106#r1>}J{&+k}bHa7pn4i8)t=dfQ}NIJC1u_byRg)oCw zOkoN9_1ufhNsLzDd(Lm8)p;%i~QAuF60XYBAbBGI5hu zv299cB6f+HeHkZe*k0+b+FV8#KQiotxBd#}wx>>IuQMglJR&4FN?1Q?m71+iJy##4 z2KC%dfa!0|Q}0W;`^a#CbUtWCpi0^Vlf!E*%i8qe)Dne4l!2dkUV16w`U2$DFv{GYCWu zx>!|Q+tS_AT2_!*+z#{|m9@&1L=(~2_ge#}Esjj{_KH5V)G*PcS1((6x-{7Jx6L!KC> zDGb5HfaCTA+H=!L0;Z#J5xGG&D)I!Gp9pdGZ<*UPv3MKx0?ln{aMUp5(YV%~krVM| zZo-+Cs&>7=aMV0o51T~~1ZwpU2HL#^3peg?J$jw&CF{B=OmceRjvq$`A}gajhLq%{ z4lF5YejAAcAI&KAf0JpRbmEpHB&o+mKWV$)QOez>bv)#FFrC0Et}WY|rGJ)hVDIvK z)P7MrjEeMCA&`!c3A!GvIxmUQ)tZQ#1H)mC=J11D>Y!HeVldCY$fxhrx?rgKl|&rw z=Bz%Ink~7Cvmth-OUDygUXuMQoQ#oX#{vpJDQ_l+U;i>w@N@gI==q~syBB!OR7u$6ea@>-&`&-sZ0KOrGKW`24|6tFKAYaZ1h7H zi)j4)B22<>#d$_d_}1q6ss9;XETn#aeCgk-aoQ(@S-Q-9VFQPy9p1m2H2%9u){1@-X_+^;3ywZMh zVR@tfD~%Xud{HJWRDOS9P{1>w@}qz96IdYdmslb^`s~T!Shy) zTZMr7OuvliD4z=1Gi>0pcRgwnM~!N6$pQ+KddXJJJW1!rCEwnj*|2biZ?8D3gu9=1 z=eAa@2eAqams;r>eT%WIgoXmKxK1o>Z&_SPZwtkaPGYh2Z5eiS2};ai6V&<=N~4_2 zW)-lS#BgJ4CI>n9yr^^NDpkFm=`s`={R)sq4CN^=JldIpvKqjjIMA)Wv+Z~ zPT6F3I=wZWU)-mxdbLD*wZMV6T7QhX&lTb8v!aC2>e7w&F)xvIm>GvWQ6(AqicZFx zvJR4dQyeSJpKXOo(mt0*Z8MbU&(a=(VUu8$GkATho-#`Qy|EEX4=u?(7dj>itY2>H!MFyewbBv5v7xhBwXbh%})upnF*V2--UPPDF z+3JI#ugTh5yr?Pi-P!}bJcSQ(yeR?|kpWgGtqksxNdNFXPSbg^1X;G-1X=8g8Jxr0 zBCb9F=g0XrSmAXGd_?qu}9r5VrEFc(tt#8~E&ptzB13$NO2aCLt#wlNfVx zkIG)p2N9Oa63|zXLI&r|rdf3hi6>C-H@BmjB#L_eQf+UhsRm7z-I2NqKmt0oiiMg$ z(r%G$MU`M?_;g>nkeF%{F*;-;Iu0?5eHPUn$UbKnn=i;@4`Ki48cy@38`t_JS$ZVr<%r2O9PTPCJQJn(7KfFE%A-50sM5{YS*srkolU8oJeAR7Qq*q)+7kHW#3(A zTpm20+jTLgX>P<~9nIU0k8#>NB%>-zvY+Q>KJCXDuv{#26QxYQtE`674J%#F z$|_k29h|oVogM7$oab~qmKfN*Ux5)0t@BpIFCW{~-lns|=Ta3_rJG>K9hxW^@j+}v z2|kvb@8wWd6kQ-fL!WtE@9`zYQS?Vry-R+<677Z<+@d_?Py2{%*yntRF70tn>QW+U zgl$)F$R9ql+#{lpr(&xQa~ASltVP|@Eg9Xjudb*}PQ5#&`)6C~jwTE@#6j2`H#UDD zTnrb-y~!iaHPtps1AoC3=}xCREfv;_1Kc1#Ro1L>=CHuLq=?^LLp$olj77#~x?m1@ zM9EXlNY_b$!`Qlzw80KMsncv5^#HX;7rH?HGa?ps^b9e;egIbcd+twAs>DEjOEC@4 zSw`3IDRlR^Ys+kBh)a~8t#B{h)B45&T~odSUHxF+f?T^kgiv}*AJ{HiFz$HEVdc1H z*R}3Lo*tMnA$INLxigPG{B1PgvyP5!#>v zOlQnw_Z&4%98b3`VY5s)hKB>3#MZ0DI+`6%5*!R7k_g6LlggksQo5^qwfby(Fc?fl zIS?I)dimn%tDn|RN|@*Y@&JQ`agj&m7+#+@LcSOncJc+lpb4w-m*GTsBSthh2<{iyIdh69 ziO;R@o3a8i92jw_ht=%@oW}}KNNH$L$A+C98(Pk{iGgYXcIXZpTL@+Z)CCw0VcazJ z3;BX})&fNqF!D)ZftSJpCr0@LqZytz|EIk9BbY8Dn3-Z)z=WFt6Hai)pWtp$lO@Mv zCCw%!@P()?9vUt%dQtQ{`Wx=R|DXHo(#?E$)CS8X@Ev`N_}!%<>?0Cucr1M%N#!~s z+Fyd9Ke_L(Byx9XKC5rjKQrZnoAYH>>?hY2PW)fYN%`PSe3{hmFVI7L$K1C7iD?)Q6(;sYh*s zvL&MbxUQ9z^OD`+aJ3z_IVgyxstwi`@7_cAjh08%R{H80Q(z~Q`s(1fs!hhj#={*Y zx|Ur`Nr@!;OxZI79?2?WlQMR{xfGH9%d(NmwS#mq6Bl4#iCf#aY zN2a(YEvj~-XtipUDzXH&_jdGjeY)y!$Q9(&p-Kk zl6O;Du2c0Z}HyzBpg)yW}WTu=!4)~4GJsi82 zN^q*vdqto5p%Hyr9W*`=H`+=XWtNNgi`Db4y2T2P9Qk$G;FZ5mb^g$X`clHVHKWG( zPCPa8{@_0r_+FZI{}^&0CyFf-ctG~?c0~9Q(pW}1$}IE;8g$-f*YZ#Vt+-giW9#bW znKW~!+@DTE8Cx>dB!XE5X6w6*6bZ~^2J=kX;sU*yq-krU^O{Mf76eJA?FKQ4rV_c? zh`D{ow>HJA=7^PiG>v7_7_xN(@VX3Ii%~1ZN5Z^8REnq_hcjukHd_8j&InATP^f>d zmpZ|p+V!n4jQZ<@@3tXa1uIsWF0ch)t?&C2h*P@+OE|=lu2whcIxp|w*((aCq`5Jw zAURwN!wz$ZbB~ra3C65Kcy#IqIulU&X0r;$DEY_F0>9qzr5{$_*)P_W?~F-=%!ArR zR7h`T<#oGdSosMb7N+Fyj;sUeR**XEj91}`I1?XiIL?;Zo1UOFHl#XG-)PIQgR&Q3 zJUZJ3(pA47_mBSb$yyMFOEH&POSEx@6{czC^Q2O^03@@s{Mql(K0Ki>(34oX>x6&w zqbHfn?a}NSXmLtDI`Waf3!@?Pk=i#Gb%bqWvb5{@2ATzT#KTJ>F<8Kr((D>A&`Gve4{(ZeZPdv{H5i(*a~KR%PP)I z9Qi!$Krfva1>&~FX{HfRf?EGNVct){tM~KqehOOtnh&^;F3#)5+D|!K@Ky)=i*8XC zFYv$u@o(MwpVcEIx_`{zE0EzIGuT4viDL#vlX}~S(^X-;#)nuJMin%Nh|Mm_O}QtD z6Hh|+_BQDEgZ({{%U;Vv{ee3)=vd{uViNP_K|$C@MY);xBxL~;A|FxqOEQ={^d=>t z(^OC}@_F+?0!hch>uTqWT+Uu?L*=Unp#3AUT2xjQKa6Y2K62BBrfornb2*~s2(G5ncEUl=LI}I zzt-uV^d?5GHl8u9GG8NAu{%%-u+Cn0#&nI{0shiSqO~GPt^e~dw>kAxyw-E<4|`~y za8198ZBok#oko$3mjiw$hO{8Z%1_5qm*xd+l{f;P;m>5^wEFqY?kiRy) zc%%M}<32H(p@cOSk9%g!` z5eTSR6GP&E;l$RDHJ&o9B>jl0@>h%}toqW4taqbKLTXwEfbo4D5_>{g%X$<#vSq(D zL;5J4jZ*A7q1L6FZo#|AUpmlNi+;UdM4q{DPMsFmG0mz;sayh%DTb))cfpBOk5VY#1>C<-N1-SvK2@ z1d#8q%h)Ha(P%v_AMMd(*`v4zdv4@x3XC6``rEA zd!OmvyU%f>)cfU-k&B6r>H$HFtUJ<@ZF;5`8#F)ICo?x$kuyH&csvqk@m{CA^)1yE zKhd=7O@%C*ER@NNMOw#o_BCl!TKsB6kB7ey9Mg5`s(wveYdOmoh;sMURr(W zdr_9ielta|PwpUkFDf8&sUb|F#KN0UE_f|+;j9q^O3cNwJfDtfO`O+~Viqk`&@5}s_#AoDF@_z4QH0l@hZLvS_x}$gWSDHa727h;@`w9MwCVf|VukEXH z>beyx{B|5JxVvc4#p7>nc4psylGE~4+M=<}dXGJsHN5D$EyP>1f5LT6;P0AReeHT9 zY4Mik)t=gtw9%Q}4`P}O#Mg84nLTYfIg3p$zP?&9|Hnc$t9N=1uGhG0C_D{RzO^v$ za->(mzdyAy5&C2+rdFuSkpX5`YsY=^Y=q)gbJ$8(!;NFV-L<5=yyup9qtz>5ToYPAZtBlwkVY?HhyDL3NhO~0=} zK4J21VXB*u!+H}Vb`kM}m2r~t@Y3>4ENWuv`(`TDN1iKvDX>mUr;#K*Jq-~YeOERc zM`bWlE#l!GXK_0DneALJiLYo> z>p4BIvu-TfwV2Hr#DJ|QBW&ySeSFvB3bi~|IxaG~7@nsq#UB_=U$reO{sPCKGh35w zY;o#px5Z(n;r9NaT*FRH+l~ijlH(?^SzKvMiR30ffO1BsO@r!R$`hC~YxSg8h#e+Y zyiDd`pM;CVX`+_x@*~n>-pXmOcHPw{)UKS*JGO9M$E1@k?cxyPhM2-x-Nz+UDjFAG z;h&72*RZxM&6Xod$JdS&`-@|(Zs_m=tzH|6vn^iZ#oy6NwV4LqA#bNz<+K`8UisXw zXql6(;k)J#?zOK?Onc97RR4MINWuMtNBKtGhB4JsnWDSRwW;?Kf-ACClp68etlsLD z|I#w)q%JwCCwkf=g9BC6P)_Jz(TWbUXOX|z`*S)N&7>iqJo?8N7v2HZGf{x8SqIR! z(&|EJ-a4Kk>71FSEqBt_^6&d)kUP^u$|5#Dtlew*D)pfJ zxzwnGWfOk((Q#X|!;yoGGKRZrM#fs#>WE*OR7SM?*fpDyGi|YL-Z)bBw9HL;y6xE( z9}G=D#Yno2_J-4ZlsW;oQ8LVOXzl_@<<-5oL=pEXbpq#S$xvoiB0rRJj?ngJvY<{> zL~fPrk=_mwab-)H5v`7$QeWpP-e9U>a7Czu-9>s5QzUZckZ7;moQBhf5V*rFju^EJ z!5=|f?HZrUPGUK?EXbZ5lP`C7Tjx0(FVVF|W+lOIbD2r(SGx@trWpmX*hUrFRsM24 zS`48}TF3`68%}+Lj>gd8Be!kON(?TCrMsANBBe(0^QUfaP}9@JBW-LZ zJ!wr6f0dkGxPSb!eFj09*Mf9s9Wn}{j37?BxL|fx`_|}a`Pm73oVM)SJ=OSm;ia$b z58%#m3+=sMAG|%g{6?zL!A14;e}^WeFhBSlao6H>J3q|PcN^JzjTSv4^l=1rmHuBF zN~qqa7brYC@G5=T=c{zT-f`rLVjMwM>DxAr)#BFQ5>>QNQ%dbF?6Wy&v)%CMu#-=1 z?1!DadrbSt(YwarNYh!HcMhI^1e}HDAD%J(j|N}k{EO8$_!Xr3pNkllmyo;Ey!c#> zepPapl=rUbg+SnHmCEeyi6gk_J8^nidYX$<<5(0?PxHH`R1U4XgCCw8&SCYm)&-Tm z;Okg>b<|ec>d@tnWE^X|Z>N2&Pn@IJDRU7FT~NABcTv3h?Bbvjo|*IR%3VM*{k zj}_l?ww`dbEomEfhS9uf)xRCPYPK*coK~9|L{+*~9@_hd;^H4=ef&ezed)z0ZN`?$ z8Jk@AcATY=9hO-u6Miv$TJ*mAO>qyC^@dnS@7N@sCk>N*PrKgxXO9*wlh~s7>8H4T zu97)1jD?m&##VFM!3U3DBNOdQ4R6}R;vclnGMP8+rN@!0%)ZU0ZMWSEMVfT0 zzsQ>_o(+9r{!T3Ed?0Cg+!qN#kC51-C(og^e)_qIt3M&WOug~(N5-W2vddZI#LTvZ z52-kDbmcVxLn2zZd~-$4Tfu6MO8?Q=C(07qOrKVML)0abB<;QbuK@bwF$WpXOG`*X ztB1(UpPk5`Bi~A+T`Hbo>m{Sy6n6^vLk`gG_e&mgpVNrQ`lC!*OA1)6RT;{vV=dyD1=@z%6I3ndZcj1-dY+Wdj zjbyd-erariz)?RW%C>#7553K^(W0K#nEs5D*q+NNA)RII;uECzl3zMK*I!OrNMd+z zXEzl`>m2m1j}%6m$GO#X9I+%@3n^y`9QgM|WoEuUg1FitQL(y*{O!3-l1j;pkT{v# zpB_jLNQ)B7M|$8pPf=gvt%BGa=(Squv3ecv31)bH4TZg05_ZCYe}t1LeV%o*Jws5^ zS##N&D5TskZhC3SYIz}bw=TnrM3KqX5@heBLR<h_%l`?tpxW6AG} z_uigeP@8JB_pZ49p+gdrske46C6g?>*l;(uwD$?%pq}#Ku;J?0B)x|J`)iU5(P=>n zpQ5fw1{h0b^zMmVta$8+tVyOIdm@*za=6{?@-qf^4bEay?h%DP9gB#* zPQewUY~5Jpr=MiK4$rM&*EHtF<yZeTC7@_7I^#d{FI+e>TA07tu|jJUfvoYHTG>t zuzrQB9oeBJ)+9xFMo{S(GZ?260i)Ad`{wI3`?!kKM+bFzk?&|}GBB`r$xnX7n zSM;9E+$$gLGha9(<+SWMN_OAQ~CVM(QV`Ph_^oY!c zwmmv?spnAk1?QI0%uKw5n>gKbQ;P$BQ;DpOA zwMa!vLcM+YP4mtxbj$PR-NN(n>wV@p`x>?3t)GXMd3T-MUX`$p6}o^tL|0@dj@rj4 zei-o~?*PwKJS%3MN$#t~hek!6V;}V@!`D%}ZX`QeuGc9X+?g@WIe2OA#SeMNzn;E2 z_tjjlv!vD0fvK~~fArpd)uJ=?46$5`Hkx7R%PtM(Tf{6Th2KtXd^GX`!|tu);WDi} z)+Hzi#yFR+@B^lVQ$2@o*S97ELIl=q64t0a9 z%k|k}7h(+qUvh5OTjp~TVN93Hv-U38={qvoI$JqQSia4+__aruJ?k~zlFuj%3+MgYa^Lzju)(^UcZ`B5g#Jz|3dljZia@Uv7pXpKktJSZRMYDc05$&>5iIWRaGmfli2@S z-?MgE_b!DyCn4{xEcuPNXa5f80c+k)DmGkH^f%}2PC4utd#r(ZuFTncIC1wbY0ey< zuG&_y%)4i~OhgYXaML}Y8+0=17ggk!9)-Bw1ON|``ucw1upu-&BExNtwA%c65`ox?e2PM~P%OBdEqr`42| zmYsjpIpaF#1cp}^aYscz?0iXYDP?KdRkayOR;KJCKPK)W?DxQPCsZ%65&W_!xn$g? zO${$?_~eD1q>ytAbKU}*hGnrnD>iA_x1Kd~%RDP}EvP__qbxBy%r3L^GWJN{M3^qB zcsnWC2$S=X5C8^|$*q_y32ZKS!|^S8#wa8OI_b*R36`AHmaQvZYpPweI-0Z9&(72MfQRF~ z6J#=HTB;Gj(br_~ilK+sIC^PGx|^4{bI0U6oaKvB4cP*VR}R~*5dG?lHqdhxCfW&G z$~*GHccsRO+7bp|NR08N;OrLFI`~bu@E9T9%t@8VdZ?N|hexl-*NttBd}Qj^JeSSX zxJ4&9#|qZ4B+)H+c;q%b=xU=#QN00DRB5K6$k1PUSCkQrdi7JsK6|?U$@)9d=e9&{ zyXJa5)-`m`<%FxT(SH$t-f;ecUJUX4xe?>Zc6oE{wNB|d8&15Y**#^=-@N}kNIw44 zq7i-DgCNhSVe^zH7Lx}s@86$KD7Wcei+ZbB2dEQKxa`;eHR}I0>id&qS5~uJdgV84;Q> zqkouAAk`nA`xNXX(QlaRFFuKm9IT8O2#|2jZ=9qCi9tH-PW{Phh^WH7B&Ht znOZ0Y^>EH-s%N6gkDYnyXaF*p$BykErk%zES&E^bFXLQj`5h4tkZ{ghtWbl*ARTsR zmpUS_6*B z_O4a36@`ky0mx|C_wm!1vQ9k|)dQdm9u%{KGH4i^rV6Vr|9=(_8{~l42(YkY|FTnI ztpba|2gvB&_wmzM{R0LnsuOrShd*44^!%@ofWcgzDz4gwaA#&fDkww(T-dp%2rBID z<6>|FG9Bsr_-RvU8L!$gQ9w1$(Yqf+Yk?AAhyET2dc1&yhR%h9-cou7`Omn;pVW< zQH8Y%ECwIIX^VHlKt*)|dUJTAirbgAXkI{X4u^NBu(ywk!3}V8_+}>-EUF*Q44yjk zDbhEdL=pmyTD~%P$38b#XQDSAk<@xUX*Rv2rI%x?NxS$|skn#L3GN*;3rsawPR_e#mywKG{`W4KS0Tpfzw+^bOV(CBiZDSwq0f0e}lT*T@$O7lRw%=1_qNi|PluyMMJM(l_4t?6!tKRmooH z;CD6mMW^%Q!!{r;07yeccYyr*X-xD}gT$a7UfVL&5mAMQ^CP}rf(ZBg>>iQh5i~2+ zHG#h-3}1yzF+VbAA-{@`p9bGw9TS6exMK+pP=`d-er3Vj$p?_+zdNjdZ?*6!8dPoO zSNv4r3YG}~3J0m{xLPO%^>CkZ^MqO^s{Gh*szyr@`QYSoI85K49EhsasiHM|a3(Qm z_5cazd~}c+BnIiQJJ+99Lqru0P9z6siUTWQrT~?xF6Wh%!_KrlgJ!3Ki9tG?^J29~ zRPEr1V{k?>*jfXIUOJ0rr`ntcdwYPK2T<6d+s>zgW zxewg;@zY39ruHsh)t>Fz|G5A-?-MKs0De&)9Q64O;YvoU!((vC5{Q2J!WYm{odXWj z1V>;FLuN%V|A&iM_=m9(Uj$lLteAxL05nsZ`;GU(BdrH0tkLn+Ax&$~9A{L1* ze1W5pSnOBZj=-UNAbr3E-~e2Y{-O(D@X#{ILyj?D#7A`zIMEIqLN%%@}Gt7~7%-hrLpa1)FD z3O)w+!0AuF^(A;z|Dd<8t?^hc@DJJ-<<1vf0E36%_O&eGi}>g+QhH}eA8NsspRS73OY) z07z3Il0nwMBn%qi=3ayXLRAO0l?1y(g84q|eV6QyHVwR89-2hu<--g}qVS0d90r|m zs$I@iK|>V_w%h~T=J_F$GDFBZi>FFFMKc{>1HS$pFWA=&O!EK{&bGPFRTD8to&}ls zMhy^EHP|!_?0p8dLV-i<{zA#dk1xjp$^AC@=r0-U!s3ot25>VV3tbCT;4tWgL%gU^ z1r1d!*v15GMFPt7;Sj%2<|&6*jb5-%fHR^K5aAG~y;HJq03rs-aELwMs|KQ~1{L?9 za2^zt!Xchms)Xzdale|${vigntUxlr!66>WRe{5x7Y=c@DjKR-P!$R)J3-|d9AaXn zl6A@fQKJ`>&VhSffCz{9bhT^HAiUQ^-2@coX>R{{VTvYy1EJ literal 0 HcmV?d00001 diff --git a/tests/data/plane_golden/wgsl.json b/tests/data/plane_golden/wgsl.json new file mode 100644 index 00000000..49a7fe12 --- /dev/null +++ b/tests/data/plane_golden/wgsl.json @@ -0,0 +1,74 @@ +{ + "image_memory/iso": { + "ImageVolumeShader": [ + "4b9f58702047e20aac1e9e84852e17a15fd967648bdce65e87cd65b00758e931", + 28615 + ] + }, + "image_memory/minip": { + "ImageVolumeShader": [ + "b08d67433f1dbd7acb7976c087d10591c6d438e108e08952ab94c583d0b4b8c8", + 23515 + ] + }, + "image_memory/mip": { + "ImageVolumeShader": [ + "5532ea51f42399fad5cd1e1b6a59adb3ac6036d16d4e71663dd301458fbfca33", + 23493 + ] + }, + "image_multiscale/attenuated_mip": { + "MultiscaleVolumeBrickShader": [ + "479168ee1f7cee3c5a3868eb7082f626c1d4d8da3c85b8a2570feb26c7253e93", + 49041 + ] + }, + "image_multiscale/iso": { + "MultiscaleVolumeBrickShader": [ + "78c30dc9ad2617739f5b14a7aa5c4955a2d8148796884220ad6c57c7296a422b", + 52196 + ] + }, + "image_multiscale/mip": { + "MultiscaleVolumeBrickShader": [ + "b82af82c9419d0dd0917199400fb033732eeb37dca81dfcf9fd0019f716284cc", + 48390 + ] + }, + "image_multiscale/smooth_iso": { + "MultiscaleVolumeBrickShader": [ + "436250e1229b8e010bea547271e7628d33af74875ef306937a97d688681b093f", + 56529 + ] + }, + "labels_memory/default": { + "LabelVolumeShader": [ + "2d6de87486336cceeb5d906ed5b3d1a218ae572ac408650de5409b6894b69100", + 28430 + ] + }, + "labels_multiscale/flat_categorical": { + "LabelVolumeBrickShader": [ + "23c3c6410a0c4b5cd2446576f85da59bbb38fb5612a86480213515be68dfb959", + 44992 + ] + }, + "labels_multiscale/gradient_debug": { + "LabelVolumeBrickShader": [ + "4243368b52d73d5bbcfd20ad70ad1efe0128a829c589c8c382a4b8ddfa1bacac", + 49523 + ] + }, + "labels_multiscale/iso_categorical": { + "LabelVolumeBrickShader": [ + "b07a363e12e25171bbafaedd11a31f64f5ba0184951ab85d6a839cc61d1af305", + 49612 + ] + }, + "labels_multiscale/smooth_iso": { + "LabelVolumeBrickShader": [ + "bf395c472432c80e8e9fab697e3b5f9f759297c6bbf316b0a158929282491903", + 51641 + ] + } +} \ No newline at end of file diff --git a/tests/gui/anywidget/test_lod_bias.py b/tests/gui/anywidget/test_lod_bias.py index d030f149..cad0c8f9 100644 --- a/tests/gui/anywidget/test_lod_bias.py +++ b/tests/gui/anywidget/test_lod_bias.py @@ -19,7 +19,7 @@ def _make_widget(**kwargs): def test_instantiate_smoke(): widget, _visual_id = _make_widget(initial_lod_bias=2.5) - assert widget.lod_bias == pytest.approx(2.5) + assert widget.settled_lod_bias == pytest.approx(2.5) def test_trait_edit_emits_update_event(): @@ -27,13 +27,13 @@ def test_trait_edit_emits_update_event(): emitted = [] widget.changed.connect(emitted.append) - widget.lod_bias = 3.5 + widget.settled_lod_bias = 3.5 assert len(emitted) == 1 event = emitted[0] assert event.source_id == widget._id assert event.visual_id == visual_id - assert event.field == "lod_bias" + assert event.field == "settled_lod_bias" assert event.value == pytest.approx(3.5) @@ -46,13 +46,13 @@ def test_inbound_change_updates_trait_without_reemit(): AppearanceChangedEvent( source_id=uuid4(), # not the widget -> applied visual_id=visual_id, - field_name="lod_bias", + field_name="settled_lod_bias", new_value=4.0, requires_reslice=True, ) ) - assert widget.lod_bias == pytest.approx(4.0) + assert widget.settled_lod_bias == pytest.approx(4.0) assert emitted == [] # applied under _applying, no echo @@ -63,13 +63,13 @@ def test_inbound_echo_filtered_by_source_id(): AppearanceChangedEvent( source_id=widget._id, # our own echo -> ignored visual_id=visual_id, - field_name="lod_bias", + field_name="settled_lod_bias", new_value=9.0, requires_reslice=True, ) ) - assert widget.lod_bias == pytest.approx(1.0) # unchanged + assert widget.settled_lod_bias == pytest.approx(1.0) # unchanged def test_inbound_unrelated_field_ignored(): @@ -85,4 +85,4 @@ def test_inbound_unrelated_field_ignored(): ) ) - assert widget.lod_bias == pytest.approx(1.0) # unchanged + assert widget.settled_lod_bias == pytest.approx(1.0) # unchanged diff --git a/tests/gui/qt/test_qt_lod_bias.py b/tests/gui/qt/test_qt_lod_bias.py index 15b5c26a..7dbbdcad 100644 --- a/tests/gui/qt/test_qt_lod_bias.py +++ b/tests/gui/qt/test_qt_lod_bias.py @@ -35,7 +35,7 @@ def _make_controller_with_visual(small_zarr_store, initial_lod_bias=1.0): cs = world_coordinate_system(spatial_axes("z", "y", "x"), name="world") scene = controller.add_scene(dim="3d", coordinate_system=cs, name="main") store = _make_store(small_zarr_store) - appearance = MultiscaleImageAppearance(lod_bias=initial_lod_bias) + appearance = MultiscaleImageAppearance(settled_lod_bias=initial_lod_bias) visual = controller.add_image_multiscale( data=store, scene_id=scene.id, appearance=appearance, name="vol" ) @@ -57,7 +57,7 @@ def test_slider_release_reaches_model(qtbot, small_zarr_store): slider._slider.setValue(3.5) slider._on_slider_released() # simulate the user releasing the handle - assert visual.appearance.lod_bias == pytest.approx(3.5) + assert visual.appearance.settled_lod_bias == pytest.approx(3.5) def test_model_push_updates_slider_without_reemit(qtbot, small_zarr_store): @@ -69,7 +69,7 @@ def test_model_push_updates_slider_without_reemit(qtbot, small_zarr_store): emitted = [] slider.changed.connect(emitted.append) - controller.update_appearance_field(visual.id, "lod_bias", 4.0) + controller.update_appearance_field(visual.id, "settled_lod_bias", 4.0) assert slider._slider.value() == pytest.approx(4.0) assert emitted == [] @@ -97,7 +97,7 @@ def test_inbound_echo_filtered_by_source_id(qtbot, small_zarr_store): AppearanceChangedEvent( source_id=slider._id, # our own echo -> ignored visual_id=visual.id, - field_name="lod_bias", + field_name="settled_lod_bias", new_value=9.0, requires_reslice=True, ) diff --git a/tests/gui/test_appearance_control_types.py b/tests/gui/test_appearance_control_types.py index 30f435e7..77d82bdb 100644 --- a/tests/gui/test_appearance_control_types.py +++ b/tests/gui/test_appearance_control_types.py @@ -44,7 +44,7 @@ import cellier.gui.qt.visuals as qt_visuals # (field-class stem, field name, label) -- the section 10.4 catalog, minus -# ``visible`` (stage 0) and ``lod_bias`` (an existing widget, not a new class). +# ``visible`` (stage 0) and ``settled_lod_bias`` (an existing widget, not a new class). CATALOG = [ ("OpacitySlider", "opacity", "Opacity"), ("LabelsRenderModeCombo", "render_mode", "Render mode"), diff --git a/tests/gui/test_visual_id_groups.py b/tests/gui/test_visual_id_groups.py index 3866d8d2..40a16efd 100644 --- a/tests/gui/test_visual_id_groups.py +++ b/tests/gui/test_visual_id_groups.py @@ -62,10 +62,10 @@ def _qt_widgets(visual_id): "clim", ), ( - "lod_bias", + "settled_lod_bias", lod, lambda: (lod._slider.setValue(2.5), lod._on_slider_released()), - "lod_bias", + "settled_lod_bias", ), ("aabb", aabb, lambda: aabb._enabled_check.setChecked(True), "enabled"), ] @@ -163,7 +163,12 @@ def _set(widget, name, value): _set(image, "single", {**image.single, "clim": [0.2, 0.8]}), "clim", ), - ("lod_bias", lod, _set(lod, "lod_bias", 2.5), "lod_bias"), + ( + "settled_lod_bias", + lod, + _set(lod, "settled_lod_bias", 2.5), + "settled_lod_bias", + ), ("aabb", aabb, _set(aabb, "enabled", True), "enabled"), ] @@ -236,7 +241,7 @@ def test_the_event_type_is_unchanged_by_the_group_form(): clim.changed.connect(clim_events.append) aabb.changed.connect(aabb_events.append) - clim.lod_bias = 2.0 + clim.settled_lod_bias = 2.0 aabb.enabled = True assert all(isinstance(e, AppearanceUpdateEvent) for e in clim_events) diff --git a/tests/render/scheduling/test_backstop_integration.py b/tests/render/scheduling/test_backstop_integration.py index 03866e43..add8d389 100644 --- a/tests/render/scheduling/test_backstop_integration.py +++ b/tests/render/scheduling/test_backstop_integration.py @@ -154,28 +154,6 @@ def new_backstop_drawn() -> bool: assert {k.slice_coord for k in tilemap if k.level == TARGET_LEVEL} == {((0, 6),)} -@pytest.mark.parametrize("dim", ["3d", "2d"]) -async def test_with_the_backstop_off_only_the_target_loads( - controller, multiscale_image_store, monkeypatch, dim -): - gate = _Gate(monkeypatch, multiscale_image_store) - gate.open.set() - scene, _visual, gfx = _add( - controller, - multiscale_image_store, - dim=dim, - loading=ProgressiveLoadingConfig(backstop=False), - ) - controller.fit_camera(scene.id) - controller.reslice_all() - await drain_loading(controller) - progress = controller._render_manager.scheduler.progress( - _residency(gfx, dim).cache_id - ) - assert progress.needed_backstop == 0 - assert {r.scale_index for r in gate.reads} == {TARGET_SCALE} - - async def test_changing_only_loading_reslices_and_keeps_the_atlas( controller, multiscale_image_store ): @@ -186,16 +164,18 @@ async def test_changing_only_loading_reslices_and_keeps_the_atlas( scheduler = controller._render_manager.scheduler cache_id = _residency(gfx, "3d").cache_id atlas = gfx.slots[0]._block_cache_3d - assert scheduler.progress(cache_id).needed_backstop > 0 + coarsest = scheduler.progress(cache_id).needed_backstop + assert coarsest > 0 + # The finest level as the backstop: more bricks than the coarsest. visual.render_config = visual.render_config.model_copy( - update={"loading": ProgressiveLoadingConfig(backstop=False)} + update={"loading": ProgressiveLoadingConfig(backstop_level=1)} ) await drain_loading(controller) assert _residency(gfx, "3d").cache_id == cache_id assert gfx.slots[0]._block_cache_3d is atlas - assert scheduler.progress(cache_id).needed_backstop == 0 + assert scheduler.progress(cache_id).needed_backstop > coarsest async def test_changing_another_render_config_field_warns( diff --git a/tests/render/scheduling/test_dims_drag.py b/tests/render/scheduling/test_dims_drag.py index 466253dd..ad8f4968 100644 --- a/tests/render/scheduling/test_dims_drag.py +++ b/tests/render/scheduling/test_dims_drag.py @@ -1,8 +1,8 @@ -"""``dims_drag`` and the dims scrub (interaction tracker design 4.3, 4.4). +"""``coarsest_while_moving`` and the dims scrub (interaction tracker design 4.3, 4.4). A scrub is a run of interactive ticks: ``update_slice_indices`` with -``interactive=True`` or inside ``dims_interaction``. During one, a visual in -``dims_drag="backstop"`` mode plans backstop-only; the scrub's end (release +``interactive=True`` or inside ``dims_interaction``. During one, a visual with +``coarsest_while_moving`` on for the view plans backstop-only; the scrub's end (release or stillness) plans it in full. A plain ``update_slice_indices`` is a jump. The fixture is a 16^3 volume with two levels and ``force_level=1``: the @@ -24,11 +24,16 @@ from cellier.visuals._image import ( MultiscaleImageAppearance, MultiscaleImageRenderConfig, + MultiscaleImageVisual, ) +from cellier.visuals._labels import MultiscaleLabelsAppearance from tests._gpu_budget import SMALL_BUDGETS from tests.render.conftest import drain_loading -DRAG = ProgressiveLoadingConfig(dims_drag="backstop") +#: Appearance fields: plan the backstop only while moving, in 2D and 3D. +DRAG = {"coarsest_while_moving_2d": True, "coarsest_while_moving_3d": True} +#: The defaults of this phase: plan in full on every tick. +EAGER: dict = {} def _tzyx_store(tmp_path): @@ -49,7 +54,7 @@ def _tzyx_store(tmp_path): ) -def _add(controller, store, dim="2d", loading=DRAG, name="scene"): +def _add(controller, store, dim="2d", moving=DRAG, name="scene"): if len(store.level_shapes[0]) == 4: from cellier.scene import spatial_axes @@ -63,10 +68,8 @@ def _add(controller, store, dim="2d", loading=DRAG, name="scene"): visual = controller.add_image_multiscale( data=store, scene_id=scene.id, - appearance=MultiscaleImageAppearance(force_level=1), - render_config=MultiscaleImageRenderConfig( - **SMALL_BUDGETS, block_size=8, loading=loading - ), + appearance=MultiscaleImageAppearance(force_level=1, **moving), + render_config=MultiscaleImageRenderConfig(**SMALL_BUDGETS, block_size=8), single=MultiscaleImageSingleAppearance( color_map="viridis", clim=(0.0, 1.0), render_mode="mip" ), @@ -139,15 +142,20 @@ def _timers(controller) -> list: # -- the config ------------------------------------------------------------------- -def test_eager_is_the_default() -> None: - assert ProgressiveLoadingConfig().dims_drag == "eager" +@pytest.mark.parametrize( + "appearance_cls", [MultiscaleImageAppearance, MultiscaleLabelsAppearance] +) +def test_eager_is_the_default(appearance_cls) -> None: + appearance = appearance_cls() + assert appearance.coarsest_while_moving_2d is False + assert appearance.coarsest_while_moving_3d is False -def test_backstop_drag_without_a_backstop_is_refused() -> None: - with pytest.raises(ValidationError, match="needs backstop=True"): - ProgressiveLoadingConfig(backstop=False, dims_drag="backstop") - # Off with eager is fine: the target alone. - ProgressiveLoadingConfig(backstop=False, dims_drag="eager") +@pytest.mark.parametrize("field", ["dims_drag", "backstop"]) +def test_the_removed_loading_settings_are_refused(field) -> None: + """``dims_drag`` and the ``backstop`` switch are gone, not ignored.""" + with pytest.raises(ValidationError, match=field): + ProgressiveLoadingConfig(**{field: "eager"}) def test_the_opt_in_is_a_model_property(multiscale_image_store, controller) -> None: @@ -155,11 +163,20 @@ def test_the_opt_in_is_a_model_property(multiscale_image_store, controller) -> N _other, eager, _gfx2 = _add( controller, multiscale_image_store, - loading=ProgressiveLoadingConfig(), + moving=EAGER, name="eager", ) - assert drag.plans_coarse_on_scrub is True - assert eager.plans_coarse_on_scrub is False + assert drag.plans_coarse_while_moving(2) is True + assert drag.plans_coarse_while_moving(3) is True + assert eager.plans_coarse_while_moving(2) is False + assert eager.plans_coarse_while_moving(3) is False + + +def test_each_view_dimension_has_its_own_setting() -> None: + appearance = MultiscaleImageAppearance(coarsest_while_moving_3d=True) + visual = MultiscaleImageVisual.model_construct(appearance=appearance) + assert visual.plans_coarse_while_moving(3) is True + assert visual.plans_coarse_while_moving(2) is False # -- a scrub ends on stillness ---------------------------------------------------- @@ -323,9 +340,7 @@ async def test_a_jump_ends_a_scrub(controller, multiscale_image_store, monkeypat async def test_eager_ticks_plan_in_full( controller, multiscale_image_store, monkeypatch ): - scene, _visual, _gfx = _add( - controller, multiscale_image_store, loading=ProgressiveLoadingConfig() - ) + scene, _visual, _gfx = _add(controller, multiscale_image_store, moving=EAGER) await _loaded(controller, scene) plans = _record_plans(monkeypatch) events = _record_events(controller, scene) @@ -595,10 +610,8 @@ async def test_a_visual_added_during_a_scrub_plans_coarse_then_in_full( added = controller.add_image_multiscale( data=multiscale_image_store, scene_id=scene.id, - appearance=MultiscaleImageAppearance(force_level=1), - render_config=MultiscaleImageRenderConfig( - **SMALL_BUDGETS, block_size=8, loading=DRAG - ), + appearance=MultiscaleImageAppearance(force_level=1, **DRAG), + render_config=MultiscaleImageRenderConfig(**SMALL_BUDGETS, block_size=8), single=MultiscaleImageSingleAppearance(color_map="viridis"), ) controller.update_slice_indices(scene.id, {0: 6.0}) @@ -618,10 +631,10 @@ async def test_a_config_change_mid_scrub_still_plans_in_full_at_the_end( plans = _record_plans(monkeypatch) with controller.dims_interaction(scene.id): controller.update_slice_indices(scene.id, {0: 6.0}) - controller.set_loading_config(visual.id, dims_drag="eager") - # Judged by its current value: its own reslice plans in full. - assert _modes(plans, visual.id) == ["BACKSTOP_ONLY", "FULL"] - assert _modes(plans, visual.id) == ["BACKSTOP_ONLY", "FULL", "FULL"] + visual.appearance.coarsest_while_moving_2d = False + # The setting is read by the next plan; changing it plans nothing. + assert _modes(plans, visual.id) == ["BACKSTOP_ONLY"] + assert _modes(plans, visual.id) == ["BACKSTOP_ONLY", "FULL"] await drain_loading(controller) @@ -635,17 +648,13 @@ async def test_a_visual_that_opts_in_through_its_model_receives_the_plan_mode( """The opt-in is the model's property, not the visual's type or config. This is the path the multiscale mesh uses: an ``eager`` image here stands - in for a visual whose model answers ``plans_coarse_on_scrub`` itself. + in for a visual whose model answers ``plans_coarse_while_moving`` itself. """ - from cellier.visuals._image import MultiscaleImageVisual - - scene, visual, gfx = _add( - controller, _tzyx_store(tmp_path), dim=dim, loading=ProgressiveLoadingConfig() - ) + scene, visual, gfx = _add(controller, _tzyx_store(tmp_path), dim=dim, moving=EAGER) await _loaded(controller, scene) - assert visual.plans_coarse_on_scrub is False + assert visual.plans_coarse_while_moving(int(dim[0])) is False monkeypatch.setattr( - MultiscaleImageVisual, "plans_coarse_on_scrub", property(lambda self: True) + MultiscaleImageVisual, "plans_coarse_while_moving", lambda self, n: True ) received: list[str] = [] original = gfx.plan diff --git a/tests/render/scheduling/test_loading_config_api.py b/tests/render/scheduling/test_loading_config_api.py index ee63e14f..db55d9c8 100644 --- a/tests/render/scheduling/test_loading_config_api.py +++ b/tests/render/scheduling/test_loading_config_api.py @@ -39,16 +39,16 @@ def test_fields_merge_into_the_current_config(controller, multiscale_image_store _scene, visual, _gfx = _add(controller, multiscale_image_store) events = _record(controller, visual.id) - result = controller.set_loading_config(visual.id, dims_drag="backstop") + result = controller.set_loading_config(visual.id, backstop_extent="view") - assert result == ProgressiveLoadingConfig(dims_drag="backstop") + assert result == ProgressiveLoadingConfig(backstop_extent="view") assert visual.render_config.loading == result [event] = events assert event.loading == result assert event.source_id == controller._id # A second field keeps the first. controller.set_loading_config(visual.id, backstop_max_slot_fraction=0.3) - assert visual.render_config.loading.dims_drag == "backstop" + assert visual.render_config.loading.backstop_extent == "view" assert visual.render_config.loading.backstop_max_slot_fraction == 0.3 @@ -60,16 +60,16 @@ def test_the_caller_source_id_is_stamped(controller, multiscale_image_store): assert events[-1].source_id == widget_id -def test_an_invalid_combination_raises_and_changes_nothing( +def test_an_invalid_value_raises_and_changes_nothing( controller, multiscale_image_store ): _scene, visual, _gfx = _add(controller, multiscale_image_store) - controller.set_loading_config(visual.id, dims_drag="backstop") + controller.set_loading_config(visual.id, backstop_extent="view") before = visual.render_config.loading events = _record(controller, visual.id) - with pytest.raises(ValueError, match="needs backstop=True"): - controller.set_loading_config(visual.id, backstop=False) + with pytest.raises(ValueError, match="backstop_level"): + controller.set_loading_config(visual.id, backstop_level=0) assert visual.render_config.loading == before assert events == [] @@ -77,21 +77,32 @@ def test_an_invalid_combination_raises_and_changes_nothing( def test_an_unknown_field_raises_with_a_suggestion(controller, multiscale_image_store): _scene, visual, _gfx = _add(controller, multiscale_image_store) - with pytest.raises(ValueError, match="Did you mean 'dims_drag'"): - controller.set_loading_config(visual.id, dims_dragg="backstop") + with pytest.raises(ValueError, match="Did you mean 'backstop_level'"): + # Built, so the typos hook does not correct it. + controller.set_loading_config(visual.id, **{"backstop_level" + "s": 2}) + + +@pytest.mark.parametrize("field", ["dims_drag", "backstop"]) +def test_a_removed_field_is_refused(controller, multiscale_image_store, field): + """``dims_drag`` and the ``backstop`` switch are gone (hard break).""" + _scene, visual, _gfx = _add(controller, multiscale_image_store) + before = visual.render_config.loading + with pytest.raises(ValueError, match=field): + controller.set_loading_config(visual.id, **{field: "eager"}) + assert visual.render_config.loading == before def test_a_visual_that_is_not_multiscale_raises(controller, image_volume): scene = controller.add_scene(dim="3d", name="scene") visual = controller.add_image(image_volume, scene.id) with pytest.raises(TypeError, match="multiscale"): - controller.set_loading_config(visual.id, dims_drag="backstop") + controller.set_loading_config(visual.id, backstop_extent="view") def test_no_change_is_a_no_op(controller, multiscale_image_store): _scene, visual, _gfx = _add(controller, multiscale_image_store) events = _record(controller, visual.id) - assert controller.set_loading_config(visual.id, dims_drag="eager") == ( + assert controller.set_loading_config(visual.id, backstop_extent="full") == ( visual.render_config.loading ) assert events == [] @@ -103,10 +114,10 @@ def test_assigning_render_config_directly_announces_the_change( _scene, visual, _gfx = _add(controller, multiscale_image_store) events = _record(controller, visual.id) visual.render_config = visual.render_config.model_copy( - update={"loading": ProgressiveLoadingConfig(backstop=False)} + update={"loading": ProgressiveLoadingConfig(backstop_level=1)} ) [event] = events - assert event.loading.backstop is False + assert event.loading.backstop_level == 1 assert event.source_id == controller._id @@ -116,10 +127,13 @@ def test_an_update_event_on_the_incoming_bus_applies( _scene, visual, _gfx = _add(controller, multiscale_image_store) controller._incoming_events.emit( LoadingConfigUpdateEvent( - source_id=uuid4(), visual_id=visual.id, field="dims_drag", value="backstop" + source_id=uuid4(), + visual_id=visual.id, + field="backstop_extent", + value="view", ) ) - assert visual.render_config.loading.dims_drag == "backstop" + assert visual.render_config.loading.backstop_extent == "view" # -- the Viewer ----------------------------------------------------------------------- @@ -160,8 +174,8 @@ async def test_viewer_mirrors_the_controller(viewer_with_image): assert progress and isinstance(progress[-1], ResliceProgressEvent) assert [type(e) for e in backstops] == [BackstopCompleteEvent] assert viewer.loading_progress(visual).complete - assert viewer.set_loading(visual, dims_drag="backstop").dims_drag == "backstop" - assert visual.render_config.loading.dims_drag == "backstop" + assert viewer.set_loading(visual, backstop_extent="view").backstop_extent == "view" + assert visual.render_config.loading.backstop_extent == "view" async def test_removing_the_visual_removes_its_subscriptions(viewer_with_image): @@ -190,11 +204,13 @@ def ortho_with_image(qtbot, multiscale_image_store): def test_ortho_set_loading_writes_every_panel(ortho_with_image): ortho, visuals = ortho_with_image - ortho.set_loading(visuals, dims_drag="backstop") - assert {v.render_config.loading.dims_drag for v in visuals.values()} == {"backstop"} - with pytest.raises(ValueError, match="needs backstop=True"): - ortho.set_loading(next(iter(visuals.values())), backstop=False) - assert all(v.render_config.loading.backstop for v in visuals.values()) + ortho.set_loading(visuals, backstop_extent="view") + assert {v.render_config.loading.backstop_extent for v in visuals.values()} == { + "view" + } + with pytest.raises(ValueError, match="backstop_level"): + ortho.set_loading(next(iter(visuals.values())), backstop_level=0) + assert {v.render_config.loading.backstop_level for v in visuals.values()} == {None} def _fake_progress(ortho, monkeypatch, table): diff --git a/tests/render/scheduling/test_loading_config_controls.py b/tests/render/scheduling/test_loading_config_controls.py index 8ba63971..adbe3b7e 100644 --- a/tests/render/scheduling/test_loading_config_controls.py +++ b/tests/render/scheduling/test_loading_config_controls.py @@ -100,26 +100,27 @@ def test_an_edit_applies_and_an_invalid_one_is_refused( widget = _make(toolkit, [visual.id], visual.render_config.loading) controller.connect_widget(widget, subscription_specs=widget.subscription_specs()) - _user_edit(widget, "dims_drag", "backstop") - assert visual.render_config.loading.dims_drag == "backstop" - assert _shown(widget)["dims_drag"] == "backstop" + _user_edit(widget, "backstop_extent", "view") + assert visual.render_config.loading.backstop_extent == "view" + assert _shown(widget)["backstop_extent"] == "view" assert _error(widget) == "" - _user_edit(widget, "backstop", False) - # Refused: the model is unchanged, the control shows it again, and why. - assert visual.render_config.loading.backstop is True - assert _shown(widget)["backstop"] is True - assert "needs backstop=True" in _error(widget) + if toolkit == "anywidget": + # With no setting that depends on another, a Qt control cannot hold + # a value the config refuses (its inputs are bounded); a front end's + # raw edit can. + _user_edit(widget, "backstop_max_slot_fraction", 0.9) + # Refused: the model is unchanged, the control shows it again, and why. + assert visual.render_config.loading.backstop_max_slot_fraction == 0.1 + assert _shown(widget)["backstop_max_slot_fraction"] == pytest.approx(0.1) + assert "less than or equal to 0.5" in _error(widget) # A change from elsewhere is shown, and clears the message. - controller.set_loading_config(visual.id, dims_drag="eager") - assert _shown(widget)["dims_drag"] == "eager" + controller.set_loading_config(visual.id, backstop_extent="full") + assert _shown(widget)["backstop_extent"] == "full" assert _error(widget) == "" - _user_edit(widget, "backstop", False) - assert visual.render_config.loading.backstop is False # Level 0 is "coarsest", i.e. None. - _user_edit(widget, "backstop", True) _user_edit(widget, "backstop_level", 1) assert visual.render_config.loading.backstop_level == 1 _user_edit(widget, "backstop_level", 0) @@ -135,9 +136,9 @@ def test_a_group_control_edits_every_visual(controller, multiscale_image_store, widget = _make("qt", [first.id, second.id], first.render_config.loading) controller.connect_widget(widget, subscription_specs=widget.subscription_specs()) - _user_edit(widget, "dims_drag", "backstop") - assert first.render_config.loading.dims_drag == "backstop" - assert second.render_config.loading.dims_drag == "backstop" + _user_edit(widget, "backstop_extent", "view") + assert first.render_config.loading.backstop_extent == "view" + assert second.render_config.loading.backstop_extent == "view" def test_the_level_control_offers_the_stores_levels(qtbot): diff --git a/tests/render/scheduling/test_progress_events.py b/tests/render/scheduling/test_progress_events.py index a69de2e0..eb55c0cd 100644 --- a/tests/render/scheduling/test_progress_events.py +++ b/tests/render/scheduling/test_progress_events.py @@ -17,7 +17,6 @@ ResliceCompletedEvent, ResliceProgressEvent, ) -from cellier.visuals import ProgressiveLoadingConfig from tests._gpu_budget import SMALL_BUDGETS from tests.render.conftest import drain_loading from tests.render.scheduling.test_backstop_integration import _add, _Gate, _until @@ -101,20 +100,6 @@ def backstop_in() -> bool: assert rec.of(ResliceProgressEvent)[-1].progress.complete -async def test_no_backstop_event_without_a_backstop(controller, multiscale_image_store): - scene, visual, _gfx = _add( - controller, - multiscale_image_store, - loading=ProgressiveLoadingConfig(backstop=False), - ) - rec = _Recorder(controller, visual.id) - await _load(controller, scene) - assert not rec.of(BackstopCompleteEvent) - last = rec.of(ResliceProgressEvent)[-1].progress - assert last.needed_backstop == 0 - assert last.complete and last.backstop_complete - - async def test_one_event_per_loop_iteration_over_every_channel( controller, tmp_path, monkeypatch ): @@ -202,7 +187,7 @@ async def test_a_dims_scrub_marks_the_target_deferred_until_it_ends( from tests.render.scheduling.test_dims_drag import DRAG, _settle_s, _tzyx_store from tests.render.scheduling.test_dims_drag import _add as _add_drag - scene, visual, _gfx = _add_drag(controller, _tzyx_store(tmp_path), loading=DRAG) + scene, visual, _gfx = _add_drag(controller, _tzyx_store(tmp_path), moving=DRAG) await _load(controller, scene) assert not controller.loading_progress(visual.id).target_deferred diff --git a/tests/render/test_backstop.py b/tests/render/test_backstop.py index 228bd90e..0a458c1e 100644 --- a/tests/render/test_backstop.py +++ b/tests/render/test_backstop.py @@ -37,7 +37,6 @@ def test_defaults_are_the_designed_ones() -> None: cfg = ProgressiveLoadingConfig() - assert cfg.backstop is True assert cfg.backstop_level is None assert cfg.backstop_extent == "full" assert cfg.backstop_max_slot_fraction == 0.1 @@ -59,7 +58,13 @@ def test_bad_settings_are_refused(kwargs) -> None: def test_the_config_is_frozen() -> None: with pytest.raises(ValidationError): - ProgressiveLoadingConfig().backstop = False + ProgressiveLoadingConfig().backstop_level = 1 + + +def test_the_backstop_cannot_be_switched_off() -> None: + """The ``backstop`` switch is gone: the backstop always loads.""" + with pytest.raises(ValidationError, match="backstop"): + ProgressiveLoadingConfig(backstop=False) # -- level and cap -------------------------------------------------------------------- @@ -77,7 +82,6 @@ def test_the_cap_is_a_share_of_the_slots() -> None: residency = SimpleNamespace(n_slots=40) assert backstop_cap_for(cfg, residency) == 10 assert backstop_cap_for(None, residency) == 0 - assert backstop_cap_for(ProgressiveLoadingConfig(backstop=False), residency) == 0 # -- the helpers ---------------------------------------------------------------------- diff --git a/tests/render/test_camera_interaction.py b/tests/render/test_camera_interaction.py index 3d5b025b..e9517771 100644 --- a/tests/render/test_camera_interaction.py +++ b/tests/render/test_camera_interaction.py @@ -17,7 +17,7 @@ from cellier.controller import CellierController from cellier.render import CameraConfig, RenderManagerConfig -from cellier.visuals import MultiscaleImageSingleAppearance, ProgressiveLoadingConfig +from cellier.visuals import MultiscaleImageSingleAppearance from cellier.visuals._image import ( MultiscaleImageAppearance, MultiscaleImageRenderConfig, @@ -51,7 +51,7 @@ def __init__( *, dim: str = "3d", settle_s: float = NO_SETTLE, - loading: ProgressiveLoadingConfig | None = None, + moving: dict | None = None, n_canvases: int = 1, render_modes: set[str] | None = None, with_image: bool = True, @@ -66,7 +66,7 @@ def __init__( kwargs = {} if render_modes is None else {"render_modes": render_modes} self.scene = self.controller.add_scene(dim=dim, name="scene", **kwargs) self.store = store - self.visual = self.add_image(loading) if with_image else None + self.visual = self.add_image(moving) if with_image else None self.canvas_ids = [] self.views = [] for _ in range(n_canvases): @@ -116,16 +116,12 @@ def reslice_scene(scene_id, dims_state, visual_configs=None, **kwargs): self.scene.id, self.camera_events.append, owner_id=self.controller._id ) - def add_image(self, loading: ProgressiveLoadingConfig | None = None): + def add_image(self, moving: dict | None = None): return self.controller.add_image_multiscale( data=self.store, scene_id=self.scene.id, - appearance=MultiscaleImageAppearance(force_level=1), - render_config=MultiscaleImageRenderConfig( - **SMALL_BUDGETS, - block_size=8, - loading=loading or ProgressiveLoadingConfig(), - ), + appearance=MultiscaleImageAppearance(force_level=1, **(moving or {})), + render_config=MultiscaleImageRenderConfig(**SMALL_BUDGETS, block_size=8), single=MultiscaleImageSingleAppearance( color_map="viridis", clim=(0.0, 1.0), render_mode="mip" ), @@ -788,12 +784,13 @@ def submit(request, visual_configs): # -- camera and dims together ----------------------------------------------------- -DRAG = ProgressiveLoadingConfig(dims_drag="backstop") +#: Plan the backstop only while the dims are scrubbed, in 2D and 3D. +DRAG = {"coarsest_while_moving_2d": True, "coarsest_while_moving_3d": True} @pytest.mark.parametrize("how", ["motion_end", "jump"]) async def test_a_camera_reslice_during_a_scrub_plans_coarse(make_rig, how): - rig = make_rig(dim="2d", loading=DRAG) + rig = make_rig(dim="2d", moving=DRAG) controller = rig.controller await rig.settle_first_frames() visual_id = rig.visual.id @@ -828,7 +825,7 @@ async def test_a_camera_reslice_during_a_scrub_plans_coarse(make_rig, how): async def test_a_scrub_ending_during_camera_motion_leaves_the_target_to_the_camera( make_rig, ): - rig = make_rig(dim="2d", loading=DRAG) + rig = make_rig(dim="2d", moving=DRAG) controller = rig.controller await rig.settle_first_frames() visual_id = rig.visual.id @@ -856,7 +853,7 @@ async def test_a_scrub_ending_during_camera_motion_leaves_the_target_to_the_came async def test_a_scrub_ending_with_no_camera_moving_plans_the_target(make_rig): - rig = make_rig(dim="2d", loading=DRAG) + rig = make_rig(dim="2d", moving=DRAG) controller = rig.controller await rig.settle_first_frames() with controller.dims_interaction(rig.scene.id): @@ -871,7 +868,7 @@ async def test_a_scrub_ending_with_no_camera_moving_plans_the_target(make_rig): async def test_a_handed_over_visual_is_planned_even_with_camera_reslicing_off( make_rig, ): - rig = make_rig(dim="2d", loading=DRAG) + rig = make_rig(dim="2d", moving=DRAG) controller = rig.controller await rig.settle_first_frames() controller.camera_reslice_enabled = False diff --git a/tests/render/test_clipping_interaction.py b/tests/render/test_clipping_interaction.py index 9b6f6f8f..77c17d9b 100644 --- a/tests/render/test_clipping_interaction.py +++ b/tests/render/test_clipping_interaction.py @@ -13,7 +13,7 @@ import pytest from cellier.data import PointsMemoryStore -from cellier.events import ClippingInteractionEvent, ClippingPlanesChangedEvent +from cellier.events import ClippingPlanesChangedEvent, PlaneInteractionEvent from cellier.transform import Axis, DataCoordinateSystem from cellier.visuals import ClippingPlane from tests.render import _clipping as h @@ -51,7 +51,7 @@ def _scene(controller): controller.add_canvas(scene_id=scene.id) log: list = [] owner = uuid4() - controller.on_clipping_interaction(visual.id, log.append, owner_id=owner) + controller.on_plane_interaction(visual.id, log.append, owner_id=owner) controller.on_clipping_planes_changed(visual.id, log.append, owner_id=owner) return visual, item, system, log @@ -64,7 +64,7 @@ def _interactions(log) -> list[tuple]: return [ (event.phase, event.reason) for event in log - if isinstance(event, ClippingInteractionEvent) + if isinstance(event, PlaneInteractionEvent) ] @@ -72,36 +72,36 @@ def test_a_change_outside_a_scope_is_a_jump(controller): visual, item, system, log = _scene(controller) _move(controller, visual, item, system, 20.0) assert _interactions(log) == [] - assert controller.clipping_interaction_state(visual.id) == "idle" + assert controller.plane_interaction_state(visual.id) == "idle" def test_opening_a_scope_starts_nothing(controller): visual, _item, _system, log = _scene(controller) source = uuid4() - controller.begin_clipping_interaction(visual.id, source_id=source) - controller.end_clipping_interaction(visual.id, source_id=source) - controller.end_clipping_interaction(visual.id, source_id=source) # not open + controller.begin_plane_interaction(visual.id, source_id=source) + controller.end_plane_interaction(visual.id, source_id=source) + controller.end_plane_interaction(visual.id, source_id=source) # not open assert log == [] def test_an_unknown_visual_is_refused(controller): with pytest.raises(KeyError): - controller.begin_clipping_interaction(uuid4(), source_id=uuid4()) + controller.begin_plane_interaction(uuid4(), source_id=uuid4()) async def test_a_drag_is_one_start_and_one_release(controller): visual, item, system, log = _scene(controller) source = uuid4() - controller.begin_clipping_interaction(visual.id, source_id=source) + controller.begin_plane_interaction(visual.id, source_id=source) for x in (18.0, 20.0, 22.0): _move(controller, visual, item, system, x, source_id=source) - assert controller.clipping_interaction_state(visual.id) == "active" - controller.end_clipping_interaction(visual.id, source_id=source) + assert controller.plane_interaction_state(visual.id) == "active" + controller.end_plane_interaction(visual.id, source_id=source) assert _interactions(log) == [("start", None), ("end", "release")] - assert controller.clipping_interaction_state(visual.id) == "idle" + assert controller.plane_interaction_state(visual.id) == "idle" # The start is announced ahead of the drag's first change. - assert isinstance(log[0], ClippingInteractionEvent) + assert isinstance(log[0], PlaneInteractionEvent) assert isinstance(log[1], ClippingPlanesChangedEvent) assert [e.source_id for e in log] == [source] * len(log) @@ -110,19 +110,19 @@ async def test_stillness_ends_a_drag_and_the_next_change_starts_another(controll controller._render_manager.config.scheduler.dims_settle_s = 0.02 visual, item, system, log = _scene(controller) source = uuid4() - controller.begin_clipping_interaction(visual.id, source_id=source) + controller.begin_plane_interaction(visual.id, source_id=source) _move(controller, visual, item, system, 18.0) await asyncio.sleep(0.1) assert _interactions(log) == [("start", None), ("end", "settle")] _move(controller, visual, item, system, 20.0) - controller.end_clipping_interaction(visual.id, source_id=source) + controller.end_plane_interaction(visual.id, source_id=source) assert _interactions(log)[2:] == [("start", None), ("end", "release")] def test_with_no_event_loop_each_change_settles(controller): visual, item, system, log = _scene(controller) - with controller.clipping_interaction(visual.id): + with controller.plane_interaction(visual.id): _move(controller, visual, item, system, 18.0) _move(controller, visual, item, system, 20.0) assert _interactions(log) == [("start", None), ("end", "settle")] * 2 @@ -150,7 +150,7 @@ def _recording(*args, **kwargs): modes.clear() item = visual.clipping_planes[0] moved = h.clipping_planes(store, [((0, 0, 20.5), (0, 0, 1))])[0].plane - with controller.clipping_interaction(visual.id): + with controller.plane_interaction(visual.id): controller.set_clipping_plane(visual.id, item.id, moved) assert modes == outside await drain_loading(controller) @@ -158,10 +158,10 @@ def _recording(*args, **kwargs): async def test_removing_the_visual_forgets_its_drag(controller): visual, item, system, log = _scene(controller) - controller.begin_clipping_interaction(visual.id, source_id=uuid4()) + controller.begin_plane_interaction(visual.id, source_id=uuid4()) _move(controller, visual, item, system, 18.0) - assert controller.clipping_interaction_state(visual.id) == "active" + assert controller.plane_interaction_state(visual.id) == "active" controller.remove_visual(visual.id) - assert controller.clipping_interaction_state(visual.id) == "idle" + assert controller.plane_interaction_state(visual.id) == "idle" assert _interactions(log) == [("start", None)] - assert controller._clip_driver.tasks() == [] + assert controller._plane_interaction_driver.tasks() == [] diff --git a/tests/render/test_clipping_plane_gizmo_controller.py b/tests/render/test_clipping_plane_gizmo_controller.py index a10c939e..94c96212 100644 --- a/tests/render/test_clipping_plane_gizmo_controller.py +++ b/tests/render/test_clipping_plane_gizmo_controller.py @@ -15,10 +15,10 @@ from cellier.data import ImageMemoryStore from cellier.data._axes import scale_and_translation_transform from cellier.events import ( - ClippingInteractionEvent, ClippingPlaneGizmoChangedEvent, ClippingPlaneGizmoUpdateEvent, ClippingPlanesChangedEvent, + PlaneInteractionEvent, ) from cellier.visuals import ClippingPlane, InMemoryImageSingleAppearance from tests.render.test_plane_gizmo import CAPTURES, Rig @@ -38,7 +38,7 @@ def __init__(self, **kwargs) -> None: self.controller.on_clipping_planes_changed( self.visual.id, self.log.append, owner_id=owner ) - self.controller.on_clipping_interaction( + self.controller.on_plane_interaction( self.visual.id, self.log.append, owner_id=owner ) for view in self.views: @@ -226,12 +226,12 @@ async def test_a_drag_along_the_normal_moves_the_plane_once_per_frame(make_rig): changes = rig.of(ClippingPlanesChangedEvent) assert 1 <= len(changes) <= 3 assert {event.source_id for event in changes} == {session.id} - interactions = rig.of(ClippingInteractionEvent) + interactions = rig.of(PlaneInteractionEvent) assert [(e.phase, e.reason) for e in interactions] == [ ("start", None), ("end", "release"), ] - assert rig.controller.clipping_interaction_state(rig.visual.id) == "idle" + assert rig.controller.plane_interaction_state(rig.visual.id) == "idle" assert not session.dragging @@ -291,8 +291,8 @@ async def test_a_drag_that_cannot_change_the_plane_assigns_nothing( else: assert gizmo.proxy.local.rotation.tolist() != [0.0, 0.0, 0.0, 1.0] # The scope opened and closed with nothing inside it. - assert rig.of(ClippingInteractionEvent) == [] - assert not rig.controller._clip_driver.scope_open(rig.visual.id) + assert rig.of(PlaneInteractionEvent) == [] + assert not rig.controller._plane_interaction_driver.scope_open(rig.visual.id) def _dim(dim): @@ -309,14 +309,14 @@ async def test_a_new_grab_ends_a_scope_whose_release_was_lost(make_rig): rig.send("pointer_down", x, y) rig.send("pointer_move", x + 20, y + 8) rig.frame() - assert rig.controller.clipping_interaction_state(rig.visual.id) == "active" + assert rig.controller.plane_interaction_state(rig.visual.id) == "active" # No release. The next press drops the capture and ends the drag. x, y = rig.handle(rig.gizmo(session), "_translate1_children", 0) rig.send("pointer_down", x, y) - phases = [(e.phase, e.reason) for e in rig.of(ClippingInteractionEvent)] + phases = [(e.phase, e.reason) for e in rig.of(PlaneInteractionEvent)] assert phases == [("start", None), ("end", "release")] rig.send("pointer_up", x, y, buttons=()) - assert not rig.controller._clip_driver.scope_open(rig.visual.id) + assert not rig.controller._plane_interaction_driver.scope_open(rig.visual.id) # -- the model -> the gizmo ----------------------------------------------------- @@ -440,9 +440,9 @@ async def test_closing_mid_drag_ends_the_drag(make_rig): rig.send("pointer_down", x, y) rig.send("pointer_move", x + 20, y + 8) rig.frame() - assert rig.controller.clipping_interaction_state(rig.visual.id) == "active" + assert rig.controller.plane_interaction_state(rig.visual.id) == "active" session.close() - assert rig.controller.clipping_interaction_state(rig.visual.id) == "idle" + assert rig.controller.plane_interaction_state(rig.visual.id) == "idle" assert not CAPTURES rig.send("pointer_up", x + 20, y + 8, buttons=()) diff --git a/tests/render/test_cull_first_planner.py b/tests/render/test_cull_first_planner.py new file mode 100644 index 00000000..8c20c489 --- /dev/null +++ b/tests/render/test_cull_first_planner.py @@ -0,0 +1,247 @@ +"""The cull-first 3D brick planner (plane rendering design v3 6.1, Phase 1). + +A clipped visual drops the bricks its clipping planes cut off before it ranks +and sorts them. The plan must be the rows of the old order (rank everything, +cull last), row for row; the golden replay +(``test_plane_golden_replay.py``) checks that on the production path, and +these check the pieces. +""" + +from __future__ import annotations + +from dataclasses import replace + +import numpy as np +import pytest + +from cellier.render import _level_of_detail as lod +from cellier.render._frustum import bricks_in_frustum_arr +from cellier.render._level_of_detail import ( + build_level_grids, + cull_mask, + cull_masks, + select_levels_arr_forced, + select_levels_from_cache, + sort_arr_by_distance, +) +from cellier.render.lut_indirection import BlockLayout3D +from cellier.render.visuals import _image, _label_multiscale +from cellier.visuals import ( + ClippingPlane, + MultiscaleImageAppearance, + MultiscaleImageRenderConfig, + MultiscaleImageSingleAppearance, + MultiscaleLabelRenderConfig, + MultiscaleLabelsAppearance, +) +from tests._gpu_budget import SMALL_BUDGETS +from tests._plane_fixtures import ANISO, open_pyramid, write_pyramid + +BLOCK_SIZE = 8 +SHAPES = [(40, 96, 96), (40, 48, 48), (40, 24, 24), (40, 12, 12)] + + +@pytest.fixture(scope="module") +def geometry(): + """An anisotropic four-level pyramid: z is never downsampled.""" + layout = BlockLayout3D(volume_shape=SHAPES[0], block_size=BLOCK_SIZE) + # shader order (x, y, z) + scales = np.array([[2.0**k, 2.0**k, 1.0] for k in range(4)]) + translations = np.array( + [[(2.0**k - 1) / 2, (2.0**k - 1) / 2, 0.0] for k in range(4)] + ) + grids = build_level_grids( + layout, 4, list(scales), list(translations), level_shapes=SHAPES + ) + return layout, grids, scales, translations + + +def _random_rows(rng, n): + """*n* half-spaces through random points of the volume, pygfx order.""" + normals = rng.normal(size=(n, 3)) + normals /= np.linalg.norm(normals, axis=1, keepdims=True) + points = rng.uniform([0, 0, 0], [96, 96, 40], size=(n, 3)) + return np.column_stack([normals, -(normals * points).sum(axis=1)]) + + +def _plan(geometry, camera, clip_rows, *, thresholds, force_level, cull_first): + layout, grids, scales, translations = geometry + keep = cull_masks(grids, clip_rows) if cull_first else None + if force_level is not None: + arr = select_levels_arr_forced(layout, force_level, grids, keep=keep) + else: + arr = select_levels_from_cache( + grids, 4, camera, thresholds=thresholds, base_layout=layout, keep=keep + ) + ranked = len(arr) + arr = sort_arr_by_distance( + arr, + camera, + BLOCK_SIZE, + scale_vecs_shader=scales, + translation_vecs_shader=translations, + ) + arr, _ = bricks_in_frustum_arr( + arr, + BLOCK_SIZE, + clip_rows, + level_scale_arr_shader=scales, + level_translation_arr_shader=translations, + ) + return arr, ranked + + +@pytest.mark.parametrize("force_level", [None, 1, 3, 9]) +@pytest.mark.parametrize("thresholds", [[60.0, 120.0, 240.0], None, []]) +def test_cull_first_plan_equals_cull_last_plan(geometry, force_level, thresholds): + rng = np.random.default_rng(7) + for _ in range(40): + clip_rows = _random_rows(rng, int(rng.integers(1, 4))) + camera = rng.uniform([-100, -100, -100], [200, 200, 140]) + kwargs = {"thresholds": thresholds, "force_level": force_level} + last, ranked_last = _plan( + geometry, camera, clip_rows, cull_first=False, **kwargs + ) + first, ranked_first = _plan( + geometry, camera, clip_rows, cull_first=True, **kwargs + ) + np.testing.assert_array_equal(first, last) + assert ranked_first <= ranked_last + + +def test_axis_aligned_rows_on_brick_faces_keep_what_the_corner_test_keeps(geometry): + """A plane lying exactly on brick faces is the rounding-sensitive case.""" + camera = np.array([150.0, 40.0, 80.0]) + for axis in range(3): + for sign in (1.0, -1.0): + for face in (-0.5, 7.5, 15.5, 31.5, 39.5): + row = np.zeros((1, 4)) + row[0, axis] = sign + row[0, 3] = -sign * face + kwargs = {"thresholds": [60.0, 120.0, 240.0], "force_level": None} + last, _ = _plan(geometry, camera, row, cull_first=False, **kwargs) + first, _ = _plan(geometry, camera, row, cull_first=True, **kwargs) + np.testing.assert_array_equal(first, last) + + +def test_cull_mask_is_none_with_nothing_to_cull_by(geometry): + _, grids, _, _ = geometry + assert cull_mask(grids[0]) is None + assert cull_masks(grids) is None + + +def test_cull_mask_plane_row_sets_are_a_union(geometry): + """A brick is kept when it reaches every row of at least one set.""" + _, grids, _, _ = geometry + grid = grids[0] + + def slab(axis, at): + low, high = np.zeros(4), np.zeros(4) + low[axis], low[3] = 1.0, -(at - 1.0) + high[axis], high[3] = -1.0, at + 1.0 + return np.stack([low, high]) + + slab_x, slab_y = slab(0, 20.0), slab(1, 60.0) + on_x = cull_mask(grid, plane_row_sets=[slab_x]) + on_y = cull_mask(grid, plane_row_sets=[slab_y]) + both = cull_mask(grid, plane_row_sets=[slab_x, slab_y]) + np.testing.assert_array_equal(both, on_x | on_y) + assert 0 < on_x.sum() < len(on_x) + # A slab is the intersection of its two rows, as clipping rows are. + np.testing.assert_array_equal(on_x, cull_mask(grid, clip_rows=slab_x)) + # Clipping rows intersect with the union of the planes. + clip = np.array([[0.0, 0.0, 1.0, -20.0]]) + np.testing.assert_array_equal( + cull_mask(grid, clip, [slab_x, slab_y]), cull_mask(grid, clip) & both + ) + + +# --------------------------------------------------------------------------- # +# The production planners # +# --------------------------------------------------------------------------- # + + +def _clip(store, point, normal): + return ClippingPlane.from_point_normal( + store.data_coordinate_systems[0], point, normal, axes=("z", "y", "x") + ) + + +def _build(controller, tmp_path, *, labels, clipped): + write_pyramid(tmp_path, ANISO, labels=labels) + store, _ = open_pyramid(tmp_path, ANISO) + scene = controller.add_scene(dim="3d", name="s") + controller._ensure_data_coordinate_systems(scene.id, store) + planes = (_clip(store, (32, 128, 128), (-0.6, 0.5, 1.0)),) if clipped else () + # A fine bias, so the fine levels (most of the bricks) are ranked. + if labels: + visual = controller.add_labels_multiscale( + data=store, + scene_id=scene.id, + appearance=MultiscaleLabelsAppearance(), + render_config=MultiscaleLabelRenderConfig(block_size=16, **SMALL_BUDGETS), + clipping_planes=planes, + ) + else: + visual = controller.add_image_multiscale( + data=store, + scene_id=scene.id, + appearance=MultiscaleImageAppearance(), + render_config=MultiscaleImageRenderConfig(block_size=16, **SMALL_BUDGETS), + single=MultiscaleImageSingleAppearance(render_mode="mip"), + clipping_planes=planes, + ) + controller.add_canvas(scene_id=scene.id, canvas_size=(400, 300)) + canvas_id = controller.get_canvas_ids(scene.id)[0] + view = controller._render_manager._canvases[canvas_id] + view._canvas.get_logical_size = lambda: (400.0, 300.0) + controller.fit_camera(scene.id) + return scene, visual + + +def _plan_counting(controller, scene, visual, monkeypatch, module): + """Plan once; return ``(bricks ranked, bricks the cull kept, all bricks)``.""" + ranked = [] + original = lod.sort_arr_by_distance + + def spy(arr, *args, **kwargs): + ranked.append(len(arr)) + return original(arr, *args, **kwargs) + + monkeypatch.setattr(module, "sort_arr_by_distance", spy) + canvas_id = controller.get_canvas_ids(scene.id)[0] + view = controller._render_manager._canvases[canvas_id] + selection = controller._selections_for_scene(scene.id)[canvas_id] + request = view.capture_reslicing_request(scene.dims.to_state(), selection, None) + gfx = controller._render_manager._scenes[scene.id].get_visual(visual.id) + config = controller._render_config_for(scene.id, visual) + # Every brick of the finest level, so the count does not hang on the camera. + gfx.plan(request, replace(config, force_level=1)) + planner = gfx if module is _label_multiscale else gfx._slots[0] + geo = planner._volume_geometry + masks = cull_masks(geo._level_grids, planner._clip_rows()) + total = len(geo._level_grids[0]["arr"]) + kept = total if masks is None else int(masks[0].sum()) + monkeypatch.undo() + return ranked[-1], kept, total + + +@pytest.mark.parametrize("labels", [False, True], ids=["image", "labels"]) +def test_a_clipped_visual_ranks_only_the_bricks_the_cull_kept( + controller, tmp_path, monkeypatch, labels +): + module = _label_multiscale if labels else _image + scene, visual = _build(controller, tmp_path, labels=labels, clipped=True) + ranked, kept, total = _plan_counting(controller, scene, visual, monkeypatch, module) + assert ranked <= kept + assert 0 < kept < total + + +@pytest.mark.parametrize("labels", [False, True], ids=["image", "labels"]) +def test_an_unclipped_visual_ranks_every_brick( + controller, tmp_path, monkeypatch, labels +): + module = _label_multiscale if labels else _image + scene, visual = _build(controller, tmp_path, labels=labels, clipped=False) + ranked, kept, total = _plan_counting(controller, scene, visual, monkeypatch, module) + assert ranked == kept == total diff --git a/tests/render/test_level_offsets_uploaded.py b/tests/render/test_level_offsets_uploaded.py index 9d66f156..ec2d7e26 100644 --- a/tests/render/test_level_offsets_uploaded.py +++ b/tests/render/test_level_offsets_uploaded.py @@ -12,7 +12,6 @@ import tensorstore as ts from cellier.data.image._zarr_multiscale_store import MultiscaleZarrDataStore -from cellier.visuals import ProgressiveLoadingConfig from cellier.visuals._image import MultiscaleImageRenderConfig from cellier.visuals._labels import ( MultiscaleLabelRenderConfig, @@ -49,19 +48,18 @@ def _store(root, dtype) -> MultiscaleZarrDataStore: @pytest.mark.parametrize("dim", ["2d", "3d"]) async def test_visuals_upload_level_offsets(controller, reslice, tmp_path, kind, dim): scene = controller.add_scene(dim=dim, name=f"offsets-{kind}-{dim}") - loading = ProgressiveLoadingConfig(backstop=False) if kind == "image": visual = controller.add_image_multiscale( data=_store(tmp_path, ts.float32), scene_id=scene.id, - render_config=MultiscaleImageRenderConfig(block_size=8, loading=loading), + render_config=MultiscaleImageRenderConfig(block_size=8), ) else: visual = controller.add_labels_multiscale( data=_store(tmp_path, ts.int32), scene_id=scene.id, appearance=MultiscaleLabelsAppearance(), - render_config=MultiscaleLabelRenderConfig(block_size=8, loading=loading), + render_config=MultiscaleLabelRenderConfig(block_size=8), ) controller.add_canvas(scene_id=scene.id) await reslice(controller, scene.id) diff --git a/tests/render/test_lod_bias_direction.py b/tests/render/test_lod_bias_direction.py index 3867b9f7..a588fb6d 100644 --- a/tests/render/test_lod_bias_direction.py +++ b/tests/render/test_lod_bias_direction.py @@ -1,6 +1,6 @@ """A larger ``lod_bias`` never picks a finer level in 3D, for images and labels. -``VisualRenderConfig.lod_bias`` is documented as "higher is coarser", and 2D +``VisualRenderConfig.settled_lod_bias`` is documented as "higher is coarser", and 2D already behaves that way. The 3D image planner divides its distance thresholds by the bias; the 3D labels planner multiplied, so for labels a higher bias picked *finer* levels. This pins both 3D planners to the same diff --git a/tests/render/test_mesh_dims_drag.py b/tests/render/test_mesh_dims_drag.py index 6ccfa91b..53627121 100644 --- a/tests/render/test_mesh_dims_drag.py +++ b/tests/render/test_mesh_dims_drag.py @@ -123,8 +123,8 @@ async def _loaded(rig, reads, *stores, **kwargs): def test_the_scrub_opt_in_follows_dims_drag(rig): coarse = _add(rig, _static_store("a")) full = _add(rig, _static_store("b"), lod=GeometryLodConfig(dims_drag="full")) - assert coarse.plans_coarse_on_scrub - assert not full.plans_coarse_on_scrub + assert coarse.plans_coarse_while_moving(3) + assert not full.plans_coarse_while_moving(3) # -- a mesh the scrub changes ------------------------------------------------- diff --git a/tests/render/test_multiscale_labels_2d_reference.py b/tests/render/test_multiscale_labels_2d_reference.py index 699c97b9..352e9b9e 100644 --- a/tests/render/test_multiscale_labels_2d_reference.py +++ b/tests/render/test_multiscale_labels_2d_reference.py @@ -28,7 +28,6 @@ import tensorstore as ts from cellier.data.image._zarr_multiscale_store import MultiscaleZarrDataStore -from cellier.visuals import ProgressiveLoadingConfig from cellier.visuals._labels import ( MultiscaleLabelRenderConfig, MultiscaleLabelsAppearance, @@ -108,9 +107,7 @@ async def _render(controller, render_scene, reslice, root, level): appearance=MultiscaleLabelsAppearance( force_level=level + 1, colormap_mode="direct", color_dict=COLORS ), - render_config=MultiscaleLabelRenderConfig( - block_size=BLOCK_SIZE, loading=ProgressiveLoadingConfig(backstop=False) - ), + render_config=MultiscaleLabelRenderConfig(block_size=BLOCK_SIZE), ) np.testing.assert_allclose( np.asarray(visual.transform.matrix), np.eye(visual.transform.matrix.shape[0]) diff --git a/tests/render/test_multiscale_labels_3d_pick.py b/tests/render/test_multiscale_labels_3d_pick.py index ff0a646b..9ea5fc15 100644 --- a/tests/render/test_multiscale_labels_3d_pick.py +++ b/tests/render/test_multiscale_labels_3d_pick.py @@ -15,7 +15,6 @@ import tensorstore as ts from cellier.data.image._zarr_multiscale_store import MultiscaleZarrDataStore -from cellier.visuals import ProgressiveLoadingConfig from cellier.visuals._labels import ( MultiscaleLabelRenderConfig, MultiscaleLabelsAppearance, @@ -80,7 +79,6 @@ async def test_a_pick_just_inside_the_silhouette_names_the_label( render_config=MultiscaleLabelRenderConfig( **SMALL_BUDGETS, block_size=8, - loading=ProgressiveLoadingConfig(backstop=False), ), ) controller.add_canvas(scene_id=scene.id) diff --git a/tests/render/test_multiscale_non_integer_pyramid.py b/tests/render/test_multiscale_non_integer_pyramid.py index 0c3d3315..29532fd3 100644 --- a/tests/render/test_multiscale_non_integer_pyramid.py +++ b/tests/render/test_multiscale_non_integer_pyramid.py @@ -173,11 +173,12 @@ async def _render_image(controller, render_scene, reslice, root, dim, level): data=_store(root, f"ramp-{dim}-{level}"), scene_id=scene.id, appearance=MultiscaleImageAppearance(force_level=level), - # The level under test only: no backstop drawn beneath it. + # The level under test only: it is its own backstop, so no other + # level is drawn beneath it. render_config=MultiscaleImageRenderConfig( **SMALL_BUDGETS, block_size=BLOCK_SIZE, - loading=ProgressiveLoadingConfig(backstop=False), + loading=ProgressiveLoadingConfig(backstop_level=level), ), single=MultiscaleImageSingleAppearance( color_map="gray", clim=(0.0, 1.0), render_mode="mip" @@ -200,7 +201,7 @@ async def _render_labels(controller, render_scene, reslice, root, dim, level): render_config=MultiscaleLabelRenderConfig( **SMALL_BUDGETS, block_size=BLOCK_SIZE, - loading=ProgressiveLoadingConfig(backstop=False), + loading=ProgressiveLoadingConfig(backstop_level=level), ), ) controller.add_canvas(scene_id=scene.id) diff --git a/tests/render/test_plane_golden_replay.py b/tests/render/test_plane_golden_replay.py new file mode 100644 index 00000000..dc5e6c3e --- /dev/null +++ b/tests/render/test_plane_golden_replay.py @@ -0,0 +1,370 @@ +"""Golden plans and shader text that the plane rendering work must not change. + +``plans/plane_rendering_design_v3.md`` 14.1 step 0.2. Captured once from the +shipping code (HEAD ``ec887f3b``) and replayed here: the cull-first planner +(Phase 1), the renames (Phase 2) and the shader branch (Phases 6, 7) must +leave every one of these equal. + +* ``plans.npz``: for each case, the desired sets of a multiscale visual's + plan (packed brick or tile keys, their request class, truncation counts), + from the production path (canvas request -> ``plan``). +* ``wgsl.json``: the sha256 and length of the WGSL every volume shader + generates in every render mode. + +The golden files are written only with ``PLANE_GOLDEN_WRITE=1`` and must +never be regenerated to make a test pass: a difference is the finding. +""" + +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path + +import numpy as np +import pytest +from pygfx.renderers.wgpu.shader.base import BaseShader + +from cellier.data import ImageMemoryStore, LabelMemoryStore +from cellier.transform import AffineTransform +from cellier.visuals import ( + ClippingPlane, + InMemoryImageSingleAppearance, + MultiscaleImageAppearance, + MultiscaleImageRenderConfig, + MultiscaleImageSingleAppearance, + MultiscaleLabelRenderConfig, + MultiscaleLabelsAppearance, +) +from tests._gpu_budget import SMALL_BUDGETS +from tests._plane_fixtures import ANISO, ISO, open_pyramid, write_pyramid + +GOLDEN = Path(__file__).resolve().parents[1] / "data" / "plane_golden" +WRITE = os.environ.get("PLANE_GOLDEN_WRITE") == "1" + +#: ``(point, normal)`` over data ``(z, y, x)`` of ``ANISO`` (level-0 voxels). +CLIPPING = { + "none": [], + "axis": [((0, 0, 128), (0, 0, 1))], + "oblique": [((32, 128, 128), (-0.6, 0.5, 1.0))], + "along_z": [((32, 0, 0), (1, 0, 0))], + "slab": [((0, 0, 96), (0, 0, 1)), ((0, 0, 160), (0, 0, -1))], + "oblique_and_axis": [((32, 128, 128), (0.3, 0.5, 1.0)), ((0, 100, 0), (0, 1, 0))], +} + + +def _bias(appearance_cls, value: float) -> dict: + """The appearance's bias field, whichever name it has at this commit.""" + fields = appearance_cls.model_fields + name = "settled_lod_bias" if "settled_lod_bias" in fields else "lod_bias" + return {name: value} + + +def _transform(controller, scene_id, store, voxel): + controller._ensure_data_coordinate_systems(scene_id, store) + data = store.data_coordinate_systems[0] + world = controller._model.scenes[scene_id].dims.world_coordinate_system + names = [a.name for a in data.axes] + return AffineTransform.from_axis_map( + data, + world, + {data.axis_by_name(n).id: world.axis_by_name(n).id for n in names}, + scale={data.axis_by_name(n).id: v for n, v in zip(names, voxel, strict=True)}, + ) + + +def _add(controller, tmp_path, spec, *, dim, labels=False, bias=1.0, planes=()): + root = tmp_path / ("labels" if labels else "image") + root.mkdir() + write_pyramid(root, spec, labels=labels) + store, voxel = open_pyramid(root, spec) + scene = controller.add_scene(dim=dim, name="s") + transform = _transform(controller, scene.id, store, voxel) + system = store.data_coordinate_systems[0] + clipping = tuple( + ClippingPlane.from_point_normal(system, p, n, axes=("z", "y", "x")) + for p, n in planes + ) + if labels: + visual = controller.add_labels_multiscale( + data=store, + scene_id=scene.id, + appearance=MultiscaleLabelsAppearance( + **_bias(MultiscaleLabelsAppearance, bias) + ), + render_config=MultiscaleLabelRenderConfig(block_size=16, **SMALL_BUDGETS), + transform=transform, + clipping_planes=clipping, + ) + else: + visual = controller.add_image_multiscale( + data=store, + scene_id=scene.id, + appearance=MultiscaleImageAppearance( + **_bias(MultiscaleImageAppearance, bias) + ), + render_config=MultiscaleImageRenderConfig(block_size=16, **SMALL_BUDGETS), + single=MultiscaleImageSingleAppearance( + color_map="viridis", render_mode="mip" + ), + transform=transform, + clipping_planes=clipping, + ) + controller.add_canvas(scene_id=scene.id, canvas_size=(400, 300)) + # The Qt canvas is never shown, so it reports 1 x 1; plan for a fixed size. + view = controller._render_manager._canvases[controller.get_canvas_ids(scene.id)[0]] + view._canvas.get_logical_size = lambda: (400.0, 300.0) + controller.fit_camera(scene.id) + return scene, visual, spec + + +def _plan(controller, scene, visual): + canvas_id = controller.get_canvas_ids(scene.id)[0] + view = controller._render_manager._canvases[canvas_id] + selection = controller._selections_for_scene(scene.id)[canvas_id] + request = view.capture_reslicing_request(scene.dims.to_state(), selection, None) + gfx = controller._render_manager._scenes[scene.id].get_visual(visual.id) + desired = gfx.plan(request, controller._render_config_for(scene.id, visual)) + out = {} + for i, ds in enumerate(desired): + out[f"{i}_keys"] = np.asarray(ds.keys, dtype=np.int64) + out[f"{i}_cls"] = np.asarray(ds.cls, dtype=np.int64).ravel() + out[f"{i}_trunc"] = np.array( + [ds.n_truncated_target, ds.n_truncated_backstop], dtype=np.int64 + ) + return out + + +def _centre(spec): + size = np.array(spec.shape0[-3:], dtype=float) * np.array(spec.voxel_size[-3:]) + return (size / 2.0)[::-1] # pygfx (x, y, z) + + +def _place(view, spec, which): + camera = view.camera + centre = _centre(spec) + fitted = np.asarray(camera.local.position, dtype=float) + offset = fitted - centre + if which == "side": + direction = np.array([-0.7, 0.45, 0.55]) + offset = direction / np.linalg.norm(direction) * np.linalg.norm(offset) + elif which == "close": + offset = offset * 0.35 + camera.local.position = tuple(centre + offset) + camera.look_at(tuple(centre)) + + +def _cases(controller, tmp_path_factory): + """Yield ``(name, arrays)`` for every plan case.""" + # 3D perspective: image, clipped and not, two biases, three cameras. + for clip, planes in CLIPPING.items(): + for bias in (1.0, 0.5): + for which in ("fit", "side", "close"): + path = tmp_path_factory.mktemp("g") + scene, visual, spec = _add( + controller, path, ANISO, dim="3d", bias=bias, planes=planes + ) + view = controller._render_manager._canvases[ + controller.get_canvas_ids(scene.id)[0] + ] + _place(view, spec, which) + yield ( + f"3d_image/{clip}/bias{bias}/{which}", + _plan(controller, scene, visual), + ) + controller.remove_scene(scene.id) + # 3D labels, with and without a clip. + for clip in ("none", "oblique"): + for which in ("fit", "side"): + path = tmp_path_factory.mktemp("g") + scene, visual, spec = _add( + controller, path, ANISO, dim="3d", labels=True, planes=CLIPPING[clip] + ) + view = controller._render_manager._canvases[ + controller.get_canvas_ids(scene.id)[0] + ] + _place(view, spec, which) + yield f"3d_labels/{clip}/{which}", _plan(controller, scene, visual) + controller.remove_scene(scene.id) + # An isotropic pyramid. + path = tmp_path_factory.mktemp("g") + scene, visual, spec = _add(controller, path, ISO, dim="3d") + view = controller._render_manager._canvases[controller.get_canvas_ids(scene.id)[0]] + _place(view, spec, "side") + yield "3d_iso/none/side", _plan(controller, scene, visual) + controller.remove_scene(scene.id) + # Orthographic 3D at three zooms (view height scales the camera). + for zoom in (1.0, 2.0, 4.0): + for clip in ("none", "oblique"): + path = tmp_path_factory.mktemp("g") + scene, visual, spec = _add( + controller, path, ANISO, dim="3d", planes=CLIPPING[clip] + ) + view = controller._render_manager._canvases[ + controller.get_canvas_ids(scene.id)[0] + ] + view.camera.fov = 0.0 + controller.fit_camera(scene.id) + view.camera.zoom = view.camera.zoom * zoom + yield f"3d_ortho/{clip}/zoom{zoom}", _plan(controller, scene, visual) + controller.remove_scene(scene.id) + # 2D slices. + for labels in (False, True): + path = tmp_path_factory.mktemp("g") + scene, visual, spec = _add(controller, path, ANISO, dim="2d", labels=labels) + yield ( + f"2d_{'labels' if labels else 'image'}/fit", + _plan(controller, scene, visual), + ) + controller.remove_scene(scene.id) + + +def _flatten(cases): + flat = {} + for name, arrays in cases: + for key, value in arrays.items(): + flat[f"{name}::{key}"] = value + return flat + + +def test_plans_equal_the_golden(controller, tmp_path_factory): + flat = _flatten(_cases(controller, tmp_path_factory)) + path = GOLDEN / "plans.npz" + if WRITE: + GOLDEN.mkdir(parents=True, exist_ok=True) + np.savez_compressed(path, **flat) + pytest.skip(f"wrote {len(flat)} arrays to {path}") + golden = np.load(path) + assert set(golden.files) == set(flat), sorted(set(golden.files) ^ set(flat))[:10] + wrong = [k for k in flat if not np.array_equal(golden[k], flat[k])] + assert not wrong, f"{len(wrong)} of {len(flat)} differ, e.g. {wrong[:5]}" + + +# --------------------------------------------------------------------------- # +# WGSL # +# --------------------------------------------------------------------------- # + + +def _capture_wgsl(controller, build): + """The WGSL the shaders generate while *build* draws one frame.""" + seen: dict[str, str] = {} + original = BaseShader.generate_wgsl + + def spy(self, **kw): + code = original(self, **kw) + seen[type(self).__name__] = code + return code + + # A cached shader module skips the templating, so a shader built earlier in + # the session would be missed: capture with the shader cache off. + from pygfx.renderers.wgpu.engine.pipeline import SHADER_CACHE + + BaseShader.generate_wgsl = spy + SHADER_CACHE.disable() + try: + build() + finally: + SHADER_CACHE.enable() + BaseShader.generate_wgsl = original + return seen + + +def _wgsl_cases(controller, tmp_path_factory, render_scene): + """``{case: {shader class: (sha256, length)}}``.""" + out = {} + + def digest(codes): + return { + name: [hashlib.sha256(code.encode()).hexdigest(), len(code)] + for name, code in sorted(codes.items()) + } + + def draw(scene): + render_scene(controller, scene.id, size=(64, 64)) + + cube = np.random.default_rng(0).random((16, 16, 16)).astype(np.float32) + labels = (np.arange(16**3).reshape(16, 16, 16) % 5).astype(np.int32) + + for mode in ("mip", "iso", "minip"): + + def build(mode=mode): + scene = controller.add_scene(dim="3d", name=mode) + controller.add_image( + data=ImageMemoryStore(data=cube, name=mode), + scene_id=scene.id, + single=InMemoryImageSingleAppearance(render_mode=mode), + ) + controller.add_canvas(scene_id=scene.id) + controller.fit_camera(scene.id) + draw(scene) + + out[f"image_memory/{mode}"] = digest(_capture_wgsl(controller, build)) + + def build_labels(): + scene = controller.add_scene(dim="3d", name="lab") + controller.add_labels( + data=LabelMemoryStore(data=labels, name="lab"), scene_id=scene.id + ) + controller.add_canvas(scene_id=scene.id) + controller.fit_camera(scene.id) + draw(scene) + + out["labels_memory/default"] = digest(_capture_wgsl(controller, build_labels)) + + for mode in ("iso", "mip", "smooth_iso", "attenuated_mip"): + # The mode lives on the single appearance; build with it directly. + def build_mode(mode=mode): + path = tmp_path_factory.mktemp("w") + write_pyramid(path, ANISO) + store, _ = open_pyramid(path, ANISO) + scene = controller.add_scene(dim="3d", name=mode) + controller.add_image_multiscale( + data=store, + scene_id=scene.id, + appearance=MultiscaleImageAppearance(), + render_config=MultiscaleImageRenderConfig( + block_size=16, **SMALL_BUDGETS + ), + single=MultiscaleImageSingleAppearance(render_mode=mode), + ) + controller.add_canvas(scene_id=scene.id) + controller.fit_camera(scene.id) + draw(scene) + + out[f"image_multiscale/{mode}"] = digest(_capture_wgsl(controller, build_mode)) + + for mode in ("iso_categorical", "flat_categorical", "gradient_debug", "smooth_iso"): + + def build_mode(mode=mode): + path = tmp_path_factory.mktemp("w") + write_pyramid(path, ANISO, labels=True) + store, _ = open_pyramid(path, ANISO) + scene = controller.add_scene(dim="3d", name=mode) + controller.add_labels_multiscale( + data=store, + scene_id=scene.id, + appearance=MultiscaleLabelsAppearance(render_mode=mode), + render_config=MultiscaleLabelRenderConfig( + block_size=16, **SMALL_BUDGETS + ), + ) + controller.add_canvas(scene_id=scene.id) + controller.fit_camera(scene.id) + draw(scene) + + out[f"labels_multiscale/{mode}"] = digest(_capture_wgsl(controller, build_mode)) + return out + + +def test_shader_text_equals_the_golden(controller, tmp_path_factory, render_scene): + cases = _wgsl_cases(controller, tmp_path_factory, render_scene) + empty = [k for k, v in cases.items() if not v] + assert not empty, f"no shader captured for {empty}" + path = GOLDEN / "wgsl.json" + if WRITE: + GOLDEN.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(cases, indent=1, sort_keys=True)) + pytest.skip(f"wrote {len(cases)} cases to {path}") + golden = json.loads(path.read_text()) + assert golden == json.loads(json.dumps(cases)) diff --git a/tests/render/test_ray_steps_per_voxel.py b/tests/render/test_ray_steps_per_voxel.py index c0470fb8..1f55fd29 100644 --- a/tests/render/test_ray_steps_per_voxel.py +++ b/tests/render/test_ray_steps_per_voxel.py @@ -15,7 +15,6 @@ from pydantic import ValidationError from cellier.data.image._zarr_multiscale_store import MultiscaleZarrDataStore -from cellier.visuals import ProgressiveLoadingConfig from cellier.visuals._image import ( MultiscaleImageAppearance, MultiscaleImageRenderConfig, @@ -68,7 +67,6 @@ def _write_slab(root, dtype: str, value) -> MultiscaleZarrDataStore: def _add_visual(controller, scene_id, root, kind): - loading = ProgressiveLoadingConfig(backstop=False) if kind == "labels": return controller.add_labels_multiscale( data=_write_slab(root, "int32", 1), @@ -76,17 +74,13 @@ def _add_visual(controller, scene_id, root, kind): appearance=MultiscaleLabelsAppearance( force_level=1, render_mode="flat_categorical" ), - render_config=MultiscaleLabelRenderConfig( - **SMALL_BUDGETS, block_size=8, loading=loading - ), + render_config=MultiscaleLabelRenderConfig(**SMALL_BUDGETS, block_size=8), ) return controller.add_image_multiscale( data=_write_slab(root, "float32", 1.0), scene_id=scene_id, appearance=MultiscaleImageAppearance(force_level=1), - render_config=MultiscaleImageRenderConfig( - **SMALL_BUDGETS, block_size=8, loading=loading - ), + render_config=MultiscaleImageRenderConfig(**SMALL_BUDGETS, block_size=8), single=MultiscaleImageSingleAppearance( color_map="gray", clim=(0.0, 1.0), render_mode="mip" ), diff --git a/tests/render/test_render_manager.py b/tests/render/test_render_manager.py index 23d1bc1d..4bb98f81 100644 --- a/tests/render/test_render_manager.py +++ b/tests/render/test_render_manager.py @@ -455,7 +455,7 @@ def _capture_request(dims_state, selection=None, target_visual_ids=None): def test_visual_render_config_defaults() -> None: config = VisualRenderConfig() - assert config.lod_bias == 1.0 + assert config.settled_lod_bias == 1.0 assert config.force_level is None assert config.frustum_cull is True @@ -466,7 +466,7 @@ def test_visual_render_config_defaults() -> None: def test_lod_bias_forwarded_to_visual() -> None: - """lod_bias from VisualRenderConfig must be passed to build_slice_request.""" + """The config's settled_lod_bias must be passed to build_slice_request.""" scene_id = uuid4() sm = SceneManager(scene_id=scene_id) visual = _make_mock_visual() @@ -475,7 +475,7 @@ def test_lod_bias_forwarded_to_visual() -> None: req = _make_reslicing_request(scene_id=scene_id) sm.build_slice_requests( - req, {visual.visual_model_id: VisualRenderConfig(lod_bias=2.0)} + req, {visual.visual_model_id: VisualRenderConfig(settled_lod_bias=2.0)} ) _, kwargs = visual.build_slice_request.call_args diff --git a/tests/render/test_volume_proxy_single_shading.py b/tests/render/test_volume_proxy_single_shading.py index 16b8199d..2f227819 100644 --- a/tests/render/test_volume_proxy_single_shading.py +++ b/tests/render/test_volume_proxy_single_shading.py @@ -25,7 +25,6 @@ InMemoryLabelsAppearance, MultiscaleImageAppearance, MultiscaleImageChannelAppearance, - ProgressiveLoadingConfig, ) from cellier.visuals._image import MultiscaleImageRenderConfig from cellier.visuals._labels import ( @@ -74,7 +73,6 @@ def _image(controller, tmp_path, mirrored): render_config=MultiscaleImageRenderConfig( **SMALL_BUDGETS, block_size=8, - loading=ProgressiveLoadingConfig(backstop=False), ), transform=_mirror(controller, scene, store, 4) if mirrored else None, channel_axis=0, @@ -124,7 +122,6 @@ def _labels_multiscale(controller, tmp_path, mirrored): render_config=MultiscaleLabelRenderConfig( **SMALL_BUDGETS, block_size=8, - loading=ProgressiveLoadingConfig(backstop=False), ), transform=_mirror(controller, scene, store, 3) if mirrored else None, ) @@ -209,8 +206,10 @@ async def test_frustum_cull_selects_bricks_under_mirroring( (gfx_visual,) = controller._render_manager._scenes[scene.id]._visuals.values() owners = gfx_visual.slots if kind == "image" else (gfx_visual,) + # The target level's bricks: the backstop (the coarsest level, always + # loaded) is not frustum-culled and is not what this test counts. resident = sum( - len(owner._block_cache_3d.tile_manager.tilemap) + sum(key.level == 1 for key in owner._block_cache_3d.tile_manager.tilemap) for owner in owners if owner._block_cache_3d is not None ) diff --git a/tests/render/test_volume_ray_direction.py b/tests/render/test_volume_ray_direction.py index 56e976ec..27e8e1d3 100644 --- a/tests/render/test_volume_ray_direction.py +++ b/tests/render/test_volume_ray_direction.py @@ -21,7 +21,7 @@ from cellier.data.image._zarr_multiscale_store import MultiscaleZarrDataStore from cellier.data.label._label_memory_store import LabelMemoryStore -from cellier.visuals import InMemoryLabelsAppearance, ProgressiveLoadingConfig +from cellier.visuals import InMemoryLabelsAppearance from cellier.visuals._labels import ( MultiscaleLabelRenderConfig, MultiscaleLabelsAppearance, @@ -88,7 +88,6 @@ def _labels_multiscale(controller, tmp_path): render_config=MultiscaleLabelRenderConfig( **SMALL_BUDGETS, block_size=8, - loading=ProgressiveLoadingConfig(backstop=False), ), ) return scene diff --git a/tests/test_plane_rendering_renames.py b/tests/test_plane_rendering_renames.py new file mode 100644 index 00000000..937475f1 --- /dev/null +++ b/tests/test_plane_rendering_renames.py @@ -0,0 +1,209 @@ +"""The hard break of plane rendering design v3 7.6 (Phase 2). + +The old names are removed, not aliased: each is refused, and none is left in +``src`` or ``examples``. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest +from pydantic import ValidationError + +import cellier.events +from cellier.controller import CellierController +from cellier.render._scene_config import VisualRenderConfig +from cellier.visuals import ( + MultiscaleImageAppearance, + MultiscaleLabelsAppearance, + ProgressiveLoadingConfig, +) +from cellier.visuals._base_visual import BaseVisual +from cellier.visuals._image import MultiscaleImageVisual +from cellier.visuals._labels import MultiscaleLabelVisual + +ROOT = Path(__file__).resolve().parents[1] + + +def _old(*parts: str) -> str: + """An old name, built so this file does not contain it.""" + return "_".join(parts) + + +LOD_BIAS = _old("lod", "bias") +DIMS_DRAG = _old("dims", "drag") +SCRUB_PROPERTY = _old("plans", "coarse", "on", "scrub") +CLIPPING_INTERACTION = _old("clipping", "interaction") + + +@pytest.mark.parametrize( + "appearance_cls", [MultiscaleImageAppearance, MultiscaleLabelsAppearance] +) +def test_the_old_bias_name_is_refused(appearance_cls) -> None: + with pytest.raises(ValidationError, match=f"{LOD_BIAS} was removed"): + appearance_cls(**{LOD_BIAS: 2.0}) + appearance = appearance_cls(settled_lod_bias=2.0) + assert appearance.settled_lod_bias == 2.0 + assert not hasattr(appearance, LOD_BIAS) + with pytest.raises(ValueError, match=LOD_BIAS): + setattr(appearance, LOD_BIAS, 3.0) + + +def test_the_render_config_carries_the_settled_bias() -> None: + assert VisualRenderConfig().settled_lod_bias == 1.0 + with pytest.raises(TypeError): + VisualRenderConfig(**{LOD_BIAS: 2.0}) + + +@pytest.mark.parametrize("field", [DIMS_DRAG, "backstop"]) +def test_the_removed_loading_fields_are_refused(field) -> None: + assert field not in ProgressiveLoadingConfig.model_fields + with pytest.raises(ValidationError, match=f"{field} was removed"): + ProgressiveLoadingConfig(**{field: True}) + + +@pytest.mark.parametrize( + "appearance_cls", [MultiscaleImageAppearance, MultiscaleLabelsAppearance] +) +def test_the_moving_settings_reproduce_the_old_defaults(appearance_cls) -> None: + """Phase 2 changes no behaviour: both settings start off (eager).""" + appearance = appearance_cls() + assert appearance.coarsest_while_moving_3d is False + assert appearance.coarsest_while_moving_2d is False + + +@pytest.mark.parametrize( + "visual_cls", [BaseVisual, MultiscaleImageVisual, MultiscaleLabelVisual] +) +def test_the_scrub_property_is_gone(visual_cls) -> None: + assert not hasattr(visual_cls, SCRUB_PROPERTY) + assert callable(visual_cls.plans_coarse_while_moving) + + +@pytest.mark.parametrize( + "name", + [ + f"begin_{CLIPPING_INTERACTION}", + f"end_{CLIPPING_INTERACTION}", + CLIPPING_INTERACTION, + f"{CLIPPING_INTERACTION}_state", + f"on_{CLIPPING_INTERACTION}", + ], +) +def test_the_clipping_interaction_names_are_gone(name) -> None: + assert not hasattr(CellierController, name) + assert hasattr(CellierController, name.replace("clipping", "plane")) + + +def test_the_interaction_event_is_renamed() -> None: + assert not hasattr(cellier.events, "Clipping" + "InteractionEvent") + assert "PlaneInteractionEvent" in cellier.events.__all__ + + +def test_the_old_controls_key_is_refused() -> None: + from cellier.convenience.gui._controls_config import ( + MultiscaleImageControlsConfig, + MultiscaleLabelsControlsConfig, + ) + + for config_cls in (MultiscaleImageControlsConfig, MultiscaleLabelsControlsConfig): + with pytest.raises(ValueError, match=LOD_BIAS): + config_cls(appearance=[LOD_BIAS]) + assert config_cls(appearance=["settled_lod_bias"]).appearance == [ + "settled_lod_bias" + ] + + +# --------------------------------------------------------------------------- # +# No old name is left # +# --------------------------------------------------------------------------- # + +#: Files where the bias is a planner argument (the settled bias is what it +#: carries) or an in-memory visual's unused argument: Phase 0 inventory, "keep". +_BIAS_ARGUMENT_FILES = { + "src/cellier/render/_level_of_detail.py", + "src/cellier/render/_level_of_detail_2d.py", + "src/cellier/render/scene_manager.py", + "src/cellier/render/visuals/_image.py", + "src/cellier/render/visuals/_label_multiscale.py", + "src/cellier/render/visuals/_image_memory.py", + "src/cellier/render/visuals/_label_memory.py", + "src/cellier/render/visuals/_lines_memory.py", + "src/cellier/render/visuals/_points_memory.py", + "src/cellier/render/visuals/_graph_memory.py", +} +#: The mesh keeps its own ``GeometryLodConfig`` setting of this name (D-P26). +_MESH_DRAG_FILES = { + "src/cellier/visuals/_loading.py", + "src/cellier/visuals/_mesh_memory.py", + "src/cellier/render/visuals/_mesh.py", + "src/cellier/render/_scene_config.py", + "src/cellier/gui/_lod.py", + "src/cellier/gui/_loading.py", + "src/cellier/controller.py", + "src/cellier/convenience/_viewer.py", + "examples/mesh/timeseries_multiscale_mesh.py", +} +_SKIP_DIRS = {"__pycache__", ".ipynb_checkpoints", "__marimo__", "node_modules"} +_SUFFIXES = {".py", ".js", ".ipynb", ".md"} + + +def _sources(): + for top in ("src", "examples"): + for path in sorted((ROOT / top).rglob("*")): + if path.suffix not in _SUFFIXES or _SKIP_DIRS & set(path.parts): + continue + yield path.relative_to(ROOT).as_posix(), path.read_text(errors="replace") + + +def _word(name: str) -> re.Pattern: + return re.compile(rf"(? None: + # The anywidget control's asset files keep their names until the level of + # detail control replaces them (Phase 4). + assets = (f"{LOD_BIAS}.js", f"{LOD_BIAS}.css") + hits = _hits( + _word(LOD_BIAS), + _BIAS_ARGUMENT_FILES, + skip_line=lambda line: any(asset in line for asset in assets), + ) + assert not hits, "\n".join(hits) + + +def test_no_image_dims_drag_is_left() -> None: + hits = _hits(re.compile(DIMS_DRAG), _MESH_DRAG_FILES) + assert not hits, "\n".join(hits) + + +@pytest.mark.parametrize( + "pattern", + [ + SCRUB_PROPERTY, + CLIPPING_INTERACTION, + "Clipping" + "InteractionEvent", + "_clip" + "_driver", + r"loading\.backstop(?![A-Za-z0-9_])", + r"(? None: + hits = _hits(re.compile(pattern), set()) + assert not hits, "\n".join(hits) diff --git a/tests/v2/test_anywidget.py b/tests/v2/test_anywidget.py index e3e705ad..98e8cd56 100644 --- a/tests/v2/test_anywidget.py +++ b/tests/v2/test_anywidget.py @@ -757,19 +757,19 @@ def _appearance_changed_event(source_id, visual_id, field, value): def test_lod_bias_slider_user_change_emits_appearance_update(): - """A user-driven lod_bias change emits AppearanceUpdateEvent.""" + """A user-driven settled_lod_bias change emits AppearanceUpdateEvent.""" from cellier.events import AppearanceUpdateEvent slider = _make_lod_bias_slider() emitted = [] slider.changed.connect(emitted.append) - slider.lod_bias = 2.0 + slider.settled_lod_bias = 2.0 assert len(emitted) == 1 event = emitted[0] assert isinstance(event, AppearanceUpdateEvent) - assert event.field == "lod_bias" + assert event.field == "settled_lod_bias" assert event.value == 2.0 diff --git a/tests/v2/test_controller.py b/tests/v2/test_controller.py index 19abeccc..c9a29734 100644 --- a/tests/v2/test_controller.py +++ b/tests/v2/test_controller.py @@ -104,7 +104,9 @@ def test_add_image_populates_scene_and_render_layer(small_zarr_store): cs = _make_cs() scene = controller.add_scene(dim="3d", coordinate_system=cs, name="main") store = _make_store(small_zarr_store) - appearance = _make_appearance(lod_bias=1.0, force_level=None, frustum_cull=True) + appearance = _make_appearance( + settled_lod_bias=1.0, force_level=None, frustum_cull=True + ) visual = controller.add_image_multiscale( data=store, scene_id=scene.id, appearance=appearance, name="vol" ) @@ -256,7 +258,9 @@ def test_reslice_scene_reads_appearance_fields(small_zarr_store): cs = _make_cs() scene = controller.add_scene(dim="3d", coordinate_system=cs, name="main") store = _make_store(small_zarr_store) - appearance = _make_appearance(lod_bias=2.5, force_level=1, frustum_cull=False) + appearance = _make_appearance( + settled_lod_bias=2.5, force_level=1, frustum_cull=False + ) visual = controller.add_image_multiscale( data=store, scene_id=scene.id, appearance=appearance, name="vol" ) @@ -272,7 +276,7 @@ def capturing_reslice(s_id, dims_state, visual_configs=None, **_): assert visual.id in captured cfg = captured[visual.id] - assert cfg.lod_bias == 2.5 + assert cfg.settled_lod_bias == 2.5 assert cfg.force_level == 1 assert cfg.frustum_cull is False @@ -432,7 +436,7 @@ def test_appearance_bridge_lod_bias(small_zarr_store): reslice_calls.append(vid) ) - visual.appearance.lod_bias = 2.0 + visual.appearance.settled_lod_bias = 2.0 assert len(events) == 1 assert events[0].requires_reslice is True assert len(reslice_calls) == 1 @@ -651,7 +655,9 @@ def _make_settle_controller(small_zarr_store, threshold_s=0.05): visual = controller.add_image_multiscale( data=store, scene_id=scene.id, - appearance=_make_appearance(lod_bias=1.0, force_level=None, frustum_cull=True), + appearance=_make_appearance( + settled_lod_bias=1.0, force_level=None, frustum_cull=True + ), name="vol", ) diff --git a/tests/v2/test_viewer_model.py b/tests/v2/test_viewer_model.py index 2ad0e6ed..68829abb 100644 --- a/tests/v2/test_viewer_model.py +++ b/tests/v2/test_viewer_model.py @@ -30,7 +30,7 @@ def _build_viewer(small_zarr_store): appearance = MultiscaleImageAppearance( color_map="viridis", clim=(0.0, 1.0), - lod_bias=1.0, + settled_lod_bias=1.0, force_level=None, frustum_cull=True, ) diff --git a/tests/v2/visuals/test_image.py b/tests/v2/visuals/test_image.py index 53ef90d3..5373b4a2 100644 --- a/tests/v2/visuals/test_image.py +++ b/tests/v2/visuals/test_image.py @@ -26,7 +26,7 @@ def _make_level_transforms_3d(factors): def test_image_appearance_roundtrip(tmp_path): # Non-default values including the new LOD/frustum fields original = MultiscaleImageAppearance( - lod_bias=2.0, + settled_lod_bias=2.0, force_level=1, frustum_cull=False, ) @@ -51,7 +51,7 @@ def test_multiscale_image_visual_roundtrip(tmp_path): data_store_id=store_id, level_transforms=transforms, appearance=MultiscaleImageAppearance( - lod_bias=1.5, force_level=None, frustum_cull=True + settled_lod_bias=1.5, force_level=None, frustum_cull=True ), single=MultiscaleImageSingleAppearance(color_map="viridis", clim=(0.0, 1.0)), ) diff --git a/tests/v2/visuals/test_multiscale_label_visual.py b/tests/v2/visuals/test_multiscale_label_visual.py index d7230485..ae92e9a1 100644 --- a/tests/v2/visuals/test_multiscale_label_visual.py +++ b/tests/v2/visuals/test_multiscale_label_visual.py @@ -24,7 +24,7 @@ def test_appearance_defaults(): assert a.color_dict == {} assert a.render_mode == "iso_categorical" assert a.opacity == 1.0 - assert a.lod_bias == 1.0 + assert a.settled_lod_bias == 1.0 assert a.force_level is None assert a.frustum_cull is True @@ -37,7 +37,7 @@ def test_appearance_json_roundtrip(): color_dict={1: (1.0, 0.0, 0.0, 1.0), 2: (0.0, 1.0, 0.0, 1.0)}, render_mode="flat_categorical", opacity=0.7, - lod_bias=2.0, + settled_lod_bias=2.0, force_level=1, frustum_cull=False, ) @@ -49,7 +49,7 @@ def test_appearance_json_roundtrip(): assert b.color_dict == {1: (1.0, 0.0, 0.0, 1.0), 2: (0.0, 1.0, 0.0, 1.0)} assert b.render_mode == "flat_categorical" assert abs(b.opacity - 0.7) < 1e-6 - assert b.lod_bias == 2.0 + assert b.settled_lod_bias == 2.0 assert b.force_level == 1 assert b.frustum_cull is False From b366c3c6261d5018e68f597db10967e4396aac25 Mon Sep 17 00:00:00 2001 From: Kevin Yamauchi Date: Tue, 6 Oct 2026 21:25:39 +0200 Subject: [PATCH 05/24] add base features for plane --- docs/Explanations/events.md | 36 +- docs/Explanations/interaction_tracker.md | 6 +- docs/Explanations/slicing.md | 4 +- docs/How_To/clipping_planes.md | 9 +- docs/api/gui.md | 2 +- docs/api/visuals.md | 8 + .../multiscale_clipping_planes_viewer.py | 2 +- ...ultiscale_clipping_planes_viewer_marimo.py | 2 +- .../convenience/multiscale_image_viewer.ipynb | 2 +- .../convenience/multiscale_image_viewer.py | 2 +- .../multiscale_image_viewer_marimo.py | 2 +- src/cellier/controller.py | 825 ++++++++++++++++- .../convenience/gui/_appearance_widgets.py | 15 +- .../convenience/gui/_appearance_widgets_qt.py | 15 +- .../convenience/gui/_controls_config.py | 21 +- src/cellier/convenience/layout/_shared.py | 26 +- src/cellier/events/__init__.py | 4 + src/cellier/events/_bus.py | 3 + src/cellier/events/_events.py | 43 +- src/cellier/events/_update_events.py | 22 + src/cellier/gui/_appearance_fields.py | 24 +- src/cellier/gui/_level_of_detail.py | 77 ++ src/cellier/gui/anywidget/visuals/__init__.py | 6 +- .../gui/anywidget/visuals/_level_of_detail.py | 178 ++++ .../gui/anywidget/visuals/_lod_bias.py | 135 --- .../{lod_bias.css => level_of_detail.css} | 17 +- .../visuals/static/level_of_detail.js | 103 +++ .../gui/anywidget/visuals/static/lod_bias.js | 64 -- src/cellier/gui/qt/render/_base.py | 2 +- src/cellier/gui/qt/visuals/__init__.py | 4 +- .../gui/qt/visuals/_level_of_detail.py | 223 +++++ src/cellier/gui/qt/visuals/_lod_bias.py | 147 --- src/cellier/render/_plane_mode.py | 32 + .../render/shaders/_label_multiscale.py | 5 +- src/cellier/render/shaders/_label_volume.py | 5 +- .../shaders/_multiscale_volume_brick.py | 5 +- src/cellier/render/visuals/_image_memory.py | 10 +- src/cellier/visuals/__init__.py | 3 + src/cellier/visuals/_base_visual.py | 5 +- src/cellier/visuals/_image.py | 25 +- src/cellier/visuals/_image_memory.py | 78 +- src/cellier/visuals/_label_memory.py | 19 +- src/cellier/visuals/_labels.py | 23 +- src/cellier/visuals/_removed.py | 8 + src/cellier/visuals/_render_plane.py | 360 ++++++++ tests/convenience/test_appearance_specs.py | 15 +- tests/convenience/test_cleanup_assumptions.py | 4 +- .../test_controls_config_validation.py | 18 +- tests/convenience/test_non_image_controls.py | 20 +- .../test_ortho_appearance_controls.py | 6 +- .../test_qt_appearance_acceptance.py | 8 +- tests/convenience/test_qt_renderer.py | 4 +- tests/gui/anywidget/test_level_of_detail.py | 145 +++ tests/gui/anywidget/test_lod_bias.py | 88 -- tests/gui/qt/test_qt_level_of_detail.py | 179 ++++ tests/gui/qt/test_qt_lod_bias.py | 117 --- tests/gui/test_image_control_rows.py | 54 +- tests/gui/test_visual_id_groups.py | 36 +- tests/render/scheduling/test_dims_drag.py | 8 +- tests/render/test_clipping_interaction.py | 13 +- tests/render/test_moving_policy.py | 543 +++++++++++ tests/render/test_render_planes_controller.py | 857 ++++++++++++++++++ tests/test_plane_rendering_renames.py | 37 +- tests/v2/test_anywidget.py | 12 +- tests/v2/visuals/test_render_plane.py | 267 ++++++ 65 files changed, 4297 insertions(+), 741 deletions(-) create mode 100644 src/cellier/gui/_level_of_detail.py create mode 100644 src/cellier/gui/anywidget/visuals/_level_of_detail.py delete mode 100644 src/cellier/gui/anywidget/visuals/_lod_bias.py rename src/cellier/gui/anywidget/visuals/static/{lod_bias.css => level_of_detail.css} (57%) create mode 100644 src/cellier/gui/anywidget/visuals/static/level_of_detail.js delete mode 100644 src/cellier/gui/anywidget/visuals/static/lod_bias.js create mode 100644 src/cellier/gui/qt/visuals/_level_of_detail.py delete mode 100644 src/cellier/gui/qt/visuals/_lod_bias.py create mode 100644 src/cellier/render/_plane_mode.py create mode 100644 src/cellier/visuals/_render_plane.py create mode 100644 tests/gui/anywidget/test_level_of_detail.py delete mode 100644 tests/gui/anywidget/test_lod_bias.py create mode 100644 tests/gui/qt/test_qt_level_of_detail.py delete mode 100644 tests/gui/qt/test_qt_lod_bias.py create mode 100644 tests/render/test_moving_policy.py create mode 100644 tests/render/test_render_planes_controller.py create mode 100644 tests/v2/visuals/test_render_plane.py diff --git a/docs/Explanations/events.md b/docs/Explanations/events.md index 03078367..aa946e39 100644 --- a/docs/Explanations/events.md +++ b/docs/Explanations/events.md @@ -35,7 +35,7 @@ src/cellier/v2/ └── visuals/ ├── _contrast_limits.py ├── _colormap.py - ├── _lod_bias.py + ├── _level_of_detail.py ├── _image.py └── _aabb.py ``` @@ -684,13 +684,17 @@ visual when its request depends on the planes. class PlaneInteractionEvent(NamedTuple): """A drag of a visual's planes started or ended. - One tracker per visual serves its planes; today those are its clipping - planes. A drag is a run of ``clipping_planes`` changes made inside - ``CellierController.plane_interaction`` (a gizmo opens one for the - length of a drag). The start is emitted with the first change, ahead - of its ``ClippingPlanesChangedEvent``. No event is emitted per change. - - It announces only: a plane change plans the same way inside a drag as + One tracker per visual serves its clipping planes and its render planes. + A drag is a run of ``clipping_planes`` or ``render_planes`` changes made + inside ``CellierController.plane_interaction`` (a gizmo opens one for + the length of a drag). The start is emitted with the first change, + ahead of its ``ClippingPlanesChangedEvent`` or + ``RenderPlanesChangedEvent``. No event is emitted per change. + + A multiscale image or labels visual in a 3D view with + ``appearance.coarsest_while_moving_3d`` on (the default) plans nothing + between the start and the end, and its target is planned at the end. + Every other visual plans a plane change the same way inside a drag as outside one. Attributes @@ -748,6 +752,16 @@ class ClippingPlaneGizmoChangedEvent(NamedTuple): plane_id: UUID | None = None ``` +**Render planes.** An image or labels visual also carries `render_planes`, a +tuple of frozen `RenderPlane`s in the scene's world space, which a 3D view +draws while the visual's render mode is `"plane"`. A change is a new tuple, +announced by `RenderPlanesChangedEvent(source_id, visual_id, render_planes)` +whatever the render mode; a GUI asks for one with +`RenderPlanesUpdateEvent(source_id, visual_id, render_planes)` on the incoming +bus. While a 3D view draws the planes, a change is also a tick of the visual's +plane interaction tracker, the one its clipping planes use, so one +`PlaneInteractionEvent` pair brackets a drag of either kind. + **Requests.** A clipping planes control sends two update events on the incoming bus. The controller applies them and emits the events above with the request's `source_id`: @@ -1273,6 +1287,7 @@ CellierEventTypes = ( | LoadingConfigChangedEvent | LodConfigChangedEvent | ClippingPlanesChangedEvent + | RenderPlanesChangedEvent | PlaneInteractionEvent | ClippingPlaneGizmoChangedEvent | ResliceCancelledEvent @@ -1452,6 +1467,7 @@ _ENTITY_FIELD: dict[type, str] = { LoadingConfigChangedEvent: "visual_id", LodConfigChangedEvent: "visual_id", ClippingPlanesChangedEvent: "visual_id", + RenderPlanesChangedEvent: "visual_id", PlaneInteractionEvent: "visual_id", ClippingPlaneGizmoChangedEvent: "canvas_id", ResliceCancelledEvent: "visual_id", @@ -2046,6 +2062,7 @@ object registration time. | Controller | `FrameRenderedEvent` | *(none — all canvases)* | With no event loop, run the camera reslices the frame queued | | `OrthoDimsController` | `DimsInteractionEvent` | `scene_id` (each panel) | Open a dims interaction scope on the other panels while one is scrubbed | | Controller | `ClippingPlanesUpdateEvent` (incoming) | *(none)* | Call `set_clipping_planes` with the request's `source_id` | +| Controller | `RenderPlanesUpdateEvent` (incoming) | *(none)* | Call `set_render_planes` with the request's `source_id` | | Controller | `ClippingPlaneGizmoUpdateEvent` (incoming) | *(none)* | Open the canvas's gizmo on the plane, or close it | | `ClippingPlaneGizmoController` | `ClippingPlanesChangedEvent` | `visual_id` | Move the gizmo to its plane's new pose; close when the plane is gone or can no longer have a gizmo | | `ClippingPlaneGizmoController` | `VisualRemovedEvent` | `scene_id` | Close the gizmo when its visual is removed | @@ -2073,6 +2090,7 @@ object registration time. | Clipping planes control | `ClippingPlanesChangedEvent` | `visual_id` (each visual it edits) | Show the planes (`QtClippingPlanesControls`, `AnywidgetClippingPlanesControls`); edits go out as one `ClippingPlanesUpdateEvent` per visual on the incoming bus | | Clipping planes control | `ClippingPlaneGizmoChangedEvent` | `canvas_id` | With a gizmo target: show which plane has the canvas's gizmo; the toggle goes out as `ClippingPlaneGizmoUpdateEvent` | | External callback | `ClippingPlanesChangedEvent` | `visual_id` | Fire `on_clipping_planes_changed` user callback | +| External callback | `RenderPlanesChangedEvent` | `visual_id` | Fire `on_render_planes_changed` user callback | | External callback | `PlaneInteractionEvent` | `visual_id` | Fire `on_plane_interaction` user callback | | External callback | `ClippingPlaneGizmoChangedEvent` | `canvas_id` | Fire `on_clipping_plane_gizmo_changed` user callback | | External callback | `CanvasMouse{Press,Move,Release}{2D,3D}Event` | `canvas_id` | Fire `on_mouse_*` user callback | @@ -2251,6 +2269,7 @@ from cellier.events._events import ( PlaneInteractionEvent, ClippingPlaneGizmoChangedEvent, ClippingPlanesChangedEvent, + RenderPlanesChangedEvent, DataStoreContentsChangedEvent, DataStoreMetadataChangedEvent, DimsChangedEvent, @@ -2287,6 +2306,7 @@ from cellier.events._update_events import ( CellierUpdateEventTypes, ClippingPlaneGizmoUpdateEvent, ClippingPlanesUpdateEvent, + RenderPlanesUpdateEvent, DimsInteractionUpdateEvent, DimsUpdateEvent, SubscriptionSpec, diff --git a/docs/Explanations/interaction_tracker.md b/docs/Explanations/interaction_tracker.md index eb573998..21ecea9b 100644 --- a/docs/Explanations/interaction_tracker.md +++ b/docs/Explanations/interaction_tracker.md @@ -130,7 +130,7 @@ Visuals do not subscribe to the tracker. Each visual model answers one question | Visual | `plans_coarse_while_moving` | |---|---| -| Multiscale image, multiscale labels | `appearance.coarsest_while_moving_3d` in a 3D view, `appearance.coarsest_while_moving_2d` in a 2D view (both default to `False`) | +| Multiscale image, multiscale labels | `appearance.coarsest_while_moving_3d` in a 3D view, `appearance.coarsest_while_moving_2d` in a 2D view (`True` by default in 3D, `False` in 2D) | | Multiscale mesh | `lod.dims_drag == "coarse"` (the default, which is `True`), when the mesh has more than one level | | Everything else | `False` | @@ -138,6 +138,10 @@ The controller turns the tracker's state into a plan mode in one place, `Cellier - While the scene's tracker is `ACTIVE`, a visual that opted in plans `BACKSTOP_ONLY` (its coarse backstop level) and joins the scene's *pending set*. This applies to **every** reslice during the scrub, not only the scrub's own ticks: a visual shown, a config change, a store change, and a camera reslice all plan coarse. - Visuals that did not opt in plan in full on every tick, as they always have. + +A drag of a visual's planes (`controller.plane_interaction`, which a clipping plane gizmo opens) is the visual's other motion, with a tracker per visual. It has no plan mode. While the tracker is `ACTIVE`, a multiscale image or labels visual in a 3D view with `coarsest_while_moving_3d` on is **not planned at all**: its last plan stays the desired set, so the bricks it has are still drawn, and the always-loaded backstop shows where the planes reveal more. (A `BACKSTOP_ONLY` plan would drop those bricks.) The drag's end, by release or stillness, plans the target. + +The target is planned only when every motion of the visual has stopped. A scrub that ends during a plane drag leaves the visual on its backstop, and the drag's end plans it; a drag that ends during a scrub is planned by the scrub's end; a camera motion that ends during a drag does not plan the visual, and a drag that ends while a camera moves is planned by the camera's end. A visibility, render mode or displayed-axes change is a jump: it ends the drag and plans in full. - When the scrub ends by release or stillness, the pending set is planned in full, once. ### Timeline: a slider drag diff --git a/docs/Explanations/slicing.md b/docs/Explanations/slicing.md index d59b767f..41fe2b35 100644 --- a/docs/Explanations/slicing.md +++ b/docs/Explanations/slicing.md @@ -210,7 +210,9 @@ A loop that wants every position at full resolution does not open a scope. That 4. **`CellierController._render_config_for`**: the one place a plan mode is decided. While the scene's tracker is `ACTIVE`, a visual whose `plans_coarse_while_moving` is true for the scene's view gets `PlanMode.BACKSTOP_ONLY` and joins the scene's pending set. 5. **`CellierController._on_dims_transition`**: at a scrub's end by release or stillness, plans the pending set in full with `target_visual_ids`. -`coarsest_while_moving_3d=False` and `coarsest_while_moving_2d=False` (the defaults) plan in full on every tick. `True` loads the backstop only on each tick, and the target when the scrub ends; it saves most of a scrub's reads and is recommended for remote stores. Both show the slider's slice, blurry, about one read behind it. +A `coarsest_while_moving` setting that is `False` plans in full on every tick. `True` loads the backstop only on each tick, and the target when the scrub ends; it saves most of a scrub's reads. The 3D setting defaults to `True`, because a full 3D plan per tick never catches up with the slider; the 2D one defaults to `False`, because a slice is a few hundred tiles, and `True` is recommended there for remote stores. Both show the slider's slice, blurry, about one read behind it. + +In a 3D view the same setting covers a drag of the visual's clipping planes: with it on, nothing is planned until the drag ends (see the interaction tracker explanation). A slider sends more than ticks. On press it opens a scope and on release it closes it, so the target loads on release instead of 0.15 s later. The release first flushes the slider's throttle: the end plans in full, and it must plan the final position. The anywidget panel's release message carries the final position itself, because in a notebook a custom message can overtake the traitlet sync sent before it. diff --git a/docs/How_To/clipping_planes.md b/docs/How_To/clipping_planes.md index ba99a8cb..bce59768 100644 --- a/docs/How_To/clipping_planes.md +++ b/docs/How_To/clipping_planes.md @@ -252,8 +252,13 @@ between them. A script can group its own changes the same way with gizmo is on is announced by `ClippingPlaneGizmoChangedEvent` (`controller.on_clipping_plane_gizmo_changed`). -A plane change during a drag costs the same as any other. On a multiscale -volume of about a million level-0 bricks that is 13 to 38 ms a frame. +A multiscale image or labels visual in a 3D view plans nothing during a +drag: it draws the bricks it already has, and its coarse backstop where the +plane reveals more, and loads the full detail when the drag ends (on release, +or after 0.15 s of stillness). This is `appearance.coarsest_while_moving_3d`, +on by default; turn it off to plan on every change, which on a multiscale +volume of about a million level-0 bricks costs 13 to 38 ms a frame. A change +outside a drag always plans at once. Known limit: with outlines enabled, a handle over an outlined visual gets a thin contour in that visual's outline colour. diff --git a/docs/api/gui.md b/docs/api/gui.md index a17fbcf3..7483c7f4 100644 --- a/docs/api/gui.md +++ b/docs/api/gui.md @@ -13,7 +13,7 @@ Qt widgets for building interactive cellier-based applications. ::: cellier.gui.qt.visuals.QtVisibleToggle ::: cellier.gui.qt.visuals.QtOpacitySlider ::: cellier.gui.qt.visuals.QtUniformColorPicker -::: cellier.gui.qt.visuals.QtLodBiasSlider +::: cellier.gui.qt.visuals.QtLevelOfDetailControls ::: cellier.gui.qt.visuals.QtLoadingIndicator ::: cellier.gui.qt.visuals.QtLoadingConfigControls ::: cellier.gui.qt.visuals.QtMeshSectionControls diff --git a/docs/api/visuals.md b/docs/api/visuals.md index ce15a01d..af20ee51 100644 --- a/docs/api/visuals.md +++ b/docs/api/visuals.md @@ -76,6 +76,14 @@ planes](../How_To/clipping_planes.md). ::: cellier.visuals.ClippingPlane +## Render planes + +An image or labels visual whose render mode is `"plane"` draws its data on +its `render_planes` tuple in a 3D view, instead of as a volume. A render +plane is in the scene's world space. + +::: cellier.visuals.RenderPlane + ## Common ::: cellier.visuals.AABBParams diff --git a/examples/clipping_planes/multiscale_clipping_planes_viewer.py b/examples/clipping_planes/multiscale_clipping_planes_viewer.py index 16a31a28..2c8d66a8 100644 --- a/examples/clipping_planes/multiscale_clipping_planes_viewer.py +++ b/examples/clipping_planes/multiscale_clipping_planes_viewer.py @@ -188,7 +188,7 @@ def beside(store, column: int) -> AffineTransform: transform=beside(image_store, 0), clipping_planes=half(image_store, (0, 0, 1)), controls=MultiscaleImageControlsConfig( - appearance=["visible", "render_mode", "iso_threshold", "settled_lod_bias"], + appearance=["visible", "render_mode", "iso_threshold", "level_of_detail"], clipping_controls=True, # How much of the plan is loaded: watch it while a plane moves. loading_indicator=True, diff --git a/examples/clipping_planes/multiscale_clipping_planes_viewer_marimo.py b/examples/clipping_planes/multiscale_clipping_planes_viewer_marimo.py index ddb8ee25..dc143f7f 100644 --- a/examples/clipping_planes/multiscale_clipping_planes_viewer_marimo.py +++ b/examples/clipping_planes/multiscale_clipping_planes_viewer_marimo.py @@ -331,7 +331,7 @@ def beside(store, column: int): transform=beside(image_store, 0), clipping_planes=half(image_store, (0, 0, 1)), controls=MultiscaleImageControlsConfig( - appearance=["visible", "render_mode", "iso_threshold", "settled_lod_bias"], + appearance=["visible", "render_mode", "iso_threshold", "level_of_detail"], clipping_controls=True, loading_indicator=True, ), diff --git a/examples/convenience/multiscale_image_viewer.ipynb b/examples/convenience/multiscale_image_viewer.ipynb index 0130221f..dd553e97 100644 --- a/examples/convenience/multiscale_image_viewer.ipynb +++ b/examples/convenience/multiscale_image_viewer.ipynb @@ -187,7 +187,7 @@ " \"render_mode\",\n", " \"iso_threshold\",\n", " \"attenuation\",\n", - " \"settled_lod_bias\",\n", + " \"level_of_detail\",\n", " ],\n", " colormap_names=[\n", " \"grays\",\n", diff --git a/examples/convenience/multiscale_image_viewer.py b/examples/convenience/multiscale_image_viewer.py index 562c16ac..609d1b70 100644 --- a/examples/convenience/multiscale_image_viewer.py +++ b/examples/convenience/multiscale_image_viewer.py @@ -143,7 +143,7 @@ def _write_zarr3(base_path, name, data): "render_mode", "iso_threshold", "attenuation", - "settled_lod_bias", + "level_of_detail", ], colormap_names=[ "grays", diff --git a/examples/convenience/multiscale_image_viewer_marimo.py b/examples/convenience/multiscale_image_viewer_marimo.py index 0a8db495..a331dfc6 100644 --- a/examples/convenience/multiscale_image_viewer_marimo.py +++ b/examples/convenience/multiscale_image_viewer_marimo.py @@ -201,7 +201,7 @@ def _( "render_mode", "iso_threshold", "attenuation", - "settled_lod_bias", + "level_of_detail", ], colormap_names=[ "grays", diff --git a/src/cellier/controller.py b/src/cellier/controller.py index da709c9c..cb7e339d 100644 --- a/src/cellier/controller.py +++ b/src/cellier/controller.py @@ -67,6 +67,8 @@ PlaneInteractionEvent, RenderConfigChangedEvent, RenderConfigUpdateEvent, + RenderPlanesChangedEvent, + RenderPlanesUpdateEvent, ResliceCompletedEvent, ResliceProgressEvent, ResliceStartedEvent, @@ -171,7 +173,7 @@ from cellier.viewer_model import DataManager, ViewerModel if TYPE_CHECKING: - from collections.abc import Mapping, Sequence + from collections.abc import Iterable, Mapping, Sequence from cellier.transform import Plane from cellier.visuals._base_visual import BaseVisual, VisualOutline @@ -196,6 +198,7 @@ InMemoryImageAppearance, InMemoryImageChannelAppearance, InMemoryImageSingleAppearance, + check_channels_share_render_mode, ) from cellier.visuals._label_memory import ( BaseLabelsAppearance, @@ -219,6 +222,11 @@ MultiscaleMeshVisual, ) from cellier.visuals._points_memory import PointsMarkerAppearance, PointsVisual +from cellier.visuals._render_plane import ( + MAX_RENDER_PLANES, + RenderPlane, + validate_render_planes, +) from cellier.visuals._scene_overlay import SceneBoundingBox, SceneOverlay if TYPE_CHECKING: @@ -256,6 +264,9 @@ #: The scheduled ones are retired while hidden: their reads stop. _SKIP_WHEN_HIDDEN = (BaseImageVisual, BaseMeshVisual) +#: Visuals with a ``"plane"`` render mode and ``render_planes``. +_PLANE_VISUALS = (BaseImageVisual, BaseLabelsVisual) + #: Visuals the chunk scheduler loads. A ``"contents"`` store change needs no #: new plan for them: invalidation already requeued what they want. _SCHEDULED_VISUALS = (MultiscaleImageVisual, MultiscaleLabelVisual, BaseMeshVisual) @@ -457,6 +468,12 @@ class _OverlayEntry: contextvars.ContextVar("_clipping_source_id_override", default=None) ) +# The same for ``visual.render_planes`` changes, stamped on +# ``RenderPlanesChangedEvent`` (``set_render_planes``). +_render_planes_source_id_override: contextvars.ContextVar[UUID | None] = ( + contextvars.ContextVar("_render_planes_source_id_override", default=None) +) + # Parallel context variable for ``visual.lod`` changes, stamped on # ``LodConfigChangedEvent`` (``set_lod_config``). _lod_source_id_override: contextvars.ContextVar[UUID | None] = contextvars.ContextVar( @@ -847,12 +864,25 @@ def __init__( self._dims_scrub_pending: dict[UUID, set[UUID]] = {} self._dims_scrub_axes: dict[UUID, set[int]] = {} self._dims_ticks: dict[UUID, bool] = {} - # Clipping plane drags, one tracker per visual (gizmo design 6). It - # announces a drag's start and end and drives nothing else. + # Plane drags, one tracker per visual (gizmo design 6; plane + # rendering design 7.2). ``_plane_plan_owed`` holds the visuals a + # drag left without a plan (7.4): the drag's end plans them. self._plane_interaction_driver = _InteractionDriver( lambda: self._render_manager.config.scheduler.dims_settle_s, self._on_plane_transition, ) + self._plane_plan_owed: set[UUID] = set() + # Render planes (plane rendering design 4). Per visual: whether a + # 3D view shows it in plane mode, the render mode its channels share + # (to put a refused one back), and whether it has been warned that a + # plane's axes are not the displayed ones. + self._plane_mode_shown: dict[UUID, bool] = {} + self._channel_render_modes: dict[UUID, dict[int, str]] = {} + # Image visuals whose channels' render modes are being set together + # (``set_image_render_mode``): the one-mode rule is not checked + # between the channels of the batch. + self._render_mode_sync: set[UUID] = set() + self._render_plane_axes_warned: set[UUID] = set() # The clipping plane gizmo of each canvas that has one: at most one # per canvas (gizmo design 7). self._clipping_gizmos: dict[UUID, ClippingPlaneGizmoController] = {} @@ -1041,6 +1071,11 @@ def __init__( self._on_clipping_planes_update, owner_id=self._id, ) + self._incoming_events.subscribe( + RenderPlanesUpdateEvent, + self._on_render_planes_update, + owner_id=self._id, + ) self._incoming_events.subscribe( ClippingPlaneGizmoUpdateEvent, self._on_clipping_plane_gizmo_update, @@ -1521,6 +1556,10 @@ def add_visual( visual_model.transform = default_data_to_world( data_store.data_coordinate_system, world ) + if isinstance(visual_model, _PLANE_VISUALS): + self._check_render_planes( + visual_model, visual_model.render_planes, scene_id=scene_id + ) if isinstance(visual_model, MultiscaleImageVisual): return self._add_multiscale_image_visual(scene_id, visual_model) @@ -1561,6 +1600,7 @@ def add_image( ambient_occlusion: bool | None = None, pick_write: bool = True, clipping_planes: Sequence[ClippingPlane] = (), + render_planes: Sequence[RenderPlane] = (), ) -> ImageVisual: """Add an in-memory image visual to a scene. @@ -1608,6 +1648,12 @@ def add_image( Build them from ``data.data_coordinate_systems[0]``. They can be changed later by assigning ``visual.clipping_planes``. Default none. + render_planes : Sequence[RenderPlane] + The planes the visual draws its data on in a 3D view while its + render mode is ``"plane"``, in the scene's world space; at most + four. Build them from the scene's world coordinate system + (``RenderPlane.from_point_normal``). They can be changed later + with :meth:`set_render_planes`. Default none. Returns ------- @@ -1622,6 +1668,7 @@ def add_image( """ visual_model = ImageVisual( clipping_planes=clipping_planes, + render_planes=render_planes, name=name, data_store_id=str(data.id), appearance=appearance @@ -1656,6 +1703,7 @@ def add_labels( outline_selected_labels: dict[int, int] | None = None, outline_mode: OutlineMode = "per_label", clipping_planes: Sequence[ClippingPlane] = (), + render_planes: Sequence[RenderPlane] = (), ) -> LabelMemoryVisual: """Add an in-memory label visual to a scene. @@ -1705,6 +1753,12 @@ def add_labels( Build them from ``data.data_coordinate_systems[0]``. They can be changed later by assigning ``visual.clipping_planes``. Default none. + render_planes : Sequence[RenderPlane] + The planes the visual draws its data on in a 3D view while its + render mode is ``"plane"``, in the scene's world space; at most + four. Build them from the scene's world coordinate system + (``RenderPlane.from_point_normal``). They can be changed later + with :meth:`set_render_planes`. Default none. Returns ------- @@ -1718,6 +1772,7 @@ def add_labels( resolved_transform = self._prepare_transform(scene_id, data, transform) visual_model = LabelMemoryVisual( clipping_planes=clipping_planes, + render_planes=render_planes, name=name, data_store_id=str(data.id), appearance=appearance, @@ -2174,6 +2229,7 @@ def add_image_multiscale( ambient_occlusion: bool | None = None, pick_write: bool = True, clipping_planes: Sequence[ClippingPlane] = (), + render_planes: Sequence[RenderPlane] = (), ) -> MultiscaleImageVisual: """Add a multiscale image visual to a scene. @@ -2218,6 +2274,12 @@ def add_image_multiscale( Build them from ``data.data_coordinate_systems[0]``. They can be changed later by assigning ``visual.clipping_planes``. Default none. + render_planes : Sequence[RenderPlane] + The planes the visual draws its data on in a 3D view while its + render mode is ``"plane"``, in the scene's world space; at most + four. Build them from the scene's world coordinate system + (``RenderPlane.from_point_normal``). They can be changed later + with :meth:`set_render_planes`. Default none. Returns ------- @@ -2234,6 +2296,7 @@ def add_image_multiscale( resolved_transform = self._prepare_transform(scene_id, data, transform) visual_model = MultiscaleImageVisual( clipping_planes=clipping_planes, + render_planes=render_planes, name=name, data_store_id=str(data.id), level_transforms=data.level_transforms, @@ -2271,6 +2334,7 @@ def add_labels_multiscale( outline_selected_labels: dict[int, int] | None = None, outline_mode: OutlineMode = "per_label", clipping_planes: Sequence[ClippingPlane] = (), + render_planes: Sequence[RenderPlane] = (), ) -> MultiscaleLabelVisual: """Add a multiscale label visual to a scene. @@ -2323,6 +2387,12 @@ def add_labels_multiscale( Build them from ``data.data_coordinate_systems[0]``. They can be changed later by assigning ``visual.clipping_planes``. Default none. + render_planes : Sequence[RenderPlane] + The planes the visual draws its data on in a 3D view while its + render mode is ``"plane"``, in the scene's world space; at most + four. Build them from the scene's world coordinate system + (``RenderPlane.from_point_normal``). They can be changed later + with :meth:`set_render_planes`. Default none. Returns ------- @@ -2334,6 +2404,7 @@ def add_labels_multiscale( resolved_transform = self._prepare_transform(scene_id, data, transform) visual_model = MultiscaleLabelVisual( clipping_planes=clipping_planes, + render_planes=render_planes, name=name, data_store_id=str(data.id), level_transforms=data.level_transforms, @@ -2533,6 +2604,9 @@ def _register_visual( self._wire_lod(visual_model) self._wire_transform(visual_model, scene_id) self._wire_clipping_planes(visual_model) + if isinstance(visual_model, _PLANE_VISUALS): + self._wire_render_planes(visual_model) + self._warn_render_planes_not_displayed(visual_model) self._wire_render_config(visual_model) self._wire_pick_write(visual_model) self._wire_visual_render(visual_model) @@ -2553,6 +2627,7 @@ def _register_visual( (AABBChangedEvent, "on_aabb_changed"), (MeshSectionChangedEvent, "on_section_changed"), (ClippingPlanesChangedEvent, "on_clipping_planes_changed"), + (RenderPlanesChangedEvent, "on_render_planes_changed"), (VisualVisibilityChangedEvent, "on_visibility_changed"), (TrailChangedEvent, "on_trail_changed"), (TransformChangedEvent, "on_transform_changed"), @@ -3948,8 +4023,17 @@ def _render_config_for( ``BACKSTOP_ONLY`` and joins the scene's pending set, which the scrub's end plans in full. A visual that scrub handed to a camera end stays ``BACKSTOP_ONLY`` until then. + + A drag of the visual's planes has no plan mode: while it holds the + plan nothing is planned at all (:meth:`_plane_drag_holds_plan`), and + a reslice that does reach here during one is a jump, planned in full. + + A visual in plane mode with no plane to draw in this view draws + nothing, so it is not sliced (design 4.2). """ config = _visual_render_config(visual) + if config.slicing_enabled and self._plane_mode_draws_nothing(scene_id, visual): + config = dataclasses.replace(config, slicing_enabled=False) if self._dims_driver.is_active(scene_id) and visual.plans_coarse_while_moving( self._n_displayed_dims(scene_id) ): @@ -4794,7 +4878,10 @@ def _on_clipping_planes(planes: tuple) -> None: ) if visual_id in self._visual_to_scene: if self._render_manager.clipping_planes_affect_request(visual_id): - self.reslice_visual(visual_id) + # A tick of a drag keeps the last plan when the view + # plans coarsest while moving; the drag's end plans. + if not self._hold_plan_for_plane_drag(visual): + self.reslice_visual(visual_id) self._request_draw_for_visual(visual_id) # No event loop means no timer: the drag ends here. self._plane_interaction_driver.settle_without_loop(visual_id) @@ -4804,6 +4891,270 @@ def _on_clipping_planes(planes: tuple) -> None: (visual.events.clipping_planes, _on_clipping_planes) ) + # ------------------------------------------------------------------ + # Render planes (plane rendering design v3, sections 4 and 7.4) + # ------------------------------------------------------------------ + + def _check_render_planes( + self, visual: BaseVisual, planes: Any, *, scene_id: UUID | None = None + ) -> None: + """Refuse render planes the visual cannot carry (design 4.3). + + Parameters + ---------- + visual : BaseVisual + An image or labels visual. + planes : tuple[RenderPlane, ...] + The tuple to check. + scene_id : UUID or None + The visual's scene, for a visual not registered yet. + + Raises + ------ + ValueError + If there are more than ``MAX_RENDER_PLANES`` planes, two share + an id, a plane is not on three axes of the scene's world + coordinate system, or the visual's transform does not map a + plane's axes by a per-axis scale and translation. + """ + if not planes: + return + if len(planes) > MAX_RENDER_PLANES: + raise ValueError( + f"Visual {visual.name!r} was given {len(planes)} render " + f"planes; a visual draws at most {MAX_RENDER_PLANES}." + ) + ids = [plane.id for plane in planes] + if len(set(ids)) != len(ids): + raise ValueError( + f"Two render planes of visual {visual.name!r} share an id. " + "Each plane of a visual needs its own." + ) + if scene_id is None: + scene_id = self._visual_to_scene[visual.id] + world = self._model.scenes[scene_id].dims.world_coordinate_system + for index, plane in enumerate(planes): + if plane.coordinate_system != world.id: + raise ValueError( + f"Render plane {index} of visual {visual.name!r} is not in " + "the scene's world coordinate system. Build it from the " + "scene's dims.world_coordinate_system." + ) + try: + axes = [world.resolve(axis) for axis in plane.axes] + except (KeyError, ValueError) as error: + raise ValueError( + f"Render plane {index} of visual {visual.name!r} names an " + f"axis that is not one axis of the scene's world " + f"{world.axis_names()}: {error}" + ) from error + if len(set(axes)) != 3: + raise ValueError( + f"Render plane {index} of visual {visual.name!r} names a " + "world axis more than once." + ) + self._check_render_plane_transform(visual, index, axes, world) + + @staticmethod + def _check_render_plane_transform( + visual: BaseVisual, index: int, axes: list[int], world: Any + ) -> None: + """Refuse a plane on world axes the visual's data does not map to singly. + + The plane's level rule and culling rows take the visual's transform, + on the plane's three axes, to be a scale and a translation per axis: + each of the three world axes fed by one data axis that feeds nothing + else. + """ + linear = getattr(visual.transform, "linear", None) + ok = linear is not None + if ok: + linear = np.asarray(linear) + columns = [] + for row in axes: + fed_by = np.flatnonzero(linear[row]) + if fed_by.size != 1: + ok = False + break + columns.append(int(fed_by[0])) + ok = ( + ok + and len(set(columns)) == 3 + and all(np.flatnonzero(linear[:, c]).size == 1 for c in columns) + ) + if not ok: + names = tuple(world.axes[axis].name for axis in axes) + raise ValueError( + f"Render plane {index} of visual {visual.name!r} is on world " + f"axes {names}, which the visual's transform does not map by " + "a scale and a translation per axis. A render plane needs " + "each of its axes to be one data axis, scaled and shifted." + ) + + def _draws_planes(self, visual: BaseVisual) -> bool: + """Whether *visual* is in plane mode in a 3D view. + + In a 2D view the mode and the planes are ignored: the visual shows + its normal slice. + """ + if not isinstance(visual, _PLANE_VISUALS): + return False + scene_id = self._visual_to_scene.get(visual.id) + if scene_id is None or self._n_displayed_dims(scene_id) != 3: + return False + return visual.plane_mode() + + def _split_render_planes( + self, scene_id: UUID, planes: Iterable[RenderPlane] + ) -> tuple[tuple[RenderPlane, ...], tuple[RenderPlane, ...]]: + """The enabled *planes*: those on the scene's displayed axes, and the rest.""" + dims = self._model.scenes[scene_id].dims + world = dims.world_coordinate_system + displayed = set(dims.selection.displayed_axes) + drawable: list[RenderPlane] = [] + elsewhere: list[RenderPlane] = [] + for plane in planes: + if not plane.enabled: + continue + try: + axes = {world.resolve(axis) for axis in plane.axes} + except (KeyError, ValueError): + axes = set() + (drawable if axes == displayed else elsewhere).append(plane) + return tuple(drawable), tuple(elsewhere) + + def _plane_mode_draws_nothing(self, scene_id: UUID, visual: BaseVisual) -> bool: + """Whether *visual* is in plane mode in a 3D view with no plane to draw. + + Then it draws nothing: it is not sliced and loads no backstop, and + it is resliced when a plane appears (design 4.2). + """ + if not isinstance(visual, _PLANE_VISUALS) or not visual.plane_mode(): + return False + if self._n_displayed_dims(scene_id) != 3: + return False + drawable, _ = self._split_render_planes(scene_id, visual.render_planes) + return not drawable + + def _warn_render_planes_not_displayed(self, visual: BaseVisual) -> None: + """Warn, once per visual, that a plane's axes are not the displayed ones. + + Such a plane is not drawn (design 4.4). The warning is armed again + once every enabled plane of the visual is on the displayed axes. + """ + if not self._draws_planes(visual): + return + scene_id = self._visual_to_scene[visual.id] + _, elsewhere = self._split_render_planes(scene_id, visual.render_planes) + if not elsewhere: + self._render_plane_axes_warned.discard(visual.id) + return + if visual.id in self._render_plane_axes_warned: + return + self._render_plane_axes_warned.add(visual.id) + dims = self._model.scenes[scene_id].dims + world = dims.world_coordinate_system + displayed = tuple(world.axes[i].name for i in dims.selection.displayed_axes) + warnings.warn( + f"Visual {visual.name!r} is in 'plane' render mode with " + f"{len(elsewhere)} render plane(s) on axes other than the " + f"displayed {displayed}; they are not drawn. A render plane is " + "drawn only in a 3D view of its own three axes.", + UserWarning, + stacklevel=2, + ) + + def _refresh_plane_mode(self, visual_id: UUID, *, reslice: bool = True) -> None: + """Act on a change of whether a 3D view shows a visual in plane mode. + + Called after a render mode, ``composite``, ``channels`` or + displayed-axes change. Entering or leaving plane mode changes what + the visual reads, so it is resliced: a jump. + + Parameters + ---------- + visual_id : UUID + The visual. + reslice : bool + ``False`` when the caller reslices anyway. + """ + if visual_id not in self._visual_to_scene: + return + visual = self.get_visual_model(visual_id) + shown = self._draws_planes(visual) + was = self._plane_mode_shown.get(visual_id, False) + self._plane_mode_shown[visual_id] = shown + if shown: + self._warn_render_planes_not_displayed(visual) + else: + self._render_plane_axes_warned.discard(visual_id) + if shown != was and reslice: + self.reslice_visual(visual_id) + self._request_draw_for_visual(visual_id) + + def _wire_render_planes(self, visual: BaseVisual) -> None: + """Act on a replaced ``render_planes`` tuple (design 7.4, 8.2). + + Emits ``RenderPlanesChangedEvent``, which carries the planes to the + render visual. While a 3D view shows the visual in plane mode the + change is also a tick of the visual's plane interaction tracker, + and a multiscale visual is resliced: at once for a jump, at the + drag's end when the view plans coarsest while moving. Otherwise the + tuple is stored and announced and nothing else happens. + + A tuple that fails the check is put back to the last good one and + the error raised. + """ + visual_id = visual.id + accepted: list[tuple] = [visual.render_planes] + self._plane_mode_shown[visual_id] = self._draws_planes(visual) + + def _on_render_planes(planes: tuple) -> None: + try: + self._check_render_planes(visual, planes) + except ValueError: + with visual.events.render_planes.blocked(): + visual.render_planes = accepted[0] + raise + previous, accepted[0] = accepted[0], planes + source_id = _render_planes_source_id_override.get() or self._id + drawn = self._draws_planes(visual) + if drawn: + # The tracker hears of it first, so a drag's start is + # announced ahead of its first change. + self._plane_interaction_driver.tick( + visual_id, source_id, interactive=False + ) + self._outgoing_events.emit( + RenderPlanesChangedEvent( + source_id=source_id, + visual_id=visual_id, + render_planes=planes, + ) + ) + if not drawn: + return + self._warn_render_planes_not_displayed(visual) + scene_id = self._visual_to_scene[visual_id] + if isinstance(visual, (MultiscaleImageVisual, MultiscaleLabelVisual)): + # What a multiscale visual reads follows its planes. + if not self._hold_plan_for_plane_drag(visual): + self.reslice_visual(visual_id) + elif ( + not self._split_render_planes(scene_id, previous)[0] + and self._split_render_planes(scene_id, planes)[0] + ): + # An in-memory visual with no plane to draw was not sliced. + self.reslice_visual(visual_id) + self._request_draw_for_visual(visual_id) + # No event loop means no timer: the drag ends here. + self._plane_interaction_driver.settle_without_loop(visual_id) + + visual.events.render_planes.connect(_on_render_planes) + self._visual_psygnal_handlers.setdefault(visual_id, []).append( + (visual.events.render_planes, _on_render_planes) + ) + def _wire_render_config(self, visual: BaseVisual) -> None: """Reslice a multiscale visual whose ``render_config.loading`` changed. @@ -4916,6 +5267,8 @@ def _on_composite(new_value: bool) -> None: if scene_id is None: return self._check_slider_axes(scene_id) + self._end_plane_drag_by_jump(visual_id, resolved_source_id) + self._refresh_plane_mode(visual_id, reslice=False) self.reslice_visual(visual_id) self._request_draw_for_visual(visual_id) @@ -4937,6 +5290,10 @@ def _on_single_replaced(new_single: Any) -> None: new_value=new_single, ) ) + self._end_plane_drag_by_jump( + visual_id, _source_id_override.get() or self._id + ) + self._refresh_plane_mode(visual_id) self._request_draw_for_visual(visual_id) visual.events.single.connect(_on_single_replaced) @@ -4946,13 +5303,21 @@ def _on_single_replaced(new_single: Any) -> None: known_keys = {"keys": set(visual.channels)} def _on_channels_replaced(new_channels: dict) -> None: + # The model has already refused channels that do not share one + # render mode (``BaseImageVisual``), so nothing to check here. self._wire_channels(visual) keys = set(new_channels) changed = keys != known_keys["keys"] known_keys["keys"] = keys - if changed and visual_id in self._visual_to_scene: + if visual_id not in self._visual_to_scene: + return + self._end_plane_drag_by_jump( + visual_id, _source_id_override.get() or self._id + ) + self._refresh_plane_mode(visual_id, reslice=not changed) + if changed: self.reslice_visual(visual_id) - self._request_draw_for_visual(visual_id) + self._request_draw_for_visual(visual_id) visual.events.channels.connect(_on_channels_replaced) handlers.append((visual.events.channels, _on_channels_replaced)) @@ -4966,6 +5331,11 @@ def _connect_single_bridge(self, visual: BaseImageVisual) -> None: visual_id = visual.id def _on_single_field(info: EmissionInfo) -> None: + if info.signal.name == "render_mode": + # A jump: it ends a drag of the visual's planes. + self._end_plane_drag_by_jump( + visual_id, _source_id_override.get() or self._id + ) self._outgoing_events.emit( SingleAppearanceChangedEvent( source_id=_source_id_override.get() or self._id, @@ -4974,6 +5344,8 @@ def _on_single_field(info: EmissionInfo) -> None: new_value=info.args[0], ) ) + if info.signal.name == "render_mode": + self._refresh_plane_mode(visual_id) self._request_draw_for_visual(visual_id) visual.single.events.connect(_on_single_field) @@ -4994,6 +5366,9 @@ def _wire_channels(self, visual: BaseImageVisual) -> None: appearance.events.connect(handler) wired.append((appearance.events, handler)) self._channel_psygnal_handlers[visual.id] = wired + self._channel_render_modes[visual.id] = { + index: channel.render_mode for index, channel in visual.channels.items() + } def _make_channel_appearance_handler( self, visual_id: UUID, channel_index: int @@ -5004,6 +5379,14 @@ def _on_channel_appearance_psygnal(info: EmissionInfo) -> None: field_name: str = info.signal.name new_value = info.args[0] resolved_source_id = _source_id_override.get() or self._id + syncing = visual_id in self._render_mode_sync + if field_name == "render_mode": + if not syncing: + self._accept_channel_render_mode( + visual_id, channel_index, new_value + ) + # A jump: it ends a drag of the visual's planes. + self._end_plane_drag_by_jump(visual_id, resolved_source_id) self._outgoing_events.emit( ChannelAppearanceChangedEvent( source_id=resolved_source_id, @@ -5015,16 +5398,50 @@ def _on_channel_appearance_psygnal(info: EmissionInfo) -> None: ) # A hidden channel is left out of every slice request, so showing # it needs a load. - if ( + resliced = ( field_name == "visible" and new_value and visual_id in self._visual_to_scene - ): + ) + # ``set_image_render_mode`` refreshes once, after its last channel. + if field_name in ("render_mode", "visible") and not syncing: + self._refresh_plane_mode(visual_id, reslice=not resliced) + if resliced: self.reslice_visual(visual_id) self._request_draw_for_visual(visual_id) return _on_channel_appearance_psygnal + def _accept_channel_render_mode( + self, visual_id: UUID, channel_index: int, new_value: str + ) -> None: + """Refuse a render mode assigned to one channel alone; put it back. + + Every channel of an image visual has the same render mode. A direct + assignment to one channel breaks that unless the visual has one + channel, so it is undone and the error raised. + + Raises + ------ + ValueError + Naming :meth:`set_image_render_mode`. + """ + visual = self.get_visual_model(visual_id) + modes = self._channel_render_modes.setdefault(visual_id, {}) + try: + check_channels_share_render_mode(visual.channels, visual.name) + except ValueError: + channel = visual.channels[channel_index] + with channel.events.render_mode.blocked(): + channel.render_mode = modes.get(channel_index, new_value) + raise ValueError( + f"Channel {channel_index} of image {visual.name!r} cannot have " + f"render mode {new_value!r} alone: every channel of an image " + "has the same render mode. Use " + "CellierController.set_image_render_mode to set them together." + ) from None + modes[channel_index] = new_value + def _wire_aabb(self, visual: BaseVisual) -> None: """Subscribe to all field changes on a visual's aabb model.""" handler = self._make_aabb_handler(visual.id) @@ -5593,6 +6010,9 @@ def _on_appearance_psygnal(info: EmissionInfo) -> None: resolved_source_id, _source_id_override.get() is not None, ) + if field_name in ("visible", "render_mode"): + # A jump: it ends a drag of the visual's planes. + self._end_plane_drag_by_jump(visual_id, resolved_source_id) if field_name == "visible": self._outgoing_events.emit( VisualVisibilityChangedEvent( @@ -5621,6 +6041,8 @@ def _on_appearance_psygnal(info: EmissionInfo) -> None: ) if field_name in _RESLICE_FIELDS: self.reslice_visual(visual_id) + elif field_name == "render_mode": + self._refresh_plane_mode(visual_id) # An appearance change repaints the same data, so it triggers no # reslice (only the three _RESLICE_FIELDS do) and nothing else in @@ -5914,6 +6336,12 @@ def _on_dims_changed_bus(self, event: DimsChangedEvent) -> None: tracked = scene_id in self._dims_ticks if event.displayed_axes_changed: self._dims_driver.cancel(scene_id, event.source_id) + # The reslice below plans every visual, so no drag is owed one. + scene = self._model.scenes.get(scene_id) + for visual in scene.visuals if scene is not None else (): + self._plane_plan_owed.discard(visual.id) + self._plane_interaction_driver.cancel(visual.id, event.source_id) + self._refresh_plane_mode(visual.id, reslice=False) elif not tracked: self._dims_driver.tick(scene_id, event.source_id, interactive=False) self.reslice_scene(scene_id) @@ -5964,6 +6392,9 @@ def _on_dims_transition(self, scene_id: UUID, transition: Transition) -> None: if handed: self._camera_handoff.setdefault(scene_id, set()).update(handed) target -= handed + # A visual whose planes are still dragged keeps its backstop: the + # target is planned when every motion has stopped (7.4). + target -= self._leave_to_plane_drags(live[vid] for vid in target) if not target: return _SCHEDULER_LOGGER.info( @@ -6451,6 +6882,13 @@ def update_channel_appearance_field( A ``pydantic.ValidationError`` from a malformed *value* is allowed to propagate (matching ``update_appearance_field``). + ``render_mode`` is the exception to "one channel": every channel of + an image has the same render mode, so setting it here sets it on all + of them, as :meth:`set_image_render_mode` does. *source_id* is + stamped on this channel's event only; the other channels' events + carry the controller's id, so the caller's echo filter lets them + through. + Parameters ---------- visual_id : @@ -6467,6 +6905,10 @@ def update_channel_appearance_field( """ visual = self.get_visual_model(visual_id) resolved_source_id = source_id if source_id is not None else self._id + if field == "render_mode": + visual.channels[channel_index] # KeyError for an unknown channel + self._set_channels_render_mode(visual, value, {channel_index: source_id}) + return _SOURCE_ID_LOGGER.debug( "set channel=%d field=%s visual=%s source=%s", channel_index, @@ -6577,6 +7019,90 @@ def update_single_group_field( visual_id, field, value, source_id=source_id ) + def set_image_render_mode( + self, + visual_id: UUID, + render_mode: str, + *, + source_id: UUID | None = None, + ) -> None: + """Set the render mode of every channel of an image visual. + + Every entry of ``visual.channels`` has the same ``render_mode``, so + the mode is set here, on all of them at once. Assigning it to one + channel of several is refused. ``single.render_mode``, which single + mode draws with, is separate and is not touched. + + One ``ChannelAppearanceChangedEvent`` is emitted per channel whose + mode changed. Entering or leaving ``"plane"`` mode reslices the + visual once. + + Parameters + ---------- + visual_id : + Target visual: an image visual. + render_mode : + The new mode, e.g. ``"mip"`` or ``"plane"``. + source_id : + UUID to stamp on the emitted events. Defaults to the + controller's own ID. + + Raises + ------ + TypeError + If the visual is not an image visual. + pydantic.ValidationError + If *render_mode* is not a mode of the visual's channels. Nothing + is changed. + """ + visual = self.get_visual_model(visual_id) + if not isinstance(visual, BaseImageVisual): + raise TypeError(f"Visual {visual_id} is not an image visual.") + self._set_channels_render_mode( + visual, render_mode, dict.fromkeys(visual.channels, source_id) + ) + + def _set_channels_render_mode( + self, + visual: BaseImageVisual, + render_mode: str, + source_ids: dict[int, UUID | None], + ) -> None: + """Set *render_mode* on every channel of *visual*, as one change. + + Parameters + ---------- + visual : BaseImageVisual + The image visual. + render_mode : str + The new mode. + source_ids : dict[int, UUID or None] + Per channel index, the source to stamp on its event. A channel + left out, or mapped to ``None``, is stamped with the + controller's id. + """ + visual_id = visual.id + channels = dict(visual.channels) + if not channels: + return + # Refuse an unknown mode before any channel changes. + first = next(iter(channels.values())) + type(first).__pydantic_validator__.validate_assignment( + first.model_copy(), "render_mode", render_mode + ) + self._render_mode_sync.add(visual_id) + try: + for index, channel in channels.items(): + token = _source_id_override.set(source_ids.get(index)) + try: + channel.render_mode = render_mode + finally: + _source_id_override.reset(token) + finally: + self._render_mode_sync.discard(visual_id) + self._channel_render_modes[visual_id] = dict.fromkeys(channels, render_mode) + self._refresh_plane_mode(visual_id) + def set_image_composite( self, visual_id: UUID, @@ -6903,8 +7429,141 @@ def set_clipping_plane( ) return moved + # ------------------------------------------------------------------ + # Render planes + # ------------------------------------------------------------------ + + def set_render_planes( + self, + visual_id: UUID, + render_planes: Sequence[RenderPlane], + *, + source_id: UUID | None = None, + ) -> tuple[RenderPlane, ...]: + """Replace an image or labels visual's render planes. + + The same as assigning ``visual.render_planes``, with the check made + before anything changes and a *source_id* for echo filtering. Emits + ``RenderPlanesChangedEvent``; nothing happens when the tuple equals + the current one. + + The planes are drawn only in a 3D view, and only while the visual's + render mode is ``"plane"``; otherwise the tuple is stored and + announced and nothing else happens. Inside + :meth:`plane_interaction` the change is a tick of a drag. + + Parameters + ---------- + visual_id : + Target visual: an image or labels visual. + render_planes : + The complete new sequence of ``RenderPlane``, in the scene's + world space. At most four. Empty removes them all. + source_id : + UUID to stamp on the emitted ``RenderPlanesChangedEvent``. GUI + widgets pass ``source_id=self._id`` so their own subscription + can ignore the echo. + + Returns + ------- + tuple[RenderPlane, ...] + The visual's new planes. + + Raises + ------ + TypeError + If the visual is not an image or labels visual. + ValueError + If there are more than four planes, two share an id, a plane is + not on three axes of the scene's world coordinate system, or + the visual's transform does not map a plane's axes by a scale + and a translation per axis. + """ + visual = self.get_visual_model(visual_id) + if not isinstance(visual, _PLANE_VISUALS): + raise TypeError( + f"Visual {visual.name!r} ({type(visual).__name__}) has no render " + "planes: only image and labels visuals do." + ) + planes = validate_render_planes(render_planes) + self._check_render_planes(visual, planes) + token = _render_planes_source_id_override.set(source_id) + try: + visual.render_planes = planes + finally: + _render_planes_source_id_override.reset(token) + return visual.render_planes + + def get_render_plane(self, visual_id: UUID, plane_id: UUID) -> RenderPlane: + """Return one render plane of a visual, by the plane's id. + + Raises + ------ + KeyError + If the visual has no render plane with that id. + """ + plane_id = UUID(str(plane_id)) + for plane in getattr(self.get_visual_model(visual_id), "render_planes", ()): + if plane.id == plane_id: + return plane + raise KeyError(f"Visual {visual_id} has no render plane {plane_id}.") + + def set_render_plane( + self, + visual_id: UUID, + plane_id: UUID, + plane: RenderPlane, + *, + source_id: UUID | None = None, + ) -> RenderPlane: + """Replace one render plane of a visual; the others stay. + + The plane keeps its id and its place in the tuple; its pose, extents + and ``enabled`` flag are taken from *plane*. Goes through + :meth:`set_render_planes`, so it emits ``RenderPlanesChangedEvent`` + and does nothing for an equal plane. + + Parameters + ---------- + visual_id : + Target visual. + plane_id : + The ``id`` of the ``RenderPlane`` to replace. + plane : + Its new state. Its own ``id`` is ignored. + source_id : + UUID to stamp on the emitted ``RenderPlanesChangedEvent``. + + Returns + ------- + RenderPlane + The plane now in the tuple. + + Raises + ------ + KeyError + If the visual has no render plane with that id. + ValueError + As :meth:`set_render_planes`. + """ + plane_id = UUID(str(plane_id)) + self.get_render_plane(visual_id, plane_id) # KeyError if absent + moved = plane.model_copy(update={"id": plane_id}) + current = self.get_visual_model(visual_id).render_planes + self.set_render_planes( + visual_id, + tuple(moved if item.id == plane_id else item for item in current), + source_id=source_id, + ) + return moved + def _on_plane_transition(self, visual_id: UUID, transition: Transition) -> None: - """Announce the start or end of a drag of a visual's planes. Nothing else.""" + """Announce the start or end of a drag of a visual's planes. + + An end by release or stillness plans a visual the drag left without + a plan. An end by a jump or a cancel plans nothing here: the change + that caused it does. + """ self._outgoing_events.emit( PlaneInteractionEvent( source_id=transition.source_id, @@ -6913,19 +7572,104 @@ def _on_plane_transition(self, visual_id: UUID, transition: Transition) -> None: reason=transition.reason, ) ) + if transition.phase != "end": + return + owed = visual_id in self._plane_plan_owed + self._plane_plan_owed.discard(visual_id) + if owed and transition.reason in ("release", "settle"): + self._plan_after_plane_drag(visual_id) + + def _plane_drag_holds_plan(self, visual: BaseVisual) -> bool: + """Whether a drag of *visual*'s planes keeps its last plan. + + ``True`` for a multiscale image or labels visual in a 3D view with + ``coarsest_while_moving_3d`` on. Everything else plans on every + tick: a 2D view's only motion is a dims scrub, and an in-memory + visual plans nothing view-dependent. + """ + if not isinstance(visual, (MultiscaleImageVisual, MultiscaleLabelVisual)): + return False + scene_id = self._visual_to_scene.get(visual.id) + if scene_id is None or self._n_displayed_dims(scene_id) != 3: + return False + return visual.appearance.coarsest_while_moving_3d + + def _hold_plan_for_plane_drag(self, visual: BaseVisual) -> bool: + """Whether to skip a plan of *visual* because its planes are dragged. + + When ``True`` the visual is owed a plan, made when the drag ends + (:meth:`_on_plane_transition`). The last plan stays the desired + set meanwhile, so the bricks already loaded are still drawn; a + ``BACKSTOP_ONLY`` plan would drop them (plane rendering design 7.4). + """ + if not self._plane_interaction_driver.is_active(visual.id): + return False + if not self._plane_drag_holds_plan(visual): + return False + self._plane_plan_owed.add(visual.id) + return True + + def _leave_to_plane_drags(self, visuals: Iterable[BaseVisual]) -> set[UUID]: + """Ids of *visuals* whose plan a plane drag holds; each is now owed one.""" + return { + visual.id for visual in visuals if self._hold_plan_for_plane_drag(visual) + } + + def _plan_after_plane_drag(self, visual_id: UUID) -> None: + """Plan a visual's target once a drag of its planes has ended. + + Only when every motion of the visual has stopped: while its scene's + dims are scrubbed the scrub's end plans it, and while a camera of the + scene moves the camera's end does. + """ + scene_id = self._visual_to_scene.get(visual_id) + if scene_id is None: + return + visual = self.get_visual_model(visual_id) + if self._dims_driver.is_active(scene_id) and visual.plans_coarse_while_moving( + self._n_displayed_dims(scene_id) + ): + self._dims_scrub_pending.setdefault(scene_id, set()).add(visual_id) + return + if ( + visual.requires_camera_reslice + and self._render_manager.config.camera.reslice_enabled + and self._scene_camera_moving(scene_id) + ): + return + self.reslice_visual(visual_id) + self._request_draw_for_visual(visual_id) + + def _end_plane_drag_by_jump(self, visual_id: UUID, source_id: UUID) -> None: + """End a drag of a visual's planes because the visual jumped. + + A visibility or render mode change is a jump. If the drag left the + visual without a plan it is planned now, in full. + """ + owed = visual_id in self._plane_plan_owed + self._plane_plan_owed.discard(visual_id) + self._plane_interaction_driver.cancel(visual_id, source_id) + if owed and visual_id in self._visual_to_scene: + self.reslice_visual(visual_id) def begin_plane_interaction(self, visual_id: UUID, *, source_id: UUID) -> None: """Open a plane interaction scope on a visual. While any scope is open, every change of the visual's - ``clipping_planes`` is a tick of one drag, announced by a + ``clipping_planes``, or of its ``render_planes`` while a 3D view + draws them, is a tick of one drag, announced by a ``PlaneInteractionEvent`` at its start and its end. Opening a scope starts nothing by itself; the first change does. Holding still for ``SchedulerConfig.dims_settle_s`` ends a drag, and the next change starts a new one. - A drag changes no planning: a plane change inside one plans as it - does outside one. + A multiscale image or labels visual in a 3D view with + ``appearance.coarsest_while_moving_3d`` on (the default) plans + nothing during a drag: it keeps drawing the bricks it has, and the + backstop where the planes reveal more. Its target is planned when + the drag ends, once its scene's dims and cameras are still too. + With the setting off, and for every other visual, a plane change + inside a drag plans as it does outside one. A clipping plane gizmo calls this when a handle is grabbed. Scripts normally use :meth:`plane_interaction`. @@ -6963,10 +7707,10 @@ def end_plane_interaction(self, visual_id: UUID, *, source_id: UUID) -> None: @contextmanager def plane_interaction(self, visual_id: UUID) -> Generator[None, None, None]: - """Drag a visual's clipping planes for the length of a ``with`` block. + """Drag a visual's planes for the length of a ``with`` block. - Every change of ``clipping_planes`` inside the block belongs to one - drag:: + Every change of ``clipping_planes`` or ``render_planes`` inside the + block belongs to one drag:: with controller.plane_interaction(visual.id): for offset in offsets: @@ -6985,7 +7729,7 @@ def plane_interaction(self, visual_id: UUID) -> Generator[None, None, None]: self.end_plane_interaction(visual_id, source_id=source_id) def plane_interaction_state(self, visual_id: UUID) -> Literal["idle", "active"]: - """Whether *visual_id*'s clipping planes are being dragged. + """Whether *visual_id*'s clipping or render planes are being dragged. Parameters ---------- @@ -7174,6 +7918,11 @@ def _on_clipping_planes_update(self, event: ClippingPlanesUpdateEvent) -> None: event.visual_id, event.clipping_planes, source_id=event.source_id ) + def _on_render_planes_update(self, event: RenderPlanesUpdateEvent) -> None: + self.set_render_planes( + event.visual_id, event.render_planes, source_id=event.source_id + ) + def _on_lod_config_update(self, event: LodConfigUpdateEvent) -> None: self.set_lod_config( event.visual_id, source_id=event.source_id, **{event.field: event.value} @@ -7844,6 +8593,11 @@ def _run_camera_reslice(self, scene_id: UUID) -> None: target_ids = frozenset( visual.id for visual in scene.visuals if visual.id in handed ) + # A camera end during a drag of a visual's planes leaves that + # visual's target to the drag's end (7.4). + target_ids -= self._leave_to_plane_drags( + visual for visual in scene.visuals if visual.id in target_ids + ) if not target_ids: return _CAMERA_LOGGER.info( @@ -8436,6 +9190,10 @@ def remove_visual(self, visual_id: UUID) -> None: self._outgoing_events.unsubscribe_all(visual_id) self._cancel_visual_pick_reads(visual_id) self._plane_interaction_driver.drop(visual_id) + self._plane_plan_owed.discard(visual_id) + self._plane_mode_shown.pop(visual_id, None) + self._channel_render_modes.pop(visual_id, None) + self._render_plane_axes_warned.discard(visual_id) # 4. Remove from controller lookup maps. self._visual_to_scene.pop(visual_id) @@ -9939,6 +10697,7 @@ def close(self) -> None: self._camera_handoff.clear() self._dims_driver.close() self._plane_interaction_driver.close() + self._plane_plan_owed.clear() self._dims_scrub_pending.clear() self._dims_scrub_axes.clear() for task in self._store_reslice_tasks.values(): @@ -10114,6 +10873,40 @@ def on_clipping_planes_changed( weak=weak, ) + def on_render_planes_changed( + self, + visual_id: UUID, + callback: Callable[[RenderPlanesChangedEvent], None], + *, + owner_id: UUID, + weak: bool = False, + ) -> SubscriptionHandle: + """Register a callback fired when a visual's render planes change. + + Parameters + ---------- + visual_id : + The visual to watch. + callback : + Called with the ``RenderPlanesChangedEvent``: ``source_id`` for + echo filtering, and ``render_planes``, the complete new tuple. + owner_id : + UUID under which this subscription is registered. + weak : + If True, hold only a weak reference to *callback*. + + Returns + ------- + SubscriptionHandle + """ + return self._outgoing_events.subscribe( + RenderPlanesChangedEvent, + callback, + entity_id=visual_id, + owner_id=owner_id, + weak=weak, + ) + def on_lod_config_changed( self, visual_id: UUID, diff --git a/src/cellier/convenience/gui/_appearance_widgets.py b/src/cellier/convenience/gui/_appearance_widgets.py index 7407ef9a..d2923723 100644 --- a/src/cellier/convenience/gui/_appearance_widgets.py +++ b/src/cellier/convenience/gui/_appearance_widgets.py @@ -34,13 +34,18 @@ def _any_image(spec: ControlSpec, visual_ids, controller=None): ) -def _any_lod_bias(spec: ControlSpec, visual_ids, controller=None): - from cellier.gui.anywidget.visuals import AnywidgetLodBiasSlider +def _any_level_of_detail(spec: ControlSpec, visual_ids, controller=None): + from cellier.gui._image_controls import display_seed + from cellier.gui.anywidget.visuals import AnywidgetLevelOfDetailControls - return AnywidgetLodBiasSlider( + # The checkbox edits the setting of the view shown: seeded, then followed. + scene_ids, n_displayed = display_seed(controller, visual_ids) + return AnywidgetLevelOfDetailControls( visual_ids, - initial_lod_bias=spec.values["initial_lod_bias"], + spec.values, title=spec.title, + n_displayed_dimensions=n_displayed, + scene_ids=scene_ids, ) @@ -195,7 +200,7 @@ def _any_trail(spec: ControlSpec, visual_ids, controller=None): ANYWIDGET_BUILDERS = { "image": _any_image, - "settled_lod_bias": _any_lod_bias, + "level_of_detail": _any_level_of_detail, "trail": _any_trail, "aabb": _any_aabb, "loading": _any_loading, diff --git a/src/cellier/convenience/gui/_appearance_widgets_qt.py b/src/cellier/convenience/gui/_appearance_widgets_qt.py index 2d1bef0e..463b64f0 100644 --- a/src/cellier/convenience/gui/_appearance_widgets_qt.py +++ b/src/cellier/convenience/gui/_appearance_widgets_qt.py @@ -36,13 +36,18 @@ def _qt_image(spec, visual_ids, controller): ) -def _qt_lod_bias(spec, visual_ids, controller): - from cellier.gui.qt.visuals import QtLodBiasSlider +def _qt_level_of_detail(spec, visual_ids, controller): + from cellier.gui._image_controls import display_seed + from cellier.gui.qt.visuals import QtLevelOfDetailControls - return QtLodBiasSlider( + # The checkbox edits the setting of the view shown: seeded, then followed. + scene_ids, n_displayed = display_seed(controller, visual_ids) + return QtLevelOfDetailControls( visual_ids, - initial_lod_bias=spec.values["initial_lod_bias"], + spec.values, title=spec.title, + n_displayed_dimensions=n_displayed, + scene_ids=scene_ids, ) @@ -204,7 +209,7 @@ def _qt_trail(spec, visual_ids, controller): QT_BUILDERS = { "image": _qt_image, - "settled_lod_bias": _qt_lod_bias, + "level_of_detail": _qt_level_of_detail, "trail": _qt_trail, "aabb": _qt_aabb, "loading": _qt_loading, diff --git a/src/cellier/convenience/gui/_controls_config.py b/src/cellier/convenience/gui/_controls_config.py index d91613ce..c08f9f43 100644 --- a/src/cellier/convenience/gui/_controls_config.py +++ b/src/cellier/convenience/gui/_controls_config.py @@ -11,6 +11,8 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, ClassVar, Literal +from cellier.visuals._removed import REMOVED_CONTROL_KEYS + if TYPE_CHECKING: from collections.abc import Iterable, Sequence @@ -26,7 +28,9 @@ "render_mode", "iso_threshold", "attenuation", - "settled_lod_bias", + # Multiscale image and labels: the level-of-detail control (the settled + # bias and "coarsest while moving"), named for the control, not a field + "level_of_detail", # Labels ("render_mode" is shared with the image models, and means a # different set of values there -- see BaseControlsConfig.APPEARANCE_CONTROLS) "salt", @@ -86,6 +90,13 @@ def _validate_field_names( for name in requested: if name in valid: continue + # A key removed by a hard break is refused by name, with its + # replacement (plane rendering design v3, 7.6). + if name in REMOVED_CONTROL_KEYS: + raise ValueError( + f"{name!r} was removed as an {argument} field: use " + f"{REMOVED_CONTROL_KEYS[name]!r}." + ) if not valid: raise ValueError( f"{config_name} accepts no {argument} fields, but {name!r} was " @@ -279,7 +290,7 @@ class MultiscaleImageControlsConfig(InMemoryImageControlsConfig): appearance : list[str] or False Appearance fields in display order, e.g. ``["color_map", "clim", "render_mode", "iso_threshold", - "attenuation", "settled_lod_bias"]``. + "attenuation", "level_of_detail"]``. colormap_names : list[str] or None Names available in the colormap dropdown. clim_range : tuple[float, float] or None @@ -306,7 +317,7 @@ class MultiscaleImageControlsConfig(InMemoryImageControlsConfig): APPEARANCE_CONTROLS: ClassVar[dict[str, str]] = { **InMemoryImageControlsConfig.APPEARANCE_CONTROLS, "attenuation": "image", - "settled_lod_bias": "settled_lod_bias", + "level_of_detail": "level_of_detail", } loading_indicator: bool = True @@ -349,7 +360,7 @@ class LabelsControlsConfig(BaseControlsConfig): class MultiscaleLabelsControlsConfig(LabelsControlsConfig): """Controls configuration for multiscale label visuals. - Adds ``settled_lod_bias``, mirroring the image pair. + Adds ``level_of_detail``, mirroring the image pair. Parameters ---------- @@ -365,7 +376,7 @@ class MultiscaleLabelsControlsConfig(LabelsControlsConfig): APPEARANCE_CONTROLS: ClassVar[dict[str, str]] = { **LabelsControlsConfig.APPEARANCE_CONTROLS, - "settled_lod_bias": "settled_lod_bias", + "level_of_detail": "level_of_detail", } loading_indicator: bool = True diff --git a/src/cellier/convenience/layout/_shared.py b/src/cellier/convenience/layout/_shared.py index 0511f7dc..baf876bc 100644 --- a/src/cellier/convenience/layout/_shared.py +++ b/src/cellier/convenience/layout/_shared.py @@ -11,6 +11,10 @@ CLIPPING_PLANES_TITLE, get_clipping_planes_data_from_visual, ) +from cellier.gui._level_of_detail import ( + LEVEL_OF_DETAIL_TITLE, + level_of_detail_values, +) from cellier.gui._loading import LOADING_CONFIG_TITLE, LOADING_TITLE from cellier.gui._lod import LOD_CONFIG_TITLE from cellier.gui._mesh_section import MESH_SECTION_TITLE @@ -35,7 +39,7 @@ class ControlSpec: ---------- kind : str Which control to build: ``color_map``, ``clim``, ``render``, - ``settled_lod_bias``, ``aabb``, ``loading``, ``loading_config``, + ``level_of_detail``, ``aabb``, ``loading``, ``loading_config``, ``mesh_section``, ``clipping_planes``, ``lod_config`` or ``dataset_info``. A renderer with no builder for a kind skips it. @@ -78,7 +82,7 @@ class AppearanceSpecs(NamedTuple): _CONTROL_TITLES = { "image": "Image", - "settled_lod_bias": "LOD bias", + "level_of_detail": LEVEL_OF_DETAIL_TITLE, "aabb": "Bounding box", "trail": "Trail", "loading": LOADING_TITLE, @@ -104,6 +108,11 @@ class AppearanceSpecs(NamedTuple): """ +_CONTROL_NEEDS_FIELD = {"level_of_detail": "settled_lod_bias"} +"""Appearance entries that are a control, not a field, and the model field +whose presence says the visual can be driven by it.""" + + APPEARANCE_DOCK_GAP_PX = 6 """Spacing between the stacked controls in an appearance dock, in pixels. @@ -228,7 +237,9 @@ def appearance_specs( # A field is honoured when the config class has a control for it *and* the # visual's model actually carries it. Both halves matter: a config can be # paired with a visual whose model is narrower (a multiscale config on an - # in-memory image has no ``settled_lod_bias`` to drive). + # in-memory image has no ``settled_lod_bias`` to drive). A control that + # is not named after one field names the field it needs in + # ``_CONTROL_NEEDS_FIELD``. # An image's mode-dependent fields live on ``single`` (unified image # design 3.1), not on the shared appearance. single = getattr(visual, "single", None) @@ -236,7 +247,10 @@ def appearance_specs( field for field in requested if field in controls_map - and (hasattr(app, field) or (single is not None and hasattr(single, field))) + and ( + hasattr(app, _CONTROL_NEEDS_FIELD.get(field, field)) + or (single is not None and hasattr(single, field)) + ) } # A default list is "everything this config *can* drive", and the visual's # model is often narrower on purpose -- a flat mesh has no ``shininess``. @@ -264,9 +278,7 @@ def _image_values(): values_for = { "image": _image_values, - "settled_lod_bias": lambda: { - "initial_lod_bias": float(getattr(app, "settled_lod_bias", 1.0)) - }, + "level_of_detail": lambda: level_of_detail_values(app), } specs: list[ControlSpec] = [] diff --git a/src/cellier/events/__init__.py b/src/cellier/events/__init__.py index 3713e46f..e158611e 100644 --- a/src/cellier/events/__init__.py +++ b/src/cellier/events/__init__.py @@ -53,6 +53,7 @@ PointsPickEvent, PointsPickInfo, RenderConfigChangedEvent, + RenderPlanesChangedEvent, ResliceCancelledEvent, ResliceCompletedEvent, ResliceProgressEvent, @@ -86,6 +87,7 @@ MeshSectionUpdateEvent, OverlayUpdateEvent, RenderConfigUpdateEvent, + RenderPlanesUpdateEvent, SingleAppearanceUpdateEvent, SliderOverrideUpdateEvent, SubscriptionSpec, @@ -162,6 +164,8 @@ "PointsPickInfo", "RenderConfigChangedEvent", "RenderConfigUpdateEvent", + "RenderPlanesChangedEvent", + "RenderPlanesUpdateEvent", "ResliceCancelledEvent", "ResliceCompletedEvent", "ResliceProgressEvent", diff --git a/src/cellier/events/_bus.py b/src/cellier/events/_bus.py index 6b5c0f98..2633fe65 100644 --- a/src/cellier/events/_bus.py +++ b/src/cellier/events/_bus.py @@ -45,6 +45,7 @@ PlaneGizmoMovedEvent, PlaneInteractionEvent, PointsPickEvent, + RenderPlanesChangedEvent, ResliceCancelledEvent, ResliceCompletedEvent, ResliceProgressEvent, @@ -86,6 +87,7 @@ LoadingConfigChangedEvent: "visual_id", LodConfigChangedEvent: "visual_id", ClippingPlanesChangedEvent: "visual_id", + RenderPlanesChangedEvent: "visual_id", PlaneInteractionEvent: "visual_id", ClippingPlaneGizmoChangedEvent: "canvas_id", PlaneGizmoMovedEvent: "gizmo_id", @@ -523,6 +525,7 @@ def get_subscribers( ``LoadingConfigChangedEvent``, ``LodConfigChangedEvent``, ``ClippingPlanesChangedEvent``, + ``RenderPlanesChangedEvent``, ``PlaneInteractionEvent``, ``ResliceCancelledEvent`` - Canvas (keyed by ``canvas_id``) — ``FrameRenderedEvent``, diff --git a/src/cellier/events/_events.py b/src/cellier/events/_events.py index 7d74bae4..908db8fc 100644 --- a/src/cellier/events/_events.py +++ b/src/cellier/events/_events.py @@ -485,16 +485,44 @@ class ClippingPlanesChangedEvent(NamedTuple): clipping_planes: Any +class RenderPlanesChangedEvent(NamedTuple): + """A visual's ``render_planes`` changed. + + Emitted for every change, whether it came from + ``CellierController.set_render_planes`` (or a + ``RenderPlanesUpdateEvent``) or from assigning ``visual.render_planes`` + directly, and whether or not the visual is in ``"plane"`` render mode. + + Parameters + ---------- + source_id : UUID + Who asked for the change: the widget's id for a GUI edit, otherwise + the controller's. + visual_id : UUID + The visual. The routing key. + render_planes : tuple[RenderPlane, ...] + The complete tuple after the change. + """ + + source_id: UUID + visual_id: UUID + render_planes: Any + + class PlaneInteractionEvent(NamedTuple): """A drag of a visual's planes started or ended. - One tracker per visual serves its planes; today those are its clipping - planes. A drag is a run of ``clipping_planes`` changes made inside - ``CellierController.plane_interaction`` (a gizmo opens one for the - length of a drag). The start is emitted with the first change, ahead - of its ``ClippingPlanesChangedEvent``. No event is emitted per change. - - It announces only: a plane change plans the same way inside a drag as + One tracker per visual serves its clipping planes and its render planes. + A drag is a run of ``clipping_planes`` or ``render_planes`` changes made + inside ``CellierController.plane_interaction`` (a gizmo opens one for + the length of a drag). The start is emitted with the first change, + ahead of its ``ClippingPlanesChangedEvent`` or + ``RenderPlanesChangedEvent``. No event is emitted per change. + + A multiscale image or labels visual in a 3D view with + ``appearance.coarsest_while_moving_3d`` on (the default) plans nothing + between the start and the end, and its target is planned at the end. + Every other visual plans a plane change the same way inside a drag as outside one. Attributes @@ -1570,6 +1598,7 @@ class _CanvasRawPointerEvent(NamedTuple): | LoadingConfigChangedEvent | LodConfigChangedEvent | ClippingPlanesChangedEvent + | RenderPlanesChangedEvent | PlaneInteractionEvent | ClippingPlaneGizmoChangedEvent | PlaneGizmoMovedEvent diff --git a/src/cellier/events/_update_events.py b/src/cellier/events/_update_events.py index 3d88a820..67c52e0d 100644 --- a/src/cellier/events/_update_events.py +++ b/src/cellier/events/_update_events.py @@ -400,6 +400,27 @@ class ClippingPlanesUpdateEvent(NamedTuple): clipping_planes: Any +class RenderPlanesUpdateEvent(NamedTuple): + """Request to replace a visual's render planes. + + Fields + ------ + source_id : + Caller's UUID. Stamped on the outgoing ``RenderPlanesChangedEvent`` + so the caller can echo-filter on its own subscription. + visual_id : + Target visual: an image or labels visual. + render_planes : + The complete new tuple of ``RenderPlane``. The whole tuple rather + than one plane, so adding, removing, moving and toggling are one + kind of request. + """ + + source_id: UUID + visual_id: UUID + render_planes: Any + + class ClippingPlaneGizmoUpdateEvent(NamedTuple): """Request to put a canvas's clipping plane gizmo on a plane, or close it. @@ -467,6 +488,7 @@ class TrailUpdateEvent(NamedTuple): | LoadingConfigUpdateEvent | LodConfigUpdateEvent | ClippingPlanesUpdateEvent + | RenderPlanesUpdateEvent | ClippingPlaneGizmoUpdateEvent | TrailUpdateEvent ) diff --git a/src/cellier/gui/_appearance_fields.py b/src/cellier/gui/_appearance_fields.py index f7403939..64122a67 100644 --- a/src/cellier/gui/_appearance_fields.py +++ b/src/cellier/gui/_appearance_fields.py @@ -159,7 +159,7 @@ class VisualIdGroup: Mix in **before** the toolkit base so the plain-``object`` MRO stays first:: - class AnywidgetLodBiasSlider(VisualIdGroup, anywidget.AnyWidget): ... + class AnywidgetAABBWidget(VisualIdGroup, anywidget.AnyWidget): ... """ _visual_ids: tuple[UUID, ...] @@ -228,12 +228,26 @@ def _emit_group(self, event_cls: type, field: str, value: Any) -> None: """ +UNOFFERED_FIELD_VALUES: dict[str, frozenset[str]] = { + "render_mode": frozenset({"plane"}), +} +"""Literal values the models accept that no control offers yet. + +``"plane"`` is a render mode on the image and labels models (plane rendering +design v3), settable from code. Its place in the render-mode combos comes +with the render planes control (design 9.1), which hides the rows that mean +nothing in it and moves a composite's channels together. Until then a combo +offering it would offer a mode it cannot set up. +""" + + def literal_choices(appearance: Any, field: str) -> tuple[str, ...]: """Return the values *field* admits on *appearance*'s model, user-facing only. Reads the field's ``Literal`` annotation, so the in-memory and multiscale variants of a field need no separate lists -- each model answers for - itself. Debug-only values are dropped per :data:`DEBUG_FIELD_VALUES`. + itself. Debug-only values are dropped per :data:`DEBUG_FIELD_VALUES`, + and values with no control yet per :data:`UNOFFERED_FIELD_VALUES`. Returns an empty tuple when the field is not a ``Literal``. """ @@ -241,7 +255,9 @@ def literal_choices(appearance: Any, field: str) -> tuple[str, ...]: if not model_fields or field not in model_fields: return () args = get_args(model_fields[field].annotation) - denied = DEBUG_FIELD_VALUES.get(field, frozenset()) + denied = DEBUG_FIELD_VALUES.get(field, frozenset()) | UNOFFERED_FIELD_VALUES.get( + field, frozenset() + ) return tuple(arg for arg in args if isinstance(arg, str) and arg not in denied) @@ -347,7 +363,7 @@ def as_rgba(value: Any) -> tuple[float, float, float, float]: ``test_the_shared_widget_table_agrees_with_the_field_classes``. Not listed: the multi-field image controls (``color_map``, ``clim``, -``render``, ``settled_lod_bias``) and ``aabb``/``dataset_info``, which predate this +``render``, ``level_of_detail``) and ``aabb``/``dataset_info``, which predate this design and keep their bespoke builders. """ diff --git a/src/cellier/gui/_level_of_detail.py b/src/cellier/gui/_level_of_detail.py new file mode 100644 index 00000000..65a27a6f --- /dev/null +++ b/src/cellier/gui/_level_of_detail.py @@ -0,0 +1,77 @@ +"""The "Level of detail" control: toolkit-neutral logic. + +One control per multiscale image or labels visual (plane rendering design +v3, 9.3): a slider for ``appearance.settled_lod_bias`` and a checkbox for +"coarsest while moving". The checkbox edits the setting of the view the +control is shown in: ``coarsest_while_moving_3d`` in a 3D view and +``coarsest_while_moving_2d`` in a 2D one. + +The Qt and anywidget controls import everything that is not toolkit from +here, so the two cannot drift. +""" + +from __future__ import annotations + +from typing import Any + +LEVEL_OF_DETAIL_TITLE = "Level of detail" + +SETTLED_BIAS_LABEL = "Settled bias" +COARSEST_LABEL = "Coarsest while moving" + +#: Slider bounds for ``settled_lod_bias``. Higher is coarser. +SETTLED_BIAS_RANGE: tuple[float, float] = (0.001, 5.0) + +#: The appearance fields the control drives. +LEVEL_OF_DETAIL_FIELDS: tuple[str, ...] = ( + "settled_lod_bias", + "coarsest_while_moving_3d", + "coarsest_while_moving_2d", +) + + +def moving_field(n_displayed_dimensions: int) -> str: + """The "coarsest while moving" field of a view. + + Parameters + ---------- + n_displayed_dimensions : int + 2 or 3: what the view the control is shown in displays. + + Returns + ------- + str + ``"coarsest_while_moving_3d"`` for 3, ``"coarsest_while_moving_2d"`` + otherwise. + """ + if n_displayed_dimensions == 3: + return "coarsest_while_moving_3d" + return "coarsest_while_moving_2d" + + +def level_of_detail_values(appearance: Any) -> dict[str, Any]: + """Read the control's construction values off a multiscale appearance. + + Parameters + ---------- + appearance : MultiscaleImageAppearance or MultiscaleLabelsAppearance + The appearance model the control drives. + + Returns + ------- + dict + ``settled_lod_bias`` (float) and the two ``coarsest_while_moving`` + booleans, keyed by field name. + """ + return { + "settled_lod_bias": float(appearance.settled_lod_bias), + "coarsest_while_moving_3d": bool(appearance.coarsest_while_moving_3d), + "coarsest_while_moving_2d": bool(appearance.coarsest_while_moving_2d), + } + + +def from_control_value(field: str, value: Any) -> Any: + """Coerce a control's value to the model's type for *field*.""" + if field == "settled_lod_bias": + return float(value) + return bool(value) diff --git a/src/cellier/gui/anywidget/visuals/__init__.py b/src/cellier/gui/anywidget/visuals/__init__.py index 29041938..2b85ee0b 100644 --- a/src/cellier/gui/anywidget/visuals/__init__.py +++ b/src/cellier/gui/anywidget/visuals/__init__.py @@ -33,13 +33,15 @@ AnywidgetLabelsRenderModeCombo, AnywidgetSaltSpin, ) +from cellier.gui.anywidget.visuals._level_of_detail import ( + AnywidgetLevelOfDetailControls, +) from cellier.gui.anywidget.visuals._lines import ( AnywidgetThicknessSpaceCombo, AnywidgetThicknessSpin, ) from cellier.gui.anywidget.visuals._loading import AnywidgetLoadingIndicator from cellier.gui.anywidget.visuals._loading_config import AnywidgetLoadingConfigControls -from cellier.gui.anywidget.visuals._lod_bias import AnywidgetLodBiasSlider from cellier.gui.anywidget.visuals._lod_config import AnywidgetLodConfigControls from cellier.gui.anywidget.visuals._mesh import ( AnywidgetFlatShadingToggle, @@ -74,9 +76,9 @@ "AnywidgetImageControls", "AnywidgetIntSpin", "AnywidgetLabelsRenderModeCombo", + "AnywidgetLevelOfDetailControls", "AnywidgetLoadingConfigControls", "AnywidgetLoadingIndicator", - "AnywidgetLodBiasSlider", "AnywidgetLodConfigControls", "AnywidgetMeshSectionControls", "AnywidgetNodeColorPicker", diff --git a/src/cellier/gui/anywidget/visuals/_level_of_detail.py b/src/cellier/gui/anywidget/visuals/_level_of_detail.py new file mode 100644 index 00000000..a2534e52 --- /dev/null +++ b/src/cellier/gui/anywidget/visuals/_level_of_detail.py @@ -0,0 +1,178 @@ +"""The "Level of detail" control wired to the cellier v2 event bus (anywidget).""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, Any +from uuid import uuid4 + +import anywidget +import traitlets +from psygnal import Signal + +from cellier.events import ( + AppearanceChangedEvent, + AppearanceUpdateEvent, + DimsChangedEvent, + SubscriptionSpec, +) +from cellier.gui._appearance_fields import VisualIdGroup +from cellier.gui._image_controls import ( + displayed_dimensions, + validate_n_displayed_dimensions, +) +from cellier.gui._level_of_detail import ( + COARSEST_LABEL, + LEVEL_OF_DETAIL_FIELDS, + LEVEL_OF_DETAIL_TITLE, + SETTLED_BIAS_LABEL, + SETTLED_BIAS_RANGE, + from_control_value, + moving_field, +) +from cellier.gui.anywidget._teardown import close_aux_widgets + +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + from uuid import UUID + +_STATIC = Path(__file__).parent / "static" + + +class AnywidgetLevelOfDetailControls(VisualIdGroup, anywidget.AnyWidget): + """Settled bias slider and "coarsest while moving" checkbox. + + Mirrors ``QtLevelOfDetailControls``. The front end shows one checkbox, + bound to ``coarsest_while_moving_3d`` while ``n_displayed_dimensions`` is + 3 and to ``coarsest_while_moving_2d`` otherwise. The bias is sent on a + settled ``change`` of the slider, not while it is dragged, because a + bias change reslices. + + Parameters + ---------- + visual_id : + The visual the control drives, or a sequence driven in lock-step. + values : + ``cellier.gui._level_of_detail.level_of_detail_values`` of the + visual's appearance. Missing keys take the model's defaults. + n_displayed_dimensions : + What the view displays now, 2 or 3. + scene_ids : + Scenes whose ``DimsChangedEvent`` the control follows. + """ + + _esm = _STATIC / "level_of_detail.js" + _css = _STATIC / "level_of_detail.css" + + changed: Signal = Signal(object) + closed: Signal = Signal() + + DEFAULT_TITLE = LEVEL_OF_DETAIL_TITLE + + title = traitlets.Unicode(DEFAULT_TITLE).tag(sync=True) + labels = traitlets.Dict().tag(sync=True) + bias_range = traitlets.List(traitlets.Float()).tag(sync=True) + + settled_lod_bias = traitlets.Float(1.0).tag(sync=True) + coarsest_while_moving_3d = traitlets.Bool(True).tag(sync=True) + coarsest_while_moving_2d = traitlets.Bool(False).tag(sync=True) + n_displayed_dimensions = traitlets.Int(3).tag(sync=True) + + @traitlets.validate("n_displayed_dimensions") + def _validate_n_displayed_dimensions(self, proposal): + return validate_n_displayed_dimensions(proposal["value"]) + + def __init__( + self, + visual_id: UUID | Sequence[UUID], + values: Mapping[str, Any] | None = None, + *, + n_displayed_dimensions: int = 3, + scene_ids: Sequence[UUID] = (), + **kwargs, + ) -> None: + values = dict(values or {}) + super().__init__( + settled_lod_bias=float(values.get("settled_lod_bias", 1.0)), + coarsest_while_moving_3d=bool(values.get("coarsest_while_moving_3d", True)), + coarsest_while_moving_2d=bool( + values.get("coarsest_while_moving_2d", False) + ), + n_displayed_dimensions=n_displayed_dimensions, + labels={"bias": SETTLED_BIAS_LABEL, "moving": COARSEST_LABEL}, + bias_range=list(SETTLED_BIAS_RANGE), + **kwargs, + ) + self._id = uuid4() + self._init_visual_ids(visual_id) + self._scene_ids = tuple(scene_ids) + self._applying = False + self.observe(self._on_trait_change, names=list(LEVEL_OF_DETAIL_FIELDS)) + + # -- public interface ----------------------------------------------------- + + @property + def widget(self) -> AnywidgetLevelOfDetailControls: + """An ``AnyWidget`` is itself the embeddable element.""" + return self + + @property + def moving_field(self) -> str: + """The appearance field the checkbox edits now.""" + return moving_field(self.n_displayed_dimensions) + + def close(self) -> None: + """Unsubscribe from the bus and release the widget.""" + self.closed.emit() + close_aux_widgets(self) + super().close() + + def subscription_specs(self) -> list[SubscriptionSpec]: + """One appearance subscription per visual, one dims one per scene.""" + specs = self._group_specs(AppearanceChangedEvent, self._on_appearance_changed) + specs.extend( + SubscriptionSpec( + event_type=DimsChangedEvent, + handler=self._on_dims_changed, + entity_id=scene_id, + ) + for scene_id in self._scene_ids + ) + return specs + + # -- model -> widget ------------------------------------------------------ + + def _on_appearance_changed(self, event: AppearanceChangedEvent) -> None: + if event.source_id == self._id: + return # echo of our own change + if event.field_name not in LEVEL_OF_DETAIL_FIELDS: + return + self._set_field( + event.field_name, from_control_value(event.field_name, event.new_value) + ) + + def _on_dims_changed(self, event) -> None: + """Follow a scene's switch between 2D and 3D display.""" + if not event.displayed_axes_changed: + return + n = displayed_dimensions(event.dims_state) + if n is not None: + self.n_displayed_dimensions = n + + def _set_field(self, name: str, value) -> None: + self._applying = True + try: + setattr(self, name, value) + finally: + self._applying = False + + # -- widget -> model ------------------------------------------------------ + + def _on_trait_change(self, change) -> None: + if self._applying: + return + self._emit_group( + AppearanceUpdateEvent, + change["name"], + from_control_value(change["name"], change["new"]), + ) diff --git a/src/cellier/gui/anywidget/visuals/_lod_bias.py b/src/cellier/gui/anywidget/visuals/_lod_bias.py deleted file mode 100644 index 60e85abf..00000000 --- a/src/cellier/gui/anywidget/visuals/_lod_bias.py +++ /dev/null @@ -1,135 +0,0 @@ -"""LOD-bias slider wired to the cellier v2 event bus (anywidget).""" - -from __future__ import annotations - -from pathlib import Path -from typing import TYPE_CHECKING -from uuid import uuid4 - -import anywidget -import traitlets -from psygnal import Signal - -from cellier.events import ( - AppearanceChangedEvent, - AppearanceUpdateEvent, - SubscriptionSpec, -) -from cellier.gui._appearance_fields import VisualIdGroup -from cellier.gui.anywidget._teardown import close_aux_widgets - -if TYPE_CHECKING: - from collections.abc import Sequence - from uuid import UUID - -_STATIC = Path(__file__).parent / "static" - - -class AnywidgetLodBiasSlider(VisualIdGroup, anywidget.AnyWidget): - """Single-value LOD-bias slider wired to the cellier v2 bus. - - Mirrors ``QtLodBiasSlider``. Because changing ``settled_lod_bias`` triggers a - reslice, the JS emits only on settled ``change`` (not on every drag - ``input``), so only one reslice fires per drag interaction. - - Wire to the controller after construction:: - - slider = AnywidgetLodBiasSlider(visual_id, initial_lod_bias=1.0) - controller.connect_widget( - slider, subscription_specs=slider.subscription_specs() - ) - - Parameters - ---------- - visual_id : - UUID of the visual whose ``settled_lod_bias`` field this widget controls. - A sequence drives every listed visual in lock-step -- the - ``OrthoViewer``'s four panel siblings (design section 8.1). - initial_lod_bias : - Starting value -- typically ``visual_model.appearance.settled_lod_bias``. - """ - - _esm = _STATIC / "lod_bias.js" - _css = _STATIC / "lod_bias.css" - - changed: Signal = Signal(object) - closed: Signal = Signal() - - DEFAULT_TITLE = "LOD bias" - """Name shown when no ``title=`` is given. - - The renderer passes the title from the shared control vocabulary; this is - what a directly-constructed widget calls itself, and - ``test_composite_default_titles_match_the_shared_vocabulary`` pins the two - together. - """ - - title = traitlets.Unicode(DEFAULT_TITLE).tag(sync=True) - """What this control calls itself, drawn by its own front end. - - A control names itself rather than being named by whatever lays it out - (``plans/label_ownership_unification.md``), which is what lets the dock - stack controls and stop. - """ - - settled_lod_bias = traitlets.Float(1.0).tag(sync=True) - - def __init__( - self, - visual_id: UUID | Sequence[UUID], - *, - initial_lod_bias: float = 1.0, - **kwargs, - ) -> None: - super().__init__(settled_lod_bias=float(initial_lod_bias), **kwargs) - self._id = uuid4() - self._init_visual_ids(visual_id) - self._applying = False - self.observe(self._on_trait_change, names="settled_lod_bias") - - # ── Public interface ───────────────────────────────────────────────────── - - @property - def widget(self) -> AnywidgetLodBiasSlider: - """An ``AnyWidget`` is itself the embeddable element.""" - return self - - def close(self) -> None: - """Unsubscribe from the bus and release the widget. - - ``closed`` tells the controller to drop this widget's subscriptions; - the rest actually releases the widget. See - ``cellier.gui.anywidget._teardown`` for why both steps are needed -- - ``ipywidgets`` holds every widget, and every widget's ``layout``, in a - process-global table that only ``close()`` clears. - """ - self.closed.emit() - close_aux_widgets(self) - super().close() - - def subscription_specs(self) -> list[SubscriptionSpec]: - """Return the inbound subscription this widget requires.""" - return self._group_specs(AppearanceChangedEvent, self._on_appearance_changed) - - # ── model -> widget ────────────────────────────────────────────────────── - - def _on_appearance_changed(self, event: AppearanceChangedEvent) -> None: - if event.source_id == self._id: - return # echo from our own change; ignore - if event.field_name != "settled_lod_bias": - return - self._set_field("settled_lod_bias", event.new_value) - - def _set_field(self, name: str, value) -> None: - self._applying = True - try: - setattr(self, name, value) - finally: - self._applying = False - - # ── widget -> model ────────────────────────────────────────────────────── - - def _on_trait_change(self, change) -> None: - if self._applying: - return - self._emit_group(AppearanceUpdateEvent, "settled_lod_bias", change["new"]) diff --git a/src/cellier/gui/anywidget/visuals/static/lod_bias.css b/src/cellier/gui/anywidget/visuals/static/level_of_detail.css similarity index 57% rename from src/cellier/gui/anywidget/visuals/static/lod_bias.css rename to src/cellier/gui/anywidget/visuals/static/level_of_detail.css index 30ed2417..df10eda2 100644 --- a/src/cellier/gui/anywidget/visuals/static/lod_bias.css +++ b/src/cellier/gui/anywidget/visuals/static/level_of_detail.css @@ -1,6 +1,9 @@ -.cellier-lod-bias { +.cellier-level-of-detail { font-family: var(--jp-ui-font-family, sans-serif); font-size: 12px; + display: flex; + flex-direction: column; + gap: 3px; min-width: 260px; } @@ -27,3 +30,15 @@ .cellier-app-row input[type="range"] { flex: 1 1 auto; } + +.cellier-lod-moving { + cursor: pointer; +} + +/* Group heading: see aabb.css. */ +.cellier-group-title { + font-weight: 600; + padding-bottom: 2px; + margin-bottom: 2px; + border-bottom: 1px solid color-mix(in srgb, currentColor 15%, transparent); +} diff --git a/src/cellier/gui/anywidget/visuals/static/level_of_detail.js b/src/cellier/gui/anywidget/visuals/static/level_of_detail.js new file mode 100644 index 00000000..d839748d --- /dev/null +++ b/src/cellier/gui/anywidget/visuals/static/level_of_detail.js @@ -0,0 +1,103 @@ +// "Level of detail" control ESM: a settled bias slider and one "coarsest +// while moving" checkbox. The checkbox is bound to the 3D setting while the +// view displays three dimensions and to the 2D setting otherwise. +// The bias is sent on a settled `change`, not on drag `input`: it reslices. + +function movingField(model) { + return model.get("n_displayed_dimensions") === 3 + ? "coarsest_while_moving_3d" + : "coarsest_while_moving_2d"; +} + +function render({ model, el }) { + el.classList.add("cellier-level-of-detail"); + + let guard = false; + const labels = model.get("labels") || {}; + + const title = document.createElement("div"); + title.className = "cellier-group-title"; + title.textContent = model.get("title") || "Level of detail"; + el.appendChild(title); + model.on("change:title", () => { + title.textContent = model.get("title") || "Level of detail"; + }); + + // -- settled bias --------------------------------------------------------- + const biasRow = document.createElement("div"); + biasRow.className = "cellier-app-row"; + const biasLabel = document.createElement("label"); + biasLabel.className = "cellier-app-label"; + biasLabel.textContent = labels.bias || "Settled bias"; + + const range = model.get("bias_range") || [0.001, 5.0]; + const initVal = model.get("settled_lod_bias") ?? 1.0; + const inp = document.createElement("input"); + inp.type = "range"; + inp.min = range[0]; + inp.max = range[1]; + inp.step = "any"; + inp.value = initVal; + + const readout = document.createElement("span"); + readout.className = "cellier-app-readout"; + readout.textContent = Number(initVal).toFixed(2); + + inp.addEventListener("change", () => { + if (guard) return; + readout.textContent = Number(inp.value).toFixed(2); + model.set("settled_lod_bias", parseFloat(inp.value)); + model.save_changes(); + }); + // Live readout while dragging, without emitting to the bus. + inp.addEventListener("input", () => { + readout.textContent = Number(inp.value).toFixed(2); + }); + model.on("change:settled_lod_bias", () => { + guard = true; + try { + const v = model.get("settled_lod_bias") ?? 1.0; + inp.value = v; + readout.textContent = Number(v).toFixed(2); + } finally { + guard = false; + } + }); + + biasRow.appendChild(biasLabel); + biasRow.appendChild(inp); + biasRow.appendChild(readout); + el.appendChild(biasRow); + + // -- coarsest while moving ------------------------------------------------ + const movingRow = document.createElement("label"); + movingRow.className = "cellier-app-row cellier-lod-moving"; + const check = document.createElement("input"); + check.type = "checkbox"; + const movingText = document.createElement("span"); + movingText.textContent = labels.moving || "Coarsest while moving"; + movingRow.appendChild(check); + movingRow.appendChild(movingText); + el.appendChild(movingRow); + + const showMoving = () => { + guard = true; + try { + check.checked = !!model.get(movingField(model)); + } finally { + guard = false; + } + }; + showMoving(); + + check.addEventListener("change", () => { + if (guard) return; + model.set(movingField(model), check.checked); + model.save_changes(); + }); + model.on("change:coarsest_while_moving_3d", showMoving); + model.on("change:coarsest_while_moving_2d", showMoving); + model.on("change:n_displayed_dimensions", showMoving); +} + +export default { render }; diff --git a/src/cellier/gui/anywidget/visuals/static/lod_bias.js b/src/cellier/gui/anywidget/visuals/static/lod_bias.js deleted file mode 100644 index 099f3c21..00000000 --- a/src/cellier/gui/anywidget/visuals/static/lod_bias.js +++ /dev/null @@ -1,64 +0,0 @@ -// LOD-bias slider ESM (split out of the former composite panel.js). -// Emits only on settled `change`, not on drag `input`, to avoid triggering a -// reslice on every tick. - -function render({ model, el }) { - el.classList.add("cellier-lod-bias"); - - let guard = false; - - const row = document.createElement("div"); - row.className = "cellier-app-row"; - - const label = document.createElement("label"); - label.className = "cellier-app-label"; - // The control names itself from the `title` trait -- one string, set - // by whoever built the control, rather than one hard-coded here and - // another in the layout that used to draw a group title around it. - label.textContent = model.get("title") || "LOD bias"; - - model.on("change:title", () => { - label.textContent = model.get("title") || "LOD bias"; - }); - - const initVal = model.get("settled_lod_bias") ?? 1.0; - const inp = document.createElement("input"); - inp.type = "range"; - inp.min = 0.001; - inp.max = 5.0; - inp.step = "any"; - inp.value = initVal; - - const readout = document.createElement("span"); - readout.className = "cellier-app-readout"; - readout.textContent = Number(initVal).toFixed(2); - - inp.addEventListener("change", () => { - if (guard) return; - readout.textContent = Number(inp.value).toFixed(2); - model.set("settled_lod_bias", parseFloat(inp.value)); - model.save_changes(); - }); - // Live readout while dragging, without emitting to the bus. - inp.addEventListener("input", () => { - readout.textContent = Number(inp.value).toFixed(2); - }); - - model.on("change:settled_lod_bias", () => { - guard = true; - try { - const v = model.get("settled_lod_bias") ?? 1.0; - inp.value = v; - readout.textContent = Number(v).toFixed(2); - } finally { - guard = false; - } - }); - - row.appendChild(label); - row.appendChild(inp); - row.appendChild(readout); - el.appendChild(row); -} - -export default { render }; diff --git a/src/cellier/gui/qt/render/_base.py b/src/cellier/gui/qt/render/_base.py index 8ff1639f..f97b8019 100644 --- a/src/cellier/gui/qt/render/_base.py +++ b/src/cellier/gui/qt/render/_base.py @@ -15,7 +15,7 @@ meaningful as a group -- a radius means nothing without the strength and the sample count beside it -- so each panel is a single composite ``WidgetView`` with one ``changed`` / ``closed`` pair, structurally like - the former per-channel list rather than like ``QtLodBiasSlider``. + the former per-channel list rather than like ``QtLevelOfDetailControls``. """ from __future__ import annotations diff --git a/src/cellier/gui/qt/visuals/__init__.py b/src/cellier/gui/qt/visuals/__init__.py index cd52248f..5f7b1e21 100644 --- a/src/cellier/gui/qt/visuals/__init__.py +++ b/src/cellier/gui/qt/visuals/__init__.py @@ -34,13 +34,13 @@ QtLabelsRenderModeCombo, QtSaltSpin, ) +from cellier.gui.qt.visuals._level_of_detail import QtLevelOfDetailControls from cellier.gui.qt.visuals._lines import ( QtThicknessSpaceCombo, QtThicknessSpin, ) from cellier.gui.qt.visuals._loading import QtLoadingIndicator from cellier.gui.qt.visuals._loading_config import QtLoadingConfigControls -from cellier.gui.qt.visuals._lod_bias import QtLodBiasSlider from cellier.gui.qt.visuals._lod_config import QtLodConfigControls from cellier.gui.qt.visuals._mesh import ( QtFlatShadingToggle, @@ -75,9 +75,9 @@ "QtImageControls", "QtIntSpin", "QtLabelsRenderModeCombo", + "QtLevelOfDetailControls", "QtLoadingConfigControls", "QtLoadingIndicator", - "QtLodBiasSlider", "QtLodConfigControls", "QtMeshSectionControls", "QtNodeColorPicker", diff --git a/src/cellier/gui/qt/visuals/_level_of_detail.py b/src/cellier/gui/qt/visuals/_level_of_detail.py new file mode 100644 index 00000000..7f96a251 --- /dev/null +++ b/src/cellier/gui/qt/visuals/_level_of_detail.py @@ -0,0 +1,223 @@ +"""The "Level of detail" control wired to the cellier v2 event bus (Qt).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any +from uuid import uuid4 + +from psygnal import Signal + +from cellier.events import ( + AppearanceChangedEvent, + AppearanceUpdateEvent, + DimsChangedEvent, + SubscriptionSpec, +) +from cellier.gui._appearance_fields import VisualIdGroup +from cellier.gui._image_controls import ( + displayed_dimensions, + validate_n_displayed_dimensions, +) +from cellier.gui._level_of_detail import ( + COARSEST_LABEL, + LEVEL_OF_DETAIL_FIELDS, + LEVEL_OF_DETAIL_TITLE, + SETTLED_BIAS_LABEL, + SETTLED_BIAS_RANGE, + from_control_value, + moving_field, +) + +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + from uuid import UUID + + +class QtLevelOfDetailControls(VisualIdGroup): + """Settled bias slider and "coarsest while moving" checkbox. + + Drives ``settled_lod_bias``, ``coarsest_while_moving_3d`` and + ``coarsest_while_moving_2d`` on a multiscale image or labels + appearance. The checkbox shows and edits the setting of the view the + control is in: the 3D one while :attr:`n_displayed_dimensions` is 3, + the 2D one otherwise. + + Changing ``settled_lod_bias`` reslices, so its ``AppearanceUpdateEvent`` + is emitted when the slider is released, not on every tick. + + Wire to the controller after construction:: + + control = QtLevelOfDetailControls(visual_id, values) + controller.connect_widget( + control, subscription_specs=control.subscription_specs() + ) + + Parameters + ---------- + visual_id : + The visual the control drives. A sequence drives every listed + visual in lock-step (the ``OrthoViewer``'s panel siblings). + values : + ``cellier.gui._level_of_detail.level_of_detail_values`` of the + visual's appearance. Missing keys take the model's defaults. + n_displayed_dimensions : + What the view displays now, 2 or 3. + scene_ids : + Scenes whose ``DimsChangedEvent`` the control follows to keep + :attr:`n_displayed_dimensions` current. + lod_range : + ``(min, max)`` of the slider. + decimals : + Decimal places shown beside the slider. + title : + The group's name. Defaults to :data:`DEFAULT_TITLE`. + parent : + Optional Qt parent widget. + """ + + DEFAULT_TITLE = LEVEL_OF_DETAIL_TITLE + + changed: Signal = Signal(object) + closed: Signal = Signal() + + def __init__( + self, + visual_id: UUID | Sequence[UUID], + values: Mapping[str, Any] | None = None, + *, + n_displayed_dimensions: int = 3, + scene_ids: Sequence[UUID] = (), + lod_range: tuple[float, float] = SETTLED_BIAS_RANGE, + decimals: int = 2, + title: str | None = None, + parent=None, + ) -> None: + from qtpy.QtCore import Qt + from qtpy.QtWidgets import QCheckBox, QVBoxLayout, QWidget + from superqt import QLabeledDoubleSlider + + from cellier.gui.qt.visuals._chrome import labelled_row, titled_group + + self._id = uuid4() + self._init_visual_ids(visual_id) + self._scene_ids = tuple(scene_ids) + self._n_displayed_dimensions = validate_n_displayed_dimensions( + n_displayed_dimensions + ) + values = dict(values or {}) + self._moving = { + "coarsest_while_moving_3d": bool( + values.get("coarsest_while_moving_3d", True) + ), + "coarsest_while_moving_2d": bool( + values.get("coarsest_while_moving_2d", False) + ), + } + + self._container = QWidget(parent) + layout = QVBoxLayout(self._container) + layout.setContentsMargins(0, 0, 0, 0) + + self._slider = QLabeledDoubleSlider(Qt.Orientation.Horizontal) + self._slider.setRange(*lod_range) + self._slider.setDecimals(decimals) + self._slider.setValue(float(values.get("settled_lod_bias", 1.0))) + # On release only: a bias change reslices. + self._slider.sliderReleased.connect(self._on_slider_released) + layout.addWidget(labelled_row(SETTLED_BIAS_LABEL, self._slider)) + + self._moving_check = QCheckBox(COARSEST_LABEL) + self._moving_check.setChecked(self._moving[self.moving_field]) + self._moving_check.toggled.connect(self._on_moving_toggled) + layout.addWidget(self._moving_check) + + self._group = titled_group( + self.DEFAULT_TITLE if title is None else title, self._container, parent + ) + + # -- public interface ----------------------------------------------------- + + @property + def widget(self): + """The titled group to insert into a layout.""" + return self._group + + @property + def control(self): + """The bare row container inside the group.""" + return self._container + + @property + def n_displayed_dimensions(self) -> int: + """What the view displays, 2 or 3; picks the checkbox's field.""" + return self._n_displayed_dimensions + + @n_displayed_dimensions.setter + def n_displayed_dimensions(self, value: int) -> None: + self._n_displayed_dimensions = validate_n_displayed_dimensions(value) + self._set_checked(self._moving[self.moving_field]) + + @property + def moving_field(self) -> str: + """The appearance field the checkbox edits now.""" + return moving_field(self._n_displayed_dimensions) + + def close(self) -> None: + """Emit ``closed`` to trigger bus unsubscription via the controller.""" + self.closed.emit() + + def subscription_specs(self) -> list[SubscriptionSpec]: + """One appearance subscription per visual, one dims one per scene.""" + specs = self._group_specs(AppearanceChangedEvent, self._on_appearance_changed) + specs.extend( + SubscriptionSpec( + event_type=DimsChangedEvent, + handler=self._on_dims_changed, + entity_id=scene_id, + ) + for scene_id in self._scene_ids + ) + return specs + + # -- model -> widget ------------------------------------------------------ + + def _on_appearance_changed(self, event) -> None: + if event.source_id == self._id: + return + if event.field_name not in LEVEL_OF_DETAIL_FIELDS: + return + if event.field_name == "settled_lod_bias": + self._slider.blockSignals(True) + self._slider.setValue(float(event.new_value)) + self._slider.blockSignals(False) + return + self._moving[event.field_name] = bool(event.new_value) + if event.field_name == self.moving_field: + self._set_checked(bool(event.new_value)) + + def _on_dims_changed(self, event) -> None: + """Follow a scene's switch between 2D and 3D display.""" + if not event.displayed_axes_changed: + return + n = displayed_dimensions(event.dims_state) + if n is not None and n != self._n_displayed_dimensions: + self.n_displayed_dimensions = n + + def _set_checked(self, checked: bool) -> None: + self._moving_check.blockSignals(True) + self._moving_check.setChecked(checked) + self._moving_check.blockSignals(False) + + # -- widget -> model ------------------------------------------------------ + + def _on_slider_released(self) -> None: + self._emit_group( + AppearanceUpdateEvent, + "settled_lod_bias", + from_control_value("settled_lod_bias", self._slider.value()), + ) + + def _on_moving_toggled(self, checked: bool) -> None: + field = self.moving_field + self._moving[field] = bool(checked) + self._emit_group(AppearanceUpdateEvent, field, bool(checked)) diff --git a/src/cellier/gui/qt/visuals/_lod_bias.py b/src/cellier/gui/qt/visuals/_lod_bias.py deleted file mode 100644 index 7e6e8bd0..00000000 --- a/src/cellier/gui/qt/visuals/_lod_bias.py +++ /dev/null @@ -1,147 +0,0 @@ -"""LOD-bias slider wired to the cellier v2 event bus.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING -from uuid import uuid4 - -from psygnal import Signal - -from cellier.events import ( - AppearanceChangedEvent, - AppearanceUpdateEvent, - SubscriptionSpec, -) -from cellier.gui._appearance_fields import VisualIdGroup - -if TYPE_CHECKING: - from collections.abc import Sequence - from uuid import UUID - - -class QtLodBiasSlider(VisualIdGroup): - """Single-value LOD-bias slider wired to the cellier v2 bus. - - Wraps a ``superqt.QLabeledDoubleSlider`` and keeps it in sync with - ``MultiscaleImageAppearance.settled_lod_bias`` (or the equivalent labels field) - via ``AppearanceChangedEvent``. - - Because changing ``settled_lod_bias`` triggers a reslice, the ``AppearanceUpdateEvent`` - is emitted on ``sliderReleased`` rather than on every ``valueChanged`` tick, - so only one reslice fires per drag interaction. - - Wire to the controller after construction:: - - slider = QtLodBiasSlider(visual_id, initial_lod_bias=1.0) - controller.connect_widget( - slider, subscription_specs=slider.subscription_specs() - ) - - Parameters - ---------- - visual_id : - UUID of the visual whose ``settled_lod_bias`` field this widget controls. - A sequence drives every listed visual in lock-step -- the - ``OrthoViewer``'s four panel siblings (design section 8.1). - initial_lod_bias : - Starting value — typically ``visual_model.appearance.settled_lod_bias``. - lod_range : - ``(min, max)`` for the slider range. Defaults to ``(1e-6, 5.0)``. - title : - The name shown beside the control. Defaults to - :data:`DEFAULT_TITLE`. - decimals : - Number of decimal places shown in the slider label. Default is ``2``. - parent : - Optional Qt parent widget. - """ - - DEFAULT_TITLE = "LOD bias" - """Name shown when no ``title=`` is given. - - The renderer passes the title from the shared control vocabulary; this - is what a directly-constructed widget calls itself, and - ``test_composite_default_titles_match_the_shared_vocabulary`` pins the - two together. - """ - - changed: Signal = Signal(object) - closed: Signal = Signal() - - def __init__( - self, - visual_id: UUID | Sequence[UUID], - *, - initial_lod_bias: float = 1.0, - lod_range: tuple[float, float] = (1e-6, 5.0), - decimals: int = 2, - title: str | None = None, - parent=None, - ) -> None: - from qtpy.QtCore import Qt - from superqt import QLabeledDoubleSlider - - from cellier.gui.qt.visuals._chrome import labelled_row - - self._id = uuid4() - self._init_visual_ids(visual_id) - - self._slider = QLabeledDoubleSlider(Qt.Orientation.Horizontal, parent) - self._slider.setRange(*lod_range) - self._slider.setValue(initial_lod_bias) - self._slider.setDecimals(decimals) - - # Emit only when the user releases the handle to avoid a reslice on - # every intermediate tick while dragging. - self._slider.sliderReleased.connect(self._on_slider_released) - - self._row = labelled_row( - self.DEFAULT_TITLE if title is None else title, self._slider, parent - ) - - # ── Public interface ───────────────────────────────────────────────────── - - @property - def widget(self): - """The labelled row to insert into a layout. - - The control names itself (``plans/label_ownership_unification.md``); - reach for :attr:`control` to drive the input directly. - """ - return self._row - - @property - def control(self): - """The bare input inside the row.""" - return self._slider - - def close(self) -> None: - """Emit ``closed`` to trigger bus unsubscription via the controller.""" - self.closed.emit() - - def subscription_specs(self) -> list[SubscriptionSpec]: - """Return the inbound subscription this widget requires.""" - return self._group_specs(AppearanceChangedEvent, self._on_visual_changed) - - # ── Cellier layer: model → widget ──────────────────────────────────────── - - def _on_visual_changed(self, event) -> None: - if event.source_id == self._id: - return - if event.field_name != "settled_lod_bias": - return - self._set_value(event.new_value) - - # ── Cellier layer: widget → model ──────────────────────────────────────── - - def _on_slider_released(self) -> None: - self._emit_group( - AppearanceUpdateEvent, "settled_lod_bias", self._slider.value() - ) - - # ── Qt seam: push value without re-firing signals ──────────────────────── - - def _set_value(self, value: float) -> None: - self._slider.blockSignals(True) - self._slider.setValue(value) - self._slider.blockSignals(False) diff --git a/src/cellier/render/_plane_mode.py b/src/cellier/render/_plane_mode.py new file mode 100644 index 00000000..93447006 --- /dev/null +++ b/src/cellier/render/_plane_mode.py @@ -0,0 +1,32 @@ +"""The ``"plane"`` render mode in the render layer. + +Plane rendering design v3: the model and the controller know the mode from +Phase 5; the shaders that draw a plane arrive with Phases 6 (in-memory) and +7 (multiscale). Until then a material asked for ``"plane"`` is given its +family's default volume mode, so a visual in plane mode builds and draws +without a shader that has no branch for it. Each caller of +:func:`volume_mode_until_planes_draw` is a place one of those phases +replaces. +""" + +from __future__ import annotations + +from cellier.visuals._render_plane import PLANE_RENDER_MODE + + +def volume_mode_until_planes_draw(render_mode: str, fallback: str) -> str: + """*render_mode*, or *fallback* in its place when it is ``"plane"``. + + Parameters + ---------- + render_mode : str + A visual's render mode. + fallback : str + The volume mode the material draws until it can draw a plane. + + Returns + ------- + str + A mode the material's shader has a branch for. + """ + return fallback if render_mode == PLANE_RENDER_MODE else render_mode diff --git a/src/cellier/render/shaders/_label_multiscale.py b/src/cellier/render/shaders/_label_multiscale.py index 48ea1da3..e191c9e0 100644 --- a/src/cellier/render/shaders/_label_multiscale.py +++ b/src/cellier/render/shaders/_label_multiscale.py @@ -25,6 +25,7 @@ from pygfx.renderers.wgpu.shaders.imageshader import ImageShader from pygfx.renderers.wgpu.shaders.volumeshader import BaseVolumeShader +from cellier.render._plane_mode import volume_mode_until_planes_draw from cellier.render.shaders._label_colormap import ( build_outline_selection_texture, ) @@ -166,7 +167,9 @@ def __init__(self, wobject): # stays valid on a canvas using the stock blender. self["write_outline_id"] = False self["has_outline_selection"] = m.outline_selection_texture is not None - self["render_mode"] = m.render_mode + self["render_mode"] = volume_mode_until_planes_draw( + m.render_mode, "iso_categorical" + ) # Default for the ``normal`` render target. ``write_normal`` is # overridden by CellierBlender.get_shader_kwargs when the target # exists; without it the write compiles away, so the same shader diff --git a/src/cellier/render/shaders/_label_volume.py b/src/cellier/render/shaders/_label_volume.py index 71a90ccc..ec5e4083 100644 --- a/src/cellier/render/shaders/_label_volume.py +++ b/src/cellier/render/shaders/_label_volume.py @@ -14,6 +14,7 @@ ) from pygfx.renderers.wgpu.shaders.volumeshader import BaseVolumeShader +from cellier.render._plane_mode import volume_mode_until_planes_draw from cellier.render.shaders._label_colormap import ( build_outline_selection_texture, ) @@ -97,7 +98,9 @@ def __init__(self, wobject): # stays valid on a canvas using the stock blender. self["write_outline_id"] = False self["has_outline_selection"] = material.outline_selection_texture is not None - self["render_mode"] = material.render_mode + self["render_mode"] = volume_mode_until_planes_draw( + material.render_mode, "iso_categorical" + ) # Default for the ``normal`` render target. ``write_normal`` is # overridden by CellierBlender.get_shader_kwargs when the target # exists; without it the write compiles away, so the same shader diff --git a/src/cellier/render/shaders/_multiscale_volume_brick.py b/src/cellier/render/shaders/_multiscale_volume_brick.py index 649c0b64..0cc6319c 100644 --- a/src/cellier/render/shaders/_multiscale_volume_brick.py +++ b/src/cellier/render/shaders/_multiscale_volume_brick.py @@ -28,6 +28,7 @@ from pygfx.renderers.wgpu.shaders.volumeshader import BaseVolumeShader from pygfx.resources import Buffer +from cellier.render._plane_mode import volume_mode_until_planes_draw from cellier.render.lut_indirection._cell_brick_rule import ( UNBOUNDED_BRICK_COUNT, level_brick_counts, @@ -521,7 +522,9 @@ def get_bindings(self, wobject, shared, scene): # Template variable for debug visualisation modes. self["debug_mode"] = getattr(material, "debug_mode", "none") # Template variable for render mode (iso / mip). - self["render_mode"] = getattr(material, "render_mode", "iso") + self["render_mode"] = volume_mode_until_planes_draw( + getattr(material, "render_mode", "iso"), "mip" + ) # Default for the ``normal`` render target. ``write_normal`` is # overridden by CellierBlender.get_shader_kwargs when the target # exists; without it the write compiles away, so the same shader diff --git a/src/cellier/render/visuals/_image_memory.py b/src/cellier/render/visuals/_image_memory.py index 4ad5690e..7637cd44 100644 --- a/src/cellier/render/visuals/_image_memory.py +++ b/src/cellier/render/visuals/_image_memory.py @@ -9,6 +9,7 @@ from cellier.data.image._image_requests import ChunkRequest from cellier.render._clipping import ClippingPlanesMixin +from cellier.render._plane_mode import volume_mode_until_planes_draw from cellier.render._spaces import RenderSpaces, node_matrix from cellier.render.shaders._image_volume import IMAGE_VOLUME_MATERIALS from cellier.render.visuals._pick import memory_image_data_coordinate @@ -72,6 +73,11 @@ def _make_colormap(color_map) -> gfx.TextureMap: _VOLUME_MATERIALS: dict[str, type] = IMAGE_VOLUME_MATERIALS +def _volume_material_class(render_mode: str) -> type: + """The material class that draws *render_mode*.""" + return _VOLUME_MATERIALS[volume_mode_until_planes_draw(render_mode, "mip")] + + def _make_volume_material(appearance, colormap, pick_write: bool): """Build the pygfx volume material for an in-memory image appearance. @@ -94,7 +100,7 @@ def _make_volume_material(appearance, colormap, pick_write: bool): gfx.VolumeRayMaterial A configured volume material. """ - material_cls = _VOLUME_MATERIALS[appearance.render_mode] + material_cls = _volume_material_class(appearance.render_mode) material = material_cls( clim=appearance.clim, map=colormap, @@ -350,7 +356,7 @@ def apply( material.depth_test = shared.depth_test and not planes_overlap material.depth_write = shared.depth_write and not planes_overlap if self.node_3d is not None: - material_cls = _VOLUME_MATERIALS[mode_appearance.render_mode] + material_cls = _volume_material_class(mode_appearance.render_mode) material = self.node_3d.material if not isinstance(material, material_cls) or type(material) is not ( material_cls diff --git a/src/cellier/visuals/__init__.py b/src/cellier/visuals/__init__.py index e42af29c..e595b232 100644 --- a/src/cellier/visuals/__init__.py +++ b/src/cellier/visuals/__init__.py @@ -52,6 +52,7 @@ ) from cellier.visuals._overlay_types import CanvasOverlayType, SceneOverlayType from cellier.visuals._points_memory import PointsMarkerAppearance, PointsVisual +from cellier.visuals._render_plane import MAX_RENDER_PLANES, RenderPlane from cellier.visuals._scene_overlay import ( SceneBoundingBox, SceneBoundingBoxAppearance, @@ -60,6 +61,7 @@ from cellier.visuals._types import VisualType __all__ = [ + "MAX_RENDER_PLANES", "AABBParams", "BaseImageAppearance", "BaseImageSingleAppearance", @@ -99,6 +101,7 @@ "PointsMarkerAppearance", "PointsVisual", "ProgressiveLoadingConfig", + "RenderPlane", "SceneBoundingBox", "SceneBoundingBoxAppearance", "SceneOverlay", diff --git a/src/cellier/visuals/_base_visual.py b/src/cellier/visuals/_base_visual.py index bfa4d4fa..e17f06ae 100644 --- a/src/cellier/visuals/_base_visual.py +++ b/src/cellier/visuals/_base_visual.py @@ -8,6 +8,7 @@ from cellier.render._config import MAX_OUTLINE_SLOT from cellier.transform import TransformType from cellier.visuals._clipping import ClippingPlane, validate_clipping_planes +from cellier.visuals._render_plane import validate_render_planes class AABBParams(EventedModel): @@ -181,7 +182,7 @@ class BaseVisual(EventedModel): ) def __setattr__(self, name: str, value: object) -> None: - """Validate ``clipping_planes`` on assignment. + """Validate ``clipping_planes`` and ``render_planes`` on assignment. The model does not validate assignments in general; without this a list would stay a list and a wrong element would be found only when @@ -189,6 +190,8 @@ def __setattr__(self, name: str, value: object) -> None: """ if name == "clipping_planes": value = validate_clipping_planes(value) + elif name == "render_planes": + value = validate_render_planes(value) super().__setattr__(name, value) def plans_coarse_while_moving(self, n_displayed_dims: int) -> bool: diff --git a/src/cellier/visuals/_image.py b/src/cellier/visuals/_image.py index 2bbe8d69..28798103 100644 --- a/src/cellier/visuals/_image.py +++ b/src/cellier/visuals/_image.py @@ -30,13 +30,17 @@ class MultiscaleImageAppearance(BaseImageAppearance): Level-of-detail bias of a settled view, 2D and 3D. Divisor on the screen-space LOD threshold: higher is coarser. Default 1.0. coarsest_while_moving_3d : bool - In a 3D view, plan no target while the visual moves (its scene's - dims are being scrubbed): each tick loads the coarse backstop only, - and the target once the motion ends. ``False`` (the default) plans - in full on every tick. + In a 3D view, plan no target while the visual moves. A visual moves + while its scene's dims are scrubbed, or while its clipping planes + are dragged (``CellierController.plane_interaction``). A dims tick + then loads the new slice's coarse backstop only; a plane drag plans + nothing and keeps the last plan. The target is planned once every + motion has ended. ``False`` plans in full on every tick and nothing + more at the end. Default ``True``. coarsest_while_moving_2d : bool - The same, for a 2D view. ``True`` saves most of a scrub's reads and - is the setting for a slow store. Default ``False``. + The same, for a 2D view, whose only motion is a dims scrub. + ``True`` saves most of a scrub's reads and is the setting for a slow + store. Default ``False``. force_level : int or None Overrides automatic LOD selection when set. Default None. frustum_cull : bool @@ -57,7 +61,7 @@ def _refuse_removed_fields(cls, data: Any) -> Any: attenuation: float = 1.0 settled_lod_bias: float = 1.0 - coarsest_while_moving_3d: bool = False + coarsest_while_moving_3d: bool = True coarsest_while_moving_2d: bool = False force_level: int | None = None frustum_cull: bool = True @@ -70,13 +74,14 @@ class MultiscaleImageSingleAppearance(BaseImageSingleAppearance): Parameters ---------- render_mode : str - ``"iso"`` (default), ``"mip"``, ``"smooth_iso"`` or - ``"attenuated_mip"``. + ``"iso"`` (default), ``"mip"``, ``"smooth_iso"``, + ``"attenuated_mip"``, or ``"plane"``, which draws the data on the + visual's ``render_planes`` instead of as a volume. iso_threshold : float Isosurface threshold. Default 0.2. """ - render_mode: Literal["iso", "mip", "smooth_iso", "attenuated_mip"] = "iso" + render_mode: Literal["iso", "mip", "smooth_iso", "attenuated_mip", "plane"] = "iso" iso_threshold: float = 0.2 diff --git a/src/cellier/visuals/_image_memory.py b/src/cellier/visuals/_image_memory.py index 7bb0c846..22a63e02 100644 --- a/src/cellier/visuals/_image_memory.py +++ b/src/cellier/visuals/_image_memory.py @@ -16,6 +16,7 @@ from pydantic import ConfigDict, Field, model_validator from cellier.visuals._base_visual import BaseDrawAppearance, BaseVisual +from cellier.visuals._render_plane import PLANE_RENDER_MODE, RenderPlane __all__ = [ "BaseImageAppearance", @@ -25,6 +26,7 @@ "InMemoryImageAppearance", "InMemoryImageChannelAppearance", "InMemoryImageSingleAppearance", + "check_channels_share_render_mode", "effective_transparency_mode", ] @@ -97,11 +99,12 @@ class InMemoryImageSingleAppearance(BaseImageSingleAppearance): Parameters ---------- render_mode : str - Volume rendering mode for the 3D view: ``"mip"`` (default), ``"iso"`` - or ``"minip"``. Ignored by the 2D view. + Rendering mode for the 3D view: ``"mip"`` (default), ``"iso"``, + ``"minip"``, or ``"plane"``, which draws the data on the visual's + ``render_planes`` instead of as a volume. Ignored by the 2D view. """ - render_mode: Literal["mip", "iso", "minip"] = "mip" + render_mode: Literal["mip", "iso", "minip", "plane"] = "mip" class InMemoryImageChannelAppearance(InMemoryImageSingleAppearance): @@ -142,9 +145,21 @@ class BaseImageVisual(BaseVisual): channels : dict[int, channel appearance] Per-channel appearances, keyed by index along ``channel_axis``. Empty is valid and draws nothing in composite mode (D16). + + **Every channel has the same ``render_mode``**, hidden ones + included: the dict is refused otherwise, here and on assignment. + Change it with ``CellierController.set_image_render_mode``, which + sets every channel together; assigning it to one channel of several + is refused. ``single.render_mode`` is separate. max_channels : int The most channels ``channels`` may hold, in 2D and 3D alike (D15). Default 4. + render_planes : tuple[RenderPlane, ...] + The planes the visual draws its data on in a 3D view while its + render mode is ``"plane"``; ignored in every other mode and in a 2D + view. World space; see :class:`~cellier.visuals.RenderPlane`. At + most four. Assign a new tuple to change them: one assignment is one + event. Default empty, which in ``"plane"`` mode draws nothing. """ model_config = ConfigDict(validate_assignment=True) @@ -160,6 +175,7 @@ class BaseImageVisual(BaseVisual): ), ) max_channels: int = Field(default=4, ge=1) + render_planes: tuple[RenderPlane, ...] = () @model_validator(mode="after") def _validate_modes(self) -> BaseImageVisual: @@ -174,8 +190,26 @@ def _validate_modes(self) -> BaseImageVisual: f"channels has {len(channels)} entries but max_channels=" f"{self.max_channels}. Raise max_channels or remove a channel." ) + check_channels_share_render_mode(channels, self.name) return self + def __setattr__(self, name: str, value: object) -> None: + """Keep the old ``channels`` when a new dict is refused. + + The rules on ``channels`` (one render mode, ``max_channels``) are + checked after pydantic has assigned the field, and a failed check + does not undo the assignment. + """ + if name != "channels": + super().__setattr__(name, value) + return + previous = self.channels + try: + super().__setattr__(name, value) + except ValueError: + self.__dict__["channels"] = previous + raise + def drawn_channels(self, size: int | None = None) -> tuple[int, ...]: """The channel indices composite mode draws, ascending. @@ -202,6 +236,19 @@ def drawn_channels(self, size: int | None = None) -> tuple[int, ...]: ) ) + def plane_mode(self) -> bool: + """Whether a 3D view draws this visual on its ``render_planes``. + + Single mode reads ``single.render_mode``. Composite mode reads its + drawn channels, which share one render mode like all the channels. + """ + if not self.composite: + return self.single.render_mode == PLANE_RENDER_MODE + drawn = self.drawn_channels() + return bool(drawn) and all( + self.channels[index].render_mode == PLANE_RENDER_MODE for index in drawn + ) + def draws_nothing(self, size: int | None = None) -> bool: """Whether the visual draws nothing and so needs no slicing (3.3). @@ -212,6 +259,31 @@ def draws_nothing(self, size: int | None = None) -> bool: return self.composite and not self.drawn_channels(size) +def check_channels_share_render_mode(channels: Any, name: str = "image") -> None: + """Raise unless every channel appearance has the same ``render_mode``. + + Parameters + ---------- + channels : dict[int, channel appearance] + An image visual's ``channels``. + name : str + The visual's name, for the message. + + Raises + ------ + ValueError + Naming each channel's mode. + """ + modes = {index: channel.render_mode for index, channel in channels.items()} + if len(set(modes.values())) > 1: + raise ValueError( + f"The channels of image {name!r} have different render modes " + f"{dict(sorted(modes.items()))}. Every channel of an image has " + "the same render mode; set it with " + "CellierController.set_image_render_mode." + ) + + def effective_transparency_mode(visual: Any) -> str: """The alpha mode an image visual draws its channels with. diff --git a/src/cellier/visuals/_label_memory.py b/src/cellier/visuals/_label_memory.py index 19168778..5ad79939 100644 --- a/src/cellier/visuals/_label_memory.py +++ b/src/cellier/visuals/_label_memory.py @@ -5,6 +5,7 @@ from pydantic import Field from cellier.visuals._base_visual import BaseAppearance, BaseVisual +from cellier.visuals._render_plane import PLANE_RENDER_MODE, RenderPlane OutlineMode = Literal["per_label", "whole_object", "all_boundaries"] """How a labels visual is outlined; see ``BaseLabelsVisual``.""" @@ -23,8 +24,9 @@ class BaseLabelsAppearance(BaseAppearance): Hash seed for random colormap mode. Default 0. color_dict : dict Explicit label-ID → RGBA mapping for direct mode. - render_mode : "iso_categorical" or "flat_categorical" - 3D rendering mode. + render_mode : "iso_categorical", "flat_categorical" or "plane" + 3D rendering mode. ``"plane"`` draws the labels on the visual's + ``render_planes`` instead of as a volume. """ colormap_mode: Literal["random", "direct"] = Field(default="random", frozen=True) @@ -33,7 +35,9 @@ class BaseLabelsAppearance(BaseAppearance): color_dict: dict[int, tuple[float, float, float, float]] = Field( default_factory=dict ) - render_mode: Literal["iso_categorical", "flat_categorical"] = "iso_categorical" + render_mode: Literal["iso_categorical", "flat_categorical", "plane"] = ( + "iso_categorical" + ) class InMemoryLabelsAppearance(BaseLabelsAppearance): @@ -99,6 +103,10 @@ class BaseLabelsVisual(BaseVisual): ``all_boundaries`` label field ``outline.slot`` ================== ============= ========================== + ``render_planes`` are the planes the visual draws its labels on in a 3D + view while ``appearance.render_mode`` is ``"plane"``; ignored otherwise. + World space, at most four; see :class:`~cellier.visuals.RenderPlane`. + ``outline_selected_labels`` is kept on the visual rather than pushed straight to the GPU because a labels material can be rebuilt underneath it -- the multiscale visual rebuilds its materials whenever the @@ -108,6 +116,11 @@ class BaseLabelsVisual(BaseVisual): outline_mode: OutlineMode = "per_label" outline_selected_labels: dict[int, int] = Field(default_factory=dict) + render_planes: tuple[RenderPlane, ...] = () + + def plane_mode(self) -> bool: + """Whether a 3D view draws this visual on its ``render_planes``.""" + return self.appearance.render_mode == PLANE_RENDER_MODE class LabelMemoryVisual(BaseLabelsVisual): diff --git a/src/cellier/visuals/_labels.py b/src/cellier/visuals/_labels.py index 5de90d4a..4dc14b27 100644 --- a/src/cellier/visuals/_labels.py +++ b/src/cellier/visuals/_labels.py @@ -31,18 +31,23 @@ class MultiscaleLabelsAppearance(BaseLabelsAppearance): Inherited from ``BaseLabelsAppearance``. render_mode : str Overrides ``BaseLabelsAppearance`` to widen the Literal to include - ``"gradient_debug"`` and ``"smooth_iso"``. Not frozen — live-mutable. + ``"gradient_debug"`` and ``"smooth_iso"`` (and ``"plane"``, as on + the base). Not frozen — live-mutable. settled_lod_bias : float Level-of-detail bias of a settled view, 2D and 3D. Divisor on the screen-space LOD threshold: higher is coarser. Default 1.0. coarsest_while_moving_3d : bool - In a 3D view, plan no target while the visual moves (its scene's - dims are being scrubbed): each tick loads the coarse backstop only, - and the target once the motion ends. ``False`` (the default) plans - in full on every tick. + In a 3D view, plan no target while the visual moves. A visual moves + while its scene's dims are scrubbed, or while its clipping planes + are dragged (``CellierController.plane_interaction``). A dims tick + then loads the new slice's coarse backstop only; a plane drag plans + nothing and keeps the last plan. The target is planned once every + motion has ended. ``False`` plans in full on every tick and nothing + more at the end. Default ``True``. coarsest_while_moving_2d : bool - The same, for a 2D view. ``True`` saves most of a scrub's reads and - is the setting for a slow store. Default ``False``. + The same, for a 2D view, whose only motion is a dims scrub. + ``True`` saves most of a scrub's reads and is the setting for a slow + store. Default ``False``. force_level : int | None Overrides automatic LOD selection when set. Default None. frustum_cull : bool @@ -60,10 +65,10 @@ def _refuse_removed_fields(cls, data: Any) -> Any: return refuse_removed_fields(data, REMOVED_APPEARANCE_FIELDS, cls.__name__) render_mode: Literal[ - "iso_categorical", "flat_categorical", "gradient_debug", "smooth_iso" + "iso_categorical", "flat_categorical", "gradient_debug", "smooth_iso", "plane" ] = "iso_categorical" settled_lod_bias: float = 1.0 - coarsest_while_moving_3d: bool = False + coarsest_while_moving_3d: bool = True coarsest_while_moving_2d: bool = False force_level: int | None = None frustum_cull: bool = True diff --git a/src/cellier/visuals/_removed.py b/src/cellier/visuals/_removed.py index ced7549e..16238cae 100644 --- a/src/cellier/visuals/_removed.py +++ b/src/cellier/visuals/_removed.py @@ -25,6 +25,14 @@ } +#: Appearance keys of the multiscale controls configs +#: (``cellier.convenience.gui``): removed key to the key that replaces it. +REMOVED_CONTROL_KEYS: dict[str, str] = { + "lod_bias": "level_of_detail", + "settled_lod_bias": "level_of_detail", +} + + def refuse_removed_fields(data: Any, removed: dict[str, str], model: str) -> Any: """Raise if *data*, a model's input, names a removed field. diff --git a/src/cellier/visuals/_render_plane.py b/src/cellier/visuals/_render_plane.py new file mode 100644 index 00000000..294d602b --- /dev/null +++ b/src/cellier/visuals/_render_plane.py @@ -0,0 +1,360 @@ +"""Render planes: the planes a visual in ``"plane"`` render mode draws on. + +Plane rendering design v3, section 4. A render plane is a pose in the +scene's world space (an origin and two in-plane axes) with an extent along +each in-plane axis. It is unlike a ``ClippingPlane``, which is a half-space +in the visual's data coordinates: a frame and a rectangle do not survive an +anisotropic data transform, so they live in world units. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any +from uuid import UUID, uuid4 + +import numpy as np +from pydantic import ( + UUID4, + BaseModel, + ConfigDict, + Field, + TypeAdapter, + field_validator, + model_validator, +) +from typing_extensions import Self + +from cellier.transform import AxisRef + +if TYPE_CHECKING: + from collections.abc import Sequence + + from numpy.typing import ArrayLike + + from cellier.transform import CoordinateSystem + +#: The ``render_mode`` value, on image and labels, that draws render planes. +PLANE_RENDER_MODE = "plane" + +#: The most render planes a visual may carry: the shader's uniforms hold +#: this many, so adding or removing one is a uniform write, not a compile. +MAX_RENDER_PLANES = 4 + +#: How far from orthogonal (as a dot product of unit vectors) the in-plane +#: axes may be. +_ORTHOGONAL_TOL = 1e-6 + +Vector3 = tuple[float, float, float] +Axes3 = tuple[AxisRef, AxisRef, AxisRef] +Extent = tuple[float | None, float | None] + + +def _unit(vector: np.ndarray, name: str) -> np.ndarray: + length = float(np.linalg.norm(vector)) + if not np.isfinite(length) or length == 0.0: + raise ValueError(f"{name} must be finite and non-zero; got {vector.tolist()}.") + return vector / length + + +def _as_vector(value: ArrayLike, name: str) -> np.ndarray: + vector = np.asarray(value, dtype=np.float64) + if vector.shape != (3,): + raise ValueError( + f"{name} must have three entries, one per axis; got shape {vector.shape}." + ) + if not np.all(np.isfinite(vector)): + raise ValueError(f"{name} must be finite; got {vector.tolist()}.") + return vector + + +def _resolve_axes( + world: CoordinateSystem, axes: Sequence[AxisRef] | None +) -> tuple[UUID4, UUID4, UUID4]: + """The ids of the three world axes a plane is given on.""" + if axes is None: + if world.ndim != 3: + raise ValueError( + f"The coordinate system has {world.ndim} axes " + f"{world.axis_names()}; pass axes= to name the three the " + "plane is given on." + ) + return tuple(axis.id for axis in world.axes) + if len(axes) != 3: + raise ValueError(f"A render plane is on three axes; got {len(axes)}.") + return tuple(world.resolve_axis(axis).id for axis in axes) + + +def _quaternion_to_matrix(quaternion: np.ndarray) -> np.ndarray: + """Rotation matrix of a quaternion ``(x, y, z, w)``, scalar last.""" + x, y, z, w = _unit(quaternion, "rotation") + return np.array( + [ + [1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)], + [2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)], + [2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)], + ] + ) + + +class RenderPlane(BaseModel): + """One plane a visual in ``"plane"`` render mode draws its data on. + + The plane is in the scene's **world** space, on three named world axes. + ``origin`` is a point on it and ``in_plane_axis_0`` and + ``in_plane_axis_1`` are two orthogonal unit vectors in it; all three are + given on ``axes``, in that order. The plane constrains no other world + axis: a ``zyx`` plane in a ``tzyx`` scene holds at every time point. + + The plane is drawn where it crosses the visual's data, inside the + visual's clipping planes, and within ``extent_0`` and ``extent_1``: the + ``(min, max)`` distances from ``origin`` along the two in-plane axes, in + world units. ``None`` is unbounded on that side. + + The model is frozen. Moving a plane means assigning a new tuple to + ``visual.render_planes``. Build the moved plane with + ``plane.model_copy(update={...})`` so it keeps its ``id``: the id names + a plane across replacements of the tuple. Two planes built from scratch + have different ids and are not equal, even with the same pose. + (``model_copy`` does not validate: give it unit, orthogonal axes, or use + a constructor.) + + Parameters + ---------- + id : UUID4 + Names this plane. Unique within a visual's ``render_planes``. + Generated when not given. + coordinate_system : UUID4 + The id of the scene's world coordinate system. + axes : tuple of three AxisRef + The world axes the pose is given on, by id or name. The + constructors store ids. + origin : tuple of three float + A point on the plane. + in_plane_axis_0, in_plane_axis_1 : tuple of three float + The in-plane frame. Normalised on construction; they must be + finite, non-zero and orthogonal. + extent_0, extent_1 : tuple of two (float or None) + ``(min, max)`` along each in-plane axis; ``None`` is unbounded. A + bounded pair needs ``min < max``. + enabled : bool + A disabled plane stays in the tuple and is not drawn. Default + ``True``. + """ + + model_config = ConfigDict(frozen=True) + + id: UUID4 = Field(default_factory=uuid4) + coordinate_system: UUID4 + axes: Axes3 + origin: Vector3 + in_plane_axis_0: Vector3 + in_plane_axis_1: Vector3 + extent_0: Extent = (None, None) + extent_1: Extent = (None, None) + enabled: bool = True + + @field_validator("axes", mode="before") + @classmethod + def _axis_ids_from_text(cls, value: Any) -> Any: + """Read an id written as text (a JSON round trip) back as an id.""" + if not isinstance(value, (list, tuple)): + return value + axes = [] + for axis in value: + if isinstance(axis, str): + try: + axis = UUID(axis) + except ValueError: + pass # an axis name + axes.append(axis) + return tuple(axes) + + @field_validator("origin") + @classmethod + def _finite_origin(cls, value: Vector3) -> Vector3: + if not all(np.isfinite(value)): + raise ValueError(f"RenderPlane.origin must be finite; got {value}.") + return value + + @field_validator("extent_0", "extent_1") + @classmethod + def _valid_extent(cls, value: Extent) -> Extent: + low, high = value + for side in value: + if side is not None and not np.isfinite(side): + raise ValueError( + "A render plane extent side is a finite number or None " + f"(unbounded); got {value}." + ) + if low is not None and high is not None and not low < high: + raise ValueError( + f"A bounded render plane extent needs min < max; got {value}." + ) + return value + + @model_validator(mode="after") + def _validate_frame(self) -> Self: + if len(set(self.axes)) != 3: + raise ValueError( + f"RenderPlane.axes must be three distinct axes; got {self.axes!r}." + ) + axis_0 = _unit(np.asarray(self.in_plane_axis_0), "RenderPlane.in_plane_axis_0") + axis_1 = _unit(np.asarray(self.in_plane_axis_1), "RenderPlane.in_plane_axis_1") + if abs(float(axis_0 @ axis_1)) > _ORTHOGONAL_TOL: + raise ValueError( + "RenderPlane.in_plane_axis_0 and in_plane_axis_1 must be " + f"orthogonal; their unit vectors have dot product " + f"{float(axis_0 @ axis_1):.3g}." + ) + # Stored normalised. The model is frozen, so write past the guard. + object.__setattr__(self, "in_plane_axis_0", tuple(float(v) for v in axis_0)) + object.__setattr__(self, "in_plane_axis_1", tuple(float(v) for v in axis_1)) + return self + + @property + def normal(self) -> Vector3: + """The unit normal, ``in_plane_axis_0 x in_plane_axis_1``, on ``axes``. + + Derived, not stored. Which of the two sides it points to changes + nothing drawn. + """ + normal = np.cross(self.in_plane_axis_0, self.in_plane_axis_1) + return tuple(float(v) for v in normal) + + @classmethod + def from_point_normal( + cls, + world: CoordinateSystem, + point: ArrayLike, + normal: ArrayLike, + axes: Sequence[AxisRef] | None = None, + up: ArrayLike | None = None, + **kwargs: Any, + ) -> RenderPlane: + """Build the plane through *point* with the given *normal*. + + The in-plane frame is chosen for the caller. Axis 0 is *up* + projected into the plane; with no *up* it is the projection of the + axis (of *axes*) least aligned with the normal. Axis 1 completes + the frame so that ``in_plane_axis_0 x in_plane_axis_1`` is the + normal. + + Parameters + ---------- + world : CoordinateSystem + The scene's world coordinate system. + point : ArrayLike + A point on the plane, on *axes*. + normal : ArrayLike + The plane normal, on *axes*. Any non-zero length. + axes : sequence of three AxisRef, or None + The world axes *point* and *normal* are given on, e.g. + ``("z", "y", "x")``. ``None`` means the system's axes, which + must then be three. + up : ArrayLike or None + A direction, on *axes*, whose projection into the plane becomes + ``in_plane_axis_0``. It must not be parallel to the normal. + **kwargs : + ``extent_0``, ``extent_1``, ``enabled``, ``id``. + + Returns + ------- + RenderPlane + """ + axis_ids = _resolve_axes(world, axes) + origin = _as_vector(point, "point") + unit_normal = _unit(_as_vector(normal, "normal"), "normal") + if up is None: + reference = np.zeros(3) + reference[int(np.argmin(np.abs(unit_normal)))] = 1.0 + else: + reference = _as_vector(up, "up") + in_plane = reference - (reference @ unit_normal) * unit_normal + if float(np.linalg.norm(in_plane)) < _ORTHOGONAL_TOL: + raise ValueError( + "up must not be parallel to the normal: it has no direction " + "in the plane." + ) + axis_0 = _unit(in_plane, "up") + axis_1 = np.cross(unit_normal, axis_0) + return cls( + coordinate_system=world.id, + axes=axis_ids, + origin=tuple(origin.tolist()), + in_plane_axis_0=tuple(axis_0.tolist()), + in_plane_axis_1=tuple(axis_1.tolist()), + **kwargs, + ) + + @classmethod + def from_rotation( + cls, + world: CoordinateSystem, + origin: ArrayLike, + rotation: ArrayLike, + axes: Sequence[AxisRef] | None = None, + **kwargs: Any, + ) -> RenderPlane: + """Build the plane at *origin* whose frame is a rotation. + + Parameters + ---------- + world : CoordinateSystem + The scene's world coordinate system. + origin : ArrayLike + A point on the plane, on *axes*. + rotation : ArrayLike + A 3 x 3 rotation matrix whose columns are ``in_plane_axis_0``, + ``in_plane_axis_1`` and the normal, on *axes*; or a quaternion + ``(x, y, z, w)`` (scalar last) of that rotation, its vector part + on *axes*. + axes : sequence of three AxisRef, or None + The world axes the pose is given on. ``None`` means the + system's axes, which must then be three. + **kwargs : + ``extent_0``, ``extent_1``, ``enabled``, ``id``. + + Returns + ------- + RenderPlane + + Raises + ------ + ValueError + If *rotation* is not a rotation: not orthonormal, or a + reflection. + """ + axis_ids = _resolve_axes(world, axes) + rotation = np.asarray(rotation, dtype=np.float64) + if rotation.shape == (4,): + matrix = _quaternion_to_matrix(rotation) + elif rotation.shape == (3, 3): + matrix = rotation + else: + raise ValueError( + "rotation must be a 3 x 3 matrix or a quaternion (x, y, z, w); " + f"got shape {rotation.shape}." + ) + if not np.allclose(matrix.T @ matrix, np.eye(3), atol=1e-6) or ( + np.linalg.det(matrix) < 0 + ): + raise ValueError( + "rotation must be a rotation matrix: orthonormal columns and " + "determinant +1." + ) + return cls( + coordinate_system=world.id, + axes=axis_ids, + origin=tuple(_as_vector(origin, "origin").tolist()), + in_plane_axis_0=tuple(matrix[:, 0].tolist()), + in_plane_axis_1=tuple(matrix[:, 1].tolist()), + **kwargs, + ) + + +_RENDER_PLANES = TypeAdapter(tuple[RenderPlane, ...]) + + +def validate_render_planes(value: object) -> tuple[RenderPlane, ...]: + """Coerce *value* to a tuple of :class:`RenderPlane`, or raise.""" + return _RENDER_PLANES.validate_python(value) diff --git a/tests/convenience/test_appearance_specs.py b/tests/convenience/test_appearance_specs.py index 8c7c221d..71020067 100644 --- a/tests/convenience/test_appearance_specs.py +++ b/tests/convenience/test_appearance_specs.py @@ -84,11 +84,11 @@ def test_every_multiscale_field_maps_to_a_control(): "render_mode", "iso_threshold", "attenuation", - "settled_lod_bias", + "level_of_detail", ] ), ) - assert kinds(result) == ["image", "settled_lod_bias", "aabb", "loading"] + assert kinds(result) == ["image", "level_of_detail", "aabb", "loading"] assert result.specs[0].values["fields"] == [ "color_map", "clim", @@ -133,15 +133,16 @@ def test_order_is_the_config_maps_order_not_the_callers(): def test_a_field_missing_from_the_visuals_model_is_skipped(): """A multiscale config on an in-memory visual: the model is narrower. - The config class knows ``settled_lod_bias``; the model does not carry it. Both + The config class knows ``level_of_detail``; the model has no + ``settled_lod_bias`` for it to drive. Both halves of the predicate have to hold. """ result = appearance_specs( _in_memory(), - MultiscaleImageControlsConfig(appearance=["clim", "settled_lod_bias"]), + MultiscaleImageControlsConfig(appearance=["clim", "level_of_detail"]), ) assert kinds(result) == ["image", "aabb"] - assert result.skipped == ["settled_lod_bias"] + assert result.skipped == ["level_of_detail"] @pytest.mark.parametrize("appearance", [False, []]) @@ -307,12 +308,12 @@ def test_titles_are_shared_by_both_front_ends(): result = appearance_specs( _multiscale(), MultiscaleImageControlsConfig( - appearance=["color_map", "clim", "render_mode", "settled_lod_bias"] + appearance=["color_map", "clim", "render_mode", "level_of_detail"] ), ) assert [spec.title for spec in result.specs] == [ "Image", - "LOD bias", + "Level of detail", "Bounding box", "Data fetch status", ] diff --git a/tests/convenience/test_cleanup_assumptions.py b/tests/convenience/test_cleanup_assumptions.py index db64a356..9bf7db0e 100644 --- a/tests/convenience/test_cleanup_assumptions.py +++ b/tests/convenience/test_cleanup_assumptions.py @@ -114,7 +114,7 @@ def test_esm_path_is_shared_by_subclasses_and_read_as_source(): """ class _Base(anywidget.AnyWidget): - _esm = _STATIC / "lod_bias.js" + _esm = _STATIC / "level_of_detail.js" value = traitlets.Float(0.0).tag(sync=True) class _FieldA(_Base): @@ -130,7 +130,7 @@ class _FieldB(_Base): # ...and it is read as the file's source text, not as a path string. source = str(_Base._esm) - assert source == (_STATIC / "lod_bias.js").read_text() + assert source == (_STATIC / "level_of_detail.js").read_text() assert "export default" in source # Instances sync identical source but stay distinguishable for CSS scoping. diff --git a/tests/convenience/test_controls_config_validation.py b/tests/convenience/test_controls_config_validation.py index 5e2e306b..8222e848 100644 --- a/tests/convenience/test_controls_config_validation.py +++ b/tests/convenience/test_controls_config_validation.py @@ -29,7 +29,7 @@ ) _IN_MEMORY_FIELDS = ["color_map", "clim", "render_mode", "iso_threshold"] -_MULTISCALE_ONLY = ["attenuation", "settled_lod_bias"] +_MULTISCALE_ONLY = ["attenuation", "level_of_detail"] # --------------------------------------------------------------------------- @@ -62,10 +62,10 @@ def test_a_whole_valid_list_is_accepted(): def test_multiscale_only_fields_are_rejected_on_the_in_memory_config(field): """The likelier mistake (section 9.1): a real name, wrong config. - ``settled_lod_bias`` and ``attenuation`` exist on ``MultiscaleImageAppearance`` - and not on ``InMemoryImageAppearance``. A closed ``Enum`` would not catch - this -- the member exists either way -- which is why the vocabulary is - per config class and not one global set. + ``attenuation`` and the level-of-detail settings exist on + ``MultiscaleImageAppearance`` and not on ``InMemoryImageAppearance``. A + closed ``Enum`` would not catch this -- the member exists either way -- + which is why the vocabulary is per config class and not one global set. """ with pytest.raises(ValueError, match=field): InMemoryImageControlsConfig(appearance=[field]) @@ -73,7 +73,7 @@ def test_multiscale_only_fields_are_rejected_on_the_in_memory_config(field): def test_the_error_names_the_config_class_and_its_valid_set(): with pytest.raises(ValueError) as excinfo: - InMemoryImageControlsConfig(appearance=["settled_lod_bias"]) + InMemoryImageControlsConfig(appearance=["level_of_detail"]) message = str(excinfo.value) assert "InMemoryImageControlsConfig" in message @@ -97,7 +97,7 @@ def test_a_typo_is_rejected_with_the_nearest_match(): [ ("clims", "clim"), ("render_modes", "render_mode"), - ("lodbias", "settled_lod_bias"), + ("level_of_detail"[:-1], "level_of_detail"), ], ) def test_near_misses_suggest_the_right_field(typo, expected): @@ -211,11 +211,11 @@ def test_a_field_valid_for_the_config_but_absent_from_the_model_is_reported(): visual = ImageVisual(name="v0", data_store_id="store") specs, skipped = appearance_specs( - visual, MultiscaleImageControlsConfig(appearance=["clim", "settled_lod_bias"]) + visual, MultiscaleImageControlsConfig(appearance=["clim", "level_of_detail"]) ) assert [spec.kind for spec in specs] == ["image", "aabb"] - assert skipped == ["settled_lod_bias"] + assert skipped == ["level_of_detail"] # --------------------------------------------------------------------------- diff --git a/tests/convenience/test_non_image_controls.py b/tests/convenience/test_non_image_controls.py index e627ace5..ad692223 100644 --- a/tests/convenience/test_non_image_controls.py +++ b/tests/convenience/test_non_image_controls.py @@ -124,7 +124,7 @@ def test_add_labels_multiscale_takes_the_multiscale_config(multiscale_labels_sto from cellier.visuals._labels import MultiscaleLabelsAppearance viewer = Viewer(spatial_axes("z", "y", "x")) - config = MultiscaleLabelsControlsConfig(appearance=["settled_lod_bias"]) + config = MultiscaleLabelsControlsConfig(appearance=["level_of_detail"]) visual = viewer.add_labels_multiscale( multiscale_labels_store, @@ -453,12 +453,12 @@ def test_a_field_from_another_visual_family_is_rejected(kind, field): CONFIGS[kind](appearance=[field]) -def test_lod_bias_is_multiscale_labels_only(): - with pytest.raises(ValueError, match="settled_lod_bias"): - LabelsControlsConfig(appearance=["settled_lod_bias"]) +def test_level_of_detail_is_multiscale_labels_only(): + with pytest.raises(ValueError, match="level_of_detail"): + LabelsControlsConfig(appearance=["level_of_detail"]) assert MultiscaleLabelsControlsConfig( - appearance=["settled_lod_bias"] - ).appearance == ["settled_lod_bias"] + appearance=["level_of_detail"] + ).appearance == ["level_of_detail"] def test_colormap_mode_is_not_in_the_labels_vocabulary(): @@ -510,9 +510,9 @@ def test_composite_default_titles_match_the_shared_vocabulary(): AnywidgetAABBWidget, AnywidgetClippingPlanesControls, AnywidgetImageControls, + AnywidgetLevelOfDetailControls, AnywidgetLoadingConfigControls, AnywidgetLoadingIndicator, - AnywidgetLodBiasSlider, AnywidgetLodConfigControls, AnywidgetMeshSectionControls, AnywidgetTrailControls, @@ -528,9 +528,9 @@ def test_composite_default_titles_match_the_shared_vocabulary(): QtAABBWidget, QtClippingPlanesControls, QtImageControls, + QtLevelOfDetailControls, QtLoadingConfigControls, QtLoadingIndicator, - QtLodBiasSlider, QtLodConfigControls, QtMeshSectionControls, QtTrailControls, @@ -538,7 +538,7 @@ def test_composite_default_titles_match_the_shared_vocabulary(): composites = { "image": (QtImageControls, AnywidgetImageControls), - "settled_lod_bias": (QtLodBiasSlider, AnywidgetLodBiasSlider), + "level_of_detail": (QtLevelOfDetailControls, AnywidgetLevelOfDetailControls), "aabb": (QtAABBWidget, AnywidgetAABBWidget), "trail": (QtTrailControls, AnywidgetTrailControls), "loading": (QtLoadingIndicator, AnywidgetLoadingIndicator), @@ -577,7 +577,7 @@ def test_every_valid_field_name_has_a_widget(): from cellier.convenience.gui import _controls_config from cellier.gui._appearance_fields import APPEARANCE_FIELD_WIDGETS - bespoke = {"image", "settled_lod_bias"} + bespoke = {"image", "level_of_detail"} config_classes = [ value for value in vars(_controls_config).values() diff --git a/tests/convenience/test_ortho_appearance_controls.py b/tests/convenience/test_ortho_appearance_controls.py index da07901a..a9a21ffd 100644 --- a/tests/convenience/test_ortho_appearance_controls.py +++ b/tests/convenience/test_ortho_appearance_controls.py @@ -101,7 +101,7 @@ def test_add_image_multiscale_records_the_config_and_the_group(multiscale_image_ visuals = ortho.add_image_multiscale( multiscale_image_store, appearance=MultiscaleImageAppearance(), - controls=MultiscaleImageControlsConfig(appearance=["settled_lod_bias"]), + controls=MultiscaleImageControlsConfig(appearance=["level_of_detail"]), single=MultiscaleImageSingleAppearance(color_map="viridis"), render_config=MultiscaleImageRenderConfig(**SMALL_BUDGETS), ) @@ -476,10 +476,10 @@ def test_the_renderer_warns_about_a_field_the_model_does_not_have(qtbot): _store(), appearance=_appearance(), # Valid for the multiscale config class; absent from the in-memory model. - controls=MultiscaleImageControlsConfig(appearance=["clim", "settled_lod_bias"]), + controls=MultiscaleImageControlsConfig(appearance=["clim", "level_of_detail"]), ) - with pytest.warns(UserWarning, match="settled_lod_bias"): + with pytest.warns(UserWarning, match="level_of_detail"): container = render_dock(AppearanceControls(), ortho, QtLayoutHost(), []) assert container is not None diff --git a/tests/convenience/test_qt_appearance_acceptance.py b/tests/convenience/test_qt_appearance_acceptance.py index d9f31e3f..60b5e7e5 100644 --- a/tests/convenience/test_qt_appearance_acceptance.py +++ b/tests/convenience/test_qt_appearance_acceptance.py @@ -45,14 +45,14 @@ "render_mode", "iso_threshold", "attenuation", - "settled_lod_bias", + "level_of_detail", ] def test_multiscale_panel_control_names_in_order(qtbot, multiscale_image_store): """Every field requested, so this is the maximal panel Qt builds today. - ``settled_lod_bias`` and the three render fields are multiscale-only; the render + ``level_of_detail`` and the three render fields are multiscale-only; the render trio collapses into the single "Render mode" group. The bounding box is not requested by name -- ``aabb`` is on ``BaseVisual`` with a default factory, so every configured panel gets it, on both toolkits. @@ -67,9 +67,11 @@ def test_multiscale_panel_control_names_in_order(qtbot, multiscale_image_store): container = render_dock(AppearanceControls(), viewer, QtLayoutHost(), []) + # "Level of detail" is a group; the walk reports its labelled row too. assert control_labels(container) == [ "Image", - "LOD bias", + "Level of detail", + "Settled bias", "Bounding box", "Data fetch status", ] diff --git a/tests/convenience/test_qt_renderer.py b/tests/convenience/test_qt_renderer.py index dcf62dad..c46f2063 100644 --- a/tests/convenience/test_qt_renderer.py +++ b/tests/convenience/test_qt_renderer.py @@ -105,7 +105,7 @@ def test_appearance_controls_multiscale_render_and_lod(qtbot, multiscale_image_s multiscale_image_store, appearance=MultiscaleImageAppearance(), controls=MultiscaleImageControlsConfig( - appearance=["render_mode", "settled_lod_bias"] + appearance=["render_mode", "level_of_detail"] ), single=MultiscaleImageSingleAppearance(color_map="viridis", render_mode="mip"), ) @@ -113,7 +113,7 @@ def test_appearance_controls_multiscale_render_and_lod(qtbot, multiscale_image_s container = render_dock(AppearanceControls(), viewer, QtLayoutHost(), []) assert container is not None - assert {"Image", "LOD bias"} <= _control_names(container) + assert {"Image", "Level of detail"} <= _control_names(container) def test_appearance_controls_placeholder_without_configs(qtbot, image_store): diff --git a/tests/gui/anywidget/test_level_of_detail.py b/tests/gui/anywidget/test_level_of_detail.py new file mode 100644 index 00000000..49b1309b --- /dev/null +++ b/tests/gui/anywidget/test_level_of_detail.py @@ -0,0 +1,145 @@ +"""Tests for the anywidget "Level of detail" control (plane rendering 9.3).""" + +from __future__ import annotations + +from pathlib import Path +from uuid import uuid4 + +import pytest + +pytest.importorskip("anywidget") + +from cellier.events import AppearanceChangedEvent +from cellier.gui._level_of_detail import ( + COARSEST_LABEL, + LEVEL_OF_DETAIL_FIELDS, + SETTLED_BIAS_LABEL, + level_of_detail_values, + moving_field, +) +from cellier.gui.anywidget.visuals._level_of_detail import ( + AnywidgetLevelOfDetailControls, +) +from cellier.visuals import MultiscaleImageAppearance, MultiscaleLabelsAppearance + + +def _make(values=None, **kwargs): + visual_id = uuid4() + return AnywidgetLevelOfDetailControls(visual_id, values, **kwargs), visual_id + + +def _changed(widget, visual_id, field, value, source_id=None): + widget._on_appearance_changed( + AppearanceChangedEvent( + source_id=source_id or uuid4(), + visual_id=visual_id, + field_name=field, + new_value=value, + requires_reslice=field == "settled_lod_bias", + ) + ) + + +def test_instantiate_smoke(): + widget, _ = _make( + { + "settled_lod_bias": 2.5, + "coarsest_while_moving_3d": False, + "coarsest_while_moving_2d": True, + }, + n_displayed_dimensions=2, + ) + assert widget.settled_lod_bias == pytest.approx(2.5) + assert widget.coarsest_while_moving_3d is False + assert widget.coarsest_while_moving_2d is True + assert widget.moving_field == "coarsest_while_moving_2d" + assert widget.title == "Level of detail" + assert widget.labels == {"bias": SETTLED_BIAS_LABEL, "moving": COARSEST_LABEL} + + +@pytest.mark.parametrize( + "appearance_cls", [MultiscaleImageAppearance, MultiscaleLabelsAppearance] +) +def test_the_trait_defaults_are_the_model_s(appearance_cls): + widget, _ = _make() + for field, value in level_of_detail_values(appearance_cls()).items(): + assert getattr(widget, field) == value + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("settled_lod_bias", 3.5), + ("coarsest_while_moving_3d", False), + ("coarsest_while_moving_2d", True), + ], +) +def test_a_trait_edit_emits_one_update_event(field, value): + widget, visual_id = _make() + emitted = [] + widget.changed.connect(emitted.append) + setattr(widget, field, value) + assert len(emitted) == 1 + event = emitted[0] + assert event.source_id == widget._id + assert event.visual_id == visual_id + assert event.field == field + assert event.value == value + assert type(event.value) is type(value) + + +@pytest.mark.parametrize("field", LEVEL_OF_DETAIL_FIELDS) +def test_an_inbound_change_updates_the_trait_without_reemitting(field): + widget, visual_id = _make() + emitted = [] + widget.changed.connect(emitted.append) + value = 4.0 if field == "settled_lod_bias" else not getattr(widget, field) + _changed(widget, visual_id, field, value) + assert getattr(widget, field) == value + assert emitted == [] + + +def test_the_echo_of_its_own_write_is_ignored(): + widget, visual_id = _make() + _changed(widget, visual_id, "settled_lod_bias", 9.0, source_id=widget._id) + _changed(widget, visual_id, "coarsest_while_moving_3d", False, widget._id) + assert widget.settled_lod_bias == pytest.approx(1.0) + assert widget.coarsest_while_moving_3d is True + + +def test_an_unrelated_field_is_ignored(): + widget, visual_id = _make() + _changed(widget, visual_id, "force_level", 2) + assert widget.settled_lod_bias == pytest.approx(1.0) + + +def test_following_the_view_is_not_an_edit(): + widget, _ = _make() + emitted = [] + widget.changed.connect(emitted.append) + assert widget.moving_field == moving_field(3) + widget.n_displayed_dimensions = 2 + assert widget.moving_field == moving_field(2) + assert emitted == [] + with pytest.raises(Exception, match="2 or 3"): + widget.n_displayed_dimensions = 4 + + +def test_one_dims_subscription_per_followed_scene(): + scenes = (uuid4(), uuid4()) + widget, visual_id = _make(scene_ids=scenes) + specs = widget.subscription_specs() + assert [spec.entity_id for spec in specs] == [visual_id, *scenes] + + +def test_the_front_end_binds_the_checkbox_to_the_view_s_setting(): + """The JS names both fields and picks by ``n_displayed_dimensions``.""" + import cellier.gui.anywidget.visuals as visuals + + source = ( + Path(visuals.__file__).parent / "static" / "level_of_detail.js" + ).read_text() + for name in (*LEVEL_OF_DETAIL_FIELDS, "n_displayed_dimensions"): + assert name in source + # The bias is sent when the slider settles, not while it is dragged. + assert 'inp.addEventListener("change"' in source diff --git a/tests/gui/anywidget/test_lod_bias.py b/tests/gui/anywidget/test_lod_bias.py deleted file mode 100644 index cad0c8f9..00000000 --- a/tests/gui/anywidget/test_lod_bias.py +++ /dev/null @@ -1,88 +0,0 @@ -"""Tests for the anywidget ``AnywidgetLodBiasSlider`` LOD-bias-control widget.""" - -from __future__ import annotations - -from uuid import uuid4 - -import pytest - -pytest.importorskip("anywidget") - -from cellier.events import AppearanceChangedEvent -from cellier.gui.anywidget.visuals._lod_bias import AnywidgetLodBiasSlider - - -def _make_widget(**kwargs): - visual_id = uuid4() - return AnywidgetLodBiasSlider(visual_id, **kwargs), visual_id - - -def test_instantiate_smoke(): - widget, _visual_id = _make_widget(initial_lod_bias=2.5) - assert widget.settled_lod_bias == pytest.approx(2.5) - - -def test_trait_edit_emits_update_event(): - widget, visual_id = _make_widget() - emitted = [] - widget.changed.connect(emitted.append) - - widget.settled_lod_bias = 3.5 - - assert len(emitted) == 1 - event = emitted[0] - assert event.source_id == widget._id - assert event.visual_id == visual_id - assert event.field == "settled_lod_bias" - assert event.value == pytest.approx(3.5) - - -def test_inbound_change_updates_trait_without_reemit(): - widget, visual_id = _make_widget() - emitted = [] - widget.changed.connect(emitted.append) - - widget._on_appearance_changed( - AppearanceChangedEvent( - source_id=uuid4(), # not the widget -> applied - visual_id=visual_id, - field_name="settled_lod_bias", - new_value=4.0, - requires_reslice=True, - ) - ) - - assert widget.settled_lod_bias == pytest.approx(4.0) - assert emitted == [] # applied under _applying, no echo - - -def test_inbound_echo_filtered_by_source_id(): - widget, visual_id = _make_widget(initial_lod_bias=1.0) - - widget._on_appearance_changed( - AppearanceChangedEvent( - source_id=widget._id, # our own echo -> ignored - visual_id=visual_id, - field_name="settled_lod_bias", - new_value=9.0, - requires_reslice=True, - ) - ) - - assert widget.settled_lod_bias == pytest.approx(1.0) # unchanged - - -def test_inbound_unrelated_field_ignored(): - widget, visual_id = _make_widget(initial_lod_bias=1.0) - - widget._on_appearance_changed( - AppearanceChangedEvent( - source_id=uuid4(), - visual_id=visual_id, - field_name="clim", - new_value=(0.0, 500.0), - requires_reslice=True, - ) - ) - - assert widget.settled_lod_bias == pytest.approx(1.0) # unchanged diff --git a/tests/gui/qt/test_qt_level_of_detail.py b/tests/gui/qt/test_qt_level_of_detail.py new file mode 100644 index 00000000..2650229f --- /dev/null +++ b/tests/gui/qt/test_qt_level_of_detail.py @@ -0,0 +1,179 @@ +"""Tests for the Qt "Level of detail" control (plane rendering design 9.3).""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("qtpy") +pytest.importorskip("superqt") + +from cellier.controller import CellierController +from cellier.data.image._zarr_multiscale_store import MultiscaleZarrDataStore +from cellier.events import AppearanceChangedEvent +from cellier.gui._image_controls import display_seed +from cellier.gui._level_of_detail import level_of_detail_values +from cellier.gui.qt.visuals._level_of_detail import QtLevelOfDetailControls +from cellier.scene.dims import spatial_axes, world_coordinate_system +from cellier.visuals import MultiscaleImageAppearance +from tests._v2 import pyramid_levels + + +def _controller_with_visual(small_zarr_store, dim="3d", **appearance): + controller = CellierController() + cs = world_coordinate_system(spatial_axes("z", "y", "x"), name="world") + scene = controller.add_scene(dim=dim, coordinate_system=cs, name="main") + store = MultiscaleZarrDataStore( + zarr_path=str(small_zarr_store), + scale_names=["s0", "s1"], + **pyramid_levels( + [[1.0, 1.0, 1.0], [2.0, 2.0, 2.0]], + [[0.0, 0.0, 0.0], [0.5, 0.5, 0.5]], + ), + ) + visual = controller.add_image_multiscale( + data=store, + scene_id=scene.id, + appearance=MultiscaleImageAppearance(**appearance), + name="vol", + ) + return controller, scene, visual + + +def _control(qtbot, controller, visual): + scene_ids, n_displayed = display_seed(controller, [visual.id]) + control = QtLevelOfDetailControls( + visual.id, + level_of_detail_values(visual.appearance), + n_displayed_dimensions=n_displayed, + scene_ids=scene_ids, + ) + qtbot.addWidget(control.widget) + controller.connect_widget(control, subscription_specs=control.subscription_specs()) + return control + + +def test_instantiate_smoke(qtbot): + control = QtLevelOfDetailControls( + object(), + { + "settled_lod_bias": 2.0, + "coarsest_while_moving_3d": False, + "coarsest_while_moving_2d": True, + }, + ) + qtbot.addWidget(control.widget) + assert control._slider.value() == pytest.approx(2.0) + assert control.moving_field == "coarsest_while_moving_3d" + assert control._moving_check.isChecked() is False + assert control.widget.title() == "Level of detail" + + +def test_defaults_follow_the_model(qtbot): + control = QtLevelOfDetailControls(object()) + qtbot.addWidget(control.widget) + defaults = level_of_detail_values(MultiscaleImageAppearance()) + assert control._slider.value() == pytest.approx(defaults["settled_lod_bias"]) + assert control._moving == { + key: value for key, value in defaults.items() if key != "settled_lod_bias" + } + + +def test_slider_release_reaches_the_model(qtbot, small_zarr_store): + controller, _scene, visual = _controller_with_visual(small_zarr_store) + control = _control(qtbot, controller, visual) + control._slider.setValue(3.5) + assert visual.appearance.settled_lod_bias == pytest.approx(1.0) # not yet + control._on_slider_released() + assert visual.appearance.settled_lod_bias == pytest.approx(3.5) + + +def test_the_checkbox_edits_the_3d_setting_in_a_3d_view(qtbot, small_zarr_store): + controller, _scene, visual = _controller_with_visual(small_zarr_store) + control = _control(qtbot, controller, visual) + assert control._moving_check.isChecked() is True + control._moving_check.setChecked(False) + assert visual.appearance.coarsest_while_moving_3d is False + assert visual.appearance.coarsest_while_moving_2d is False # untouched + + +def test_the_checkbox_edits_the_2d_setting_in_a_2d_view(qtbot, small_zarr_store): + controller, _scene, visual = _controller_with_visual(small_zarr_store, dim="2d") + control = _control(qtbot, controller, visual) + assert control.moving_field == "coarsest_while_moving_2d" + assert control._moving_check.isChecked() is False + control._moving_check.setChecked(True) + assert visual.appearance.coarsest_while_moving_2d is True + assert visual.appearance.coarsest_while_moving_3d is True # its default + + +def test_the_checkbox_follows_the_view_between_2d_and_3d(qtbot, small_zarr_store): + controller, scene, visual = _controller_with_visual(small_zarr_store) + control = _control(qtbot, controller, visual) + emitted = [] + control.changed.connect(emitted.append) + assert control._moving_check.isChecked() is True + + controller.set_displayed_axes(scene.id, (1, 2)) + assert control.n_displayed_dimensions == 2 + assert control._moving_check.isChecked() is False + controller.set_displayed_axes(scene.id, (0, 1, 2)) + assert control._moving_check.isChecked() is True + # Showing another view's setting is not an edit. + assert emitted == [] + assert visual.appearance.coarsest_while_moving_3d is True + assert visual.appearance.coarsest_while_moving_2d is False + + +def test_a_model_change_updates_the_control_without_reemitting(qtbot, small_zarr_store): + controller, _scene, visual = _controller_with_visual(small_zarr_store) + control = _control(qtbot, controller, visual) + emitted = [] + control.changed.connect(emitted.append) + + controller.update_appearance_field(visual.id, "settled_lod_bias", 4.0) + controller.update_appearance_field(visual.id, "coarsest_while_moving_3d", False) + assert control._slider.value() == pytest.approx(4.0) + assert control._moving_check.isChecked() is False + assert emitted == [] + + # The other view's setting is remembered but not shown. + controller.update_appearance_field(visual.id, "coarsest_while_moving_2d", True) + assert control._moving_check.isChecked() is False + control.n_displayed_dimensions = 2 + assert control._moving_check.isChecked() is True + assert emitted == [] + + +def test_the_echo_of_its_own_write_is_ignored(qtbot): + control = QtLevelOfDetailControls(object(), {"settled_lod_bias": 1.0}) + qtbot.addWidget(control.widget) + for field, value in ( + ("settled_lod_bias", 9.0), + ("coarsest_while_moving_3d", False), + ): + control._on_appearance_changed( + AppearanceChangedEvent( + source_id=control._id, + visual_id=control.visual_ids[0], + field_name=field, + new_value=value, + requires_reslice=True, + ) + ) + assert control._slider.value() == pytest.approx(1.0) + assert control._moving_check.isChecked() is True + + +def test_an_unrelated_field_is_ignored(qtbot, small_zarr_store): + controller, _scene, visual = _controller_with_visual(small_zarr_store) + control = _control(qtbot, controller, visual) + controller.update_appearance_field(visual.id, "interpolation", "linear") + assert control._slider.value() == pytest.approx(1.0) + assert control._moving_check.isChecked() is True + + +def test_n_displayed_dimensions_is_validated(qtbot): + control = QtLevelOfDetailControls(object()) + qtbot.addWidget(control.widget) + with pytest.raises(ValueError, match="2 or 3"): + control.n_displayed_dimensions = 4 diff --git a/tests/gui/qt/test_qt_lod_bias.py b/tests/gui/qt/test_qt_lod_bias.py deleted file mode 100644 index 7dbbdcad..00000000 --- a/tests/gui/qt/test_qt_lod_bias.py +++ /dev/null @@ -1,117 +0,0 @@ -"""Tests for the Qt ``QtLodBiasSlider`` LOD-bias-control widget.""" - -from __future__ import annotations - -import pytest - -pytest.importorskip("qtpy") -pytest.importorskip("superqt") - -from cellier.controller import CellierController -from cellier.data.image._zarr_multiscale_store import ( - MultiscaleZarrDataStore, -) -from cellier.gui.qt.visuals._lod_bias import QtLodBiasSlider -from cellier.scene.dims import spatial_axes, world_coordinate_system -from cellier.visuals import MultiscaleImageAppearance -from tests._v2 import pyramid_levels - - -def _make_store(small_zarr_store, **kwargs) -> MultiscaleZarrDataStore: - defaults = { - "zarr_path": str(small_zarr_store), - "scale_names": ["s0", "s1"], - **pyramid_levels( - [[1.0, 1.0, 1.0], [2.0, 2.0, 2.0]], - [[0.0, 0.0, 0.0], [0.5, 0.5, 0.5]], - ), - } - defaults.update(kwargs) - return MultiscaleZarrDataStore(**defaults) - - -def _make_controller_with_visual(small_zarr_store, initial_lod_bias=1.0): - controller = CellierController() - cs = world_coordinate_system(spatial_axes("z", "y", "x"), name="world") - scene = controller.add_scene(dim="3d", coordinate_system=cs, name="main") - store = _make_store(small_zarr_store) - appearance = MultiscaleImageAppearance(settled_lod_bias=initial_lod_bias) - visual = controller.add_image_multiscale( - data=store, scene_id=scene.id, appearance=appearance, name="vol" - ) - return controller, visual - - -def test_instantiate_smoke(qtbot): - slider = QtLodBiasSlider(visual_id=object(), initial_lod_bias=2.0) - qtbot.addWidget(slider.widget) - assert slider._slider.value() == pytest.approx(2.0) - - -def test_slider_release_reaches_model(qtbot, small_zarr_store): - controller, visual = _make_controller_with_visual(small_zarr_store) - slider = QtLodBiasSlider(visual_id=visual.id) - qtbot.addWidget(slider.widget) - controller.connect_widget(slider, subscription_specs=slider.subscription_specs()) - - slider._slider.setValue(3.5) - slider._on_slider_released() # simulate the user releasing the handle - - assert visual.appearance.settled_lod_bias == pytest.approx(3.5) - - -def test_model_push_updates_slider_without_reemit(qtbot, small_zarr_store): - controller, visual = _make_controller_with_visual(small_zarr_store) - slider = QtLodBiasSlider(visual_id=visual.id) - qtbot.addWidget(slider.widget) - controller.connect_widget(slider, subscription_specs=slider.subscription_specs()) - - emitted = [] - slider.changed.connect(emitted.append) - - controller.update_appearance_field(visual.id, "settled_lod_bias", 4.0) - - assert slider._slider.value() == pytest.approx(4.0) - assert emitted == [] - - -def test_inbound_echo_filtered_by_source_id(qtbot, small_zarr_store): - """A widget must ignore the bus echo of its own write. - - The anywidget twin has always covered this; the Qt module did not, which - is the kind of one-sided gap ``plans/gui_backend_unification.md`` D14 is - about -- the two front ends implement the same ``source_id`` filter, so - testing it on one side only leaves the other free to lose it. - - Driven through the handler rather than through the controller because the - controller stamps its own ``source_id``; the case under test is - specifically an event carrying *this widget's* id. - """ - from cellier.events import AppearanceChangedEvent - - _controller, visual = _make_controller_with_visual(small_zarr_store) - slider = QtLodBiasSlider(visual_id=visual.id, initial_lod_bias=1.0) - qtbot.addWidget(slider.widget) - - slider._on_visual_changed( - AppearanceChangedEvent( - source_id=slider._id, # our own echo -> ignored - visual_id=visual.id, - field_name="settled_lod_bias", - new_value=9.0, - requires_reslice=True, - ) - ) - - assert slider._slider.value() == pytest.approx(1.0) # unchanged - - -def test_unrelated_field_change_ignored(qtbot, small_zarr_store): - controller, visual = _make_controller_with_visual(small_zarr_store) - slider = QtLodBiasSlider(visual_id=visual.id, initial_lod_bias=1.0) - qtbot.addWidget(slider.widget) - controller.connect_widget(slider, subscription_specs=slider.subscription_specs()) - - controller.update_appearance_field(visual.id, "interpolation", "linear") - - assert slider._slider.value() == pytest.approx(1.0) diff --git a/tests/gui/test_image_control_rows.py b/tests/gui/test_image_control_rows.py index 05adc483..f8486c12 100644 --- a/tests/gui/test_image_control_rows.py +++ b/tests/gui/test_image_control_rows.py @@ -214,17 +214,25 @@ def test_qt_the_setter_validates(qt_controls): qt_controls(uuid4(), _values(_multiscale()), n_displayed_dimensions=1) -def test_qt_channel_rows_follow_their_own_mode(qt_controls): - widget = qt_controls(uuid4(), _values(_multiscale(channel_modes=["iso", "mip"]))) +def test_qt_channel_rows_follow_the_channels_mode(qt_controls): + """Every channel has the same render mode, so their rows agree.""" + widget = qt_controls(uuid4(), _values(_multiscale(channel_modes=["iso", "iso"]))) assert _shown(widget, "channel", 0, "iso_threshold") - assert not _shown(widget, "channel", 1, "iso_threshold") - # One attenuation row below the channels, shown when any channel uses it. + assert _shown(widget, "channel", 1, "iso_threshold") + # One attenuation row below the channels, shown when the channels use it. assert not _shown(widget, "composite", None, "attenuation") + # Not wired to a controller, so only the edited row is told here; wired, + # the controller sets the mode on every channel (the tests below). widget._controls[("channel", 1, "render_mode")].setCurrentText("attenuated_mip") assert _shown(widget, "composite", None, "attenuation") - assert _shown(widget, "channel", 0, "iso_threshold") + assert not _shown(widget, "channel", 1, "iso_threshold") + + +def test_channels_with_different_render_modes_are_refused(): + with pytest.raises(ValueError, match="different render modes"): + _multiscale(channel_modes=["iso", "mip"]) def test_qt_both_attenuation_rows_show_one_value(qt_controls): @@ -274,9 +282,43 @@ def test_qt_an_inbound_render_mode_updates_the_rows(qt_controls): controller.update_single_appearance_field(visual.id, "render_mode", "iso") assert _shown(widget, "single", None, "iso_threshold") + # A channel's render mode is every channel's. controller.update_channel_appearance_field(visual.id, 1, "render_mode", "iso") assert _shown(widget, "channel", 1, "iso_threshold") - assert not _shown(widget, "channel", 0, "iso_threshold") + assert _shown(widget, "channel", 0, "iso_threshold") + + +def test_qt_one_channels_combo_sets_every_channel(qt_controls): + controller, scene, visual = _in_memory() + values = image_control_values(visual, fields=_FIELDS) + widget = _wired(controller, qt_controls(visual.id, values, scene_ids=[scene.id])) + + widget._controls[("channel", 1, "render_mode")].setCurrentText("iso") + + assert [visual.channels[k].render_mode for k in (0, 1)] == ["iso", "iso"] + # The other channel's combo and rows followed, with no echo loop. + assert widget._controls[("channel", 0, "render_mode")].currentText() == "iso" + assert _shown(widget, "channel", 0, "iso_threshold") + assert _shown(widget, "channel", 1, "iso_threshold") + # The single page is separate. + assert visual.single.render_mode == "mip" + assert widget._controls[("single", None, "render_mode")].currentText() == "mip" + + +def test_anywidget_one_channels_mode_sets_every_channel(any_controls): + controller, scene, visual = _in_memory() + values = image_control_values(visual, fields=_FIELDS) + widget = _wired(controller, any_controls(visual.id, values, scene_ids=[scene.id])) + + # What the front end does when a channel's select changes. + channels = {key: dict(fields) for key, fields in widget.channels.items()} + channels["1"]["render_mode"] = "iso" + widget.channels = channels + + assert [visual.channels[k].render_mode for k in (0, 1)] == ["iso", "iso"] + assert widget.channels["0"]["render_mode"] == "iso" + assert widget.channels["1"]["render_mode"] == "iso" + assert widget.single["render_mode"] == "mip" def test_qt_follows_a_2d_3d_switch_on_its_scene(qt_controls): diff --git a/tests/gui/test_visual_id_groups.py b/tests/gui/test_visual_id_groups.py index 40a16efd..d965ee37 100644 --- a/tests/gui/test_visual_id_groups.py +++ b/tests/gui/test_visual_id_groups.py @@ -48,10 +48,14 @@ def _qt_widgets(visual_id): Returns ``(name, widget, edit, expected_field)`` where ``edit()`` performs a single user-level change. """ - from cellier.gui.qt.visuals import QtAABBWidget, QtImageControls, QtLodBiasSlider + from cellier.gui.qt.visuals import ( + QtAABBWidget, + QtImageControls, + QtLevelOfDetailControls, + ) image = QtImageControls(visual_id, _image_values()) - lod = QtLodBiasSlider(visual_id, initial_lod_bias=1.0) + lod = QtLevelOfDetailControls(visual_id) aabb = QtAABBWidget(visual_id) return [ @@ -62,11 +66,17 @@ def _qt_widgets(visual_id): "clim", ), ( - "settled_lod_bias", + "level_of_detail", lod, lambda: (lod._slider.setValue(2.5), lod._on_slider_released()), "settled_lod_bias", ), + ( + "level_of_detail moving", + lod, + lambda: lod._moving_check.setChecked(False), + "coarsest_while_moving_3d", + ), ("aabb", aabb, lambda: aabb._enabled_check.setChecked(True), "enabled"), ] @@ -109,10 +119,10 @@ def test_qt_widgets_subscribe_to_every_visual(qtbot, n_ids): def test_qt_a_single_uuid_still_works_unchanged(qtbot): """The compatible signature: one id in, one event out, one subscription.""" - from cellier.gui.qt.visuals import QtLodBiasSlider + from cellier.gui.qt.visuals import QtLevelOfDetailControls visual_id = uuid4() - widget = QtLodBiasSlider(visual_id, initial_lod_bias=1.0) + widget = QtLevelOfDetailControls(visual_id) assert widget.visual_ids == (visual_id,) assert widget._visual_id == visual_id @@ -146,11 +156,11 @@ def _anywidget_widgets(visual_id): from cellier.gui.anywidget.visuals import ( AnywidgetAABBWidget, AnywidgetImageControls, - AnywidgetLodBiasSlider, + AnywidgetLevelOfDetailControls, ) image = AnywidgetImageControls(visual_id, _image_values()) - lod = AnywidgetLodBiasSlider(visual_id, initial_lod_bias=1.0) + lod = AnywidgetLevelOfDetailControls(visual_id) aabb = AnywidgetAABBWidget(visual_id) def _set(widget, name, value): @@ -204,10 +214,10 @@ def test_anywidget_widgets_subscribe_to_every_visual(n_ids): def test_anywidget_a_single_uuid_still_works_unchanged(): - from cellier.gui.anywidget.visuals import AnywidgetLodBiasSlider + from cellier.gui.anywidget.visuals import AnywidgetLevelOfDetailControls visual_id = uuid4() - widget = AnywidgetLodBiasSlider(visual_id, initial_lod_bias=1.0) + widget = AnywidgetLevelOfDetailControls(visual_id) assert widget.visual_ids == (visual_id,) assert widget._visual_id == visual_id @@ -221,20 +231,20 @@ def test_anywidget_a_single_uuid_still_works_unchanged(): def test_an_empty_sequence_is_rejected(): """A widget driving nothing would silently do nothing on every edit.""" - from cellier.gui.anywidget.visuals import AnywidgetLodBiasSlider + from cellier.gui.anywidget.visuals import AnywidgetLevelOfDetailControls with pytest.raises(ValueError, match="must not be empty"): - AnywidgetLodBiasSlider([], initial_lod_bias=1.0) + AnywidgetLevelOfDetailControls([]) def test_the_event_type_is_unchanged_by_the_group_form(): """Fanning out changes how many events, never which kind.""" from cellier.gui.anywidget.visuals import ( AnywidgetAABBWidget, - AnywidgetLodBiasSlider, + AnywidgetLevelOfDetailControls, ) - clim = AnywidgetLodBiasSlider(_ids(3), initial_lod_bias=1.0) + clim = AnywidgetLevelOfDetailControls(_ids(3)) aabb = AnywidgetAABBWidget(_ids(3)) clim_events: list = [] aabb_events: list = [] diff --git a/tests/render/scheduling/test_dims_drag.py b/tests/render/scheduling/test_dims_drag.py index ad8f4968..2bb78dd4 100644 --- a/tests/render/scheduling/test_dims_drag.py +++ b/tests/render/scheduling/test_dims_drag.py @@ -32,8 +32,8 @@ #: Appearance fields: plan the backstop only while moving, in 2D and 3D. DRAG = {"coarsest_while_moving_2d": True, "coarsest_while_moving_3d": True} -#: The defaults of this phase: plan in full on every tick. -EAGER: dict = {} +#: Plan in full on every tick, in 2D (the default) and 3D. +EAGER = {"coarsest_while_moving_2d": False, "coarsest_while_moving_3d": False} def _tzyx_store(tmp_path): @@ -145,10 +145,10 @@ def _timers(controller) -> list: @pytest.mark.parametrize( "appearance_cls", [MultiscaleImageAppearance, MultiscaleLabelsAppearance] ) -def test_eager_is_the_default(appearance_cls) -> None: +def test_the_defaults_are_eager_in_2d_and_coarsest_in_3d(appearance_cls) -> None: appearance = appearance_cls() assert appearance.coarsest_while_moving_2d is False - assert appearance.coarsest_while_moving_3d is False + assert appearance.coarsest_while_moving_3d is True @pytest.mark.parametrize("field", ["dims_drag", "backstop"]) diff --git a/tests/render/test_clipping_interaction.py b/tests/render/test_clipping_interaction.py index 77c17d9b..df1f6d67 100644 --- a/tests/render/test_clipping_interaction.py +++ b/tests/render/test_clipping_interaction.py @@ -1,7 +1,7 @@ """The clipping interaction tracker: scopes, ticks and the start / end event. -A drag is announced and nothing else: no plan mode depends on it (clipping -plane gizmo design, section 6). +A drag is announced (clipping plane gizmo design, section 6). What it does +to planning is in ``test_moving_policy.py`` (plane rendering design 7). """ from __future__ import annotations @@ -128,7 +128,9 @@ def test_with_no_event_loop_each_change_settles(controller): assert _interactions(log) == [("start", None), ("end", "settle")] * 2 -async def test_a_drag_changes_no_planning(controller, reslice, tmp_path, monkeypatch): +async def test_a_drag_holds_a_multiscale_volume_s_plan_until_it_ends( + controller, reslice, tmp_path, monkeypatch +): scene = controller.add_scene(dim="3d", name="ms") visual, store = h.add_visual( "image_multiscale_mip", controller, scene.id, h.image_data(), tmp_path, "ms" @@ -146,13 +148,14 @@ def _recording(*args, **kwargs): monkeypatch.setattr(controller, "_render_config_for", _recording) visual.clipping_planes = h.clipping_planes(store, [((0, 0, 16.5), (0, 0, 1))]) outside = list(modes) - assert outside # the change planned + assert outside # a jump plans at once modes.clear() item = visual.clipping_planes[0] moved = h.clipping_planes(store, [((0, 0, 20.5), (0, 0, 1))])[0].plane with controller.plane_interaction(visual.id): controller.set_clipping_plane(visual.id, item.id, moved) - assert modes == outside + assert modes == [] # held: coarsest_while_moving_3d is on by default + assert modes == outside # the release plans, in full await drain_loading(controller) diff --git a/tests/render/test_moving_policy.py b/tests/render/test_moving_policy.py new file mode 100644 index 00000000..ff793268 --- /dev/null +++ b/tests/render/test_moving_policy.py @@ -0,0 +1,543 @@ +"""The moving policy: what a multiscale visual plans while it moves. + +Plane rendering design v3, section 7. A visual moves while its planes are +dragged (a ``plane_interaction`` scope) or its scene's dims are scrubbed. +With ``coarsest_while_moving_3d`` on (the 3D default) a plane drag plans +nothing and keeps the last plan, a dims tick plans the backstop, and the +target is planned once every motion has stopped. + +The plans are recorded where every reslice path meets +(``SceneManager.plan_chunked``); what a plan wants is read from the +scheduler's registry. Counts only, no clocks beyond the settle time. +""" + +from __future__ import annotations + +import asyncio +from uuid import uuid4 + +import pytest + +from cellier.render.scheduling import ChunkClass, Tier +from cellier.scene import spatial_axes +from cellier.visuals import ( + ClippingPlane, + MultiscaleImageAppearance, + MultiscaleImageRenderConfig, + MultiscaleImageSingleAppearance, + MultiscaleLabelRenderConfig, + MultiscaleLabelsAppearance, +) +from tests import _plane_fixtures as fx +from tests._gpu_budget import SMALL_BUDGETS +from tests.render.conftest import drain_loading + +EAGER = {"coarsest_while_moving_3d": False} +SETTLE_S = 0.03 + + +def _record_plans(monkeypatch) -> list[tuple]: + """Record ``(visual_id, plan mode name)`` for every visual a pass plans.""" + from cellier.render.scene_manager import SceneManager + + plans: list[tuple] = [] + original = SceneManager.plan_chunked + + def spy(self, request, visual_configs): + for visual_id, cfg in visual_configs.items(): + targets = request.target_visual_ids + if targets is None or visual_id in targets: + plans.append((visual_id, cfg.plan_mode.name)) + return original(self, request, visual_configs) + + monkeypatch.setattr(SceneManager, "plan_chunked", spy) + return plans + + +def _modes(plans: list[tuple], visual_id) -> list[str]: + return [mode for vid, mode in plans if vid == visual_id] + + +class Rig: + """A 3D scene with one multiscale visual of a small pyramid, one canvas.""" + + def __init__(self, controller, tmp_path, *, kind="image", spec=fx.ANISO, **moving): + self.controller = controller + controller._render_manager.config.scheduler.dims_settle_s = SETTLE_S + labels = kind == "labels" + fx.write_pyramid(tmp_path, spec, labels=labels) + self.store, _voxel = fx.open_pyramid(tmp_path, spec) + self.system = self.store.data_coordinate_systems[0] + if "t" in spec.axes: + self.scene = controller.add_scene( + coordinate_system=[("t", "time"), *spatial_axes("z", "y", "x")], + dim="3d", + name="scene", + ) + else: + self.scene = controller.add_scene(dim="3d", name="scene") + self.depth = spec.shape0[-3] + if labels: + self.visual = controller.add_labels_multiscale( + data=self.store, + scene_id=self.scene.id, + appearance=MultiscaleLabelsAppearance(force_level=1, **moving), + render_config=MultiscaleLabelRenderConfig( + **SMALL_BUDGETS, block_size=16 + ), + clipping_planes=(self.plane(4.0),), + ) + else: + self.visual = controller.add_image_multiscale( + data=self.store, + scene_id=self.scene.id, + appearance=MultiscaleImageAppearance(force_level=1, **moving), + render_config=MultiscaleImageRenderConfig( + **SMALL_BUDGETS, block_size=16 + ), + single=MultiscaleImageSingleAppearance( + color_map="viridis", clim=(0.0, 65535.0), render_mode="mip" + ), + clipping_planes=(self.plane(4.0),), + ) + controller.add_canvas(scene_id=self.scene.id, canvas_size=(200, 125)) + self.canvas_id = controller.get_canvas_ids(self.scene.id)[-1] + self.item = self.visual.clipping_planes[0] + self.events: list[tuple] = [] + controller.on_plane_interaction( + self.visual.id, + lambda event: self.events.append((event.phase, event.reason)), + owner_id=controller._id, + ) + + def plane(self, z: float) -> ClippingPlane: + """Keep data ``z >= z``.""" + return ClippingPlane.from_point_normal( + self.system, (z, 0, 0), (1, 0, 0), axes=("z", "y", "x") + ) + + def move(self, z: float, **kwargs) -> None: + self.controller.set_clipping_plane( + self.visual.id, self.item.id, self.plane(z).plane, **kwargs + ) + + async def loaded(self) -> None: + self.controller.fit_camera(self.scene.id) + self.controller.reslice_all() + await drain_loading(self.controller) + + @property + def cache_id(self) -> int: + gfx = self.controller._render_manager._scenes[self.scene.id].get_visual( + self.visual.id + ) + residency = ( + gfx.slots[0].residency_3d() if hasattr(gfx, "slots") else gfx.residency_3d() + ) + return residency.cache_id + + def registry(self): + return self.controller._render_manager.scheduler.core.registry(self.cache_id) + + def wanted(self) -> dict[int, frozenset[int]]: + """Per chunk class, the keys the latest plan wants.""" + reg = self.registry() + visible = reg.tier == Tier.VISIBLE + return { + int(cls): frozenset(int(k) for k in reg.key[visible & (reg.cls == cls)]) + for cls in (ChunkClass.BACKSTOP, ChunkClass.TARGET) + } + + +@pytest.fixture +def make_rig(controller, tmp_path): + def _make(**kwargs) -> Rig: + return Rig(controller, tmp_path, **kwargs) + + return _make + + +# -- a plane drag holds the plan ------------------------------------------------ + + +@pytest.mark.parametrize("kind", ["image", "labels"]) +async def test_a_drag_plans_nothing_and_its_release_plans_once( + make_rig, monkeypatch, kind +): + rig = make_rig(kind=kind) + await rig.loaded() + before = rig.wanted() + assert before[int(ChunkClass.TARGET)] + plans = _record_plans(monkeypatch) + + with rig.controller.plane_interaction(rig.visual.id): + for z in (8.0, 20.0, 36.0): + rig.move(z) + assert plans == [] + # The last plan is still the desired set: nothing was dropped. + assert rig.wanted() == before + assert rig.controller.plane_interaction_state(rig.visual.id) == "active" + + # The release planned before the block returned. + assert _modes(plans, rig.visual.id) == ["FULL"] + assert rig.events == [("start", None), ("end", "release")] + await drain_loading(rig.controller) + after = rig.wanted() + assert after[int(ChunkClass.TARGET)] != before[int(ChunkClass.TARGET)] + assert not rig.controller._plane_plan_owed + + +async def test_a_held_drag_always_has_the_backstop_to_draw(make_rig, monkeypatch): + """What a drag reveals is drawn from the backstop: it is never clipped. + + The plane starts far in (little kept) and is dragged out, so most of what + the drag shows was not in the held plan. + """ + rig = make_rig() + rig.move(48.0) + await rig.loaded() + backstop = rig.wanted()[int(ChunkClass.BACKSTOP)] + assert backstop + with rig.controller.plane_interaction(rig.visual.id): + rig.move(2.0) + assert rig.wanted()[int(ChunkClass.BACKSTOP)] == backstop + progress = rig.controller._render_manager.scheduler.progress(rig.cache_id) + assert progress.resident_backstop == progress.needed_backstop > 0 + await drain_loading(rig.controller) + + +async def test_stillness_ends_a_drag_and_plans_and_the_next_move_holds_again( + make_rig, monkeypatch +): + rig = make_rig() + await rig.loaded() + plans = _record_plans(monkeypatch) + source = uuid4() + rig.controller.begin_plane_interaction(rig.visual.id, source_id=source) + rig.move(8.0) + assert plans == [] + await asyncio.sleep(SETTLE_S * 4) # held still, the scope still open + assert rig.events == [("start", None), ("end", "settle")] + assert _modes(plans, rig.visual.id) == ["FULL"] + + rig.move(20.0) # a new motion + assert rig.events[-1] == ("start", None) + assert _modes(plans, rig.visual.id) == ["FULL"] + rig.controller.end_plane_interaction(rig.visual.id, source_id=source) + assert rig.events[-1] == ("end", "release") + assert _modes(plans, rig.visual.id) == ["FULL", "FULL"] + await drain_loading(rig.controller) + + +async def test_a_change_outside_a_scope_is_a_jump_and_plans_at_once( + make_rig, monkeypatch +): + rig = make_rig() + await rig.loaded() + plans = _record_plans(monkeypatch) + rig.move(20.0) + assert _modes(plans, rig.visual.id) == ["FULL"] + assert rig.events == [] + await drain_loading(rig.controller) + + +async def test_eager_plans_every_tick_and_nothing_at_the_end(make_rig, monkeypatch): + rig = make_rig(**EAGER) + await rig.loaded() + plans = _record_plans(monkeypatch) + with rig.controller.plane_interaction(rig.visual.id): + for z in (8.0, 20.0, 36.0): + rig.move(z) + assert _modes(plans, rig.visual.id) == ["FULL"] * 3 + assert _modes(plans, rig.visual.id) == ["FULL"] * 3 + assert rig.events == [("start", None), ("end", "release")] + await drain_loading(rig.controller) + + +async def test_a_scope_closed_by_an_exception_ends_the_drag_and_plans( + make_rig, monkeypatch +): + rig = make_rig() + await rig.loaded() + plans = _record_plans(monkeypatch) + with pytest.raises(RuntimeError): + with rig.controller.plane_interaction(rig.visual.id): + rig.move(20.0) + raise RuntimeError("the script failed mid-drag") + assert rig.events == [("start", None), ("end", "release")] + assert _modes(plans, rig.visual.id) == ["FULL"] + assert rig.controller.plane_interaction_state(rig.visual.id) == "idle" + await drain_loading(rig.controller) + + +def test_with_no_event_loop_each_change_plans(make_rig, monkeypatch): + """No loop, no timer: each change settles at once, and so plans.""" + rig = make_rig() + plans = _record_plans(monkeypatch) + with rig.controller.plane_interaction(rig.visual.id): + rig.move(8.0) + rig.move(20.0) + assert _modes(plans, rig.visual.id) == ["FULL"] * 2 + assert not rig.controller._plane_plan_owed + + +async def test_the_setting_is_read_at_each_tick(make_rig, monkeypatch): + """Assigning the setting plans nothing; a drag turned eager stays planned.""" + rig = make_rig() + await rig.loaded() + plans = _record_plans(monkeypatch) + with rig.controller.plane_interaction(rig.visual.id): + rig.move(8.0) # held + rig.visual.appearance.coarsest_while_moving_3d = False + assert plans == [] + rig.move(20.0) # eager now + assert _modes(plans, rig.visual.id) == ["FULL"] + # The held tick was still owed its plan at the end. + assert _modes(plans, rig.visual.id) == ["FULL", "FULL"] + await drain_loading(rig.controller) + + +# -- jumps end a drag ------------------------------------------------------------- + + +@pytest.mark.parametrize("change", ["visible", "render_mode"]) +async def test_a_visibility_or_mode_change_ends_a_drag(make_rig, monkeypatch, change): + rig = make_rig() + await rig.loaded() + plans = _record_plans(monkeypatch) + source = uuid4() + rig.controller.begin_plane_interaction(rig.visual.id, source_id=source) + rig.move(20.0) + assert plans == [] + if change == "visible": + rig.visual.appearance.visible = False + rig.visual.appearance.visible = True + else: + rig.visual.single.render_mode = "iso" + assert rig.events == [("start", None), ("end", "cancel")] + assert rig.controller.plane_interaction_state(rig.visual.id) == "idle" + # The plan the drag held back was made by the jump. + assert "FULL" in _modes(plans, rig.visual.id) + assert not rig.controller._plane_plan_owed + n_plans = len(plans) + rig.controller.end_plane_interaction(rig.visual.id, source_id=source) + assert len(plans) == n_plans # nothing more at the scope's end + await drain_loading(rig.controller) + + +async def test_a_displayed_axes_change_ends_a_drag(make_rig, monkeypatch): + rig = make_rig() + await rig.loaded() + source = uuid4() + rig.controller.begin_plane_interaction(rig.visual.id, source_id=source) + rig.move(20.0) + plans = _record_plans(monkeypatch) + rig.controller.set_displayed_axes(rig.scene.id, (1, 2)) + assert rig.events == [("start", None), ("end", "cancel")] + assert not rig.controller._plane_plan_owed + await drain_loading(rig.controller) + assert set(_modes(plans, rig.visual.id)) <= {"FULL"} + rig.controller.end_plane_interaction(rig.visual.id, source_id=source) + assert rig.events == [("start", None), ("end", "cancel")] + + +async def test_removing_a_visual_mid_drag_forgets_what_it_was_owed(make_rig): + rig = make_rig() + await rig.loaded() + rig.controller.begin_plane_interaction(rig.visual.id, source_id=uuid4()) + rig.move(20.0) + assert rig.visual.id in rig.controller._plane_plan_owed + rig.controller.remove_visual(rig.visual.id) + assert not rig.controller._plane_plan_owed + assert rig.controller._plane_interaction_driver.tasks() == [] + + +# -- C3: a camera end during a drag ---------------------------------------------- + + +def _moved(state, step: float): + return state._replace(position=tuple(p + step for p in state.position)) + + +async def test_a_camera_end_during_a_drag_leaves_the_target_to_the_drag( + make_rig, monkeypatch +): + rig = make_rig() + controller = rig.controller + await rig.loaded() + controller.camera_reslice_enabled = True # the fixture turns it off + plans = _record_plans(monkeypatch) + state = controller.get_camera_state(rig.canvas_id) + + with controller.plane_interaction(rig.visual.id): + rig.move(8.0) + with controller.camera_interaction(rig.canvas_id): + for step in (1.0, 2.0, 3.0): + controller.set_camera_state(rig.canvas_id, _moved(state, step)) + # The orbit was released: its end planned nothing of this visual. + assert controller.camera_interaction_state(rig.canvas_id) == "idle" + assert plans == [] + rig.move(20.0) + assert plans == [] + assert _modes(plans, rig.visual.id) == ["FULL"] + await drain_loading(controller) + + +async def test_a_drag_that_ends_while_the_camera_moves_is_planned_by_the_camera_end( + make_rig, monkeypatch +): + rig = make_rig() + controller = rig.controller + await rig.loaded() + controller.camera_reslice_enabled = True # the fixture turns it off + plans = _record_plans(monkeypatch) + state = controller.get_camera_state(rig.canvas_id) + + with controller.camera_interaction(rig.canvas_id): + controller.set_camera_state(rig.canvas_id, _moved(state, 1.0)) + with controller.plane_interaction(rig.visual.id): + rig.move(8.0) + # The drag ended first: no target from a camera still moving. + assert rig.events == [("start", None), ("end", "release")] + assert plans == [] + assert _modes(plans, rig.visual.id) == ["FULL"] + await drain_loading(controller) + + +async def test_with_camera_reslicing_off_a_drag_end_plans_whatever_the_camera_does( + make_rig, monkeypatch +): + rig = make_rig() + controller = rig.controller + await rig.loaded() + assert controller.camera_reslice_enabled is False + plans = _record_plans(monkeypatch) + state = controller.get_camera_state(rig.canvas_id) + with controller.camera_interaction(rig.canvas_id): + controller.set_camera_state(rig.canvas_id, _moved(state, 1.0)) + with controller.plane_interaction(rig.visual.id): + rig.move(8.0) + # No camera end will plan it, so the drag's end does. + assert _modes(plans, rig.visual.id) == ["FULL"] + await drain_loading(controller) + + +# -- C4: a plane drag and a dims scrub together --------------------------------- + + +async def test_a_drag_and_a_scrub_together_plan_the_target_once_both_have_stopped( + make_rig, monkeypatch +): + rig = make_rig(spec=fx.TZYX) + controller = rig.controller + await rig.loaded() + plans = _record_plans(monkeypatch) + scrub = uuid4() + + with controller.plane_interaction(rig.visual.id): + rig.move(8.0) + assert plans == [] + controller.begin_dims_interaction(rig.scene.id, source_id=scrub) + controller.update_slice_indices(rig.scene.id, {0: 3.0}) + # The new slice's backstop: the held plan belongs to another slice. + assert _modes(plans, rig.visual.id) == ["BACKSTOP_ONLY"] + rig.move(12.0) + assert _modes(plans, rig.visual.id) == ["BACKSTOP_ONLY"] + # The scrub ends first: the target waits for the drag. + controller.end_dims_interaction(rig.scene.id, source_id=scrub) + assert _modes(plans, rig.visual.id) == ["BACKSTOP_ONLY"] + assert _modes(plans, rig.visual.id) == ["BACKSTOP_ONLY", "FULL"] + await drain_loading(controller) + + +async def test_a_drag_that_ends_during_a_scrub_is_planned_by_the_scrub_end( + make_rig, monkeypatch +): + rig = make_rig(spec=fx.TZYX) + controller = rig.controller + await rig.loaded() + plans = _record_plans(monkeypatch) + + with controller.dims_interaction(rig.scene.id): + controller.update_slice_indices(rig.scene.id, {0: 3.0}) + with controller.plane_interaction(rig.visual.id): + rig.move(8.0) + # The drag ended first: no target while the dims still move. + assert _modes(plans, rig.visual.id) == ["BACKSTOP_ONLY"] + assert _modes(plans, rig.visual.id) == ["BACKSTOP_ONLY", "FULL"] + await drain_loading(controller) + + +# -- the 3D dims scrub ------------------------------------------------------------ + + +async def test_a_3d_scrub_plans_the_backstop_per_tick_by_default(make_rig, monkeypatch): + rig = make_rig(spec=fx.TZYX) + controller = rig.controller + await rig.loaded() + plans = _record_plans(monkeypatch) + with controller.dims_interaction(rig.scene.id): + for t in (1.0, 2.0, 3.0): + controller.update_slice_indices(rig.scene.id, {0: t}) + await asyncio.sleep(0) + await asyncio.sleep(0) + assert rig.wanted()[int(ChunkClass.TARGET)] == frozenset() + assert _modes(plans, rig.visual.id) == ["BACKSTOP_ONLY"] * 3 + assert _modes(plans, rig.visual.id) == ["BACKSTOP_ONLY"] * 3 + ["FULL"] + await drain_loading(controller) + assert rig.wanted()[int(ChunkClass.TARGET)] + + +async def test_an_eager_3d_scrub_plans_in_full_per_tick(make_rig, monkeypatch): + rig = make_rig(spec=fx.TZYX, **EAGER) + controller = rig.controller + await rig.loaded() + plans = _record_plans(monkeypatch) + with controller.dims_interaction(rig.scene.id): + for t in (1.0, 2.0): + controller.update_slice_indices(rig.scene.id, {0: t}) + assert _modes(plans, rig.visual.id) == ["FULL"] * 2 + await drain_loading(controller) + + +# -- other visuals are untouched -------------------------------------------------- + + +async def test_a_2d_view_plans_a_clipping_change_on_every_tick( + controller, tmp_path, monkeypatch +): + fx.write_pyramid(tmp_path, fx.ISO) + store, _ = fx.open_pyramid(tmp_path, fx.ISO) + scene = controller.add_scene(dim="2d", name="scene") + system = store.data_coordinate_systems[0] + + def plane(x: float) -> ClippingPlane: + return ClippingPlane.from_point_normal( + system, (0, 0, x), (0, 0, 1), axes=("z", "y", "x") + ) + + visual = controller.add_image_multiscale( + data=store, + scene_id=scene.id, + appearance=MultiscaleImageAppearance(coarsest_while_moving_2d=True), + render_config=MultiscaleImageRenderConfig(**SMALL_BUDGETS, block_size=16), + clipping_planes=(plane(8.0),), + ) + controller.add_canvas(scene_id=scene.id) + controller.fit_camera(scene.id) + controller.reslice_all() + await drain_loading(controller) + affects = controller._render_manager.clipping_planes_affect_request(visual.id) + reslices: list = [] + original = controller.reslice_visual + monkeypatch.setattr( + controller, + "reslice_visual", + lambda vid: (reslices.append(vid), original(vid))[1], + ) + with controller.plane_interaction(visual.id): + controller.set_clipping_planes(visual.id, (plane(20.0),)) + # Not held: a 2D view's only motion is a dims scrub. + assert reslices == ([visual.id] if affects else []) + assert not controller._plane_plan_owed + await drain_loading(controller) diff --git a/tests/render/test_render_planes_controller.py b/tests/render/test_render_planes_controller.py new file mode 100644 index 00000000..2f19daa4 --- /dev/null +++ b/tests/render/test_render_planes_controller.py @@ -0,0 +1,857 @@ +"""Render planes on the controller: checks, API, events, planning. + +Plane rendering design v3, sections 4, 7.4 and 8 (Phase 5: nothing is drawn +on a plane yet; a visual in plane mode draws its family's default volume). +""" + +from __future__ import annotations + +import warnings +from uuid import uuid4 + +import numpy as np +import pytest +from psygnal import EmitLoopError + +from cellier.data import ImageMemoryStore, LabelMemoryStore, PointsMemoryStore +from cellier.events import ( + ChannelAppearanceChangedEvent, + PlaneInteractionEvent, + RenderPlanesChangedEvent, + RenderPlanesUpdateEvent, +) +from cellier.scene import spatial_axes +from cellier.visuals import ( + InMemoryImageChannelAppearance, + InMemoryImageSingleAppearance, + InMemoryLabelsAppearance, + MultiscaleImageAppearance, + MultiscaleImageChannelAppearance, + MultiscaleImageRenderConfig, + MultiscaleImageSingleAppearance, + MultiscaleLabelRenderConfig, + MultiscaleLabelsAppearance, + RenderPlane, +) +from tests import _plane_fixtures as fx +from tests._gpu_budget import SMALL_BUDGETS +from tests.render.conftest import drain_loading + +SETTLE_S = 0.03 +#: A refused direct assignment: the handler's ``ValueError``, which psygnal +#: wraps in ``EmitLoopError`` on its way out of the model. +REFUSED = (ValueError, EmitLoopError) + + +def _world(controller, scene): + return controller._model.scenes[scene.id].dims.world_coordinate_system + + +def _plane(world, z: float = 10.0, **kwargs) -> RenderPlane: + return RenderPlane.from_point_normal( + world, (z, 0, 0), (1, 0, 0), axes=("z", "y", "x"), **kwargs + ) + + +def _record_plans(monkeypatch) -> list[tuple]: + """Record ``(visual_id, plan mode, sliced)`` for every visual a pass plans.""" + from cellier.render.scene_manager import SceneManager + + plans: list[tuple] = [] + original = SceneManager.plan_chunked + + def spy(self, request, visual_configs): + for visual_id, cfg in visual_configs.items(): + targets = request.target_visual_ids + if targets is None or visual_id in targets: + plans.append((visual_id, cfg.plan_mode.name, cfg.slicing_enabled)) + return original(self, request, visual_configs) + + monkeypatch.setattr(SceneManager, "plan_chunked", spy) + return plans + + +def _scene(controller, spec=fx.ISO, dim="3d"): + if "t" in spec.axes: + return controller.add_scene( + coordinate_system=[("t", "time"), *spatial_axes("z", "y", "x")], + dim=dim, + name="scene", + ) + return controller.add_scene(dim=dim, name="scene") + + +def _multiscale_image(controller, tmp_path, *, spec=fx.ISO, dim="3d", **kwargs): + """A multiscale image in ``"plane"`` mode; returns ``(scene, visual)``.""" + controller._render_manager.config.scheduler.dims_settle_s = SETTLE_S + fx.write_pyramid(tmp_path, spec) + store, _ = fx.open_pyramid(tmp_path, spec) + scene = _scene(controller, spec, dim) + kwargs.setdefault( + "single", + MultiscaleImageSingleAppearance( + color_map="viridis", clim=(0.0, 65535.0), render_mode="plane" + ), + ) + visual = controller.add_image_multiscale( + data=store, + scene_id=scene.id, + appearance=MultiscaleImageAppearance(force_level=1), + render_config=MultiscaleImageRenderConfig(**SMALL_BUDGETS, block_size=16), + **kwargs, + ) + controller.add_canvas(scene_id=scene.id, canvas_size=(200, 125)) + return scene, visual + + +def _memory_image(controller, *, channels=None, **kwargs): + """An in-memory image; channel axis 0 when *channels* is given.""" + scene = controller.add_scene(dim="3d", name="scene") + rng = np.random.default_rng(0) + if channels is None: + store = ImageMemoryStore(data=rng.random((8, 8, 8), dtype=np.float32)) + else: + store = ImageMemoryStore(data=rng.random((3, 8, 8, 8), dtype=np.float32)) + kwargs.update(channel_axis=0, channels=channels) + scene = controller.add_scene( + coordinate_system=[("c", "channel"), *spatial_axes("z", "y", "x")], + dim="3d", + name="channels", + ) + visual = controller.add_image(data=store, scene_id=scene.id, **kwargs) + controller.add_canvas(scene_id=scene.id, canvas_size=(200, 125)) + return scene, visual + + +def _events(controller, visual) -> list: + log: list = [] + owner = uuid4() + controller.on_render_planes_changed(visual.id, log.append, owner_id=owner) + controller.on_plane_interaction(visual.id, log.append, owner_id=owner) + return log + + +async def _loaded(controller, scene) -> None: + controller.fit_camera(scene.id) + controller.reslice_all() + await drain_loading(controller) + + +# -- the fields on the four visuals --------------------------------------------- + + +def test_the_four_visuals_take_render_planes_and_plane_mode( + controller, tmp_path, image_volume, labels_volume +): + fx.write_pyramid(tmp_path / "i", fx.ISO) + fx.write_pyramid(tmp_path / "l", fx.ISO, labels=True) + image_store, _ = fx.open_pyramid(tmp_path / "i", fx.ISO) + label_store, _ = fx.open_pyramid(tmp_path / "l", fx.ISO) + scene = controller.add_scene(dim="3d", name="scene") + plane = _plane(_world(controller, scene)) + + visuals = [ + controller.add_image( + data=image_volume, + scene_id=scene.id, + single=InMemoryImageSingleAppearance(render_mode="plane"), + render_planes=(plane,), + ), + controller.add_labels( + data=labels_volume, + scene_id=scene.id, + appearance=InMemoryLabelsAppearance(render_mode="plane"), + render_planes=(plane,), + ), + controller.add_image_multiscale( + data=image_store, + scene_id=scene.id, + single=MultiscaleImageSingleAppearance(render_mode="plane"), + render_config=MultiscaleImageRenderConfig(**SMALL_BUDGETS, block_size=16), + render_planes=(plane,), + ), + controller.add_labels_multiscale( + data=label_store, + scene_id=scene.id, + appearance=MultiscaleLabelsAppearance(render_mode="plane"), + render_config=MultiscaleLabelRenderConfig(**SMALL_BUDGETS, block_size=16), + render_planes=(plane,), + ), + ] + for visual in visuals: + assert visual.render_planes == (plane,) + assert visual.plane_mode() is True + assert controller.get_render_plane(visual.id, plane.id) == plane + + +def test_render_planes_default_to_empty_and_are_validated_on_assignment( + controller, image_volume +): + scene = controller.add_scene(dim="3d", name="scene") + visual = controller.add_image(data=image_volume, scene_id=scene.id) + assert visual.render_planes == () + assert visual.plane_mode() is False + plane = _plane(_world(controller, scene)) + visual.render_planes = [plane] # a list is coerced + assert visual.render_planes == (plane,) + with pytest.raises(REFUSED): + visual.render_planes = ("not a plane",) + assert visual.render_planes == (plane,) + + +def test_a_visual_without_render_planes_is_refused(controller): + scene = controller.add_scene(dim="3d", name="scene") + points = controller.add_points( + data=PointsMemoryStore(positions=np.zeros((4, 3), dtype=np.float32)), + scene_id=scene.id, + ) + with pytest.raises(TypeError, match="image and labels"): + controller.set_render_planes(points.id, ()) + with pytest.raises(KeyError): + controller.get_render_plane(points.id, uuid4()) + + +# -- the rules (4.3) -------------------------------------------------------------- + + +def test_bad_tuples_are_refused_and_the_last_good_one_put_back( + controller, image_volume +): + scene = controller.add_scene(dim="3d", name="scene") + other = controller.add_scene(dim="3d", name="other") + visual = controller.add_image(data=image_volume, scene_id=scene.id) + world = _world(controller, scene) + good = _plane(world) + controller.set_render_planes(visual.id, (good,)) + log = _events(controller, visual) + + cases = { + "world coordinate system": (_plane(_world(controller, other)),), + "share an id": (good, good.model_copy(update={"origin": (1.0, 0.0, 0.0)})), + "at most 4": tuple(_plane(world, z) for z in range(5)), + "not one axis": (good.model_copy(update={"axes": ("z", "y", "q")}),), + } + for match, planes in cases.items(): + # Through the method: checked before anything changes. + with pytest.raises(ValueError, match=match): + controller.set_render_planes(visual.id, planes) + assert visual.render_planes == (good,) + # By direct assignment: put back, and the error raised. + with pytest.raises(REFUSED, match=match): + visual.render_planes = planes + assert visual.render_planes == (good,) + assert log == [] + + # Four are allowed. + four = tuple(_plane(world, z) for z in range(4)) + assert controller.set_render_planes(visual.id, four) == four + + +def test_a_plane_on_an_axis_the_data_does_not_span_is_refused(controller, image_volume): + """P16: each plane axis must be one data axis, scaled and shifted.""" + scene = controller.add_scene( + coordinate_system=[("t", "time"), *spatial_axes("z", "y", "x")], + dim="3d", + name="scene", + ) + visual = controller.add_image(data=image_volume, scene_id=scene.id) # zyx data + world = _world(controller, scene) + on_data_axes = RenderPlane.from_point_normal( + world, (4, 0, 0), (1, 0, 0), axes=("z", "y", "x") + ) + controller.set_render_planes(visual.id, (on_data_axes,)) + off_data_axes = RenderPlane.from_point_normal( + world, (0, 0, 0), (1, 0, 0), axes=("t", "y", "x") + ) + with pytest.raises(ValueError, match="scale and a translation per axis"): + controller.set_render_planes(visual.id, (off_data_axes,)) + with pytest.raises(REFUSED, match="scale and a translation per axis"): + visual.render_planes = (off_data_axes,) + assert visual.render_planes == (on_data_axes,) + + +def test_a_visual_added_with_bad_planes_is_refused(controller, image_volume): + scene = controller.add_scene(dim="3d", name="scene") + other = controller.add_scene(dim="3d", name="other") + with pytest.raises(ValueError, match="world coordinate system"): + controller.add_image( + data=image_volume, + scene_id=scene.id, + render_planes=(_plane(_world(controller, other)),), + ) + assert controller._model.scenes[scene.id].visuals == [] + + +# -- C9, as decided 2026-10-06: every channel has the same render mode ------------ + + +def _channel_events(controller, visual) -> list: + log: list = [] + controller._outgoing_events.subscribe( + ChannelAppearanceChangedEvent, + log.append, + entity_id=visual.id, + owner_id=uuid4(), + ) + return log + + +def _channels(*modes): + return { + index: InMemoryImageChannelAppearance(render_mode=mode) + for index, mode in enumerate(modes) + } + + +def test_channels_with_different_modes_are_refused_when_the_visual_is_built( + controller, +): + """Plane beside volume, and one volume mode beside another, alike.""" + for modes in (("plane", "mip", "mip"), ("mip", "iso", "mip")): + with pytest.raises(ValueError, match="different render modes"): + _memory_image(controller, channels=_channels(*modes), composite=True) + # In single mode too, and for a hidden channel. + channels = _channels(*modes) + channels[0].visible = False + with pytest.raises(ValueError, match="different render modes"): + _memory_image(controller, channels=channels) + + +async def test_one_channel_cannot_be_given_a_mode_alone(controller): + _scene, visual = _memory_image( + controller, channels=_channels("mip", "mip", "mip"), composite=True + ) + log = _channel_events(controller, visual) + for mode in ("plane", "iso"): + with pytest.raises(REFUSED, match="set_image_render_mode"): + visual.channels[1].render_mode = mode + assert [c.render_mode for c in visual.channels.values()] == ["mip"] * 3 + assert log == [] # a refused mode is not announced + + # A replaced dict is checked by the model: the old one stays. + previous = visual.channels + with pytest.raises(ValueError, match="different render modes"): + visual.channels = _channels("plane", "mip", "plane") + assert visual.channels is previous + visual.channels = _channels("iso", "iso", "iso") + assert visual.plane_mode() is False + + +async def test_set_image_render_mode_sets_every_channel(controller): + _scene, visual = _memory_image( + controller, channels=_channels("mip", "mip", "mip"), composite=True + ) + visual.channels[2].visible = False # hidden channels are set too + log = _channel_events(controller, visual) + source = uuid4() + + controller.set_image_render_mode(visual.id, "plane", source_id=source) + assert [c.render_mode for c in visual.channels.values()] == ["plane"] * 3 + assert visual.plane_mode() is True + assert [(e.channel_index, e.field_name, e.new_value) for e in log] == [ + (index, "render_mode", "plane") for index in range(3) + ] + assert {e.source_id for e in log} == {source} + # The single appearance is separate. + assert visual.single.render_mode == "mip" + + # The same mode again changes nothing and announces nothing. + log.clear() + controller.set_image_render_mode(visual.id, "plane") + assert log == [] + + # An unknown mode is refused before any channel changes. + with pytest.raises(ValueError): + controller.set_image_render_mode(visual.id, "no_such_mode") + assert [c.render_mode for c in visual.channels.values()] == ["plane"] * 3 + assert log == [] + + # After a sync a lone assignment is still refused, and put back. + with pytest.raises(REFUSED, match="set_image_render_mode"): + visual.channels[0].render_mode = "mip" + assert visual.channels[0].render_mode == "plane" + + +async def test_a_channel_field_update_of_the_mode_sets_every_channel(controller): + """What a GUI's per-channel combo sends.""" + _scene, visual = _memory_image( + controller, channels=_channels("mip", "mip", "mip"), composite=True + ) + log = _channel_events(controller, visual) + widget = uuid4() + controller.update_channel_appearance_field( + visual.id, 1, "render_mode", "iso", source_id=widget + ) + assert [c.render_mode for c in visual.channels.values()] == ["iso"] * 3 + # The widget's own channel carries its id; the others the controller's, + # so the widget's echo filter lets them through. + assert {e.channel_index: e.source_id for e in log} == { + 0: controller._id, + 1: widget, + 2: controller._id, + } + with pytest.raises(KeyError): + controller.update_channel_appearance_field(visual.id, 7, "render_mode", "mip") + + +async def test_single_and_channel_modes_are_independent(controller): + scene, visual = _memory_image( + controller, + channels=_channels("plane", "plane", "plane"), + single=InMemoryImageSingleAppearance(render_mode="mip"), + ) + assert visual.plane_mode() is False # single mode reads ``single`` + visual.composite = True + assert visual.plane_mode() is True + visual.composite = False + visual.single.render_mode = "plane" + assert visual.plane_mode() is True + controller.set_image_render_mode(visual.id, "iso") + assert visual.single.render_mode == "plane" + assert scene.id in controller._model.scenes + + +def test_only_an_image_has_a_channel_render_mode(controller, labels_volume): + scene = controller.add_scene(dim="3d", name="scene") + labels = controller.add_labels(data=labels_volume, scene_id=scene.id) + with pytest.raises(TypeError, match="image visual"): + controller.set_image_render_mode(labels.id, "plane") + + +async def test_the_multiscale_channels_follow_the_rule_and_reslice_once( + controller, tmp_path, monkeypatch +): + spec = fx.channel_spec(2, fx.ISO) + fx.write_pyramid(tmp_path, spec) + store, _ = fx.open_pyramid(tmp_path, spec) + scene = controller.add_scene( + coordinate_system=spatial_axes("c", "z", "y", "x"), dim="3d", name="scene" + ) + visual = controller.add_image_multiscale( + data=store, + scene_id=scene.id, + channel_axis=0, + composite=True, + channels={ + 0: MultiscaleImageChannelAppearance(render_mode="mip"), + 1: MultiscaleImageChannelAppearance(render_mode="mip"), + }, + render_config=MultiscaleImageRenderConfig(**SMALL_BUDGETS, block_size=16), + ) + controller.add_canvas(scene_id=scene.id, canvas_size=(200, 125)) + world = _world(controller, scene) + controller.set_render_planes(visual.id, (_plane(world, 30.0),)) + await _loaded(controller, scene) + with pytest.raises(REFUSED, match="set_image_render_mode"): + visual.channels[0].render_mode = "plane" + assert visual.channels[0].render_mode == "mip" + + plans = _record_plans(monkeypatch) + controller.set_image_render_mode(visual.id, "iso") # volume to volume + assert plans == [] + controller.set_image_render_mode(visual.id, "plane") + assert len(plans) == 1 # entering plane mode: one reslice, not one a channel + controller.set_image_render_mode(visual.id, "mip") + assert len(plans) == 2 + await drain_loading(controller) + + +# -- events ----------------------------------------------------------------------- + + +def test_one_changed_event_per_assignment_with_the_source_stamped( + controller, image_volume +): + scene = controller.add_scene(dim="3d", name="scene") + visual = controller.add_image(data=image_volume, scene_id=scene.id) + world = _world(controller, scene) + log = _events(controller, visual) + first, second = _plane(world, 2.0), _plane(world, 4.0) + source = uuid4() + + controller.set_render_planes(visual.id, (first,), source_id=source) + controller.set_render_planes(visual.id, (first,), source_id=source) # equal + visual.render_planes = (first, second) + assert [type(event) for event in log] == [RenderPlanesChangedEvent] * 2 + assert [event.source_id for event in log] == [source, controller._id] + assert [event.render_planes for event in log] == [(first,), (first, second)] + assert {event.visual_id for event in log} == {visual.id} + + # A GUI's request goes the same way. + widget = uuid4() + controller.incoming_events.emit( + RenderPlanesUpdateEvent( + source_id=widget, visual_id=visual.id, render_planes=(second,) + ) + ) + assert visual.render_planes == (second,) + assert log[-1].source_id == widget + + +def test_set_render_plane_replaces_one_plane_and_keeps_its_id(controller, image_volume): + scene = controller.add_scene(dim="3d", name="scene") + visual = controller.add_image(data=image_volume, scene_id=scene.id) + world = _world(controller, scene) + first, second = _plane(world, 2.0), _plane(world, 4.0) + controller.set_render_planes(visual.id, (first, second)) + + replacement = _plane(world, 6.0, extent_0=(-1.0, 1.0), enabled=False) + moved = controller.set_render_plane(visual.id, second.id, replacement) + assert moved.id == second.id + assert moved.origin == (6.0, 0.0, 0.0) + assert moved.extent_0 == (-1.0, 1.0) + assert moved.enabled is False + assert visual.render_planes == (first, moved) + assert controller.get_render_plane(visual.id, second.id) == moved + with pytest.raises(KeyError): + controller.set_render_plane(visual.id, uuid4(), replacement) + + +# -- 8.2: a change while not in plane mode is stored, announced, inert ------------- + + +async def test_a_change_outside_plane_mode_is_inert(controller, tmp_path, monkeypatch): + scene, visual = _multiscale_image( + controller, + tmp_path, + single=MultiscaleImageSingleAppearance( + color_map="viridis", clim=(0.0, 65535.0), render_mode="mip" + ), + ) + await _loaded(controller, scene) + world = _world(controller, scene) + log = _events(controller, visual) + plans = _record_plans(monkeypatch) + with controller.plane_interaction(visual.id): + controller.set_render_planes(visual.id, (_plane(world),)) + controller.set_render_planes(visual.id, (_plane(world, 20.0),)) + assert controller.plane_interaction_state(visual.id) == "idle" + assert plans == [] + assert [type(event) for event in log] == [RenderPlanesChangedEvent] * 2 + await drain_loading(controller) + + +async def test_plane_mode_is_ignored_in_a_2d_view(controller, tmp_path, monkeypatch): + """D-P19: a 2D view shows the normal slice, planes or not.""" + scene, visual = _multiscale_image(controller, tmp_path, dim="2d") + assert visual.plane_mode() is True + assert controller._draws_planes(visual) is False + world = _world(controller, scene) + plans = _record_plans(monkeypatch) + with warnings.catch_warnings(): + warnings.simplefilter("error") + controller.set_render_planes(visual.id, (_plane(world),)) + assert plans == [] # inert + await _loaded(controller, scene) + assert [(mode, sliced) for _, mode, sliced in plans] == [("FULL", True)] + await drain_loading(controller) + + +# -- 4.2 (P17): plane mode with nothing to draw draws nothing ---------------------- + + +async def test_plane_mode_with_no_plane_is_not_sliced_until_one_appears( + controller, tmp_path, monkeypatch +): + scene, visual = _multiscale_image(controller, tmp_path) + world = _world(controller, scene) + plans = _record_plans(monkeypatch) + await _loaded(controller, scene) + assert [(mode, sliced) for _, mode, sliced in plans] == [("FULL", False)] + gfx = controller._render_manager._scenes[scene.id].get_visual(visual.id) + cache_id = gfx.slots[0].residency_3d().cache_id + progress = controller._render_manager.scheduler.progress(cache_id) + assert progress.needed_backstop == 0 # no backstop either + assert progress.needed_target == 0 + + plans.clear() + plane = _plane(world, 30.0) + controller.set_render_planes(visual.id, (plane,)) + assert [(mode, sliced) for _, mode, sliced in plans] == [("FULL", True)] + await drain_loading(controller) + progress = controller._render_manager.scheduler.progress(cache_id) + assert progress.needed_backstop > 0 + + # Disabled, it is as if there were none. + plans.clear() + controller.set_render_planes( + visual.id, (plane.model_copy(update={"enabled": False}),) + ) + assert [(mode, sliced) for _, mode, sliced in plans] == [("FULL", False)] + await drain_loading(controller) + + +async def test_entering_and_leaving_plane_mode_reslices( + controller, tmp_path, monkeypatch +): + scene, visual = _multiscale_image( + controller, + tmp_path, + single=MultiscaleImageSingleAppearance( + color_map="viridis", clim=(0.0, 65535.0), render_mode="mip" + ), + ) + controller.set_render_planes(visual.id, (_plane(_world(controller, scene)),)) + await _loaded(controller, scene) + plans = _record_plans(monkeypatch) + visual.single.render_mode = "iso" # volume to volume: the same bricks + assert plans == [] + visual.single.render_mode = "plane" + assert [(mode, sliced) for _, mode, sliced in plans] == [("FULL", True)] + visual.single.render_mode = "mip" + assert len(plans) == 2 + await drain_loading(controller) + + +async def test_an_in_memory_visual_is_sliced_when_its_first_plane_appears( + controller, image_volume, monkeypatch +): + scene = controller.add_scene(dim="3d", name="scene") + visual = controller.add_image( + data=image_volume, + scene_id=scene.id, + single=InMemoryImageSingleAppearance(render_mode="plane"), + ) + controller.add_canvas(scene_id=scene.id) + world = _world(controller, scene) + assert controller._render_config_for(scene.id, visual).slicing_enabled is False + reslices: list = [] + original = controller.reslice_visual + monkeypatch.setattr( + controller, + "reslice_visual", + lambda vid: (reslices.append(vid), original(vid))[1], + ) + controller.set_render_planes(visual.id, (_plane(world, 2.0),)) + assert reslices == [visual.id] + assert controller._render_config_for(scene.id, visual).slicing_enabled is True + # A moved plane is a uniform write: nothing is read again. + controller.set_render_planes(visual.id, (_plane(world, 3.0),)) + assert reslices == [visual.id] + + +# -- C10 and 4.4: a plane whose axes are not displayed ----------------------------- + + +async def test_a_plane_off_the_displayed_axes_is_not_drawn_and_warns_once( + controller, tmp_path, monkeypatch +): + scene, visual = _multiscale_image(controller, tmp_path, spec=fx.TZYX) + world = _world(controller, scene) + zyx = RenderPlane.from_point_normal( + world, (8, 0, 0), (1, 0, 0), axes=("z", "y", "x") + ) + with warnings.catch_warnings(): + warnings.simplefilter("error") # drawable: no warning + controller.set_render_planes(visual.id, (zyx,)) + await _loaded(controller, scene) + + plans = _record_plans(monkeypatch) + # Display t, y, x in 3D: the plane is on z, y, x. + with pytest.warns(UserWarning, match="not drawn") as caught: + controller.set_displayed_axes(scene.id, (0, 2, 3)) + assert len(caught) == 1 + await drain_loading(controller) + assert plans + assert all(sliced is False for _, _, sliced in plans) # nothing planned + + # One warning per visual: further changes are quiet. + with warnings.catch_warnings(): + warnings.simplefilter("error") + controller.set_render_planes( + visual.id, (zyx.model_copy(update={"origin": (9.0, 0.0, 0.0)}),) + ) + controller.reslice_all() + + # A plane on the displayed axes is drawn; the other still is not. + tyx = RenderPlane.from_point_normal( + world, (2, 0, 0), (0, 1, 0), axes=("t", "y", "x") + ) + plans.clear() + controller.set_render_planes(visual.id, (zyx, tyx)) + assert [(mode, sliced) for _, mode, sliced in plans] == [("FULL", True)] + + # Back on z, y, x the other plane is the one not drawn: still one warning + # per visual, so this is quiet. + with warnings.catch_warnings(): + warnings.simplefilter("error") + controller.set_displayed_axes(scene.id, (1, 2, 3)) + await drain_loading(controller) + + # Once every enabled plane is on the displayed axes the warning is armed + # again. + controller.set_render_planes(visual.id, (zyx,)) + with pytest.warns(UserWarning, match="not drawn") as caught: + controller.set_displayed_axes(scene.id, (0, 2, 3)) + assert len(caught) == 1 + await drain_loading(controller) + + +# -- 7.4: a render plane change is a tick, and holds the plan ---------------------- + + +async def test_a_render_plane_drag_holds_the_plan(controller, tmp_path, monkeypatch): + scene, visual = _multiscale_image(controller, tmp_path) + world = _world(controller, scene) + plane = _plane(world, 20.0) + controller.set_render_planes(visual.id, (plane,)) + await _loaded(controller, scene) + log = _events(controller, visual) + plans = _record_plans(monkeypatch) + reads = controller._render_manager.scheduler.core + gfx = controller._render_manager._scenes[scene.id].get_visual(visual.id) + cache_id = gfx.slots[0].residency_3d().cache_id + before = controller._render_manager.scheduler.progress(cache_id) + + with controller.plane_interaction(visual.id): + for z in (30.0, 40.0, 50.0): + controller.set_render_plane( + visual.id, plane.id, plane.model_copy(update={"origin": (z, 0.0, 0.0)}) + ) + assert controller.plane_interaction_state(visual.id) == "active" + assert plans == [] # nothing planned: no reads + assert reads.idle() + assert controller._render_manager.scheduler.progress(cache_id) == before + assert [(mode, sliced) for _, mode, sliced in plans] == [("FULL", True)] + + interactions = [ + (event.phase, event.reason) + for event in log + if isinstance(event, PlaneInteractionEvent) + ] + assert interactions == [("start", None), ("end", "release")] + # The start is announced ahead of the drag's first change. + assert isinstance(log[0], PlaneInteractionEvent) + assert isinstance(log[1], RenderPlanesChangedEvent) + assert sum(isinstance(e, RenderPlanesChangedEvent) for e in log) == 3 + await drain_loading(controller) + + +async def test_a_render_plane_and_a_clipping_plane_share_one_drag( + controller, tmp_path, monkeypatch +): + """One tracker per visual serves both kinds of plane (D-P17).""" + from cellier.visuals import ClippingPlane + + scene, visual = _multiscale_image(controller, tmp_path) + world = _world(controller, scene) + plane = _plane(world, 20.0) + controller.set_render_planes(visual.id, (plane,)) + await _loaded(controller, scene) + system = controller._model.data.stores[ + __import__("uuid").UUID(visual.data_store_id) + ].data_coordinate_systems[0] + log = _events(controller, visual) + plans = _record_plans(monkeypatch) + + with controller.plane_interaction(visual.id): + controller.set_render_planes(visual.id, (_plane(world, 30.0),)) + controller.set_clipping_planes( + visual.id, + ( + ClippingPlane.from_point_normal( + system, (0, 0, 40), (0, 0, 1), axes=("z", "y", "x") + ), + ), + ) + assert plans == [] + assert len(plans) == 1 # the target loads once, at the end + interactions = [e for e in log if isinstance(e, PlaneInteractionEvent)] + assert [(e.phase, e.reason) for e in interactions] == [ + ("start", None), + ("end", "release"), + ] + await drain_loading(controller) + + +async def test_an_eager_render_plane_drag_plans_every_tick( + controller, tmp_path, monkeypatch +): + scene, visual = _multiscale_image(controller, tmp_path) + visual.appearance.coarsest_while_moving_3d = False + world = _world(controller, scene) + controller.set_render_planes(visual.id, (_plane(world, 20.0),)) + await _loaded(controller, scene) + plans = _record_plans(monkeypatch) + with controller.plane_interaction(visual.id): + controller.set_render_planes(visual.id, (_plane(world, 30.0),)) + controller.set_render_planes(visual.id, (_plane(world, 40.0),)) + assert len(plans) == 2 + assert len(plans) == 2 # nothing more at the end + await drain_loading(controller) + + +def test_an_in_memory_plane_drag_is_announced_and_reads_nothing( + controller, image_volume, monkeypatch +): + scene = controller.add_scene(dim="3d", name="scene") + world = _world(controller, scene) + visual = controller.add_image( + data=image_volume, + scene_id=scene.id, + single=InMemoryImageSingleAppearance(render_mode="plane"), + render_planes=(_plane(world, 2.0),), + ) + controller.add_canvas(scene_id=scene.id) + log = _events(controller, visual) + reslices: list = [] + monkeypatch.setattr(controller, "reslice_visual", reslices.append) + with controller.plane_interaction(visual.id): + controller.set_render_planes(visual.id, (_plane(world, 3.0),)) + assert reslices == [] + assert [type(e) for e in log] == [ + PlaneInteractionEvent, + RenderPlanesChangedEvent, + PlaneInteractionEvent, + ] + + +# -- nothing is drawn differently yet --------------------------------------------- + + +def test_the_gui_combos_do_not_offer_plane_yet(controller, image_volume, labels_volume): + from cellier.gui._appearance_fields import literal_choices + from cellier.gui._image_controls import image_control_values + + scene = controller.add_scene(dim="3d", name="scene") + image = controller.add_image( + data=image_volume, + scene_id=scene.id, + single=InMemoryImageSingleAppearance(render_mode="plane"), + ) + labels = controller.add_labels(data=labels_volume, scene_id=scene.id) + assert ( + "plane" + not in image_control_values(image, fields=["render_mode"])["render_modes"] + ) + assert "plane" not in literal_choices(labels.appearance, "render_mode") + + +@pytest.mark.parametrize("kind", ["image", "labels"]) +async def test_a_plane_mode_visual_builds_and_draws( + controller, render_scene, image_volume, labels_volume, kind +): + """Until the plane shaders arrive the visual draws as a volume, and no + shader is built for a mode it has no branch for.""" + scene = controller.add_scene(dim="3d", name="scene") + world = _world(controller, scene) + if kind == "image": + assert isinstance(image_volume, ImageMemoryStore) + controller.add_image( + data=image_volume, + scene_id=scene.id, + single=InMemoryImageSingleAppearance(render_mode="plane"), + render_planes=(_plane(world, 2.0),), + ) + else: + assert isinstance(labels_volume, LabelMemoryStore) + controller.add_labels( + data=labels_volume, + scene_id=scene.id, + appearance=InMemoryLabelsAppearance(render_mode="plane"), + render_planes=(_plane(world, 2.0),), + ) + controller.add_canvas(scene_id=scene.id) + await _loaded(controller, scene) + frame = render_scene(controller, scene.id) + assert np.isfinite(np.asarray(frame)).all() diff --git a/tests/test_plane_rendering_renames.py b/tests/test_plane_rendering_renames.py index 937475f1..5feb4c6b 100644 --- a/tests/test_plane_rendering_renames.py +++ b/tests/test_plane_rendering_renames.py @@ -67,10 +67,10 @@ def test_the_removed_loading_fields_are_refused(field) -> None: @pytest.mark.parametrize( "appearance_cls", [MultiscaleImageAppearance, MultiscaleLabelsAppearance] ) -def test_the_moving_settings_reproduce_the_old_defaults(appearance_cls) -> None: - """Phase 2 changes no behaviour: both settings start off (eager).""" +def test_the_moving_settings_defaults(appearance_cls) -> None: + """Coarsest while moving in 3D, eager in 2D (design 7.1).""" appearance = appearance_cls() - assert appearance.coarsest_while_moving_3d is False + assert appearance.coarsest_while_moving_3d is True assert appearance.coarsest_while_moving_2d is False @@ -109,10 +109,12 @@ def test_the_old_controls_key_is_refused() -> None: ) for config_cls in (MultiscaleImageControlsConfig, MultiscaleLabelsControlsConfig): - with pytest.raises(ValueError, match=LOD_BIAS): - config_cls(appearance=[LOD_BIAS]) - assert config_cls(appearance=["settled_lod_bias"]).appearance == [ - "settled_lod_bias" + # The Phase 2 key went too, when the level of detail control came. + for old in (LOD_BIAS, "settled_" + LOD_BIAS): + with pytest.raises(ValueError, match="use 'level_of_detail'"): + config_cls(appearance=[old]) + assert config_cls(appearance=["level_of_detail"]).appearance == [ + "level_of_detail" ] @@ -177,17 +179,22 @@ def _hits(pattern: re.Pattern, allowed: set[str], skip_line=lambda line: False): def test_no_old_bias_name_is_left() -> None: - # The anywidget control's asset files keep their names until the level of - # detail control replaces them (Phase 4). - assets = (f"{LOD_BIAS}.js", f"{LOD_BIAS}.css") - hits = _hits( - _word(LOD_BIAS), - _BIAS_ARGUMENT_FILES, - skip_line=lambda line: any(asset in line for asset in assets), - ) + hits = _hits(_word(LOD_BIAS), _BIAS_ARGUMENT_FILES) assert not hits, "\n".join(hits) +def test_the_bias_slider_widgets_are_gone() -> None: + """The level of detail control replaced them (design 9.3).""" + import cellier.gui.anywidget.visuals as any_visuals + import cellier.gui.qt.visuals as qt_visuals + + assert not hasattr(qt_visuals, "QtLodBias" + "Slider") + assert not hasattr(any_visuals, "AnywidgetLodBias" + "Slider") + static = ROOT / "src/cellier/gui/anywidget/visuals/static" + assert not list(static.glob(LOD_BIAS + ".*")) + assert (static / "level_of_detail.js").exists() + + def test_no_image_dims_drag_is_left() -> None: hits = _hits(re.compile(DIMS_DRAG), _MESH_DRAG_FILES) assert not hits, "\n".join(hits) diff --git a/tests/v2/test_anywidget.py b/tests/v2/test_anywidget.py index 98e8cd56..ebe49f4f 100644 --- a/tests/v2/test_anywidget.py +++ b/tests/v2/test_anywidget.py @@ -724,12 +724,10 @@ def test_layout_single_preset_with_docks(): # --------------------------------------------------------------------------- -def _make_lod_bias_slider(**kwargs): - from cellier.gui.anywidget.visuals import AnywidgetLodBiasSlider +def _make_level_of_detail_controls(**values): + from cellier.gui.anywidget.visuals import AnywidgetLevelOfDetailControls - defaults = {"initial_lod_bias": 1.0} - defaults.update(kwargs) - return AnywidgetLodBiasSlider(uuid4(), **defaults) + return AnywidgetLevelOfDetailControls(uuid4(), {"settled_lod_bias": 1.0, **values}) def _make_aabb_widget(**kwargs): @@ -756,11 +754,11 @@ def _appearance_changed_event(source_id, visual_id, field, value): ) -def test_lod_bias_slider_user_change_emits_appearance_update(): +def test_level_of_detail_bias_change_emits_appearance_update(): """A user-driven settled_lod_bias change emits AppearanceUpdateEvent.""" from cellier.events import AppearanceUpdateEvent - slider = _make_lod_bias_slider() + slider = _make_level_of_detail_controls() emitted = [] slider.changed.connect(emitted.append) diff --git a/tests/v2/visuals/test_render_plane.py b/tests/v2/visuals/test_render_plane.py new file mode 100644 index 00000000..d3c2614a --- /dev/null +++ b/tests/v2/visuals/test_render_plane.py @@ -0,0 +1,267 @@ +"""``RenderPlane``: constructors, validation, identity (plane rendering 4.1).""" + +from __future__ import annotations + +from uuid import uuid4 + +import numpy as np +import pytest +from pydantic import ValidationError + +from cellier.scene.dims import spatial_axes, world_coordinate_system +from cellier.visuals import MAX_RENDER_PLANES, RenderPlane +from cellier.visuals._render_plane import validate_render_planes + + +@pytest.fixture +def world(): + return world_coordinate_system(spatial_axes("z", "y", "x"), name="world") + + +@pytest.fixture +def world_tzyx(): + return world_coordinate_system( + [("t", "time"), *spatial_axes("z", "y", "x")], name="world" + ) + + +def _frame(plane: RenderPlane) -> np.ndarray: + return np.column_stack([plane.in_plane_axis_0, plane.in_plane_axis_1, plane.normal]) + + +# -- constructors --------------------------------------------------------------- + + +def test_from_point_normal_builds_a_right_handed_unit_frame(world): + plane = RenderPlane.from_point_normal(world, (1, 2, 3), (0, 3, 4)) + frame = _frame(plane) + np.testing.assert_allclose(frame.T @ frame, np.eye(3), atol=1e-12) + np.testing.assert_allclose(plane.normal, (0, 0.6, 0.8), atol=1e-12) + assert plane.origin == (1.0, 2.0, 3.0) + assert plane.coordinate_system == world.id + assert plane.axes == tuple(axis.id for axis in world.axes) + assert plane.extent_0 == (None, None) + assert plane.extent_1 == (None, None) + assert plane.enabled is True + + +def test_with_no_up_axis_0_is_the_axis_least_aligned_with_the_normal(world): + plane = RenderPlane.from_point_normal(world, (0, 0, 0), (0.1, 0.2, 1.0)) + # z is least aligned with the normal: axis 0 is z projected into the plane. + expected = np.array([1.0, 0.0, 0.0]) + normal = np.array(plane.normal) + expected = expected - (expected @ normal) * normal + np.testing.assert_allclose( + plane.in_plane_axis_0, expected / np.linalg.norm(expected), atol=1e-12 + ) + + +def test_up_is_projected_into_the_plane(world): + plane = RenderPlane.from_point_normal(world, (0, 0, 0), (0, 0, 1), up=(0, 1, 5)) + np.testing.assert_allclose(plane.in_plane_axis_0, (0, 1, 0), atol=1e-12) + np.testing.assert_allclose(plane.in_plane_axis_1, (-1, 0, 0), atol=1e-12) + with pytest.raises(ValueError, match="parallel"): + RenderPlane.from_point_normal(world, (0, 0, 0), (0, 0, 1), up=(0, 0, 2)) + + +def test_the_two_constructors_agree(world): + by_normal = RenderPlane.from_point_normal( + world, (4, 5, 6), (1, -2, 0.5), up=(0, 1, 0) + ) + by_rotation = RenderPlane.from_rotation( + world, (4, 5, 6), _frame(by_normal), id=by_normal.id + ) + assert by_rotation == by_normal + + +def test_from_rotation_takes_a_quaternion(world): + # A quarter turn about the third axis of the plane's axes. + half = np.sqrt(0.5) + plane = RenderPlane.from_rotation(world, (0, 0, 0), (0, 0, half, half)) + np.testing.assert_allclose(plane.in_plane_axis_0, (0, 1, 0), atol=1e-12) + np.testing.assert_allclose(plane.in_plane_axis_1, (-1, 0, 0), atol=1e-12) + np.testing.assert_allclose(plane.normal, (0, 0, 1), atol=1e-12) + + +@pytest.mark.parametrize( + "rotation", + [ + np.diag([1.0, 1.0, -1.0]), # a reflection + np.diag([2.0, 1.0, 1.0]), # not orthonormal + np.eye(2), + (1.0, 0.0, 0.0), + ], +) +def test_from_rotation_refuses_what_is_not_a_rotation(world, rotation): + with pytest.raises(ValueError, match="rotation"): + RenderPlane.from_rotation(world, (0, 0, 0), rotation) + + +def test_axes_name_three_world_axes_and_are_stored_as_ids(world_tzyx): + plane = RenderPlane.from_point_normal( + world_tzyx, (1, 2, 3), (0, 0, 1), axes=("z", "y", "x") + ) + assert plane.axes == tuple(world_tzyx.axis_by_name(n).id for n in "zyx") + reordered = RenderPlane.from_point_normal( + world_tzyx, (3, 2, 1), (1, 0, 0), axes=("x", "y", "z") + ) + assert reordered.axes == tuple(world_tzyx.axis_by_name(n).id for n in "xyz") + with pytest.raises(ValueError, match="pass axes="): + RenderPlane.from_point_normal(world_tzyx, (1, 2, 3), (0, 0, 1)) + with pytest.raises(ValueError, match="three axes"): + RenderPlane.from_point_normal(world_tzyx, (1, 2, 3), (0, 0, 1), axes=("z", "y")) + with pytest.raises(KeyError): + RenderPlane.from_point_normal( + world_tzyx, (1, 2, 3), (0, 0, 1), axes=("z", "y", "w") + ) + + +# -- validation ----------------------------------------------------------------- + + +def _raw(world, **fields) -> dict: + return { + "coordinate_system": world.id, + "axes": tuple(axis.id for axis in world.axes), + "origin": (0.0, 0.0, 0.0), + "in_plane_axis_0": (1.0, 0.0, 0.0), + "in_plane_axis_1": (0.0, 1.0, 0.0), + **fields, + } + + +def test_the_in_plane_axes_are_stored_normalised(world): + plane = RenderPlane( + **_raw(world, in_plane_axis_0=(3, 0, 0), in_plane_axis_1=(0, 0, 0.5)) + ) + assert plane.in_plane_axis_0 == (1.0, 0.0, 0.0) + assert plane.in_plane_axis_1 == (0.0, 0.0, 1.0) + assert plane.normal == (0.0, -1.0, 0.0) + + +@pytest.mark.parametrize( + ("fields", "match"), + [ + ({"in_plane_axis_0": (0, 0, 0)}, "non-zero"), + ({"in_plane_axis_1": (float("nan"), 0, 1)}, "finite"), + ({"in_plane_axis_1": (float("inf"), 0, 1)}, "finite"), + ({"in_plane_axis_1": (1.0, 1e-3, 0.0)}, "orthogonal"), + ({"in_plane_axis_1": (1.0, 0.0, 0.0)}, "orthogonal"), + ({"origin": (0.0, float("inf"), 0.0)}, "finite"), + ({"extent_0": (2.0, 2.0)}, "min < max"), + ({"extent_1": (3.0, -3.0)}, "min < max"), + ({"extent_0": (float("-inf"), 1.0)}, "finite number or None"), + ], +) +def test_a_bad_plane_is_refused(world, fields, match): + with pytest.raises(ValidationError, match=match): + RenderPlane(**_raw(world, **fields)) + + +def test_the_axes_must_be_three_distinct(world): + z, y, _x = (axis.id for axis in world.axes) + with pytest.raises(ValidationError, match="distinct"): + RenderPlane(**_raw(world, axes=(z, y, y))) + with pytest.raises(ValidationError): + RenderPlane(**_raw(world, axes=(z, y))) + + +@pytest.mark.parametrize( + "extent", [(None, None), (-2.0, None), (None, 5.0), (-2.0, 5.0), (1.0, 2.0)] +) +def test_each_extent_side_is_bounded_or_not_on_its_own(world, extent): + plane = RenderPlane(**_raw(world, extent_0=extent, extent_1=(None, 0.5))) + assert plane.extent_0 == extent + assert plane.extent_1 == (None, 0.5) + + +# -- identity ------------------------------------------------------------------- + + +def test_the_plane_is_frozen(world): + plane = RenderPlane.from_point_normal(world, (0, 0, 0), (0, 0, 1)) + with pytest.raises(ValidationError): + plane.origin = (1.0, 1.0, 1.0) + + +def test_equality_includes_the_id(world): + one = RenderPlane.from_point_normal(world, (0, 0, 0), (0, 0, 1)) + other = RenderPlane.from_point_normal(world, (0, 0, 0), (0, 0, 1)) + assert one.id != other.id + assert one != other + assert other.model_copy(update={"id": one.id}) == one + moved = one.model_copy(update={"origin": (0.0, 0.0, 9.0)}) + assert moved.id == one.id + assert moved != one + assert hash(one) == hash(other.model_copy(update={"id": one.id})) + + +def test_json_round_trip(world): + plane = RenderPlane.from_point_normal( + world, + (1.5, -2, 3), + (0.3, 1, -0.2), + extent_0=(-4.0, None), + extent_1=(-1.0, 2.5), + enabled=False, + ) + again = RenderPlane.model_validate_json(plane.model_dump_json()) + assert again == plane + assert again.axes == plane.axes # ids, not their text + + +def test_axis_names_are_kept_as_names(world): + plane = RenderPlane(**_raw(world, axes=("z", "y", "x"))) + assert plane.axes == ("z", "y", "x") + assert RenderPlane.model_validate_json(plane.model_dump_json()) == plane + + +def test_a_given_id_is_kept(world): + plane_id = uuid4() + plane = RenderPlane.from_point_normal(world, (0, 0, 0), (1, 0, 0), id=plane_id) + assert plane.id == plane_id + + +def test_a_tuple_is_validated_and_the_cap_is_four(world): + plane = RenderPlane.from_point_normal(world, (0, 0, 0), (0, 0, 1)) + assert validate_render_planes([plane, plane.model_dump()]) == (plane, plane) + with pytest.raises(ValidationError): + validate_render_planes([plane, "not a plane"]) + assert MAX_RENDER_PLANES == 4 + + +# -- on a visual ------------------------------------------------------------------ + + +def test_a_visual_with_render_planes_round_trips_through_json(world): + from cellier.visuals import ImageVisual, InMemoryImageSingleAppearance + + plane = RenderPlane.from_point_normal(world, (1, 2, 3), (0, 0, 1)) + visual = ImageVisual( + name="image", + data_store_id=str(uuid4()), + single=InMemoryImageSingleAppearance(render_mode="plane"), + render_planes=(plane,), + ) + again = ImageVisual.model_validate_json(visual.model_dump_json()) + assert again.render_planes == (plane,) + assert again.single.render_mode == "plane" + assert again.plane_mode() is True + + +@pytest.mark.parametrize( + "appearance_cls", + [ + "InMemoryImageSingleAppearance", + "InMemoryImageChannelAppearance", + "MultiscaleImageSingleAppearance", + "MultiscaleImageChannelAppearance", + "InMemoryLabelsAppearance", + "MultiscaleLabelsAppearance", + ], +) +def test_plane_is_a_render_mode_of_every_image_and_labels_appearance(appearance_cls): + import cellier.visuals as visuals + + appearance = getattr(visuals, appearance_cls)(render_mode="plane") + assert appearance.render_mode == "plane" From 5deab949fb630fc25c7439cd19decd32c1f23f4f Mon Sep 17 00:00:00 2001 From: Kevin Yamauchi Date: Wed, 7 Oct 2026 07:20:14 +0200 Subject: [PATCH 06/24] add to visuals --- src/cellier/controller.py | 6 + src/cellier/render/_level_of_detail.py | 34 +- src/cellier/render/_plane_mode.py | 32 - src/cellier/render/_plane_planning.py | 496 +++++++++++ src/cellier/render/_render_planes.py | 246 ++++++ src/cellier/render/render_manager.py | 10 + src/cellier/render/shaders/_image_volume.py | 57 +- .../render/shaders/_label_multiscale.py | 40 +- src/cellier/render/shaders/_label_volume.py | 45 +- .../shaders/_multiscale_volume_brick.py | 35 +- .../render/shaders/wgsl/image_volume.wgsl | 46 + .../render/shaders/wgsl/label_volume.wgsl | 62 ++ .../shaders/wgsl/label_volume_brick.wgsl | 66 ++ .../shaders/wgsl/multiscale_volume_brick.wgsl | 51 +- .../render/shaders/wgsl/render_planes.wgsl | 66 ++ src/cellier/render/visuals/_chunked.py | 33 + src/cellier/render/visuals/_image.py | 80 +- src/cellier/render/visuals/_image_memory.py | 18 +- src/cellier/render/visuals/_label_memory.py | 6 +- .../render/visuals/_label_multiscale.py | 82 +- tests/_plane_fixtures.py | 12 +- tests/render/_plane_rig.py | 338 +++++++ tests/render/_planes.py | 429 +++++++++ tests/render/test_plane_planning.py | 706 +++++++++++++++ tests/render/test_plane_rendering_memory.py | 829 ++++++++++++++++++ .../render/test_plane_rendering_multiscale.py | 560 ++++++++++++ tests/render/test_render_planes_controller.py | 15 +- 27 files changed, 4329 insertions(+), 71 deletions(-) delete mode 100644 src/cellier/render/_plane_mode.py create mode 100644 src/cellier/render/_plane_planning.py create mode 100644 src/cellier/render/_render_planes.py create mode 100644 src/cellier/render/shaders/wgsl/render_planes.wgsl create mode 100644 tests/render/_plane_rig.py create mode 100644 tests/render/_planes.py create mode 100644 tests/render/test_plane_planning.py create mode 100644 tests/render/test_plane_rendering_memory.py create mode 100644 tests/render/test_plane_rendering_multiscale.py diff --git a/src/cellier/controller.py b/src/cellier/controller.py index cb7e339d..10da0b7d 100644 --- a/src/cellier/controller.py +++ b/src/cellier/controller.py @@ -2588,6 +2588,12 @@ def _register_visual( self._render_manager.set_visual_clipping_planes( visual_model.id, visual_model.clipping_planes ) + if getattr(visual_model, "render_planes", ()): + # The bus carries later changes; a visual constructed with + # planes draws them on its first frame. + self._render_manager.set_visual_render_planes( + visual_model.id, visual_model.render_planes + ) # psygnal bridges if hasattr(visual_model, "appearance"): diff --git a/src/cellier/render/_level_of_detail.py b/src/cellier/render/_level_of_detail.py index afb9ce8e..2e0ab867 100644 --- a/src/cellier/render/_level_of_detail.py +++ b/src/cellier/render/_level_of_detail.py @@ -156,7 +156,12 @@ def _reaches_rows(grid: dict, rows: np.ndarray) -> np.ndarray: rows = np.asarray(rows, dtype=np.float64) normals = rows[:, :3] abs_normals = np.abs(normals) - reach = abs_normals @ grid["half_extents"] + half_extents = np.asarray(grid["half_extents"], dtype=np.float64) + if half_extents.ndim == 2: + # One box size per brick (``owned_cell_boxes``): (M, N). + reach = half_extents @ abs_normals.T + else: + reach = abs_normals @ half_extents signed = grid["centres"] @ normals.T + rows[:, 3] magnitude = abs_normals @ grid["centre_abs_max"] + reach + np.abs(rows[:, 3]) return (signed + reach >= -1e-9 * magnitude).all(axis=1) @@ -176,7 +181,9 @@ def cull_mask( Parameters ---------- grid : dict - One level of ``build_level_grids``. + One level of ``build_level_grids``, or any dict with its ``arr``, + ``centres``, ``half_extents`` and ``centre_abs_max``; + ``half_extents`` may be ``(M, 3)``, one box size per brick. clip_rows : ndarray, shape (N, 4), or None Half-spaces ``n . p + d >= 0`` in the space of ``grid["centres"]``. A brick is kept when its box reaches the kept side of every row. @@ -226,6 +233,7 @@ def select_levels_from_cache( thresholds: list[float] | None = None, base_layout: BlockLayout3D | None = None, keep: list[np.ndarray] | None = None, + metric: np.ndarray | None = None, ) -> np.ndarray: """Select LOD levels using precomputed coarse grid data. @@ -261,6 +269,11 @@ def select_levels_from_cache( keep : list of ndarray of bool, or None Output of ``cull_masks``: per level, the bricks to rank. The result is the unculled result with the other rows removed, in order. + metric : ndarray, shape (3,), or None + Per-axis factor applied to every offset from the camera before its + length is taken, so *thresholds* can be distances in another unit + than the grids' (world units, for the plane level rule). ``None`` + measures in the grids' own space. Returns ------- @@ -307,11 +320,15 @@ def select_levels_from_cache( arr_k = arr_k[kept] diff = centres - cam + half_extents = grid["half_extents"] + if metric is not None: + diff = diff * metric + half_extents = half_extents * metric dist = np.sqrt((diff * diff).sum(axis=1)) if level > 1: abs_d = np.abs(diff) - max_corner_dist = np.sqrt(((abs_d + grid["half_extents"]) ** 2).sum(axis=1)) + max_corner_dist = np.sqrt(((abs_d + half_extents) ** 2).sum(axis=1)) else: max_corner_dist = dist @@ -477,6 +494,7 @@ def sort_arr_by_distance( block_size: int, scale_vecs_shader: np.ndarray | list[np.ndarray] | None = None, translation_vecs_shader: np.ndarray | list[np.ndarray] | None = None, + metric: np.ndarray | None = None, ) -> np.ndarray: """Sort brick rows nearest-to-camera first. @@ -503,6 +521,11 @@ def sort_arr_by_distance( translation_vecs_shader : list of ndarray, optional Per-level translation vectors in shader order ``(tx, ty, tz)``. Index 0 = level 1 (finest). + metric : ndarray, shape (3,), or None + Per-axis factor applied to every offset from the camera before its + length is taken: world units per level-0 voxel, to sort by world + distance (the plane render mode). ``None`` sorts by distance in + level-0 voxels. Returns ------- @@ -524,7 +547,10 @@ def sort_arr_by_distance( scale, translation = s[:, None], t[:, None] centres = brick_centre_data(index, block_size, scale, translation) - distances = np.sqrt(((centres - cam[:3]) ** 2).sum(axis=1)) + diff = centres - cam[:3] + if metric is not None: + diff = diff * metric + distances = np.sqrt((diff**2).sum(axis=1)) order = np.argsort(distances, kind="stable") return arr[order] diff --git a/src/cellier/render/_plane_mode.py b/src/cellier/render/_plane_mode.py deleted file mode 100644 index 93447006..00000000 --- a/src/cellier/render/_plane_mode.py +++ /dev/null @@ -1,32 +0,0 @@ -"""The ``"plane"`` render mode in the render layer. - -Plane rendering design v3: the model and the controller know the mode from -Phase 5; the shaders that draw a plane arrive with Phases 6 (in-memory) and -7 (multiscale). Until then a material asked for ``"plane"`` is given its -family's default volume mode, so a visual in plane mode builds and draws -without a shader that has no branch for it. Each caller of -:func:`volume_mode_until_planes_draw` is a place one of those phases -replaces. -""" - -from __future__ import annotations - -from cellier.visuals._render_plane import PLANE_RENDER_MODE - - -def volume_mode_until_planes_draw(render_mode: str, fallback: str) -> str: - """*render_mode*, or *fallback* in its place when it is ``"plane"``. - - Parameters - ---------- - render_mode : str - A visual's render mode. - fallback : str - The volume mode the material draws until it can draw a plane. - - Returns - ------- - str - A mode the material's shader has a branch for. - """ - return fallback if render_mode == PLANE_RENDER_MODE else render_mode diff --git a/src/cellier/render/_plane_planning.py b/src/cellier/render/_plane_planning.py new file mode 100644 index 00000000..ca80259f --- /dev/null +++ b/src/cellier/render/_plane_planning.py @@ -0,0 +1,496 @@ +"""Brick planning for the ``"plane"`` render mode (plane rendering design v3, 6). + +A multiscale visual in plane mode reads only the bricks its render planes +cross, at levels picked by a rule of the planes' own: + +- **Culling rows** (6.2). Each plane becomes half-spaces in level-0 data + coordinates, the space the frustum and clipping rows are in: a thin slab + either side of the plane, and one row per bounded side of its extent. + A brick is kept when a plane reaches the part of the volume the brick + *owns* in the lookup table, which is the brick a sample there reads + (``owned_cell_boxes``). The planes are a union, so they are applied as + a mask before the level bands, not appended to the row list. +- **The level rule** (6.3). A level's voxel is measured in the plane: its + largest world size over every in-plane direction. A brick draws level + ``k`` where a screen pixel at its distance, times the bias, is between + the voxels of levels ``k - 1`` and ``k``: the 2D rule's transition, + applied per brick. +- **Orthographic** (6.3): the whole visual takes one level by the same + measure, and a plan that does not fit the atlas takes the next coarser + level until it does. + +``plan_plane_bricks`` is the one planner both multiscale families call in +plane mode. In a volume mode they keep their own planner. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import numpy as np + +from cellier.render._frustum import bricks_in_frustum_arr +from cellier.render._level_of_detail import ( + cull_mask, + select_levels_arr_forced, + select_levels_from_cache, + sort_arr_by_distance, +) +from cellier.render.lut_indirection._cell_brick_rule import ( + level_brick_counts, + level_cell_spans, +) + +if TYPE_CHECKING: + from collections.abc import Callable + + from cellier.render._render_planes import ReducedPlanes + +#: The slab kept either side of a plane, in level-0 voxels. A sample at +#: the plane reads the brick that owns the place it is in (and, with linear +#: sampling, its neighbours from that brick's own padding), so the bricks +#: the plane passes through are enough. The slab only covers the +#: difference between float64 here and float32 in the shader for a plane +#: lying on a brick face, where both sides are then kept. +PLANE_REACH_VOXELS = 1e-3 + + +@dataclass(frozen=True) +class PlanePlanning: + """A visual's render planes, as the brick planner takes them. + + Parameters + ---------- + row_sets : list[np.ndarray] + One ``(N_i, 4)`` array of culling rows per plane: ``n . p + d >= 0`` + in level-0 data coordinates, pygfx order. + voxel_size : np.ndarray + ``(n_levels,)``: the world size of one voxel of each level in the + planes (6.3's ``size(k)``); with several planes, the smallest. + metric : np.ndarray + ``(3,)``: world units per level-0 voxel along each data axis, pygfx + order. Turns a distance in level-0 voxels into a world distance. + """ + + row_sets: list[np.ndarray] + voxel_size: np.ndarray + metric: np.ndarray + + +def owned_cell_boxes(geo) -> list[dict]: + """Per level, the box of the volume each brick owns in the lookup table. + + The shaders find a brick through the LUT: a position is in one base + cell (a level-0 brick), and the cell names the level-k brick that owns + it, by the one rule of ``_cell_brick_rule``. The box of the cells a + brick owns is therefore where that brick is read from. On a pyramid + with integer level ratios and block-averaged translations it is the + brick's own box; otherwise the two differ by up to a voxel of the + level, and a cull on the brick's own box would drop a brick the plane + is drawn from. + + Cached on *geo* for as long as its level grids are the same. + + Parameters + ---------- + geo : MultiscaleBrickLayout3D + The visual's brick layout. + + Returns + ------- + list[dict] + One per level, shaped like a level of ``build_level_grids``: + ``arr``, ``centres`` and ``half_extents`` (both ``(M, 3)``, level-0 + data coordinates, pygfx order), ``centre_abs_max``, and ``owns`` + (``(M,)`` bool: the brick owns at least one cell). + """ + cached = getattr(geo, "_owned_cell_boxes", None) + if cached is not None and cached[0] is geo._level_grids: + return cached[1] + block = int(geo.block_size) + grid_dims = np.asarray(geo.base_layout.grid_dims, dtype=np.int64) + extent = np.asarray(geo.level_shapes[0], dtype=np.float64) + spans = level_cell_spans(geo.n_levels, 3, geo._scale_vecs_data) + counts = level_brick_counts(spans, grid_dims, block, geo.level_shapes) + boxes = [] + for level, grid in enumerate(geo._level_grids): + brick = grid["arr"][:, 1:4].astype(np.int64) # (gz, gy, gx) + span = np.asarray(spans[level], dtype=np.int64) + count = np.asarray(counts[level], dtype=np.int64) + start = np.minimum(brick * span, grid_dims) + stop = np.where( + brick >= count - 1, grid_dims, np.minimum((brick + 1) * span, grid_dims) + ) + # Voxel i is centred on i: cells [start, stop) cover this interval. + low = start * block - 0.5 + high = np.minimum(stop * block, extent) - 0.5 + centres = ((low + high) / 2.0)[:, ::-1] + boxes.append( + { + "arr": grid["arr"], + "centres": centres, + "half_extents": ((high - low) / 2.0)[:, ::-1], + "centre_abs_max": np.abs(centres).max(axis=0), + "owns": (stop > start).all(axis=1), + } + ) + geo._owned_cell_boxes = (geo._level_grids, boxes) + return boxes + + +def data_to_world_affine( + to_level0: Callable[[np.ndarray], np.ndarray], +) -> tuple[np.ndarray, np.ndarray]: + """``world = linear @ data + offset`` from the visual's world-to-data map. + + Parameters + ---------- + to_level0 : callable + Maps ``(N, 3)`` points of the scene's rendered space (pygfx order) + to level-0 data coordinates of the displayed axes (pygfx order). + Affine on the displayed axes. + + Returns + ------- + linear : np.ndarray + ``(3, 3)``. + offset : np.ndarray + ``(3,)``. + """ + probes = np.concatenate([np.zeros((1, 3)), np.eye(3)]) + mapped = np.asarray(to_level0(probes), dtype=np.float64) + world_to_data = (mapped[1:] - mapped[0]).T + linear = np.linalg.inv(world_to_data) + return linear, -linear @ mapped[0] + + +def plane_voxel_sizes( + reduced: ReducedPlanes, linear: np.ndarray, level_scales: np.ndarray +) -> np.ndarray: + """The world size of one voxel of each level, measured in the planes. + + For a plane with in-plane axes ``B`` (3 x 2) and a level with per-axis + voxel size ``s`` (level-0 voxels), a unit in-plane direction ``B c`` + crosses ``|diag(1 / s) W B c|`` voxels per world unit, ``W`` being + ``world -> data``. A voxel is longest along the ``c`` that makes that + least: ``1 / sqrt(smallest eigenvalue of B^T W^T diag(1 / s^2) W B)``. + Over every in-plane direction, so the answer does not change when the + frame is spun about the normal. + + Parameters + ---------- + reduced : ReducedPlanes + The planes the view draws; at least one. + linear : np.ndarray + ``(3, 3)`` ``data -> world`` linear block, pygfx order. + level_scales : np.ndarray + ``(n_levels, 3)``: each level's voxel in level-0 voxels, pygfx order. + + Returns + ------- + np.ndarray + ``(n_levels,)``. With several planes, the smallest over them: no + plane is drawn coarser than asked. + """ + world_to_data = np.linalg.inv(linear) + scales = np.asarray(level_scales, dtype=np.float64) + sizes = np.full(len(scales), np.inf) + for axis_0, axis_1 in zip(reduced.axis_0, reduced.axis_1, strict=True): + in_data = world_to_data @ np.stack([axis_0, axis_1], axis=1) # (3, 2) + for level, scale in enumerate(scales): + voxels = in_data / scale[:, None] + smallest = float(np.linalg.eigvalsh(voxels.T @ voxels)[0]) + size = np.inf if smallest <= 0.0 else 1.0 / np.sqrt(smallest) + sizes[level] = min(sizes[level], size) + return sizes + + +def _half_spaces(reduced: ReducedPlanes, index: int, reach_world: float) -> list: + """Plane *index*'s region as world half-spaces ``n . w + c >= 0``.""" + origin = reduced.origin[index] + axis_0, axis_1 = reduced.axis_0[index], reduced.axis_1[index] + normal = np.cross(axis_0, axis_1) + offset = float(normal @ origin) + rows = [(normal, reach_world - offset), (-normal, reach_world + offset)] + extent, bounded = reduced.extent[index], reduced.bounded[index] + for axis, low, high in ((axis_0, 0, 1), (axis_1, 2, 3)): + along = float(axis @ origin) + if bounded[low]: + rows.append((axis, -along - float(extent[low]))) + if bounded[high]: + rows.append((-axis, along + float(extent[high]))) + return rows + + +def plane_row_sets( + reduced: ReducedPlanes, linear: np.ndarray, offset: np.ndarray +) -> list[np.ndarray]: + """Each plane as culling rows (design 6.2). + + A world half-space ``n . w + c >= 0`` is, with ``w = A d + b``, the + data half-space ``(A^T n) . d + (n . b + c) >= 0``: the normal maps as a + covector. + + Parameters + ---------- + reduced : ReducedPlanes + The planes the view draws. + linear, offset : np.ndarray + ``data -> world``, pygfx order. + + Returns + ------- + list[np.ndarray] + One ``(N, 4)`` array per plane: two rows for the slab, one for each + bounded side of the extent. + """ + normals = reduced.normal + # A level-0 voxel's box, in world, spans |A| 1 along each world axis. + voxel_world = np.abs(linear).sum(axis=1) + out = [] + for index in range(len(reduced)): + reach = PLANE_REACH_VOXELS * float(np.abs(normals[index]) @ voxel_world) + out.append( + np.array( + [ + (*(linear.T @ n), float(n @ offset) + c) + for n, c in _half_spaces(reduced, index, reach) + ], + dtype=np.float64, + ) + ) + return out + + +def build_plane_planning( + reduced: ReducedPlanes, + to_level0: Callable[[np.ndarray], np.ndarray], + level_scales: np.ndarray, +) -> PlanePlanning: + """What the brick planner needs of the planes one view draws. + + Parameters + ---------- + reduced : ReducedPlanes + The planes the view draws, in pygfx order. May be empty: the plan + is then empty. + to_level0 : callable + The visual's rendered-space to level-0 data map, pygfx order. + level_scales : np.ndarray + ``(n_levels, 3)``: each level's voxel in level-0 voxels, pygfx order. + + Returns + ------- + PlanePlanning + """ + linear, offset = data_to_world_affine(to_level0) + scales = np.asarray(level_scales, dtype=np.float64) + if len(reduced) == 0: + return PlanePlanning( + row_sets=[], + voxel_size=np.full(len(scales), np.inf), + metric=np.linalg.norm(linear, axis=0), + ) + return PlanePlanning( + row_sets=plane_row_sets(reduced, linear, offset), + voxel_size=plane_voxel_sizes(reduced, linear, scales), + metric=np.linalg.norm(linear, axis=0), + ) + + +def plane_thresholds( + voxel_size: np.ndarray, fov_y_rad: float, screen_height_px: float, bias: float +) -> list[float]: + """World distances at which a plane's bricks change level (design 6.3). + + A screen pixel at distance ``d`` covers ``d / focal`` world units. + Level ``k + 1`` takes over where that, times the bias, reaches the + geometric mean of the two levels' voxels. + + Returns + ------- + list[float] + ``n_levels - 1`` distances, as ``select_levels_from_cache`` takes + them. + """ + focal = (screen_height_px / 2.0) / np.tan(fov_y_rad / 2.0) + bias = max(bias, 1e-6) + return [ + float(np.sqrt(voxel_size[k] * voxel_size[k + 1])) * focal / bias + for k in range(len(voxel_size) - 1) + ] + + +def plane_level_orthographic( + voxel_size: np.ndarray, + view_height_world: float, + screen_height_px: float, + bias: float, +) -> int | None: + """The one level an orthographic view draws a plane at (design 6.3). + + Returns + ------- + int or None + 1-indexed, as ``select_levels_arr_forced`` takes it; ``None`` for a + degenerate view. + """ + if view_height_world <= 0 or screen_height_px <= 0: + return None + biased = view_height_world / screen_height_px * max(bias, 1e-6) + level = 1 + for k in range(len(voxel_size) - 1): + if biased < float(np.sqrt(voxel_size[k] * voxel_size[k + 1])): + break + level = k + 2 + return level + + +def plane_cull_masks( + geo, planning: PlanePlanning, clip_rows: np.ndarray | None +) -> list[np.ndarray]: + """Per level, the bricks a plane is drawn from that no clipping row drops. + + A brick is kept when a plane reaches the box of the cells it owns + (:func:`owned_cell_boxes`) and its own box reaches the kept side of + every clipping row, as in a volume mode. + """ + masks = [] + for grid, owned in zip(geo._level_grids, owned_cell_boxes(geo), strict=True): + keep = owned["owns"] & cull_mask(owned, None, plane_row_sets=planning.row_sets) + if clip_rows is not None: + keep &= cull_mask(grid, clip_rows) + masks.append(keep) + return masks + + +def plan_plane_bricks( + geo, + planning: PlanePlanning, + *, + clip_rows: np.ndarray | None, + camera_pos_data: np.ndarray, + frustum_planes: np.ndarray | None, + fov_y_rad: float, + screen_height_px: float, + bias: float, + force_level: int | None, + view_height_world: float = 0.0, + max_bricks: int | None = None, +) -> tuple[np.ndarray, dict]: + """Plan the bricks a visual's render planes draw, nearest in world first. + + Cull to the planes and the clipping rows, then the plane's level bands + (or the forced or orthographic level), the distance sort and the + frustum test (design 6.1). + + Parameters + ---------- + geo : MultiscaleBrickLayout3D + The visual's brick layout: level grids, scales, block size. + planning : PlanePlanning + From :func:`build_plane_planning`. + clip_rows : np.ndarray or None + The visual's clipping planes as culling rows. + camera_pos_data : np.ndarray + The camera in level-0 data coordinates, pygfx order. + frustum_planes : np.ndarray or None + The frustum as culling rows, or ``None`` not to cull to it. + fov_y_rad, screen_height_px, bias : + The view and the settled bias. + force_level : int or None + 1-indexed level that replaces the rule. + view_height_world : float + The visible world height of an orthographic view (``fov_y_rad`` 0). + max_bricks : int or None + The room the atlas has for the target. An orthographic plan over + it takes the next coarser level until it fits (D-P48); a + perspective plan is left to the truncation that follows. + + Returns + ------- + arr : np.ndarray + ``(N, 4)`` rows ``[level, g0, g1, g2]``. + stats : dict + ``n_ranked`` (bricks left by the cull, summed over the levels the + rule could use), ``n_selected`` (before the frustum test), + ``level`` (the one level of a forced or orthographic plan, else + ``None``) and ``levels_stepped`` (coarser steps taken to fit). + """ + grids = geo._level_grids + n_levels = geo.n_levels + keep = plane_cull_masks(geo, planning, clip_rows) + rows = frustum_planes + if clip_rows is not None: + rows = clip_rows if rows is None else np.concatenate([rows, clip_rows]) + + def finish(arr: np.ndarray) -> tuple[np.ndarray, int]: + arr = sort_arr_by_distance( + arr, + camera_pos_data, + geo.block_size, + scale_vecs_shader=geo._scale_arr_shader, + translation_vecs_shader=geo._translation_arr_shader, + # Nearest in world units, as the level rule measures: this is + # the order bricks load in and what a full atlas keeps. + metric=planning.metric, + ) + selected = len(arr) + if rows is not None: + arr, _ = bricks_in_frustum_arr( + arr, + geo.block_size, + rows, + level_scale_arr_shader=geo._scale_arr_shader, + level_translation_arr_shader=geo._translation_arr_shader, + ) + return arr, selected + + def at_level(level: int) -> tuple[np.ndarray, int]: + return finish( + select_levels_arr_forced(geo.base_layout, level, grids, keep=keep) + ) + + stats = {"n_ranked": 0, "n_selected": 0, "level": None, "levels_stepped": 0} + orthographic = force_level is None and fov_y_rad <= 0 + level = force_level + if orthographic: + level = plane_level_orthographic( + planning.voxel_size, view_height_world, screen_height_px, bias + ) + if level is not None: + level = min(max(int(level), 1), n_levels) + arr, selected = at_level(level) + while ( + orthographic + and max_bricks is not None + and len(arr) > max_bricks + and level < n_levels + ): + level += 1 + stats["levels_stepped"] += 1 + arr, selected = at_level(level) + stats.update( + n_ranked=int(keep[level - 1].sum()), n_selected=selected, level=level + ) + return arr, stats + + thresholds = None + if fov_y_rad > 0: + thresholds = plane_thresholds( + planning.voxel_size, fov_y_rad, screen_height_px, bias + ) + arr, selected = finish( + select_levels_from_cache( + grids, + n_levels, + camera_pos_data, + thresholds=thresholds, + base_layout=geo.base_layout, + keep=keep, + metric=planning.metric if thresholds is not None else None, + ) + ) + stats.update(n_ranked=int(sum(int(k.sum()) for k in keep)), n_selected=selected) + return arr, stats diff --git a/src/cellier/render/_render_planes.py b/src/cellier/render/_render_planes.py new file mode 100644 index 00000000..b4995631 --- /dev/null +++ b/src/cellier/render/_render_planes.py @@ -0,0 +1,246 @@ +"""Render planes in the render layer (plane rendering design v3, 5.3, 6.2). + +A visual's render planes are authored in the scene's world space, on three +named world axes. ``reduce_render_planes`` turns them into what one 3D +view draws: frames in the scene's rendered space, in pygfx ``(x, y, z)`` +order. Only enabled planes whose axes are the displayed ones are kept. + +The planes reach the shaders through one uniform buffer per render visual, +bound by every material of the visual that is built for the ``"plane"`` +render mode. A change of planes is therefore one buffer write, whatever +the number of channels, and a material created later needs nothing. + +``RenderPlanesMixin`` gives a render visual that buffer and the one seam +through which planes reach it. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +import numpy as np +import pygfx as gfx +from pygfx.utils import array_from_shadertype + +from cellier.visuals._render_plane import MAX_RENDER_PLANES + +if TYPE_CHECKING: + from collections.abc import Iterable, Sequence + + from cellier.render._spaces import RenderSpaces + +#: What an unbounded extent side is uploaded as. Not infinity: an infinity +#: times zero in the shader is NaN. +UNBOUNDED = 1.0e30 + +#: The uniform block ``u_render_planes`` (design 5.3). +RENDER_PLANES_SHADERTYPE: dict[str, str] = { + "origin": f"{MAX_RENDER_PLANES}*4xf4", + "axis_0": f"{MAX_RENDER_PLANES}*4xf4", + "axis_1": f"{MAX_RENDER_PLANES}*4xf4", + "extent": f"{MAX_RENDER_PLANES}*4xf4", + "count": "i4", +} + +#: The WGSL struct name of the block. +RENDER_PLANES_STRUCT = "RenderPlanes" + + +@dataclass(frozen=True) +class ReducedPlanes: + """The planes one 3D view draws, in pygfx ``(x, y, z)`` order. + + Parameters + ---------- + origin, axis_0, axis_1 : np.ndarray + ``(n, 3)`` float64. + extent : np.ndarray + ``(n, 4)`` float64, ``(min_0, max_0, min_1, max_1)``; an unbounded + side is ``-+UNBOUNDED``. + bounded : np.ndarray + ``(n, 4)`` bool: which sides of ``extent`` are finite. + """ + + origin: np.ndarray + axis_0: np.ndarray + axis_1: np.ndarray + extent: np.ndarray + bounded: np.ndarray + + def __len__(self) -> int: + return len(self.origin) + + @property + def normal(self) -> np.ndarray: + """``(n, 3)``: ``axis_0 x axis_1``.""" + return np.cross(self.axis_0, self.axis_1).reshape(-1, 3) + + +def _no_planes() -> ReducedPlanes: + return ReducedPlanes( + origin=np.zeros((0, 3)), + axis_0=np.zeros((0, 3)), + axis_1=np.zeros((0, 3)), + extent=np.zeros((0, 4)), + bounded=np.zeros((0, 4), dtype=bool), + ) + + +def reduce_render_planes( + spaces: RenderSpaces | None, planes: Sequence[Any] +) -> ReducedPlanes: + """Reduce world-space render planes to one view's rendered space. + + A plane is kept when it is enabled and its three axes are the world + axes the view displays. Its origin and in-plane axes are carried + through ``world -> rendered`` (a reorder of the displayed axes) and + reversed to pygfx order. + + Parameters + ---------- + spaces : RenderSpaces or None + The systems the visual is placed with. ``None``, or a 2D view, + keeps no plane. + planes : Sequence[RenderPlane] + The visual's planes. + + Returns + ------- + ReducedPlanes + The planes the view draws, in the order given. + """ + if spaces is None or not planes: + return _no_planes() + linear = np.asarray(spaces.world_to_rendered.linear, dtype=np.float64) + if linear.shape[0] != 3: + return _no_planes() + translation = np.asarray(spaces.world_to_rendered.translation, dtype=np.float64) + displayed = set(np.flatnonzero(np.any(linear != 0.0, axis=0)).tolist()) + signs = np.array([-1.0, 1.0, -1.0, 1.0]) + origins, axes_0, axes_1, extents, bounded = [], [], [], [], [] + for plane in planes: + if not plane.enabled: + continue + try: + axes = [spaces.world.resolve(axis) for axis in plane.axes] + except (KeyError, ValueError): + continue + if set(axes) != displayed: + continue + block = linear[:, axes] + # Rendered order is pygfx order reversed. + origins.append((block @ np.asarray(plane.origin) + translation)[::-1]) + axes_0.append((block @ np.asarray(plane.in_plane_axis_0))[::-1]) + axes_1.append((block @ np.asarray(plane.in_plane_axis_1))[::-1]) + sides = (*plane.extent_0, *plane.extent_1) + bounded.append([side is not None for side in sides]) + extents.append( + [ + sign * UNBOUNDED if side is None else float(side) + for side, sign in zip(sides, signs, strict=True) + ] + ) + if not origins: + return _no_planes() + return ReducedPlanes( + origin=np.asarray(origins, dtype=np.float64), + axis_0=np.asarray(axes_0, dtype=np.float64), + axis_1=np.asarray(axes_1, dtype=np.float64), + extent=np.asarray(extents, dtype=np.float64), + bounded=np.asarray(bounded, dtype=bool), + ) + + +def make_render_planes_buffer() -> gfx.Buffer: + """A ``u_render_planes`` uniform buffer holding no plane.""" + return gfx.Buffer( + array_from_shadertype(RENDER_PLANES_SHADERTYPE), force_contiguous=True + ) + + +def write_render_planes(buffer: gfx.Buffer, reduced: ReducedPlanes) -> bool: + """Write *reduced* to a ``u_render_planes`` buffer. + + Parameters + ---------- + buffer : gfx.Buffer + From :func:`make_render_planes_buffer`. + reduced : ReducedPlanes + At most ``MAX_RENDER_PLANES`` planes. + + Returns + ------- + bool + Whether the buffer changed. + """ + count = len(reduced) + if count > MAX_RENDER_PLANES: + raise ValueError( + f"A visual draws at most {MAX_RENDER_PLANES} render planes; got {count}." + ) + new = np.zeros_like(buffer.data) + new["origin"][:count, :3] = reduced.origin + new["origin"][:count, 3] = 1.0 + new["axis_0"][:count, :3] = reduced.axis_0 + new["axis_1"][:count, :3] = reduced.axis_1 + new["extent"][:count] = reduced.extent + new["count"] = count + if new.tobytes() == buffer.data.tobytes(): + return False + buffer.data[...] = new + buffer.update_full() + return True + + +class RenderPlanesMixin: + """The one way render planes reach a render visual's shaders. + + A host class: + + - gives ``self.render_planes_buffer`` to every material it builds that + can draw the ``"plane"`` render mode; + - calls :meth:`_apply_render_planes` wherever it is given its render + spaces (the displayed axes may have changed). + + It reads ``self._spaces``. + """ + + _render_planes: tuple = () + _render_planes_buffer: gfx.Buffer | None = None + _reduced_render_planes: ReducedPlanes | None = None + + @property + def render_planes(self) -> tuple: + """The planes last given to this visual.""" + return self._render_planes + + @property + def render_planes_buffer(self) -> gfx.Buffer: + """The visual's ``u_render_planes`` buffer, shared by its materials.""" + if self._render_planes_buffer is None: + self._render_planes_buffer = make_render_planes_buffer() + return self._render_planes_buffer + + @property + def drawn_render_planes(self) -> ReducedPlanes: + """The planes the current view draws, in pygfx order.""" + if self._reduced_render_planes is None: + self._reduced_render_planes = reduce_render_planes( + getattr(self, "_spaces", None), self._render_planes + ) + return self._reduced_render_planes + + def set_render_planes(self, planes: Iterable[Any]) -> None: + """Adopt *planes* and write the drawn ones to the uniform buffer.""" + self._render_planes = tuple(planes) + self._apply_render_planes() + + def on_render_planes_changed(self, event: Any) -> None: + """Bus handler for ``RenderPlanesChangedEvent``.""" + self.set_render_planes(event.render_planes) + + def _apply_render_planes(self) -> None: + """Reduce the stored planes for the view and upload them.""" + self._reduced_render_planes = None + write_render_planes(self.render_planes_buffer, self.drawn_render_planes) diff --git a/src/cellier/render/render_manager.py b/src/cellier/render/render_manager.py index 3e0a1b60..3cd90819 100644 --- a/src/cellier/render/render_manager.py +++ b/src/cellier/render/render_manager.py @@ -1823,6 +1823,16 @@ def set_visual_clipping_planes(self, visual_id: UUID, planes: Any) -> None: if setter is not None: setter(planes) + def set_visual_render_planes(self, visual_id: UUID, planes: Any) -> None: + """Hand a visual its render planes; ignored if it draws none.""" + scene_id = self._visual_to_scene.get(visual_id) + scene_manager = self._scenes.get(scene_id) + if scene_manager is None: + return + setter = getattr(scene_manager.get_visual(visual_id), "set_render_planes", None) + if setter is not None: + setter(planes) + def clipping_planes_affect_request(self, visual_id: UUID) -> bool: """Whether a change of planes changes what *visual_id* reads.""" scene_id = self._visual_to_scene.get(visual_id) diff --git a/src/cellier/render/shaders/_image_volume.py b/src/cellier/render/shaders/_image_volume.py index d47a82e2..476038df 100644 --- a/src/cellier/render/shaders/_image_volume.py +++ b/src/cellier/render/shaders/_image_volume.py @@ -29,9 +29,15 @@ import pygfx as gfx from pygfx.objects import Volume -from pygfx.renderers.wgpu import register_wgpu_render_function +from pygfx.renderers.wgpu import Binding, register_wgpu_render_function from pygfx.renderers.wgpu.shaders.volumeshader import VolumeRayShader +from cellier.render._render_planes import ( + RENDER_PLANES_STRUCT, + make_render_planes_buffer, +) +from cellier.visuals._render_plane import PLANE_RENDER_MODE + _WGSL_DIR = Path(__file__).parent / "wgsl" IMAGE_VOLUME_WGSL: str = (_WGSL_DIR / "image_volume.wgsl").read_text() @@ -54,14 +60,44 @@ class ImageVolumeIsoMaterial(gfx.VolumeIsoMaterial): """ +class ImageVolumePlaneMaterial(gfx.VolumeRayMaterial): + """The ``"plane"`` render mode: the volume drawn on its render planes. + + One sample per fragment where the view ray meets the nearest plane + (plane rendering design v3, 5.1). The planes are not state of the + material: they are read from ``render_planes_buffer``, which the render + visual owns and shares between its materials. + + Parameters + ---------- + render_planes_buffer : gfx.Buffer or None + The visual's ``u_render_planes`` buffer. ``None`` gives the + material a buffer of its own with no plane, which draws nothing. + **kwargs : + As for ``gfx.VolumeRayMaterial``. + """ + + render_mode = PLANE_RENDER_MODE + + def __init__(self, render_planes_buffer: gfx.Buffer | None = None, **kwargs): + super().__init__(**kwargs) + self.render_planes_buffer = ( + render_planes_buffer + if render_planes_buffer is not None + else make_render_planes_buffer() + ) + + #: ``InMemoryImageAppearance.render_mode`` -> material class. IMAGE_VOLUME_MATERIALS: dict[str, type] = { "mip": ImageVolumeMipMaterial, "iso": ImageVolumeIsoMaterial, "minip": ImageVolumeMinipMaterial, + PLANE_RENDER_MODE: ImageVolumePlaneMaterial, } +@register_wgpu_render_function(Volume, ImageVolumePlaneMaterial) @register_wgpu_render_function(Volume, ImageVolumeMipMaterial) @register_wgpu_render_function(Volume, ImageVolumeMinipMaterial) @register_wgpu_render_function(Volume, ImageVolumeIsoMaterial) @@ -81,6 +117,25 @@ def __init__(self, wobject) -> None: # stays valid on a canvas using the stock blender. self["write_normal"] = False + def get_bindings(self, wobject, shared, scene): + """The inherited bindings, plus the render planes in plane mode.""" + bindings = super().get_bindings(wobject, shared, scene) + material = wobject.material + if material.render_mode == PLANE_RENDER_MODE: + # Bound for this mode only, so the other modes' shaders are + # what they were before the mode existed. + group = bindings[0] + index = len(group) + group[index] = Binding( + "u_render_planes", + "buffer/uniform", + material.render_planes_buffer, + "FRAGMENT", + structname=RENDER_PLANES_STRUCT, + ) + self.define_binding(0, index, group[index]) + return bindings + def get_code(self) -> str: """Return cellier's volume raycasting WGSL.""" return IMAGE_VOLUME_WGSL diff --git a/src/cellier/render/shaders/_label_multiscale.py b/src/cellier/render/shaders/_label_multiscale.py index e191c9e0..98704259 100644 --- a/src/cellier/render/shaders/_label_multiscale.py +++ b/src/cellier/render/shaders/_label_multiscale.py @@ -25,10 +25,14 @@ from pygfx.renderers.wgpu.shaders.imageshader import ImageShader from pygfx.renderers.wgpu.shaders.volumeshader import BaseVolumeShader -from cellier.render._plane_mode import volume_mode_until_planes_draw +from cellier.render._render_planes import ( + RENDER_PLANES_STRUCT, + make_render_planes_buffer, +) from cellier.render.shaders._label_colormap import ( build_outline_selection_texture, ) +from cellier.visuals._render_plane import PLANE_RENDER_MODE if TYPE_CHECKING: from pygfx.resources import Buffer @@ -83,6 +87,9 @@ class LabelVolumeBrickMaterial(gfx.VolumeBasicMaterial): ray_steps_per_voxel : float Ray-march samples per voxel of the drawn level, measured along the ray. Default is ``1.0``. + render_planes_buffer : Buffer or None + The visual's ``u_render_planes`` buffer, read in ``"plane"`` render + mode. ``None`` gives the material one of its own with no plane. """ uniform_type: ClassVar[dict] = dict( @@ -107,11 +114,17 @@ def __init__( n_entries: int = 0, outline_selection_texture: gfx.Texture | None = None, ray_steps_per_voxel: float = 1.0, + render_planes_buffer: Buffer | None = None, **kwargs, ) -> None: super().__init__(**kwargs) self.uniform_buffer.data["ray_steps_per_voxel"] = float(ray_steps_per_voxel) self.uniform_buffer.update_full() + self.render_planes_buffer = ( + render_planes_buffer + if render_planes_buffer is not None + else make_render_planes_buffer() + ) self.cache_texture = cache_texture self.lut_texture = lut_texture self.brick_max_texture = brick_max_texture @@ -135,6 +148,15 @@ def __init__( else build_outline_selection_texture() ) + @property + def render_mode(self) -> str: + """The render mode; tracked, so a change rebuilds the shader.""" + return self._store.render_mode + + @render_mode.setter + def render_mode(self, value: str) -> None: + self._store.render_mode = value + @property def ray_steps_per_voxel(self) -> float: """Ray-march samples per level-k voxel along the ray (3D).""" @@ -167,9 +189,7 @@ def __init__(self, wobject): # stays valid on a canvas using the stock blender. self["write_outline_id"] = False self["has_outline_selection"] = m.outline_selection_texture is not None - self["render_mode"] = volume_mode_until_planes_draw( - m.render_mode, "iso_categorical" - ) + self["render_mode"] = m.render_mode # Default for the ``normal`` render target. ``write_normal`` is # overridden by CellierBlender.get_shader_kwargs when the target # exists; without it the write compiles away, so the same shader @@ -273,6 +293,18 @@ def get_bindings(self, wobject, shared, scene): "FRAGMENT", ) ) + if material.render_mode == PLANE_RENDER_MODE: + # Bound for this mode only, so the other modes' shaders are + # what they were before the mode existed. + bindings.append( + Binding( + "u_render_planes", + "buffer/uniform", + material.render_planes_buffer, + "FRAGMENT", + structname=RENDER_PLANES_STRUCT, + ) + ) bindings = dict(enumerate(bindings)) self.define_bindings(0, bindings) return {0: bindings} diff --git a/src/cellier/render/shaders/_label_volume.py b/src/cellier/render/shaders/_label_volume.py index ec5e4083..a478c4b3 100644 --- a/src/cellier/render/shaders/_label_volume.py +++ b/src/cellier/render/shaders/_label_volume.py @@ -14,10 +14,14 @@ ) from pygfx.renderers.wgpu.shaders.volumeshader import BaseVolumeShader -from cellier.render._plane_mode import volume_mode_until_planes_draw +from cellier.render._render_planes import ( + RENDER_PLANES_STRUCT, + make_render_planes_buffer, +) from cellier.render.shaders._label_colormap import ( build_outline_selection_texture, ) +from cellier.visuals._render_plane import PLANE_RENDER_MODE _WGSL_DIR = Path(__file__).parent / "wgsl" @@ -37,8 +41,9 @@ class LabelVolumeMaterial(gfx.VolumeBasicMaterial): Frozen after construction. salt : int Hash seed for random mode. - render_mode : "iso_categorical" or "flat_categorical" - Whether to apply Lambertian shading. + render_mode : "iso_categorical", "flat_categorical" or "plane" + Whether to apply Lambertian shading; ``"plane"`` draws the labels + on the render planes instead. A change rebuilds the shader. label_keys_texture : gfx.Texture or None Sorted int32 label-ID texture for direct mode. label_colors_texture : gfx.Texture or None @@ -47,6 +52,9 @@ class LabelVolumeMaterial(gfx.VolumeBasicMaterial): Number of entries in the direct-mode LUT. label_params_buffer : gfx.Buffer Uniform buffer containing background_label, salt, n_entries, _pad. + render_planes_buffer : gfx.Buffer or None + The visual's ``u_render_planes`` buffer, read in ``"plane"`` mode. + ``None`` gives the material one of its own with no plane. """ def __init__( @@ -60,6 +68,7 @@ def __init__( n_entries: int = 0, label_params_buffer=None, outline_selection_texture=None, + render_planes_buffer=None, **kwargs, ) -> None: super().__init__(**kwargs) @@ -67,6 +76,11 @@ def __init__( self.colormap_mode = colormap_mode self.salt = salt self.render_mode = render_mode + self.render_planes_buffer = ( + render_planes_buffer + if render_planes_buffer is not None + else make_render_planes_buffer() + ) self.label_keys_texture = label_keys_texture self.label_colors_texture = label_colors_texture self.n_entries = n_entries @@ -81,6 +95,15 @@ def __init__( else build_outline_selection_texture() ) + @property + def render_mode(self) -> str: + """The render mode; tracked, so a change rebuilds the shader.""" + return self._store.render_mode + + @render_mode.setter + def render_mode(self, value: str) -> None: + self._store.render_mode = value + @register_wgpu_render_function(Volume, LabelVolumeMaterial) class LabelVolumeShader(BaseVolumeShader): @@ -98,9 +121,7 @@ def __init__(self, wobject): # stays valid on a canvas using the stock blender. self["write_outline_id"] = False self["has_outline_selection"] = material.outline_selection_texture is not None - self["render_mode"] = volume_mode_until_planes_draw( - material.render_mode, "iso_categorical" - ) + self["render_mode"] = material.render_mode # Default for the ``normal`` render target. ``write_normal`` is # overridden by CellierBlender.get_shader_kwargs when the target # exists; without it the write compiles away, so the same shader @@ -162,6 +183,18 @@ def get_bindings(self, wobject, shared, scene): "FRAGMENT", ) ) + if material.render_mode == PLANE_RENDER_MODE: + # Bound for this mode only, so the other modes' shaders are + # what they were before the mode existed. + bindings.append( + Binding( + "u_render_planes", + "buffer/uniform", + material.render_planes_buffer, + "FRAGMENT", + structname=RENDER_PLANES_STRUCT, + ) + ) bindings = dict(enumerate(bindings)) self.define_bindings(0, bindings) return {0: bindings} diff --git a/src/cellier/render/shaders/_multiscale_volume_brick.py b/src/cellier/render/shaders/_multiscale_volume_brick.py index 0cc6319c..79dbffa1 100644 --- a/src/cellier/render/shaders/_multiscale_volume_brick.py +++ b/src/cellier/render/shaders/_multiscale_volume_brick.py @@ -28,12 +28,16 @@ from pygfx.renderers.wgpu.shaders.volumeshader import BaseVolumeShader from pygfx.resources import Buffer -from cellier.render._plane_mode import volume_mode_until_planes_draw +from cellier.render._render_planes import ( + RENDER_PLANES_STRUCT, + make_render_planes_buffer, +) from cellier.render.lut_indirection._cell_brick_rule import ( UNBOUNDED_BRICK_COUNT, level_brick_counts, level_cell_spans, ) +from cellier.visuals._render_plane import PLANE_RENDER_MODE if TYPE_CHECKING: from cellier.render.block_cache import BlockCacheParameters3D @@ -409,6 +413,9 @@ class MultiscaleVolumeBrickMaterial(gfx.VolumeIsoMaterial): ray_steps_per_voxel : float Ray-march samples per voxel of the drawn level, measured along the ray. Default is ``1.0``. + render_planes_buffer : Buffer or None + The visual's ``u_render_planes`` buffer, read in ``"plane"`` render + mode. ``None`` gives the material one of its own with no plane. """ uniform_type: ClassVar[dict] = dict( @@ -429,6 +436,7 @@ def __init__( threshold: float = 0.5, attenuation: float = 1.0, ray_steps_per_voxel: float = 1.0, + render_planes_buffer: Buffer | None = None, **kwargs, ) -> None: super().__init__( @@ -443,6 +451,11 @@ def __init__( self.brick_max_texture = brick_max_texture self.vol_params_buffer = vol_params_buffer self.block_scales_buffer = block_scales_buffer + self.render_planes_buffer = ( + render_planes_buffer + if render_planes_buffer is not None + else make_render_planes_buffer() + ) self._store.render_mode = "iso" self.uniform_buffer.data["attenuation"] = float(attenuation) self.uniform_buffer.data["ray_steps_per_voxel"] = float(ray_steps_per_voxel) @@ -453,8 +466,8 @@ def __init__( def render_mode(self) -> str: """Volume render mode. - Must be one of: ``"iso"``, ``"mip"``, ``"smooth_iso"``, or - ``"attenuated_mip"``. + Must be one of: ``"iso"``, ``"mip"``, ``"smooth_iso"``, + ``"attenuated_mip"`` or ``"plane"``. """ return self._store.render_mode @@ -522,9 +535,7 @@ def get_bindings(self, wobject, shared, scene): # Template variable for debug visualisation modes. self["debug_mode"] = getattr(material, "debug_mode", "none") # Template variable for render mode (iso / mip). - self["render_mode"] = volume_mode_until_planes_draw( - getattr(material, "render_mode", "iso"), "mip" - ) + self["render_mode"] = getattr(material, "render_mode", "iso") # Default for the ``normal`` render target. ``write_normal`` is # overridden by CellierBlender.get_shader_kwargs when the target # exists; without it the write compiles away, so the same shader @@ -587,6 +598,18 @@ def get_bindings(self, wobject, shared, scene): ) ) + if material.render_mode == PLANE_RENDER_MODE: + # Bound for this mode only, so the other modes' shaders are + # what they were before the mode existed. + bindings.append( + Binding( + "u_render_planes", + "buffer/uniform", + material.render_planes_buffer, + "FRAGMENT", + structname=RENDER_PLANES_STRUCT, + ) + ) bindings = dict(enumerate(bindings)) self.define_bindings(0, bindings) return {0: bindings} diff --git a/src/cellier/render/shaders/wgsl/image_volume.wgsl b/src/cellier/render/shaders/wgsl/image_volume.wgsl index f5d8b499..9a1ea1f6 100644 --- a/src/cellier/render/shaders/wgsl/image_volume.wgsl +++ b/src/cellier/render/shaders/wgsl/image_volume.wgsl @@ -150,6 +150,9 @@ fn vs_main(in: VertexInput) -> Varyings { {$ include 'cellier.ray_clip.wgsl' $} +$$ if mode == 'plane' +{$ include 'cellier.render_planes.wgsl' $} +$$ endif @fragment fn fs_main(varyings: Varyings) -> FragmentOutput { @@ -184,6 +187,45 @@ fn fs_main(varyings: Varyings) -> FragmentOutput { s_front = clipped.x; s_back = clipped.y; if (s_front >= s_back) { discard; } + $$ if mode == 'plane' + // Plane mode (plane rendering design v3, 5.1): one sample where the ray + // meets the nearest render plane inside the interval cut above. + let plane_ray_o = (u_wobject.world_transform * vec4(back_pos, 1.0)).xyz; + let plane_ray_d = (u_wobject.world_transform * vec4(view_ray, 0.0)).xyz; + let plane = plane_nearest_hit(plane_ray_o, plane_ray_d, s_front, s_back, true); + if (!plane.hit) { discard; } + let plane_pos = back_pos + view_ray * plane.t; + let plane_coord = (plane_pos + vec3(0.5)) / sizef; + // A value below the contrast limits is drawn, not skipped. + let plane_color = sampled_value_to_color(sample_vol(plane_coord, sizef)); + $$ if colorspace == 'srgb' + let plane_physical = srgb2physical(plane_color.rgb); + $$ else + let plane_physical = plane_color.rgb; + $$ endif + let plane_world = u_wobject.world_transform * vec4(plane_pos, 1.0); + let plane_ndc = u_stdinfo.projection_transform * u_stdinfo.cam_transform * plane_world; + + var out: FragmentOutput; + out.color = vec4(plane_physical, plane_color.a * u_material.opacity); + do_alpha_test(out.color.a); + out.depth = plane_ndc.z / plane_ndc.w; + $$ if write_normal + // The plane's own normal, faced to the viewer by pack_view_normal. + out.normal = pack_view_normal( + plane_normal_local(plane.normal), + (u_stdinfo.cam_transform * plane_world).xyz); + $$ endif + $$ if write_pick + out.pick = ( + pick_pack(u32(u_wobject.global_id), 20) + + pick_pack(u32(clamp(plane_coord.x, 0.0, 1.0) * 16383.0), 14) + + pick_pack(u32(clamp(plane_coord.y, 0.0, 1.0) * 16383.0), 14) + + pick_pack(u32(clamp(plane_coord.z, 0.0, 1.0) * 16383.0), 14) + ); + $$ endif + return out; + $$ else let front_pos = back_pos + view_ray * s_front; let end_pos = back_pos + view_ray * s_back; @@ -231,6 +273,7 @@ fn fs_main(varyings: Varyings) -> FragmentOutput { ); $$ endif return out; + $$ endif } @@ -484,6 +527,9 @@ $$ elif mode == 'iso' return out; } +$$ elif mode == 'plane' + // Plane mode samples once in fs_main and marches no ray. + $$ else fn raycast(sizef: vec3, nsteps: i32, start_coord: vec3, step_coord: vec3) -> RenderOutput { {{ mode }}__is_not_a_valid_render_mode(); diff --git a/src/cellier/render/shaders/wgsl/label_volume.wgsl b/src/cellier/render/shaders/wgsl/label_volume.wgsl index f228ffca..10e44acc 100644 --- a/src/cellier/render/shaders/wgsl/label_volume.wgsl +++ b/src/cellier/render/shaders/wgsl/label_volume.wgsl @@ -167,6 +167,9 @@ fn vs_main(in: VertexInput) -> Varyings { // ── Fragment shader ─────────────────────────────────────────────────────── {$ include 'cellier.ray_clip.wgsl' $} +$$ if render_mode == "plane" +{$ include 'cellier.render_planes.wgsl' $} +$$ endif @fragment fn fs_main(varyings: Varyings) -> FragmentOutput { let vol_dims = vec3(textureDimensions(t_img)); @@ -192,6 +195,64 @@ fn fs_main(varyings: Varyings) -> FragmentOutput { t_start = clipped.x; t_end = clipped.y; if (t_start >= t_end) { discard; } + $$ if render_mode == "plane" + // Plane mode (plane rendering design v3, 5.1, 5.2): the label at the + // nearest render plane the ray meets. Background is passed over and + // the next plane along the ray tried. + let plane_ray_o = (u_wobject.world_transform * vec4(near_pos, 1.0)).xyz; + let plane_ray_d = (u_wobject.world_transform * vec4(ray_dir, 0.0)).xyz; + var plane_found = false; + var plane_t = t_start; + var plane_normal = vec3(0.0); + var plane_lid = u_label_params.background_label; + var plane_color = vec4(0.0); + for (var pass_i = 0; pass_i < u_render_planes.count; pass_i = pass_i + 1) { + let plane = plane_nearest_hit( + plane_ray_o, plane_ray_d, plane_t, t_end, pass_i == 0); + if (!plane.hit) { break; } + plane_t = plane.t; + let hit_voxel = clamp( + vec3(floor(near_pos + ray_dir * plane.t + vec3(0.5))), + vec3(0), vol_dims - vec3(1)); + let lid = load_label(hit_voxel); + if (lid == u_label_params.background_label) { continue; } + let lid_color = get_label_color(lid); + if (lid_color.a < 0.001) { continue; } + plane_found = true; + plane_normal = plane.normal; + plane_lid = lid; + plane_color = lid_color; + break; + } + if (!plane_found) { discard; } + let plane_pos = near_pos + ray_dir * plane_t; + let plane_coord = (plane_pos + vec3(0.5)) / vol_dimsf; + let plane_world = u_wobject.world_transform * vec4(plane_pos, 1.0); + let plane_ndc = u_stdinfo.projection_transform * u_stdinfo.cam_transform * plane_world; + + var out: FragmentOutput; + // Flat label colour: a plane is not shaded. + out.color = vec4(plane_color.rgb, plane_color.a * u_material.opacity); + out.depth = plane_ndc.z / plane_ndc.w; + $$ if write_normal + // The plane's own normal, faced to the viewer by pack_view_normal. + out.normal = pack_view_normal( + plane_normal_local(plane_normal), + (u_stdinfo.cam_transform * plane_world).xyz); + $$ endif + $$ if write_outline_id + out.outline_id = label_outline_key(plane_lid); + $$ endif + $$ if write_pick + out.pick = ( + pick_pack(u32(u_wobject.global_id), 20) + + pick_pack(u32(clamp(plane_coord.x, 0.0, 1.0) * 16383.0), 14) + + pick_pack(u32(clamp(plane_coord.y, 0.0, 1.0) * 16383.0), 14) + + pick_pack(u32(clamp(plane_coord.z, 0.0, 1.0) * 16383.0), 14) + ); + $$ endif + return out; + $$ else // True when the ray entered through a box face (vs. the near clip plane // sitting inside the volume, which clamps t_near < 0 to t_start = 0). // A ray that starts on a clipping plane did not enter through one either. @@ -370,4 +431,5 @@ fn fs_main(varyings: Varyings) -> FragmentOutput { $$ endif return out; + $$ endif } diff --git a/src/cellier/render/shaders/wgsl/label_volume_brick.wgsl b/src/cellier/render/shaders/wgsl/label_volume_brick.wgsl index f43011c4..23358a2a 100644 --- a/src/cellier/render/shaders/wgsl/label_volume_brick.wgsl +++ b/src/cellier/render/shaders/wgsl/label_volume_brick.wgsl @@ -533,6 +533,9 @@ fn vs_main(in: VertexInput) -> Varyings { {$ include 'cellier.ray_clip.wgsl' $} +$$ if render_mode == "plane" +{$ include 'cellier.render_planes.wgsl' $} +$$ endif @fragment fn fs_main(varyings: Varyings) -> FragmentOutput { var out: FragmentOutput; @@ -565,6 +568,68 @@ fn fs_main(varyings: Varyings) -> FragmentOutput { t_end = clipped.y; if (t_start >= t_end) { discard; } + $$ if render_mode == "plane" + // ── Plane mode (plane rendering design v3, 5.1, 5.2) ─────────────── + // The label at the nearest render plane the ray meets. Background, and + // a brick that is not loaded, are passed over and the next plane along + // the ray tried. + let plane_ray_o = (u_wobject.world_transform * vec4(near_pos, 1.0)).xyz; + let plane_ray_d = (u_wobject.world_transform * vec4(ray_dir, 0.0)).xyz; + var plane_found = false; + var plane_t = t_start; + var plane_normal = vec3(0.0); + var plane_lid = background_label; + var plane_color = vec4(0.0); + for (var pass_i = 0; pass_i < u_render_planes.count; pass_i = pass_i + 1) { + let plane = plane_nearest_hit( + plane_ray_o, plane_ray_d, plane_t, t_end, pass_i == 0); + if (!plane.hit) { break; } + plane_t = plane.t; + let hit_voxel = clamp( + norm_to_voxel(near_pos + ray_dir * plane.t, norm_size, dataset_size), + vec3(0.0), + dataset_size - vec3(0.001)); + let ctx = lookup_brick_context(hit_voxel); + if (!ctx.valid) { continue; } + let lid = sample_atlas_label( + hit_voxel, ctx.lut_entry, ctx.lod_scale, ctx.brick_corner_k); + if (lid == background_label) { continue; } + let lid_color = get_label_color(lid); + if (lid_color.a < 0.001) { continue; } + plane_found = true; + plane_normal = plane.normal; + plane_lid = lid; + plane_color = lid_color; + break; + } + if (!plane_found) { discard; } + let plane_pos = near_pos + ray_dir * plane_t; + let plane_world = u_wobject.world_transform * vec4(plane_pos, 1.0); + let plane_ndc = u_stdinfo.projection_transform * u_stdinfo.cam_transform * plane_world; + // Flat label colour: a plane is not shaded. + out.color = vec4(plane_color.rgb, plane_color.a * u_material.opacity); + out.depth = plane_ndc.z / plane_ndc.w; + $$ if write_normal + // The plane's own normal, faced to the viewer by pack_view_normal. + out.normal = pack_view_normal( + plane_normal_local(plane_normal), + (u_stdinfo.cam_transform * plane_world).xyz); + $$ endif + $$ if write_outline_id + out.outline_id = label_outline_key(plane_lid); + $$ endif + $$ if write_pick + // The existing volume payload: the normalised position of the hit. + let plane_pick = (plane_pos / norm_size) + vec3(0.5); + out.pick = ( + pick_pack(u32(u_wobject.global_id), 20) + + pick_pack(u32(clamp(plane_pick.x, 0.0, 1.0) * 16383.0), 14) + + pick_pack(u32(clamp(plane_pick.y, 0.0, 1.0) * 16383.0), 14) + + pick_pack(u32(clamp(plane_pick.z, 0.0, 1.0) * 16383.0), 14) + ); + $$ endif + + $$ else let ray_origin = near_pos; let clip_t0 = t_start; // Whether the hit is on a clipping plane's cut face. @@ -833,6 +898,7 @@ fn fs_main(varyings: Varyings) -> FragmentOutput { pick_pack(u32(clamp(pick_coord.z, 0.0, 1.0) * 16383.0), 14) ); $$ endif + $$ endif return out; } diff --git a/src/cellier/render/shaders/wgsl/multiscale_volume_brick.wgsl b/src/cellier/render/shaders/wgsl/multiscale_volume_brick.wgsl index 2bcdae7b..ac422ae6 100644 --- a/src/cellier/render/shaders/wgsl/multiscale_volume_brick.wgsl +++ b/src/cellier/render/shaders/wgsl/multiscale_volume_brick.wgsl @@ -519,6 +519,9 @@ fn vs_main(in: VertexInput) -> Varyings { {$ include 'cellier.ray_clip.wgsl' $} +$$ if render_mode == 'plane' +{$ include 'cellier.render_planes.wgsl' $} +$$ endif @fragment fn fs_main(varyings: Varyings) -> FragmentOutput { var out: FragmentOutput; @@ -558,7 +561,53 @@ fn fs_main(varyings: Varyings) -> FragmentOutput { t_end = clipped.y; if (t_start >= t_end) { discard; } - $$ if debug_mode == 'ray_dir' + $$ if render_mode == 'plane' + // ── Plane mode (plane rendering design v3, 5.1, 5.2) ─────────────── + // One sample where the ray meets the nearest render plane inside the + // interval cut above, from the brick the LUT names there: the level + // planned for that place, or the backstop where it is not loaded yet. + let plane_ray_o = (u_wobject.world_transform * vec4(near_pos, 1.0)).xyz; + let plane_ray_d = (u_wobject.world_transform * vec4(ray_dir, 0.0)).xyz; + let plane = plane_nearest_hit(plane_ray_o, plane_ray_d, t_start, t_end, true); + if (!plane.hit) { discard; } + let plane_brick = setup_brick( + near_pos, ray_dir, inv_ray_dir, plane.t, t_end, norm_size, dataset_size); + if (!plane_brick.valid) { discard; } + let plane_pos = near_pos + ray_dir * plane.t; + let plane_value = sample_atlas( + norm_to_voxel(plane_pos, norm_size, dataset_size), + plane_brick.lut_entry, plane_brick.lod_scale, plane_brick.brick_corner_k); + // A value below the contrast limits is drawn, not skipped. + let plane_color = sampled_value_to_color(vec4(plane_value, 0.0, 0.0, 1.0)); + $$ if colorspace == 'srgb' + let plane_physical = srgb2physical(plane_color.rgb); + $$ else + let plane_physical = plane_color.rgb; + $$ endif + let plane_opacity = plane_color.a * u_material.opacity; + do_alpha_test(plane_opacity); + let plane_world = u_wobject.world_transform * vec4(plane_pos, 1.0); + let plane_ndc = u_stdinfo.projection_transform * u_stdinfo.cam_transform * plane_world; + out.color = vec4(plane_physical, plane_opacity); + out.depth = plane_ndc.z / plane_ndc.w; + $$ if write_normal + // The plane's own normal, faced to the viewer by pack_view_normal. + out.normal = pack_view_normal( + plane_normal_local(plane.normal), + (u_stdinfo.cam_transform * plane_world).xyz); + $$ endif + $$ if write_pick + // The existing volume payload: the normalised position of the hit. + let plane_pick = (plane_pos / norm_size) + vec3(0.5); + out.pick = ( + pick_pack(u32(u_wobject.global_id), 20) + + pick_pack(u32(clamp(plane_pick.x, 0.0, 1.0) * 16383.0), 14) + + pick_pack(u32(clamp(plane_pick.y, 0.0, 1.0) * 16383.0), 14) + + pick_pack(u32(clamp(plane_pick.z, 0.0, 1.0) * 16383.0), 14) + ); + $$ endif + + $$ elif debug_mode == 'ray_dir' // ── Debug: show ray direction as RGB ────────────────────────────── out.color = vec4((ray_dir + 1.0) * 0.5, 1.0); out.depth = 0.0; diff --git a/src/cellier/render/shaders/wgsl/render_planes.wgsl b/src/cellier/render/shaders/wgsl/render_planes.wgsl new file mode 100644 index 00000000..0d142c10 --- /dev/null +++ b/src/cellier/render/shaders/wgsl/render_planes.wgsl @@ -0,0 +1,66 @@ +// Render planes: the ray-plane search of the "plane" render mode (plane +// rendering design v3, 5.1). +// +// Included by the four volume shaders, and only when they are built for +// ``render_mode == "plane"``: a shader built for any other mode contains +// none of this. Each shader samples its own data at the hit; what they +// share is the search below. +// +// Requires from the including shader: ``u_render_planes`` (bound in plane +// mode only) and ``u_wobject``. A plane is a frame in the scene's world +// space, pygfx ``(x, y, z)``: an origin, two in-plane unit vectors and an +// extent ``(min_0, max_0, min_1, max_1)`` measured from the origin along +// them. An unbounded side is ``+-1e30``, never infinity. + +struct PlaneHit { + hit: bool, + // The ray parameter of the hit. + t: f32, + // The plane's world normal, ``axis_0 x axis_1``. + normal: vec3, +} + +// The nearest plane the world-space ray ``ray_o + t * ray_d`` meets inside +// its extent, for ``t`` in ``[t_after, t_end]``; with ``inclusive`` false +// a hit at ``t_after`` itself does not count, which is how a caller walks +// from one plane to the next along a ray. +// +// A ray is affine in its parameter, so ``t`` is the same number in the +// node's local space and in world space: the caller passes the interval it +// has already cut to the data box and to the clipping planes. +fn plane_nearest_hit( + ray_o: vec3, ray_d: vec3, t_after: f32, t_end: f32, inclusive: bool, +) -> PlaneHit { + var result = PlaneHit(false, t_end, vec3(0.0)); + for (var i = 0; i < u_render_planes.count; i = i + 1) { + let origin = u_render_planes.origin[i].xyz; + let axis_0 = u_render_planes.axis_0[i].xyz; + let axis_1 = u_render_planes.axis_1[i].xyz; + let extent = u_render_planes.extent[i]; + let normal = cross(axis_0, axis_1); + let denom = dot(ray_d, normal); + if (abs(denom) < 1e-20) { continue; } + let t_i = dot(origin - ray_o, normal) / denom; + if (t_i < t_after || t_i > result.t) { continue; } + if (!inclusive && t_i <= t_after) { continue; } + let rel = ray_o + t_i * ray_d - origin; + let c0 = dot(rel, axis_0); + let c1 = dot(rel, axis_1); + if (c0 < extent.x || c0 > extent.y || c1 < extent.z || c1 > extent.w) { + continue; + } + result = PlaneHit(true, t_i, normal); + } + return result; +} + +// A plane's world normal as a covector in the node's local space, which is +// what ``pack_view_normal`` takes. That function faces the result to the +// viewer, so the two sides of a plane are shaded alike. +fn plane_normal_local(world_normal: vec3) -> vec3 { + return transpose(mat3x3( + u_wobject.world_transform[0].xyz, + u_wobject.world_transform[1].xyz, + u_wobject.world_transform[2].xyz, + )) * world_normal; +} diff --git a/src/cellier/render/visuals/_chunked.py b/src/cellier/render/visuals/_chunked.py index e08493f1..9aaa4481 100644 --- a/src/cellier/render/visuals/_chunked.py +++ b/src/cellier/render/visuals/_chunked.py @@ -124,6 +124,39 @@ def _pack( return pack_keys(levels, slice_ids, arr[:, 1:]), slice_ids +def target_room( + residency: ImageResidency3D | ImageResidency2D | None, + backstop_arr: np.ndarray | None, + loading: ProgressiveLoadingConfig | None, +) -> int | None: + """How many target bricks an atlas keeps beside its backstop. + + The room :func:`desired_bricks` truncates the target to, for a planner + that would rather plan less than be truncated (an orthographic plane + plan, plane rendering design 6.3). + + Parameters + ---------- + residency : ImageResidency3D or ImageResidency2D or None + The atlas. + backstop_arr : np.ndarray or None + The planned backstop. + loading : ProgressiveLoadingConfig or None + For the backstop cap. + + Returns + ------- + int or None + ``None`` without an atlas. + """ + if residency is None: + return None + budget = max(0, int(residency.n_slots) - 1) + n_backstop = 0 if backstop_arr is None else len(backstop_arr) + cap = max(0, int(backstop_cap_for(loading, residency))) + return budget - min(n_backstop, cap, budget) + + def desired_bricks( planner: MultiscaleRegionPlanner, residency: ImageResidency3D | ImageResidency2D, diff --git a/src/cellier/render/visuals/_image.py b/src/cellier/render/visuals/_image.py index 5bbf1d04..08826254 100644 --- a/src/cellier/render/visuals/_image.py +++ b/src/cellier/render/visuals/_image.py @@ -39,6 +39,12 @@ sort_tiles_by_distance_2d, viewport_cull_2d, ) +from cellier.render._plane_planning import ( + PlanePlanning, + build_plane_planning, + plan_plane_bricks, +) +from cellier.render._render_planes import RenderPlanesMixin from cellier.render._spaces import ( RenderSpaces, axis_correspondence, @@ -82,6 +88,7 @@ log_backstop_cap_once, make_residency_2d, make_residency_3d, + target_room, viewport_2d, ) from cellier.render.visuals._image_memory import ( @@ -99,6 +106,7 @@ image_plane_selection, ) from cellier.visuals._image_memory import effective_transparency_mode +from cellier.visuals._render_plane import PLANE_RENDER_MODE if TYPE_CHECKING: from uuid import UUID @@ -1400,8 +1408,13 @@ def __init__( aabb_line_width: float = 2.0, render_order: int = 0, pick_write: bool = True, + render_planes_buffer: gfx.Buffer | None = None, ) -> None: self.visual_model_id = visual_model_id + # The owning visual's render planes, bound by the 3D material in + # the "plane" render mode. ``None`` leaves the material a buffer + # of its own with no plane. + self._render_planes_buffer = render_planes_buffer # ndim of the original data (not the displayed subspace). if full_level_shapes is not None: @@ -1454,6 +1467,8 @@ def __init__( self._gpu_budget_bytes = gpu_budget_bytes_3d self._frame_number = 0 self._last_plan_stats: dict = {} + # What the last plane-mode plan was made of (``plan_plane_bricks``). + self._last_plane_plan: dict = {} # The chunk scheduler's view of the 3D atlas; rebuilt whenever the # atlas, its LUT or the displayed axes change (a new cache id). self._residency_3d: ImageResidency3D | None = None @@ -2129,6 +2144,8 @@ def _plan_bricks( lod_bias: float, force_level: int | None, view_height_world: float = 0.0, + render_planes: PlanePlanning | None = None, + max_bricks: int | None = None, ) -> np.ndarray: """Run LOD selection, distance sort, frustum cull, and budget truncation. @@ -2155,6 +2172,15 @@ def _plan_bricks( Visible height in world units of an orthographic view (``fov_y_rad`` of 0), which selects one level for the whole visual. Unused with a perspective camera. + render_planes : PlanePlanning or None + The visual's render planes, in the ``"plane"`` render mode: only + the bricks they cross are planned, at levels picked by the + plane's own rule (plane rendering design 6). ``None`` plans + the volume. + max_bricks : int or None + The room the atlas has for the target. Read only for an + orthographic plane plan, which steps to a coarser level rather + than overrun it. Returns ------- @@ -2193,6 +2219,23 @@ def _plan_bricks( else: frustum_planes = None + if render_planes is not None: + # Plane mode: its own cull, level rule and orthographic level. + brick_arr, self._last_plane_plan = plan_plane_bricks( + geo, + render_planes, + clip_rows=self._clip_rows(), + camera_pos_data=camera_pos_data, + frustum_planes=frustum_planes, + fov_y_rad=fov_y_rad, + screen_height_px=screen_height_px, + bias=lod_bias, + force_level=force_level, + view_height_world=view_height_world, + max_bricks=max_bricks, + ) + return brick_arr + # 1. LOD selection if force_level is None and fov_y_rad <= 0: # Orthographic: a pixel covers the same world size everywhere, so @@ -2840,6 +2883,7 @@ def _build_3d_node( threshold=threshold, attenuation=attenuation, pick_write=pick_write, + render_planes_buffer=self._render_planes_buffer, ) geometry = gfx.Geometry(grid=proxy_tex) @@ -2936,7 +2980,7 @@ def _slot_geometries( return volume_geometry, image_geometry_2d -class GFXMultiscaleImageVisual(ClippingPlanesMixin): +class GFXMultiscaleImageVisual(RenderPlanesMixin, ClippingPlanesMixin): """Render-layer visual for one ``MultiscaleImageVisual``. Draws the image single-channel or composited from a pool of @@ -3040,6 +3084,7 @@ def __init__( aabb_line_width=visual_model.aabb.line_width, render_order=visual_model.appearance.render_order, pick_write=self._pick_write, + render_planes_buffer=self.render_planes_buffer, ) slot._block_size = config.block_size # Weakly: the slots are this visual's, so a strong bound method @@ -3479,7 +3524,11 @@ def plan( loading, ) else: + backstop = planner._plan_backstop_3d( + request.camera_pos, request.frustum_corners, loading + ) if plan_target: + planes = self._plane_planning(planner, keys[0]) arr = planner._plan_bricks( request.camera_pos, request.frustum_corners if config.frustum_cull else None, @@ -3488,10 +3537,13 @@ def plan( config.settled_lod_bias, config.force_level, view_height_world=request.world_extent[1], + render_planes=planes, + max_bricks=( + None + if planes is None + else target_room(planner.residency_3d(), backstop, loading) + ), ) - backstop = planner._plan_backstop_3d( - request.camera_pos, request.frustum_corners, loading - ) base_coord = planner._block_key_slice_coord() composite = self._composite() n_target = 0 if arr is None else len(arr) @@ -3514,6 +3566,24 @@ def plan( log_backstop_cap_once(self, desired) return desired + def _plane_planning( + self, planner: _MultiscaleImageSlot, key: int + ) -> PlanePlanning | None: + """The render planes as the brick planner takes them, in plane mode. + + Every channel of an image has the same render mode, so one drawn + channel answers for the visual and the planes are planned once. + ``None`` in a volume mode. + """ + mode_appearance = self._mode_appearance(key) + if mode_appearance is None or mode_appearance.render_mode != PLANE_RENDER_MODE: + return None + return build_plane_planning( + self.drawn_render_planes, + planner._to_level0_displayed, + planner._volume_geometry._scale_arr_shader, + ) + def residencies(self) -> dict[int, ImageResidency3D | ImageResidency2D]: """``cache_id -> adapter`` for every slot's 3D and 2D atlas.""" out: dict[int, ImageResidency3D | ImageResidency2D] = {} @@ -3568,6 +3638,8 @@ def set_render_spaces(self, spaces: RenderSpaces | None) -> None: for slot in self._slots: slot.set_render_spaces(spaces) self._apply_clipping_planes() + # Which planes are drawn follows the displayed axes. + self._apply_render_planes() def on_appearance_changed(self, event: AppearanceChangedEvent) -> None: """A shared field changed: restyle every drawn slot.""" diff --git a/src/cellier/render/visuals/_image_memory.py b/src/cellier/render/visuals/_image_memory.py index 7637cd44..12395bb1 100644 --- a/src/cellier/render/visuals/_image_memory.py +++ b/src/cellier/render/visuals/_image_memory.py @@ -9,7 +9,7 @@ from cellier.data.image._image_requests import ChunkRequest from cellier.render._clipping import ClippingPlanesMixin -from cellier.render._plane_mode import volume_mode_until_planes_draw +from cellier.render._render_planes import RenderPlanesMixin from cellier.render._spaces import RenderSpaces, node_matrix from cellier.render.shaders._image_volume import IMAGE_VOLUME_MATERIALS from cellier.render.visuals._pick import memory_image_data_coordinate @@ -18,6 +18,7 @@ image_plane_selection, ) from cellier.visuals._image_memory import effective_transparency_mode +from cellier.visuals._render_plane import PLANE_RENDER_MODE if TYPE_CHECKING: from cellier._state import DimsState @@ -75,7 +76,7 @@ def _make_colormap(color_map) -> gfx.TextureMap: def _volume_material_class(render_mode: str) -> type: """The material class that draws *render_mode*.""" - return _VOLUME_MATERIALS[volume_mode_until_planes_draw(render_mode, "mip")] + return _VOLUME_MATERIALS[render_mode] def _make_volume_material(appearance, colormap, pick_write: bool): @@ -322,6 +323,7 @@ def apply( pick_write: bool, alpha_mode: str, planes_overlap: bool, + render_planes_buffer: gfx.Buffer | None = None, ) -> None: """Draw with *shared* plus the mode's own appearance (design 3.3). @@ -340,6 +342,9 @@ def apply( the same depth, so depth testing and writing are turned off on the 2D materials or the first plane would hide the rest. The 3D volumes stop writing depth for the same reason. + render_planes_buffer : gfx.Buffer or None + The visual's render planes, for a material built for the + ``"plane"`` render mode. """ colormap = self._colormap_for(mode_appearance.color_map) for node in self.nodes(): @@ -361,11 +366,15 @@ def apply( if not isinstance(material, material_cls) or type(material) is not ( material_cls ): + extra = {} + if mode_appearance.render_mode == PLANE_RENDER_MODE: + extra["render_planes_buffer"] = render_planes_buffer material = material_cls( clim=mode_appearance.clim, map=colormap, interpolation=shared.interpolation, pick_write=pick_write, + **extra, ) self.node_3d.material = material material.clim = mode_appearance.clim @@ -384,7 +393,7 @@ def apply( material.threshold = mode_appearance.iso_threshold -class GFXImageMemoryVisual(ClippingPlanesMixin): +class GFXImageMemoryVisual(RenderPlanesMixin, ClippingPlanesMixin): """Render-layer visual for one ``ImageVisual`` backed by ``ImageMemoryStore``. Draws the image single-channel or composited from a pool of @@ -635,6 +644,7 @@ def _apply_materials(self) -> None: pick_write=self._pick_write, alpha_mode=alpha_mode, planes_overlap=overlap, + render_planes_buffer=self.render_planes_buffer, ) # A render-mode change gives a slot a new volume material, which # starts with no planes (clipping planes design 4.6). @@ -663,6 +673,8 @@ def set_render_spaces(self, spaces: RenderSpaces | None) -> None: self._spaces = spaces if spaces is not None and self._last_displayed_axes is not None: self._update_node_matrix(self._last_displayed_axes) + # Which planes are drawn follows the displayed axes. + self._apply_render_planes() def _update_node_matrix(self, displayed_axes: tuple[int, ...]) -> None: """Place both groups with the composition of design 3.9. diff --git a/src/cellier/render/visuals/_label_memory.py b/src/cellier/render/visuals/_label_memory.py index 5dcaabef..6a347dc4 100644 --- a/src/cellier/render/visuals/_label_memory.py +++ b/src/cellier/render/visuals/_label_memory.py @@ -11,6 +11,7 @@ import cellier.render.shaders._label_volume # noqa: F401 from cellier.data.image._image_requests import ChunkRequest from cellier.render._clipping import ClippingPlanesMixin +from cellier.render._render_planes import RenderPlanesMixin from cellier.render._spaces import RenderSpaces, node_matrix from cellier.render.shaders._label_colormap import ( build_direct_lut_textures, @@ -45,7 +46,7 @@ def _make_placeholder_label_params() -> gfx.Buffer: return build_label_params_buffer(background_label=0, salt=0, n_entries=0) -class GFXLabelMemoryVisual(ClippingPlanesMixin): +class GFXLabelMemoryVisual(RenderPlanesMixin, ClippingPlanesMixin): """Render-layer visual for a LabelMemoryVisual backed by LabelMemoryStore. Owns gfx.Image (2D) and/or gfx.Volume (3D) nodes with custom label shaders. @@ -174,6 +175,7 @@ def __init__( label_params_buffer=label_params_buf, opacity=appearance.opacity, pick_write=visual_model.pick_write, + render_planes_buffer=self.render_planes_buffer, ) self._inner_node_3d = gfx.Volume(gfx.Geometry(grid=tex), mat3d) placeholder_pos = _box_wireframe_positions(np.zeros(3), np.ones(3)) @@ -242,6 +244,8 @@ def set_render_spaces(self, spaces: RenderSpaces | None) -> None: self._spaces = spaces if spaces is not None and self._last_displayed_axes is not None: self._update_node_matrix(self._last_displayed_axes) + # Which planes are drawn follows the displayed axes. + self._apply_render_planes() def _update_node_matrix(self, displayed_axes: tuple[int, ...]) -> None: """Place the nodes with the composition of design 3.9. diff --git a/src/cellier/render/visuals/_label_multiscale.py b/src/cellier/render/visuals/_label_multiscale.py index a85d9344..3926a6fe 100644 --- a/src/cellier/render/visuals/_label_multiscale.py +++ b/src/cellier/render/visuals/_label_multiscale.py @@ -30,6 +30,12 @@ sort_tiles_by_distance_2d, viewport_cull_2d, ) +from cellier.render._plane_planning import ( + PlanePlanning, + build_plane_planning, + plan_plane_bricks, +) +from cellier.render._render_planes import RenderPlanesMixin from cellier.render._spaces import ( RenderSpaces, node_matrix, @@ -72,6 +78,7 @@ log_backstop_cap_once, make_residency_2d, make_residency_3d, + target_room, viewport_2d, ) from cellier.render.visuals._image import ( @@ -97,6 +104,7 @@ multiscale_image_data_coordinate, multiscale_volume_data_coordinate, ) +from cellier.visuals._render_plane import PLANE_RENDER_MODE if TYPE_CHECKING: from uuid import UUID @@ -133,7 +141,9 @@ _LABELS_SAMPLING_MARGIN_2D = 0.0 -class GFXMultiscaleLabelVisual(ClippingPlanesMixin, MultiscaleRegionPlanner): +class GFXMultiscaleLabelVisual( + RenderPlanesMixin, ClippingPlanesMixin, MultiscaleRegionPlanner +): """Render-layer wrapper for one logical multiscale label visual. Owns GPU resources (int32 brick caches, LUT textures, label colormap @@ -253,6 +263,8 @@ def __init__( # The 2D atlas's adapter (see residency_2d). self._residency_2d: ImageResidency2D | None = None self._last_plan_stats: dict = {} + # What the last plane-mode plan was made of (``plan_plane_bricks``). + self._last_plane_plan: dict = {} self._data_ready_3d: bool = False self._data_ready_2d: bool = False @@ -794,6 +806,8 @@ def set_render_spaces(self, spaces: RenderSpaces | None) -> None: self._spaces = spaces if spaces is not None and self._last_displayed_axes is not None: self._update_node_matrix(self._last_displayed_axes) + # Which planes are drawn follows the displayed axes. + self._apply_render_planes() def _update_node_matrix(self, displayed_axes: tuple[int, ...]) -> None: """Place the nodes with the composition of design 3.9. @@ -846,6 +860,8 @@ def _plan_bricks_3d( force_level: int | None = None, selection: RegionSelection | None = None, view_height_world: float = 0.0, + render_planes: PlanePlanning | None = None, + max_bricks: int | None = None, ) -> np.ndarray: """LOD selection, distance sort and frustum cull. @@ -855,6 +871,12 @@ def _plan_bricks_3d( An orthographic view (``fov_y_rad`` of 0) takes one level for the whole visual, from ``view_height_world``, its visible height in world units. + + With *render_planes* (the ``"plane"`` render mode) only the bricks + the planes cross are planned, at levels picked by the plane's own + rule (plane rendering design 6); *max_bricks* is the atlas's room + for the target, which an orthographic plane plan steps to a coarser + level rather than overrun. """ t_plan_start = time.perf_counter() self._frame_number += 1 @@ -907,6 +929,34 @@ def _plan_bricks_3d( np.round(camera_pos_data, 1).tolist(), ) + if render_planes is not None: + # Plane mode: its own cull, level rule and orthographic level. + brick_arr, plane_stats = plan_plane_bricks( + geo, + render_planes, + clip_rows=self._clip_rows(), + camera_pos_data=camera_pos_data, + frustum_planes=frustum_planes, + fov_y_rad=fov_y_rad, + screen_height_px=screen_height_px, + bias=lod_bias, + force_level=force_level, + view_height_world=view_height_world, + max_bricks=max_bricks, + ) + self._last_plane_plan = plane_stats + self._last_plan_stats = { + "total_required": plane_stats["n_selected"], + "n_culled": plane_stats["n_selected"] - len(brick_arr), + "n_needed": len(brick_arr), + "cull_timings": {}, + "lod_select_ms": 0.0, + "distance_sort_ms": 0.0, + "frustum_cull_ms": 0.0, + "plan_total_ms": (time.perf_counter() - t_plan_start) * 1000, + } + return brick_arr + t0 = time.perf_counter() if force_level is None and fov_y_rad <= 0: # Orthographic: a pixel covers the same world size everywhere, so @@ -1080,6 +1130,20 @@ def plan( return [] brick_arr = None if mode == PlanMode.FULL: + planes = self._plane_planning() + max_bricks = None + if planes is not None: + # The room an orthographic plane plan must fit: what the + # backstop leaves. Planned again below, once the target + # has adopted the request. + self._adopt_request_3d(request) + max_bricks = target_room( + self.residency_3d(), + self._plan_backstop_3d( + request.camera_pos, request.frustum_corners, config.loading + ), + config.loading, + ) brick_arr = self._plan_bricks_3d( request.camera_pos, request.frustum_corners if config.frustum_cull else None, @@ -1090,6 +1154,8 @@ def plan( force_level=config.force_level, selection=request.selection, view_height_world=request.world_extent[1], + render_planes=planes, + max_bricks=max_bricks, ) else: self._adopt_request_3d(request) @@ -1101,6 +1167,19 @@ def plan( return [] return self._finish_plan(residency, brick_arr, backstop, config.loading) + def _plane_planning(self) -> PlanePlanning | None: + """The render planes as the brick planner takes them, in plane mode. + + ``None`` in a volume mode. + """ + if self._render_mode != PLANE_RENDER_MODE: + return None + return build_plane_planning( + self.drawn_render_planes, + self._to_level0_displayed, + self._volume_geometry._scale_arr_shader, + ) + def _begin_region_planning(self, selection) -> None: """Start a planning call, applying the one-plane slicing rule first. @@ -1817,6 +1896,7 @@ def _build_3d_node( n_entries=self._n_entries, pick_write=pick_write, ray_steps_per_voxel=self._ray_steps_per_voxel, + render_planes_buffer=self.render_planes_buffer, ) geometry = gfx.Geometry(grid=proxy_tex) diff --git a/tests/_plane_fixtures.py b/tests/_plane_fixtures.py index 1c2c1b8e..bdbd50e6 100644 --- a/tests/_plane_fixtures.py +++ b/tests/_plane_fixtures.py @@ -39,11 +39,19 @@ class PyramidSpec: voxel_size: tuple[float, ...] dtype: str = "uint16" axes: tuple[str, ...] = AXES_ZYX + #: ``translations[k][axis]``: where level k's voxel 0 is centred, in + #: level-0 voxels. ``None`` is block averaging, ``(factor - 1) / 2``. + translations: tuple[tuple[float, ...], ...] | None = None @property def n_levels(self) -> int: return len(self.factors) + def level_translation(self, level: int) -> tuple[float, ...]: + if self.translations is not None: + return tuple(self.translations[level]) + return tuple((f - 1.0) / 2.0 for f in self.factors[level]) + def level_shape(self, level: int) -> tuple[int, ...]: return tuple( max(1, math.ceil(n / f)) @@ -130,7 +138,7 @@ def open_pyramid(root: Path, spec: PyramidSpec, *, name: str = "pyramid"): """Open the pyramid at *root* as a store; returns ``(store, voxel_size)``. Level-k voxel centres sit at ``(factor - 1) / 2`` level-0 voxels, as the - measured store's loader does. + measured store's loader does, unless the spec gives ``translations``. """ system = DataCoordinateSystem( name="data", @@ -143,7 +151,7 @@ def open_pyramid(root: Path, spec: PyramidSpec, *, name: str = "pyramid"): zarr_path=str(root), scale_names=[f"s{k}" for k in range(spec.n_levels)], level_scales=[tuple(f) for f in spec.factors], - level_translations=[tuple((v - 1.0) / 2.0 for v in f) for f in spec.factors], + level_translations=[spec.level_translation(k) for k in range(spec.n_levels)], data_coordinate_system=system, name=name, ) diff --git a/tests/render/_plane_rig.py b/tests/render/_plane_rig.py new file mode 100644 index 00000000..a94aa38d --- /dev/null +++ b/tests/render/_plane_rig.py @@ -0,0 +1,338 @@ +"""A multiscale visual in ``"plane"`` render mode, for the Phase 7 tests. + +Small pyramids whose levels hold a known pattern, a rig that places the +camera, plans, loads and draws, and the level-aware comparison with the CPU +reference of ``tests/render/_planes.py``. +""" + +from __future__ import annotations + +import numpy as np +import tensorstore as ts + +from cellier.render.scheduling import ChunkClass, Tier +from cellier.visuals import ( + ClippingPlane, + MultiscaleImageAppearance, + MultiscaleImageRenderConfig, + MultiscaleImageSingleAppearance, + MultiscaleLabelRenderConfig, + MultiscaleLabelsAppearance, +) +from tests import _plane_fixtures as fx +from tests._gpu_budget import SMALL_BUDGETS +from tests.render import _planes as h +from tests.render.conftest import drain_loading + +SIZE = (320, 320) +BLOCK = 16 + +#: Small pyramids whose every level is filled by ``_level_values``. +ISO = fx.PyramidSpec( + shape0=(48, 64, 80), + factors=fx.halving(3, (1, 1, 1)), + voxel_size=(1.0, 1.0, 1.0), + dtype="float32", +) +ANISO = fx.PyramidSpec( + shape0=(40, 96, 96), + factors=fx.halving(3, (0, 1, 1)), + voxel_size=(2.5, 1.0, 1.0), + dtype="float32", +) +NONPOW2 = fx.PyramidSpec( + shape0=(49, 76, 66), + factors=( + (1.0, 1.0, 1.0), + (49 / 25, 76 / 38, 66 / 33), + (49 / 13, 76 / 19, 66 / 17), + ), + voxel_size=(1.0, 1.0, 1.0), + dtype="float32", +) +#: Levels placed with no translation: a level's bricks then sit most of a +#: level voxel off the lookup table's cells, so the brick a place is drawn +#: from is not always the one whose own box holds it. +UNALIGNED = fx.PyramidSpec( + shape0=(48, 64, 80), + factors=fx.halving(3, (1, 1, 1)), + voxel_size=(1.0, 1.0, 1.0), + dtype="float32", + translations=((0.0, 0.0, 0.0),) * 3, +) +SPECS = {"iso": ISO, "aniso": ANISO, "nonpow2": NONPOW2, "unaligned": UNALIGNED} + +#: ``name -> normal`` over world ``(z, y, x)``. +POSES = { + "xy": (1.0, 0.0, 0.0), + "xz": (0.0, 1.0, 0.0), + "oblique": (0.6, -0.5, 1.0), +} + + +def level_values(spec: fx.PyramidSpec, level: int) -> np.ndarray: + """The voxel pattern of ``_planes.voxel_values`` on level *level*'s grid.""" + return h.voxel_values(spec.level_shape(level)) + + +def write_levels( + root, spec: fx.PyramidSpec, *, labels: bool = False, fill=None +) -> None: + """Write *spec* with each level holding ``fill(level)`` (default the pattern).""" + for level in range(spec.n_levels): + shape = spec.level_shape(level) + values = level_values(spec, level) if fill is None else fill(level) + if labels: + data = values.astype(np.int32) + else: + data = ((values + 1) / (h.N_VALUES + 1)).astype(np.float32) + array = ts.open( + { + "driver": "zarr3", + "kvstore": {"driver": "file", "path": str(root / f"s{level}")}, + "metadata": { + "shape": list(shape), + "data_type": "int32" if labels else "float32", + "chunk_grid": { + "name": "regular", + "configuration": {"chunk_shape": [min(s, 32) for s in shape]}, + }, + }, + "create": True, + "delete_existing": True, + } + ).result() + array[...].write(data).result() + + +def centre_of(spec: fx.PyramidSpec) -> np.ndarray: + """A point near the data's centre, world ``(z, y, x)``, off every voxel face + of every level.""" + return ((np.array(spec.shape0) - 1) / 2 + 0.3) * np.array(spec.voxel_size) + + +class Rig: + """One multiscale visual in plane mode, a camera and an offscreen frame.""" + + def __init__(self, controller, scene, visual, spec, planes, labels, size=SIZE): + self.size = size + self.controller = controller + self.scene = scene + self.visual = visual + self.spec = spec + self.planes = tuple(planes) + self.labels = labels + self.gfx = controller._render_manager._scenes[scene.id].get_visual(visual.id) + canvas_id = controller.get_canvas_ids(scene.id)[0] + self.view = controller._render_manager._canvases[canvas_id] + # The Qt canvas is never shown, so it reports 1 x 1; plan for the + # size the frames are drawn at. + self.view._canvas.get_logical_size = lambda: (float(size[0]), float(size[1])) + + def shoot(self, **kwargs) -> h.Shot: + return h.shoot(self.controller, self.scene, size=self.size, **kwargs) + + async def settle(self, **camera) -> h.Shot: + """Place the camera, plan for it, load, and draw.""" + # A frame first: the planner culls to the camera's frustum, which a + # draw updates. + self.shoot(**camera) + self.controller.reslice_all() + await drain_loading(self.controller) + return self.shoot(**camera) + + def set_planes(self, planes) -> None: + self.planes = tuple(planes) + self.controller.set_render_planes(self.visual.id, self.planes) + + def trace(self, shot: h.Shot, **kwargs) -> h.Reference: + return h.trace( + shot, + self.planes, + self.spec.shape0, + scale_zyx=self.spec.voxel_size, + **kwargs, + ) + + def plan(self, **camera): + """Plan for the camera as placed, through the production path. + + Draws a frame first (the planner culls to the camera's frustum, which + a draw updates), then builds the canvas's request and calls the + visual's planner. Nothing is loaded. + + Returns + ------- + desired : list[DesiredSet] + One per drawn channel. + request : ReslicingRequest + """ + self.shoot(**camera) + canvas_id = self.controller.get_canvas_ids(self.scene.id)[0] + selection = self.controller._selections_for_scene(self.scene.id)[canvas_id] + request = self.view.capture_reslicing_request( + self.scene.dims.to_state(), selection, None + ) + config = self.controller._render_config_for(self.scene.id, self.visual) + return self.gfx.plan(request, config), request + + @property + def planner(self): + """The object whose ``_last_plane_plan`` and geometry the plan used.""" + return self.gfx if self.labels else self.gfx.slots[0] + + def brick_centres_world(self, rows: np.ndarray, block: int = BLOCK) -> np.ndarray: + """World ``(x, y, z)`` centres of ``[level, gz, gy, gx]`` rows.""" + rows = np.asarray(rows, dtype=np.float64).reshape(-1, 4) + factors = np.array([self.spec.factors[int(k) - 1] for k in rows[:, 0]]) + shifts = np.array([self.spec.level_translation(int(k) - 1) for k in rows[:, 0]]) + level_centre = (rows[:, 1:] + 0.5) * block - 0.5 + data = level_centre * factors + shifts + return (data * np.asarray(self.spec.voxel_size))[:, ::-1] + + def planned(self) -> np.ndarray: + """``[level, gz, gy, gx]`` rows of the target the scheduler holds.""" + from cellier.render.block_cache._image_residency import unpack_keys + + residency = ( + self.gfx.residency_3d() if self.labels else self.gfx.slots[0].residency_3d() + ) + registry = self.controller._render_manager.scheduler.core.registry( + residency.cache_id + ) + keep = (registry.tier == Tier.VISIBLE) & (registry.cls == ChunkClass.TARGET) + levels, _sids, grids = unpack_keys(registry.key[keep]) + return np.column_stack([levels, grids]).astype(np.int64) + + +async def make_rig( + controller, + tmp_path, + spec: fx.PyramidSpec, + make_planes, + *, + labels: bool = False, + fill=None, + clipping=(), + appearance=None, + single=None, + budget=None, + fov: float | None = None, + size: tuple[int, int] = SIZE, + block_size: int = BLOCK, +) -> Rig: + root = tmp_path / "pyramid" + root.mkdir(parents=True, exist_ok=True) + write_levels(root, spec, labels=labels, fill=fill) + store, voxel = fx.open_pyramid(root, spec) + scene = controller.add_scene(dim="3d", name="scene") + world = h.world_of(controller, scene) + planes = tuple(make_planes(world, centre_of(spec))) + transform = h.scale_translation_transform( + controller, scene.id, store, voxel, (0.0, 0.0, 0.0) + ) + system = store.data_coordinate_systems[0] + clip = tuple( + ClippingPlane.from_point_normal(system, p, n, axes=("z", "y", "x")) + for p, n in clipping + ) + budgets = dict(SMALL_BUDGETS) + if budget is not None: + budgets["gpu_budget_bytes"] = budget + if labels: + visual = controller.add_labels_multiscale( + data=store, + scene_id=scene.id, + appearance=MultiscaleLabelsAppearance( + **{ + "render_mode": "plane", + "colormap_mode": "direct", + "color_dict": h.label_colours(), + } + | (appearance or {}) + ), + render_config=MultiscaleLabelRenderConfig(block_size=block_size, **budgets), + transform=transform, + render_planes=planes, + clipping_planes=clip, + ) + else: + visual = controller.add_image_multiscale( + data=store, + scene_id=scene.id, + appearance=MultiscaleImageAppearance(**(appearance or {})), + render_config=MultiscaleImageRenderConfig(block_size=block_size, **budgets), + single=MultiscaleImageSingleAppearance( + **{"render_mode": "plane", "color_map": "gray", "clim": (0.0, 1.0)} + | (single or {}) + ), + transform=transform, + render_planes=planes, + clipping_planes=clip, + ) + controller.add_canvas(scene_id=scene.id, canvas_size=size) + if fov is not None: + canvas_id = controller.get_canvas_ids(scene.id)[0] + controller._render_manager._canvases[canvas_id].camera.fov = fov + return Rig(controller, scene, visual, spec, planes, labels, size=size) + + +def level_voxel(spec: fx.PyramidSpec, level: int, data_position: np.ndarray): + """The level-*level* voxel ``(z, y, x)`` a level-0 position falls in. + + Parameters + ---------- + data_position : np.ndarray + ``(..., 3)`` centred level-0 positions, ``(x, y, z)``. + + Returns + ------- + voxel : np.ndarray + ``(..., 3)`` integer ``(z, y, x)``, clamped to the level's shape. + clear : np.ndarray + ``(...)`` bool: the position is clear of every face of that voxel, + and inside the part of the volume the level has voxels for. (A + level placed with no translation ends short of the level-0 extent; + past its last voxel there is nothing to draw but fill.) + """ + factor = np.asarray(spec.factors[level], dtype=np.float64)[::-1] + shift = np.asarray(spec.level_translation(level), dtype=np.float64)[::-1] + u = (data_position - shift) / factor + shape = np.asarray(spec.level_shape(level))[::-1] + index = np.floor(u + 0.5) + covered = ((index >= 0) & (index <= shape - 1)).all(axis=-1) + voxel = np.clip(index, 0, shape - 1).astype(np.int64) + face = np.abs((u + 0.5) - np.round(u + 0.5)).min(axis=-1) + return voxel[..., ::-1], covered & (face > h.MARGIN / 2) + + +def compare_level(rig: Rig, shot, reference, level: int, values, **kwargs) -> dict: + """``_planes.compare_greys`` against level *level*'s voxel grid.""" + voxel, clear = level_voxel(rig.spec, level, reference.data_position) + at_level = h.Reference( + hit=reference.hit, + voxel=voxel, + plane=reference.plane, + position=reference.position, + data_position=reference.data_position, + sure=reference.sure & clear, + ) + return h.compare_greys(shot, at_level, values, **kwargs) + + +def assert_matches(counts: dict, least: int = 2000) -> None: + assert counts["sure_hit"] > least, counts + assert counts["missing"] == 0, counts + assert counts["extra"] == 0, counts + assert counts["wrong"] == 0, counts + + +def target_rows(desired) -> np.ndarray: + """``[level, gz, gy, gx]`` rows of a desired set's target, in plan order.""" + from cellier.render.block_cache._image_residency import unpack_keys + + keys = np.asarray(desired.keys)[ + np.asarray(desired.cls).ravel() == int(ChunkClass.TARGET) + ] + levels, _sids, grids = unpack_keys(keys) + return np.column_stack([levels, grids]).astype(np.int64) diff --git a/tests/render/_planes.py b/tests/render/_planes.py new file mode 100644 index 00000000..1c313907 --- /dev/null +++ b/tests/render/_planes.py @@ -0,0 +1,429 @@ +"""Shared helpers for the plane rendering pixel tests. + +A visual in ``"plane"`` render mode is drawn through an offscreen renderer +and compared, pixel by pixel, with a CPU reference built here from nothing +but the camera's matrices, the planes and the data: the ray through each +pixel centre is met with every plane and the voxel at the nearest hit is +looked up. The reference shares no code with the render layer. + +A pixel whose ray passes within a small margin of any edge the picture +depends on (the data box, a plane's extent, a clipping plane, a voxel face, +the line where two planes cross) is "unsure": float32 on the GPU and float64 +here may fall on different sides of it. Tests count wrong pixels among the +sure ones, and check that the sure ones are most of the picture. + +Coordinates here are pygfx ``(x, y, z)`` unless a name says ``zyx``. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +import numpy as np +import pygfx as gfx + +from cellier.transform import AffineTransform +from cellier.visuals import RenderPlane + +if TYPE_CHECKING: + from collections.abc import Sequence + +#: Grey levels a voxel value is drawn as: ``(value + 1) / (N_VALUES + 1)``. +N_VALUES = 9 +#: How far (in level-0 voxels) a ray must stay from an edge to be sure. +MARGIN = 0.06 +#: A colour channel differing by more than this is another grey level. +COLOUR_TOL = 9 +#: The same for a label colour, after its display encoding. +LINEAR_COLOUR_TOL = 4 + + +def voxel_values(shape: Sequence[int]) -> np.ndarray: + """An integer in ``[0, N_VALUES)`` per voxel, different from all 26 neighbours.""" + z, y, x = np.indices(tuple(shape), dtype=np.int64) + return (z + 2 * y + 4 * x) % N_VALUES + + +def grey_of(values: np.ndarray) -> np.ndarray: + """The 8-bit grey a voxel value is drawn as by a grey colormap on ``[0, 1]``.""" + return np.round(255.0 * (values + 1) / (N_VALUES + 1)) + + +def image_data(shape: Sequence[int]) -> np.ndarray: + """float32 image whose every voxel differs from its neighbours.""" + return ((voxel_values(shape) + 1) / (N_VALUES + 1)).astype(np.float32) + + +def label_data(shape: Sequence[int]) -> np.ndarray: + """int32 labels ``1 .. N_VALUES - 1`` with background (0) scattered through.""" + return voxel_values(shape).astype(np.int32) + + +def label_colours() -> dict[int, tuple[float, float, float, float]]: + """A grey per label, so a pixel's colour names its label.""" + return { + label: (g, g, g, 1.0) + for label in range(1, N_VALUES) + for g in [(label + 1) / (N_VALUES + 1)] + } + + +def scale_translation_transform(controller, scene_id, store, scale_zyx, shift_zyx): + """``data -> world`` scaling and shifting each of ``z, y, x``.""" + controller._ensure_data_coordinate_systems(scene_id, store) + data = store.data_coordinate_systems[0] + world = controller._model.scenes[scene_id].dims.world_coordinate_system + names = ("z", "y", "x") + return AffineTransform.from_axis_map( + data, + world, + {data.axis_by_name(n).id: world.axis_by_name(n).id for n in names}, + scale={ + data.axis_by_name(n).id: float(s) + for n, s in zip(names, scale_zyx, strict=True) + }, + translation={ + data.axis_by_name(n).id: float(t) + for n, t in zip(names, shift_zyx, strict=True) + }, + ) + + +def world_of(controller, scene): + return controller._model.scenes[scene.id].dims.world_coordinate_system + + +def plane_zyx(world, point, normal, **kwargs) -> RenderPlane: + """A render plane from a world ``(z, y, x)`` point and normal.""" + return RenderPlane.from_point_normal( + world, point, normal, axes=("z", "y", "x"), **kwargs + ) + + +@dataclass +class Shot: + """One offscreen frame and what is needed to trace its pixels.""" + + frame: np.ndarray + renderer: gfx.WgpuRenderer + camera: gfx.Camera + #: World position of each pixel centre on the near plane, and the vector + #: from there to the far plane: ``(H, W, 3)`` each. + ray_origin: np.ndarray + ray_vector: np.ndarray + canvas: object = field(repr=False, default=None) + + @property + def drawn(self) -> np.ndarray: + return self.frame[..., 3] > 0 + + +def shoot( + controller, + scene, + *, + size: tuple[int, int] = (320, 320), + view_dir=(-0.5, -0.4, -1.0), + up=(0.0, 1.0, 0.0), + fov: float | None = None, + distance_factor: float = 1.0, + blender_targets: Sequence[str] = (), +) -> Shot: + """Draw *scene* from *view_dir* with no anti-aliasing; return the frame. + + The background is hidden, so a pixel with alpha above zero is one a + visual drew. *distance_factor* moves the camera along its view line + after the fit: 2.0 is twice as far from the scene's centre. + """ + from rendercanvas.offscreen import RenderCanvas + + from cellier.render._cellier_blender import install_cellier_blender + + controller.update_background_field(scene.id, "visible", False) + canvas_view = controller._render_manager._canvases[ + controller.get_canvas_ids(scene.id)[0] + ] + gfx_scene = canvas_view._get_scene_fn(scene.id) + camera = canvas_view.camera + if fov is not None: + camera.fov = fov + camera.show_object(gfx_scene, view_dir=view_dir, up=up) + if distance_factor != 1.0: + centre = np.asarray(gfx_scene.get_world_bounding_sphere()[:3], dtype=float) + position = np.asarray(camera.world.position, dtype=float) + camera.world.position = tuple(centre + (position - centre) * distance_factor) + + canvas = RenderCanvas(size=size, pixel_ratio=1) + renderer = gfx.WgpuRenderer(canvas) + renderer.pixel_scale = 1 + renderer.ppaa = "none" + if blender_targets: + assert install_cellier_blender(renderer, list(blender_targets)) is True + errors: list[BaseException] = [] + + def _draw() -> None: + try: + renderer.render(gfx_scene, camera) + except BaseException as exc: # pragma: no cover - failure path + errors.append(exc) + raise + + canvas.request_draw(_draw) + frame = canvas.draw() + if errors: # pragma: no cover - failure path + raise RuntimeError( + f"offscreen draw failed -- {type(errors[0]).__name__}: {errors[0]}" + ) from errors[0] + frame = np.asarray(frame) + + width, height = size + cols, rows = np.meshgrid(np.arange(width), np.arange(height)) + ndc_x = (cols + 0.5) / width * 2.0 - 1.0 + ndc_y = 1.0 - (rows + 0.5) / height * 2.0 + inverse = np.linalg.inv(np.asarray(camera.camera_matrix, dtype=np.float64)) + + def unproject(depth: float) -> np.ndarray: + ndc = np.stack( + [ndc_x, ndc_y, np.full_like(ndc_x, depth), np.ones_like(ndc_x)], axis=-1 + ) + world = ndc @ inverse.T + return world[..., :3] / world[..., 3:4] + + near = unproject(0.0) + far = unproject(1.0) + return Shot( + frame=frame, + renderer=renderer, + camera=camera, + ray_origin=near, + ray_vector=far - near, + canvas=canvas, + ) + + +@dataclass +class Reference: + """What the CPU says each pixel shows.""" + + #: The pixel shows a plane. + hit: np.ndarray + #: The voxel shown, data ``(z, y, x)``; meaningless where not ``hit``. + voxel: np.ndarray + #: Which plane, ``-1`` where not ``hit``. + plane: np.ndarray + #: World position of the hit. + position: np.ndarray + #: Level-0 data position of the hit, ``(x, y, z)``, continuous. + data_position: np.ndarray + #: The pixel is clear of every edge. + sure: np.ndarray + + +def trace( + shot: Shot, + planes: Sequence[RenderPlane], + shape_zyx: Sequence[int], + *, + scale_zyx=(1.0, 1.0, 1.0), + shift_zyx=(0.0, 0.0, 0.0), + clipping: Sequence[tuple[Sequence[float], Sequence[float]]] = (), + background: np.ndarray | None = None, + margin: float = MARGIN, +) -> Reference: + """Trace every pixel of *shot* against *planes*. + + Parameters + ---------- + shot : Shot + The frame. + planes : Sequence[RenderPlane] + Planes on world ``("z", "y", "x")``; disabled ones are skipped. + shape_zyx : Sequence[int] + Level-0 data shape. + scale_zyx, shift_zyx : Sequence[float] + The visual's transform: ``world = scale * data + shift``. + clipping : sequence of ``(point, normal)`` + Clipping planes in data ``(z, y, x)``; the normal's side is kept. + background : np.ndarray or None + Boolean per voxel, data ``(z, y, x)``: voxels a plane does not draw + (label background), so the next plane along the ray shows instead. + margin : float + The unsure band, in level-0 voxels. + """ + scale = np.asarray(scale_zyx, dtype=np.float64)[::-1] + shift = np.asarray(shift_zyx, dtype=np.float64)[::-1] + size = np.asarray(shape_zyx, dtype=np.float64)[::-1] + origin, vector = shot.ray_origin, shot.ray_vector + shape = origin.shape[:2] + # One voxel as a fraction of the ray, to compare distances along it. + voxel_world = float(scale.min()) + + best_t = np.full(shape, np.inf) + hit = np.zeros(shape, dtype=bool) + plane_index = np.full(shape, -1, dtype=np.int64) + sure = np.ones(shape, dtype=bool) + hit_ts = [] + for index, plane in enumerate(planes): + if not plane.enabled: + continue + o = np.asarray(plane.origin, dtype=np.float64)[::-1] + a0 = np.asarray(plane.in_plane_axis_0, dtype=np.float64)[::-1] + a1 = np.asarray(plane.in_plane_axis_1, dtype=np.float64)[::-1] + normal = np.cross(a0, a1) + denom = vector @ normal + with np.errstate(divide="ignore", invalid="ignore"): + t = ((o - origin) @ normal) / denom + ok = np.isfinite(t) & (t >= 0.0) & (t <= 1.0) + t = np.where(ok, t, 0.0) + world = origin + t[..., None] * vector + data = (world - shift) / scale + # Distances (in voxels, positive inside) to every edge of the plane. + edges = [data + 0.5, size - 0.5 - data] + distance = np.minimum(edges[0], edges[1]).min(axis=-1) + rel = world - o + for axis, (low, high) in ((a0, plane.extent_0), (a1, plane.extent_1)): + along = rel @ axis + if low is not None: + distance = np.minimum(distance, (along - low) / voxel_world) + if high is not None: + distance = np.minimum(distance, (high - along) / voxel_world) + for point, clip_normal in clipping: + n = np.asarray(clip_normal, dtype=np.float64)[::-1] + p = np.asarray(point, dtype=np.float64)[::-1] + distance = np.minimum(distance, ((data - p) @ n) / np.linalg.norm(n)) + inside = ok & (distance > 0.0) + sure &= ~ok | (np.abs(distance) > margin) + voxel = np.clip(np.floor(data + 0.5), 0, size - 1).astype(np.int64) + face = np.abs((data + 0.5) - np.round(data + 0.5)).min(axis=-1) + if background is not None: + is_background = background[voxel[..., 2], voxel[..., 1], voxel[..., 0]] + # A plane is passed over on background, so which side of a + # voxel face the hit is on matters to every plane, not only + # the one that is shown. + sure &= ~inside | (face > margin / 2) + inside &= ~is_background + hit_ts.append(np.where(inside, t, np.inf)) + nearer = inside & (t < best_t) + best_t = np.where(nearer, t, best_t) + plane_index = np.where(nearer, index, plane_index) + hit |= inside + + # Two planes at nearly the same distance: the line where they cross. + ray_length = np.linalg.norm(vector, axis=-1) + for t in hit_ts: + other = np.isfinite(t) & np.isfinite(best_t) & (t != best_t) + gap = np.zeros(shape) + gap[other] = np.abs(t[other] - best_t[other]) * ray_length[other] / voxel_world + sure &= ~other | (gap > margin) + + t = np.where(hit, best_t, 0.0) + position = origin + t[..., None] * vector + data = (position - shift) / scale + voxel_xyz = np.clip(np.floor(data + 0.5), 0, size - 1).astype(np.int64) + face = np.abs((data + 0.5) - np.round(data + 0.5)).min(axis=-1) + sure &= ~hit | (face > margin / 2) + return Reference( + hit=hit, + voxel=voxel_xyz[..., ::-1], + plane=plane_index, + position=position, + data_position=data, + sure=sure, + ) + + +def srgb_encode(grey: np.ndarray) -> np.ndarray: + """8-bit linear grey to the 8-bit sRGB value a display is sent.""" + linear = np.asarray(grey, dtype=np.float64) / 255.0 + encoded = np.where( + linear <= 0.0031308, 12.92 * linear, 1.055 * linear ** (1 / 2.4) - 0.055 + ) + return np.round(255.0 * encoded) + + +def compare_greys( + shot: Shot, + reference: Reference, + values: np.ndarray, + *, + linear_colours: bool = False, + channel: int = 0, +) -> dict: + """Count the sure pixels that disagree with the reference. + + Parameters + ---------- + shot, reference : + The frame and its trace. + values : np.ndarray + The voxel values (``voxel_values`` of the level-0 shape, or a label + array), data ``(z, y, x)``. + linear_colours : bool + ``True`` for labels: a label colour is taken as linear light and + encoded for the display, where an image colormap is sRGB already. + channel : int + The colour channel of the frame the grey is read from. + + Returns + ------- + dict + ``sure_hit`` and ``sure_empty`` (pixels compared), ``missing`` (the + reference shows a plane, nothing was drawn), ``extra`` (the + reverse) and ``wrong`` (drawn, another grey). + """ + drawn = shot.drawn + hit, sure = reference.hit, reference.sure + voxel = reference.voxel + expected = grey_of(values[voxel[..., 0], voxel[..., 1], voxel[..., 2]]) + tolerance = COLOUR_TOL + if linear_colours: + expected = srgb_encode(expected) + # The encoding crowds the bright greys: neighbours are 12 apart. + tolerance = LINEAR_COLOUR_TOL + red = shot.frame[..., channel].astype(np.float64) + both = sure & hit & drawn + return { + "sure_hit": int((sure & hit).sum()), + "sure_empty": int((sure & ~hit).sum()), + "missing": int((sure & hit & ~drawn).sum()), + "extra": int((sure & ~hit & drawn).sum()), + "wrong": int((both & (np.abs(red - expected) > tolerance)).sum()), + } + + +def pick_voxel(shot: Shot, gfx_visual, row: int, col: int): + """The data voxel ``(z, y, x)`` a pick at a pixel names, or ``None``.""" + info = shot.renderer.get_pick_info((col + 0.5, row + 0.5)) + coordinate = gfx_visual.pick_data_coordinate(info.get("world_object"), info) + if coordinate is None: + return None + # pygfx (x, y, z) reverses onto data (z, y, x); [i, i + 1) convention. + return tuple(int(np.floor(v)) for v in tuple(coordinate)[::-1]) + + +def pick_coordinate(shot: Shot, gfx_visual, row: int, col: int): + """The continuous level-0 data position ``(x, y, z)`` of a pick. + + In the centre convention (voxel ``i`` spans ``[i - 0.5, i + 0.5)``). + """ + info = shot.renderer.get_pick_info((col + 0.5, row + 0.5)) + coordinate = gfx_visual.pick_data_coordinate(info.get("world_object"), info) + if coordinate is None: + return None + return np.asarray(coordinate, dtype=np.float64) - 0.5 + + +def read_target(shot: Shot, name: str, dtype, channels: int) -> np.ndarray: + """Read an extra render target of *shot*'s blender as ``(H, W, channels)``.""" + from pygfx.renderers.wgpu.engine.shared import get_shared + + texture = shot.renderer._blender.get_texture(name) + width, height = texture.size[:2] + itemsize = np.dtype(dtype).itemsize * channels + raw = get_shared().device.queue.read_texture( + {"texture": texture, "mip_level": 0, "origin": (0, 0, 0)}, + {"offset": 0, "bytes_per_row": itemsize * width, "rows_per_image": height}, + (width, height, 1), + ) + return np.frombuffer(raw, dtype).reshape(height, width, channels) diff --git a/tests/render/test_plane_planning.py b/tests/render/test_plane_planning.py new file mode 100644 index 00000000..65eaa9ae --- /dev/null +++ b/tests/render/test_plane_planning.py @@ -0,0 +1,706 @@ +"""Planning for the ``"plane"`` render mode: culling rows, levels, the cache. + +Plane rendering design v3, section 6, and 14.8 (Phase 7). Counts and +levels on small pyramids; no pixel is read here (those tests are in +``test_plane_rendering_multiscale.py``). ``tests/_plane_fixtures.ANISO`` at +a 200 x 125 view is the stand-in for the measured store: its z is never +downsampled, so the 3D level rule picks levels several screen pixels per +voxel coarse on a plane, which is the fault the plane's own rule fixes. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from cellier.render._frustum import bricks_in_frustum_arr, frustum_planes_from_corners +from cellier.render._level_of_detail import ( + cull_mask, + select_levels_from_cache, + sort_arr_by_distance, +) +from cellier.render._plane_planning import ( + PLANE_REACH_VOXELS, + build_plane_planning, + owned_cell_boxes, + plane_cull_masks, + plane_level_orthographic, + plane_row_sets, + plane_thresholds, + plane_voxel_sizes, +) +from cellier.render._render_planes import ReducedPlanes +from cellier.render.scheduling import ChunkClass +from tests import _plane_fixtures as fx +from tests.render import _planes as h +from tests.render._plane_rig import POSES, make_rig, target_rows + +VIEW = (200, 125) +BLOCK = 16 + + +def _frame(normal, spin_degrees: float = 0.0): + """Two unit in-plane axes for *normal*, turned by *spin_degrees*.""" + normal = np.asarray(normal, dtype=np.float64) + normal = normal / np.linalg.norm(normal) + reference = np.zeros(3) + reference[int(np.argmin(np.abs(normal)))] = 1.0 + axis_0 = reference - (reference @ normal) * normal + axis_0 /= np.linalg.norm(axis_0) + axis_1 = np.cross(normal, axis_0) + c, s = np.cos(np.radians(spin_degrees)), np.sin(np.radians(spin_degrees)) + return c * axis_0 + s * axis_1, -s * axis_0 + c * axis_1 + + +def _reduced(*frames, origin=(0.0, 0.0, 0.0), extents=None) -> ReducedPlanes: + n = len(frames) + extent = np.tile([-1e30, 1e30, -1e30, 1e30], (n, 1)) + bounded = np.zeros((n, 4), dtype=bool) + if extents is not None: + for index, sides in enumerate(extents): + for side, value in enumerate(sides): + if value is not None: + extent[index, side] = value + bounded[index, side] = True + return ReducedPlanes( + origin=np.tile(np.asarray(origin, dtype=np.float64), (n, 1)), + axis_0=np.array([f[0] for f in frames]), + axis_1=np.array([f[1] for f in frames]), + extent=extent, + bounded=bounded, + ) + + +def _largest_voxel_by_search(axis_0, axis_1, voxel_world) -> float: + """The longest a voxel is along any in-plane direction, by trying them.""" + angles = np.linspace(0.0, np.pi, 20001) + directions = np.cos(angles)[:, None] * axis_0 + np.sin(angles)[:, None] * axis_1 + return float((1.0 / np.linalg.norm(directions / voxel_world, axis=1)).max()) + + +# -- the measure (6.3) ----------------------------------------------------------- + +#: Level voxels of a pyramid that never downsamples z, in level-0 voxels (x, y, z). +SCALES = np.array([[1.0, 1.0, 1.0], [2.0, 2.0, 1.0], [4.0, 4.0, 1.0]]) +#: ``data -> world``: a voxel is 6.55 x 6.55 x 5 world units (x, y, z). +LINEAR = np.diag([6.55, 6.55, 5.0]) + + +@pytest.mark.parametrize("normal", [(0, 0, 1), (0, 1, 0), (1.0, -0.5, 0.6)]) +@pytest.mark.parametrize("spin", [0.0, 22.5, 45.0, 90.0]) +def test_a_level_s_voxel_is_its_largest_size_in_the_plane(normal, spin): + frame = _frame(normal, spin) + sizes = plane_voxel_sizes(_reduced(frame), LINEAR, SCALES) + for level, scale in enumerate(SCALES): + searched = _largest_voxel_by_search(*frame, np.diag(LINEAR) * scale) + assert sizes[level] == pytest.approx(searched, rel=1e-6) + # The same for every spin of the frame about the normal. + unspun = plane_voxel_sizes(_reduced(_frame(normal)), LINEAR, SCALES) + assert np.allclose(sizes, unspun, rtol=1e-12) + + +def test_the_coarsest_in_plane_direction_counts_not_a_mean(): + """An xz plane: z is never downsampled, x is. x sets the size.""" + sizes = plane_voxel_sizes(_reduced(_frame((0, 1, 0))), LINEAR, SCALES) + assert np.allclose(sizes, 6.55 * SCALES[:, 0]) + + +def test_several_planes_take_the_smallest_voxel_of_each_level(): + xy, xz = _frame((0, 0, 1)), _frame((0, 1, 0)) + oblique = _frame((1.0, -0.5, 0.6)) + each = [plane_voxel_sizes(_reduced(f), LINEAR, SCALES) for f in (xy, xz, oblique)] + together = plane_voxel_sizes(_reduced(xy, xz, oblique), LINEAR, SCALES) + assert np.allclose(together, np.min(each, axis=0)) + + +def test_thresholds_are_the_2d_rule_s_transition_at_a_distance(): + sizes = np.array([1.0, 2.0, 4.0]) + fov, height = np.radians(60.0), 500.0 + thresholds = plane_thresholds(sizes, fov, height, bias=1.0) + focal = (height / 2) / np.tan(fov / 2) + # At a threshold a pixel covers the geometric mean of the two voxels. + assert np.allclose(np.array(thresholds) / focal, [np.sqrt(2.0), np.sqrt(8.0)]) + coarser = plane_thresholds(sizes, fov, height, bias=2.0) + assert np.allclose(np.array(coarser) * 2.0, thresholds) + + +def test_the_orthographic_level_is_the_one_whose_voxel_matches_the_pixel(): + sizes = np.array([1.0, 2.0, 4.0, 8.0]) + for pixel, level in ((0.5, 1), (1.4, 1), (1.5, 2), (2.8, 2), (2.9, 3), (50.0, 4)): + assert plane_level_orthographic(sizes, pixel * 100.0, 100.0, 1.0) == level + assert plane_level_orthographic(sizes, 100.0, 100.0, 3.0) == 3 + assert plane_level_orthographic(sizes, 0.0, 100.0, 1.0) is None + + +# -- culling rows (6.2) ---------------------------------------------------------- + + +def test_plane_rows_keep_the_bricks_the_plane_crosses(): + """Under an anisotropic, shifted transform: rows in data space agree with + the plane in world space, corner by corner.""" + rng = np.random.default_rng(3) + linear = np.diag([2.0, 0.5, 3.0]) + offset = np.array([10.0, -4.0, 7.0]) + frame = _frame((0.4, 1.0, -0.7), 20.0) + origin = np.array([40.0, 5.0, 60.0]) + reduced = _reduced(frame, origin=origin, extents=[(-15.0, 20.0, None, 9.0)]) + rows = plane_row_sets(reduced, linear, offset)[0] + # Two for the slab, three for the bounded sides. + assert rows.shape == (5, 4) + + normal = np.cross(*frame) + half = np.array([4.0, 4.0, 4.0]) + reach = PLANE_REACH_VOXELS * float(np.abs(normal) @ np.abs(linear).sum(axis=1)) + agree = 0 + for centre in rng.uniform(-10, 60, size=(4000, 3)): + corners = centre + half * (np.indices((2, 2, 2)).reshape(3, -1).T * 2 - 1) + world = corners @ linear.T + offset + rel = world - origin + tests = [ + (rel @ normal + reach >= 0).any(), + (-(rel @ normal) + reach >= 0).any(), + (rel @ frame[0] >= -15.0).any(), + (rel @ frame[0] <= 20.0).any(), + (rel @ frame[1] <= 9.0).any(), + ] + by_rows = all((corners @ row[:3] + row[3] >= 0).any() for row in rows) + assert by_rows == all(tests) + agree += by_rows + assert 100 < agree < 3900 + + +def test_no_plane_keeps_no_brick(): + planning = build_plane_planning( + _reduced(), lambda points: np.asarray(points), np.ones((2, 3)) + ) + grid = { + "arr": np.zeros((5, 4), dtype=np.int32), + "centres": np.zeros((5, 3)), + "half_extents": np.ones(3), + "centre_abs_max": np.zeros(3), + } + assert not cull_mask(grid, None, plane_row_sets=planning.row_sets).any() + + +# -- the plan, through the production path ---------------------------------------- + + +def _focal(request) -> float: + return (request.screen_size_px[1] / 2.0) / np.tan(request.fov_y_rad / 2.0) + + +def _pixels_per_voxel(rig, rows, request, plane, sizes_of=None) -> np.ndarray: + """Per brick: screen pixels across one voxel of its level, in *plane*. + + Measured along the in-plane direction the level's voxel is longest in, + found by search, at the brick centre's distance from the camera. + """ + spec = rig.spec + axis_0 = np.asarray(plane.in_plane_axis_0)[::-1] + axis_1 = np.asarray(plane.in_plane_axis_1)[::-1] + voxel = np.asarray(spec.voxel_size)[::-1] + size = np.array( + [ + _largest_voxel_by_search(axis_0, axis_1, voxel * np.asarray(f)[::-1]) + for f in spec.factors + ] + ) + centres = rig.brick_centres_world(rows) + distance = np.linalg.norm(centres - np.asarray(request.camera_pos)[:3], axis=1) + return size[rows[:, 0] - 1] * _focal(request) / distance + + +async def _plane_rig(controller, tmp_path, spec, normals, **kwargs): + return await make_rig( + controller, + tmp_path, + spec, + lambda world, centre: [h.plane_zyx(world, centre, n) for n in normals], + size=VIEW, + **kwargs, + ) + + +@pytest.mark.parametrize("spec", [fx.ANISO, fx.ISO], ids=["aniso", "iso"]) +@pytest.mark.parametrize("pose", list(POSES)) +async def test_the_plane_rule_draws_about_a_pixel_per_voxel( + controller, tmp_path, spec, pose +): + """Level rule (14.8): 0.7 to 1.6 screen pixels per voxel at bias 1.0.""" + rig = await _plane_rig(controller, tmp_path, spec, [POSES[pose]]) + (desired,), request = rig.plan() + rows = target_rows(desired) + assert len(rows) > 4 + assert desired.n_truncated_target == 0 + ratio = _pixels_per_voxel(rig, rows, request, rig.planes[0]) + # The finest level has nothing finer to hand over to, so a near brick of + # it may be drawn larger; the bound below is the rule's. + between = rows[:, 0] > 1 + assert np.median(ratio) > 0.7 + if between.any(): + assert np.percentile(ratio[between], 95) < 1.6 + assert np.percentile(ratio[between], 5) > 0.7 + + +@pytest.mark.parametrize("pose", list(POSES)) +async def test_the_3d_rule_is_several_pixels_per_voxel_coarse_on_a_plane( + controller, tmp_path, pose +): + """Why 6.3 exists: on a pyramid that never downsamples z the shipping + thresholds, applied to the same bricks, pick levels far too coarse.""" + rig = await _plane_rig(controller, tmp_path, fx.ANISO, [POSES[pose]]) + (desired,), request = rig.plan() + plane_rows = target_rows(desired) + by_plane_rule = np.median( + _pixels_per_voxel(rig, plane_rows, request, rig.planes[0]) + ) + + slot = rig.planner + geo = slot._volume_geometry + planning = build_plane_planning( + rig.gfx.drawn_render_planes, slot._to_level0_displayed, geo._scale_arr_shader + ) + keep = plane_cull_masks(geo, planning, None) + camera = slot._to_level0_displayed(np.asarray(request.camera_pos).reshape(1, -1)) + shipping = [ + geo._level_scale_factors[k - 1] * _focal(request) + for k in range(1, geo.n_levels) + ] + rows = select_levels_from_cache( + geo._level_grids, + geo.n_levels, + camera.ravel(), + thresholds=shipping, + base_layout=geo.base_layout, + keep=keep, + ) + by_3d_rule = np.median(_pixels_per_voxel(rig, rows, request, rig.planes[0])) + assert by_3d_rule > 2.0 + assert by_3d_rule > 1.8 * by_plane_rule + + +@pytest.mark.parametrize("pose", ["xz", "oblique"]) +async def test_the_plan_does_not_depend_on_the_frame_s_spin(controller, tmp_path, pose): + rig = await _plane_rig(controller, tmp_path, fx.ANISO, [POSES[pose]]) + start = rig.planes[0] + plans = [] + for spin in (0.0, 22.5, 45.0, 90.0): + a0, a1 = np.array(start.in_plane_axis_0), np.array(start.in_plane_axis_1) + c, s = np.cos(np.radians(spin)), np.sin(np.radians(spin)) + rig.set_planes( + [ + start.model_copy( + update={ + "in_plane_axis_0": tuple(c * a0 + s * a1), + "in_plane_axis_1": tuple(-s * a0 + c * a1), + } + ) + ] + ) + (desired,), _request = rig.plan() + plans.append(target_rows(desired)) + assert len(plans[0]) > 4 + for other in plans[1:]: + assert np.array_equal(other, plans[0]) + + +async def test_no_plane_is_drawn_coarser_than_asked_when_there_are_several( + controller, tmp_path +): + """One set of thresholds serves the visual: the finest any plane asks.""" + normals = [POSES["xy"], POSES["xz"]] + rig = await _plane_rig(controller, tmp_path, fx.ANISO, normals) + (desired,), request = rig.plan() + rows = target_rows(desired) + for plane in rig.planes: + ratio = _pixels_per_voxel(rig, rows, request, plane) + assert np.percentile(ratio, 5) > 0.7 + + +CULL_CASES = { + "extents": { + "normals": [POSES["oblique"]], + "extents": [((-300.0, 200.0), (None, 150.0))], + }, + "clipping": { + "normals": [POSES["oblique"]], + "clipping": [((32, 128, 128), (0.3, 0.5, 1.0)), ((0, 100, 0), (0, 1, 0))], + }, + "three_planes": {"normals": [POSES["xy"], POSES["xz"], (0.0, 0.0, 1.0)]}, + "three_bounded_and_clipped": { + "normals": [POSES["xy"], POSES["xz"], POSES["oblique"]], + "extents": [((-400.0, 300.0), (None, None))] * 3, + "clipping": [((0, 0, 128), (0, 0, 1))], + }, +} + + +@pytest.mark.parametrize("case", list(CULL_CASES)) +@pytest.mark.parametrize("bias", [1.0, 0.5]) +async def test_the_cull_first_plane_plan_equals_ranking_every_brick_then_culling( + controller, tmp_path, case, bias +): + """Cull equivalence (14.8): the same rows in the same order.""" + options = CULL_CASES[case] + extents = options.get("extents") + + def planes(world, centre): + out = [] + for index, normal in enumerate(options["normals"]): + extra = {} + if extents is not None: + extra = {"extent_0": extents[index][0], "extent_1": extents[index][1]} + out.append(h.plane_zyx(world, centre, normal, **extra)) + return out + + rig = await make_rig( + controller, + tmp_path, + fx.ANISO, + planes, + size=VIEW, + clipping=options.get("clipping", ()), + appearance={"settled_lod_bias": bias}, + ) + (desired,), request = rig.plan(view_dir=(-0.6, -0.3, -1.0)) + assert desired.n_truncated_target == 0 + planned = target_rows(desired) + backstop = np.asarray(desired.keys)[ + np.asarray(desired.cls).ravel() == int(ChunkClass.BACKSTOP) + ] + + slot = rig.planner + geo = slot._volume_geometry + planning = build_plane_planning( + rig.gfx.drawn_render_planes, slot._to_level0_displayed, geo._scale_arr_shader + ) + camera = slot._to_level0_displayed( + np.asarray(request.camera_pos).reshape(1, -1) + ).ravel() + # Rank every brick of every level, sort them all, then cull. + everything = select_levels_from_cache( + geo._level_grids, + geo.n_levels, + camera, + thresholds=plane_thresholds( + planning.voxel_size, request.fov_y_rad, request.screen_size_px[1], bias + ), + base_layout=geo.base_layout, + metric=planning.metric, + ) + everything = sort_arr_by_distance( + everything, + camera, + geo.block_size, + scale_vecs_shader=geo._scale_arr_shader, + translation_vecs_shader=geo._translation_arr_shader, + metric=planning.metric, + ) + clip_rows = slot._clip_rows() + on_a_plane = np.zeros(len(everything), dtype=bool) + for level, owned in enumerate(owned_cell_boxes(geo)): + mask = owned["owns"] & cull_mask(owned, None, plane_row_sets=planning.row_sets) + index = {tuple(row) for row in owned["arr"][mask]} + of_level = everything[:, 0] == level + 1 + on_a_plane |= of_level & np.array([tuple(r) in index for r in everything]) + brute = everything[on_a_plane] + rows = frustum_planes_from_corners( + slot._to_level0_displayed(request.frustum_corners) + ) + if clip_rows is not None: + rows = np.concatenate([rows, clip_rows]) + brute, _ = bricks_in_frustum_arr( + brute, + geo.block_size, + rows, + level_scale_arr_shader=geo._scale_arr_shader, + level_translation_arr_shader=geo._translation_arr_shader, + ) + # The desired set leaves out target rows that are backstop bricks too. + from cellier.render.visuals._chunked import desired_bricks + + expected = desired_bricks( + slot, + slot.residency_3d(), + brute, + backstop_arr=None, + ) + expected_rows = target_rows(expected) + is_backstop = np.isin(np.asarray(expected.keys), backstop) + assert len(planned) > 6 + assert np.array_equal(planned, expected_rows[~is_backstop]) + # And it ranked fewer bricks than there are: those the planes cross. + assert slot._last_plane_plan["n_ranked"] < sum( + len(grid["arr"]) for grid in geo._level_grids + ) + + +async def test_force_level_replaces_the_plane_rule(controller, tmp_path): + rig = await _plane_rig( + controller, + tmp_path, + fx.ANISO, + [POSES["oblique"]], + appearance={"force_level": 3}, + ) + (desired,), _request = rig.plan() + rows = target_rows(desired) + assert len(rows) and set(rows[:, 0]) == {3} + assert rig.planner._last_plane_plan["level"] == 3 + + +# -- the cache (6.4) --------------------------------------------------------------- + + +@pytest.mark.parametrize("normals", [[POSES["oblique"]], list(POSES.values())]) +async def test_one_channel_fits_the_cache(controller, tmp_path, normals): + rig = await _plane_rig(controller, tmp_path, fx.ANISO, normals) + (desired,), _request = rig.plan() + assert len(target_rows(desired)) > 4 + assert desired.n_truncated_target == 0 + + +async def test_a_plan_over_the_budget_is_truncated_nearest_first(controller, tmp_path): + """D-P10: a plan over the budget is truncated; the part of the plane + farthest from the camera, in world units, is left to the backstop.""" + normals = [POSES["oblique"]] + full = await _plane_rig(controller, tmp_path / "full", fx.ANISO, normals) + (wanted,), request = full.plan() + wanted_rows = target_rows(wanted) + + (tmp_path / "small").mkdir() + small = await _plane_rig( + controller, tmp_path / "small", fx.ANISO, normals, budget=2 * 1024**2 + ) + (desired,), _request = small.plan() + kept = target_rows(desired) + assert desired.n_truncated_target > 0 + assert len(kept) + desired.n_truncated_target == len(wanted_rows) + # What is kept is the head of the full plan: the bricks nearest the camera. + assert np.array_equal(kept, wanted_rows[: len(kept)]) + # Nearest in world units, although the data's voxels are not cubes. + centres = full.brick_centres_world(wanted_rows) + camera = np.asarray(request.camera_pos)[:3] + distance = np.linalg.norm(centres - camera, axis=1) + assert np.all(np.diff(distance) >= -1e-6) + assert distance[: len(kept)].max() <= distance[len(kept) :].min() + 1e-6 + # In level-0 voxels the same rows are not in order: the two differ here. + voxel = np.asarray(fx.ANISO.voxel_size)[::-1] + in_voxels = np.linalg.norm(centres / voxel - camera / voxel, axis=1) + assert np.any(np.diff(in_voxels) < -1e-6) + + +async def test_foreshortening_plans_at_most_half_again_as_many_bricks( + controller, tmp_path +): + """B8: a plane seen at a slant is planned finer than the screen shows, + never coarser, and within 1.6 times the face-on count. + + Summed over camera distances spanning two octaves: on a pyramid this + small one distance is a single level band face-on, so a tilt that brings + the near half into the next band is a fourfold step there and no step + at the next distance. + """ + rig = await _plane_rig(controller, tmp_path, fx.ANISO, [POSES["xy"]]) + distances = [2.0 ** (step / 4.0) for step in range(-4, 6)] + totals = {} + for tilt in (0.0, 35.0, 60.0, 75.0, 85.0): + a = np.radians(tilt) + total = 0 + for factor in distances: + (desired,), _request = rig.plan( + view_dir=(-np.sin(a), 0.0, -np.cos(a)), distance_factor=factor + ) + assert desired.n_truncated_target == 0 + total += len(target_rows(desired)) + totals[tilt] = total + assert totals[0.0] > 200 + for total in totals.values(): + assert total <= 1.6 * totals[0.0], totals + # Finer, never coarser. + assert total >= totals[0.0], totals + + +# -- orthographic (6.3, D-P47, D-P48) ------------------------------------------------ + + +@pytest.mark.parametrize("pose", list(POSES)) +async def test_an_orthographic_plane_takes_one_level_in_the_2d_rule_s_band( + controller, tmp_path, pose +): + rig = await _plane_rig(controller, tmp_path, fx.ANISO, [POSES[pose]], fov=0.0) + (desired,), request = rig.plan() + rows = target_rows(desired) + assert len(rows) > 2 + assert len(set(rows[:, 0])) == 1 + level = int(rows[0, 0]) + plan = rig.planner._last_plane_plan + assert plan["level"] == level and plan["levels_stepped"] == 0 + # Screen pixels per voxel of the level, by the plane's own measure. + pixel = request.world_extent[1] / request.screen_size_px[1] + planning = build_plane_planning( + rig.gfx.drawn_render_planes, + rig.planner._to_level0_displayed, + rig.planner._volume_geometry._scale_arr_shader, + ) + ratio = planning.voxel_size[level - 1] / pixel + n_levels = rig.spec.n_levels + if 1 < level < n_levels: + assert 1 / np.sqrt(2) - 1e-9 <= ratio <= np.sqrt(2) + 1e-9 + elif level == 1: + assert ratio >= 1 / np.sqrt(2) - 1e-9 + + +async def test_an_orthographic_plan_that_does_not_fit_takes_a_coarser_level( + controller, tmp_path +): + """P19 (D-P48): step to the next coarser level until the plan fits.""" + normals = [POSES["oblique"]] + kwargs = {"fov": 0.0, "appearance": {"settled_lod_bias": 0.25}} + roomy = await _plane_rig( + controller, tmp_path / "roomy", fx.ANISO, normals, **kwargs + ) + (wanted,), _request = roomy.plan() + rule_level = int(target_rows(wanted)[0, 0]) + assert roomy.planner._last_plane_plan["levels_stepped"] == 0 + + (tmp_path / "tight").mkdir() + tight = await _plane_rig( + controller, tmp_path / "tight", fx.ANISO, normals, budget=2 * 1024**2, **kwargs + ) + (desired,), _request = tight.plan() + rows = target_rows(desired) + plan = tight.planner._last_plane_plan + # The rule's level does not fit this atlas ... + from cellier.render.visuals._chunked import target_room + + assert len(target_rows(wanted)) > target_room( + tight.planner.residency_3d(), None, None + ) + # ... so a coarser one is drawn, whole. + assert plan["levels_stepped"] >= 1 + assert plan["level"] == rule_level + plan["levels_stepped"] + assert len(rows) and set(rows[:, 0]) == {plan["level"]} + assert desired.n_truncated_target == 0 + + +async def test_a_perspective_plan_over_the_budget_is_not_stepped(controller, tmp_path): + rig = await _plane_rig( + controller, + tmp_path, + fx.ANISO, + [POSES["oblique"]], + budget=2 * 1024**2, + appearance={"settled_lod_bias": 0.25}, + ) + (desired,), _request = rig.plan() + assert desired.n_truncated_target > 0 + assert rig.planner._last_plane_plan["levels_stepped"] == 0 + + +# -- labels, composite --------------------------------------------------------------- + + +async def test_labels_plan_the_same_bricks_as_an_image(controller, tmp_path): + normals = [POSES["oblique"]] + image = await _plane_rig(controller, tmp_path / "image", fx.ANISO, normals) + (tmp_path / "labels").mkdir() + labels = await _plane_rig( + controller, tmp_path / "labels", fx.ANISO, normals, labels=True + ) + (of_image,), _ = image.plan() + (of_labels,), _ = labels.plan() + assert len(target_rows(of_image)) > 4 + assert np.array_equal(target_rows(of_image), target_rows(of_labels)) + + +async def test_a_composite_splits_the_cache_and_every_channel_plans_the_same_bricks( + controller, tmp_path +): + """B4: the budget is split between the drawn channels, so a plan that + fits one channel is truncated with two; each channel gets the same + bricks, and the same number is dropped from each.""" + from cellier.scene import spatial_axes + from cellier.transform import AffineTransform + from cellier.visuals import ( + MultiscaleImageAppearance, + MultiscaleImageChannelAppearance, + MultiscaleImageRenderConfig, + RenderPlane, + ) + from tests._gpu_budget import BUDGET_2D + + # Room for the plan with one channel, not with the budget halved. + budget = 6 * 1024**2 + plans = {} + for n_channels in (1, 2): + spec = fx.channel_spec(n_channels) + root = tmp_path / f"c{n_channels}" + root.mkdir() + fx.write_pyramid(root, spec) + store, voxel = fx.open_pyramid(root, spec, name=f"c{n_channels}") + scene = controller.add_scene( + coordinate_system=spatial_axes("c", "z", "y", "x"), + dim="3d", + name=f"channels-{n_channels}", + ) + controller._ensure_data_coordinate_systems(scene.id, store) + data = store.data_coordinate_systems[0] + world = controller._model.scenes[scene.id].dims.world_coordinate_system + names = ("c", "z", "y", "x") + transform = AffineTransform.from_axis_map( + data, + world, + {data.axis_by_name(n).id: world.axis_by_name(n).id for n in names}, + scale={ + data.axis_by_name(n).id: v for n, v in zip(names, voxel, strict=True) + }, + ) + centre = ((np.array(fx.ANISO.shape0) - 1) / 2 + 0.3) * np.array( + fx.ANISO.voxel_size + ) + plane = RenderPlane.from_point_normal( + world, centre, POSES["oblique"], axes=("z", "y", "x") + ) + visual = controller.add_image_multiscale( + data=store, + scene_id=scene.id, + appearance=MultiscaleImageAppearance(settled_lod_bias=0.5), + render_config=MultiscaleImageRenderConfig( + block_size=BLOCK, gpu_budget_bytes=budget, gpu_budget_bytes_2d=BUDGET_2D + ), + channel_axis=0, + composite=True, + channels={ + index: MultiscaleImageChannelAppearance(render_mode="plane") + for index in range(n_channels) + }, + transform=transform, + render_planes=(plane,), + ) + controller.add_canvas(scene_id=scene.id, canvas_size=VIEW) + canvas_id = controller.get_canvas_ids(scene.id)[0] + view = controller._render_manager._canvases[canvas_id] + view._canvas.get_logical_size = lambda: (float(VIEW[0]), float(VIEW[1])) + h.shoot(controller, scene, size=VIEW) + selection = controller._selections_for_scene(scene.id)[canvas_id] + request = view.capture_reslicing_request(scene.dims.to_state(), selection, None) + gfx = controller._render_manager._scenes[scene.id].get_visual(visual.id) + desired = gfx.plan(request, controller._render_config_for(scene.id, visual)) + assert len(desired) == n_channels + rooms = [slot.residency_3d().n_slots for slot in gfx.slots[:n_channels]] + plans[n_channels] = (desired, rooms, gfx.slots[0]._last_plane_plan) + + (alone,), (room_alone,), plan_alone = plans[1] + both, rooms, plan_both = plans[2] + wanted = plan_alone["n_selected"] + # The plan is the same; the room for it is halved. + assert plan_both["n_selected"] == wanted + assert alone.n_truncated_target == 0 + assert rooms[0] == rooms[1] and rooms[0] < room_alone + kept = [len(target_rows(d)) for d in both] + dropped = [d.n_truncated_target for d in both] + assert dropped[0] == dropped[1] > 0 + assert kept[0] == kept[1] + assert np.array_equal(target_rows(both[0]), target_rows(both[1])) + assert kept[0] + dropped[0] == len(target_rows(alone)) + alone.n_truncated_target diff --git a/tests/render/test_plane_rendering_memory.py b/tests/render/test_plane_rendering_memory.py new file mode 100644 index 00000000..800b512b --- /dev/null +++ b/tests/render/test_plane_rendering_memory.py @@ -0,0 +1,829 @@ +"""In-memory plane rendering: the right voxel at the right pixel. + +Plane rendering design v3, section 5 and 14.7 (Phase 6). Each test draws an +in-memory image or labels visual in ``"plane"`` render mode and compares the +frame with the CPU reference of ``tests/render/_planes.py``. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from cellier.data import ImageMemoryStore, LabelMemoryStore +from cellier.visuals import ( + ClippingPlane, + InMemoryImageSingleAppearance, + InMemoryLabelsAppearance, +) +from tests.render import _planes as h + +SHAPE = (20, 28, 36) +#: ``(scale, shift)`` of ``data -> world`` over ``(z, y, x)``. +TRANSFORMS = { + "identity": ((1.0, 1.0, 1.0), (0.0, 0.0, 0.0)), + "anisotropic": ((2.5, 1.0, 1.0), (0.0, 4.0, -7.0)), + "squashed": ((0.5, 1.5, 1.25), (30.0, 0.0, 12.0)), +} + + +def _centre(scale, shift) -> np.ndarray: + """A point near the data's centre, in world ``(z, y, x)``. + + A third of a voxel off the centre on every axis: the centre of an + even-sized axis is a voxel face, where an axis-aligned plane would show + one voxel or its neighbour by rounding alone. + """ + return ((np.array(SHAPE) - 1) / 2 + 0.3) * np.array(scale) + np.array(shift) + + +#: ``name -> normal`` over world ``(z, y, x)``. +POSES = { + "xy": (1.0, 0.0, 0.0), + "xz": (0.0, 1.0, 0.0), + "oblique": (0.6, -0.5, 1.0), +} + + +async def _image( + controller, reslice, make_planes, *, transform="identity", clipping=(), **single +): + scale, shift = TRANSFORMS[transform] + scene = controller.add_scene(dim="3d", name="scene") + world = h.world_of(controller, scene) + store = ImageMemoryStore(data=h.image_data(SHAPE), name="image") + planes = tuple(make_planes(world, _centre(scale, shift))) + system_planes = () + transform_model = h.scale_translation_transform( + controller, scene.id, store, scale, shift + ) + if clipping: + system = store.data_coordinate_systems[0] + system_planes = tuple( + ClippingPlane.from_point_normal(system, p, n, axes=("z", "y", "x")) + for p, n in clipping + ) + visual = controller.add_image( + data=store, + scene_id=scene.id, + single=InMemoryImageSingleAppearance( + **{"render_mode": "plane", "color_map": "gray", "clim": (0.0, 1.0)} | single + ), + transform=transform_model, + render_planes=planes, + clipping_planes=system_planes, + ) + controller.add_canvas(scene_id=scene.id) + await reslice(controller, scene.id) + gfx_visual = controller._render_manager._scenes[scene.id].get_visual(visual.id) + return scene, visual, gfx_visual, planes + + +@pytest.mark.parametrize("transform", list(TRANSFORMS)) +@pytest.mark.parametrize("pose", list(POSES)) +async def test_an_image_plane_shows_the_voxel_the_ray_meets( + controller, reslice, transform, pose +): + """Right voxel (14.7): every sure pixel shows the voxel under it.""" + scene, _visual, _gfx, planes = await _image( + controller, + reslice, + lambda world, centre: [h.plane_zyx(world, centre, POSES[pose])], + transform=transform, + ) + shot = h.shoot(controller, scene) + scale, shift = TRANSFORMS[transform] + reference = h.trace(shot, planes, SHAPE, scale_zyx=scale, shift_zyx=shift) + counts = h.compare_greys(shot, reference, h.voxel_values(SHAPE)) + assert counts["sure_hit"] > 2000, counts + assert counts["missing"] == 0, counts + assert counts["extra"] == 0, counts + assert counts["wrong"] == 0, counts + + +async def _labels(controller, reslice, make_planes, *, transform="identity", **extra): + scale, shift = TRANSFORMS[transform] + scene = controller.add_scene(dim="3d", name="scene") + world = h.world_of(controller, scene) + store = LabelMemoryStore(data=h.label_data(SHAPE), name="labels") + planes = tuple(make_planes(world, _centre(scale, shift))) + visual = controller.add_labels( + data=store, + scene_id=scene.id, + appearance=InMemoryLabelsAppearance( + **{ + "render_mode": "plane", + "colormap_mode": "direct", + "color_dict": h.label_colours(), + } + | extra + ), + transform=h.scale_translation_transform( + controller, scene.id, store, scale, shift + ), + render_planes=planes, + ) + controller.add_canvas(scene_id=scene.id) + await reslice(controller, scene.id) + gfx_visual = controller._render_manager._scenes[scene.id].get_visual(visual.id) + return scene, visual, gfx_visual, planes + + +def _assert_matches(counts: dict, least: int = 2000) -> None: + assert counts["sure_hit"] > least, counts + assert counts["missing"] == 0, counts + assert counts["extra"] == 0, counts + assert counts["wrong"] == 0, counts + + +def _spun(plane, degrees: float): + """*plane* with its frame turned about its normal.""" + a0, a1 = np.array(plane.in_plane_axis_0), np.array(plane.in_plane_axis_1) + c, s = np.cos(np.radians(degrees)), np.sin(np.radians(degrees)) + return plane.model_copy( + update={ + "in_plane_axis_0": tuple(c * a0 + s * a1), + "in_plane_axis_1": tuple(-s * a0 + c * a1), + } + ) + + +def _record_plans(monkeypatch) -> list: + from cellier.render.scene_manager import SceneManager + + plans: list = [] + original = SceneManager.plan_chunked + + def spy(self, request, visual_configs): + plans.append(tuple(visual_configs)) + return original(self, request, visual_configs) + + monkeypatch.setattr(SceneManager, "plan_chunked", spy) + return plans + + +# -- labels ------------------------------------------------------------------ + + +@pytest.mark.parametrize("transform", ["identity", "anisotropic"]) +@pytest.mark.parametrize("pose", list(POSES)) +async def test_a_label_plane_shows_the_label_and_skips_background( + controller, reslice, transform, pose +): + """Labels: 0 wrong; a background voxel draws nothing.""" + scene, _visual, _gfx, planes = await _labels( + controller, + reslice, + lambda world, centre: [h.plane_zyx(world, centre, POSES[pose])], + transform=transform, + ) + shot = h.shoot(controller, scene) + scale, shift = TRANSFORMS[transform] + labels = h.label_data(SHAPE) + reference = h.trace( + shot, planes, SHAPE, scale_zyx=scale, shift_zyx=shift, background=labels == 0 + ) + counts = h.compare_greys(shot, reference, labels, linear_colours=True) + _assert_matches(counts, least=1500) + # Background is a ninth of the voxels: holes are in the picture. + assert counts["sure_empty"] > 0 + + +async def test_a_label_shows_through_another_plane_s_background(controller, reslice): + """A ray that meets background on the near plane shows the far plane.""" + scene, _visual, _gfx, planes = await _labels( + controller, + reslice, + lambda world, centre: [ + h.plane_zyx(world, centre + np.array([0.0, 0.0, 6.0]), (0.3, 0.0, 1.0)), + h.plane_zyx(world, centre - np.array([0.0, 0.0, 6.0]), (0.0, 0.2, 1.0)), + h.plane_zyx(world, centre, (1.0, 0.1, 0.0)), + ], + ) + shot = h.shoot(controller, scene, view_dir=(-1.0, -0.3, -0.6)) + labels = h.label_data(SHAPE) + background = labels == 0 + reference = h.trace(shot, planes, SHAPE, background=background) + _assert_matches( + h.compare_greys(shot, reference, labels, linear_colours=True), least=1500 + ) + + # Pixels whose nearest plane hit is background but that show a label: + # the label of a plane behind. + nearest = h.trace(shot, planes, SHAPE) + seen_through = reference.sure & reference.hit & nearest.hit + seen_through &= reference.plane != nearest.plane + assert seen_through.sum() > 100 + assert shot.drawn[seen_through].all() + + +# -- extents, clipping, several planes ---------------------------------------- + + +async def test_extents_and_clipping_planes_cut_the_plane(controller, reslice): + """The rectangle drawn equals the CPU's: 0 wrong, 0 missing.""" + clipping = [((0.0, 0.0, 22.0), (0.0, 0.2, -1.0))] + scene, _visual, _gfx, planes = await _image( + controller, + reslice, + lambda world, centre: [ + h.plane_zyx( + world, + centre, + (0.6, -0.5, 1.0), + extent_0=(-6.0, 9.0), + extent_1=(None, 7.0), + ) + ], + transform="anisotropic", + clipping=clipping, + ) + shot = h.shoot(controller, scene) + scale, shift = TRANSFORMS["anisotropic"] + reference = h.trace( + shot, planes, SHAPE, scale_zyx=scale, shift_zyx=shift, clipping=clipping + ) + _assert_matches(h.compare_greys(shot, reference, h.voxel_values(SHAPE)), 800) + # Both the extent and the clipping plane removed something. + unbounded = h.trace( + shot, + [ + planes[0].model_copy( + update={"extent_0": (None, None), "extent_1": (None, None)} + ) + ], + SHAPE, + scale_zyx=scale, + shift_zyx=shift, + ) + assert (unbounded.hit & ~reference.hit).sum() > 500 + + +async def test_three_planes_draw_the_nearest_at_every_pixel(controller, reslice): + scene, _visual, _gfx, planes = await _image( + controller, + reslice, + lambda world, centre: [ + h.plane_zyx(world, centre, (1.0, 0.0, 0.0)), + h.plane_zyx(world, centre, (0.0, 1.0, 0.0)), + h.plane_zyx(world, centre, (0.0, 0.0, 1.0)), + ], + ) + shot = h.shoot(controller, scene) + reference = h.trace(shot, planes, SHAPE) + _assert_matches(h.compare_greys(shot, reference, h.voxel_values(SHAPE)), 6000) + assert set(np.unique(reference.plane[reference.hit])) == {0, 1, 2} + + +async def test_a_disabled_plane_is_not_drawn(controller, reslice): + scene, _visual, _gfx, planes = await _image( + controller, + reslice, + lambda world, centre: [ + h.plane_zyx(world, centre, (1.0, 0.0, 0.0), enabled=False), + h.plane_zyx(world, centre, (0.0, 0.0, 1.0)), + ], + ) + shot = h.shoot(controller, scene) + reference = h.trace(shot, planes, SHAPE) + _assert_matches(h.compare_greys(shot, reference, h.voxel_values(SHAPE))) + assert set(np.unique(reference.plane[reference.hit])) == {1} + + +async def test_moving_and_spinning_a_bounded_plane_is_a_uniform_write( + controller, reslice, monkeypatch +): + """C7: an in-plane move and a spin with finite extents; nothing is read.""" + scene, visual, _gfx, planes = await _image( + controller, + reslice, + lambda world, centre: [ + h.plane_zyx( + world, + centre, + (0.6, -0.5, 1.0), + extent_0=(-7.0, 5.0), + extent_1=(-4.0, 8.0), + ) + ], + ) + plans = _record_plans(monkeypatch) + values = h.voxel_values(SHAPE) + start = planes[0] + compared = 0 + for step in range(1, 5): + moved = start.model_copy( + update={ + "origin": tuple( + np.array(start.origin) + + 1.3 * step * np.array(start.in_plane_axis_0) + - 0.7 * step * np.array(start.in_plane_axis_1) + ) + } + ) + moved = _spun(moved, 17.0 * step) + controller.set_render_planes(visual.id, (moved,)) + shot = h.shoot(controller, scene) + counts = h.compare_greys(shot, h.trace(shot, (moved,), SHAPE), values) + _assert_matches(counts, least=500) + compared += counts["sure_hit"] + assert compared > 4000 + assert plans == [] + + +async def test_a_plane_is_not_drawn_when_its_axes_are_not_displayed( + controller, reslice +): + """Design 4.4: a 2D view shows the slice; the mode and planes are ignored.""" + scene, visual, gfx_visual, _planes = await _image( + controller, + reslice, + lambda world, centre: [h.plane_zyx(world, centre, POSES["xy"])], + ) + assert len(gfx_visual.drawn_render_planes) == 1 + controller.set_displayed_axes(scene.id, (1, 2)) + assert len(gfx_visual.drawn_render_planes) == 0 + controller.set_displayed_axes(scene.id, (0, 1, 2)) + assert len(gfx_visual.drawn_render_planes) == 1 + assert visual.render_planes # the model is untouched + + +# -- modes ------------------------------------------------------------------- + + +async def test_switching_an_image_between_plane_and_a_volume_mode(controller, reslice): + scene, visual, _gfx, planes = await _image( + controller, + reslice, + lambda world, centre: [h.plane_zyx(world, centre, POSES["xy"])], + ) + values = h.voxel_values(SHAPE) + shot = h.shoot(controller, scene) + plane_pixels = int(shot.drawn.sum()) + _assert_matches(h.compare_greys(shot, h.trace(shot, planes, SHAPE), values)) + + controller.update_single_appearance_field(visual.id, "render_mode", "mip") + await reslice(controller, scene.id) + volume = h.shoot(controller, scene) + # The whole box draws, not one section of it. + assert volume.drawn.sum() > 1.5 * plane_pixels + + controller.update_single_appearance_field(visual.id, "render_mode", "plane") + await reslice(controller, scene.id) + shot = h.shoot(controller, scene) + _assert_matches(h.compare_greys(shot, h.trace(shot, planes, SHAPE), values)) + + +async def test_switching_labels_between_plane_and_a_volume_mode(controller, reslice): + scene, visual, _gfx, planes = await _labels( + controller, + reslice, + lambda world, centre: [h.plane_zyx(world, centre, POSES["xy"])], + ) + labels = h.label_data(SHAPE) + background = labels == 0 + + def check() -> int: + shot = h.shoot(controller, scene) + reference = h.trace(shot, planes, SHAPE, background=background) + _assert_matches( + h.compare_greys(shot, reference, labels, linear_colours=True), least=1500 + ) + return int(shot.drawn.sum()) + + plane_pixels = check() + controller.update_appearance_field(visual.id, "render_mode", "flat_categorical") + await reslice(controller, scene.id) + assert h.shoot(controller, scene).drawn.sum() > 1.5 * plane_pixels + controller.update_appearance_field(visual.id, "render_mode", "plane") + await reslice(controller, scene.id) + check() + + +async def test_plane_mode_with_no_plane_draws_nothing_until_one_is_added( + controller, reslice +): + scene, visual, _gfx, _planes = await _image( + controller, reslice, lambda world, centre: [] + ) + assert not h.shoot(controller, scene).drawn.any() + world = h.world_of(controller, scene) + plane = h.plane_zyx(world, _centre(*TRANSFORMS["identity"]), POSES["oblique"]) + controller.set_render_planes(visual.id, (plane,)) + await reslice(controller, scene.id) + shot = h.shoot(controller, scene) + _assert_matches( + h.compare_greys(shot, h.trace(shot, (plane,), SHAPE), h.voxel_values(SHAPE)) + ) + + +def test_a_volume_mode_shader_holds_no_plane_code(): + """V8: the branch is compiled in for the plane mode only. + + The byte-for-byte check of every volume mode's generated shader is + ``test_plane_golden_replay.py``; this guards the mechanism. + """ + from pathlib import Path + + import cellier.render.shaders as shaders + + wgsl = Path(shaders.__file__).parent / "wgsl" + for name, guard in ( + ("image_volume.wgsl", "$$ if mode == 'plane'"), + ("label_volume.wgsl", '$$ if render_mode == "plane"'), + ): + source = (wgsl / name).read_text() + include = source.index("cellier.render_planes.wgsl") + assert source.rindex(guard, 0, include) > source.rindex("$$ endif", 0, include) + use = source.index("plane_nearest_hit(") + assert "$$ else" not in source[source.rindex(guard, 0, use) : use], name + + +# -- depth and alpha ---------------------------------------------------------- + + +def _crossing(world, centre): + return [ + h.plane_zyx(world, centre, (1.0, 0.0, 0.0)), + h.plane_zyx(world, centre, (0.0, 0.0, 1.0)), + ] + + +@pytest.mark.parametrize( + ("mode", "depth_write"), + [("blend", True), ("blend", False), ("add", True), ("add", False)], +) +async def test_a_translucent_plane_is_blended_once( + controller, reslice, mode, depth_write +): + """Depth and alpha (5.4): a pixel is one sample, whatever lies behind it. + + Two planes cross, so many rays meet both. A pixel that showed both + blended would differ from a pixel of the same voxel value with nothing + behind it. + """ + from cellier.visuals import InMemoryImageAppearance + + scene = controller.add_scene(dim="3d", name="scene") + world = h.world_of(controller, scene) + store = ImageMemoryStore(data=h.image_data(SHAPE), name="image") + planes = tuple(_crossing(world, _centre(*TRANSFORMS["identity"]))) + controller.add_image( + data=store, + scene_id=scene.id, + appearance=InMemoryImageAppearance( + transparency_mode=mode, depth_write=depth_write + ), + single=InMemoryImageSingleAppearance(render_mode="plane", opacity=0.5), + render_planes=planes, + ) + controller.add_canvas(scene_id=scene.id) + await reslice(controller, scene.id) + shot = h.shoot(controller, scene) + reference = h.trace(shot, planes, SHAPE) + both = h.trace(shot, planes[:1], SHAPE).hit & h.trace(shot, planes[1:], SHAPE).hit + sure = reference.sure & reference.hit + assert shot.drawn[sure].all() + assert (sure & both).sum() > 1000 and (sure & ~both).sum() > 1000 + + voxel = reference.voxel + values = h.voxel_values(SHAPE)[voxel[..., 0], voxel[..., 1], voxel[..., 2]] + for value in range(h.N_VALUES): + alone = shot.frame[sure & ~both & (values == value)].astype(int) + behind = shot.frame[sure & both & (values == value)].astype(int) + assert len(alone) and len(behind) + colour = np.round(alone.mean(axis=0)) + assert np.abs(alone - colour).max() <= 1, (value, mode) + assert np.abs(behind - colour).max() <= 1, (value, mode) + # Half opacity: the picture is not opaque. + assert shot.frame[sure][:, 3].max() < 200 + + +async def test_a_composite_draws_every_channel_on_the_planes(controller, reslice): + """A composite is additive and writes no depth, as a composite volume.""" + from cellier.scene import spatial_axes + from cellier.visuals import InMemoryImageChannelAppearance + + scene = controller.add_scene( + coordinate_system=[("c", "channel"), *spatial_axes("z", "y", "x")], + dim="3d", + name="channels", + ) + world = h.world_of(controller, scene) + first = h.voxel_values(SHAPE) + second = first[:, :, ::-1] + data = np.stack([first, second]).astype(np.float32) + data = (data + 1) / (h.N_VALUES + 1) + planes = tuple(_crossing(world, _centre(*TRANSFORMS["identity"]))) + visual = controller.add_image( + data=ImageMemoryStore(data=data, name="channels"), + scene_id=scene.id, + channel_axis=0, + composite=True, + channels={ + 0: InMemoryImageChannelAppearance(render_mode="plane", color_map="red"), + 1: InMemoryImageChannelAppearance(render_mode="plane", color_map="blue"), + }, + render_planes=planes, + ) + controller.add_canvas(scene_id=scene.id) + await reslice(controller, scene.id) + gfx_visual = controller._render_manager._scenes[scene.id].get_visual(visual.id) + drawn = [gfx_visual.slots[i] for i in gfx_visual._drawn.values()] + assert len(drawn) == 2 + assert not any(slot.node_3d.material.depth_write for slot in drawn) + # One buffer of planes for every channel's material. + buffers = {id(slot.node_3d.material.render_planes_buffer) for slot in drawn} + assert buffers == {id(gfx_visual.render_planes_buffer)} + + shot = h.shoot(controller, scene) + reference = h.trace(shot, planes, SHAPE) + _assert_matches(h.compare_greys(shot, reference, first, channel=0)) + _assert_matches(h.compare_greys(shot, reference, second, channel=2)) + + +# -- against a mesh ----------------------------------------------------------- + + +def _slab(centre_xyz, size, colour, **material): + import pygfx as gfx + + mesh = gfx.Mesh( + gfx.box_geometry(*size), gfx.MeshBasicMaterial(color=colour, **material) + ) + mesh.local.position = tuple(centre_xyz) + return mesh + + +@pytest.mark.parametrize("where", ["in_front", "behind"]) +async def test_a_plane_and_an_opaque_mesh_occlude_each_other( + controller, reslice, where +): + scene, _visual, _gfx, planes = await _image( + controller, + reslice, + lambda world, centre: [h.plane_zyx(world, centre, POSES["xy"])], + ) + centre = np.array(planes[0].origin)[::-1] + # The camera looks down -z, so +z is nearer to it. + offset = 3.0 if where == "in_front" else -3.0 + gfx_scene = controller._render_manager.get_scene(scene.id) + gfx_scene.add( + _slab(centre + np.array([0.0, 0.0, offset]), (10.0, 8.0, 1.0), "#ff0000") + ) + shot = h.shoot(controller, scene, view_dir=(0.0, 0.0, -1.0)) + reference = h.trace(shot, planes, SHAPE) + xy = reference.position[..., :2] - centre[:2] + over_mesh = (np.abs(xy[..., 0]) < 4.5) & (np.abs(xy[..., 1]) < 3.5) + # Clear of the mesh's outline, which perspective enlarges when in front. + clear = (np.abs(xy[..., 0]) > 6.5) | (np.abs(xy[..., 1]) > 5.5) + sure = reference.sure & reference.hit + assert (sure & over_mesh).sum() > 300 and (sure & clear).sum() > 300 + red = (shot.frame[..., 0] > 250) & (shot.frame[..., 1] < 5) + if where == "in_front": + assert red[sure & over_mesh].all() + else: + assert not red[sure & over_mesh].any() + assert not red[sure & clear].any() + + +@pytest.mark.parametrize("depth_write", [True, False]) +async def test_a_translucent_mesh_behind_a_plane_follows_its_depth_write( + controller, reslice, depth_write +): + """C11: a mesh drawn after the plane is tested against the plane's depth + where the visual writes depth, and shows through where it does not.""" + from cellier.visuals import InMemoryImageAppearance + + scene = controller.add_scene(dim="3d", name="scene") + world = h.world_of(controller, scene) + store = ImageMemoryStore(data=h.image_data(SHAPE), name="image") + plane = h.plane_zyx(world, _centre(*TRANSFORMS["identity"]), POSES["xy"]) + controller.add_image( + data=store, + scene_id=scene.id, + appearance=InMemoryImageAppearance(depth_write=depth_write), + single=InMemoryImageSingleAppearance(render_mode="plane"), + render_planes=(plane,), + ) + controller.add_canvas(scene_id=scene.id) + await reslice(controller, scene.id) + before = h.shoot(controller, scene, view_dir=(0.0, 0.0, -1.0)) + + centre = np.array(plane.origin)[::-1] + mesh = _slab( + centre - np.array([0.0, 0.0, 3.0]), + (10.0, 8.0, 1.0), + "#ff0000", + opacity=0.5, + alpha_mode="blend", + ) + mesh.render_order = 10 + controller._render_manager.get_scene(scene.id).add(mesh) + after = h.shoot(controller, scene, view_dir=(0.0, 0.0, -1.0)) + reference = h.trace(after, (plane,), SHAPE) + xy = reference.position[..., :2] - centre[:2] + over_mesh = (np.abs(xy[..., 0]) < 4.5) & (np.abs(xy[..., 1]) < 3.5) + over_mesh &= reference.sure & reference.hit + assert over_mesh.sum() > 300 + changed = (before.frame != after.frame).any(axis=-1) + if depth_write: + assert not changed[over_mesh].any() + else: + assert changed[over_mesh].all() + + +# -- pick --------------------------------------------------------------------- + + +def _sample(mask: np.ndarray, count: int) -> list[tuple[int, int]]: + rows, cols = np.nonzero(mask) + chosen = np.linspace(0, len(rows) - 1, count).astype(int) + return [(int(rows[i]), int(cols[i])) for i in chosen] + + +@pytest.mark.parametrize("pose", list(POSES)) +async def test_a_pick_on_an_image_plane_names_the_voxel_at_the_hit( + controller, reslice, pose +): + scene, _visual, gfx_visual, planes = await _image( + controller, + reslice, + lambda world, centre: [h.plane_zyx(world, centre, POSES[pose])], + transform="anisotropic", + ) + shot = h.shoot(controller, scene) + scale, shift = TRANSFORMS["anisotropic"] + reference = h.trace(shot, planes, SHAPE, scale_zyx=scale, shift_zyx=shift) + pixels = _sample(reference.sure & reference.hit, 150) + wrong, worst = [], 0.0 + for row, col in pixels: + voxel = h.pick_voxel(shot, gfx_visual, row, col) + if voxel != tuple(reference.voxel[row, col]): + wrong.append((row, col, voxel, tuple(reference.voxel[row, col]))) + position = h.pick_coordinate(shot, gfx_visual, row, col) + worst = max(worst, np.abs(position - reference.data_position[row, col]).max()) + assert not wrong, wrong[:5] + # An eighth of a voxel plus the 14-bit step. + assert worst < 0.125 + max(SHAPE) / 16383, worst + + +async def test_a_pick_on_a_label_plane_names_the_label_drawn(controller, reslice): + scene, _visual, gfx_visual, planes = await _labels( + controller, + reslice, + lambda world, centre: [ + h.plane_zyx(world, centre + np.array([0.0, 0.0, 6.0]), (0.3, 0.0, 1.0)), + h.plane_zyx(world, centre, (1.0, 0.1, 0.0)), + ], + ) + shot = h.shoot(controller, scene, view_dir=(-1.0, -0.3, -0.6)) + labels = h.label_data(SHAPE) + reference = h.trace(shot, planes, SHAPE, background=labels == 0) + wrong = [] + for row, col in _sample(reference.sure & reference.hit, 200): + voxel = h.pick_voxel(shot, gfx_visual, row, col) + expected = tuple(reference.voxel[row, col]) + if voxel != expected or labels[voxel] == 0: + wrong.append((row, col, voxel, expected)) + assert not wrong, wrong[:5] + + +# -- the normal and outline targets ------------------------------------------- + + +def _view_normals(shot, plane) -> np.ndarray: + """The unit view-space normal of *plane*, faced to the viewer, per pixel.""" + normal = np.cross(plane.in_plane_axis_0[::-1], plane.in_plane_axis_1[::-1]) + view = np.asarray(shot.camera.view_matrix, dtype=np.float64) + in_view = view[:3, :3] @ normal + in_view /= np.linalg.norm(in_view) + return in_view + + +@pytest.mark.parametrize("kind", ["image", "labels"]) +async def test_the_plane_writes_its_normal_facing_the_viewer(controller, reslice, kind): + """Normal (5.5, P15): the plane's own, the same from both of its sides.""" + from cellier.render._cellier_blender import NORMAL_TARGET + + add = _image if kind == "image" else _labels + scene, visual, _gfx, planes = await add( + controller, + reslice, + lambda world, centre: [h.plane_zyx(world, centre, POSES["oblique"])], + transform="anisotropic", + ) + scale, shift = TRANSFORMS["anisotropic"] + background = h.label_data(SHAPE) == 0 if kind == "labels" else None + + def normals_and_frame(plane): + shot = h.shoot(controller, scene, blender_targets=[NORMAL_TARGET]) + written = h.read_target(shot, NORMAL_TARGET, np.float16, 4).astype(np.float64) + reference = h.trace( + shot, + (plane,), + SHAPE, + scale_zyx=scale, + shift_zyx=shift, + background=background, + ) + return shot, written, reference + + shot, written, reference = normals_and_frame(planes[0]) + sure = reference.sure & reference.hit + assert sure.sum() > 1500 + expected = _view_normals(shot, planes[0]) + # Faced to the viewer: against the view-space position of the hit. + view = np.asarray(shot.camera.view_matrix, dtype=np.float64) + position = reference.position[sure] @ view[:3, :3].T + view[:3, 3] + facing = np.where((position @ expected)[:, None] < 0, expected, -expected) + got = written[sure][:, :3] + # The target is 16-bit float: unit to three decimal places. + length = np.linalg.norm(got, axis=-1, keepdims=True) + assert np.allclose(length, 1.0, atol=0.01) + got = got / length + cosine = np.clip(np.sum(got * facing, axis=-1), -1.0, 1.0) + assert np.degrees(np.arccos(cosine)).max() < 0.35 + # Nothing is written where no plane is drawn. + assert not written[reference.sure & ~reference.hit][:, :3].any() + + # The other side of the same plane: nothing drawn differs. + plane = planes[0] + flipped = plane.model_copy( + update={ + "in_plane_axis_0": plane.in_plane_axis_1, + "in_plane_axis_1": plane.in_plane_axis_0, + "extent_0": plane.extent_1, + "extent_1": plane.extent_0, + } + ) + assert np.allclose(np.array(flipped.normal), -np.array(plane.normal)) + controller.set_render_planes(visual.id, (flipped,)) + flipped_shot, flipped_written, _ = normals_and_frame(flipped) + assert np.array_equal(flipped_shot.frame, shot.frame) + assert np.array_equal(flipped_written, written) + + +async def test_a_label_plane_writes_the_outline_key_of_the_label_drawn( + controller, reslice +): + """Outline (5.5): one key per label, none on background or off the plane.""" + from cellier.render._cellier_blender import OUTLINE_ID_TARGET + + scene, _visual, _gfx, planes = await _labels( + controller, + reslice, + lambda world, centre: [h.plane_zyx(world, centre, POSES["oblique"])], + ) + shot = h.shoot(controller, scene, blender_targets=[OUTLINE_ID_TARGET]) + keys = h.read_target(shot, OUTLINE_ID_TARGET, np.uint32, 1)[..., 0] + labels = h.label_data(SHAPE) + reference = h.trace(shot, planes, SHAPE, background=labels == 0) + voxel = reference.voxel + label = labels[voxel[..., 0], voxel[..., 1], voxel[..., 2]] + sure = reference.sure & reference.hit + assert sure.sum() > 1500 + assert not keys[reference.sure & ~reference.hit].any() + key_of = {} + for value in range(1, h.N_VALUES): + found = np.unique(keys[sure & (label == value)]) + assert len(found) == 1 and found[0] >= 16, (value, found) + key_of[value] = int(found[0]) + assert len(set(key_of.values())) == h.N_VALUES - 1 + + +# -- nD data (R9) ------------------------------------------------------------- + + +async def test_a_plane_on_three_axes_of_a_4d_scene_shows_the_current_time_point( + controller, reslice +): + """A ``zyx`` plane in a ``tzyx`` scene holds at every time point: dims + supplies ``t``, and the plane shows that time point's voxels.""" + from cellier.scene import spatial_axes + + scene = controller.add_scene( + coordinate_system=[("t", "time"), *spatial_axes("z", "y", "x")], + dim="3d", + name="scene", + ) + world = h.world_of(controller, scene) + values = np.stack([(h.voxel_values(SHAPE) + 3 * t) % h.N_VALUES for t in range(3)]) + data = ((values + 1) / (h.N_VALUES + 1)).astype(np.float32) + plane = h.plane_zyx(world, _centre(*TRANSFORMS["identity"]), POSES["oblique"]) + controller.add_image( + data=ImageMemoryStore(data=data, name="tzyx"), + scene_id=scene.id, + single=InMemoryImageSingleAppearance(render_mode="plane"), + render_planes=(plane,), + ) + controller.add_canvas(scene_id=scene.id) + for t in (0, 2): + controller.update_slice_indices(scene.id, {0: float(t)}) + await reslice(controller, scene.id) + shot = h.shoot(controller, scene) + reference = h.trace(shot, (plane,), SHAPE) + _assert_matches(h.compare_greys(shot, reference, values[t])) + # Not another time point's picture. + other = h.compare_greys(shot, reference, values[1]) + assert other["wrong"] > 0.5 * other["sure_hit"] diff --git a/tests/render/test_plane_rendering_multiscale.py b/tests/render/test_plane_rendering_multiscale.py new file mode 100644 index 00000000..8c38ab57 --- /dev/null +++ b/tests/render/test_plane_rendering_multiscale.py @@ -0,0 +1,560 @@ +"""Multiscale plane rendering: the right voxel, of the right level. + +Plane rendering design v3, sections 5 and 6, and 14.8 (Phase 7). Each +pixel test draws a multiscale image or labels visual in ``"plane"`` render +mode and compares the frame with the CPU reference of +``tests/render/_planes.py``. The planner's own tests (culling rows, the +level rule, the cache) are in ``test_plane_planning.py``. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from cellier.render.scheduling import Tier +from tests.render import _planes as h +from tests.render._plane_rig import ( + ANISO, + BLOCK, + ISO, + POSES, + SPECS, + UNALIGNED, + Rig, + assert_matches, + centre_of, + compare_level, + level_values, + level_voxel, + make_rig, +) +from tests.render.conftest import drain_loading + +# -- the right voxel ----------------------------------------------------------- + + +@pytest.mark.parametrize("spec_name", list(SPECS)) +@pytest.mark.parametrize("pose", list(POSES)) +@pytest.mark.parametrize("level", [0, 1, 2]) +async def test_an_image_plane_shows_the_voxel_of_the_level_drawn( + controller, tmp_path, spec_name, pose, level +): + """Every pixel of the plane shows the forced level's voxel: the right + voxel, and never the backstop's, so every brick the plane is drawn from + was planned. On an integer pyramid, one that never downsamples z, one + with non-integer level ratios and one whose levels are not aligned with + the lookup table's cells.""" + spec = SPECS[spec_name] + rig = await make_rig( + controller, + tmp_path, + spec, + lambda world, centre: [h.plane_zyx(world, centre, POSES[pose])], + appearance={"force_level": level + 1}, + ) + shot = await rig.settle() + reference = rig.trace(shot) + counts = compare_level(rig, shot, reference, level, level_values(spec, level)) + assert_matches(counts, least=1500) + + +@pytest.mark.parametrize("pose", list(POSES)) +@pytest.mark.parametrize("level", [0, 1, 2]) +async def test_a_label_plane_shows_the_label_of_the_level_drawn( + controller, tmp_path, pose, level +): + """Labels (14.8): label, background and level right at three levels.""" + spec = ANISO + rig = await make_rig( + controller, + tmp_path, + spec, + lambda world, centre: [h.plane_zyx(world, centre, POSES[pose])], + labels=True, + appearance={"force_level": level + 1}, + ) + shot = await rig.settle() + labels = level_values(spec, level) + voxel, clear = level_voxel(spec, level, rig.trace(shot).data_position) + # Background is decided by the level's own voxel grid. + is_background = labels[voxel[..., 0], voxel[..., 1], voxel[..., 2]] == 0 + drawn_where_label = rig.trace(shot) + sure = drawn_where_label.sure & clear + hit = drawn_where_label.hit + expected = h.srgb_encode( + h.grey_of(labels[voxel[..., 0], voxel[..., 1], voxel[..., 2]]) + ) + red = shot.frame[..., 0].astype(float) + label_pixels = sure & hit & ~is_background + assert label_pixels.sum() > 1200 + assert shot.drawn[label_pixels].all() + assert (np.abs(red - expected)[label_pixels] <= h.LINEAR_COLOUR_TOL).all() + # Background voxels of the plane, and everything off it, draw nothing. + assert (sure & hit & is_background).sum() > 100 + assert not shot.drawn[sure & hit & is_background].any() + assert not shot.drawn[sure & ~hit].any() + + +async def test_a_multiscale_label_shows_through_another_plane_s_background( + controller, tmp_path +): + spec = ISO + rig = await make_rig( + controller, + tmp_path, + spec, + lambda world, centre: [ + h.plane_zyx(world, centre + np.array([0.0, 0.0, 9.0]), (0.3, 0.0, 1.0)), + h.plane_zyx(world, centre, (1.0, 0.1, 0.0)), + ], + labels=True, + appearance={"force_level": 1}, + ) + camera = {"view_dir": (-1.0, -0.3, -0.6)} + shot = await rig.settle(**camera) + labels = level_values(spec, 0) + reference = rig.trace(shot, background=labels == 0) + assert_matches( + h.compare_greys(shot, reference, labels, linear_colours=True), least=1500 + ) + nearest = rig.trace(shot) + seen_through = reference.sure & reference.hit & (reference.plane != nearest.plane) + assert seen_through.sum() > 100 + assert shot.drawn[seen_through].all() + + +# -- extents and clipping ------------------------------------------------------ + + +async def test_extents_and_clipping_cut_a_multiscale_plane(controller, tmp_path): + spec = ANISO + clipping = [((0.0, 0.0, 60.0), (0.0, 0.2, -1.0))] + rig = await make_rig( + controller, + tmp_path, + spec, + lambda world, centre: [ + h.plane_zyx( + world, + centre, + (0.6, -0.5, 1.0), + extent_0=(-20.0, 30.0), + extent_1=(None, 25.0), + ) + ], + clipping=clipping, + appearance={"force_level": 1}, + ) + shot = await rig.settle() + reference = rig.trace(shot, clipping=clipping) + assert_matches(compare_level(rig, shot, reference, 0, level_values(spec, 0)), 800) + whole = h.trace( + shot, + [ + rig.planes[0].model_copy( + update={"extent_0": (None, None), "extent_1": (None, None)} + ) + ], + spec.shape0, + scale_zyx=spec.voxel_size, + ) + assert (whole.hit & ~reference.hit).sum() > 500 + + +# -- the level drawn is the level planned --------------------------------------- + + +def _expected_levels(rig: Rig, reference: h.Reference): + """Per pixel: the level the plan names at the hit, and whether it is sure. + + The LUT names a brick per base cell (a level-0 brick), so the level at + a position is that of the finest planned brick covering its base cell, + or the backstop (the coarsest level) where none does. + """ + from cellier.render._level_mapping import base_cell_range + + spec = rig.spec + shape = np.asarray(spec.shape0) + cells = np.ceil(shape / BLOCK).astype(int) + level_of = np.full(tuple(cells), spec.n_levels, dtype=np.int64) + for level, gz, gy, gx in rig.planned(): + factor = np.asarray(spec.factors[level - 1], dtype=np.float64) + shift = (factor - 1.0) / 2.0 + low = (np.array([gz, gy, gx]) * BLOCK - 0.5) * factor + shift + high = ((np.array([gz, gy, gx]) + 1) * BLOCK - 0.5) * factor + shift + start, stop = base_cell_range(low + 1e-6, high - 1e-6, BLOCK) + start, stop = np.maximum(start, 0), np.minimum(stop, cells) + block = level_of[start[0] : stop[0], start[1] : stop[1], start[2] : stop[2]] + np.minimum(block, level, out=block) + index = reference.data_position[..., ::-1] + 0.5 # (z, y, x), [0, N) + cell = np.clip(np.floor(index / BLOCK), 0, cells - 1).astype(int) + edge = np.abs(index / BLOCK - np.round(index / BLOCK)).min(axis=-1) * BLOCK + expected = level_of[cell[..., 0], cell[..., 1], cell[..., 2]] + return expected, reference.sure & (edge > 0.2) + + +@pytest.mark.parametrize("spec_name", ["aniso", "nonpow2"]) +@pytest.mark.parametrize("pose", ["xy", "oblique"]) +async def test_each_pixel_draws_the_level_the_plan_names( + controller, tmp_path, spec_name, pose +): + """C1: every level holds one grey, so a pixel names the level it drew.""" + spec = SPECS[spec_name] + rig = await make_rig( + controller, + tmp_path, + spec, + lambda world, centre: [h.plane_zyx(world, centre, POSES[pose])], + fill=lambda level: np.full(spec.level_shape(level), level, dtype=np.int64), + # Coarse enough, and seen at enough of a slant, that the plane's + # near and far parts take different levels at this frame size. + appearance={"settled_lod_bias": 4.0}, + ) + shot = await rig.settle(view_dir=(-1.0, -0.25, -0.5)) + reference = rig.trace(shot) + expected, sure = _expected_levels(rig, reference) + sure &= reference.hit + assert sure.sum() > 3000 + assert shot.drawn[sure].all() + # Level k (0-based) is drawn as grey (k + 1) / 10; the plan counts from 1. + drawn_level = np.round(shot.frame[..., 0] / 255.0 * (h.N_VALUES + 1)) + wrong = sure & (drawn_level != expected) + assert wrong.sum() == 0, (int(wrong.sum()), int(sure.sum())) + # The rule used more than one level, so the test is not vacuous. + assert len(np.unique(expected[sure])) > 1, np.unique(expected[sure]) + + +# -- the held plan, with a real plane ------------------------------------------- + + +def _record_plans(monkeypatch) -> list: + from cellier.render.scene_manager import SceneManager + + plans: list = [] + original = SceneManager.plan_chunked + + def spy(self, request, visual_configs): + plans.extend( + (visual_id, cfg.plan_mode.name, cfg.slicing_enabled) + for visual_id, cfg in visual_configs.items() + ) + return original(self, request, visual_configs) + + monkeypatch.setattr(SceneManager, "plan_chunked", spy) + return plans + + +@pytest.mark.parametrize("labels", [False, True]) +async def test_a_dragged_plane_draws_held_bricks_or_the_backstop_never_nothing( + controller, tmp_path, monkeypatch, labels +): + """C2 with a real plane: in a scope the extent grows, the plane moves out + of its bricks and a second plane is added. Nothing is planned or read, + and every pixel of the planes still draws: from a brick the last plan + held, or from the backstop.""" + controller._render_manager.config.scheduler.dims_settle_s = 10.0 + spec = ISO + # Every level one value (level k is k + 1), so a pixel names its level + # and no label plane has background holes. + rig = await make_rig( + controller, + tmp_path, + spec, + lambda world, centre: [ + h.plane_zyx( + world, + centre, + POSES["oblique"], + extent_0=(-12.0, 12.0), + extent_1=(-12.0, 12.0), + ) + ], + labels=labels, + fill=lambda level: np.full(spec.level_shape(level), level + 1, dtype=np.int64), + ) + await rig.settle() + start = rig.planes[0] + scheduler = controller._render_manager.scheduler + plans = _record_plans(monkeypatch) + world = h.world_of(controller, rig.scene) + + def drawn_levels(shot): + reference = rig.trace(shot) + sure = reference.sure & reference.hit + assert sure.sum() > 500 + # Never nothing. + assert shot.drawn[sure].all() + grey = shot.frame[..., 0].astype(float) + if labels: + levels = [h.srgb_encode(h.grey_of(np.array(k + 1))) for k in range(3)] + else: + levels = [h.grey_of(np.array(k + 1)) for k in range(3)] + index = np.abs(grey[..., None] - np.array(levels)).argmin(axis=-1) + return index[sure] + + with controller.plane_interaction(rig.visual.id): + grown = start.model_copy( + update={"extent_0": (None, None), "extent_1": (None, None)} + ) + rig.set_planes([grown]) + levels = drawn_levels(rig.shoot()) + # The part the last plan held is still fine; the rest is the backstop. + assert (levels == 0).any() and (levels == 2).any() + + moved = grown.model_copy( + update={ + "origin": tuple(np.array(grown.origin) + 20.0 * np.array(grown.normal)) + } + ) + second = h.plane_zyx(world, centre_of(spec), POSES["xy"]) + rig.set_planes([moved, second]) + levels = drawn_levels(rig.shoot()) + assert (levels == 2).any() + + assert plans == [] + assert scheduler.core.idle() + + # The release plans the target for the planes as they are now. + assert [(mode, sliced) for _vid, mode, sliced in plans] == [("FULL", True)] + await drain_loading(controller) + levels = drawn_levels(rig.shoot()) + assert (levels == 0).all() + + +# -- orthographic --------------------------------------------------------------- + + +async def test_an_orthographic_view_draws_a_plane_at_one_level(controller, tmp_path): + spec = ANISO + rig = await make_rig( + controller, + tmp_path, + spec, + lambda world, centre: [h.plane_zyx(world, centre, POSES["oblique"])], + fill=lambda level: np.full(spec.level_shape(level), level, dtype=np.int64), + appearance={"settled_lod_bias": 4.0}, + fov=0.0, + ) + shot = await rig.settle(view_dir=(-1.0, -0.25, -0.5)) + planned = rig.planned() + assert len(planned) + assert len(np.unique(planned[:, 0])) == 1 + reference = rig.trace(shot) + sure = reference.sure & reference.hit + assert sure.sum() > 3000 and shot.drawn[sure].all() + drawn_level = np.round(shot.frame[..., 0] / 255.0 * (h.N_VALUES + 1)) + assert set(np.unique(drawn_level[sure])) == {float(planned[0, 0])} + assert rig.gfx.slots[0]._last_plane_plan["level"] == planned[0, 0] + + +# -- force_level, and nothing to draw --------------------------------------------- + + +async def test_plane_mode_with_no_plane_plans_and_loads_nothing( + controller, tmp_path, monkeypatch +): + """P17: no backstop either; a plane that appears is planned at once.""" + spec = ISO + rig = await make_rig(controller, tmp_path, spec, lambda world, centre: []) + plans = _record_plans(monkeypatch) + shot = await rig.settle() + assert not shot.drawn.any() + assert plans and not any(sliced for _vid, _mode, sliced in plans) + registry = controller._render_manager.scheduler.core.registry( + rig.gfx.slots[0].residency_3d().cache_id + ) + assert not (registry.tier == Tier.VISIBLE).any() + + world = h.world_of(controller, rig.scene) + rig.set_planes([h.plane_zyx(world, centre_of(spec), POSES["oblique"])]) + await drain_loading(controller) + shot = rig.shoot() + counts = compare_level(rig, shot, rig.trace(shot), 0, level_values(spec, 0)) + assert_matches(counts) + + +async def test_switching_a_multiscale_visual_between_plane_and_a_volume_mode( + controller, tmp_path +): + spec = ISO + rig = await make_rig( + controller, + tmp_path, + spec, + lambda world, centre: [h.plane_zyx(world, centre, POSES["xy"])], + appearance={"force_level": 1}, + ) + shot = await rig.settle() + plane_pixels = int(shot.drawn.sum()) + plane_bricks = len(rig.planned()) + assert_matches(compare_level(rig, shot, rig.trace(shot), 0, level_values(spec, 0))) + + controller.update_single_appearance_field(rig.visual.id, "render_mode", "mip") + await drain_loading(controller) + volume = rig.shoot() + assert volume.drawn.sum() > 1.5 * plane_pixels + # The volume reads every brick of the level, the plane only its own. + assert len(rig.planned()) > 2 * plane_bricks + + controller.update_single_appearance_field(rig.visual.id, "render_mode", "plane") + await drain_loading(controller) + shot = rig.shoot() + assert len(rig.planned()) == plane_bricks + assert_matches(compare_level(rig, shot, rig.trace(shot), 0, level_values(spec, 0))) + + +# -- pick ------------------------------------------------------------------------ + + +@pytest.mark.parametrize("labels", [False, True]) +async def test_a_pick_on_a_multiscale_plane_names_the_voxel_at_the_hit( + controller, tmp_path, labels +): + spec = ANISO + rig = await make_rig( + controller, + tmp_path, + spec, + lambda world, centre: [h.plane_zyx(world, centre, POSES["oblique"])], + labels=labels, + appearance={"force_level": 1}, + ) + shot = await rig.settle() + values = level_values(spec, 0) + reference = rig.trace(shot, background=(values == 0) if labels else None) + rows, cols = np.nonzero(reference.sure & reference.hit) + chosen = np.linspace(0, len(rows) - 1, 150).astype(int) + wrong, worst = [], 0.0 + for row, col in zip(rows[chosen], cols[chosen], strict=True): + voxel = h.pick_voxel(shot, rig.gfx, int(row), int(col)) + expected = tuple(int(v) for v in reference.voxel[row, col]) + if voxel != expected: + wrong.append((int(row), int(col), voxel, expected)) + position = h.pick_coordinate(shot, rig.gfx, int(row), int(col)) + worst = max(worst, np.abs(position - reference.data_position[row, col]).max()) + assert not wrong, wrong[:5] + assert worst < 0.125 + max(spec.shape0) / 16383, worst + + +async def test_a_plane_just_past_a_cell_face_is_drawn_from_the_brick_that_owns_it( + controller, tmp_path +): + """The cull keeps the brick the lookup table names, not the brick whose + own box holds the plane. + + With levels placed at no translation a level-1 brick's box ends half a + level-0 voxel short of the cells it owns. A plane in that half voxel is + inside the next brick's box, but the shader draws it from the brick + that owns the cell (reading the next brick's first voxels from its + padding). Were the owner not planned, the plane would fall to the + backstop. + """ + spec = UNALIGNED + # Level-1 bricks along z: boxes [-1, 31), [31, 63); cells [-0.5, 31.5), ... + z = 31.25 + rig = await make_rig( + controller, + tmp_path, + spec, + lambda world, centre: [ + h.plane_zyx(world, (z, centre[1], centre[2]), POSES["xy"]) + ], + appearance={"force_level": 2}, + ) + shot = await rig.settle() + planned = rig.planned() + assert len(planned) and set(planned[:, 1]) == {0} + counts = compare_level(rig, shot, rig.trace(shot), 1, level_values(spec, 1)) + assert_matches(counts) + + +async def test_the_part_of_a_plane_the_cache_has_no_room_for_draws_the_backstop( + controller, tmp_path +): + """Truncation as shipped (D-P10): the near part of the plane is fine, + the part dropped from the plan draws the backstop, and nothing is blank.""" + spec = ISO + rig = await make_rig( + controller, + tmp_path, + spec, + lambda world, centre: [h.plane_zyx(world, centre, POSES["oblique"])], + fill=lambda level: np.full(spec.level_shape(level), level, dtype=np.int64), + appearance={"force_level": 1}, + # Room for the backstop and about half of the plane's bricks. + budget=2 * 1024**2, + ) + shot = await rig.settle() + progress = controller._render_manager.scheduler.progress( + rig.gfx.slots[0].residency_3d().cache_id + ) + assert progress.truncated_target > 0 and progress.truncated_backstop == 0 + reference = rig.trace(shot) + sure = reference.sure & reference.hit + assert sure.sum() > 3000 and shot.drawn[sure].all() + drawn_level = np.round(shot.frame[..., 0] / 255.0 * (h.N_VALUES + 1)) + levels, counts = np.unique(drawn_level[sure], return_counts=True) + # Level 1 where a brick was kept, the coarsest (3) where it was dropped. + assert set(levels) == {1.0, 3.0}, dict(zip(levels, counts, strict=True)) + expected, clear = _expected_levels(rig, reference) + assert not (sure & clear & (drawn_level != expected)).any() + + +async def test_switching_multiscale_labels_between_plane_and_a_volume_mode( + controller, tmp_path +): + spec = ISO + rig = await make_rig( + controller, + tmp_path, + spec, + lambda world, centre: [h.plane_zyx(world, centre, POSES["xy"])], + labels=True, + appearance={"force_level": 1}, + ) + labels = level_values(spec, 0) + + def check(shot) -> None: + reference = rig.trace(shot, background=labels == 0) + counts = h.compare_greys(shot, reference, labels, linear_colours=True) + assert_matches(counts, least=1500) + + shot = await rig.settle() + check(shot) + plane_pixels = int(shot.drawn.sum()) + plane_bricks = len(rig.planned()) + + controller.update_appearance_field(rig.visual.id, "render_mode", "flat_categorical") + await drain_loading(controller) + assert rig.shoot().drawn.sum() > 1.5 * plane_pixels + assert len(rig.planned()) > 2 * plane_bricks + + controller.update_appearance_field(rig.visual.id, "render_mode", "plane") + await drain_loading(controller) + assert len(rig.planned()) == plane_bricks + check(rig.shoot()) + + +async def test_a_2d_view_plans_its_slice_and_ignores_the_mode(controller, tmp_path): + """Design 4.4: in a 2D view a visual in plane mode shows the normal slice.""" + spec = ISO + rig = await make_rig( + controller, + tmp_path, + spec, + lambda world, centre: [h.plane_zyx(world, centre, POSES["oblique"])], + ) + await rig.settle() + assert len(rig.gfx.drawn_render_planes) == 1 + controller.set_displayed_axes(rig.scene.id, (1, 2)) + await drain_loading(controller) + assert len(rig.gfx.drawn_render_planes) == 0 + residency = rig.gfx.slots[0].residency_2d() + registry = controller._render_manager.scheduler.core.registry(residency.cache_id) + # Tiles of the slice are wanted, as for any 2D image. + assert (registry.tier == Tier.VISIBLE).any() + controller.set_displayed_axes(rig.scene.id, (0, 1, 2)) + await drain_loading(controller) + assert len(rig.gfx.drawn_render_planes) == 1 + assert len(rig.planned()) diff --git a/tests/render/test_render_planes_controller.py b/tests/render/test_render_planes_controller.py index 2f19daa4..f2c4e3ef 100644 --- a/tests/render/test_render_planes_controller.py +++ b/tests/render/test_render_planes_controller.py @@ -1,7 +1,8 @@ """Render planes on the controller: checks, API, events, planning. -Plane rendering design v3, sections 4, 7.4 and 8 (Phase 5: nothing is drawn -on a plane yet; a visual in plane mode draws its family's default volume). +Plane rendering design v3, sections 4, 7.4 and 8. What a plane draws is +tested in ``test_plane_rendering_memory.py`` and +``test_plane_rendering_multiscale.py``. """ from __future__ import annotations @@ -831,8 +832,10 @@ def test_the_gui_combos_do_not_offer_plane_yet(controller, image_volume, labels_ async def test_a_plane_mode_visual_builds_and_draws( controller, render_scene, image_volume, labels_volume, kind ): - """Until the plane shaders arrive the visual draws as a volume, and no - shader is built for a mode it has no branch for.""" + """A visual added in plane mode with a plane draws it on its first frame. + + The pixels are checked in ``test_plane_rendering_memory.py``. + """ scene = controller.add_scene(dim="3d", name="scene") world = _world(controller, scene) if kind == "image": @@ -853,5 +856,5 @@ async def test_a_plane_mode_visual_builds_and_draws( ) controller.add_canvas(scene_id=scene.id) await _loaded(controller, scene) - frame = render_scene(controller, scene.id) - assert np.isfinite(np.asarray(frame)).all() + frame = np.asarray(render_scene(controller, scene.id)) + assert (frame[..., 3] > 0).any() From 6e1c402b447256135bc22c4024a618724d462ab5 Mon Sep 17 00:00:00 2001 From: Kevin Yamauchi Date: Wed, 7 Oct 2026 13:36:12 +0200 Subject: [PATCH 07/24] gizmo and ui --- docs/Explanations/events.md | 43 +- docs/How_To/clipping_planes.md | 9 +- docs/api/gui.md | 10 + src/cellier/clipping/__init__.py | 10 +- src/cellier/clipping/_gizmo_controller.py | 472 +++++++-- src/cellier/controller.py | 396 +++++++- src/cellier/convenience/_ortho_planes.py | 465 +++++++++ src/cellier/convenience/_ortho_viewer.py | 175 +++- src/cellier/convenience/_viewer.py | 108 +- .../convenience/gui/_appearance_widgets.py | 23 + .../convenience/gui/_appearance_widgets_qt.py | 23 + .../convenience/gui/_controls_config.py | 14 + src/cellier/convenience/layout/_shared.py | 44 +- src/cellier/convenience/layout/_walk.py | 23 +- src/cellier/events/__init__.py | 8 +- src/cellier/events/_bus.py | 6 +- src/cellier/events/_events.py | 36 +- src/cellier/events/_update_events.py | 15 +- src/cellier/gui/__init__.py | 11 +- src/cellier/gui/_appearance_fields.py | 20 +- src/cellier/gui/_clipping_planes.py | 29 +- src/cellier/gui/_render_planes.py | 898 ++++++++++++++++ src/cellier/gui/anywidget/visuals/__init__.py | 4 + .../gui/anywidget/visuals/_render_planes.py | 249 +++++ .../visuals/static/render_planes.css | 158 +++ .../anywidget/visuals/static/render_planes.js | 334 ++++++ src/cellier/gui/qt/visuals/__init__.py | 2 + src/cellier/gui/qt/visuals/_render_planes.py | 455 +++++++++ src/cellier/render/_render_planes.py | 91 ++ src/cellier/render/canvas_view.py | 4 + src/cellier/render/render_manager.py | 120 ++- src/cellier/render/visuals/_plane_gizmo.py | 112 +- tests/convenience/test_appearance_specs.py | 2 +- .../test_clipping_planes_viewers.py | 10 +- tests/convenience/test_non_image_controls.py | 5 +- .../test_ortho_clipping_controls.py | 4 +- .../test_plane_rendering_examples.py | 443 ++++++++ .../convenience/test_render_planes_viewers.py | 680 +++++++++++++ tests/gui/test_appearance_control_types.py | 9 +- tests/gui/test_clipping_planes_controls.py | 18 +- tests/gui/test_image_controls.py | 2 +- tests/gui/test_render_planes_controls.py | 952 +++++++++++++++++ .../test_clipping_plane_gizmo_controller.py | 35 +- tests/render/test_render_plane_gizmo.py | 956 ++++++++++++++++++ tests/render/test_render_planes_controller.py | 14 +- tests/test_plane_rendering_renames.py | 30 + zensical.toml | 1 + 47 files changed, 7264 insertions(+), 264 deletions(-) create mode 100644 src/cellier/convenience/_ortho_planes.py create mode 100644 src/cellier/gui/_render_planes.py create mode 100644 src/cellier/gui/anywidget/visuals/_render_planes.py create mode 100644 src/cellier/gui/anywidget/visuals/static/render_planes.css create mode 100644 src/cellier/gui/anywidget/visuals/static/render_planes.js create mode 100644 src/cellier/gui/qt/visuals/_render_planes.py create mode 100644 tests/convenience/test_plane_rendering_examples.py create mode 100644 tests/convenience/test_render_planes_viewers.py create mode 100644 tests/gui/test_render_planes_controls.py create mode 100644 tests/render/test_render_plane_gizmo.py diff --git a/docs/Explanations/events.md b/docs/Explanations/events.md index aa946e39..1c3f9df9 100644 --- a/docs/Explanations/events.md +++ b/docs/Explanations/events.md @@ -724,13 +724,14 @@ With no event loop there is no stillness timer, so every change inside a scope is a drag of its own: one `start` and one `end` (`"settle"`). ```python -class ClippingPlaneGizmoChangedEvent(NamedTuple): - """The plane a canvas's clipping plane gizmo edits changed. +class PlaneGizmoChangedEvent(NamedTuple): + """The plane a canvas's plane gizmo edits changed. - A canvas has at most one clipping plane gizmo. Emitted when one is - added, when it is replaced by one on another plane, and when it is - closed, by its owner or by itself (its plane or visual was removed, the - canvas left 3D). + A canvas has at most one plane gizmo, on a clipping plane or on a + render plane. Emitted when one is added, when it is replaced by one on + another plane, and when it is closed, by its owner or by itself (its + plane or visual was removed, the canvas left 3D, a render plane's + visual left plane mode). Parameters ---------- @@ -742,6 +743,9 @@ class ClippingPlaneGizmoChangedEvent(NamedTuple): visual_id : UUID or None The visual whose plane the gizmo edits; ``None`` when the canvas has no gizmo. + kind : {"clipping", "render"} or None + Which of the visual's tuples the plane is in; ``None`` when the + canvas has no gizmo. plane_id : UUID or None That plane's id; ``None`` when the canvas has no gizmo. """ @@ -789,20 +793,22 @@ class ClippingPlanesUpdateEvent(NamedTuple): clipping_planes: Any -class ClippingPlaneGizmoUpdateEvent(NamedTuple): - """Request to put a canvas's clipping plane gizmo on a plane, or close it. +class PlaneGizmoUpdateEvent(NamedTuple): + """Request to put a canvas's plane gizmo on a plane, or close it. Fields ------ source_id : Caller's UUID. Stamped on the outgoing - ``ClippingPlaneGizmoChangedEvent``. + ``PlaneGizmoChangedEvent``. visual_id : The visual the plane belongs to. plane_id : - The ``id`` of the ``ClippingPlane``. + The ``id`` of the ``ClippingPlane`` or ``RenderPlane``. canvas_id : The 3D canvas to draw the gizmo in. + kind : + ``"clipping"`` or ``"render"``: which tuple the plane is in. enabled : ``True`` opens a gizmo on the plane, replacing the canvas's current one. ``False`` closes the canvas's gizmo if it is on this plane. @@ -1289,7 +1295,7 @@ CellierEventTypes = ( | ClippingPlanesChangedEvent | RenderPlanesChangedEvent | PlaneInteractionEvent - | ClippingPlaneGizmoChangedEvent + | PlaneGizmoChangedEvent | ResliceCancelledEvent | FrameRenderedEvent | VisualAddedEvent @@ -1469,7 +1475,7 @@ _ENTITY_FIELD: dict[type, str] = { ClippingPlanesChangedEvent: "visual_id", RenderPlanesChangedEvent: "visual_id", PlaneInteractionEvent: "visual_id", - ClippingPlaneGizmoChangedEvent: "canvas_id", + PlaneGizmoChangedEvent: "canvas_id", ResliceCancelledEvent: "visual_id", FrameRenderedEvent: "canvas_id", VisualAddedEvent: "scene_id", @@ -2063,9 +2069,12 @@ object registration time. | `OrthoDimsController` | `DimsInteractionEvent` | `scene_id` (each panel) | Open a dims interaction scope on the other panels while one is scrubbed | | Controller | `ClippingPlanesUpdateEvent` (incoming) | *(none)* | Call `set_clipping_planes` with the request's `source_id` | | Controller | `RenderPlanesUpdateEvent` (incoming) | *(none)* | Call `set_render_planes` with the request's `source_id` | -| Controller | `ClippingPlaneGizmoUpdateEvent` (incoming) | *(none)* | Open the canvas's gizmo on the plane, or close it | +| Controller | `PlaneGizmoUpdateEvent` (incoming) | *(none)* | Open the canvas's gizmo on the plane, or close it | | `ClippingPlaneGizmoController` | `ClippingPlanesChangedEvent` | `visual_id` | Move the gizmo to its plane's new pose; close when the plane is gone or can no longer have a gizmo | | `ClippingPlaneGizmoController` | `VisualRemovedEvent` | `scene_id` | Close the gizmo when its visual is removed | +| `RenderPlaneGizmoController` | `PlaneGizmoMovedEvent` | `gizmo_id` | Assign the fields the held handle changes: `origin` (translate), the in-plane axes (rotate), one extent (scale); one plane interaction scope per drag | +| `RenderPlaneGizmoController` | `RenderPlanesChangedEvent` | `visual_id` | Move the gizmo to its plane's new frame and show a scale handle on each bounded axis; close when the plane is gone | +| `RenderPlaneGizmoController` | `DimsChangedEvent`, `VisualRemovedEvent` | `scene_id` | Close when the view leaves 3D or stops displaying the plane's axes, or the visual is removed (the controller closes it when the visual leaves plane mode) | | `OrthoClippingController` | `ClippingPlanesChangedEvent` | `visual_id` (each panel visual) | Write the tuple to the panel visuals linked to it whose tuple differs (`set_clipping_planes`, same `source_id`) | | `OrthoClippingController` | `PlaneInteractionEvent` | `visual_id` (each panel visual) | Open a plane interaction scope on the linked panel visuals while one is dragged | | `OrthoClippingController` | `VisualRemovedEvent` | `visual_id` (each panel visual) | Close the scopes forwarded from the visual, drop its subscriptions, take it out of its group | @@ -2088,11 +2097,11 @@ object registration time. | Level-of-detail control | `LodConfigChangedEvent` | `visual_id` | Show a multiscale mesh's `GeometryLodConfig` (`QtLodConfigControls`, `AnywidgetLodConfigControls`); edits go out as `LodConfigUpdateEvent` on the incoming bus, and a refused edit shows the reason | | External callback | `LodConfigChangedEvent` | `visual_id` | Fire `on_lod_config_changed` user callback | | Clipping planes control | `ClippingPlanesChangedEvent` | `visual_id` (each visual it edits) | Show the planes (`QtClippingPlanesControls`, `AnywidgetClippingPlanesControls`); edits go out as one `ClippingPlanesUpdateEvent` per visual on the incoming bus | -| Clipping planes control | `ClippingPlaneGizmoChangedEvent` | `canvas_id` | With a gizmo target: show which plane has the canvas's gizmo; the toggle goes out as `ClippingPlaneGizmoUpdateEvent` | +| Clipping planes control | `PlaneGizmoChangedEvent` | `canvas_id` | With a gizmo target: show which plane has the canvas's gizmo; the toggle goes out as `PlaneGizmoUpdateEvent` | | External callback | `ClippingPlanesChangedEvent` | `visual_id` | Fire `on_clipping_planes_changed` user callback | | External callback | `RenderPlanesChangedEvent` | `visual_id` | Fire `on_render_planes_changed` user callback | | External callback | `PlaneInteractionEvent` | `visual_id` | Fire `on_plane_interaction` user callback | -| External callback | `ClippingPlaneGizmoChangedEvent` | `canvas_id` | Fire `on_clipping_plane_gizmo_changed` user callback | +| External callback | `PlaneGizmoChangedEvent` | `canvas_id` | Fire `on_plane_gizmo_changed` user callback | | External callback | `CanvasMouse{Press,Move,Release}{2D,3D}Event` | `canvas_id` | Fire `on_mouse_*` user callback | | External callback | `{Image,Labels,Points,Lines,Mesh,Graph}PickEvent` | `canvas_id` | Fire `on_pick` user callback; enables pick-detail extraction | | Paint controller | `CanvasMouse{Press,Move,Release}2DEvent` | `canvas_id` | Accumulate brush stroke (direct bus subscription; does not enable pick details) | @@ -2267,7 +2276,7 @@ from cellier.events._events import ( CellierEventTypes, ChannelAppearanceChangedEvent, PlaneInteractionEvent, - ClippingPlaneGizmoChangedEvent, + PlaneGizmoChangedEvent, ClippingPlanesChangedEvent, RenderPlanesChangedEvent, DataStoreContentsChangedEvent, @@ -2304,7 +2313,7 @@ from cellier.events._update_events import ( AABBUpdateEvent, AppearanceUpdateEvent, CellierUpdateEventTypes, - ClippingPlaneGizmoUpdateEvent, + PlaneGizmoUpdateEvent, ClippingPlanesUpdateEvent, RenderPlanesUpdateEvent, DimsInteractionUpdateEvent, diff --git a/docs/How_To/clipping_planes.md b/docs/How_To/clipping_planes.md index bce59768..020400e5 100644 --- a/docs/How_To/clipping_planes.md +++ b/docs/How_To/clipping_planes.md @@ -5,6 +5,9 @@ type takes them: images and labels (in memory or multiscale), meshes, points, lines and graphs. Each visual has its own planes; there are no scene-wide ones. +To draw a volume's data *on* a plane instead of cutting the volume with one, +see [Draw a volume on planes](render_planes.md). + ## The short version ```python @@ -183,7 +186,7 @@ A gizmo in a 3D view moves and tilts one plane by dragging: ```python gizmo = viewer.add_clipping_plane_gizmo(visual, visual.clipping_planes[0]) ... -gizmo.close() # or viewer.remove_clipping_plane_gizmo() +gizmo.close() # or viewer.remove_plane_gizmo() ``` On an `OrthoViewer` the gizmo is drawn in the 3D panel and edits the 3D @@ -249,8 +252,8 @@ A drag is announced by `PlaneInteractionEvent` (`controller. on_plane_interaction`): one start and one end, with the plane changes between them. A script can group its own changes the same way with `with controller.plane_interaction(visual.id): ...`. The plane a view's -gizmo is on is announced by `ClippingPlaneGizmoChangedEvent` -(`controller.on_clipping_plane_gizmo_changed`). +gizmo is on is announced by `PlaneGizmoChangedEvent` +(`controller.on_plane_gizmo_changed`). A multiscale image or labels visual in a 3D view plans nothing during a drag: it draws the bricks it already has, and its coarse backstop where the diff --git a/docs/api/gui.md b/docs/api/gui.md index 7483c7f4..776baf3a 100644 --- a/docs/api/gui.md +++ b/docs/api/gui.md @@ -29,3 +29,13 @@ from. See [Clip a visual with planes](../How_To/clipping_planes.md). ::: cellier.gui.get_axis_bounds_from_store ::: cellier.gui.ClippingPlaneGizmoTarget ::: cellier.gui.get_clipping_plane_gizmo_data + +## Render planes control + +The planes a visual in the `"plane"` render mode draws its data on: the +clipping planes rows with the extents added, in world units. + +::: cellier.gui.qt.visuals.QtRenderPlanesControls +::: cellier.gui.get_render_planes_data_from_visual +::: cellier.gui.RenderPlaneGizmoTarget +::: cellier.gui.get_render_plane_gizmo_data diff --git a/src/cellier/clipping/__init__.py b/src/cellier/clipping/__init__.py index f0683b41..b139e9bc 100644 --- a/src/cellier/clipping/__init__.py +++ b/src/cellier/clipping/__init__.py @@ -1,13 +1,21 @@ -"""Interactive editing of clipping planes.""" +"""Interactive editing of clipping planes and render planes with a gizmo.""" from cellier.clipping._gizmo_controller import ( ClippingPlaneGizmoController, + PlaneGizmoController, + RenderPlaneGizmoController, handle_changes_plane, initial_anchor, + scale_handle_axes, + scaled_extent, ) __all__ = [ "ClippingPlaneGizmoController", + "PlaneGizmoController", + "RenderPlaneGizmoController", "handle_changes_plane", "initial_anchor", + "scale_handle_axes", + "scaled_extent", ] diff --git a/src/cellier/clipping/_gizmo_controller.py b/src/cellier/clipping/_gizmo_controller.py index 8c9c84d5..50019840 100644 --- a/src/cellier/clipping/_gizmo_controller.py +++ b/src/cellier/clipping/_gizmo_controller.py @@ -1,15 +1,22 @@ -"""Drag a gizmo in a 3D canvas to move one clipping plane of one visual. +"""Drag a gizmo in a 3D canvas to move one plane of one visual. The canvas draws the gizmo and reports its pose in rendered space -(``PlaneGizmoMovedEvent``). ``ClippingPlaneGizmoController`` turns a pose -into the visual's data coordinates and assigns the plane, and moves the -gizmo when the plane is changed from anywhere else (clipping plane gizmo -design v2, 5.2 to 5.5). +(``PlaneGizmoMovedEvent``). A session turns a pose into the plane's own +coordinates and assigns it, and moves the gizmo when the plane is changed +from anywhere else. ``PlaneGizmoController`` is the session; its two +kinds differ only in the plane they edit: + +- ``ClippingPlaneGizmoController``: a ``ClippingPlane``, in the visual's + data coordinates (clipping plane gizmo design v2, 5.2 to 5.5). +- ``RenderPlaneGizmoController``: a ``RenderPlane``, in world coordinates, + with its whole frame and its extents (plane rendering design v3, 8.3). + +A canvas has one gizmo, of either kind. """ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, ClassVar, Literal from uuid import UUID, uuid4 import numpy as np @@ -18,6 +25,7 @@ ClippingPlanesChangedEvent, DimsChangedEvent, PlaneGizmoMovedEvent, + RenderPlanesChangedEvent, TransformChangedEvent, VisualRemovedEvent, ) @@ -104,26 +112,18 @@ def initial_anchor( return _closest_on_plane(np.zeros(3), normal, offset) -class ClippingPlaneGizmoController: - """One gizmo on one clipping plane of one visual, in one 3D canvas. +class PlaneGizmoController: + """One gizmo on one plane of one visual, in one 3D canvas. - Build one with ``CellierController.add_clipping_plane_gizmo``; end it - with :meth:`close`. A canvas holds one at a time. - - - Dragging the arrow along the normal, or a rotation ring that tilts - the normal, assigns the plane, once per frame. Sliding the gizmo - within the plane or spinning it about the normal moves the gizmo - only. - - A plane changed from anywhere else (a slider, an assignment) moves - the gizmo. - - A drag is one clipping interaction on the visual - (``PlaneInteractionEvent``). - - It closes itself when its plane or its visual is removed, when the - canvas leaves 3D, and when the plane gains a component on an axis the - view does not show. + The part of a session that does not depend on the kind of plane: the + drag's interaction scope, the subscriptions and closing. Build one + with ``CellierController.add_clipping_plane_gizmo`` or + ``add_render_plane_gizmo``; end it with :meth:`close`. A canvas holds + one at a time. - The camera controller is left alone: the gizmo takes only the drags - that start on a handle. + A drag is one plane interaction on the visual + (``PlaneInteractionEvent``). The camera controller is left alone: the + gizmo takes only the drags that start on a handle. Parameters ---------- @@ -134,10 +134,10 @@ class ClippingPlaneGizmoController: canvas_id : UUID A 3D canvas of the visual's scene. plane_id : UUID - The ``id`` of the ``ClippingPlane`` to edit. + The ``id`` of the plane to edit. screen_size : float The gizmo's size on screen, in logical pixels. - on_closed : Callable[[ClippingPlaneGizmoController], None] or None + on_closed : Callable[[PlaneGizmoController], None] or None Called once, when the session has closed. Raises @@ -147,9 +147,12 @@ class ClippingPlaneGizmoController: plane with that id. ValueError If the canvas is in 2D, the visual is not in the canvas's scene, or - the plane has a component on an axis the view does not show. + the plane cannot have a gizmo in this view. """ + #: Which of the visual's tuples the plane is in. + kind: ClassVar[Literal["clipping", "render"]] + def __init__( self, cellier_controller: CellierController, @@ -158,7 +161,7 @@ def __init__( plane_id: UUID, *, screen_size: float = 100.0, - on_closed: Callable[[ClippingPlaneGizmoController], None] | None = None, + on_closed: Callable[[Any], None] | None = None, ) -> None: self._id: UUID = uuid4() self._controller = cellier_controller @@ -168,7 +171,6 @@ def __init__( self._on_closed = on_closed self._closed = False self._dragging = False - self._changes_plane = False scene_id = cellier_controller._canvas_to_scene[canvas_id] if cellier_controller._visual_to_scene[visual_id] != scene_id: @@ -176,36 +178,19 @@ def __init__( f"Visual {visual_id} is not in the scene canvas {canvas_id} shows." ) self._scene_id = scene_id - render_manager = cellier_controller._render_manager - if render_manager.canvas_dim(canvas_id) != "3d": + if cellier_controller._render_manager.canvas_dim(canvas_id) != "3d": raise ValueError( - "A clipping plane gizmo needs a 3D canvas; canvas " + f"A {self.kind} plane gizmo needs a 3D canvas; canvas " f"{canvas_id} is in 2D." ) - item = cellier_controller.get_clipping_plane(visual_id, self._plane_id) - self._refuse_hidden_component(item) - - normal, offset = self._rendered_plane(item) - # The gizmo's state the model does not hold: where on the plane it - # sits. (Its spin about the normal is the render gizmo's.) - self._anchor = initial_anchor( - normal, - offset, - render_manager.canvas_orbit_point(canvas_id), - render_manager.visual_rendered_bounds(visual_id), - ) - self._plane = item.plane - render_manager.add_plane_gizmo( - canvas_id, self._id, self._anchor, normal, screen_size=screen_size - ) + self._open(screen_size) bus = cellier_controller._outgoing_events for event_type, handler, entity_id in ( (PlaneGizmoMovedEvent, self._on_gizmo, self._id), - (ClippingPlanesChangedEvent, self._on_planes_changed, visual_id), - (TransformChangedEvent, self._on_placement_changed, visual_id), (DimsChangedEvent, self._on_placement_changed, scene_id), (VisualRemovedEvent, self._on_visual_removed, scene_id), + *self._subscriptions(), ): bus.subscribe(event_type, handler, entity_id=entity_id, owner_id=self._id) @@ -241,11 +226,6 @@ def dragging(self) -> bool: """Whether a handle is held.""" return self._dragging - @property - def anchor(self) -> tuple[float, float, float]: - """Where the gizmo sits on the plane, in rendered ``(x, y, z)``.""" - return tuple(float(v) for v in self._anchor) - def close(self) -> None: """End the session: remove the gizmo and end a drag in progress. @@ -261,6 +241,30 @@ def close(self) -> None: if self._on_closed is not None: self._on_closed(self) + # -- for the kinds ------------------------------------------------------- + + def _open(self, screen_size: float) -> None: + """Check the plane and draw the gizmo on it.""" + raise NotImplementedError + + def _subscriptions(self) -> list[tuple[Any, Any, UUID]]: + """``(event type, handler, entity id)`` this kind also follows.""" + return [] + + def _grabbed(self, event: PlaneGizmoMovedEvent) -> None: + """A handle was grabbed.""" + + def _moved(self, event: PlaneGizmoMovedEvent) -> None: + """The gizmo moved: assign the plane it describes.""" + raise NotImplementedError + + def _released(self, event: PlaneGizmoMovedEvent) -> None: + """The handle was released (after the last move).""" + + def _on_placement_changed(self, _event: Any) -> None: + """The view changed: the plane may have moved on screen.""" + raise NotImplementedError + # -- the gizmo -> the model ---------------------------------------------- def _on_gizmo(self, event: PlaneGizmoMovedEvent) -> None: @@ -268,19 +272,112 @@ def _on_gizmo(self, event: PlaneGizmoMovedEvent) -> None: # A release that never came leaves a scope open: end it first. self._end_scope() self._dragging = True - self._changes_plane = handle_changes_plane( - event.handle_kind, event.handle_axis - ) + self._grabbed(event) self._controller.begin_plane_interaction( self._visual_id, source_id=self._id ) elif event.phase == "move": - self._anchor = np.asarray(event.point, dtype=np.float64) - if self._changes_plane: - self._assign(event.point, event.normal) + self._moved(event) elif event.phase == "end": + self._released(event) self._end_scope() + def _end_scope(self) -> None: + self._dragging = False + self._controller.end_plane_interaction(self._visual_id, source_id=self._id) + + def _on_visual_removed(self, event: VisualRemovedEvent) -> None: + if event.visual_id == self._visual_id: + self.close() + + +class ClippingPlaneGizmoController(PlaneGizmoController): + """One gizmo on one clipping plane of one visual, in one 3D canvas. + + Build one with ``CellierController.add_clipping_plane_gizmo``; end it + with :meth:`close`. A canvas holds one gizmo at a time. + + - Dragging the arrow along the normal, or a rotation ring that tilts + the normal, assigns the plane, once per frame. Sliding the gizmo + within the plane or spinning it about the normal moves the gizmo + only. + - A plane changed from anywhere else (a slider, an assignment) moves + the gizmo. + - A drag is one plane interaction on the visual + (``PlaneInteractionEvent``). + - It closes itself when its plane or its visual is removed, when the + canvas leaves 3D, and when the plane gains a component on an axis the + view does not show. + + Parameters + ---------- + cellier_controller : CellierController + The controller the visual and the canvas belong to. + visual_id : UUID + The visual whose plane is edited. + canvas_id : UUID + A 3D canvas of the visual's scene. + plane_id : UUID + The ``id`` of the ``ClippingPlane`` to edit. + screen_size : float + The gizmo's size on screen, in logical pixels. + on_closed : Callable[[ClippingPlaneGizmoController], None] or None + Called once, when the session has closed. + + Raises + ------ + KeyError + If the canvas or the visual is not registered, or the visual has no + plane with that id. + ValueError + If the canvas is in 2D, the visual is not in the canvas's scene, or + the plane has a component on an axis the view does not show. + """ + + kind = "clipping" + + def _open(self, screen_size: float) -> None: + controller = self._controller + render_manager = controller._render_manager + self._changes_plane = False + item = controller.get_clipping_plane(self._visual_id, self._plane_id) + self._refuse_hidden_component(item) + + normal, offset = self._rendered_plane(item) + # The gizmo's state the model does not hold: where on the plane it + # sits. (Its spin about the normal is the render gizmo's.) + self._anchor = initial_anchor( + normal, + offset, + render_manager.canvas_orbit_point(self._canvas_id), + render_manager.visual_rendered_bounds(self._visual_id), + ) + self._plane = item.plane + render_manager.add_plane_gizmo( + self._canvas_id, self._id, self._anchor, normal, screen_size=screen_size + ) + + def _subscriptions(self) -> list[tuple[Any, Any, UUID]]: + return [ + (ClippingPlanesChangedEvent, self._on_planes_changed, self._visual_id), + (TransformChangedEvent, self._on_placement_changed, self._visual_id), + ] + + @property + def anchor(self) -> tuple[float, float, float]: + """Where the gizmo sits on the plane, in rendered ``(x, y, z)``.""" + return tuple(float(v) for v in self._anchor) + + # -- the gizmo -> the model ---------------------------------------------- + + def _grabbed(self, event: PlaneGizmoMovedEvent) -> None: + self._changes_plane = handle_changes_plane(event.handle_kind, event.handle_axis) + + def _moved(self, event: PlaneGizmoMovedEvent) -> None: + self._anchor = np.asarray(event.point, dtype=np.float64) + if self._changes_plane: + self._assign(event.point, event.normal) + def _assign(self, point: Any, normal: Any) -> None: from cellier.transform import Plane @@ -298,10 +395,6 @@ def _assign(self, point: Any, normal: Any) -> None: self._visual_id, self._plane_id, plane, source_id=self._id ) - def _end_scope(self) -> None: - self._dragging = False - self._controller.end_plane_interaction(self._visual_id, source_id=self._id) - # -- the model -> the gizmo ---------------------------------------------- def _on_planes_changed(self, event: ClippingPlanesChangedEvent) -> None: @@ -325,10 +418,6 @@ def _on_placement_changed(self, _event: Any) -> None: return self._follow(item) - def _on_visual_removed(self, event: VisualRemovedEvent) -> None: - if event.visual_id == self._visual_id: - self.close() - def _follow(self, item: Any) -> None: """Put the gizmo on *item*, at the point nearest where it was.""" render_manager = self._controller._render_manager @@ -364,3 +453,248 @@ def _refuse_hidden_component(self, item: Any) -> None: f"axes {hidden}, which the 3D view does not show; a gizmo " "cannot move it." ) + + +#: A scale drag that would shrink an extent to under this factor is not +#: assigned: a bounded pair needs ``min < max``. +_MIN_SCALE_FACTOR = 1e-6 + + +def scale_handle_axes(plane: Any) -> tuple[int, ...]: + """The gizmo axes of a render plane that get a scale handle. + + Gizmo axis 1 is ``in_plane_axis_0`` and axis 2 is ``in_plane_axis_1``. + An axis has a handle when both sides of its extent are finite; with an + unbounded side there is no size to scale (plane rendering design v3, + 8.3). + + Parameters + ---------- + plane : RenderPlane + The plane the gizmo is on. + + Returns + ------- + tuple[int, ...] + A subset of ``(1, 2)``. + """ + return tuple( + axis + for axis, extent in ((1, plane.extent_0), (2, plane.extent_1)) + if extent[0] is not None and extent[1] is not None + ) + + +def scaled_extent( + extent: tuple[float | None, float | None], factor: float +) -> tuple[float | None, float | None]: + """An extent multiplied by a scale handle's factor, about the origin. + + Both sides are multiplied, so the rectangle grows and shrinks about the + plane's origin. An extent with an unbounded side, and a factor too + small to leave ``min < max``, are returned unchanged. + """ + low, high = extent + if low is None or high is None or not factor >= _MIN_SCALE_FACTOR: + return extent + return (float(low) * factor, float(high) * factor) + + +class RenderPlaneGizmoController(PlaneGizmoController): + """One gizmo on one render plane of one visual, in one 3D canvas. + + Build one with ``CellierController.add_render_plane_gizmo``; end it + with :meth:`close`. A canvas holds one gizmo at a time. + + The gizmo carries the plane's whole pose: it sits at the plane's + ``origin``, its axis 0 is the normal and its axes 1 and 2 are + ``in_plane_axis_0`` and ``in_plane_axis_1``. Every handle changes the + model, once per frame: + + - a translate handle moves ``origin``, along the normal or within the + plane; + - a rotation ring turns the in-plane axes about ``origin``; + - a scale handle on an in-plane axis multiplies that axis's extent, + symmetrically about ``origin``. On release the factor stays in the + extent and the gizmo's scale goes back to 1. An axis with an + unbounded side has no scale handle. + + A plane changed from anywhere else moves the gizmo. A drag is one + plane interaction on the visual (``PlaneInteractionEvent``). The + session closes itself when its plane or its visual is removed, when + the canvas leaves 3D, when the visual leaves the ``"plane"`` render + mode, and when the plane's axes stop being the displayed ones. + + Parameters + ---------- + cellier_controller : CellierController + The controller the visual and the canvas belong to. + visual_id : UUID + The visual whose plane is edited. + canvas_id : UUID + A 3D canvas of the visual's scene. + plane_id : UUID + The ``id`` of the ``RenderPlane`` to edit. It may be disabled. + screen_size : float + The gizmo's size on screen, in logical pixels. + on_closed : Callable[[RenderPlaneGizmoController], None] or None + Called once, when the session has closed. + + Raises + ------ + KeyError + If the canvas or the visual is not registered, or the visual has no + render plane with that id. + ValueError + If the canvas is in 2D, the visual is not in the canvas's scene or + not in the ``"plane"`` render mode, or the plane's axes are not the + ones the view displays. + """ + + kind = "render" + + def _open(self, screen_size: float) -> None: + controller = self._controller + plane = controller.get_render_plane(self._visual_id, self._plane_id) + if not controller._draws_planes(controller.get_visual_model(self._visual_id)): + raise ValueError( + f"Visual {self._visual_id} is not in the 'plane' render mode; " + "a render plane gizmo edits a plane that is drawn." + ) + origin, axis_0, axis_1 = controller._render_manager.reduce_render_plane( + self._visual_id, plane + ) + self._plane = plane + # The extents a scale drag multiplies: the plane's at the grab. + self._grab_extents = (plane.extent_0, plane.extent_1) + self._handle_kind = "translate" + self._handle_axis: Any = 0 + controller._render_manager.add_plane_gizmo( + self._canvas_id, + self._id, + origin, + np.cross(axis_0, axis_1), + screen_size=screen_size, + in_plane_axes=(axis_0, axis_1), + scale_axes=scale_handle_axes(plane), + ) + + def _subscriptions(self) -> list[tuple[Any, Any, UUID]]: + return [(RenderPlanesChangedEvent, self._on_planes_changed, self._visual_id)] + + # -- the gizmo -> the model ---------------------------------------------- + + def _grabbed(self, event: PlaneGizmoMovedEvent) -> None: + self._handle_kind = event.handle_kind + self._handle_axis = event.handle_axis + self._grab_extents = (self._plane.extent_0, self._plane.extent_1) + + def _moved(self, event: PlaneGizmoMovedEvent) -> None: + """Assign the fields the held handle changes, and no other. + + The other fields are kept from the model as they are, so a + translate leaves the in-plane axes bit for bit and a scale leaves + the pose. + """ + plane = self._plane + update: dict[str, Any] = {} + if self._handle_kind == "scale": + # Only the grabbed axis: the gizmo's scale on the other is 1 + # to float noise, not exactly. + if self._handle_axis == 1: + update["extent_0"] = scaled_extent( + self._grab_extents[0], event.scale[0] + ) + elif self._handle_axis == 2: + update["extent_1"] = scaled_extent( + self._grab_extents[1], event.scale[1] + ) + else: + origin, axis_0, axis_1 = ( + self._controller._render_manager.expand_rendered_frame( + self._visual_id, + plane.axes, + event.point, + event.in_plane_axis_0, + event.in_plane_axis_1, + ) + ) + if self._handle_kind == "rotate": + axis_0 = axis_0 / np.linalg.norm(axis_0) + axis_1 = axis_1 - float(axis_1 @ axis_0) * axis_0 + axis_1 = axis_1 / np.linalg.norm(axis_1) + update["in_plane_axis_0"] = tuple(float(v) for v in axis_0) + update["in_plane_axis_1"] = tuple(float(v) for v in axis_1) + else: + update["origin"] = tuple(float(v) for v in origin) + self._assign(update) + + def _released(self, event: PlaneGizmoMovedEvent) -> None: + """Fold a scale drag in: the factor is in the extent already.""" + self._grab_extents = (self._plane.extent_0, self._plane.extent_1) + self._controller._render_manager.reset_plane_gizmo_scale( + self._canvas_id, self._id + ) + + def _assign(self, update: dict[str, Any]) -> None: + if "in_plane_axis_0" in update: + # Validated: the model checks and normalises the axes. + plane = type(self._plane)(**{**self._plane.model_dump(), **update}) + else: + # A copy: the axes stay bit for bit under a translate or scale. + plane = self._plane.model_copy(update=update) + if plane == self._plane: + return + self._plane = self._controller.set_render_plane( + self._visual_id, self._plane_id, plane, source_id=self._id + ) + + # -- the model -> the gizmo ---------------------------------------------- + + def _on_planes_changed(self, event: RenderPlanesChangedEvent) -> None: + if event.source_id == self._id: + return # this session's own move: the gizmo is already there + plane = next( + (plane for plane in event.render_planes if plane.id == self._plane_id), + None, + ) + if plane is None: + self.close() + elif plane != self._plane: + self._follow(plane) + + def _on_placement_changed(self, _event: Any) -> None: + """The displayed axes changed: the plane may not be drawn any more.""" + try: + plane = self._controller.get_render_plane(self._visual_id, self._plane_id) + except KeyError: + self.close() + return + self._follow(plane) + + def _follow(self, plane: Any) -> None: + """Put the gizmo on *plane*; close if the view no longer draws it.""" + controller = self._controller + render_manager = controller._render_manager + try: + if render_manager.canvas_dim(self._canvas_id) != "3d": + raise ValueError("the canvas left 3D") + if not controller._draws_planes( + controller.get_visual_model(self._visual_id) + ): + raise ValueError("the visual left plane mode") + origin, axis_0, axis_1 = render_manager.reduce_render_plane( + self._visual_id, plane + ) + except (KeyError, ValueError): + self.close() + return + self._plane = plane + if not self._dragging: + self._grab_extents = (plane.extent_0, plane.extent_1) + render_manager.set_plane_gizmo_frame( + self._canvas_id, self._id, origin, axis_0, axis_1 + ) + render_manager.set_plane_gizmo_scale_axes( + self._canvas_id, self._id, scale_handle_axes(plane) + ) diff --git a/src/cellier/controller.py b/src/cellier/controller.py index 10da0b7d..015928f9 100644 --- a/src/cellier/controller.py +++ b/src/cellier/controller.py @@ -15,7 +15,11 @@ import numpy as np from cellier._interaction_driver import _InteractionDriver -from cellier.clipping import ClippingPlaneGizmoController +from cellier.clipping import ( + ClippingPlaneGizmoController, + PlaneGizmoController, + RenderPlaneGizmoController, +) from cellier.data._axes import ( data_axes_from_world, default_data_to_world, @@ -40,8 +44,6 @@ CanvasSizeChangedEvent, ChannelAppearanceChangedEvent, ChannelAppearanceUpdateEvent, - ClippingPlaneGizmoChangedEvent, - ClippingPlaneGizmoUpdateEvent, ClippingPlanesChangedEvent, ClippingPlanesUpdateEvent, DataStoreContentsChangedEvent, @@ -64,6 +66,8 @@ OverlayChangedEvent, OverlayUpdateEvent, PickWriteChangedEvent, + PlaneGizmoChangedEvent, + PlaneGizmoUpdateEvent, PlaneInteractionEvent, RenderConfigChangedEvent, RenderConfigUpdateEvent, @@ -224,6 +228,7 @@ from cellier.visuals._points_memory import PointsMarkerAppearance, PointsVisual from cellier.visuals._render_plane import ( MAX_RENDER_PLANES, + PLANE_RENDER_MODE, RenderPlane, validate_render_planes, ) @@ -883,13 +888,14 @@ def __init__( # between the channels of the batch. self._render_mode_sync: set[UUID] = set() self._render_plane_axes_warned: set[UUID] = set() - # The clipping plane gizmo of each canvas that has one: at most one - # per canvas (gizmo design 7). - self._clipping_gizmos: dict[UUID, ClippingPlaneGizmoController] = {} + # The plane gizmo of each canvas that has one: at most one per + # canvas, on a clipping plane or a render plane (gizmo design 7; + # plane rendering design 8.3). + self._plane_gizmos: dict[UUID, PlaneGizmoController] = {} # The source to stamp on the state event of a gizmo closed on # request, and whether one is being replaced (no event for the old). - self._clipping_gizmo_source: UUID | None = None - self._clipping_gizmo_replacing: bool = False + self._plane_gizmo_source: UUID | None = None + self._plane_gizmo_replacing: bool = False # Reslices after store changes, capped per store at # ``SchedulerConfig.store_change_max_hz`` (design v3 5.14): the loop # time of the last one, the trailing one waiting to run, and whether @@ -1077,8 +1083,8 @@ def __init__( owner_id=self._id, ) self._incoming_events.subscribe( - ClippingPlaneGizmoUpdateEvent, - self._on_clipping_plane_gizmo_update, + PlaneGizmoUpdateEvent, + self._on_plane_gizmo_update, owner_id=self._id, ) @@ -5094,6 +5100,8 @@ def _refresh_plane_mode(self, visual_id: UUID, *, reslice: bool = True) -> None: self._warn_render_planes_not_displayed(visual) else: self._render_plane_axes_warned.discard(visual_id) + # A gizmo edits a plane that is drawn (design 8.3). + self._close_render_plane_gizmos(visual_id) if shown != was and reslice: self.reslice_visual(visual_id) self._request_draw_for_visual(visual_id) @@ -7514,6 +7522,133 @@ def get_render_plane(self, visual_id: UUID, plane_id: UUID) -> RenderPlane: return plane raise KeyError(f"Visual {visual_id} has no render plane {plane_id}.") + def visual_world_extent(self, visual_id: UUID) -> list[tuple[float, float]]: + """The box a visual's data occupies, per world axis of its scene. + + Parameters + ---------- + visual_id : UUID + The visual. + + Returns + ------- + list[tuple[float, float]] + ``(low, high)`` per world axis, in the world system's order. + ``(nan, nan)`` on an axis the visual has no extent on (one it + broadcasts over) and for a store with no data yet. + + Raises + ------ + KeyError + If *visual_id* is not registered. + """ + from cellier.scene._bounds import visual_world_bounds + + visual = self.get_visual_model(visual_id) + scene_id = self._visual_to_scene[visual_id] + world = self._model.scenes[scene_id].dims.world_coordinate_system + store = self.get_data_store(UUID(str(visual.data_store_id))) + extents = store.axis_extents + transform = getattr(visual, "transform", None) + if extents is None or transform is None: + return [(float("nan"), float("nan"))] * world.ndim + low, high = visual_world_bounds(transform, extents, world) + return [(float(a), float(b)) for a, b in zip(low, high, strict=True)] + + def default_render_plane(self, visual_id: UUID) -> RenderPlane: + """A new render plane for a visual: centred, unbounded. + + The plane a control adds: through the centre of the visual's data + box, on the three world axes the scene displays, facing the first + of them, unbounded on every side. + + Parameters + ---------- + visual_id : UUID + The visual the plane is for. It is not given the plane. + + Returns + ------- + RenderPlane + + Raises + ------ + KeyError + If *visual_id* is not registered. + ValueError + If the visual's scene does not display three axes. + """ + scene_id = self._visual_to_scene[visual_id] + dims = self._model.scenes[scene_id].dims + world = dims.world_coordinate_system + displayed = tuple(dims.selection.displayed_axes) + if len(displayed) != 3: + raise ValueError( + "A render plane is on the three world axes a 3D view " + f"displays; the scene displays {len(displayed)}." + ) + extent = self.visual_world_extent(visual_id) + centre = [ + 0.5 * (extent[axis][0] + extent[axis][1]) + if np.isfinite(extent[axis]).all() + else 0.0 + for axis in displayed + ] + return RenderPlane.from_point_normal( + world, + centre, + (1.0, 0.0, 0.0), + axes=tuple(world.axes[axis].id for axis in displayed), + ) + + def render_planes_blocked(self, visual_id: UUID) -> str: + """Why a visual's render planes are not drawn now, or ``""`` if they are. + + A sentence a control can show: the visual is not in the ``"plane"`` + render mode, or its scene is shown in 2D (where a visual in plane + mode shows its normal slice). + + Parameters + ---------- + visual_id : UUID + The visual. + + Returns + ------- + str + """ + try: + visual = self.get_visual_model(visual_id) + scene_id = self._visual_to_scene[visual_id] + except KeyError as error: + return str(error.args[0]) if error.args else "Not available." + if not isinstance(visual, _PLANE_VISUALS): + return "This visual has no render planes." + if self._n_displayed_dims(scene_id) != 3: + return "Render planes are drawn in a 3D view; this one is 2D." + if not visual.plane_mode(): + return "Set the render mode to 'plane' to draw on render planes." + return "" + + def _give_plane_mode_a_plane(self, visual_id: UUID, field: str, value: Any) -> None: + """Add a centred render plane when a control chose ``"plane"`` with none. + + Called for a GUI's request only (an appearance update event): a + control that selects the mode on a visual with no planes would + otherwise blank it. The model does not switch itself: assigning + the mode from code adds nothing (design 9.1). + """ + if field != "render_mode" or value != PLANE_RENDER_MODE: + return + if visual_id not in self._visual_to_scene: + return + visual = self.get_visual_model(visual_id) + if not isinstance(visual, _PLANE_VISUALS) or visual.render_planes: + return + if not self._draws_planes(visual): + return + self.set_render_planes(visual_id, (self.default_render_plane(visual_id),)) + def set_render_plane( self, visual_id: UUID, @@ -7677,7 +7812,7 @@ def begin_plane_interaction(self, visual_id: UUID, *, source_id: UUID) -> None: With the setting off, and for every other visual, a plane change inside a drag plans as it does outside one. - A clipping plane gizmo calls this when a handle is grabbed. + A plane gizmo calls this when a handle is grabbed. Scripts normally use :meth:`plane_interaction`. Parameters @@ -7769,7 +7904,10 @@ def add_clipping_plane_gizmo( its visual is removed, when the canvas leaves 3D, and when the plane gains a component on an axis the view does not show. - Emits ``ClippingPlaneGizmoChangedEvent``. + The canvas's one gizmo serves both kinds of plane: a gizmo on a + render plane (:meth:`add_render_plane_gizmo`) is closed too. + + Emits ``PlaneGizmoChangedEvent`` with ``kind="clipping"``. Parameters ---------- @@ -7781,7 +7919,7 @@ def add_clipping_plane_gizmo( The ``id`` of the ``ClippingPlane`` to edit. The plane may be disabled. source_id : UUID or None - Stamped on the emitted ``ClippingPlaneGizmoChangedEvent``. + Stamped on the emitted ``PlaneGizmoChangedEvent``. screen_size : float The gizmo's size on screen, in logical pixels. @@ -7800,24 +7938,110 @@ def add_clipping_plane_gizmo( scene, or the plane has a component on an axis the view does not show. The canvas keeps the gizmo it had. """ - session = ClippingPlaneGizmoController( + return self._open_plane_gizmo( + ClippingPlaneGizmoController, + visual_id, + canvas_id, + plane_id, + source_id, + screen_size, + ) + + def add_render_plane_gizmo( + self, + visual_id: UUID, + canvas_id: UUID, + plane_id: UUID, + *, + source_id: UUID | None = None, + screen_size: float = 100.0, + ) -> RenderPlaneGizmoController: + """Put a gizmo on one render plane of a visual, in a 3D canvas. + + The gizmo carries the plane's whole pose. Dragging a translate + handle moves the plane's ``origin``, a rotation ring turns its + in-plane axes, and a scale handle on an in-plane axis multiplies + that axis's extent about the origin (an axis with an unbounded side + has no scale handle). A plane changed from anywhere else moves the + gizmo. Each drag is one plane interaction on the visual. + + A canvas has one gizmo at a time, whatever the kind of plane: the + one it had is closed. The gizmo closes itself when its plane or + its visual is removed, when the canvas leaves 3D, when the visual + leaves the ``"plane"`` render mode, and when the plane's axes stop + being the displayed ones. + + Emits ``PlaneGizmoChangedEvent`` with ``kind="render"``. + + Parameters + ---------- + visual_id : UUID + The visual the plane belongs to. It must be in the + ``"plane"`` render mode. + canvas_id : UUID + A 3D canvas of the visual's scene. + plane_id : UUID + The ``id`` of the ``RenderPlane`` to edit. The plane may be + disabled. + source_id : UUID or None + Stamped on the emitted ``PlaneGizmoChangedEvent``. + screen_size : float + The gizmo's size on screen, in logical pixels. + + Returns + ------- + RenderPlaneGizmoController + The session. ``close()`` ends it. + + Raises + ------ + KeyError + If the canvas or the visual is not registered, or the visual + has no render plane with that id. + ValueError + If the canvas is in 2D, the visual is not in the canvas's scene + or not in the ``"plane"`` render mode, or the plane's axes are + not the ones the view displays. The canvas keeps the gizmo it + had. + """ + return self._open_plane_gizmo( + RenderPlaneGizmoController, + visual_id, + canvas_id, + plane_id, + source_id, + screen_size, + ) + + def _open_plane_gizmo( + self, + session_type: type[PlaneGizmoController], + visual_id: UUID, + canvas_id: UUID, + plane_id: UUID, + source_id: UUID | None, + screen_size: float, + ) -> Any: + """Open a gizmo session on a canvas, replacing the one it had.""" + # Built first: a refusal leaves the canvas the gizmo it had. + session = session_type( self, visual_id, canvas_id, plane_id, screen_size=screen_size, - on_closed=self._on_clipping_gizmo_closed, + on_closed=self._on_plane_gizmo_closed, ) - previous = self._clipping_gizmos.get(canvas_id) + previous = self._plane_gizmos.get(canvas_id) if previous is not None: # One event for a replacement: the new plane, not "none" first. - self._clipping_gizmo_replacing = True + self._plane_gizmo_replacing = True try: previous.close() finally: - self._clipping_gizmo_replacing = False - self._clipping_gizmos[canvas_id] = session - self._emit_clipping_gizmo(canvas_id, source_id) + self._plane_gizmo_replacing = False + self._plane_gizmos[canvas_id] = session + self._emit_plane_gizmo(canvas_id, source_id) return session def clipping_plane_gizmo_blocked( @@ -7859,65 +8083,125 @@ def clipping_plane_gizmo_blocked( "not show; a gizmo cannot move it." ) - def get_clipping_plane_gizmo( - self, canvas_id: UUID - ) -> ClippingPlaneGizmoController | None: - """The clipping plane gizmo of a canvas, or ``None`` if it has none.""" - return self._clipping_gizmos.get(canvas_id) + def render_plane_gizmo_blocked( + self, visual_id: UUID, canvas_id: UUID, plane_id: UUID + ) -> str: + """Why a render plane cannot have a gizmo in a canvas now, or ``""``. + + What :meth:`add_render_plane_gizmo` would refuse, as a sentence a + control can show: the canvas is in 2D, the visual is not in the + ``"plane"`` render mode, or the plane's axes are not the displayed + ones. + + Parameters + ---------- + visual_id : UUID + The visual the plane belongs to. + canvas_id : UUID + The canvas the gizmo would be drawn in. + plane_id : UUID + The ``id`` of the ``RenderPlane``. + + Returns + ------- + str + """ + try: + plane = self.get_render_plane(visual_id, plane_id) + if self._render_manager.canvas_dim(canvas_id) != "3d": + return "A gizmo needs a 3D view; this one is 2D." + visual = self.get_visual_model(visual_id) + if not visual.plane_mode(): + return "A gizmo needs the visual in the 'plane' render mode." + scene_id = self._visual_to_scene[visual_id] + drawable, _ = self._split_render_planes( + scene_id, (plane.model_copy(update={"enabled": True}),) + ) + except (KeyError, ValueError) as error: + return str(error.args[0]) if error.args else "Not available." + if drawable: + return "" + return ( + "The plane is on axes other than the ones the 3D view displays; " + "a gizmo cannot move it." + ) + + def get_plane_gizmo(self, canvas_id: UUID) -> PlaneGizmoController | None: + """The plane gizmo of a canvas, or ``None`` if it has none. - def remove_clipping_plane_gizmo( + Its ``kind`` is ``"clipping"`` or ``"render"``. + """ + return self._plane_gizmos.get(canvas_id) + + def remove_plane_gizmo( self, canvas_id: UUID, *, source_id: UUID | None = None ) -> None: - """Close a canvas's clipping plane gizmo; nothing if it has none. + """Close a canvas's plane gizmo, of either kind; nothing if it has none. - Emits ``ClippingPlaneGizmoChangedEvent`` stamped with *source_id*. + Emits ``PlaneGizmoChangedEvent`` stamped with *source_id*. """ - session = self._clipping_gizmos.get(canvas_id) + session = self._plane_gizmos.get(canvas_id) if session is None: return - self._clipping_gizmo_source = source_id + self._plane_gizmo_source = source_id try: session.close() finally: - self._clipping_gizmo_source = None + self._plane_gizmo_source = None - def _on_clipping_gizmo_closed(self, session: ClippingPlaneGizmoController) -> None: + def _on_plane_gizmo_closed(self, session: PlaneGizmoController) -> None: canvas_id = session.canvas_id - if self._clipping_gizmos.get(canvas_id) is not session: + if self._plane_gizmos.get(canvas_id) is not session: return - del self._clipping_gizmos[canvas_id] - if not self._clipping_gizmo_replacing: - self._emit_clipping_gizmo(canvas_id, self._clipping_gizmo_source) + del self._plane_gizmos[canvas_id] + if not self._plane_gizmo_replacing: + self._emit_plane_gizmo(canvas_id, self._plane_gizmo_source) - def _emit_clipping_gizmo(self, canvas_id: UUID, source_id: UUID | None) -> None: - session = self._clipping_gizmos.get(canvas_id) + def _emit_plane_gizmo(self, canvas_id: UUID, source_id: UUID | None) -> None: + session = self._plane_gizmos.get(canvas_id) self._outgoing_events.emit( - ClippingPlaneGizmoChangedEvent( + PlaneGizmoChangedEvent( source_id=source_id or self._id, canvas_id=canvas_id, visual_id=None if session is None else session.visual_id, + kind=None if session is None else session.kind, plane_id=None if session is None else session.plane_id, ) ) - def _on_clipping_plane_gizmo_update( - self, event: ClippingPlaneGizmoUpdateEvent - ) -> None: + def _close_render_plane_gizmos(self, visual_id: UUID) -> None: + """Close the render plane gizmos on a visual that left plane mode.""" + for session in list(self._plane_gizmos.values()): + if session.kind == "render" and session.visual_id == visual_id: + session.close() + + def _on_plane_gizmo_update(self, event: PlaneGizmoUpdateEvent) -> None: + if event.kind not in ("clipping", "render"): + raise ValueError( + f"Unknown plane gizmo kind {event.kind!r}; expected " + '"clipping" or "render".' + ) if event.enabled: - self.add_clipping_plane_gizmo( + add = ( + self.add_render_plane_gizmo + if event.kind == "render" + else self.add_clipping_plane_gizmo + ) + add( event.visual_id, event.canvas_id, event.plane_id, source_id=event.source_id, ) return - session = self._clipping_gizmos.get(event.canvas_id) + session = self._plane_gizmos.get(event.canvas_id) if ( session is not None + and session.kind == event.kind and session.visual_id == event.visual_id and session.plane_id == UUID(str(event.plane_id)) ): - self.remove_clipping_plane_gizmo(event.canvas_id, source_id=event.source_id) + self.remove_plane_gizmo(event.canvas_id, source_id=event.source_id) def _on_clipping_planes_update(self, event: ClippingPlanesUpdateEvent) -> None: self.set_clipping_planes( @@ -8045,6 +8329,7 @@ def _on_appearance_update(self, event: AppearanceUpdateEvent) -> None: self.update_appearance_field( event.visual_id, event.field, event.value, source_id=event.source_id ) + self._give_plane_mode_a_plane(event.visual_id, event.field, event.value) def _on_dims_update(self, event: DimsUpdateEvent) -> None: # No ordering between the two: every axis keeps a position whether or @@ -8099,6 +8384,7 @@ def _on_single_appearance_update(self, event: SingleAppearanceUpdateEvent) -> No self.update_single_appearance_field( event.visual_id, event.field, event.value, source_id=event.source_id ) + self._give_plane_mode_a_plane(event.visual_id, event.field, event.value) def _on_image_composite_update(self, event: ImageCompositeUpdateEvent) -> None: self.set_image_composite( @@ -8115,6 +8401,7 @@ def _on_channel_appearance_update( event.value, source_id=event.source_id, ) + self._give_plane_mode_a_plane(event.visual_id, event.field, event.value) # ------------------------------------------------------------------ # Camera reslicing @@ -9126,7 +9413,7 @@ def remove_canvas(self, canvas_id: UUID) -> None: """ scene_id = self._canvas_to_scene[canvas_id] - self.remove_clipping_plane_gizmo(canvas_id) + self.remove_plane_gizmo(canvas_id) # 1. Drop the canvas's camera motion state (no event; timer cancelled). self._forget_camera_interaction(canvas_id) @@ -10696,7 +10983,7 @@ def close(self) -> None: # task only observes its CancelledError once the loop runs again. What # is guaranteed here is that nothing stays tracked and nothing is left # un-cancelled -- not that everything has already stopped. - for session in list(self._clipping_gizmos.values()): + for session in list(self._plane_gizmos.values()): session.close() self._camera_driver.close() self._cancel_queued_camera_reslices() @@ -10775,24 +11062,25 @@ def on_section_changed( weak=weak, ) - def on_clipping_plane_gizmo_changed( + def on_plane_gizmo_changed( self, canvas_id: UUID, - callback: Callable[[ClippingPlaneGizmoChangedEvent], None], + callback: Callable[[PlaneGizmoChangedEvent], None], *, owner_id: UUID, weak: bool = False, ) -> SubscriptionHandle: - """Register a callback fired when a canvas's clipping plane gizmo changes. + """Register a callback fired when a canvas's plane gizmo changes. Parameters ---------- canvas_id : The canvas to watch. callback : - Called with the ``ClippingPlaneGizmoChangedEvent``: the visual - and plane the canvas's gizmo is now on, or ``None`` for both - when it has none. + Called with the ``PlaneGizmoChangedEvent``: the visual, the + kind (``"clipping"`` or ``"render"``) and the plane the + canvas's gizmo is now on, or ``None`` for all three when it has + none. owner_id : UUID under which this subscription is registered. weak : @@ -10803,7 +11091,7 @@ def on_clipping_plane_gizmo_changed( SubscriptionHandle """ return self._outgoing_events.subscribe( - ClippingPlaneGizmoChangedEvent, + PlaneGizmoChangedEvent, callback, entity_id=canvas_id, owner_id=owner_id, diff --git a/src/cellier/convenience/_ortho_planes.py b/src/cellier/convenience/_ortho_planes.py new file mode 100644 index 00000000..b171a899 --- /dev/null +++ b/src/cellier/convenience/_ortho_planes.py @@ -0,0 +1,465 @@ +"""Slice planes in the 3D panel of an OrthoViewer. + +See ``plans/plane_rendering_design_v3.md`` section 10.2. +""" + +from __future__ import annotations + +import warnings +from typing import TYPE_CHECKING, Any, Literal +from uuid import UUID, uuid4 + +import numpy as np +from psygnal import Signal + +from cellier.visuals import RenderPlane + +if TYPE_CHECKING: + from collections.abc import Callable, Mapping, Sequence + + from cellier.controller import CellierController + from cellier.events import ( + DimsChangedEvent, + DimsInteractionEvent, + RenderPlanesChangedEvent, + VisualRemovedEvent, + ) + from cellier.scene.scene import Scene + +OrthoPlaneMode = Literal["slices"] | None +"""How the 3D panel's render planes are tied to the 2D panels.""" + +#: The 2D panels in the order of the spatial axis each one slices. +_SLICE_PANELS = ("xy", "xz", "yz") +#: How far a slice plane's normal may be from its axis (1 - |cosine|). +_NORMAL_TOL = 1e-9 + +SLICES_REASON = ( + "The planes follow the 2D views' sliders " + '(plane_controller.mode is "slices"). Move a slider to move a plane.' +) +"""Why a render planes control is read-only in ``"slices"`` mode.""" + + +def _check_mode(mode: object) -> None: + if mode not in ("slices", None): + raise ValueError(f'Unknown plane mode {mode!r}. Expected "slices" or None.') + + +class OrthoPlaneController: + """Tie the 3D panel's render planes to the 2D panels' slices. + + ``mode`` says how: + + ============ ======================================================== + ``None`` No link (the default). The 3D panel's visuals keep + whatever render planes they are given. + ``"slices"`` Every image and labels visual of the 3D panel carries + exactly three render planes, one per 2D panel, at that + panel's slice position and facing its sliced axis. + Moving a 2D slider moves the matching plane. + ============ ======================================================== + + In a visual in the ``"plane"`` render mode the three planes show, in + 3D, the three slices the 2D panels show. A visual in another render + mode carries the planes too and draws them as soon as it is put in + plane mode. The mode sets no render mode itself. + + This holds **no plane state**. Each visual's ``render_planes`` stays + the source of truth and is what is serialized; in ``"slices"`` mode + this keeps the planes of the 3D panel's visuals and the 2D sliders in + step, through the controller's public API: + + - **A slider moves** (or anything else changes a slice position): each + plane is moved along its normal to its panel's position, on every + visual. + - **A plane is changed on one visual** (from code: there is no gizmo + on a slice plane, and the render planes control shows them + read-only): the change is carried over. A plane moved along its + normal moves its panel's slider, on every panel when the panels' + axes are linked. Its extents, its ``enabled`` flag and its spin are + copied to the other visuals. A tuple that is not the three slice + planes (a plane tilted off its axis, added, removed or replaced) + cannot be carried over: the visual is given the slice planes back, + with a warning. + + A 2D panel's **scrub** is forwarded: while a slider is dragged this + holds a plane interaction scope on each visual, so a multiscale visual + keeps the bricks it has while the planes move and loads once when the + slider is released. + + Turning the mode on replaces the visuals' planes with the three slice + planes. Turning it off leaves them, free to edit. + + Parameters + ---------- + controller : CellierController + The controller owning the scenes and the visuals. + scenes : Mapping[str, Scene] + The panel scenes keyed ``"xy"``, ``"xz"``, ``"yz"``, ``"vol"``. + spatial_axes : Sequence[int] + The three world axes the 3D panel displays, in order. The ``xy`` + panel slices the first, ``xz`` the second and ``yz`` the third. + dims_controller : Callable[[], OrthoDimsController | None] or None + Returns the controller mirroring the panels' dims while their axes + are linked, else ``None``. A plane moved along its normal then + moves its slider on every panel; with no link it moves the 2D panel + that slices that axis, and the 3D panel. + mode : {"slices"} or None + The starting mode. + + Attributes + ---------- + mode_changed : psygnal.Signal + Emitted with the new mode after :attr:`mode` changed. + """ + + mode_changed = Signal(object) + + def __init__( + self, + controller: CellierController, + scenes: Mapping[str, Scene], + spatial_axes: Sequence[int], + dims_controller: Callable[[], Any] | None = None, + mode: OrthoPlaneMode = None, + ) -> None: + _check_mode(mode) + self._id: UUID = uuid4() + self._controller = controller + self._scenes = dict(scenes) + self._axes = tuple(int(axis) for axis in spatial_axes) + if len(self._axes) != 3: + raise ValueError("The 3D panel displays three world axes.") + self._dims_controller = dims_controller + self._mode: OrthoPlaneMode = mode + #: The 3D panel's visuals that have render planes, in the order added. + self._visuals: list[UUID] = [] + #: The ids of the three slice planes, by sliced axis. One triple + #: for every visual, so their tuples can be equal. + self._plane_ids = (uuid4(), uuid4(), uuid4()) + self._syncing = False + self._warned = False + # One id per visual: the owner of its subscriptions. + self._owners: dict[UUID, UUID] = {} + # The 2D panels being scrubbed, and the scope source of each. + self._scope_ids: dict[UUID, UUID] = {} + self._scrubbing: set[UUID] = set() + for key in _SLICE_PANELS: + scene = self._scenes[key] + self._scope_ids[scene.id] = uuid4() + controller.on_dims_changed(scene.id, self._on_dims, owner_id=self._id) + controller.on_dims_interaction( + scene.id, self._on_dims_interaction, owner_id=self._id + ) + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + @property + def id(self) -> UUID: + """The source id stamped on the planes this writes.""" + return self._id + + @property + def mode(self) -> OrthoPlaneMode: + """How the 3D panel's render planes are tied to the 2D panels. + + Setting ``"slices"`` replaces the render planes of every image and + labels visual of the 3D panel with the three slice planes, + unbounded, at the panels' current positions. Setting ``None`` + writes nothing: the visuals keep the three planes, free to edit. + ``mode_changed`` is emitted once, after the writes. Setting the + current mode does nothing. + """ + return self._mode + + @mode.setter + def mode(self, mode: OrthoPlaneMode) -> None: + _check_mode(mode) + if mode == self._mode: + return + self._end_scrubs() + self._mode = mode + if mode == "slices": + planes = self.slice_planes() + for visual_id in self._visuals: + self._write(visual_id, planes) + self.mode_changed.emit(mode) + + @property + def visual_ids(self) -> tuple[UUID, ...]: + """The 3D panel's visuals whose planes the mode writes.""" + return tuple(self._visuals) + + @property + def plane_ids(self) -> tuple[UUID, UUID, UUID]: + """The ids of the slice planes of the ``xy``, ``xz`` and ``yz`` panels.""" + return self._plane_ids + + def blocked_reason(self) -> str: + """Why the slice planes cannot be edited by hand now, or ``""``.""" + return SLICES_REASON if self._mode == "slices" else "" + + def positions(self) -> tuple[float, float, float]: + """The slice positions of the ``xy``, ``xz`` and ``yz`` panels. + + Each is read from its own panel: the position of the world axis + that panel slices. + """ + return tuple( + float(self._scenes[key].dims.selection.slice_indices.get(axis, 0.0)) + for key, axis in zip(_SLICE_PANELS, self._axes, strict=True) + ) + + def slice_planes( + self, like: Sequence[RenderPlane] | None = None + ) -> tuple[RenderPlane, RenderPlane, RenderPlane]: + """The three slice planes at the panels' current positions. + + Parameters + ---------- + like : Sequence[RenderPlane] or None + Slice planes to keep everything of but the position along the + normal (their extents, ``enabled`` and spin). ``None`` builds + new ones: through the point the three slices meet at, unbounded. + + Returns + ------- + tuple[RenderPlane, RenderPlane, RenderPlane] + The planes of the ``xy``, ``xz`` and ``yz`` panels. + """ + positions = self.positions() + if like is not None: + by_id = {plane.id: plane for plane in like} + out = [] + for k, plane_id in enumerate(self._plane_ids): + plane = by_id[plane_id] + origin = list(plane.origin) + if origin[k] != positions[k]: + origin[k] = positions[k] + plane = plane.model_copy(update={"origin": tuple(origin)}) + out.append(plane) + return tuple(out) + world = self._scenes["vol"].dims.world_coordinate_system + axis_ids = tuple(world.axes[axis].id for axis in self._axes) + unit = np.eye(3) + return tuple( + RenderPlane( + id=self._plane_ids[k], + coordinate_system=world.id, + axes=axis_ids, + origin=positions, + # The other two axes, in the order whose cross product is + # axis k. + in_plane_axis_0=tuple(unit[(k + 1) % 3]), + in_plane_axis_1=tuple(unit[(k + 2) % 3]), + ) + for k in range(3) + ) + + def add_visual(self, visual: Any) -> None: + """Register a visual of the 3D panel. + + A visual with no render planes (a mesh, points) is ignored. In + ``"slices"`` mode the visual is given the slice planes: those the + other visuals carry, so extents set on them are kept. + + Parameters + ---------- + visual : visual model or UUID + The 3D panel's visual of one ``add_*`` call. + """ + visual_id = getattr(visual, "id", visual) + model = self._controller.get_visual_model(visual_id) + if not hasattr(model, "render_planes") or visual_id in self._visuals: + return + owner_id = self._owners[visual_id] = uuid4() + self._visuals.append(visual_id) + self._controller.on_render_planes_changed( + visual_id, self._on_planes, owner_id=owner_id + ) + self._controller.on_visual_removed( + visual_id, self._on_removed, owner_id=owner_id + ) + if self._mode == "slices": + self._write(visual_id, self._current_planes(skip=visual_id)) + + def close(self) -> None: + """Stop following: close forwarded scopes and drop the subscriptions.""" + self._end_scrubs() + self._controller.unsubscribe_owner(self._id) + for owner_id in self._owners.values(): + self._controller.unsubscribe_owner(owner_id) + self._owners.clear() + self._visuals.clear() + + # ------------------------------------------------------------------ + # Planes + # ------------------------------------------------------------------ + + def _planes_of(self, visual_id: UUID) -> tuple[RenderPlane, ...]: + return self._controller.get_visual_model(visual_id).render_planes + + def _slice_like( + self, planes: Sequence[RenderPlane] + ) -> tuple[RenderPlane, RenderPlane, RenderPlane] | None: + """*planes* in panel order if they are three slice planes, else ``None``. + + Three planes with the slice planes' ids, on the 3D panel's axes, + each with its normal along its own axis. Where a plane sits along + its normal, its extents, ``enabled`` and spin are free. + """ + if len(planes) != 3: + return None + by_id = {plane.id: plane for plane in planes} + world = self._scenes["vol"].dims.world_coordinate_system + out = [] + for k, plane_id in enumerate(self._plane_ids): + plane = by_id.get(plane_id) + if plane is None: + return None + try: + axes = tuple(world.resolve(axis) for axis in plane.axes) + except (KeyError, ValueError): + return None + if axes != self._axes: + return None + normal = np.cross(plane.in_plane_axis_0, plane.in_plane_axis_1) + if 1.0 - abs(float(normal[k])) > _NORMAL_TOL: + return None + out.append(plane) + return tuple(out) + + def _current_planes( + self, skip: UUID | None = None + ) -> tuple[RenderPlane, RenderPlane, RenderPlane]: + """The slice planes now: a visual's, moved to the panels' positions.""" + for visual_id in self._visuals: + if visual_id == skip: + continue + like = self._slice_like(self._planes_of(visual_id)) + if like is not None: + return self.slice_planes(like) + return self.slice_planes() + + def _write(self, visual_id: UUID, planes: Sequence[RenderPlane]) -> None: + """Give *visual_id* *planes* if it does not carry them already.""" + if self._planes_of(visual_id) == tuple(planes): + return + was, self._syncing = self._syncing, True + try: + self._controller.set_render_planes(visual_id, planes, source_id=self._id) + finally: + self._syncing = was + + def _on_dims(self, _event: DimsChangedEvent) -> None: + """A slice position may have changed: move the planes to it.""" + if self._mode != "slices" or self._syncing or not self._visuals: + return + planes = self._current_planes() + for visual_id in self._visuals: + self._write(visual_id, planes) + + def _on_planes(self, event: RenderPlanesChangedEvent) -> None: + """A visual's planes changed from outside: carry the change over.""" + if self._mode != "slices" or self._syncing: + return + visual_id = event.visual_id + if visual_id not in self._owners: + return + planes = self._planes_of(visual_id) + if planes != tuple(event.render_planes): + # A subscriber called before this one changed the visual again; + # that change's own event carries the tuple to act on. + return + like = self._slice_like(planes) + if like is None: + if not self._warned: + self._warned = True + warnings.warn( + "In plane_controller.mode 'slices' the 3D panel's render " + "planes are the three slice planes: one per 2D panel, " + "facing its sliced axis. A change that tilts, adds, " + "removes or replaces one cannot be kept and was put " + "back. Set plane_controller.mode = None to edit the " + "planes freely.", + UserWarning, + stacklevel=2, + ) + self._write(visual_id, self._current_planes(skip=visual_id)) + return + # The planes' positions become the panels' slice positions... + moved = { + axis: float(plane.origin[k]) + for k, (axis, plane) in enumerate(zip(self._axes, like, strict=True)) + if float(plane.origin[k]) != self.positions()[k] + } + was, self._syncing = self._syncing, True + try: + if moved: + self._move_sliders(moved) + finally: + self._syncing = was + # ... and every visual takes the tuple: extents, ``enabled`` and + # spin with it. Read back from the sliders, in case a position was + # not taken as given. + planes = self.slice_planes(like) + for other in self._visuals: + self._write(other, planes) + + def _move_sliders(self, positions: Mapping[int, float]) -> None: + linked = None if self._dims_controller is None else self._dims_controller() + if linked is not None: + linked.set_slice_positions(positions, source_id=self._id) + return + for key, axis in zip(_SLICE_PANELS, self._axes, strict=True): + if axis in positions: + for scene in (self._scenes[key], self._scenes["vol"]): + self._controller.update_slice_indices( + scene.id, {axis: positions[axis]}, source_id=self._id + ) + + # ------------------------------------------------------------------ + # Scrub forwarding + # ------------------------------------------------------------------ + + def _on_dims_interaction(self, event: DimsInteractionEvent) -> None: + """Hold a plane scope on the visuals while a 2D slider is dragged.""" + scene_id = event.scene_id + if event.phase == "end": + self._end_scrub(scene_id) + return + if self._mode != "slices" or scene_id in self._scrubbing: + return + self._scrubbing.add(scene_id) + for visual_id in self._visuals: + self._controller.begin_plane_interaction( + visual_id, source_id=self._scope_ids[scene_id] + ) + + def _end_scrub(self, scene_id: UUID) -> None: + if scene_id not in self._scrubbing: + return + self._scrubbing.discard(scene_id) + for visual_id in self._visuals: + self._controller.end_plane_interaction( + visual_id, source_id=self._scope_ids[scene_id] + ) + + def _end_scrubs(self) -> None: + for scene_id in list(self._scrubbing): + self._end_scrub(scene_id) + + # ------------------------------------------------------------------ + # Removal + # ------------------------------------------------------------------ + + def _on_removed(self, event: VisualRemovedEvent) -> None: + removed = event.visual_id + owner_id = self._owners.pop(removed, None) + if owner_id is None: + return + self._visuals.remove(removed) + self._controller.unsubscribe_owner(owner_id) diff --git a/src/cellier/convenience/_ortho_viewer.py b/src/cellier/convenience/_ortho_viewer.py index 3ec4dc42..b414d948 100644 --- a/src/cellier/convenience/_ortho_viewer.py +++ b/src/cellier/convenience/_ortho_viewer.py @@ -20,6 +20,7 @@ OrthoClippingController, ) from cellier.convenience._ortho_dims import OrthoDimsController +from cellier.convenience._ortho_planes import OrthoPlaneController from cellier.convenience._render_settings import RenderSettingsMixin from cellier.convenience._startup import StartupState from cellier.render._capture import write_png @@ -39,7 +40,10 @@ import numpy as np from cellier._state import CameraState - from cellier.clipping import ClippingPlaneGizmoController + from cellier.clipping import ( + ClippingPlaneGizmoController, + RenderPlaneGizmoController, + ) from cellier.convenience.gui._controls_config import ( BaseControlsConfig, GraphControlsConfig, @@ -68,7 +72,7 @@ from cellier.render._config import RenderManagerConfig from cellier.scene._background import BackgroundAppearance from cellier.transform import BaseTransform, WorldCoordinateSystem - from cellier.visuals import ClippingPlane + from cellier.visuals import ClippingPlane, RenderPlane from cellier.visuals._base_visual import VisualOutline from cellier.visuals._graph_memory import ( GraphAppearance, @@ -297,6 +301,7 @@ def __init__( self._extra_axes = {i for i in range(self._ndim) if i not in self._spatial_axes} self._scenes = self._build_scenes(world) self._dims_controller: OrthoDimsController | None = None + self._plane_controller = self._build_plane_controller() # Per-visual controls configs, keyed by a representative visual id; # _visual_groups maps that id to the visuals one widget drives: the # three 2D panels, or the 3D panel (_CONTROLS_GROUPS). Not @@ -343,6 +348,17 @@ def _build_scenes(self, world: WorldCoordinateSystem) -> dict[str, Scene]: scenes[key] = self._controller.add_scene_model(scene) return scenes + def _build_plane_controller(self) -> OrthoPlaneController: + """The controller of the 3D panel's slice planes (mode ``None``).""" + return OrthoPlaneController( + self._controller, + self._scenes, + self._spatial_axes, + dims_controller=lambda: ( + self._dims_controller if self.axis_sync_enabled else None + ), + ) + def _wire_axis_sync(self) -> None: """Mirror dims across the panels, xy first so it wins a disagreement.""" self._dims_controller = OrthoDimsController( @@ -730,6 +746,25 @@ def clipping_controller(self) -> OrthoClippingController: """ return self._clipping_controller + @property + def plane_controller(self) -> OrthoPlaneController: + """The controller tying the 3D panel's render planes to the 2D panels. + + Its ``mode`` is ``None`` (the default: no link) or ``"slices"``: + every image and labels visual of the 3D panel then carries three + render planes, one per 2D panel, at that panel's slice position, and + a 2D slider moves its plane:: + + ortho.add_image_multiscale( + store, single=MultiscaleImageSingleAppearance(render_mode="plane") + ) + ortho.plane_controller.mode = "slices" + + The planes are drawn by the visuals that are in the ``"plane"`` + render mode. + """ + return self._plane_controller + @property def axis_sync_enabled(self) -> bool: """Whether dims edits are mirrored across the panels.""" @@ -835,6 +870,7 @@ def from_file( obj._ndim = ndim obj._extra_axes = {i for i in range(ndim) if i not in vol_displayed} obj._dims_controller = None + obj._plane_controller = obj._build_plane_controller() obj._clipping_controller = OrthoClippingController( controller, link_clipping_planes ) @@ -1076,6 +1112,7 @@ def _fan_out(self, add_one) -> dict[str, object]: """ visuals = {key: add_one(key, scene) for key, scene in self._scenes.items()} self._clipping_controller.add_group(visuals) + self._plane_controller.add_visual(visuals["vol"]) return visuals # ------------------------------------------------------------------ @@ -1097,6 +1134,7 @@ def add_image( outline: VisualOutline | None = None, ambient_occlusion: bool | None = None, clipping_planes: Sequence[ClippingPlane] = (), + render_planes: Sequence[RenderPlane] = (), ) -> dict[str, ImageVisual]: """Add an in-memory image to every panel from a single data store. @@ -1142,6 +1180,16 @@ def add_image( Build them from ``data.data_coordinate_systems[0]``. They can be changed later by assigning ``visual.clipping_planes``. Default none. + render_planes : Sequence[RenderPlane] + The planes the ``"plane"`` render mode draws the data on, in + the scene's world coordinates + (``RenderPlane.from_point_normal(viewer.scene.dims. + world_coordinate_system, ...)``). Ignored in every other + render mode. They can be changed later with + ``controller.set_render_planes``. Default none. + Given to the 3D panel's visual only: the 2D panels show + their slices. Replaced by the slice planes while + ``plane_controller.mode`` is ``"slices"``. Returns ------- @@ -1164,6 +1212,7 @@ def add_image( outline=outline, ambient_occlusion=ambient_occlusion, clipping_planes=clipping_planes, + render_planes=render_planes if key == "vol" else (), ) ) self._record_controls(visuals, controls, name) @@ -1180,6 +1229,7 @@ def add_labels( ambient_occlusion: bool | None = None, outline_selected_labels: dict[int, int] | None = None, clipping_planes: Sequence[ClippingPlane] = (), + render_planes: Sequence[RenderPlane] = (), ) -> dict[str, LabelMemoryVisual]: """Add an in-memory label image to every panel from one data store. @@ -1219,6 +1269,16 @@ def add_labels( Build them from ``data.data_coordinate_systems[0]``. They can be changed later by assigning ``visual.clipping_planes``. Default none. + render_planes : Sequence[RenderPlane] + The planes the ``"plane"`` render mode draws the data on, in + the scene's world coordinates + (``RenderPlane.from_point_normal(viewer.scene.dims. + world_coordinate_system, ...)``). Ignored in every other + render mode. They can be changed later with + ``controller.set_render_planes``. Default none. + Given to the 3D panel's visual only: the 2D panels show + their slices. Replaced by the slice planes while + ``plane_controller.mode`` is ``"slices"``. Returns ------- @@ -1237,6 +1297,7 @@ def add_labels( ambient_occlusion=ambient_occlusion, outline_selected_labels=outline_selected_labels, clipping_planes=clipping_planes, + render_planes=render_planes if key == "vol" else (), ) ) self._record_controls(visuals, controls, name) @@ -1618,6 +1679,7 @@ def add_image_multiscale( outline: VisualOutline | None = None, ambient_occlusion: bool | None = None, clipping_planes: Sequence[ClippingPlane] = (), + render_planes: Sequence[RenderPlane] = (), ) -> dict[str, MultiscaleImageVisual]: """Add a multiscale image to every panel from a single data store. @@ -1660,6 +1722,16 @@ def add_image_multiscale( Build them from ``data.data_coordinate_systems[0]``. They can be changed later by assigning ``visual.clipping_planes``. Default none. + render_planes : Sequence[RenderPlane] + The planes the ``"plane"`` render mode draws the data on, in + the scene's world coordinates + (``RenderPlane.from_point_normal(viewer.scene.dims. + world_coordinate_system, ...)``). Ignored in every other + render mode. They can be changed later with + ``controller.set_render_planes``. Default none. + Given to the 3D panel's visual only: the 2D panels show + their slices. Replaced by the slice planes while + ``plane_controller.mode`` is ``"slices"``. Returns ------- @@ -1683,6 +1755,7 @@ def add_image_multiscale( outline=outline, ambient_occlusion=ambient_occlusion, clipping_planes=clipping_planes, + render_planes=render_planes if key == "vol" else (), ) ) self._record_controls(visuals, controls, name) @@ -1700,6 +1773,7 @@ def add_labels_multiscale( ambient_occlusion: bool | None = None, outline_selected_labels: dict[int, int] | None = None, clipping_planes: Sequence[ClippingPlane] = (), + render_planes: Sequence[RenderPlane] = (), ) -> dict[str, MultiscaleLabelVisual]: """Add a multiscale label image to every panel from one data store. @@ -1740,6 +1814,16 @@ def add_labels_multiscale( Build them from ``data.data_coordinate_systems[0]``. They can be changed later by assigning ``visual.clipping_planes``. Default none. + render_planes : Sequence[RenderPlane] + The planes the ``"plane"`` render mode draws the data on, in + the scene's world coordinates + (``RenderPlane.from_point_normal(viewer.scene.dims. + world_coordinate_system, ...)``). Ignored in every other + render mode. They can be changed later with + ``controller.set_render_planes``. Default none. + Given to the 3D panel's visual only: the 2D panels show + their slices. Replaced by the slice planes while + ``plane_controller.mode`` is ``"slices"``. Returns ------- @@ -1759,6 +1843,7 @@ def add_labels_multiscale( ambient_occlusion=ambient_occlusion, outline_selected_labels=outline_selected_labels, clipping_planes=clipping_planes, + render_planes=render_planes if key == "vol" else (), ) ) self._record_controls(visuals, controls, name) @@ -2028,9 +2113,89 @@ def add_clipping_plane_gizmo( getattr(plane, "id", plane), ) - def remove_clipping_plane_gizmo(self) -> None: - """Close the 3D panel's clipping plane gizmo; nothing if it has none.""" - self._controller.remove_clipping_plane_gizmo(self._panel_canvas("vol")) + def add_render_plane_gizmo( + self, visual: object, plane: RenderPlane | UUID + ) -> RenderPlaneGizmoController: + """Put a gizmo on one render plane of a visual, in the 3D panel. + + Dragging the gizmo moves, turns and resizes the plane. Mirrors + :meth:`CellierController.add_render_plane_gizmo`: the 3D panel has + one gizmo at a time, clipping planes included. + + Parameters + ---------- + visual : UUID, visual model, or dict + Any panel's visual, or the dict an ``add_*`` method returned. + plane : RenderPlane or UUID + One of the 3D panel's visual's ``render_planes``, or its ``id``. + + Returns + ------- + RenderPlaneGizmoController + The session. ``close()`` ends it. + + Raises + ------ + KeyError + If the 3D panel's visual has no render plane with that id. + ValueError + If ``plane_controller.mode`` is ``"slices"`` (the slice planes + follow the 2D sliders and have no gizmo), the 3D panel has no + canvas yet, or its visual is not in the ``"plane"`` render mode. + """ + if self._plane_controller.mode == "slices": + raise ValueError( + "The 3D panel's render planes are slice planes " + '(plane_controller.mode is "slices"): they follow the 2D ' + "views' sliders and have no gizmo. Set " + "plane_controller.mode = None to edit them freely." + ) + group = self.image_group(visual) + return self._controller.add_render_plane_gizmo( + group[_PANEL_KEYS.index("vol")], + self._panel_canvas("vol"), + getattr(plane, "id", plane), + ) + + def remove_plane_gizmo(self) -> None: + """Close the 3D panel's plane gizmo, of either kind; nothing if none.""" + self._controller.remove_plane_gizmo(self._panel_canvas("vol")) + + def _render_planes_target(self, visual_ids: Sequence[UUID]) -> object | None: + """What a dock's render planes control edits, and where its gizmo is. + + Asked by the layout walk when it builds the control. Only the 3D + panel draws render planes, so the control of the 2D views' group is + not built (``None``), and the 3D view's edits the 3D panel's visual + alone. In ``plane_controller.mode`` ``"slices"`` the control is + disabled with the reason: the planes follow the sliders. + """ + from cellier.convenience.layout._shared import RenderPlanesTarget + + vol_scene_id = self._scenes["vol"].id + vol_ids = [ + visual_id + for visual_id in visual_ids + if self._controller.get_visual_scene_id(visual_id) == vol_scene_id + ] + if not vol_ids: + return None + planes = self._plane_controller + controller = self._controller + + def blocked() -> str: + return planes.blocked_reason() or controller.render_planes_blocked( + vol_ids[0] + ) + + canvases = controller.get_canvas_ids(vol_scene_id) + return RenderPlanesTarget( + vol_ids, + canvases[0] if canvases else None, + blocked, + # A bound method is held weakly: the control can go away. + planes.mode_changed.connect, + ) def _clipping_gizmo_target( self, visual_ids: Sequence[UUID] diff --git a/src/cellier/convenience/_viewer.py b/src/cellier/convenience/_viewer.py index 431e895b..796a48f1 100644 --- a/src/cellier/convenience/_viewer.py +++ b/src/cellier/convenience/_viewer.py @@ -25,7 +25,10 @@ from PySide6.QtWidgets import QWidget from cellier._state import CameraState - from cellier.clipping import ClippingPlaneGizmoController + from cellier.clipping import ( + ClippingPlaneGizmoController, + RenderPlaneGizmoController, + ) from cellier.convenience.gui._controls_config import ( GraphControlsConfig, InMemoryImageControlsConfig, @@ -54,7 +57,7 @@ from cellier.scene._background import BackgroundAppearance from cellier.scene.scene import Scene from cellier.transform import BaseTransform - from cellier.visuals import ClippingPlane + from cellier.visuals import ClippingPlane, RenderPlane from cellier.visuals._base_visual import VisualOutline from cellier.visuals._graph_memory import ( GraphAppearance, @@ -701,16 +704,60 @@ def add_clipping_plane_gizmo( _visual_id(visual), self._camera_canvas(canvas), getattr(plane, "id", plane) ) - def remove_clipping_plane_gizmo(self, *, canvas: UUID | None = None) -> None: - """Close a canvas's clipping plane gizmo; nothing if it has none. + def add_render_plane_gizmo( + self, + visual: object, + plane: RenderPlane | UUID, + *, + canvas: UUID | None = None, + ) -> RenderPlaneGizmoController: + """Put a gizmo on one render plane of a visual, in the 3D view. + + Dragging the gizmo moves and turns the plane, and its two in-plane + scale handles resize it (an axis with an unbounded side has none). + Mirrors :meth:`CellierController.add_render_plane_gizmo`: a canvas + has one gizmo at a time, clipping planes included, and the gizmo + closes itself when its plane or its visual is removed, the visual + leaves the ``"plane"`` render mode or the view leaves 3D. Parameters ---------- + visual : visual model or UUID + The visual the plane belongs to, in the ``"plane"`` render mode. + plane : RenderPlane or UUID + One of ``visual.render_planes``, or its ``id``. canvas : UUID or None One of :attr:`canvases`. ``None`` (default) is the viewer's single canvas. + + Returns + ------- + RenderPlaneGizmoController + The session. ``close()`` ends it. + + Raises + ------ + KeyError + If the visual has no render plane with that id. + ValueError + If the view is in 2D, the visual is not in plane mode, the + plane's axes are not the displayed ones, or *canvas* is omitted + while the viewer does not have exactly one canvas. """ - self._controller.remove_clipping_plane_gizmo(self._camera_canvas(canvas)) + return self._controller.add_render_plane_gizmo( + _visual_id(visual), self._camera_canvas(canvas), getattr(plane, "id", plane) + ) + + def remove_plane_gizmo(self, *, canvas: UUID | None = None) -> None: + """Close a canvas's plane gizmo, of either kind; nothing if it has none. + + Parameters + ---------- + canvas : UUID or None + One of :attr:`canvases`. ``None`` (default) is the viewer's + single canvas. + """ + self._controller.remove_plane_gizmo(self._camera_canvas(canvas)) def _clipping_gizmo_target( self, visual_ids: Sequence[UUID] @@ -751,6 +798,21 @@ def _clipping_gizmo_target( return None return visual_ids[0], canvas_ids[0] + def _render_planes_target(self, visual_ids: Sequence[UUID]) -> object: + """What a dock's render planes control edits, and where its gizmo is. + + Asked by the layout walk when it builds the control. The control + edits the visuals it is given. Its rows have a gizmo toggle when + the viewer has exactly one canvas, as for the clipping planes + control (:meth:`_clipping_gizmo_target`). + """ + from cellier.convenience.layout._shared import RenderPlanesTarget + + canvas_ids = self.canvases if "3d" in self._scene.render_modes else () + return RenderPlanesTarget( + list(visual_ids), canvas_ids[0] if len(canvas_ids) == 1 else None + ) + # ------------------------------------------------------------------ # Capture # ------------------------------------------------------------------ @@ -1235,6 +1297,7 @@ def add_image( ambient_occlusion: bool | None = None, pick_write: bool = True, clipping_planes: Sequence[ClippingPlane] = (), + render_planes: Sequence[RenderPlane] = (), ) -> ImageVisual: """Add an in-memory image visual. @@ -1278,6 +1341,13 @@ def add_image( Build them from ``data.data_coordinate_systems[0]``. They can be changed later by assigning ``visual.clipping_planes``. Default none. + render_planes : Sequence[RenderPlane] + The planes the ``"plane"`` render mode draws the data on, in + the scene's world coordinates + (``RenderPlane.from_point_normal(viewer.scene.dims. + world_coordinate_system, ...)``). Ignored in every other + render mode. They can be changed later with + ``controller.set_render_planes``. Default none. Returns ------- @@ -1299,6 +1369,7 @@ def add_image( ambient_occlusion=ambient_occlusion, pick_write=pick_write, clipping_planes=clipping_planes, + render_planes=render_planes, ) self._store_controls([visual.id], controls) return visual @@ -1316,6 +1387,7 @@ def add_labels( outline_selected_labels: dict[int, int] | None = None, outline_mode: OutlineMode = "per_label", clipping_planes: Sequence[ClippingPlane] = (), + render_planes: Sequence[RenderPlane] = (), ) -> LabelMemoryVisual: """Add an in-memory label visual. @@ -1367,6 +1439,13 @@ def add_labels( Build them from ``data.data_coordinate_systems[0]``. They can be changed later by assigning ``visual.clipping_planes``. Default none. + render_planes : Sequence[RenderPlane] + The planes the ``"plane"`` render mode draws the data on, in + the scene's world coordinates + (``RenderPlane.from_point_normal(viewer.scene.dims. + world_coordinate_system, ...)``). Ignored in every other + render mode. They can be changed later with + ``controller.set_render_planes``. Default none. Returns ------- @@ -1385,6 +1464,7 @@ def add_labels( outline_selected_labels=outline_selected_labels, outline_mode=outline_mode, clipping_planes=clipping_planes, + render_planes=render_planes, ) self._store_controls([visual.id], controls) return visual @@ -1785,6 +1865,7 @@ def add_image_multiscale( ambient_occlusion: bool | None = None, pick_write: bool = True, clipping_planes: Sequence[ClippingPlane] = (), + render_planes: Sequence[RenderPlane] = (), ) -> MultiscaleImageVisual: """Add a multiscale image visual. @@ -1826,6 +1907,13 @@ def add_image_multiscale( Build them from ``data.data_coordinate_systems[0]``. They can be changed later by assigning ``visual.clipping_planes``. Default none. + render_planes : Sequence[RenderPlane] + The planes the ``"plane"`` render mode draws the data on, in + the scene's world coordinates + (``RenderPlane.from_point_normal(viewer.scene.dims. + world_coordinate_system, ...)``). Ignored in every other + render mode. They can be changed later with + ``controller.set_render_planes``. Default none. Returns ------- @@ -1848,6 +1936,7 @@ def add_image_multiscale( ambient_occlusion=ambient_occlusion, pick_write=pick_write, clipping_planes=clipping_planes, + render_planes=render_planes, ) self._store_controls([visual.id], controls) return visual @@ -1866,6 +1955,7 @@ def add_labels_multiscale( outline_selected_labels: dict[int, int] | None = None, outline_mode: OutlineMode = "per_label", clipping_planes: Sequence[ClippingPlane] = (), + render_planes: Sequence[RenderPlane] = (), ) -> MultiscaleLabelVisual: """Add a multiscale label visual. @@ -1919,6 +2009,13 @@ def add_labels_multiscale( Build them from ``data.data_coordinate_systems[0]``. They can be changed later by assigning ``visual.clipping_planes``. Default none. + render_planes : Sequence[RenderPlane] + The planes the ``"plane"`` render mode draws the data on, in + the scene's world coordinates + (``RenderPlane.from_point_normal(viewer.scene.dims. + world_coordinate_system, ...)``). Ignored in every other + render mode. They can be changed later with + ``controller.set_render_planes``. Default none. Returns ------- @@ -1938,6 +2035,7 @@ def add_labels_multiscale( outline_selected_labels=outline_selected_labels, outline_mode=outline_mode, clipping_planes=clipping_planes, + render_planes=render_planes, ) self._store_controls([visual.id], controls) return visual diff --git a/src/cellier/convenience/gui/_appearance_widgets.py b/src/cellier/convenience/gui/_appearance_widgets.py index d2923723..f8e3d494 100644 --- a/src/cellier/convenience/gui/_appearance_widgets.py +++ b/src/cellier/convenience/gui/_appearance_widgets.py @@ -121,6 +121,28 @@ def _any_clipping_planes(spec: ControlSpec, visual_ids, controller=None, gizmo=N ) +def _any_render_planes(spec: ControlSpec, visual_ids, controller=None, target=None): + """The render planes control. + + *target* is the ``RenderPlanesTarget`` the viewer named: the canvas of + the gizmo toggle (``None`` for no toggle) and why the control is + disabled. The control reads the scene through the controller. + """ + from cellier.gui._render_planes import ( + get_render_plane_gizmo_data, + get_render_planes_data_from_visual, + ) + from cellier.gui.anywidget.visuals import AnywidgetRenderPlanesControls + + canvas_id = None if target is None else target.canvas_id + data = get_render_planes_data_from_visual( + controller, visual_ids[0], blocked=None if target is None else target.blocked + ) + if canvas_id is not None: + data.update(get_render_plane_gizmo_data(controller, visual_ids[0], canvas_id)) + return AnywidgetRenderPlanesControls(visual_ids, title=spec.title, **data) + + def _any_aabb(spec: ControlSpec, visual_ids, controller=None): from cellier.gui.anywidget.visuals import AnywidgetAABBWidget @@ -207,6 +229,7 @@ def _any_trail(spec: ControlSpec, visual_ids, controller=None): "loading_config": _any_loading_config, "mesh_section": _any_mesh_section, "clipping_planes": _any_clipping_planes, + "render_planes": _any_render_planes, "lod_config": _any_lod_config, "visual_outline": _any_visual_outline, "labels_outline": _any_labels_outline, diff --git a/src/cellier/convenience/gui/_appearance_widgets_qt.py b/src/cellier/convenience/gui/_appearance_widgets_qt.py index 463b64f0..bc460b22 100644 --- a/src/cellier/convenience/gui/_appearance_widgets_qt.py +++ b/src/cellier/convenience/gui/_appearance_widgets_qt.py @@ -116,6 +116,28 @@ def _qt_clipping_planes(spec, visual_ids, controller, gizmo=None): ) +def _qt_render_planes(spec, visual_ids, controller, target=None): + """The render planes control. + + *target* is the ``RenderPlanesTarget`` the viewer named: the canvas of + the gizmo toggle (``None`` for no toggle) and why the control is + disabled. The control reads the scene through the controller. + """ + from cellier.gui._render_planes import ( + get_render_plane_gizmo_data, + get_render_planes_data_from_visual, + ) + from cellier.gui.qt.visuals import QtRenderPlanesControls + + canvas_id = None if target is None else target.canvas_id + data = get_render_planes_data_from_visual( + controller, visual_ids[0], blocked=None if target is None else target.blocked + ) + if canvas_id is not None: + data.update(get_render_plane_gizmo_data(controller, visual_ids[0], canvas_id)) + return QtRenderPlanesControls(visual_ids, title=spec.title, **data) + + def _qt_aabb(spec, visual_ids, controller): from cellier.gui.qt.visuals import QtAABBWidget @@ -216,6 +238,7 @@ def _qt_trail(spec, visual_ids, controller): "loading_config": _qt_loading_config, "mesh_section": _qt_mesh_section, "clipping_planes": _qt_clipping_planes, + "render_planes": _qt_render_planes, "lod_config": _qt_lod_config, "visual_outline": _qt_visual_outline, "labels_outline": _qt_labels_outline, diff --git a/src/cellier/convenience/gui/_controls_config.py b/src/cellier/convenience/gui/_controls_config.py index c08f9f43..35f700a2 100644 --- a/src/cellier/convenience/gui/_controls_config.py +++ b/src/cellier/convenience/gui/_controls_config.py @@ -252,6 +252,14 @@ class InMemoryImageControlsConfig(BaseControlsConfig): (the handle and range labels) and the iso threshold, on the single page and on every channel. Default 2. Set it to suit the data, e.g. 0 for integer images; fractions such as opacity always show 2. + render_plane_controls : bool + Show the visual's render planes control: a row per plane of the + ``"plane"`` render mode (on or off, the normal, a position slider, + the extents) and a button to add one. ``False`` (default) omits + it. Values are in world units. It is part of the appearance dock + and is disabled, with the reason shown, while the visual is not in + plane mode or the view is 2D. On an ``OrthoViewer`` it is shown + for the 3D view only. """ APPEARANCE_CONTROLS: ClassVar[dict[str, str]] = { @@ -267,6 +275,7 @@ class InMemoryImageControlsConfig(BaseControlsConfig): clim_range: tuple[float, float] | None = None channel_labels: dict[int, str] | None = None decimals: int = 2 + render_plane_controls: bool = False def __post_init__(self) -> None: """Check ``decimals`` as well as ``appearance``.""" @@ -346,6 +355,9 @@ class LabelsControlsConfig(BaseControlsConfig): ``colormap_mode`` is deliberately absent: it is ``frozen=True`` on ``BaseLabelsAppearance``, so a control wired to it could only raise (design section 6.5.1 proposal 4). + + ``render_plane_controls`` (``False`` by default) shows the visual's + render planes control, as on :class:`InMemoryImageControlsConfig`. """ APPEARANCE_CONTROLS: ClassVar[dict[str, str]] = { @@ -355,6 +367,8 @@ class LabelsControlsConfig(BaseControlsConfig): "background_label": "background_label", } + render_plane_controls: bool = False + @dataclass class MultiscaleLabelsControlsConfig(LabelsControlsConfig): diff --git a/src/cellier/convenience/layout/_shared.py b/src/cellier/convenience/layout/_shared.py index baf876bc..711fba7b 100644 --- a/src/cellier/convenience/layout/_shared.py +++ b/src/cellier/convenience/layout/_shared.py @@ -4,7 +4,7 @@ import warnings from dataclasses import dataclass -from typing import NamedTuple +from typing import Any, NamedTuple from uuid import UUID from cellier.gui._clipping_planes import ( @@ -19,6 +19,7 @@ from cellier.gui._lod import LOD_CONFIG_TITLE from cellier.gui._mesh_section import MESH_SECTION_TITLE from cellier.gui._render_controls import VISUAL_RENDER_TITLES +from cellier.gui._render_planes import RENDER_PLANES_TITLE # ── Appearance controls: the toolkit-neutral decision layer ────────────────── # @@ -89,6 +90,7 @@ class AppearanceSpecs(NamedTuple): "loading_config": LOADING_CONFIG_TITLE, "mesh_section": MESH_SECTION_TITLE, "clipping_planes": CLIPPING_PLANES_TITLE, + "render_planes": RENDER_PLANES_TITLE, "lod_config": LOD_CONFIG_TITLE, # Read rather than restated: the per-visual groups name themselves in # the shared control spec, beside the controls they hold. @@ -108,6 +110,31 @@ class AppearanceSpecs(NamedTuple): """ +class RenderPlanesTarget(NamedTuple): + """What a dock's render planes control edits, as its viewer names it. + + Parameters + ---------- + visual_ids : list[UUID] + The visuals whose ``render_planes`` the control edits. + canvas_id : UUID or None + The 3D canvas its gizmo toggle draws in. ``None`` gives the rows + no toggle. + blocked : Callable[[], str] or None + Why the control is disabled now, or ``""``. ``None`` uses the + controller's answer: the visual is not in plane mode, or the view + is 2D. + watch : Callable[[Callable[[], None]], None] or None + Given the control's ``refresh``, arranges for it to be called when + what *blocked* reads may have changed. + """ + + visual_ids: list + canvas_id: Any = None + blocked: Any = None + watch: Any = None + + _CONTROL_NEEDS_FIELD = {"level_of_detail": "settled_lod_bias"} """Appearance entries that are a control, not a field, and the model field whose presence says the visual can be driven by it.""" @@ -354,6 +381,14 @@ def _image_values(): ) ) + # The planes of the "plane" render mode: a row per plane. Opt-in, and + # only for a visual that has the mode (an image or labels). The control + # reads the scene through the controller, so its builder fills it in. + if getattr(config, "render_plane_controls", False) and hasattr( + visual, "render_planes" + ): + specs.append(ControlSpec("render_planes", _CONTROL_TITLES["render_planes"], {})) + # A multiscale mesh's level-of-detail settings: what a dims scrub loads # and draws, and what a moving camera draws. Opt-in. lod = getattr(visual, "lod", None) @@ -921,6 +956,13 @@ def missing_dock_node( "AppearanceControls() to a dock of the Layout, or pass " "appearance=False." ) + if getattr(config, "render_plane_controls", False) and not appearance: + return ( + "render_plane_controls=True and no appearance controls. The " + "render planes control is part of the appearance dock: pass " + "appearance=True (or a list of fields) as well, and give the " + "Layout an AppearanceControls() dock." + ) if not getattr(config, "clipping_controls", False): return None if clipping_node == "AppearanceControls": diff --git a/src/cellier/convenience/layout/_walk.py b/src/cellier/convenience/layout/_walk.py index 000f9834..3f243d5c 100644 --- a/src/cellier/convenience/layout/_walk.py +++ b/src/cellier/convenience/layout/_walk.py @@ -159,6 +159,7 @@ def build_appearance_widgets( backend: object, clipping_gizmo_target: Callable[[list], tuple | None] | None = None, clipping_controls: bool = True, + render_planes_target: Callable[[list], object | None] | None = None, ) -> list: """Build and wire the appearance controls for *visual*, on any backend. @@ -175,9 +176,17 @@ def build_appearance_widgets( *clipping_controls* ``False`` builds no clipping planes control whatever the config says: on an ``OrthoViewer`` they are in the ``OrthoClippingControls()`` dock, one per link group, and not here. + + *render_planes_target* is how the viewer names what a render planes + control edits: given the control's visual ids it returns a + ``RenderPlanesTarget`` (the visuals whose planes it edits, the canvas of + its gizmo toggle, why it is disabled), or ``None`` for no control (an + ``OrthoViewer``'s 2D views). Without it the control edits every visual + of the group and has no gizmo toggle. """ from cellier.convenience.layout._shared import ( STATIC_CONTROL_KINDS, + RenderPlanesTarget, _resolve_data_store, appearance_specs, warn_skipped_appearance_fields, @@ -200,7 +209,18 @@ def build_appearance_widgets( if spec.kind == "clipping_planes" and not clipping_controls: continue builder = backend.builders.get(spec.kind) - if builder is not None and spec.kind == "clipping_planes": + if builder is not None and spec.kind == "render_planes": + target = ( + RenderPlanesTarget(list(visual_ids)) + if render_planes_target is None + else render_planes_target(list(visual_ids)) + ) + if target is None: + continue + widget = builder(spec, list(target.visual_ids), controller, target) + if target.watch is not None: + target.watch(widget.refresh) + elif builder is not None and spec.kind == "clipping_planes": gizmo = ( None if clipping_gizmo_target is None @@ -253,6 +273,7 @@ def build(target) -> list: backend=host.backend, clipping_gizmo_target=getattr(viewer, "_clipping_gizmo_target", None), clipping_controls=clipping_node == "AppearanceControls", + render_planes_target=getattr(viewer, "_render_planes_target", None), ) dock = ControlsDock( diff --git a/src/cellier/events/__init__.py b/src/cellier/events/__init__.py index e158611e..a4f1eab8 100644 --- a/src/cellier/events/__init__.py +++ b/src/cellier/events/__init__.py @@ -22,7 +22,6 @@ CanvasSizeChangedEvent, CellierEventTypes, ChannelAppearanceChangedEvent, - ClippingPlaneGizmoChangedEvent, ClippingPlanesChangedEvent, DataStoreContentsChangedEvent, DataStoreMetadataChangedEvent, @@ -48,6 +47,7 @@ OverlayChangedEvent, PickEvent, PickWriteChangedEvent, + PlaneGizmoChangedEvent, PlaneGizmoMovedEvent, PlaneInteractionEvent, PointsPickEvent, @@ -77,7 +77,6 @@ BackgroundUpdateEvent, CellierUpdateEventTypes, ChannelAppearanceUpdateEvent, - ClippingPlaneGizmoUpdateEvent, ClippingPlanesUpdateEvent, DimsInteractionUpdateEvent, DimsUpdateEvent, @@ -86,6 +85,7 @@ LodConfigUpdateEvent, MeshSectionUpdateEvent, OverlayUpdateEvent, + PlaneGizmoUpdateEvent, RenderConfigUpdateEvent, RenderPlanesUpdateEvent, SingleAppearanceUpdateEvent, @@ -121,8 +121,6 @@ "CellierUpdateEventTypes", "ChannelAppearanceChangedEvent", "ChannelAppearanceUpdateEvent", - "ClippingPlaneGizmoChangedEvent", - "ClippingPlaneGizmoUpdateEvent", "ClippingPlanesChangedEvent", "ClippingPlanesUpdateEvent", "DataStoreContentsChangedEvent", @@ -158,7 +156,9 @@ "OverlayUpdateEvent", "PickEvent", "PickWriteChangedEvent", + "PlaneGizmoChangedEvent", "PlaneGizmoMovedEvent", + "PlaneGizmoUpdateEvent", "PlaneInteractionEvent", "PointsPickEvent", "PointsPickInfo", diff --git a/src/cellier/events/_bus.py b/src/cellier/events/_bus.py index 2633fe65..e0cab10d 100644 --- a/src/cellier/events/_bus.py +++ b/src/cellier/events/_bus.py @@ -24,7 +24,6 @@ CanvasMouseRelease2DEvent, CanvasMouseRelease3DEvent, ChannelAppearanceChangedEvent, - ClippingPlaneGizmoChangedEvent, ClippingPlanesChangedEvent, DataStoreContentsChangedEvent, DataStoreMetadataChangedEvent, @@ -42,6 +41,7 @@ MeshSectionChangedEvent, OverlayChangedEvent, PickWriteChangedEvent, + PlaneGizmoChangedEvent, PlaneGizmoMovedEvent, PlaneInteractionEvent, PointsPickEvent, @@ -89,7 +89,7 @@ ClippingPlanesChangedEvent: "visual_id", RenderPlanesChangedEvent: "visual_id", PlaneInteractionEvent: "visual_id", - ClippingPlaneGizmoChangedEvent: "canvas_id", + PlaneGizmoChangedEvent: "canvas_id", PlaneGizmoMovedEvent: "gizmo_id", ResliceCancelledEvent: "visual_id", FrameRenderedEvent: "canvas_id", @@ -530,7 +530,7 @@ def get_subscribers( ``ResliceCancelledEvent`` - Canvas (keyed by ``canvas_id``) — ``FrameRenderedEvent``, ``CanvasConnectedEvent``, - ``ClippingPlaneGizmoChangedEvent`` + ``PlaneGizmoChangedEvent`` - Data store (keyed by ``data_store_id``) — ``DataStoreMetadataChangedEvent``, ``DataStoreContentsChangedEvent`` diff --git a/src/cellier/events/_events.py b/src/cellier/events/_events.py index 908db8fc..02af6e6b 100644 --- a/src/cellier/events/_events.py +++ b/src/cellier/events/_events.py @@ -444,11 +444,19 @@ class PlaneGizmoMovedEvent(NamedTuple): phase : str ``"start"``, ``"move"`` or ``"end"``. handle_kind : str - ``"translate"`` or ``"rotate"``: the handle being dragged. + ``"translate"``, ``"rotate"`` or ``"scale"``: the handle being + dragged. Only a gizmo on a render plane shows scale handles. handle_axis : int or tuple[int, int] The axis of the handle in the gizmo's own frame, where axis 0 is - the normal: the axis a one-axis handle moves along or turns about, - or the pair of axes a two-axis translate handle moves in. + the normal and axes 1 and 2 are the in-plane axes: the axis a + one-axis handle moves along, turns about or scales, or the pair of + axes a two-axis translate handle moves in. + in_plane_axis_0, in_plane_axis_1 : tuple[float, float, float] + The gizmo's two in-plane axes, unit vectors. With ``normal`` they + are the gizmo's whole frame: ``normal`` is their cross product. + scale : tuple[float, float] + The gizmo's scale along the two in-plane axes: 1.0 unless a scale + handle is held, when it is the factor of the drag so far. """ source_id: UUID @@ -459,6 +467,9 @@ class PlaneGizmoMovedEvent(NamedTuple): phase: str handle_kind: str handle_axis: Any + in_plane_axis_0: tuple[float, float, float] = (0.0, 1.0, 0.0) + in_plane_axis_1: tuple[float, float, float] = (0.0, 0.0, 1.0) + scale: tuple[float, float] = (1.0, 1.0) class ClippingPlanesChangedEvent(NamedTuple): @@ -545,13 +556,14 @@ class PlaneInteractionEvent(NamedTuple): reason: Literal["release", "settle", "jump", "cancel"] | None = None -class ClippingPlaneGizmoChangedEvent(NamedTuple): - """The plane a canvas's clipping plane gizmo edits changed. +class PlaneGizmoChangedEvent(NamedTuple): + """The plane a canvas's plane gizmo edits changed. - A canvas has at most one clipping plane gizmo. Emitted when one is - added, when it is replaced by one on another plane, and when it is - closed, by its owner or by itself (its plane or visual was removed, the - canvas left 3D). + A canvas has at most one plane gizmo, on a clipping plane or on a + render plane. Emitted when one is added, when it is replaced by one on + another plane, and when it is closed, by its owner or by itself (its + plane or visual was removed, the canvas left 3D, a render plane's + visual left plane mode). Parameters ---------- @@ -563,6 +575,9 @@ class ClippingPlaneGizmoChangedEvent(NamedTuple): visual_id : UUID or None The visual whose plane the gizmo edits; ``None`` when the canvas has no gizmo. + kind : {"clipping", "render"} or None + Which of the visual's tuples the plane is in: ``clipping_planes`` + or ``render_planes``. ``None`` when the canvas has no gizmo. plane_id : UUID or None That plane's id; ``None`` when the canvas has no gizmo. """ @@ -570,6 +585,7 @@ class ClippingPlaneGizmoChangedEvent(NamedTuple): source_id: UUID canvas_id: UUID visual_id: UUID | None = None + kind: Literal["clipping", "render"] | None = None plane_id: UUID | None = None @@ -1600,7 +1616,7 @@ class _CanvasRawPointerEvent(NamedTuple): | ClippingPlanesChangedEvent | RenderPlanesChangedEvent | PlaneInteractionEvent - | ClippingPlaneGizmoChangedEvent + | PlaneGizmoChangedEvent | PlaneGizmoMovedEvent | ResliceCancelledEvent | FrameRenderedEvent diff --git a/src/cellier/events/_update_events.py b/src/cellier/events/_update_events.py index 67c52e0d..770cf1d5 100644 --- a/src/cellier/events/_update_events.py +++ b/src/cellier/events/_update_events.py @@ -421,20 +421,22 @@ class RenderPlanesUpdateEvent(NamedTuple): render_planes: Any -class ClippingPlaneGizmoUpdateEvent(NamedTuple): - """Request to put a canvas's clipping plane gizmo on a plane, or close it. +class PlaneGizmoUpdateEvent(NamedTuple): + """Request to put a canvas's plane gizmo on a plane, or close it. Fields ------ source_id : - Caller's UUID. Stamped on the outgoing - ``ClippingPlaneGizmoChangedEvent``. + Caller's UUID. Stamped on the outgoing ``PlaneGizmoChangedEvent``. visual_id : The visual the plane belongs to. plane_id : - The ``id`` of the ``ClippingPlane``. + The ``id`` of the ``ClippingPlane`` or ``RenderPlane``. canvas_id : The 3D canvas to draw the gizmo in. + kind : + ``"clipping"`` or ``"render"``: which of the visual's tuples the + plane is in. enabled : ``True`` opens a gizmo on the plane, replacing the canvas's current one. ``False`` closes the canvas's gizmo if it is on this plane. @@ -444,6 +446,7 @@ class ClippingPlaneGizmoUpdateEvent(NamedTuple): visual_id: UUID plane_id: UUID canvas_id: UUID + kind: str enabled: bool = True @@ -489,7 +492,7 @@ class TrailUpdateEvent(NamedTuple): | LodConfigUpdateEvent | ClippingPlanesUpdateEvent | RenderPlanesUpdateEvent - | ClippingPlaneGizmoUpdateEvent + | PlaneGizmoUpdateEvent | TrailUpdateEvent ) diff --git a/src/cellier/gui/__init__.py b/src/cellier/gui/__init__.py index 93cedefd..bacc1ee4 100644 --- a/src/cellier/gui/__init__.py +++ b/src/cellier/gui/__init__.py @@ -5,7 +5,8 @@ helpers read and write the RGBA tuples cellier's models use, and the clipping planes helpers read what a clipping planes control of either toolkit is built from (its planes, its store's bounds, and where its gizmo -is drawn). +is drawn); the render planes helpers do the same for a render planes +control. """ from cellier.gui._appearance_fields import as_rgba, hex_to_rgba, rgba_to_hex @@ -15,13 +16,21 @@ get_clipping_plane_gizmo_data, get_clipping_planes_data_from_visual, ) +from cellier.gui._render_planes import ( + RenderPlaneGizmoTarget, + get_render_plane_gizmo_data, + get_render_planes_data_from_visual, +) __all__ = [ "ClippingPlaneGizmoTarget", + "RenderPlaneGizmoTarget", "as_rgba", "get_axis_bounds_from_store", "get_clipping_plane_gizmo_data", "get_clipping_planes_data_from_visual", + "get_render_plane_gizmo_data", + "get_render_planes_data_from_visual", "hex_to_rgba", "rgba_to_hex", ] diff --git a/src/cellier/gui/_appearance_fields.py b/src/cellier/gui/_appearance_fields.py index 64122a67..a7fbee61 100644 --- a/src/cellier/gui/_appearance_fields.py +++ b/src/cellier/gui/_appearance_fields.py @@ -228,26 +228,12 @@ def _emit_group(self, event_cls: type, field: str, value: Any) -> None: """ -UNOFFERED_FIELD_VALUES: dict[str, frozenset[str]] = { - "render_mode": frozenset({"plane"}), -} -"""Literal values the models accept that no control offers yet. - -``"plane"`` is a render mode on the image and labels models (plane rendering -design v3), settable from code. Its place in the render-mode combos comes -with the render planes control (design 9.1), which hides the rows that mean -nothing in it and moves a composite's channels together. Until then a combo -offering it would offer a mode it cannot set up. -""" - - def literal_choices(appearance: Any, field: str) -> tuple[str, ...]: """Return the values *field* admits on *appearance*'s model, user-facing only. Reads the field's ``Literal`` annotation, so the in-memory and multiscale variants of a field need no separate lists -- each model answers for - itself. Debug-only values are dropped per :data:`DEBUG_FIELD_VALUES`, - and values with no control yet per :data:`UNOFFERED_FIELD_VALUES`. + itself. Debug-only values are dropped per :data:`DEBUG_FIELD_VALUES`. Returns an empty tuple when the field is not a ``Literal``. """ @@ -255,9 +241,7 @@ def literal_choices(appearance: Any, field: str) -> tuple[str, ...]: if not model_fields or field not in model_fields: return () args = get_args(model_fields[field].annotation) - denied = DEBUG_FIELD_VALUES.get(field, frozenset()) | UNOFFERED_FIELD_VALUES.get( - field, frozenset() - ) + denied = DEBUG_FIELD_VALUES.get(field, frozenset()) return tuple(arg for arg in args if isinstance(arg, str) and arg not in denied) diff --git a/src/cellier/gui/_clipping_planes.py b/src/cellier/gui/_clipping_planes.py index 6c374b3d..9786ff11 100644 --- a/src/cellier/gui/_clipping_planes.py +++ b/src/cellier/gui/_clipping_planes.py @@ -13,7 +13,7 @@ A row can also carry a "Gizmo" toggle, which puts a gizmo on that plane in the viewer's 3D canvas. A canvas has one gizmo, so the toggles of every control that names the canvas behave as one set of radio buttons: the -controller says which plane has it (``ClippingPlaneGizmoChangedEvent``) and +controller says which plane has it (``PlaneGizmoChangedEvent``) and each control draws that. A row is plain data, so the anywidget control syncs the whole list in one @@ -36,12 +36,12 @@ from uuid import UUID, uuid4 from cellier.events import ( - ClippingPlaneGizmoChangedEvent, - ClippingPlaneGizmoUpdateEvent, ClippingPlanesChangedEvent, ClippingPlanesUpdateEvent, DataStoreMetadataChangedEvent, DimsChangedEvent, + PlaneGizmoChangedEvent, + PlaneGizmoUpdateEvent, SubscriptionSpec, ) from cellier.gui._loading import error_message @@ -323,8 +323,12 @@ def get_clipping_plane_gizmo_data( f"{controller.get_scene(scene_id).name!r}, the scene of visual " f"{visual_id}." ) - session = controller.get_clipping_plane_gizmo(canvas_id) - on_this = session is not None and session.visual_id == visual_id + session = controller.get_plane_gizmo(canvas_id) + on_this = ( + session is not None + and session.kind == "clipping" + and session.visual_id == visual_id + ) def blocked(plane_id: str) -> str: return controller.clipping_plane_gizmo_blocked( @@ -440,7 +444,7 @@ def subscription_specs(self) -> list[SubscriptionSpec]: And the store's ``DataStoreMetadataChangedEvent``, when the control was given a way to read the store's bounds. With a gizmo toggle: - the canvas's ``ClippingPlaneGizmoChangedEvent`` and the scene's + the canvas's ``PlaneGizmoChangedEvent`` and the scene's ``DimsChangedEvent``. """ specs = [ @@ -461,7 +465,7 @@ def subscription_specs(self) -> list[SubscriptionSpec]: _visual_id, canvas_id, scene_id = self._gizmo specs.append( SubscriptionSpec( - ClippingPlaneGizmoChangedEvent, + PlaneGizmoChangedEvent, self.on_gizmo_changed, entity_id=canvas_id, ) @@ -578,7 +582,7 @@ def set_component(self, index: int, axis: int, value: float) -> None: def set_gizmo(self, index: int, enabled: bool) -> None: """Put the canvas's gizmo on plane *index*, or take it off. - Sends a ``ClippingPlaneGizmoUpdateEvent``. The toggle is drawn from + Sends a ``PlaneGizmoUpdateEvent``. The toggle is drawn from what the controller answers (:meth:`on_gizmo_changed`), not from the click: a refused request leaves it off, with the reason shown. """ @@ -588,11 +592,12 @@ def set_gizmo(self, index: int, enabled: bool) -> None: error = "" try: self._emit( - ClippingPlaneGizmoUpdateEvent( + PlaneGizmoUpdateEvent( source_id=self._source_id, visual_id=visual_id, plane_id=UUID(self.rows[index]["id"]), canvas_id=canvas_id, + kind="clipping", enabled=bool(enabled), ) ) @@ -610,12 +615,14 @@ def on_changed(self, event: ClippingPlanesChangedEvent) -> None: self.rows = rows self._show(list(self.rows), "") - def on_gizmo_changed(self, event: ClippingPlaneGizmoChangedEvent) -> None: + def on_gizmo_changed(self, event: PlaneGizmoChangedEvent) -> None: """The canvas's gizmo moved to another plane, or closed: show it.""" if self._gizmo is None: return on_this = ( - event.visual_id == self._gizmo.visual_id and event.plane_id is not None + event.kind == "clipping" + and event.visual_id == self._gizmo.visual_id + and event.plane_id is not None ) plane = str(event.plane_id) if on_this else None if plane == self.gizmo_plane: diff --git a/src/cellier/gui/_render_planes.py b/src/cellier/gui/_render_planes.py new file mode 100644 index 00000000..08d52860 --- /dev/null +++ b/src/cellier/gui/_render_planes.py @@ -0,0 +1,898 @@ +"""The "Render planes" control of a visual, decided once for both toolkits. + +A visual in the ``"plane"`` render mode draws its data on a tuple of +:class:`~cellier.visuals.RenderPlane`, in the scene's world coordinates. +The Qt and anywidget controls (``QtRenderPlanesControls``, +``AnywidgetRenderPlanesControls``) draw one row per plane, as close to the +"Clipping planes" control as they can be (plane rendering design v3, 9.2): +an enabled checkbox, the normal (one entry per axis of the plane, under that +axis's name, with a button to face the plane along either direction of an +axis), a flip button, a position slider along the normal, a remove button, +and what a clipping plane has not: the extents, four fields with an +"unbounded" box each. An add button appends a plane. + +A row is plain data, so the anywidget control syncs the whole list in one +trait and constructs nothing when a plane is added:: + + { + "id": "0f6c...", + "enabled": True, + "axes": ["z", "y", "x"], + "normal": [1.0, 0.0, 0.0], + "position": 12.0, + "extent_0": [None, None], + "extent_1": [-20.0, 20.0], + } + +``normal`` and ``position`` are on the plane's own three world axes, named +by ``axes``; ``position`` is where the plane sits along its unit normal. +``extent_0`` and ``extent_1`` are ``[min, max]`` distances from the plane's +origin along its two in-plane axes, in world units; ``None`` is unbounded. + +**The in-plane frame has no entry.** A row does not hold the plane: the +editor keeps the planes the model last reported and applies each edit to +them. A normal edit turns the frame about the plane's origin by the +smallest rotation and so keeps its spin; a position edit moves the origin +along the normal and keeps its in-plane coordinates. The spin is set with +the gizmo's ring or from code. + +Nothing here imports a toolkit. +""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING, Any, NamedTuple +from uuid import UUID + +import numpy as np + +from cellier.events import ( + AppearanceChangedEvent, + ChannelAppearanceChangedEvent, + DataStoreMetadataChangedEvent, + DimsChangedEvent, + ImageCompositeChangedEvent, + PlaneGizmoChangedEvent, + PlaneGizmoUpdateEvent, + RenderPlanesChangedEvent, + RenderPlanesUpdateEvent, + SingleAppearanceChangedEvent, + SubscriptionSpec, + TransformChangedEvent, +) +from cellier.gui._clipping_planes import facing_of, position_range +from cellier.gui._loading import error_message +from cellier.visuals._render_plane import MAX_RENDER_PLANES, RenderPlane + +if TYPE_CHECKING: + from collections.abc import Callable, Iterable, Mapping, Sequence + +RENDER_PLANES_TITLE = "Render planes" +"""The control's name: its ``DEFAULT_TITLE`` on both toolkits.""" + +RENDER_PLANE_GIZMO_TOOLTIP = ( + "Drag a gizmo in the 3D view to move, turn and resize this plane." +) +"""The gizmo toggle's tooltip while it can be used.""" + +Row = dict[str, Any] +Bounds = list[list[float]] + + +# --------------------------------------------------------------------------- # +# Planes, edited # +# --------------------------------------------------------------------------- # + + +def _unit_normal(plane: RenderPlane) -> np.ndarray: + normal = np.cross(plane.in_plane_axis_0, plane.in_plane_axis_1) + return normal / np.linalg.norm(normal) + + +def _floats(vector: Iterable[float]) -> tuple[float, ...]: + return tuple(float(v) for v in vector) + + +def with_position(plane: RenderPlane, position: float) -> RenderPlane: + """*plane* moved along its normal to sit at *position*. + + The origin keeps its in-plane coordinates. + """ + normal = _unit_normal(plane) + origin = np.asarray(plane.origin, dtype=np.float64) + moved = origin + (float(position) - float(origin @ normal)) * normal + return plane.model_copy(update={"origin": _floats(moved)}) + + +def with_normal(plane: RenderPlane, normal: Sequence[float]) -> RenderPlane: + """*plane* turned about its origin so its normal is *normal*. + + The frame is turned by the smallest rotation that takes the old normal + to the new one, so its spin about the normal is kept. An opposite + normal is a half turn about ``in_plane_axis_0``. + + Raises + ------ + ValueError + If *normal* is zero or not finite. + """ + target = np.asarray([float(v) for v in normal], dtype=np.float64) + length = float(np.linalg.norm(target)) + if not math.isfinite(length) or length == 0.0: + raise ValueError("A normal must not be all zero.") + target = target / length + current = _unit_normal(plane) + axis_0 = np.asarray(plane.in_plane_axis_0, dtype=np.float64) + cosine = float(np.clip(current @ target, -1.0, 1.0)) + if cosine > 1.0 - 1e-12: + return plane + if cosine < -1.0 + 1e-12: + new_0 = axis_0 # the half turn's own axis + else: + # Rodrigues, about the axis that is perpendicular to both normals. + axis = np.cross(current, target) + sine = float(np.linalg.norm(axis)) + axis = axis / sine + new_0 = ( + axis_0 * cosine + + np.cross(axis, axis_0) * sine + + axis * float(axis @ axis_0) * (1.0 - cosine) + ) + new_0 = new_0 - float(new_0 @ target) * target + new_0 = new_0 / np.linalg.norm(new_0) + new_1 = np.cross(target, new_0) + return RenderPlane( + **{ + **plane.model_dump(), + "in_plane_axis_0": _floats(new_0), + "in_plane_axis_1": _floats(new_1), + } + ) + + +def flipped(plane: RenderPlane) -> RenderPlane: + """*plane* with its normal reversed and nothing drawn changed. + + ``in_plane_axis_1`` is reversed and its extent mirrored with it, so the + rectangle stays where it is (design 9.2, P15). + """ + low, high = plane.extent_1 + return plane.model_copy( + update={ + "in_plane_axis_1": _floats(-np.asarray(plane.in_plane_axis_1)), + "extent_1": ( + None if high is None else -float(high), + None if low is None else -float(low), + ), + } + ) + + +def box_cut( + plane: RenderPlane, bounds: Sequence[Sequence[float]] +) -> tuple[tuple[float, float], tuple[float, float]] | None: + """Where the data box cuts a plane, along the plane's two in-plane axes. + + Parameters + ---------- + plane : RenderPlane + The plane. + bounds : Sequence[Sequence[float]] + ``(low, high)`` of the box on each of the plane's three axes. + + Returns + ------- + tuple or None + ``((min_0, max_0), (min_1, max_1))``: the span of the polygon the + box cuts out of the plane, as distances from the plane's origin + along ``in_plane_axis_0`` and ``in_plane_axis_1``. ``None`` when + the plane misses the box. + """ + box = np.asarray(bounds, dtype=np.float64) + if box.shape != (3, 2) or not np.all(np.isfinite(box)): + return None + origin = np.asarray(plane.origin, dtype=np.float64) + normal = _unit_normal(plane) + corners = np.array( + [ + [box[0, i], box[1, j], box[2, k]] + for i in (0, 1) + for j in (0, 1) + for k in (0, 1) + ] + ) + side = (corners - origin) @ normal + points = [] + for a in range(8): + for b in range(a + 1, 8): + if bin(a ^ b).count("1") != 1: + continue # not an edge of the box + if side[a] == 0.0: + points.append(corners[a]) + if side[b] == 0.0: + points.append(corners[b]) + if side[a] * side[b] < 0.0: + t = side[a] / (side[a] - side[b]) + points.append(corners[a] + t * (corners[b] - corners[a])) + if not points: + return None + relative = np.asarray(points) - origin + along_0 = relative @ np.asarray(plane.in_plane_axis_0) + along_1 = relative @ np.asarray(plane.in_plane_axis_1) + return ( + (float(along_0.min()), float(along_0.max())), + (float(along_1.min()), float(along_1.max())), + ) + + +def with_side_bounded( + plane: RenderPlane, + axis: int, + side: int, + bounded: bool, + bounds: Sequence[Sequence[float]], +) -> RenderPlane: + """*plane* with one extent side made finite or unbounded. + + Making a side finite fills in where the data box cuts the plane on that + side, so the picture does not jump (design 9.2). When the plane misses + the box, or the cut would not leave ``min < max`` against the other + side, the side is put a little past the other one. + + Parameters + ---------- + plane : RenderPlane + The plane. + axis : {0, 1} + The in-plane axis. + side : {0, 1} + ``0`` the minimum, ``1`` the maximum. + bounded : bool + ``False`` clears the side to ``None``. + bounds : Sequence[Sequence[float]] + ``(low, high)`` of the data box on each of the plane's three axes. + """ + field = "extent_1" if axis else "extent_0" + extent = list(getattr(plane, field)) + if not bounded: + extent[side] = None + return plane.model_copy(update={field: tuple(extent)}) + box = np.asarray(bounds, dtype=np.float64) + diagonal = float(np.linalg.norm(box[:, 1] - box[:, 0])) if box.size else 1.0 + if not math.isfinite(diagonal) or diagonal <= 0.0: + diagonal = 1.0 + cut = box_cut(plane, bounds) + value = (-0.5 if side == 0 else 0.5) * diagonal if cut is None else cut[axis][side] + other = extent[1 - side] + if other is not None: + gap = 0.01 * diagonal + if side == 0 and not value < other: + value = float(other) - gap + elif side == 1 and not value > other: + value = float(other) + gap + extent[side] = float(value) + return plane.model_copy(update={field: tuple(extent)}) + + +def with_side(plane: RenderPlane, axis: int, side: int, value: float) -> RenderPlane: + """*plane* with one bounded extent side set to *value*. + + Raises + ------ + ValueError + If the pair would not have ``min < max``, or *value* is not finite. + """ + field = "extent_1" if axis else "extent_0" + extent = list(getattr(plane, field)) + extent[side] = float(value) + return RenderPlane(**{**plane.model_dump(), field: tuple(extent)}) + + +# --------------------------------------------------------------------------- # +# Rows # +# --------------------------------------------------------------------------- # + + +def _extent_row(extent: Sequence[float | None]) -> list[float | None]: + return [None if side is None else float(side) for side in extent] + + +def rows_from_planes( + planes: Iterable[RenderPlane], axis_names: Mapping[str, str] | None = None +) -> list[Row]: + """The rows that show *planes* (a visual's ``render_planes``). + + Parameters + ---------- + planes : Iterable[RenderPlane] + The planes. + axis_names : Mapping[str, str] or None + World axis id (as a string) -> name. An axis not in it is shown as + it is written on the plane. + """ + names = axis_names or {} + rows = [] + for plane in planes: + normal = _unit_normal(plane) + rows.append( + { + "id": str(plane.id), + "enabled": bool(plane.enabled), + "axes": [names.get(str(axis), str(axis)) for axis in plane.axes], + # ``+ 0.0`` turns a negative zero into zero. + "normal": [float(v) + 0.0 for v in normal], + "position": float(np.asarray(plane.origin) @ normal), + "extent_0": _extent_row(plane.extent_0), + "extent_1": _extent_row(plane.extent_1), + } + ) + return rows + + +class RenderPlaneGizmoTarget(NamedTuple): + """Where the gizmo of a render planes control is drawn. + + Parameters + ---------- + visual_id : UUID + The visual whose plane the gizmo edits. + canvas_id : UUID + The 3D canvas the gizmo is drawn in. + scene_id : UUID + That canvas's scene. + """ + + visual_id: UUID + canvas_id: UUID + scene_id: UUID + + +def _plain_bounds(extent: Iterable[Sequence[float]]) -> Bounds: + """``[low, high]`` per axis; ``[0, 1]`` where the visual has no extent.""" + out = [] + for low, high in extent: + low, high = float(low), float(high) + out.append( + [low, high] if math.isfinite(low) and math.isfinite(high) else [0.0, 1.0] + ) + return out + + +def get_render_planes_data_from_visual( + controller: Any, + visual_id: UUID, + *, + blocked: Callable[[], str] | None = None, +) -> dict[str, Any]: + """What a render planes control is built with, read through the controller. + + Unlike a clipping planes control, a render planes control reads the + scene as well as the visual: its planes are in world coordinates, a new + one is put on the axes the 3D view displays, and the control is + disabled while the planes are not drawn. + + Parameters + ---------- + controller : CellierController + The controller the control is wired to. + visual_id : UUID + The visual. For a group of visuals edited together, the first. + blocked : Callable[[], str] or None + Why the control is disabled now, or ``""``. ``None`` uses + ``controller.render_planes_blocked(visual_id)``: the visual is not + in the ``"plane"`` render mode, or the view is 2D. + + Returns + ------- + dict[str, Any] + Keyword arguments of the control: ``coordinate_system`` (the world + system's id, as a string), ``axis_ids`` and ``axis_names`` (the + world axes, in order), ``planes`` (the visual's ``render_planes``), + ``scene_id``, ``data_store_id`` and ``state_source``, a reader of + ``{"blocked", "displayed_axes", "bounds"}`` as they are now. + + Raises + ------ + KeyError + If the visual is not registered. + """ + visual_id = UUID(str(visual_id)) + visual = controller.get_visual_model(visual_id) + scene_id = controller.get_visual_scene_id(visual_id) + world = controller.get_scene(scene_id).dims.world_coordinate_system + + def state() -> dict[str, Any]: + dims = controller.get_scene(scene_id).dims + return { + "blocked": ( + controller.render_planes_blocked(visual_id) + if blocked is None + else blocked() + ), + "displayed_axes": [ + str(world.axes[axis].id) for axis in dims.selection.displayed_axes + ], + "bounds": _plain_bounds(controller.visual_world_extent(visual_id)), + } + + return { + "coordinate_system": str(world.id), + "axis_ids": [str(axis.id) for axis in world.axes], + "axis_names": [str(name) for name in world.axis_names()], + "planes": tuple(visual.render_planes), + "scene_id": str(scene_id), + "data_store_id": str(visual.data_store_id), + "state_source": state, + } + + +def get_render_plane_gizmo_data( + controller: Any, visual_id: UUID, canvas_id: UUID +) -> dict[str, Any]: + """The gizmo arguments of a render planes control, for one visual and canvas. + + Selects nothing: the caller names the visual whose plane the gizmo + edits and the canvas it is drawn in, as for + ``CellierController.add_render_plane_gizmo``. The canvas must exist: + build the canvas before the control. + + Returns + ------- + dict[str, Any] + ``gizmo_target`` (a :class:`RenderPlaneGizmoTarget`), ``gizmo_plane`` + (the id of the render plane that has the canvas's gizmo now, as a + string, if it is one of this visual's; otherwise ``None``) and + ``gizmo_blocked`` (a reader of why a plane cannot have one, over + ``controller.render_plane_gizmo_blocked``). + + Raises + ------ + KeyError + If the visual or the canvas is not registered. + ValueError + If the canvas does not show the visual's scene. + """ + visual_id, canvas_id = UUID(str(visual_id)), UUID(str(canvas_id)) + scene_id = controller.get_visual_scene_id(visual_id) + if canvas_id not in controller.get_canvas_ids(scene_id): + controller.get_canvas_view(canvas_id) # KeyError for an unknown canvas + raise ValueError( + f"Canvas {canvas_id} does not show scene " + f"{controller.get_scene(scene_id).name!r}, the scene of visual " + f"{visual_id}." + ) + session = controller.get_plane_gizmo(canvas_id) + on_this = ( + session is not None + and session.kind == "render" + and session.visual_id == visual_id + ) + + def blocked(plane_id: str) -> str: + return controller.render_plane_gizmo_blocked( + visual_id, canvas_id, UUID(str(plane_id)) + ) + + return { + "gizmo_target": RenderPlaneGizmoTarget(visual_id, canvas_id, scene_id), + "gizmo_plane": str(session.plane_id) if on_this else None, + "gizmo_blocked": blocked, + } + + +# --------------------------------------------------------------------------- # +# The editor # +# --------------------------------------------------------------------------- # + + +class RenderPlanesEditor: + """The toolkit-neutral half of a render planes control. + + Holds the planes the model last reported, sends edits, and tells the + widget what to show. An edit is one ``RenderPlanesUpdateEvent`` per + visual, carrying the whole new tuple. If the controller refuses it, + the widget is told to show the last rows again, with the error. + + Parameters + ---------- + visual_ids : Iterable[UUID] + The visuals edited together. One update is sent per visual. + coordinate_system : UUID or str + The scene's world coordinate system. + axis_ids, axis_names : Sequence[str] + The world axes, in order: their ids and their names. + planes : Sequence[RenderPlane] + The visual's current ``render_planes``. + source_id : UUID + The widget's id, stamped on each edit. + emit : Callable[[Any], None] + Sends an edit (the widget's ``changed.emit``). + show : Callable[[list[Row], str], None] + Draws the rows and an error message ("" for none). + displayed_axes : Sequence[str] or None + The ids of the three world axes a new plane is put on. ``None`` + means the last three world axes. Read from *state_source* when + that is given. + bounds : Sequence[Sequence[float]] or None + ``(low, high)`` of the visual's data on each world axis. ``None`` + means ``(0, 1)`` on each. Read from *state_source* when given. + state_source : Callable[[], Mapping[str, Any]] or None + Reads ``{"blocked", "displayed_axes", "bounds"}`` as they are now + (:func:`get_render_planes_data_from_visual`). The control is + disabled, with the reason shown, while ``blocked`` is not ``""``. + scene_id, data_store_id : UUID, str or None + The visuals' scene and store, whose changes move what + *state_source* reads. + gizmo_target : RenderPlaneGizmoTarget or None + Where a plane's gizmo is drawn. ``None`` gives the rows no gizmo + toggle. See :func:`get_render_plane_gizmo_data`. + gizmo_plane : str or None + The id of the plane that has the canvas's gizmo now. + gizmo_blocked : Callable[[str], str] or None + Given a plane's id, why it cannot have a gizmo now, or ``""``. + + Raises + ------ + ValueError + If the visual of *gizmo_target* is not one of *visual_ids*. + """ + + def __init__( + self, + visual_ids: Iterable[UUID], + coordinate_system: UUID | str, + axis_ids: Sequence[str], + axis_names: Sequence[str], + planes: Sequence[RenderPlane], + source_id: UUID, + emit: Callable[[Any], None], + show: Callable[[list[Row], str], None], + displayed_axes: Sequence[str] | None = None, + bounds: Sequence[Sequence[float]] | None = None, + state_source: Callable[[], Mapping[str, Any]] | None = None, + scene_id: UUID | str | None = None, + data_store_id: UUID | str | None = None, + gizmo_target: RenderPlaneGizmoTarget | None = None, + gizmo_plane: str | None = None, + gizmo_blocked: Callable[[str], str] | None = None, + ) -> None: + self._visual_ids = tuple(visual_ids) + self._coordinate_system = UUID(str(coordinate_system)) + self._axis_ids = [str(axis) for axis in axis_ids] + self.axis_names = [str(name) for name in axis_names] + self._names = dict(zip(self._axis_ids, self.axis_names, strict=True)) + self._index = {axis: i for i, axis in enumerate(self._axis_ids)} + self._index.update({name: i for i, name in enumerate(self.axis_names)}) + self.planes: tuple[RenderPlane, ...] = tuple(planes) + self._source_id = source_id + self._emit = emit + self._show = show + self._state_source = state_source + self._scene_id = None if scene_id is None else UUID(str(scene_id)) + self._data_store_id = ( + None if data_store_id is None else UUID(str(data_store_id)) + ) + self._displayed_axes = ( + self._axis_ids[-3:] + if displayed_axes is None + else [str(axis) for axis in displayed_axes] + ) + self._bounds: Bounds = ( + [[0.0, 1.0] for _ in self._axis_ids] + if bounds is None + else _plain_bounds(bounds) + ) + #: Why the control is disabled now, or "". + self.blocked: str = "" + self._read_state() + self._gizmo: RenderPlaneGizmoTarget | None = None + if gizmo_target is not None: + self._gizmo = RenderPlaneGizmoTarget( + *(UUID(str(value)) for value in gizmo_target) + ) + if self._gizmo.visual_id not in self._visual_ids: + raise ValueError( + f"The gizmo target's visual {self._gizmo.visual_id} is not " + "one of the visuals this control edits." + ) + self.gizmo_plane: str | None = None if gizmo_plane is None else str(gizmo_plane) + self._gizmo_blocked = gizmo_blocked + + # -- state --------------------------------------------------------------- + + @property + def rows(self) -> list[Row]: + """The rows of the planes the model last reported.""" + return rows_from_planes(self.planes, self._names) + + @property + def has_gizmo(self) -> bool: + """Whether the rows have a gizmo toggle.""" + return self._gizmo is not None + + @property + def can_add(self) -> bool: + """Whether a plane can be added: enabled, and under four planes.""" + return not self.blocked and len(self.planes) < MAX_RENDER_PLANES + + def _read_state(self) -> bool: + """Read the scene's state again; whether anything shown changed.""" + if self._state_source is None: + return False + state = self._state_source() + before = (self.blocked, self._displayed_axes, self._bounds) + self.blocked = str(state.get("blocked", "")) + self._displayed_axes = [str(axis) for axis in state["displayed_axes"]] + self._bounds = _plain_bounds(state["bounds"]) + return before != (self.blocked, self._displayed_axes, self._bounds) + + def _plane_bounds(self, plane: RenderPlane) -> Bounds: + """The data box on *plane*'s three axes.""" + return [ + self._bounds[self._index[str(axis)]] + if str(axis) in self._index + else [0.0, 1.0] + for axis in plane.axes + ] + + def subscription_specs(self) -> list[SubscriptionSpec]: + """What the control follows on the bus. + + Per visual: its ``RenderPlanesChangedEvent``, and the events that + can change whether its planes are drawn or where its data is (a + render mode, ``composite``, its transform). The scene's + ``DimsChangedEvent`` and the store's extent. With a gizmo toggle, + the canvas's ``PlaneGizmoChangedEvent``. + """ + specs = [] + for visual_id in self._visual_ids: + specs.append( + SubscriptionSpec( + RenderPlanesChangedEvent, self.on_changed, entity_id=visual_id + ) + ) + for event_type in ( + AppearanceChangedEvent, + SingleAppearanceChangedEvent, + ChannelAppearanceChangedEvent, + ImageCompositeChangedEvent, + TransformChangedEvent, + ): + specs.append( + SubscriptionSpec( + event_type, self.on_state_changed, entity_id=visual_id + ) + ) + if self._scene_id is not None: + specs.append( + SubscriptionSpec( + DimsChangedEvent, self.on_dims_changed, entity_id=self._scene_id + ) + ) + if self._data_store_id is not None: + specs.append( + SubscriptionSpec( + DataStoreMetadataChangedEvent, + self.on_state_changed, + entity_id=self._data_store_id, + ) + ) + if self._gizmo is not None: + specs.append( + SubscriptionSpec( + PlaneGizmoChangedEvent, + self.on_gizmo_changed, + entity_id=self._gizmo.canvas_id, + ) + ) + return specs + + # -- edits --------------------------------------------------------------- + + def set_planes(self, planes: Iterable[RenderPlane]) -> None: + """Send *planes* as the new tuple; nothing when they are the current. + + The comparison is by value: a host that delivers one front-end edit + twice (marimo does) sends one update. + """ + try: + planes = tuple(planes) + if planes == self.planes: + return + if self.blocked: + raise ValueError(self.blocked) + for visual_id in self._visual_ids: + self._emit( + RenderPlanesUpdateEvent( + source_id=self._source_id, + visual_id=visual_id, + render_planes=planes, + ) + ) + except Exception as error: + self._show(self.rows, error_message(error)) + return + self.planes = planes + self._show(self.rows, "") + + def _edit(self, index: int, edit: Callable[[RenderPlane], RenderPlane]) -> None: + """Replace plane *index* by ``edit(plane)``; a refusal is shown.""" + try: + planes = list(self.planes) + planes[index] = edit(planes[index]) + except Exception as error: + self._show(self.rows, error_message(error)) + return + self.set_planes(planes) + + def add(self) -> None: + """Append a plane through the data's middle, facing the first axis. + + The plane is on the three axes the 3D view displays and unbounded. + """ + try: + if len(self.planes) >= MAX_RENDER_PLANES: + raise ValueError( + f"A visual draws at most {MAX_RENDER_PLANES} render planes." + ) + axes = list(self._displayed_axes) + if len(axes) != 3: + raise ValueError( + "A render plane is on the three axes a 3D view displays." + ) + centre = [0.5 * sum(self._bounds[self._index[axis]]) for axis in axes] + plane = RenderPlane( + coordinate_system=self._coordinate_system, + axes=tuple(UUID(axis) for axis in axes), + origin=tuple(centre), + in_plane_axis_0=(0.0, 1.0, 0.0), + in_plane_axis_1=(0.0, 0.0, 1.0), + ) + except Exception as error: + self._show(self.rows, error_message(error)) + return + self.set_planes([*self.planes, plane]) + + def remove(self, index: int) -> None: + """Remove plane *index*.""" + self.set_planes([p for i, p in enumerate(self.planes) if i != index]) + + def set_enabled(self, index: int, enabled: bool) -> None: + """Switch plane *index* on or off; it stays in the list.""" + self._edit(index, lambda p: p.model_copy(update={"enabled": bool(enabled)})) + + def set_position(self, index: int, position: float) -> None: + """Move plane *index* along its normal.""" + self._edit(index, lambda p: with_position(p, position)) + + def flip(self, index: int) -> None: + """Reverse the normal of plane *index*; nothing drawn changes.""" + self._edit(index, flipped) + + def set_normal(self, index: int, normal: Sequence[float]) -> None: + """Turn plane *index* about its origin to a new normal; keeps its spin.""" + self._edit(index, lambda p: with_normal(p, normal)) + + def set_facing(self, index: int, axis: int, sign: int) -> None: + """Face plane *index* along its axis *axis* (0 to 2), toward *sign*.""" + normal = [0.0, 0.0, 0.0] + normal[axis] = -1.0 if sign < 0 else 1.0 + self.set_normal(index, normal) + + def set_component(self, index: int, axis: int, value: float) -> None: + """Set the normal's entry on the plane's axis *axis*; the others stay.""" + normal = list(self.rows[index]["normal"]) + normal[axis] = float(value) + self.set_normal(index, normal) + + def set_side_bounded(self, index: int, axis: int, side: int, bounded: bool) -> None: + """Make one extent side of plane *index* finite or unbounded. + + Making it finite fills in where the data box cuts the plane there. + """ + self._edit( + index, + lambda p: with_side_bounded(p, axis, side, bounded, self._plane_bounds(p)), + ) + + def set_side(self, index: int, axis: int, side: int, value: float) -> None: + """Set one bounded extent side of plane *index*, in world units.""" + self._edit(index, lambda p: with_side(p, axis, side, value)) + + def set_gizmo(self, index: int, enabled: bool) -> None: + """Put the canvas's gizmo on plane *index*, or take it off. + + Sends a ``PlaneGizmoUpdateEvent`` of kind ``"render"``. The toggle + is drawn from what the controller answers (:meth:`on_gizmo_changed`), + not from the click: a refused request leaves it off, with the + reason shown. + """ + if self._gizmo is None: + return + visual_id, canvas_id, _scene_id = self._gizmo + error = "" + try: + if self.blocked: + raise ValueError(self.blocked) + self._emit( + PlaneGizmoUpdateEvent( + source_id=self._source_id, + visual_id=visual_id, + plane_id=self.planes[index].id, + canvas_id=canvas_id, + kind="render", + enabled=bool(enabled), + ) + ) + except Exception as refused: + error = error_message(refused) + self._show(self.rows, error) + + # -- model -> widget ----------------------------------------------------- + + def on_changed(self, event: RenderPlanesChangedEvent) -> None: + """The model changed (this widget's edit or anyone's): show it.""" + planes = tuple(event.render_planes) + if planes == self.planes: + return + self.planes = planes + self._show(self.rows, "") + + def on_gizmo_changed(self, event: PlaneGizmoChangedEvent) -> None: + """The canvas's gizmo moved to another plane, or closed: show it.""" + if self._gizmo is None: + return + on_this = ( + event.kind == "render" + and event.visual_id == self._gizmo.visual_id + and event.plane_id is not None + ) + plane = str(event.plane_id) if on_this else None + if plane == self.gizmo_plane: + return + self.gizmo_plane = plane + self._show(self.rows, "") + + def on_dims_changed(self, event: DimsChangedEvent) -> None: + """The displayed axes changed: the planes may not be drawn now.""" + if event.displayed_axes_changed: + self.refresh() + + def on_state_changed(self, _event: Any = None) -> None: + """A render mode, the transform or the store's extent changed.""" + if self._read_state(): + self._show(self.rows, "") + + def refresh(self) -> None: + """Read the scene's state again and show the rows.""" + self._read_state() + self._show(self.rows, "") + + def describe(self) -> list[Row]: + """The rows with what a front end needs to draw each one. + + Adds ``facing`` (the signed axis the normal lies along, or + ``None``) and the position slider's ``low`` / ``high``. With a + gizmo toggle, also ``gizmo`` (whether this plane has the canvas's + gizmo) and ``gizmo_blocked`` (why it cannot have one, or ``""``); + without, neither key. + """ + described = [] + for plane, row in zip(self.planes, self.rows, strict=True): + low, high = position_range(row["normal"], self._plane_bounds(plane)) + entry = { + **row, + "facing": facing_of(row["normal"]), + "low": min(low, row["position"]), + "high": max(high, row["position"]), + } + if self._gizmo is not None: + entry["gizmo"] = row["id"] == self.gizmo_plane + entry["gizmo_blocked"] = self.blocked or ( + "" + if self._gizmo_blocked is None + else self._gizmo_blocked(row["id"]) + ) + described.append(entry) + return described diff --git a/src/cellier/gui/anywidget/visuals/__init__.py b/src/cellier/gui/anywidget/visuals/__init__.py index 2b85ee0b..132e9ac1 100644 --- a/src/cellier/gui/anywidget/visuals/__init__.py +++ b/src/cellier/gui/anywidget/visuals/__init__.py @@ -56,6 +56,9 @@ AnywidgetSizeSpaceCombo, AnywidgetSizeSpin, ) +from cellier.gui.anywidget.visuals._render_planes import ( + AnywidgetRenderPlanesControls, +) from cellier.gui.anywidget.visuals._trail import AnywidgetTrailControls from cellier.gui.anywidget.visuals._visible import AnywidgetVisibleToggle @@ -86,6 +89,7 @@ "AnywidgetNodeSizeSpin", "AnywidgetNodeVisibleToggle", "AnywidgetOpacitySlider", + "AnywidgetRenderPlanesControls", "AnywidgetSaltSpin", "AnywidgetShininessSpin", "AnywidgetSideCombo", diff --git a/src/cellier/gui/anywidget/visuals/_render_planes.py b/src/cellier/gui/anywidget/visuals/_render_planes.py new file mode 100644 index 00000000..5c54ac00 --- /dev/null +++ b/src/cellier/gui/anywidget/visuals/_render_planes.py @@ -0,0 +1,249 @@ +"""The anywidget "Render planes" control of a visual. + +One widget whose synced trait holds the whole list of planes; its front +end draws a row per entry. Adding a plane constructs no widget, so +marimo's rule against building widgets outside a running cell never +applies. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, Any +from uuid import uuid4 + +import anywidget +import traitlets +from psygnal import Signal + +from cellier.gui._appearance_fields import VisualIdGroup +from cellier.gui._render_planes import RENDER_PLANES_TITLE, RenderPlanesEditor +from cellier.gui.anywidget._teardown import close_aux_widgets + +if TYPE_CHECKING: + from collections.abc import Callable, Mapping, Sequence + from uuid import UUID + + from cellier.events import SubscriptionSpec + from cellier.gui._render_planes import RenderPlaneGizmoTarget + +_STATIC = Path(__file__).parent / "static" + + +def _pair(value: Any, size: int) -> list | None: + """*value* as a list of *size* entries, or ``None`` if it is not one.""" + if not isinstance(value, (list, tuple)) or len(value) != size: + return None + return list(value) + + +class AnywidgetRenderPlanesControls(VisualIdGroup, anywidget.AnyWidget): + """A visual's render planes: one row per plane, and an add button. + + The planes a visual in the ``"plane"`` render mode draws its data on. + Each row is a "Clipping planes" row with the extents added: an enabled + checkbox, a flip button, a remove button, the normal (two buttons and an + entry per axis of the plane), a position slider along the normal, and + for each of the plane's two in-plane axes a minimum and a maximum, each + with an "unbounded" box. Values are in world units. Given + *gizmo_target*, a row also has a "Gizmo" toggle that puts the canvas's + gizmo on its plane; one plane of a canvas has it at a time, clipping + planes included. + + The front end reports an action by setting ``edit`` to + ``{"action", "index", "value", "serial"}``; the Python side applies it + and syncs ``rows`` back. A refused edit comes back as ``error`` with + ``rows`` unchanged. ``rows``, ``error``, ``blocked`` and ``can_add`` + are written by the Python side only; a value a host writes into them is + put back. An update is sent only when the planes differ from the ones + held, so a host that delivers one edit twice sends one. + + Wire to the controller after construction:: + + data = get_render_planes_data_from_visual(controller, visual.id) + controls = AnywidgetRenderPlanesControls(visual.id, **data) + controller.connect_widget( + controls, subscription_specs=controls.subscription_specs() + ) + + Parameters + ---------- + visual_id : + The visual, or a group of them, edited together. + coordinate_system : + The scene's world coordinate system (its id). + axis_ids, axis_names : + The world axes, in order: their ids and names. + planes : + The visual's current ``render_planes``. + displayed_axes, bounds, state_source, scene_id, data_store_id : + What the scene and the visual's data are now, and a reader of it: + ``get_render_planes_data_from_visual(controller, visual_id)``. + gizmo_target, gizmo_plane, gizmo_blocked : + Where a plane's gizmo is drawn (a ``RenderPlaneGizmoTarget``), the + plane that has it now, and why a plane cannot have one: + ``get_render_plane_gizmo_data(controller, visual_id, canvas_id)``. + Without *gizmo_target* the rows have no gizmo toggle. + """ + + _esm = _STATIC / "render_planes.js" + _css = _STATIC / "render_planes.css" + + changed: Signal = Signal(object) + closed: Signal = Signal() + + DEFAULT_TITLE = RENDER_PLANES_TITLE + """Name shown when no ``title=`` is given.""" + + title = traitlets.Unicode(DEFAULT_TITLE).tag(sync=True) + #: One entry per plane: id, enabled, axes, normal, position, extent_0, + #: extent_1, facing, low, high; with a gizmo toggle, also gizmo and + #: gizmo_blocked. + rows = traitlets.List([]).tag(sync=True) + #: The reason the last edit was refused, or "". + error = traitlets.Unicode("").tag(sync=True) + #: Why the control is disabled now (the planes are not drawn), or "". + blocked = traitlets.Unicode("").tag(sync=True) + #: Whether a plane can be added: not disabled, and under four planes. + can_add = traitlets.Bool(True).tag(sync=True) + #: Set by the front end: ``{"action", "index", "value", "serial"}``. + edit = traitlets.Dict({}).tag(sync=True) + + def __init__( + self, + visual_id: UUID | Sequence[UUID], + *, + coordinate_system: UUID | str, + axis_ids: Sequence[str], + axis_names: Sequence[str], + planes: Sequence[Any] = (), + displayed_axes: Sequence[str] | None = None, + bounds: Sequence[Sequence[float]] | None = None, + state_source: Callable[[], Mapping[str, Any]] | None = None, + scene_id: UUID | str | None = None, + data_store_id: UUID | str | None = None, + gizmo_target: RenderPlaneGizmoTarget | None = None, + gizmo_plane: str | None = None, + gizmo_blocked: Callable[[str], str] | None = None, + **kwargs, + ) -> None: + super().__init__(**kwargs) + self._id = uuid4() + self._init_visual_ids(visual_id) + self._shown: dict[str, Any] = {} + self._editor = RenderPlanesEditor( + self._visual_ids, + coordinate_system, + axis_ids, + axis_names, + planes, + self._id, + self.changed.emit, + self._show, + displayed_axes=displayed_axes, + bounds=bounds, + state_source=state_source, + scene_id=scene_id, + data_store_id=data_store_id, + gizmo_target=gizmo_target, + gizmo_plane=gizmo_plane, + gizmo_blocked=gizmo_blocked, + ) + self._show(self._editor.rows, "") + self.observe(self._on_edit, names="edit") + self.observe(self._keep_shown, names=["rows", "error", "blocked", "can_add"]) + + # -- Public interface ------------------------------------------------------ + + @property + def widget(self) -> AnywidgetRenderPlanesControls: + """An ``AnyWidget`` is itself the embeddable element.""" + return self + + @property + def editor(self) -> RenderPlanesEditor: + """The toolkit-neutral half (for tests and scripting).""" + return self._editor + + def refresh(self) -> None: + """Read the scene's state again (whether the control is disabled).""" + self._editor.refresh() + + def close(self) -> None: + """Unsubscribe from the bus and release the widget.""" + self.closed.emit() + close_aux_widgets(self) + super().close() + + def subscription_specs(self) -> list[SubscriptionSpec]: + """What the control follows on the bus (see the editor).""" + return self._editor.subscription_specs() + + # -- widget -> model ------------------------------------------------------- + + def _on_edit(self, change) -> None: + edit = change["new"] or {} + action = edit.get("action") + editor = self._editor + if action == "add": + editor.add() + return + index = edit.get("index") + if not isinstance(index, int) or not 0 <= index < len(editor.planes): + return + value = edit.get("value") + if action == "remove": + editor.remove(index) + elif action == "enabled": + editor.set_enabled(index, bool(value)) + elif action == "position": + editor.set_position(index, float(value)) + elif action == "flip": + editor.flip(index) + elif action == "gizmo": + editor.set_gizmo(index, bool(value)) + elif action in ("facing", "component"): + # ``[axis index, sign]`` or ``[axis index, entry]``. + pair = _pair(value, 2) + if pair is None or pair[0] not in (0, 1, 2): + return + axis, amount = pair + if action == "facing": + editor.set_facing(index, axis, -1 if float(amount) < 0 else 1) + else: + editor.set_component(index, axis, float(amount)) + elif action in ("side", "bounded"): + # ``[in-plane axis, side, value]`` or ``[..., bounded]``. + triple = _pair(value, 3) + if triple is None or triple[0] not in (0, 1) or triple[1] not in (0, 1): + return + axis, side, amount = triple + if action == "side": + editor.set_side(index, axis, side, float(amount)) + else: + editor.set_side_bounded(index, axis, side, bool(amount)) + + # -- model -> widget ------------------------------------------------------- + + def _show(self, rows: list[dict[str, Any]], error: str) -> None: + self._shown = { + "rows": self._editor.describe(), + "error": error, + "blocked": self._editor.blocked, + "can_add": self._editor.can_add, + } + with self.hold_sync(): + for name, value in self._shown.items(): + setattr(self, name, value) + + def _keep_shown(self, change) -> None: + """Put back a trait this side owns when a host overwrites it. + + They are written by this side only. marimo sends a widget's whole + state with every front-end edit, so the front end's copy of them, + which is the one from before the edit, arrives with the edit and + would replace what the edit just produced. + """ + name = change["name"] + if name in self._shown and change["new"] != self._shown[name]: + setattr(self, name, self._shown[name]) diff --git a/src/cellier/gui/anywidget/visuals/static/render_planes.css b/src/cellier/gui/anywidget/visuals/static/render_planes.css new file mode 100644 index 00000000..732d40b7 --- /dev/null +++ b/src/cellier/gui/anywidget/visuals/static/render_planes.css @@ -0,0 +1,158 @@ +.cellier-render-planes { + font-family: var(--jp-ui-font-family, sans-serif); + font-size: 12px; + min-width: 260px; +} + +/* A host may reset the browser's own button and input borders (marimo + does), which leaves them looking like plain text: draw them here. */ +.cellier-render-planes button, +.cellier-render-planes input[type="number"] { + border: 1px solid rgba(128, 128, 128, 0.6); + border-radius: 3px; + background: transparent; + color: inherit; + font: inherit; +} + +.cellier-render-planes button { + padding: 1px 8px; + cursor: pointer; +} + +.cellier-render-planes input[type="number"] { + padding: 1px 4px; +} + +.cellier-render-planes-title { + font-weight: 600; + margin: 2px 0 4px; +} + +.cellier-render-planes-rows { + display: flex; + flex-direction: column; + gap: 12px; +} + +.cellier-render-planes-row { + display: flex; + flex-direction: column; + gap: 6px; +} + +.cellier-render-planes-header { + display: flex; + align-items: center; + gap: 6px; +} + +.cellier-render-planes-spacer { + flex: 1; +} + +/* Column 1 is the label, beside both rows; then one column per data axis. */ +.cellier-render-planes-normal { + display: grid; + align-items: center; + gap: 4px 8px; +} + +.cellier-render-planes-normal > .cellier-render-planes-label { + grid-column: 1; + grid-row: 1 / span 2; + align-self: center; +} + +.cellier-render-planes-facing { + grid-row: 1; + display: flex; + justify-content: center; + gap: 2px; +} + +.cellier-render-planes-facing > button { + padding: 1px 5px; +} + +.cellier-render-planes button:disabled { + opacity: 0.45; + cursor: default; +} + +.cellier-render-planes-header > button.cellier-render-planes-on, +.cellier-render-planes-facing > button.cellier-render-planes-on { + background: var(--jp-brand-color1, #3b82f6); + border-color: var(--jp-brand-color1, #3b82f6); + color: #fff; +} + +.cellier-render-planes-normal > input { + grid-row: 2; + min-width: 0; + width: 100%; + box-sizing: border-box; +} + +.cellier-render-planes-along { + display: flex; + align-items: center; + gap: 8px; +} + +.cellier-render-planes-position { + flex: 1; + min-width: 0; +} + +.cellier-render-planes-value { + min-width: 4em; + text-align: right; + font-variant-numeric: tabular-nums; +} + +.cellier-render-planes-add { + margin-top: 8px; +} + +.cellier-render-planes-error { + color: #d04040; + margin-top: 4px; +} + +/* One line per in-plane axis: a label, then min and max with their boxes. */ +.cellier-render-planes-extent { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.cellier-render-planes-extent input[type="number"] { + flex: 1; + min-width: 4em; + width: 4em; + box-sizing: border-box; +} + +.cellier-render-planes-extent input[type="number"]:disabled { + opacity: 0.45; +} + +.cellier-render-planes-side { + display: flex; + align-items: center; + gap: 4px; + flex: 1; +} + +.cellier-render-planes-reason { + font-style: italic; + margin: 2px 0 6px; +} + +/* Disabled while the planes are not drawn: nothing in the rows reacts. */ +.cellier-render-planes-rows.cellier-render-planes-blocked { + opacity: 0.5; + pointer-events: none; +} diff --git a/src/cellier/gui/anywidget/visuals/static/render_planes.js b/src/cellier/gui/anywidget/visuals/static/render_planes.js new file mode 100644 index 00000000..cde50f09 --- /dev/null +++ b/src/cellier/gui/anywidget/visuals/static/render_planes.js @@ -0,0 +1,334 @@ +// Render planes ESM: one row per entry of `rows`. The Python side syncs +// `rows` (what to draw), `blocked` (why the control is disabled, or "") and +// `can_add`; an action is reported by setting `edit`, and a refused one +// comes back as `error` with `rows` unchanged. +// +// A row is a clipping planes row with the extents added. It is built once +// and then updated in place, so a slider is not destroyed while it is +// dragged. Rows are only added or removed when the number of planes +// changes. A plane has three axes, named by the row's `axes`. + +const GIZMO_TOOLTIP = + "Drag a gizmo in the 3D view to move, turn and resize this plane."; +const SIDES = ["min", "max"]; + +// A number as an input shows it: at most three decimals. +function shown(value) { + return String(Number(Number(value).toFixed(3))); +} + +function render({ model, el }) { + el.classList.add("cellier-render-planes"); + let serial = 0; + + const heading = document.createElement("div"); + heading.className = "cellier-render-planes-title"; + el.appendChild(heading); + + const reason = document.createElement("div"); + reason.className = "cellier-render-planes-reason"; + reason.dataset.role = "reason"; + el.appendChild(reason); + + const body = document.createElement("div"); + body.className = "cellier-render-planes-rows"; + el.appendChild(body); + + const add = document.createElement("button"); + add.className = "cellier-render-planes-add"; + add.textContent = "Add plane"; + add.title = + "Add a plane through the middle of the data, facing the first displayed " + + "axis. A visual draws at most four."; + add.dataset.role = "add"; + el.appendChild(add); + + const error = document.createElement("div"); + error.className = "cellier-render-planes-error"; + el.appendChild(error); + + function send(action, index, value) { + serial += 1; + model.set("edit", { action, index, value, serial }); + model.save_changes(); + } + + add.addEventListener("click", () => send("add", null, null)); + + // One entry per built row: { root, apply(row) }. + const built = []; + + function build(index) { + const root = document.createElement("div"); + root.className = "cellier-render-planes-row"; + root.dataset.role = "row"; + root.dataset.index = String(index); + + // -- header: on or off, gizmo, flip, remove + const header = document.createElement("div"); + header.className = "cellier-render-planes-header"; + + const enabledLabel = document.createElement("label"); + const enabled = document.createElement("input"); + enabled.type = "checkbox"; + enabled.title = "Whether this plane is drawn. It stays in the list."; + enabled.dataset.role = "enabled"; + enabled.addEventListener("change", () => send("enabled", index, enabled.checked)); + enabledLabel.append(enabled, ` Plane ${index + 1}`); + + // Drawn from `rows`, not from the click: one canvas has one gizmo, so + // switching one on switches another off. Hidden when the row has no + // `gizmo` entry (no 3D canvas to draw one in). + const gizmo = document.createElement("button"); + gizmo.textContent = "Gizmo"; + gizmo.dataset.role = "gizmo"; + let gizmoOn = false; + gizmo.addEventListener("click", () => send("gizmo", index, !gizmoOn)); + + const flip = document.createElement("button"); + flip.textContent = "Flip"; + flip.title = + "Reverse the normal. A plane is drawn the same from both sides, so the " + + "picture does not change."; + flip.dataset.role = "flip"; + flip.addEventListener("click", () => send("flip", index, null)); + + const remove = document.createElement("button"); + remove.textContent = "Remove"; + remove.dataset.role = "remove"; + remove.addEventListener("click", () => send("remove", index, null)); + + const spacer = document.createElement("span"); + spacer.className = "cellier-render-planes-spacer"; + header.append(enabledLabel, spacer, gizmo, flip, remove); + + // -- normal: one column per axis of the plane, two buttons over an entry + const normal = document.createElement("div"); + normal.className = "cellier-render-planes-normal"; + normal.style.gridTemplateColumns = "auto repeat(3, minmax(0, 1fr))"; + + const normalLabel = document.createElement("span"); + normalLabel.className = "cellier-render-planes-label"; + normalLabel.textContent = "Normal"; + normalLabel.title = + "The plane's normal, one entry per world axis of the plane. Only its " + + "direction matters. A button faces the plane along its axis."; + normal.appendChild(normalLabel); + + // The normal last drawn, to refuse an all-zero one without a round trip. + let current = [0, 0, 0]; + const facing = []; // { axis, sign, button } + const components = []; + for (let axis = 0; axis < 3; axis += 1) { + const pair = document.createElement("div"); + pair.className = "cellier-render-planes-facing"; + pair.style.gridColumn = String(axis + 2); + for (const sign of [1, -1]) { + const button = document.createElement("button"); + button.dataset.role = "facing"; + button.dataset.axis = String(axis); + button.dataset.sign = String(sign); + button.addEventListener("click", () => send("facing", index, [axis, sign])); + pair.appendChild(button); + facing.push({ axis, sign, button }); + } + normal.appendChild(pair); + } + for (let axis = 0; axis < 3; axis += 1) { + const component = document.createElement("input"); + component.type = "number"; + component.step = "0.1"; + component.dataset.role = "component"; + component.dataset.axis = String(axis); + component.style.gridColumn = String(axis + 2); + component.addEventListener("change", () => { + const entry = Number(component.value); + const next = current.map((v, i) => (i === axis ? entry : v)); + send("component", index, [axis, entry]); + // An all-zero or unreadable normal is refused and `rows` does not + // change, so nothing would draw the entry again: put it back here. + if (!Number.isFinite(entry) || next.every((v) => v === 0)) { + component.value = shown(current[axis]); + } + }); + normal.appendChild(component); + components.push(component); + } + + // -- position along the normal + const along = document.createElement("div"); + along.className = "cellier-render-planes-along"; + + const positionLabel = document.createElement("span"); + positionLabel.className = "cellier-render-planes-label"; + positionLabel.textContent = "Position"; + + const value = document.createElement("span"); + value.className = "cellier-render-planes-value"; + value.dataset.role = "value"; + + const position = document.createElement("input"); + position.type = "range"; + position.step = "any"; + position.className = "cellier-render-planes-position"; + position.title = "Where the plane sits along its normal. World units."; + position.dataset.role = "position"; + let dragging = false; + position.addEventListener("input", () => { + dragging = true; + value.textContent = Number(position.value).toFixed(2); + send("position", index, Number(position.value)); + }); + position.addEventListener("change", () => { dragging = false; }); + + along.append(positionLabel, position, value); + + // -- extents: one line per in-plane axis, a min and a max, each with an + // "unbounded" box + const sides = []; // { axis, side, input, box, last } + const extents = []; + for (let axis = 0; axis < 2; axis += 1) { + const line = document.createElement("div"); + line.className = "cellier-render-planes-extent"; + const label = document.createElement("span"); + label.className = "cellier-render-planes-label"; + label.textContent = `Extent ${axis}`; + label.title = + `How far the plane reaches from its origin along in-plane axis ${axis}, ` + + "in world units. An unbounded side ends at the data."; + line.appendChild(label); + for (let side = 0; side < 2; side += 1) { + const group = document.createElement("span"); + group.className = "cellier-render-planes-side"; + const name = document.createElement("span"); + name.textContent = SIDES[side]; + const input = document.createElement("input"); + input.type = "number"; + input.step = "any"; + input.title = `The ${SIDES[side]} along in-plane axis ${axis}.`; + input.dataset.role = "side"; + input.dataset.axis = String(axis); + input.dataset.side = String(side); + const entry = { axis, side, input, box: null, last: null }; + input.addEventListener("change", () => { + const amount = Number(input.value); + if (input.value === "" || !Number.isFinite(amount)) { + // Unreadable: nothing is sent, so put the last value back. + input.value = entry.last === null ? "" : shown(entry.last); + return; + } + send("side", index, [axis, side, amount]); + }); + const boxLabel = document.createElement("label"); + const box = document.createElement("input"); + box.type = "checkbox"; + box.title = + `No ${SIDES[side]}: the plane ends at the data on this side. ` + + "Unticking fills in where the data ends now."; + box.dataset.role = "unbounded"; + box.dataset.axis = String(axis); + box.dataset.side = String(side); + box.addEventListener("change", () => + send("bounded", index, [axis, side, !box.checked])); + boxLabel.append(box, " unbounded"); + entry.box = box; + group.append(name, input, boxLabel); + line.appendChild(group); + sides.push(entry); + } + extents.push(line); + } + + root.append(header, normal, along, ...extents); + + function apply(row) { + enabled.checked = Boolean(row.enabled); + gizmo.style.display = row.gizmo === undefined ? "none" : ""; + gizmoOn = Boolean(row.gizmo); + gizmo.classList.toggle("cellier-render-planes-on", gizmoOn); + gizmo.setAttribute("aria-pressed", String(gizmoOn)); + gizmo.disabled = Boolean(row.gizmo_blocked); + gizmo.title = row.gizmo_blocked || GIZMO_TOOLTIP; + current = row.normal.map(Number); + for (const { axis, sign, button } of facing) { + const name = row.axes[axis]; + button.textContent = `${sign > 0 ? "+" : "-"}${name}`; + button.title = `Face the plane along ${name}.`; + const on = Boolean(row.facing) && row.facing[0] === axis && row.facing[1] === sign; + button.classList.toggle("cellier-render-planes-on", on); + button.setAttribute("aria-pressed", String(on)); + } + components.forEach((component, axis) => { + component.title = `The normal's entry on ${row.axes[axis]}.`; + // An entry being typed already reads as the value it will send; + // only a different number is written over it. + if (component.value === "" || shown(component.value) !== shown(current[axis])) { + component.value = shown(current[axis]); + } + }); + position.min = String(row.low); + position.max = String(row.high); + // While the thumb is held the element already shows the newest value; + // writing an older echo back would make it jump. + if (!dragging) position.value = String(row.position); + value.textContent = Number(row.position).toFixed(2); + for (const entry of sides) { + const amount = row[`extent_${entry.axis}`][entry.side]; + const unbounded = amount === null || amount === undefined; + entry.last = unbounded ? null : Number(amount); + entry.box.checked = unbounded; + entry.input.disabled = unbounded; + if (unbounded) { + entry.input.value = ""; + } else if (entry.input.value === "" || shown(entry.input.value) !== shown(amount)) { + entry.input.value = shown(amount); + } + } + } + return { root, apply }; + } + + function draw() { + const rows = model.get("rows"); + while (built.length > rows.length) { + built.pop().root.remove(); + } + while (built.length < rows.length) { + const entry = build(built.length); + built.push(entry); + body.appendChild(entry.root); + } + rows.forEach((row, index) => built[index].apply(row)); + } + + function drawTitle() { + heading.textContent = model.get("title"); + heading.style.display = model.get("title") ? "" : "none"; + } + + function drawState() { + const blocked = model.get("blocked"); + reason.textContent = blocked; + reason.style.display = blocked ? "" : "none"; + body.classList.toggle("cellier-render-planes-blocked", Boolean(blocked)); + body.inert = Boolean(blocked); + add.disabled = !model.get("can_add"); + } + + function drawError() { + error.textContent = model.get("error"); + } + + model.on("change:rows", draw); + model.on("change:title", drawTitle); + model.on("change:blocked", drawState); + model.on("change:can_add", drawState); + // A refused edit leaves `rows` as they were: draw them over what was typed. + model.on("change:error", () => { drawError(); draw(); }); + drawTitle(); + draw(); + drawState(); + drawError(); +} + +export default { render }; diff --git a/src/cellier/gui/qt/visuals/__init__.py b/src/cellier/gui/qt/visuals/__init__.py index 5f7b1e21..a1fe0dd9 100644 --- a/src/cellier/gui/qt/visuals/__init__.py +++ b/src/cellier/gui/qt/visuals/__init__.py @@ -55,6 +55,7 @@ QtSizeSpaceCombo, QtSizeSpin, ) +from cellier.gui.qt.visuals._render_planes import QtRenderPlanesControls from cellier.gui.qt.visuals._trail import QtTrailControls from cellier.gui.qt.visuals._visible import QtVisibleToggle @@ -85,6 +86,7 @@ "QtNodeSizeSpin", "QtNodeVisibleToggle", "QtOpacitySlider", + "QtRenderPlanesControls", "QtSaltSpin", "QtShininessSpin", "QtSideCombo", diff --git a/src/cellier/gui/qt/visuals/_render_planes.py b/src/cellier/gui/qt/visuals/_render_planes.py new file mode 100644 index 00000000..4b7f33b3 --- /dev/null +++ b/src/cellier/gui/qt/visuals/_render_planes.py @@ -0,0 +1,455 @@ +"""The Qt "Render planes" control of a visual. + +One row per plane, described and edited by +:class:`cellier.gui._render_planes.RenderPlanesEditor`. The rows are those +of the "Clipping planes" control with the extents added. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any +from uuid import uuid4 + +from psygnal import Signal + +from cellier.gui._appearance_fields import VisualIdGroup +from cellier.gui._render_planes import ( + RENDER_PLANE_GIZMO_TOOLTIP, + RENDER_PLANES_TITLE, + RenderPlanesEditor, +) + +if TYPE_CHECKING: + from collections.abc import Callable, Mapping, Sequence + from uuid import UUID + + from cellier.events import SubscriptionSpec + from cellier.gui._render_planes import RenderPlaneGizmoTarget + +#: Steps of the position slider (Qt sliders are integers). +_SLIDER_STEPS = 1000 +#: The label of each extent side, by ``side`` (0 the minimum, 1 the maximum). +_SIDE_NAMES = ("min", "max") + + +class _PlaneRow: + """The widgets of one plane. Updated in place; never rebuilt by a move. + + The normal is laid out one column per axis of the plane: two buttons + named after the axis (``+z`` and ``-z`` for an axis ``z``) that face the + plane along it, and under them the normal's entry on it. Under the + position, one line per in-plane axis with its two extent sides. + """ + + def __init__(self, owner: QtRenderPlanesControls, index: int, parent) -> None: + from qtpy.QtCore import Qt + from qtpy.QtWidgets import ( + QCheckBox, + QDoubleSpinBox, + QGridLayout, + QHBoxLayout, + QLabel, + QPushButton, + QSlider, + QToolButton, + QWidget, + ) + + self.index = index + self._range = (0.0, 1.0) + editor = owner._editor + self._editor = editor + self.widget = QWidget(parent) + grid = QGridLayout(self.widget) + grid.setContentsMargins(0, 2, 0, 6) + + self.enabled = QCheckBox(f"Plane {index + 1}", self.widget) + self.enabled.setToolTip("Whether this plane is drawn. It stays in the list.") + self.enabled.toggled.connect(lambda v: editor.set_enabled(self.index, v)) + + # Drawn from what the controller says, not from the click: one + # canvas has one gizmo, so switching one on switches another off. + self.gizmo = QPushButton("Gizmo", self.widget) + self.gizmo.setCheckable(True) + self.gizmo.setToolTip(RENDER_PLANE_GIZMO_TOOLTIP) + self.gizmo.setVisible(editor.has_gizmo) + self.gizmo.clicked.connect(lambda on: editor.set_gizmo(self.index, on)) + + self.flip = QPushButton("Flip", self.widget) + self.flip.setToolTip( + "Reverse the normal. A plane is drawn the same from both sides, " + "so the picture does not change." + ) + self.flip.clicked.connect(lambda: editor.flip(self.index)) + + self.remove = QPushButton("Remove", self.widget) + self.remove.clicked.connect(lambda: editor.remove(self.index)) + + header = QHBoxLayout() + header.addWidget(self.enabled) + header.addStretch(1) + header.addWidget(self.gizmo) + header.addWidget(self.flip) + header.addWidget(self.remove) + grid.addLayout(header, 0, 0, 1, 4) + + normal = QLabel("Normal", self.widget) + normal.setToolTip( + "The plane's normal, one entry per world axis of the plane. Only " + "its direction matters. A button faces the plane along its axis." + ) + # Beside both of its rows: the buttons and the entries. + grid.addWidget(normal, 1, 0, 2, 1, Qt.AlignmentFlag.AlignVCenter) + + #: ``(axis index, sign)`` -> the button that faces the plane that way. + self.facing: dict[tuple[int, int], Any] = {} + #: The normal's entry on each of the plane's three axes. + self.components: list[Any] = [] + for axis in range(3): + column = axis + 1 + buttons = QHBoxLayout() + buttons.setSpacing(2) + for sign in (1, -1): + button = QToolButton(self.widget) + button.setCheckable(True) + button.clicked.connect( + lambda _checked, a=axis, s=sign: self._on_facing(a, s) + ) + buttons.addWidget(button) + self.facing[(axis, sign)] = button + grid.addLayout(buttons, 1, column, Qt.AlignmentFlag.AlignCenter) + + component = QDoubleSpinBox(self.widget) + component.setDecimals(3) + component.setRange(-100.0, 100.0) + component.setSingleStep(0.1) + component.setKeyboardTracking(False) + component.valueChanged.connect( + lambda v, a=axis: editor.set_component(self.index, a, v) + ) + grid.addWidget(component, 2, column) + grid.setColumnStretch(column, 1) + self.components.append(component) + + self.slider = QSlider(Qt.Orientation.Horizontal, self.widget) + self.slider.setRange(0, _SLIDER_STEPS) + self.slider.setToolTip("Where the plane sits along its normal. World units.") + self.slider.valueChanged.connect(self._on_slider) + + self.position = QDoubleSpinBox(self.widget) + self.position.setDecimals(2) + self.position.setRange(-1e9, 1e9) + self.position.setKeyboardTracking(False) + self.position.valueChanged.connect(lambda v: editor.set_position(self.index, v)) + + grid.addWidget(QLabel("Position", self.widget), 3, 0) + along = QHBoxLayout() + along.addWidget(self.slider, 1) + along.addWidget(self.position) + grid.addLayout(along, 3, 1, 1, 3) + + #: ``(in-plane axis, side)`` -> the side's value, and its + #: "unbounded" box. + self.sides: dict[tuple[int, int], Any] = {} + self.unbounded: dict[tuple[int, int], Any] = {} + for axis in (0, 1): + label = QLabel(f"Extent {axis}", self.widget) + label.setToolTip( + f"How far the plane reaches from its origin along in-plane " + f"axis {axis}, in world units. An unbounded side ends at the " + "data." + ) + grid.addWidget(label, 4 + axis, 0) + line = QHBoxLayout() + for side in (0, 1): + value = QDoubleSpinBox(self.widget) + value.setDecimals(2) + value.setRange(-1e9, 1e9) + value.setKeyboardTracking(False) + value.setToolTip(f"The {_SIDE_NAMES[side]} along in-plane axis {axis}.") + value.valueChanged.connect( + lambda v, a=axis, s=side: editor.set_side(self.index, a, s, v) + ) + box = QCheckBox("unbounded", self.widget) + box.setToolTip( + f"No {_SIDE_NAMES[side]}: the plane ends at the data on " + "this side. Unticking fills in where the data ends now." + ) + box.toggled.connect( + lambda on, a=axis, s=side: editor.set_side_bounded( + self.index, a, s, not on + ) + ) + line.addWidget(QLabel(_SIDE_NAMES[side], self.widget)) + line.addWidget(value, 1) + line.addWidget(box) + self.sides[(axis, side)] = value + self.unbounded[(axis, side)] = box + grid.addLayout(line, 4 + axis, 1, 1, 3) + + self._inputs = ( + self.enabled, + *self.facing.values(), + *self.components, + self.slider, + self.position, + *self.sides.values(), + *self.unbounded.values(), + ) + + def _on_slider(self, step: int) -> None: + low, high = self._range + self._editor.set_position(self.index, low + (high - low) * step / _SLIDER_STEPS) + + def _on_facing(self, axis: int, sign: int) -> None: + # A click on the button already shown un-checks it and changes + # nothing, so nothing would draw it again. + self.facing[(axis, sign)].setChecked(True) + self._editor.set_facing(self.index, axis, sign) + + def show(self, row: Mapping[str, Any]) -> None: + """Show *row* (from ``RenderPlanesEditor.describe``); emits nothing.""" + for widget in self._inputs: + widget.blockSignals(True) + try: + self.enabled.setChecked(bool(row["enabled"])) + facing = None if row["facing"] is None else tuple(row["facing"]) + names = [str(name) for name in row["axes"]] + for (axis, sign), button in self.facing.items(): + name = names[axis] + button.setText(f"{'+' if sign > 0 else '-'}{name}") + button.setToolTip(f"Face the plane along {name}.") + button.setChecked((axis, sign) == facing) + for axis, (component, value) in enumerate( + zip(self.components, row["normal"]) + ): + component.setToolTip(f"The normal's entry on {names[axis]}.") + component.setValue(float(value)) + low, high = float(row["low"]), float(row["high"]) + self._range = (low, high) + span = high - low + step = ( + 0 + if span <= 0 + else round((row["position"] - low) / span * _SLIDER_STEPS) + ) + self.slider.setValue(int(min(max(step, 0), _SLIDER_STEPS))) + self.position.setSingleStep(max(span / 200.0, 0.01)) + self.position.setValue(float(row["position"])) + for (axis, side), value in self.sides.items(): + amount = row[f"extent_{axis}"][side] + self.unbounded[(axis, side)].setChecked(amount is None) + value.setEnabled(amount is not None) + value.setSingleStep(max(span / 200.0, 0.01)) + if amount is not None: + value.setValue(float(amount)) + blocked = str(row.get("gizmo_blocked", "")) + self.gizmo.setChecked(bool(row.get("gizmo", False))) + self.gizmo.setEnabled(not blocked) + self.gizmo.setToolTip(blocked or RENDER_PLANE_GIZMO_TOOLTIP) + finally: + for widget in self._inputs: + widget.blockSignals(False) + + +class QtRenderPlanesControls(VisualIdGroup): + """A visual's render planes: one row per plane, and an add button. + + The planes a visual in the ``"plane"`` render mode draws its data on. + Each row is a "Clipping planes" row with the extents added: an enabled + checkbox, a flip button, a remove button, the normal (two buttons and an + entry per axis of the plane), a position slider along the normal, and + for each of the plane's two in-plane axes a minimum and a maximum, each + with an "unbounded" box. Values are in world units. Given + *gizmo_target*, a row also has a "Gizmo" toggle that puts the canvas's + gizmo on its plane; one plane of a canvas has it at a time, clipping + planes included. + + An edit is sent as ``RenderPlanesUpdateEvent`` with the whole new tuple. + A moved plane updates its row in place, so a slider is not destroyed + while it is dragged. A visual draws at most four planes: the add button + is disabled at four. While the planes are not drawn (the visual is not + in plane mode, or the view is 2D) the control is disabled and says why. + + Wire to the controller after construction:: + + data = get_render_planes_data_from_visual(controller, visual.id) + controls = QtRenderPlanesControls(visual.id, **data) + controller.connect_widget( + controls, subscription_specs=controls.subscription_specs() + ) + + Parameters + ---------- + visual_id : + The visual, or a group of them, edited together. + coordinate_system : + The scene's world coordinate system (its id). + axis_ids, axis_names : + The world axes, in order: their ids and names. + planes : + The visual's current ``render_planes``. + displayed_axes, bounds, state_source, scene_id, data_store_id : + What the scene and the visual's data are now, and a reader of it: + ``get_render_planes_data_from_visual(controller, visual_id)``. + gizmo_target, gizmo_plane, gizmo_blocked : + Where a plane's gizmo is drawn (a ``RenderPlaneGizmoTarget``), the + plane that has it now, and why a plane cannot have one: + ``get_render_plane_gizmo_data(controller, visual_id, canvas_id)``. + Without *gizmo_target* the rows have no gizmo toggle. + title : + The group's name. Defaults to :data:`DEFAULT_TITLE`. + parent : + Optional Qt parent widget. + """ + + DEFAULT_TITLE = RENDER_PLANES_TITLE + """Name shown when no ``title=`` is given.""" + + changed: Signal = Signal(object) + closed: Signal = Signal() + + def __init__( + self, + visual_id: UUID | Sequence[UUID], + *, + coordinate_system: UUID | str, + axis_ids: Sequence[str], + axis_names: Sequence[str], + planes: Sequence[Any] = (), + displayed_axes: Sequence[str] | None = None, + bounds: Sequence[Sequence[float]] | None = None, + state_source: Callable[[], Mapping[str, Any]] | None = None, + scene_id: UUID | str | None = None, + data_store_id: UUID | str | None = None, + gizmo_target: RenderPlaneGizmoTarget | None = None, + gizmo_plane: str | None = None, + gizmo_blocked: Callable[[str], str] | None = None, + title: str | None = None, + parent=None, + ) -> None: + from qtpy.QtWidgets import QLabel, QPushButton, QVBoxLayout, QWidget + + from cellier.gui.qt.visuals._chrome import titled_group + + self._id = uuid4() + self._init_visual_ids(visual_id) + + self._content = QWidget(parent) + column = QVBoxLayout(self._content) + column.setContentsMargins(0, 0, 0, 0) + + # Why the control is disabled; outside the part that is disabled. + self._reason = QLabel(self._content) + self._reason.setWordWrap(True) + self._reason.setVisible(False) + column.addWidget(self._reason) + + self._body = QWidget(self._content) + self._rows_layout = QVBoxLayout(self._body) + self._rows_layout.setContentsMargins(0, 0, 0, 0) + column.addWidget(self._body) + self._rows: list[_PlaneRow] = [] + + self._add = QPushButton("Add plane", self._content) + self._add.setToolTip( + "Add a plane through the middle of the data, facing the first " + "displayed axis. A visual draws at most four." + ) + column.addWidget(self._add) + + self._error = QLabel(self._content) + self._error.setWordWrap(True) + self._error.setStyleSheet("color: #d04040") + self._error.setVisible(False) + column.addWidget(self._error) + + self._group = titled_group( + self.DEFAULT_TITLE if title is None else title, self._content, parent + ) + self._editor = RenderPlanesEditor( + self._visual_ids, + coordinate_system, + axis_ids, + axis_names, + planes, + self._id, + self.changed.emit, + self._show, + displayed_axes=displayed_axes, + bounds=bounds, + state_source=state_source, + scene_id=scene_id, + data_store_id=data_store_id, + gizmo_target=gizmo_target, + gizmo_plane=gizmo_plane, + gizmo_blocked=gizmo_blocked, + ) + self._add.clicked.connect(lambda: self._editor.add()) + self._show(self._editor.rows, "") + + # -- Public interface ------------------------------------------------------ + + @property + def widget(self): + """The widget to insert into a layout: the titled group.""" + return self._group + + @property + def planes(self) -> list[dict[str, Any]]: + """The rows as last reported by the model.""" + return self._editor.rows + + @property + def editor(self) -> RenderPlanesEditor: + """The toolkit-neutral half (for tests and scripting).""" + return self._editor + + @property + def error(self) -> str: + """The reason the last edit was refused, or "".""" + return self._error.text() + + @property + def blocked(self) -> str: + """Why the control is disabled now, or "".""" + return self._editor.blocked + + def row(self, index: int) -> _PlaneRow: + """The widgets of plane *index* (for tests and scripting).""" + return self._rows[index] + + def refresh(self) -> None: + """Read the scene's state again (whether the control is disabled).""" + self._editor.refresh() + + def close(self) -> None: + """Emit ``closed`` to trigger bus unsubscription via the controller.""" + self.closed.emit() + + def subscription_specs(self) -> list[SubscriptionSpec]: + """What the control follows on the bus (see the editor).""" + return self._editor.subscription_specs() + + # -- model -> widget ------------------------------------------------------- + + def _show(self, rows: list[dict[str, Any]], error: str) -> None: + described = self._editor.describe() + while len(self._rows) > len(described): + row = self._rows.pop() + self._rows_layout.removeWidget(row.widget) + row.widget.setParent(None) + row.widget.deleteLater() + while len(self._rows) < len(described): + row = _PlaneRow(self, len(self._rows), self._body) + self._rows.append(row) + self._rows_layout.addWidget(row.widget) + for row, values in zip(self._rows, described): + row.show(values) + blocked = self._editor.blocked + self._reason.setText(blocked) + self._reason.setVisible(bool(blocked)) + self._body.setEnabled(not blocked) + self._add.setEnabled(self._editor.can_add) + self._error.setText(error) + self._error.setVisible(bool(error)) diff --git a/src/cellier/render/_render_planes.py b/src/cellier/render/_render_planes.py index b4995631..41503807 100644 --- a/src/cellier/render/_render_planes.py +++ b/src/cellier/render/_render_planes.py @@ -152,6 +152,97 @@ def reduce_render_planes( ) +def _displayed_block( + spaces: RenderSpaces | None, axes: Sequence[Any] +) -> tuple[np.ndarray, np.ndarray] | None: + """``world on axes -> rendered`` of a 3D view, or ``None``. + + ``None`` when the view is not 3D or *axes* are not the three world axes + it displays. Rendered order is ``(z, y, x)``-like: pygfx order reversed. + """ + if spaces is None: + return None + linear = np.asarray(spaces.world_to_rendered.linear, dtype=np.float64) + if linear.shape[0] != 3: + return None + displayed = set(np.flatnonzero(np.any(linear != 0.0, axis=0)).tolist()) + try: + resolved = [spaces.world.resolve(axis) for axis in axes] + except (KeyError, ValueError): + return None + if set(resolved) != displayed or len(resolved) != 3: + return None + translation = np.asarray(spaces.world_to_rendered.translation, dtype=np.float64) + return linear[:, resolved], translation + + +def rendered_plane_frame( + spaces: RenderSpaces | None, plane: Any +) -> tuple[np.ndarray, np.ndarray, np.ndarray] | None: + """One plane's frame in a 3D view's rendered space, in pygfx order. + + Unlike :func:`reduce_render_planes` the plane's ``enabled`` flag is not + looked at: a gizmo can sit on a disabled plane. + + Returns + ------- + tuple[np.ndarray, np.ndarray, np.ndarray] or None + ``(origin, in_plane_axis_0, in_plane_axis_1)`` in ``(x, y, z)``; + ``None`` when the view does not display the plane's axes. + """ + found = _displayed_block(spaces, plane.axes) + if found is None: + return None + block, translation = found + return ( + (block @ np.asarray(plane.origin, dtype=np.float64) + translation)[::-1], + (block @ np.asarray(plane.in_plane_axis_0, dtype=np.float64))[::-1], + (block @ np.asarray(plane.in_plane_axis_1, dtype=np.float64))[::-1], + ) + + +def expand_rendered_frame( + spaces: RenderSpaces | None, + axes: Sequence[Any], + origin: Any, + in_plane_axis_0: Any, + in_plane_axis_1: Any, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """A rendered-space frame on three named world axes. + + The inverse of :func:`rendered_plane_frame`. + + Parameters + ---------- + spaces : RenderSpaces or None + The systems of the 3D view. + axes : Sequence[AxisRef] + The three world axes to express the frame on, in the order wanted. + origin, in_plane_axis_0, in_plane_axis_1 : array-like + The frame in rendered ``(x, y, z)``. + + Returns + ------- + tuple[np.ndarray, np.ndarray, np.ndarray] + ``(origin, in_plane_axis_0, in_plane_axis_1)`` on *axes*. + + Raises + ------ + ValueError + If the view is not 3D or does not display *axes*. + """ + found = _displayed_block(spaces, axes) + if found is None: + raise ValueError("The 3D view does not display the plane's three axes.") + block, translation = found + inverse = np.linalg.inv(block) + return ( + inverse @ (np.asarray(origin, dtype=np.float64)[::-1] - translation), + inverse @ np.asarray(in_plane_axis_0, dtype=np.float64)[::-1], + inverse @ np.asarray(in_plane_axis_1, dtype=np.float64)[::-1], + ) + + def make_render_planes_buffer() -> gfx.Buffer: """A ``u_render_planes`` uniform buffer holding no plane.""" return gfx.Buffer( diff --git a/src/cellier/render/canvas_view.py b/src/cellier/render/canvas_view.py index 9d418653..435110ef 100644 --- a/src/cellier/render/canvas_view.py +++ b/src/cellier/render/canvas_view.py @@ -953,6 +953,7 @@ def _emit_gizmo(self, gizmo: GFXPlaneGizmo, phase: str) -> None: if self._event_bus is None: return point, normal = gizmo.pose() + axis_0, axis_1, scale = gizmo.frame() self._event_bus.emit( PlaneGizmoMovedEvent( source_id=self._canvas_id, @@ -963,6 +964,9 @@ def _emit_gizmo(self, gizmo: GFXPlaneGizmo, phase: str) -> None: phase=phase, handle_kind=gizmo.handle_kind, handle_axis=gizmo.handle_axis, + in_plane_axis_0=axis_0, + in_plane_axis_1=axis_1, + scale=scale, ) ) diff --git a/src/cellier/render/render_manager.py b/src/cellier/render/render_manager.py index 3cd90819..1f031546 100644 --- a/src/cellier/render/render_manager.py +++ b/src/cellier/render/render_manager.py @@ -11,6 +11,7 @@ from cellier.events._events import AABBChangedEvent, ViewRay, _CanvasRawPointerEvent from cellier.render._clipping import expand_rendered_plane, reduce_clipping_planes from cellier.render._config import RenderManagerConfig +from cellier.render._render_planes import expand_rendered_frame, rendered_plane_frame from cellier.render._scene_config import VisualRenderConfig from cellier.render._visual_lut import ( AO_EXCLUDED_BIT, @@ -1966,6 +1967,69 @@ def canvas_orbit_point(self, canvas_id: UUID) -> tuple[float, float, float] | No """The point a canvas's 3D camera orbits about; ``None`` in 2D.""" return self._canvases[canvas_id].orbit_point() + def _plane_spaces(self, visual_id: UUID) -> Any: + """The render spaces a visual's render planes are reduced with.""" + scene_manager = self._scenes.get(self._visual_to_scene.get(visual_id)) + gfx_visual = ( + None if scene_manager is None else scene_manager.get_visual(visual_id) + ) + return getattr(gfx_visual, "_spaces", None) + + def reduce_render_plane( + self, visual_id: UUID, plane: Any + ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """One render plane of a visual in its 3D view's rendered space. + + Parameters + ---------- + visual_id : UUID + The visual the plane belongs to. + plane : RenderPlane + The plane. Its ``enabled`` flag is not looked at. + + Returns + ------- + tuple[np.ndarray, np.ndarray, np.ndarray] + ``(origin, in_plane_axis_0, in_plane_axis_1)`` in pygfx + ``(x, y, z)`` order. + + Raises + ------ + ValueError + If the visual is not drawn in 3D, or the view does not display + the plane's three axes. + """ + frame = rendered_plane_frame(self._plane_spaces(visual_id), plane) + if frame is None: + raise ValueError( + f"The 3D view of visual {visual_id} does not display the " + "render plane's three axes." + ) + return frame + + def expand_rendered_frame( + self, + visual_id: UUID, + axes: Any, + point: Any, + in_plane_axis_0: Any, + in_plane_axis_1: Any, + ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """A rendered-space frame on three named world axes. + + The inverse of :meth:`reduce_render_plane`: world to rendered is a + reorder of the displayed axes. + + Raises + ------ + ValueError + If the visual is not drawn in 3D, or the view does not display + *axes*. + """ + return expand_rendered_frame( + self._plane_spaces(visual_id), axes, point, in_plane_axis_0, in_plane_axis_1 + ) + def add_plane_gizmo( self, canvas_id: UUID, @@ -1974,11 +2038,27 @@ def add_plane_gizmo( normal: Any, *, screen_size: float = 100.0, + in_plane_axes: tuple[Any, Any] | None = None, + scale_axes: Any = (), ) -> None: """Draw a plane gizmo in a canvas, placed at a rendered-space pose. Its drags are reported as ``PlaneGizmoMovedEvent`` with *gizmo_id*. + Parameters + ---------- + canvas_id, gizmo_id : UUID + The canvas, and the id the gizmo's events carry. + point, normal : array-like + A point on the plane and its normal, in rendered ``(x, y, z)``. + screen_size : float + The gizmo's size on screen, in logical pixels. + in_plane_axes : tuple[array-like, array-like] or None + The gizmo's two in-plane axes. Given, they fix its whole frame + and *normal* is not used. + scale_axes : Iterable[int] + The in-plane axes (1 and 2) that get a scale handle. + Raises ------ KeyError @@ -1986,12 +2066,50 @@ def add_plane_gizmo( ValueError If the canvas already has a gizmo with that id. """ - self._canvases[canvas_id].add_plane_gizmo( + gizmo = self._canvases[canvas_id].add_plane_gizmo( gizmo_id, point, normal, screen_size=screen_size ) + if in_plane_axes is not None: + gizmo.set_frame(point, *in_plane_axes) + gizmo.set_scale_axes(scale_axes) # The handles take no ambient occlusion: their ids join the table. self._sync_visual_lut() + def set_plane_gizmo_frame( + self, + canvas_id: UUID, + gizmo_id: UUID, + point: Any, + in_plane_axis_0: Any, + in_plane_axis_1: Any, + ) -> None: + """Give a plane gizmo a whole frame; a gizmo that is gone is ignored.""" + canvas = self._canvases.get(canvas_id) + if canvas is None or gizmo_id not in canvas._plane_gizmos: + return + canvas.get_plane_gizmo(gizmo_id).set_frame( + point, in_plane_axis_0, in_plane_axis_1 + ) + canvas.request_draw() + + def set_plane_gizmo_scale_axes( + self, canvas_id: UUID, gizmo_id: UUID, axes: Any + ) -> None: + """Choose which in-plane axes of a gizmo have a scale handle.""" + canvas = self._canvases.get(canvas_id) + if canvas is None or gizmo_id not in canvas._plane_gizmos: + return + canvas.get_plane_gizmo(gizmo_id).set_scale_axes(axes) + canvas.request_draw() + + def reset_plane_gizmo_scale(self, canvas_id: UUID, gizmo_id: UUID) -> None: + """Put a gizmo's scale back to 1; a gizmo that is gone is ignored.""" + canvas = self._canvases.get(canvas_id) + if canvas is None or gizmo_id not in canvas._plane_gizmos: + return + canvas.get_plane_gizmo(gizmo_id).reset_scale() + canvas.request_draw() + def set_plane_gizmo_pose( self, canvas_id: UUID, gizmo_id: UUID, point: Any, normal: Any ) -> None: diff --git a/src/cellier/render/visuals/_plane_gizmo.py b/src/cellier/render/visuals/_plane_gizmo.py index 071df635..57b8b3c0 100644 --- a/src/cellier/render/visuals/_plane_gizmo.py +++ b/src/cellier/render/visuals/_plane_gizmo.py @@ -31,15 +31,21 @@ class PlaneTransformGizmo(gfx.TransformGizmo): - Every element is drawn with ``depth_test=False``: inside a volume the volume would otherwise win the pick and the handles could not be grabbed. - - The scale handles and the centre sphere are hidden. A plane has no - scale, and in pygfx a drag on the sphere is a uniform scale and a - click on it changes the frame. + - The centre sphere is hidden: in pygfx a drag on it is a uniform scale + and a click on it changes the frame. + - The scale handles are hidden except those of ``scale_axes``. A + clipping plane has no size; a render plane has one along each + in-plane axis that is bounded on both sides (plane rendering design + v3, 8.3). Axis 0, the normal, never has one. + - A scale drag along a handle's arrow always grows the object, whatever + the camera angle (see :meth:`_handle_scale_move`). - The frame stays ``"object"``: axis 0 is the normal. - It tells its owner when a handle is grabbed, moved and released. Relies on private pygfx names: ``_ref``, ``_camera``, ``_viewport``, - ``_update_visibility``, ``_highlight``, ``_center_sphere`` and - ``_scale_children``. + ``_update_visibility``, ``_highlight``, ``_center_sphere``, + ``_scale_children``, ``_handle_scale_move``, ``_object_to_control`` and + ``gizmo_scale``. Parameters ---------- @@ -54,6 +60,8 @@ class PlaneTransformGizmo(gfx.TransformGizmo): def __init__( self, target: gfx.WorldObject, owner: GFXPlaneGizmo, screen_size: float ) -> None: + #: The axes (1 and 2, the in-plane ones) whose scale handle is shown. + self.scale_axes: frozenset[int] = frozenset() super().__init__(target, screen_size=screen_size) self._owner = owner self.toggle_mode("object") @@ -63,8 +71,34 @@ def __init__( def _update_visibility(self) -> None: super()._update_visibility() self._center_sphere.visible = False - for element in self._scale_children: - element.visible = False + for dim, element in enumerate(self._scale_children): + if dim not in self.scale_axes: + element.visible = False + + def _handle_scale_move(self, event: Any) -> None: + """Scale as pygfx does, with the drag's direction put right. + + pygfx flips a handle that would point away from the camera and + means its scale drag to flip with it. It reads the flip from the + sign of the gizmo's ``local.scale``, but a node's matrix is kept as + translation, rotation and scale, and a flip does not come back from + it as a negative scale on the axis that was flipped (two flipped + axes come back as a half turn). ``gizmo_scale`` is the array the + flip was written to, so its sign is the truth: where the two + disagree, the factor pygfx applied is inverted. + """ + super()._handle_scale_move(event) + dim = self._ref["dim"] + if dim is None: + return + assumed = self.local.scale[dim] < 0 + flipped = self.gizmo_scale[dim] < 0 + if assumed != flipped: + target = self._object_to_control + scale = np.array(target.local.scale, dtype=np.float64) + start = float(self._ref["scale"][dim]) + scale[dim] = start * start / scale[dim] + target.local.scale = scale def process_event(self, event: Any) -> None: """Handle the event as pygfx does, then report what it did.""" @@ -173,6 +207,70 @@ def pose(self) -> tuple[tuple[float, float, float], tuple[float, float, float]]: (float(normal[0]), float(normal[1]), float(normal[2])), ) + def frame( + self, + ) -> tuple[ + tuple[float, float, float], tuple[float, float, float], tuple[float, float] + ]: + """The proxy's in-plane axes and its scale along them. + + Returns + ------- + tuple + ``(in_plane_axis_0, in_plane_axis_1, scale)``: the proxy's local + ``+y`` and ``+z`` as unit vectors in rendered ``(x, y, z)``, and + the size of its scale along each (1.0 unless a scale handle is + held). + """ + rotation = self.proxy.world.rotation + axes = [] + for local in ((0.0, 1.0, 0.0), (0.0, 0.0, 1.0)): + axis = la.vec_transform_quat(local, rotation) + axis = axis / np.linalg.norm(axis) + axes.append((float(axis[0]), float(axis[1]), float(axis[2]))) + scale = self.proxy.local.scale + return axes[0], axes[1], (abs(float(scale[1])), abs(float(scale[2]))) + + def set_frame(self, point: Any, in_plane_axis_0: Any, in_plane_axis_1: Any) -> None: + """Place the proxy at *point* with its whole frame given. + + The proxy's local ``+y`` and ``+z`` become the two in-plane axes + and its ``+x`` their cross product, the normal. + + Parameters + ---------- + point : array-like + A point on the plane, in rendered ``(x, y, z)``. + in_plane_axis_0, in_plane_axis_1 : array-like + Two orthogonal directions in the plane. Any length but zero. + + Raises + ------ + ValueError + If the axes are zero or parallel. + """ + axis_0 = np.asarray(in_plane_axis_0, dtype=np.float64) + axis_1 = np.asarray(in_plane_axis_1, dtype=np.float64) + normal = np.cross(axis_0, axis_1) + if float(np.linalg.norm(normal)) == 0.0: + raise ValueError("A plane's in-plane axes must not be zero or parallel.") + normal = normal / np.linalg.norm(normal) + axis_0 = axis_0 / np.linalg.norm(axis_0) + # Exactly orthogonal, so the matrix is a rotation. + axis_1 = np.cross(normal, axis_0) + matrix = np.eye(4) + matrix[:3, 0], matrix[:3, 1], matrix[:3, 2] = normal, axis_0, axis_1 + self.proxy.local.rotation = la.quat_from_mat(matrix) + self.proxy.local.position = tuple(float(v) for v in point) + + def set_scale_axes(self, axes: Any) -> None: + """Show the scale handles of the in-plane *axes* (1, 2) and no other.""" + self.gizmo.scale_axes = frozenset(int(a) for a in axes if int(a) in (1, 2)) + + def reset_scale(self) -> None: + """Put the proxy's scale back to 1 (a scale drag was folded in).""" + self.proxy.local.scale = (1.0, 1.0, 1.0) + def set_pose(self, point: Any, normal: Any) -> None: """Place the proxy at *point*, its normal along *normal*. diff --git a/tests/convenience/test_appearance_specs.py b/tests/convenience/test_appearance_specs.py index 71020067..807594a0 100644 --- a/tests/convenience/test_appearance_specs.py +++ b/tests/convenience/test_appearance_specs.py @@ -352,7 +352,7 @@ class _FakeLabelsConfig(BaseControlsConfig): # the in-memory and the multiscale render-mode vocabularies. assert labels_result.specs[0].values == { "initial_value": "iso_categorical", - "choices": ("iso_categorical", "flat_categorical"), + "choices": ("iso_categorical", "flat_categorical", "plane"), } diff --git a/tests/convenience/test_clipping_planes_viewers.py b/tests/convenience/test_clipping_planes_viewers.py index 8bcb5897..129edf0c 100644 --- a/tests/convenience/test_clipping_planes_viewers.py +++ b/tests/convenience/test_clipping_planes_viewers.py @@ -128,15 +128,15 @@ def test_the_viewer_puts_a_gizmo_on_a_plane(qtbot): (canvas_id,) = viewer.canvases session = viewer.add_clipping_plane_gizmo(visual, plane) assert (session.canvas_id, session.plane_id) == (canvas_id, plane.id) - assert viewer.controller.get_clipping_plane_gizmo(canvas_id) is session + assert viewer.controller.get_plane_gizmo(canvas_id) is session # By id, on the other plane: the canvas's one gizmo moves there. second = viewer.add_clipping_plane_gizmo(visual.id, other.id) assert session.closed - viewer.remove_clipping_plane_gizmo() + viewer.remove_plane_gizmo() assert second.closed - assert viewer.controller.get_clipping_plane_gizmo(canvas_id) is None - viewer.remove_clipping_plane_gizmo() # none: nothing + assert viewer.controller.get_plane_gizmo(canvas_id) is None + viewer.remove_plane_gizmo() # none: nothing finally: viewer.controller.close() @@ -181,7 +181,7 @@ def test_the_ortho_gizmo_is_on_the_3d_panel_and_the_2d_panels_follow(qtbot): assert data["gizmo_target"].scene_id == ortho.scenes["vol"].id assert data["gizmo_plane"] == str(plane.id) - ortho.remove_clipping_plane_gizmo() + ortho.remove_plane_gizmo() assert session.closed finally: ortho.controller.close() diff --git a/tests/convenience/test_non_image_controls.py b/tests/convenience/test_non_image_controls.py index ad692223..d033593c 100644 --- a/tests/convenience/test_non_image_controls.py +++ b/tests/convenience/test_non_image_controls.py @@ -351,7 +351,7 @@ def test_the_labels_combo_offers_the_models_own_render_modes(qtbot, labels_store spec = appearance_specs(visual, viewer._controls_configs[visual.id]).specs[0] widget = QtLabelsRenderModeCombo(visual.id, **spec.values) - assert widget.choices == ("iso_categorical", "flat_categorical") + assert widget.choices == ("iso_categorical", "flat_categorical", "plane") assert "mip" not in widget.choices @@ -515,6 +515,7 @@ def test_composite_default_titles_match_the_shared_vocabulary(): AnywidgetLoadingIndicator, AnywidgetLodConfigControls, AnywidgetMeshSectionControls, + AnywidgetRenderPlanesControls, AnywidgetTrailControls, ) from cellier.gui.qt import QtDatasetInfo @@ -533,6 +534,7 @@ def test_composite_default_titles_match_the_shared_vocabulary(): QtLoadingIndicator, QtLodConfigControls, QtMeshSectionControls, + QtRenderPlanesControls, QtTrailControls, ) @@ -548,6 +550,7 @@ def test_composite_default_titles_match_the_shared_vocabulary(): QtClippingPlanesControls, AnywidgetClippingPlanesControls, ), + "render_planes": (QtRenderPlanesControls, AnywidgetRenderPlanesControls), "lod_config": (QtLodConfigControls, AnywidgetLodConfigControls), "visual_outline": (QtVisualOutlineControls, AnywidgetVisualOutlineControls), "labels_outline": (QtLabelsOutlineControls, AnywidgetLabelsOutlineControls), diff --git a/tests/convenience/test_ortho_clipping_controls.py b/tests/convenience/test_ortho_clipping_controls.py index 70dc2eb5..63030f94 100644 --- a/tests/convenience/test_ortho_clipping_controls.py +++ b/tests/convenience/test_ortho_clipping_controls.py @@ -256,7 +256,7 @@ def test_the_gizmo_toggle_follows_the_canvas_across_modes( (all_views,) = widget.widgets all_views.editor.set_gizmo(0, True) - session = controller.get_clipping_plane_gizmo(vol_canvas) + session = controller.get_plane_gizmo(vol_canvas) assert (session.visual_id, session.plane_id) == (visuals["vol"].id, plane.id) assert all_views.editor.gizmo_plane == str(plane.id) @@ -268,7 +268,7 @@ def test_the_gizmo_toggle_follows_the_canvas_across_modes( assert not session.closed volume.editor.set_gizmo(0, False) - assert controller.get_clipping_plane_gizmo(vol_canvas) is None + assert controller.get_plane_gizmo(vol_canvas) is None ortho.clipping_controller.mode = "all" assert all_views.editor.gizmo_plane is None diff --git a/tests/convenience/test_plane_rendering_examples.py b/tests/convenience/test_plane_rendering_examples.py new file mode 100644 index 00000000..f91f7afb --- /dev/null +++ b/tests/convenience/test_plane_rendering_examples.py @@ -0,0 +1,443 @@ +"""The plane rendering examples build, draw a frame and exit. + +``examples/plane_rendering/`` (plane rendering design 10.3, 14.12). Each Qt +example builds its viewer and its layout at import and calls ``run`` only +under ``__main__``, so a test imports it, renders the layout into a window, +loads the data, runs the viewer's ready callbacks and captures the window. +""" + +from __future__ import annotations + +import importlib.util +import itertools +import tempfile +from pathlib import Path + +import numpy as np +import pytest + +from cellier.convenience import screenshot_window +from cellier.convenience.layout._qt_renderer import render_qt +from tests.render.conftest import drain_loading + +EXAMPLES = Path(__file__).parents[2] / "examples" / "plane_rendering" + +QT_EXAMPLES = ( + "plane_gizmo", + "plane_interaction_script", + "plane_labels_composite", + "ortho_slice_planes", + "plane_slicing_validation", +) + + +@pytest.fixture +def load_example(monkeypatch, tmp_path, qtbot): + """Import an example by file name, its temporary stores under tmp_path. + + The modules are kept until teardown, and each keeps its window (see + ``_show``): a window dropped without being closed takes its controls' + widgets with it and leaves them subscribed to the controller. + """ + counter = itertools.count() + + def _mkdtemp(*args, **kwargs) -> str: + path = tmp_path / f"example_{next(counter)}" + path.mkdir() + return str(path) + + monkeypatch.setattr(tempfile, "mkdtemp", _mkdtemp) + loaded = [] + + def _load(name: str): + path = EXAMPLES / f"{name}.py" + spec = importlib.util.spec_from_file_location(f"_example_{name}", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + loaded.append(module) + return module + + yield _load + for module in loaded: + module.viewer.controller.close() + + +@pytest.fixture +def run_marimo_example(monkeypatch, load_example): + """Run every cell of a marimo twin; returns what its cells defined. + + ``app.run()`` runs the cells as a script, where marimo does not report a + running notebook, so the host is named for ``display``. + """ + pytest.importorskip("marimo") + viewers = [] + + def _run(name: str) -> dict: + monkeypatch.setattr("cellier.convenience._hosts._marimo_running", lambda: True) + module = load_example(f"{name}_marimo") + outputs, defs = module.app.run() + # What load_example closes. + module.viewer = defs["viewer"] + viewers.append(defs["viewer"]) + # The last thing shown before any button is the composed layout. + assert any(type(output).__name__.endswith("Html") for output in outputs) + return defs + + return _run + + +def _scenes(viewer) -> list: + scenes = getattr(viewer, "scenes", None) + return list(scenes.values()) if scenes is not None else [viewer.scene] + + +async def _show(module, qtbot): + """Do what ``run`` does up to the first loaded frame; return the window.""" + viewer = module.viewer + controller = viewer.controller + window = render_qt(module.layout, viewer) + module.window = window + qtbot.addWidget(window) + window.resize(1000, 700) + window.show() + for scene in _scenes(viewer): + controller.fit_camera(scene.id) + controller.reslice_scene(scene.id) + await drain_loading(controller) + # fit="ready" fits again once the data is loaded. + for scene in _scenes(viewer): + controller.fit_camera(scene.id) + await drain_loading(controller) + for callback in viewer._ready_callbacks: + callback() + return window + + +def _drawn_colours(window, controller) -> int: + """How many distinct colours the captured window holds.""" + picture = screenshot_window(window, controller) + return len(np.unique(picture.reshape(-1, picture.shape[-1]), axis=0)) + + +def test_every_example_is_listed(): + scripts = { + path.stem for path in EXAMPLES.glob("*.py") if not path.stem.endswith("_marimo") + } + assert scripts == set(QT_EXAMPLES) + twins = {path.stem for path in EXAMPLES.glob("*_marimo.py")} + assert twins == {f"{name}_marimo" for name in QT_EXAMPLES} + + +@pytest.mark.parametrize("name", QT_EXAMPLES) +async def test_the_example_builds_and_draws(name, load_example, qtbot, offscreen_gpu): + module = load_example(name) + window = await _show(module, qtbot) + # A blank canvas beside the dock's widgets is a few dozen colours; the + # data drawn through a colour map is hundreds. + assert _drawn_colours(window, module.viewer.controller) > 200 + + +# -- What each example is there to show ---------------------------------------- + + +def _canvas_id(viewer): + (canvas_id,) = viewer.canvases + return canvas_id + + +async def test_plane_gizmo_puts_a_gizmo_on_its_plane(load_example, qtbot): + module = load_example("plane_gizmo") + await _show(module, qtbot) + session = module.controller.get_plane_gizmo(_canvas_id(module.viewer)) + assert session is not None + assert session.kind == "render" + assert session.plane_id == module.plane.id + assert module.visual.plane_mode() + # Both extents are bounded, so both scale handles are there. + assert all(None not in extent for extent in _extents(module.plane)) + + +def _extents(plane): + return (plane.extent_0, plane.extent_1) + + +async def test_the_scripted_motion_is_one_interaction_and_one_plan( + load_example, qtbot, capsys +): + module = load_example("plane_interaction_script") + controller, visual = module.controller, module.visual + log: list[str] = [] + owner_id = controller._id + controller.on_plane_interaction( + visual.id, lambda event: log.append(event.phase), owner_id=owner_id + ) + await _show(module, qtbot) + controller.on_reslice_started( + module.viewer.scene.id, + lambda event: visual.id in event.visual_ids and log.append("plan"), + owner_id=owner_id, + ) + (motion,) = module.motions + await motion + await drain_loading(controller) + + assert log == ["start", "end", "plan"] + # Both planes are where the motion ends. + assert visual.render_planes[0].origin[0] == module.Z_END + assert visual.clipping_planes[0].plane.offset == module.X_END + printed = capsys.readouterr().out + assert "plane interaction: start" in printed + assert "plane interaction: end (release)" in printed + + +async def test_the_scripted_motion_without_a_scope_plans_every_step( + load_example, qtbot, monkeypatch +): + module = load_example("plane_interaction_script") + monkeypatch.setattr(module, "USE_SCOPE", False) + monkeypatch.setattr(module, "STEPS", 3) + controller, visual = module.controller, module.visual + await _show(module, qtbot) + log: list[str] = [] + owner_id = controller._id + controller.on_plane_interaction( + visual.id, lambda event: log.append(event.phase), owner_id=owner_id + ) + controller.on_reslice_started( + module.viewer.scene.id, + lambda event: visual.id in event.visual_ids and log.append("plan"), + owner_id=owner_id, + ) + (motion,) = module.motions + await motion + await drain_loading(controller) + # Six jumps. The first step of each motion is where its plane already + # is, which changes nothing. + assert log == ["plan"] * 4 + + +async def test_the_composite_and_the_labels_are_on_their_own_planes( + load_example, qtbot +): + module = load_example("plane_labels_composite") + await _show(module, qtbot) + image, labels = module.image_visual, module.labels_visual + assert image.composite + assert image.plane_mode() + assert labels.plane_mode() + assert {channel.render_mode for channel in image.channels.values()} == {"plane"} + assert len(image.render_planes) == len(labels.render_planes) == 2 + # The labels' planes are where the labels are, in world. + assert labels.render_planes[0].origin[2] == module.LABELS_X + module.SIDE / 2 + extent = module.viewer.controller.visual_world_extent(labels.id) + low, high = extent[-1] + assert low < labels.render_planes[0].origin[2] < high + + +async def test_the_ortho_example_ties_three_planes_to_the_sliders(load_example, qtbot): + module = load_example("ortho_slice_planes") + await _show(module, qtbot) + viewer, vol = module.viewer, module.vol_visual + assert viewer.plane_controller.mode == "slices" + assert vol.plane_mode() + half = module.SIDE / 2 + assert [plane.origin[k] for k, plane in enumerate(vol.render_planes)] == [half] * 3 + # Only the 3D panel's visual carries planes. + for name in ("xy", "xz", "yz"): + assert module.visuals[name].render_planes == () + viewer.set_slice_positions({0: 20.0}) + assert vol.render_planes[0].origin[0] == 20.0 + assert [plane.origin[k] for k, plane in enumerate(vol.render_planes)][1:] == [ + half, + half, + ] + + +# -- The validation scene: points, labels and image agree at every time -------- + +_SHOT = 480 + + +def _look_down_z(module) -> None: + """Look at the volume along -z. + + The plane faces z, so everything on it is at one depth and a disc on it + is a circle on screen, centred where its centre projects. + """ + viewer = module.viewer + # Rendered space is (x, y, z). + viewer.controller.look_at_visual( + module.image_visual.id, + _canvas_id(viewer), + view_direction=(0, 0, -1), + up=(0, 1, 0), + ) + + +async def _only(module, visual) -> np.ndarray: + """A picture of the scene with one of the three visuals drawn.""" + viewer = module.viewer + for other in (module.image_visual, module.labels_visual, module.points_visual): + other.appearance.visible = other is visual + viewer.controller.reslice_scene(viewer.scene.id) + await drain_loading(viewer.controller) + return viewer.screenshot(size=(_SHOT, _SHOT))[..., :3].astype(int) + + +def _blob(picture: np.ndarray, color) -> tuple[np.ndarray, int]: + """The centroid ``(row, col)`` and the size of the pixels of one colour.""" + wanted = np.round(np.asarray(color, dtype=float) * 255).astype(int) + rows, cols = np.nonzero((np.abs(picture - wanted) <= 3).all(axis=-1)) + if rows.size == 0: + return np.array([np.nan, np.nan]), 0 + return np.array([rows.mean(), cols.mean()]), int(rows.size) + + +async def test_the_validation_scene_lines_up_at_every_time_point( + load_example, qtbot, offscreen_gpu +): + module = load_example("plane_slicing_validation") + await _show(module, qtbot) + viewer, controller = module.viewer, module.viewer.controller + image, labels, points = ( + module.image_visual, + module.labels_visual, + module.points_visual, + ) + # The frame and the gizmo are not part of what is compared. + viewer.remove_plane_gizmo() + image.aabb.enabled = False + controller.update_background_field(viewer.scene.id, "visible", False) + _look_down_z(module) + + upper, lower = range(4, 8), range(4) + for t in range(module.N_TIMES): + viewer.set_slice_positions({0: float(t)}) + # The plane through the four points of the upper half of z. It is + # given to the labels; the example carries it over to the image. + z = float(module.POSITIONS[t, 4, 0]) + plane = labels.render_planes[0].model_copy( + update={"origin": (z, module.SIDE / 2, module.SIDE / 2)} + ) + controller.set_render_plane(labels.id, plane.id, plane) + assert image.render_planes == labels.render_planes + await drain_loading(controller) + + of_points = await _only(module, points) + of_labels = await _only(module, labels) + of_image = await _only(module, image) + + for octant in upper: + color = module.COLORS[t, octant] + marker, marker_size = _blob(of_points, color) + label, label_size = _blob(of_labels, color) + sphere, sphere_size = _blob(of_image, color * module.IMAGE_INTENSITY) + assert marker_size > 0, (t, octant) + # The label and the image are centred on the marker. + assert np.abs(label - marker).max() < 1.5, (t, octant, label, marker) + assert np.abs(sphere - marker).max() < 1.5, (t, octant, sphere, marker) + # And are discs of the two radii: a sphere cut through its centre. + ratio = (module.IMAGE_RADIUS / module.LABEL_RADIUS) ** 2 + assert sphere_size / label_size == pytest.approx(ratio, rel=0.15) + # A circle on screen, though a voxel is twice as deep as wide. + rows, cols = np.nonzero( + (np.abs(of_image - np.round(color * 127.5)) <= 3).all(axis=-1) + ) + assert np.ptp(rows) == pytest.approx(np.ptp(cols), abs=3) + + # The plane does not reach the lower half: its markers are drawn + # (a marker is not on the plane) and nothing else of theirs is. + for octant in lower: + color = module.COLORS[t, octant] + assert _blob(of_points, color)[1] > 0 + assert _blob(of_labels, color)[1] == 0 + assert _blob(of_image, color * module.IMAGE_INTENSITY)[1] == 0 + + # Nothing of another time point is drawn by any of the three. + for other in range(module.N_TIMES): + if other == t: + continue + for octant in range(8): + color = module.COLORS[other, octant] + assert _blob(of_points, color)[1] == 0, (t, other, octant) + assert _blob(of_labels, color)[1] == 0, (t, other, octant) + dim = color * module.IMAGE_INTENSITY + assert _blob(of_image, dim)[1] == 0, (t, other, octant) + + +def test_the_validation_scene_moves_its_points_and_changes_its_colours( + load_example, qtbot +): + module = load_example("plane_slicing_validation") + positions, colors = module.POSITIONS, module.COLORS + for t in range(module.N_TIMES): + for octant in range(8): + # In its octant, with room for the image sphere. + centre = np.array( + [0.25 + 0.5 * ((octant >> (2 - axis)) & 1) for axis in range(3)] + ) + offset = np.abs(positions[t, octant] - centre * module.SIDE) + assert (offset + module.IMAGE_RADIUS < module.SIDE / 4).all() + for a in range(module.N_TIMES): + for b in range(a + 1, module.N_TIMES): + assert (np.abs(positions[a] - positions[b]).max(axis=-1) > 0).all() + # 24 colours, all different, and none the dimmed form of another. + flat = colors.reshape(-1, 3) + both = np.vstack([flat, flat * module.IMAGE_INTENSITY]) + distance = np.abs(both[:, None] - both[None]).max(axis=-1) + assert distance[~np.eye(len(both), dtype=bool)].min() > 8 / 255 + + +# -- The marimo twins ---------------------------------------------------------- + + +async def _load(viewer) -> None: + """Load the viewer's data, as its first frame in a browser would.""" + controller = viewer.controller + for scene in _scenes(viewer): + controller.fit_camera(scene.id) + controller.reslice_scene(scene.id) + await drain_loading(controller) + + +def _colours(picture: np.ndarray) -> int: + return len(np.unique(picture.reshape(-1, picture.shape[-1]), axis=0)) + + +@pytest.mark.parametrize( + "name", [name for name in QT_EXAMPLES if name != "ortho_slice_planes"] +) +async def test_the_marimo_twin_runs_and_its_viewer_draws( + name, run_marimo_example, offscreen_gpu +): + defs = run_marimo_example(name) + viewer = defs["viewer"] + assert viewer.gui == "anywidget" + await _load(viewer) + # The data through a colour map, not a blank frame. + assert _colours(viewer.screenshot(size=(300, 300))) > 50 + + +async def test_the_ortho_marimo_twin_runs_and_its_3d_panel_draws( + run_marimo_example, offscreen_gpu +): + defs = run_marimo_example("ortho_slice_planes") + viewer = defs["viewer"] + assert viewer.plane_controller.mode == "slices" + assert len(defs["visuals"]["vol"].render_planes) == 3 + await _load(viewer) + assert _colours(viewer.screenshot(panel="vol", size=(300, 300))) > 50 + + +async def test_the_marimo_motion_is_one_interaction_and_one_plan(run_marimo_example): + defs = run_marimo_example("plane_interaction_script") + viewer, log = defs["viewer"], defs["log"] + await _load(viewer) + log.clear() + await defs["move_planes"]() + await drain_loading(viewer.controller) + assert log == [ + "plane interaction: start", + "plane interaction: end (release)", + "plan", + ] diff --git a/tests/convenience/test_render_planes_viewers.py b/tests/convenience/test_render_planes_viewers.py new file mode 100644 index 00000000..389df7fb --- /dev/null +++ b/tests/convenience/test_render_planes_viewers.py @@ -0,0 +1,680 @@ +"""Render planes through the convenience viewers (plane rendering design 10). + +``Viewer`` arguments, the gizmo method and the dock's control (10.1); +``OrthoViewer`` pass-through and ``OrthoPlaneController``'s ``"slices"`` +mode (10.2). +""" + +from __future__ import annotations + +import warnings +from uuid import UUID, uuid4 + +import numpy as np +import pytest + +from cellier.convenience import ( + InMemoryImageControlsConfig, + LabelsControlsConfig, + OrthoViewer, + Viewer, +) +from cellier.convenience._ortho_planes import SLICES_REASON +from cellier.data import ImageMemoryStore, LabelMemoryStore, PointsMemoryStore +from cellier.events import PlaneInteractionEvent, RenderPlanesChangedEvent +from cellier.scene.dims import spatial_axes +from cellier.visuals import ( + ClippingPlane, + InMemoryImageSingleAppearance, + InMemoryLabelsAppearance, + MultiscaleImageAppearance, + MultiscaleImageRenderConfig, + MultiscaleImageSingleAppearance, + RenderPlane, +) +from tests import _plane_fixtures as fx +from tests._gpu_budget import SMALL_BUDGETS +from tests.render.conftest import drain_loading + +AXES = ("z", "y", "x") +SHAPE = (8, 12, 16) +SETTLE_S = 0.03 + + +def _image() -> ImageMemoryStore: + data = np.random.default_rng(0).random(SHAPE, dtype=np.float32) + return ImageMemoryStore(data=data) + + +def _plane_single() -> InMemoryImageSingleAppearance: + return InMemoryImageSingleAppearance(render_mode="plane") + + +def _world(viewer): + scene = viewer.scenes["vol"] if hasattr(viewer, "scenes") else viewer.scene + return scene.dims.world_coordinate_system + + +def _plane(viewer, point=(4.0, 6.0, 8.0), normal=(1, 0, 0), **kwargs) -> RenderPlane: + return RenderPlane.from_point_normal( + _world(viewer), point, normal, axes=AXES, **kwargs + ) + + +@pytest.fixture +def make_viewer(qtbot): + made = [] + + def _make(cls=Viewer, **kwargs): + if cls is Viewer: + kwargs.setdefault("dim", "3d") + viewer = cls(spatial_axes(*AXES), gui="offscreen", **kwargs) + viewer.controller.camera_reslice_enabled = False + made.append(viewer) + return viewer + + yield _make + for viewer in made: + viewer.controller.close() + + +def _normals(planes) -> list[list[float]]: + return [ + np.round(np.cross(p.in_plane_axis_0, p.in_plane_axis_1), 9).tolist() + for p in planes + ] + + +def _positions(planes) -> list[float]: + """Where each of three slice planes sits along its own axis.""" + return [float(plane.origin[k]) for k, plane in enumerate(planes)] + + +# -- Viewer (10.1) -------------------------------------------------------------- + + +def test_the_viewer_passes_render_planes_and_the_mode(make_viewer): + viewer = make_viewer() + plane = _plane(viewer) + image = viewer.add_image(_image(), single=_plane_single(), render_planes=(plane,)) + assert image.render_planes == (plane,) + assert image.plane_mode() + labels = viewer.add_labels( + LabelMemoryStore(data=np.ones(SHAPE, np.int32)), + appearance=InMemoryLabelsAppearance(render_mode="plane"), + render_planes=(plane,), + ) + assert labels.render_planes == (plane,) + assert labels.plane_mode() + # A bad plane is refused as by the controller: another world system. + other = make_viewer() + with pytest.raises(ValueError, match="world coordinate system"): + viewer.add_image(_image(), render_planes=(_plane(other),)) + + +def test_the_viewer_passes_render_planes_to_the_multiscale_visuals( + make_viewer, tmp_path +): + viewer = make_viewer() + plane = _plane(viewer) + fx.write_pyramid(tmp_path / "image", fx.ISO) + store, _ = fx.open_pyramid(tmp_path / "image", fx.ISO) + image = viewer.add_image_multiscale( + store, + single=MultiscaleImageSingleAppearance(render_mode="plane"), + render_config=MultiscaleImageRenderConfig(**SMALL_BUDGETS, block_size=16), + render_planes=(plane,), + ) + assert image.render_planes == (plane,) + assert image.plane_mode() + + +async def test_the_viewer_puts_a_gizmo_on_a_render_plane(make_viewer): + viewer = make_viewer() + plane, other = _plane(viewer), _plane(viewer, normal=(0, 1, 0)) + visual = viewer.add_image( + _image(), single=_plane_single(), render_planes=(plane, other) + ) + viewer.add_canvas() + (canvas_id,) = viewer.canvases + session = viewer.add_render_plane_gizmo(visual, plane) + assert (session.kind, session.canvas_id, session.plane_id) == ( + "render", + canvas_id, + plane.id, + ) + assert viewer.controller.get_plane_gizmo(canvas_id) is session + # By id, on the other plane: the canvas's one gizmo moves there. + second = viewer.add_render_plane_gizmo(visual.id, other.id) + assert session.closed + # The canvas's one gizmo serves both kinds: a clipping gizmo takes it. + store = viewer.controller.get_data_store(UUID(str(visual.data_store_id))) + item = ClippingPlane.from_point_normal( + store.data_coordinate_system, (4, 6, 8), (0, 0, 1), enabled=False + ) + visual.clipping_planes = (item,) + clipping = viewer.add_clipping_plane_gizmo(visual, item) + assert second.closed + viewer.remove_plane_gizmo() + assert clipping.closed + assert viewer.controller.get_plane_gizmo(canvas_id) is None + + +def _dock_controls(viewer, backend="anywidget") -> list: + """Every control of every dock target, as the layout walk builds them.""" + from cellier.convenience._backend import backend_for + from cellier.convenience.layout._shared import appearance_targets + from cellier.convenience.layout._walk import build_appearance_widgets + + built = [] + for target in appearance_targets(viewer): + built += build_appearance_widgets( + target.visual, + target.config, + viewer.controller, + target.visual_ids, + backend=backend_for(backend), + clipping_gizmo_target=getattr(viewer, "_clipping_gizmo_target", None), + render_planes_target=getattr(viewer, "_render_planes_target", None), + ) + return built + + +def _plane_controls(viewer, backend="anywidget") -> list: + return [ + widget + for widget in _dock_controls(viewer, backend) + if type(widget).__name__.endswith("RenderPlanesControls") + ] + + +@pytest.mark.parametrize("backend", ["anywidget", "qt"]) +async def test_the_dock_builds_the_render_planes_control(make_viewer, backend, qtbot): + viewer = make_viewer() + visual = viewer.add_image( + _image(), + single=_plane_single(), + controls=InMemoryImageControlsConfig( + appearance=True, render_plane_controls=True + ), + ) + viewer.add_canvas() + (control,) = _plane_controls(viewer, backend) + if backend == "qt": + qtbot.addWidget(control.widget) + assert control.visual_ids == (visual.id,) + assert control.editor.has_gizmo # the viewer's one canvas + assert control.editor.blocked == "" + # It is wired: an edit reaches the model, and the model's change the rows. + control.editor.add() + assert len(visual.render_planes) == 1 + viewer.controller.set_render_planes(visual.id, ()) + assert control.editor.rows == [] + + +def test_the_control_is_opt_in_and_only_for_visuals_with_planes(make_viewer): + viewer = make_viewer() + viewer.add_image(_image(), controls=InMemoryImageControlsConfig(appearance=True)) + viewer.add_labels( + LabelMemoryStore(data=np.ones(SHAPE, np.int32)), + name="with", + controls=LabelsControlsConfig(appearance=True, render_plane_controls=True), + ) + viewer.add_points(PointsMemoryStore(positions=np.zeros((3, 3), np.float32))) + controls = _plane_controls(viewer) + assert len(controls) == 1 + assert not controls[0].editor.has_gizmo # no canvas: no toggle + # Not in plane mode: built, disabled, with the reason. + assert "render mode" in controls[0].blocked + + +def test_the_flag_needs_the_appearance_dock(make_viewer): + from cellier.convenience.layout._shared import missing_dock_node + + config = InMemoryImageControlsConfig(render_plane_controls=True) + problem = missing_dock_node( + config, frozenset({"AppearanceControls"}), clipping_node="AppearanceControls" + ) + assert "render_plane_controls=True and no appearance controls" in problem + config = InMemoryImageControlsConfig(appearance=True, render_plane_controls=True) + for node in ("AppearanceControls", "OrthoClippingControls"): + assert ( + missing_dock_node( + config, frozenset({"AppearanceControls"}), clipping_node=node + ) + is None + ) + + +# -- OrthoViewer: passing through (10.2) ---------------------------------------- + + +def test_render_planes_go_to_the_3d_panel_only(make_viewer): + ortho = make_viewer(OrthoViewer) + plane = _plane(ortho) + visuals = ortho.add_image(_image(), single=_plane_single(), render_planes=(plane,)) + assert visuals["vol"].render_planes == (plane,) + assert visuals["vol"].plane_mode() + for key in ("xy", "xz", "yz"): + assert visuals[key].render_planes == () + # The mode is on every panel's model and acts in the 3D panel alone: a + # 2D view draws its slice whatever the mode (D-P19). + controller = ortho.controller + assert controller._draws_planes(visuals["vol"]) + assert not any(controller._draws_planes(visuals[key]) for key in ("xy", "xz", "yz")) + labels = ortho.add_labels( + LabelMemoryStore(data=np.ones(SHAPE, np.int32)), + appearance=InMemoryLabelsAppearance(render_mode="plane"), + render_planes=(plane,), + ) + assert labels["vol"].render_planes == (plane,) + assert labels["xy"].render_planes == () + + +async def test_the_ortho_gizmo_is_on_the_3d_panels_visual(make_viewer): + ortho = make_viewer(OrthoViewer) + plane = _plane(ortho) + visuals = ortho.add_image(_image(), single=_plane_single(), render_planes=(plane,)) + controller = ortho.controller + with pytest.raises(ValueError, match="no canvas yet"): + ortho.add_render_plane_gizmo(visuals, plane) + for scene in ortho.scenes.values(): + controller.add_canvas(scene.id) + vol_canvas = controller.get_canvas_ids(ortho.scenes["vol"].id)[0] + # Any panel's visual names the group; the session is on the 3D one. + session = ortho.add_render_plane_gizmo(visuals["xy"], plane) + assert (session.visual_id, session.canvas_id) == (visuals["vol"].id, vol_canvas) + ortho.remove_plane_gizmo() + assert session.closed + + +async def test_the_ortho_dock_shows_the_control_for_the_3d_view_only(make_viewer): + ortho = make_viewer(OrthoViewer) + visuals = ortho.add_image( + _image(), + single=_plane_single(), + controls=InMemoryImageControlsConfig( + appearance=True, render_plane_controls=True + ), + ) + for scene in ortho.scenes.values(): + ortho.controller.add_canvas(scene.id) + group_2d = [visuals[key].id for key in ("xy", "xz", "yz")] + assert ortho._render_planes_target(group_2d) is None + # One control, for the 3D view's group, editing the 3D panel's visual. + (control,) = _plane_controls(ortho) + assert control.visual_ids == (visuals["vol"].id,) + assert control.editor.has_gizmo + control.editor.add() + assert len(visuals["vol"].render_planes) == 1 + assert visuals["xy"].render_planes == () + + +# -- OrthoViewer: the "slices" mode (10.2) -------------------------------------- + + +def _slices(make_viewer, *, mode=True, **kwargs): + ortho = make_viewer(OrthoViewer, **kwargs) + visuals = ortho.add_image(_image(), single=_plane_single()) + ortho.dims_controller.set_slice_positions({0: 3.0, 1: 5.0, 2: 7.0}) + if mode: + ortho.plane_controller.mode = "slices" + return ortho, visuals + + +def test_the_mode_is_off_by_default_and_checked(make_viewer): + ortho = make_viewer(OrthoViewer) + visuals = ortho.add_image(_image(), single=_plane_single()) + assert ortho.plane_controller.mode is None + assert visuals["vol"].render_planes == () # the mode writes nothing unasked + with pytest.raises(ValueError, match="Unknown plane mode"): + ortho.plane_controller.mode = "all" + assert ortho.plane_controller.blocked_reason() == "" + + +def test_turning_the_mode_on_writes_three_planes_at_the_slice_positions(make_viewer): + ortho, visuals = _slices(make_viewer, mode=False) + own = _plane(ortho, normal=(1, 1, 0)) + ortho.controller.set_render_planes(visuals["vol"].id, (own,)) + modes: list = [] + ortho.plane_controller.mode_changed.connect(modes.append) + + ortho.plane_controller.mode = "slices" + planes = visuals["vol"].render_planes + # One plane per 2D panel, facing the axis that panel slices: xy slices + # z, xz slices y, yz slices x. + assert _normals(planes) == [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]] + assert _positions(planes) == [3.0, 5.0, 7.0] + assert tuple(p.id for p in planes) == ortho.plane_controller.plane_ids + assert all(p.extent_0 == p.extent_1 == (None, None) and p.enabled for p in planes) + assert own not in planes # the mode replaced the visual's own plane + assert modes == ["slices"] + for key in ("xy", "xz", "yz"): + assert visuals[key].render_planes == () + ortho.plane_controller.mode = "slices" # the current mode: nothing + assert modes == ["slices"] + + +def test_every_image_and_labels_vol_visual_gets_the_planes(make_viewer): + """D-P62: whatever its render mode; other visual types are left alone.""" + ortho, visuals = _slices(make_viewer) + volume = ortho.add_image(_image(), name="volume") # in a volume mode + labels = ortho.add_labels(LabelMemoryStore(data=np.ones(SHAPE, np.int32))) + points = ortho.add_points(PointsMemoryStore(positions=np.zeros((3, 3), np.float32))) + expected = visuals["vol"].render_planes + assert len(expected) == 3 + assert volume["vol"].render_planes == expected + assert labels["vol"].render_planes == expected + assert not hasattr(points["vol"], "render_planes") + assert set(ortho.plane_controller.visual_ids) == { + visuals["vol"].id, + volume["vol"].id, + labels["vol"].id, + } + # A volume-mode visual carries them and draws them once in plane mode. + assert not volume["vol"].plane_mode() + ortho.controller.set_image_render_mode(volume["vol"].id, "plane") + assert volume["vol"].render_planes == expected + + +@pytest.mark.parametrize(("panel", "axis"), [("xy", 0), ("xz", 1), ("yz", 2)]) +def test_a_slider_moves_its_plane(make_viewer, panel, axis): + ortho, visuals = _slices(make_viewer) + log: list = [] + ortho.controller.on_render_planes_changed( + visuals["vol"].id, log.append, owner_id=uuid4() + ) + before = visuals["vol"].render_planes + # The 2D panel's own slider, as its widget moves it. + ortho.controller.update_slice_indices(ortho.scenes[panel].id, {axis: 6.0}) + after = visuals["vol"].render_planes + expected = [3.0, 5.0, 7.0] + expected[axis] = 6.0 + assert _positions(after) == expected + assert _normals(after) == _normals(before) + assert [p.id for p in after] == [p.id for p in before] # moved, not replaced + # One change of the tuple for one slider move, stamped as the mode's. + assert len(log) == 1 + assert log[0].source_id == ortho.plane_controller.id + + +def test_a_plane_moved_by_hand_moves_its_slider_and_the_other_visuals(make_viewer): + """D-P61: a change made on a visual is carried over, not thrown away.""" + ortho, visuals = _slices(make_viewer) + other = ortho.add_labels(LabelMemoryStore(data=np.ones(SHAPE, np.int32))) + controller = ortho.controller + vol = visuals["vol"] + xz_plane = vol.render_planes[1] + + moved = xz_plane.model_copy(update={"origin": (3.0, 9.0, 7.0)}) + controller.set_render_plane(vol.id, xz_plane.id, moved) + # The xz panel's slider (world y), on every panel: the axes are linked. + for scene in ortho.scenes.values(): + assert scene.dims.selection.slice_indices[1] == 9.0 + assert _positions(vol.render_planes) == [3.0, 9.0, 7.0] + assert other["vol"].render_planes == vol.render_planes + + # The same through a direct assignment, on the other visual. + planes = list(other["vol"].render_planes) + planes[0] = planes[0].model_copy(update={"origin": (1.0, 9.0, 7.0)}) + other["vol"].render_planes = tuple(planes) + assert ortho.scenes["xy"].dims.selection.slice_indices[0] == 1.0 + assert _positions(vol.render_planes) == [1.0, 9.0, 7.0] + assert vol.render_planes == other["vol"].render_planes + + +def test_extents_and_enabled_set_by_hand_are_kept_and_copied(make_viewer): + ortho, visuals = _slices(make_viewer) + other = ortho.add_labels(LabelMemoryStore(data=np.ones(SHAPE, np.int32))) + vol = visuals["vol"] + first = vol.render_planes[0] + edited = first.model_copy(update={"extent_0": (-2.0, 3.0), "enabled": False}) + ortho.controller.set_render_plane(vol.id, first.id, edited) + assert vol.render_planes[0] == edited + assert other["vol"].render_planes == vol.render_planes + # A slider move afterwards keeps them: only the position changes. + ortho.dims_controller.set_slice_position(0, 4.0) + kept = vol.render_planes[0] + assert kept.extent_0 == (-2.0, 3.0) and kept.enabled is False + assert kept.origin[0] == 4.0 + assert other["vol"].render_planes == vol.render_planes + # A visual added later takes the planes the others carry. + late = ortho.add_image(_image(), name="late") + assert late["vol"].render_planes == vol.render_planes + + +def test_a_tuple_that_is_not_the_three_slice_planes_is_put_back(make_viewer): + ortho, visuals = _slices(make_viewer) + controller = ortho.controller + vol = visuals["vol"] + slices = vol.render_planes + tilted = RenderPlane.from_point_normal( + _world(ortho), slices[0].origin, (1, 0.5, 0), axes=AXES, id=slices[0].id + ) + foreign = _plane(ortho) + cases = { + "tilted": (tilted, *slices[1:]), + "removed": slices[:2], + "added": (*slices, foreign), + "replaced": (foreign, *slices[1:]), + "cleared": (), + } + with pytest.warns(UserWarning, match="three slice planes") as caught: + for planes in cases.values(): + controller.set_render_planes(vol.id, planes) + assert vol.render_planes == slices + assert len(caught) == 1 # said once + # No slider moved. + assert ortho.plane_controller.positions() == (3.0, 5.0, 7.0) + + +def test_turning_the_mode_off_leaves_the_planes_free_to_edit(make_viewer): + ortho, visuals = _slices(make_viewer) + vol = visuals["vol"] + slices = vol.render_planes + ortho.plane_controller.mode = None + assert vol.render_planes == slices # left as they are + assert ortho.plane_controller.blocked_reason() == "" + # Free: a tilt stays, a slider no longer moves a plane. + tilted = RenderPlane.from_point_normal( + _world(ortho), slices[0].origin, (1, 0.5, 0), axes=AXES, id=slices[0].id + ) + with warnings.catch_warnings(): + warnings.simplefilter("error") + ortho.controller.set_render_planes(vol.id, (tilted,)) + assert vol.render_planes == (tilted,) + ortho.dims_controller.set_slice_position(0, 6.0) + assert vol.render_planes == (tilted,) + + +async def test_no_gizmo_on_the_slice_planes(make_viewer): + ortho, visuals = _slices(make_viewer) + for scene in ortho.scenes.values(): + ortho.controller.add_canvas(scene.id) + plane = visuals["vol"].render_planes[0] + with pytest.raises(ValueError, match="have no gizmo"): + ortho.add_render_plane_gizmo(visuals, plane) + ortho.plane_controller.mode = None + session = ortho.add_render_plane_gizmo(visuals, plane) + assert not session.closed + # Turning the mode on gives the visual planes of the same ids in the + # same places: the session stays, and the control says why it is off. + ortho.plane_controller.mode = "slices" + assert ortho.plane_controller.blocked_reason() == SLICES_REASON + + +@pytest.mark.parametrize("backend", ["anywidget", "qt"]) +async def test_the_control_shows_the_slice_planes_read_only( + make_viewer, backend, qtbot +): + ortho = make_viewer(OrthoViewer) + visuals = ortho.add_image( + _image(), + single=_plane_single(), + controls=InMemoryImageControlsConfig( + appearance=True, render_plane_controls=True + ), + ) + for scene in ortho.scenes.values(): + ortho.controller.add_canvas(scene.id) + (control,) = _plane_controls(ortho, backend) + if backend == "qt": + qtbot.addWidget(control.widget) + assert control.editor.blocked == "" + + ortho.plane_controller.mode = "slices" + # The mode change reached the control: three rows, disabled, with why. + assert control.editor.blocked == SLICES_REASON + assert len(control.editor.rows) == 3 + described = control.editor.describe() + assert all(row["gizmo_blocked"] == SLICES_REASON for row in described) + assert not control.editor.can_add + if backend == "qt": + assert not control._body.isEnabled() + assert control._reason.text() == SLICES_REASON + assert not control._add.isEnabled() + else: + assert control.blocked == SLICES_REASON + assert control.can_add is False + # An edit is refused with the reason; the rows follow the sliders. + control.editor.set_position(0, 1.0) + assert visuals["vol"].render_planes[0].origin[0] == 0.0 + ortho.dims_controller.set_slice_position(0, 5.0) + assert control.editor.rows[0]["position"] == 5.0 + + ortho.plane_controller.mode = None + assert control.editor.blocked == "" + control.editor.set_position(0, 1.0) + assert visuals["vol"].render_planes[0].origin[0] == 1.0 + + +def test_unlinked_axes_move_the_slicing_panel_and_the_3d_panel(make_viewer): + ortho = make_viewer(OrthoViewer, link_axes=False) + visuals = ortho.add_image(_image(), single=_plane_single()) + controller = ortho.controller + for (key, axis), value in zip( + (("xy", 0), ("xz", 1), ("yz", 2)), (3.0, 5.0, 7.0), strict=True + ): + controller.update_slice_indices(ortho.scenes[key].id, {axis: value}) + ortho.plane_controller.mode = "slices" + vol = visuals["vol"] + # Each plane is at its own panel's position. + assert _positions(vol.render_planes) == [3.0, 5.0, 7.0] + first = vol.render_planes[0] + controller.set_render_plane( + vol.id, first.id, first.model_copy(update={"origin": (6.0, 5.0, 7.0)}) + ) + assert ortho.scenes["xy"].dims.selection.slice_indices[0] == 6.0 + assert ortho.scenes["vol"].dims.selection.slice_indices[0] == 6.0 + assert ortho.scenes["xz"].dims.selection.slice_indices[0] == 0.0 # not linked + + +def test_a_removed_visual_is_forgotten(make_viewer): + ortho, visuals = _slices(make_viewer) + other = ortho.add_labels(LabelMemoryStore(data=np.ones(SHAPE, np.int32))) + ortho.controller.remove_visual(other["vol"].id) + assert ortho.plane_controller.visual_ids == (visuals["vol"].id,) + ortho.dims_controller.set_slice_position(0, 6.0) # nothing raises + assert visuals["vol"].render_planes[0].origin[0] == 6.0 + + +def test_the_clipping_link_is_as_before(make_viewer): + ortho, visuals = _slices(make_viewer) + store = ortho.controller.get_data_store(UUID(str(visuals["vol"].data_store_id))) + item = ClippingPlane.from_point_normal( + store.data_coordinate_system, (4, 6, 8), (0, 0, 1) + ) + visuals["xz"].clipping_planes = (item,) + assert {v.clipping_planes for v in visuals.values()} == {(item,)} + # And the slice planes took no notice. + assert _positions(visuals["vol"].render_planes) == [3.0, 5.0, 7.0] + + +# -- a 2D scrub holds the 3D visual's plan (B9) --------------------------------- + + +def _record_plans(monkeypatch, visual_id) -> list[str]: + from cellier.render.scene_manager import SceneManager + + plans: list[str] = [] + original = SceneManager.plan_chunked + + def spy(self, request, visual_configs): + for planned, cfg in visual_configs.items(): + targets = request.target_visual_ids + if planned == visual_id and (targets is None or planned in targets): + plans.append(cfg.plan_mode.name) + return original(self, request, visual_configs) + + monkeypatch.setattr(SceneManager, "plan_chunked", spy) + return plans + + +async def test_a_2d_scrub_holds_the_vol_plan_and_loads_once( + make_viewer, tmp_path, monkeypatch +): + ortho = make_viewer(OrthoViewer) + controller = ortho.controller + controller._render_manager.config.scheduler.dims_settle_s = SETTLE_S + fx.write_pyramid(tmp_path, fx.ISO) + store, _ = fx.open_pyramid(tmp_path, fx.ISO) + visuals = ortho.add_image_multiscale( + store, + appearance=MultiscaleImageAppearance(force_level=1), + single=MultiscaleImageSingleAppearance( + color_map="viridis", clim=(0.0, 65535.0), render_mode="plane" + ), + render_config=MultiscaleImageRenderConfig(**SMALL_BUDGETS, block_size=16), + ) + vol = visuals["vol"] + for scene in ortho.scenes.values(): + controller.add_canvas(scene.id, canvas_size=(200, 125)) + ortho.dims_controller.set_slice_positions({0: 40.0, 1: 40.0, 2: 40.0}) + ortho.plane_controller.mode = "slices" + for scene in ortho.scenes.values(): + controller.fit_camera(scene.id) + controller.reslice_all() + await drain_loading(controller) + + drags: list = [] + changes: list = [] + owner = uuid4() + controller.on_plane_interaction(vol.id, drags.append, owner_id=owner) + controller.on_render_planes_changed(vol.id, changes.append, owner_id=owner) + plans = _record_plans(monkeypatch, vol.id) + xy = ortho.scenes["xy"] + widget = uuid4() + + # The xy panel's z slider, scrubbed as its widget scrubs it. + controller.begin_dims_interaction(xy.id, source_id=widget) + for z in (44.0, 48.0, 52.0, 56.0): + controller.update_slice_indices( + xy.id, {0: z}, source_id=widget, interactive=True + ) + # The plane follows every tick, and the 3D visual plans nothing. + assert vol.render_planes[0].origin[0] == z + assert plans == [] + assert controller.plane_interaction_state(vol.id) == "active" + assert len(changes) == 4 + controller.end_dims_interaction(xy.id, source_id=widget) + # Released: the target is planned once. + assert plans == ["FULL"] + assert [(e.phase, e.reason) for e in drags] == [ + ("start", None), + ("end", "release"), + ] + assert all(isinstance(e, PlaneInteractionEvent) for e in drags) + assert all(isinstance(e, RenderPlanesChangedEvent) for e in changes) + await drain_loading(controller) + + # With the mode off a scrub is no plane interaction. + ortho.plane_controller.mode = None + drags.clear() + controller.begin_dims_interaction(xy.id, source_id=widget) + controller.update_slice_indices( + xy.id, {0: 30.0}, source_id=widget, interactive=True + ) + controller.end_dims_interaction(xy.id, source_id=widget) + assert drags == [] + assert vol.render_planes[0].origin[0] == 56.0 + await drain_loading(controller) diff --git a/tests/gui/test_appearance_control_types.py b/tests/gui/test_appearance_control_types.py index 77d82bdb..ed5bb8a9 100644 --- a/tests/gui/test_appearance_control_types.py +++ b/tests/gui/test_appearance_control_types.py @@ -393,8 +393,13 @@ def test_choices_come_from_the_models_literal_not_a_restated_list(qtbot): in_memory = literal_choices(InMemoryLabelsAppearance(), "render_mode") multiscale = literal_choices(MultiscaleLabelsAppearance(), "render_mode") - assert in_memory == ("iso_categorical", "flat_categorical") - assert multiscale == ("iso_categorical", "flat_categorical", "smooth_iso") + assert in_memory == ("iso_categorical", "flat_categorical", "plane") + assert multiscale == ( + "iso_categorical", + "flat_categorical", + "smooth_iso", + "plane", + ) visual_id = uuid4() widget = _qt("LabelsRenderModeCombo")(visual_id, choices=multiscale) diff --git a/tests/gui/test_clipping_planes_controls.py b/tests/gui/test_clipping_planes_controls.py index 9289f0e0..9a46235e 100644 --- a/tests/gui/test_clipping_planes_controls.py +++ b/tests/gui/test_clipping_planes_controls.py @@ -633,7 +633,7 @@ def test_a_control_given_no_gizmo_target_draws_no_toggle(controller, toolkit): else: assert "gizmo" not in widget.rows[0] _act(widget, "gizmo", 0, True) # ignored - assert controller._clipping_gizmos == {} + assert controller._plane_gizmos == {} @pytest.mark.parametrize("toolkit", ["qt", "anywidget"]) @@ -644,16 +644,16 @@ def test_the_toggle_puts_the_gizmo_on_one_plane_at_a_time(controller, toolkit): assert _gizmo_states(widget) == [(False, ""), (False, "")] _act(widget, "gizmo", 0, True) - assert controller.get_clipping_plane_gizmo(canvas_id).plane_id == first.id + assert controller.get_plane_gizmo(canvas_id).plane_id == first.id assert _gizmo_states(widget) == [(True, ""), (False, "")] # The other row: the canvas's one gizmo moves there. _act(widget, "gizmo", 1, True) - assert controller.get_clipping_plane_gizmo(canvas_id).plane_id == second.id + assert controller.get_plane_gizmo(canvas_id).plane_id == second.id assert _gizmo_states(widget) == [(False, ""), (True, "")] _act(widget, "gizmo", 1, False) - assert controller.get_clipping_plane_gizmo(canvas_id) is None + assert controller.get_plane_gizmo(canvas_id) is None assert _gizmo_states(widget) == [(False, ""), (False, "")] assert widget.error == "" @@ -680,7 +680,7 @@ def test_the_toggle_follows_a_gizmo_opened_and_closed_elsewhere(controller, tool _act(widget, "enabled", 0, False) _act(widget, "gizmo", 0, True) _act(widget, "enabled", 0, True) - assert controller.get_clipping_plane_gizmo(canvas_id).plane_id == first.id + assert controller.get_plane_gizmo(canvas_id).plane_id == first.id assert _gizmo_states(widget) == [(True, "")] @@ -699,7 +699,7 @@ def test_two_controls_on_one_canvas_share_the_gizmo(controller, toolkit): _act(a, "gizmo", 0, True) _act(b, "gizmo", 0, True) - assert controller.get_clipping_plane_gizmo(canvas_id).visual_id == other.id + assert controller.get_plane_gizmo(canvas_id).visual_id == other.id assert _gizmo_states(a) == [(False, ""), (False, "")] assert _gizmo_states(b) == [(True, "")] @@ -712,7 +712,7 @@ async def test_the_toggle_is_blocked_in_2d_with_the_reason(controller, toolkit): _act(widget, "gizmo", 0, True) scene.dims.selection.displayed_axes = (1, 2) - assert controller.get_clipping_plane_gizmo(canvas_id) is None + assert controller.get_plane_gizmo(canvas_id) is None states = _gizmo_states(widget) assert [on for on, _ in states] == [False, False] assert all("2D" in why for _, why in states) @@ -740,7 +740,7 @@ async def test_a_refused_request_shows_its_reason_and_leaves_the_toggle_off(cont # The front end has not drawn the block yet and sends the click anyway. scene.dims.selection.displayed_axes = (1, 2) _act(widget, "gizmo", 0, True) - assert controller.get_clipping_plane_gizmo(canvas_id) is None + assert controller.get_plane_gizmo(canvas_id) is None assert "3D canvas" in widget.error assert not widget.rows[0]["gizmo"] @@ -774,7 +774,7 @@ def test_the_panel_builders_give_the_control_its_gizmo(controller, toolkit): controller.connect_widget(widget, subscription_specs=widget.subscription_specs()) assert widget.editor.has_gizmo _act(widget, "gizmo", 1, True) - assert controller.get_clipping_plane_gizmo(canvas_id) is not None + assert controller.get_plane_gizmo(canvas_id) is not None assert _gizmo_states(widget) == [(False, ""), (True, "")] diff --git a/tests/gui/test_image_controls.py b/tests/gui/test_image_controls.py index 8df475f6..49a4d27b 100644 --- a/tests/gui/test_image_controls.py +++ b/tests/gui/test_image_controls.py @@ -82,7 +82,7 @@ def test_values_follow_the_storage_type(): assert memory["storage"] == "memory" assert "attenuation" not in memory["shared"] - assert set(memory["render_modes"]) == {"mip", "iso", "minip"} + assert set(memory["render_modes"]) == {"mip", "iso", "minip", "plane"} assert multiscale["storage"] == "multiscale" assert "attenuation" in multiscale["shared"] assert {"smooth_iso", "attenuated_mip"} <= set(multiscale["render_modes"]) diff --git a/tests/gui/test_render_planes_controls.py b/tests/gui/test_render_planes_controls.py new file mode 100644 index 00000000..303e887c --- /dev/null +++ b/tests/gui/test_render_planes_controls.py @@ -0,0 +1,952 @@ +"""The "Render planes" control on both toolkits (plane rendering design 9.2). + +The control sends ``RenderPlanesUpdateEvent`` and follows +``RenderPlanesChangedEvent``. It is the "Clipping planes" control with the +extents added, in world coordinates. Also here: "Plane" in the render-mode +combos (9.1). +""" + +from __future__ import annotations + +import itertools +from uuid import UUID, uuid4 + +import numpy as np +import pytest + +from cellier.controller import CellierController +from cellier.data import ImageMemoryStore, LabelMemoryStore +from cellier.events import ( + AppearanceUpdateEvent, + ChannelAppearanceUpdateEvent, + SingleAppearanceUpdateEvent, +) +from cellier.gui._render_planes import ( + RENDER_PLANES_TITLE, + RenderPlaneGizmoTarget, + box_cut, + flipped, + get_render_plane_gizmo_data, + get_render_planes_data_from_visual, + rows_from_planes, + with_normal, + with_position, + with_side_bounded, +) +from cellier.scene import spatial_axes +from cellier.visuals import ( + ClippingPlane, + InMemoryImageChannelAppearance, + InMemoryImageSingleAppearance, + InMemoryLabelsAppearance, + RenderPlane, +) + +_SERIALS = itertools.count(1) +_QTBOT: list = [] +AXES = ("z", "y", "x") +#: The image is (z, y, x) = (10, 20, 40) voxels: world box 0 to 10 / 20 / 40 +#: (a voxel's centre is at its index, so the box is -0.5 to 9.5 and so on). +SHAPE = (10, 20, 40) +BOX = [[-0.5, 9.5], [-0.5, 19.5], [-0.5, 39.5]] +CENTRE = (4.5, 9.5, 19.5) + + +@pytest.fixture(autouse=True) +def _own_qt_controls(qtbot): + """Give every Qt control a test builds to ``qtbot``, which closes it.""" + _QTBOT.append(qtbot) + yield + _QTBOT.clear() + + +@pytest.fixture +def controller(qtbot): + controller = CellierController(gui="offscreen") + controller.camera_reslice_enabled = False + yield controller + controller.close() + + +def _add_image(controller, *, render_mode="plane", canvas=False, name="image"): + """An in-memory image in a 3D scene; returns ``(scene, visual)``.""" + scene = controller.add_scene(dim="3d", name=f"scene-{name}") + data = np.random.default_rng(0).random(SHAPE, dtype=np.float32) + visual = controller.add_image( + data=ImageMemoryStore(data=data), + scene_id=scene.id, + single=InMemoryImageSingleAppearance(render_mode=render_mode), + name=name, + ) + if canvas: + controller.add_canvas(scene_id=scene.id) + return scene, visual + + +def _world(controller, scene): + return controller.get_scene(scene.id).dims.world_coordinate_system + + +def _plane(controller, scene, point=CENTRE, normal=(1, 0, 0), **kwargs) -> RenderPlane: + return RenderPlane.from_point_normal( + _world(controller, scene), point, normal, axes=AXES, **kwargs + ) + + +def _make(toolkit, controller, visual, *, visual_ids=None, canvas_id=None, **extra): + data = get_render_planes_data_from_visual(controller, visual.id, **extra) + if canvas_id is not None: + data.update(get_render_plane_gizmo_data(controller, visual.id, canvas_id)) + ids = visual.id if visual_ids is None else visual_ids + if toolkit == "qt": + from cellier.gui.qt.visuals import QtRenderPlanesControls + + widget = QtRenderPlanesControls(ids, **data) + _QTBOT[-1].addWidget(widget.widget) + else: + from cellier.gui.anywidget.visuals import AnywidgetRenderPlanesControls + + widget = AnywidgetRenderPlanesControls(ids, **data) + controller.connect_widget(widget, subscription_specs=widget.subscription_specs()) + return widget + + +def _act(widget, action, index=None, value=None) -> None: + """Do one thing as a user would.""" + if hasattr(widget, "comm"): # anywidget: what the front end sends + widget.edit = { + "action": action, + "index": index, + "value": value, + "serial": next(_SERIALS), + } + return + if action == "add": + widget._add.click() + return + row = widget.row(index) + if action == "remove": + row.remove.click() + elif action == "flip": + row.flip.click() + elif action == "gizmo": + assert row.gizmo.isChecked() != value + row.gizmo.click() + elif action == "enabled": + row.enabled.setChecked(value) + elif action == "facing": + row.facing[tuple(value)].click() + elif action == "position": + row.position.setValue(value) + elif action == "component": + row.components[value[0]].setValue(value[1]) + elif action == "side": + row.sides[(value[0], value[1])].setValue(value[2]) + elif action == "bounded": + row.unbounded[(value[0], value[1])].setChecked(not value[2]) + + +def _n_rows(widget) -> int: + return len(widget.rows) if hasattr(widget, "comm") else len(widget._rows) + + +def _blocked(widget) -> str: + return widget.blocked + + +def _can_add(widget) -> bool: + return widget.can_add if hasattr(widget, "comm") else widget._add.isEnabled() + + +def _gizmo_states(widget) -> list[tuple[bool, str]]: + if hasattr(widget, "comm"): + return [(row["gizmo"], row["gizmo_blocked"]) for row in widget.rows] + return [ + (row.gizmo.isChecked(), "" if row.gizmo.isEnabled() else row.gizmo.toolTip()) + for row in widget._rows + ] + + +def _frame(plane: RenderPlane) -> np.ndarray: + axis_0, axis_1 = np.array(plane.in_plane_axis_0), np.array(plane.in_plane_axis_1) + return np.array([np.cross(axis_0, axis_1), axis_0, axis_1]) + + +# -- the toolkit-free half ------------------------------------------------------ + + +def _free_plane(**kwargs) -> RenderPlane: + return RenderPlane( + coordinate_system=uuid4(), + axes=AXES, + origin=(3.0, 4.0, 5.0), + in_plane_axis_0=(0.0, 1.0, 0.0), + in_plane_axis_1=(0.0, 0.0, 1.0), + **kwargs, + ) + + +def test_a_row_shows_the_normal_the_position_and_the_extents(): + plane = _free_plane(extent_1=(-2.0, 6.0), enabled=False) + (row,) = rows_from_planes([plane], {}) + assert row == { + "id": str(plane.id), + "enabled": False, + "axes": ["z", "y", "x"], + "normal": [1.0, 0.0, 0.0], + "position": 3.0, + "extent_0": [None, None], + "extent_1": [-2.0, 6.0], + } + + +def test_a_position_edit_keeps_the_origins_in_plane_coordinates(): + plane = with_normal(_free_plane(), (1.0, 1.0, 0.0)) + moved = with_position(plane, 9.0) + normal = _frame(plane)[0] + assert np.asarray(moved.origin) @ normal == pytest.approx(9.0) + for axis in (plane.in_plane_axis_0, plane.in_plane_axis_1): + assert np.asarray(moved.origin) @ axis == pytest.approx( + np.asarray(plane.origin) @ axis + ) + assert moved.in_plane_axis_0 == plane.in_plane_axis_0 + assert moved.id == plane.id + + +@pytest.mark.parametrize( + "normal", [(0.0, 1.0, 0.0), (1.0, 2.0, -0.5), (-1.0, 0.0, 0.0), (0.3, -0.2, 0.9)] +) +def test_a_normal_edit_is_the_smallest_rotation_and_keeps_the_spin(normal): + """The frame turns about the axis perpendicular to both normals.""" + # A frame spun 25 degrees about its normal, to see the spin kept. + spin = np.radians(25.0) + plane = _free_plane().model_copy( + update={ + "in_plane_axis_0": (0.0, float(np.cos(spin)), float(np.sin(spin))), + "in_plane_axis_1": (0.0, float(-np.sin(spin)), float(np.cos(spin))), + } + ) + turned = with_normal(plane, normal) + before, after = _frame(plane), _frame(turned) + target = np.asarray(normal) / np.linalg.norm(normal) + np.testing.assert_allclose(after[0], target, atol=1e-12) + np.testing.assert_allclose(after @ after.T, np.eye(3), atol=1e-12) + assert turned.origin == plane.origin # about the origin + rotation = after.T @ before # takes the old frame to the new + # The rotation's angle is the angle between the normals: no extra spin. + angle = np.arccos(np.clip((np.trace(rotation) - 1) / 2, -1, 1)) + assert angle == pytest.approx(np.arccos(np.clip(before[0] @ target, -1, 1))) + + +def test_a_normal_edit_refuses_zero(): + with pytest.raises(ValueError, match="all zero"): + with_normal(_free_plane(), (0.0, 0.0, 0.0)) + + +def test_a_flip_reverses_the_normal_and_keeps_the_rectangle(): + plane = _free_plane(extent_0=(-1.0, 2.0), extent_1=(-3.0, None)) + turned = flipped(plane) + np.testing.assert_allclose(_frame(turned)[0], -_frame(plane)[0]) + assert turned.origin == plane.origin + assert turned.extent_0 == plane.extent_0 + # Axis 1 is reversed and its extent mirrored: the same points. + assert turned.extent_1 == (None, 3.0) + assert flipped(turned) == plane + + +def test_the_box_cut_of_a_plane(): + # x = 5 plane of a box 0..10, 0..20, 0..40, origin off centre. + plane = _free_plane().model_copy(update={"origin": (5.0, 4.0, 30.0)}) + box = [[0.0, 10.0], [0.0, 20.0], [0.0, 40.0]] + assert box_cut(plane, box) == ((-4.0, 16.0), (-30.0, 10.0)) + # A plane that misses the box has no cut. + assert box_cut(with_position(plane, 50.0), box) is None + # An oblique plane: the polygon's span, checked against its corners. + oblique = with_normal(plane, (1.0, 1.0, 0.0)) + (low_0, high_0), (low_1, high_1) = box_cut(oblique, box) + assert low_0 < 0 < high_0 and low_1 < 0 < high_1 + for along_0 in (low_0, high_0): + point = np.array(oblique.origin) + along_0 * np.array(oblique.in_plane_axis_0) + # A corner of the cut is on the box's surface, not outside it. + assert np.all(point >= np.array(box)[:, 0] - 40) and np.isfinite(point).all() + + +def test_bounding_a_side_fills_in_the_box_cut(): + box = [[0.0, 10.0], [0.0, 20.0], [0.0, 40.0]] + plane = _free_plane().model_copy(update={"origin": (5.0, 4.0, 30.0)}) + bounded = with_side_bounded(plane, 0, 1, True, box) + assert bounded.extent_0 == (None, 16.0) + bounded = with_side_bounded(bounded, 1, 0, True, box) + assert bounded.extent_1 == (-30.0, None) + assert with_side_bounded(bounded, 0, 1, False, box).extent_0 == (None, None) + # The cut would not leave min < max against the other side: just past it. + tight = plane.model_copy(update={"extent_0": (None, -10.0)}) + low, high = with_side_bounded(tight, 0, 0, True, box).extent_0 + assert low < high == -10.0 + # A plane off the box still gets a finite, ordered pair. + off = with_position(plane, 99.0) + low, high = with_side_bounded( + with_side_bounded(off, 0, 0, True, box), 0, 1, True, box + ).extent_0 + assert low < high + + +def test_the_control_data_is_read_through_the_controller(controller): + scene, visual = _add_image(controller) + data = get_render_planes_data_from_visual(controller, visual.id) + world = _world(controller, scene) + assert data["coordinate_system"] == str(world.id) + assert data["axis_names"] == ["z", "y", "x"] + assert data["axis_ids"] == [str(axis.id) for axis in world.axes] + assert data["planes"] == () + assert data["scene_id"] == str(scene.id) + state = data["state_source"]() + assert state == {"blocked": "", "displayed_axes": data["axis_ids"], "bounds": BOX} + + +# -- the control ---------------------------------------------------------------- + + +@pytest.mark.parametrize("toolkit", ["qt", "anywidget"]) +def test_it_adds_moves_toggles_and_removes_planes(controller, toolkit): + _scene, visual = _add_image(controller) + widget = _make(toolkit, controller, visual) + + _act(widget, "add") + # Through the middle of the data, facing the first displayed axis, + # unbounded: what controller.default_render_plane builds. + (added,) = visual.render_planes + expected = controller.default_render_plane(visual.id) + assert added.model_copy(update={"id": expected.id}) == expected + assert added.origin == CENTRE + assert added.extent_0 == added.extent_1 == (None, None) + plane_id = added.id + + _act(widget, "position", 0, 2.0) + assert visual.render_planes[0].origin == (2.0, 9.5, 19.5) + assert visual.render_planes[0].id == plane_id # an edit keeps the id + _act(widget, "enabled", 0, False) + assert visual.render_planes[0].enabled is False + assert _n_rows(widget) == 1 # a disabled plane stays in the list + + _act(widget, "add") + assert len(visual.render_planes) == 2 + _act(widget, "facing", 1, [1, 1]) + np.testing.assert_allclose( + _frame(visual.render_planes[1])[0], [0, 1, 0], atol=1e-12 + ) + _act(widget, "facing", 1, [2, -1]) + np.testing.assert_allclose( + _frame(visual.render_planes[1])[0], [0, 0, -1], atol=1e-12 + ) + assert visual.render_planes[1].origin == CENTRE # turned in place + + second_id = visual.render_planes[1].id + _act(widget, "remove", 0) + assert [plane.id for plane in visual.render_planes] == [second_id] + assert _n_rows(widget) == 1 + assert widget.error == "" + widget.close() + + +@pytest.mark.parametrize("toolkit", ["qt", "anywidget"]) +def test_position_and_normal_edits_keep_the_frames_spin(controller, toolkit): + scene, visual = _add_image(controller) + # A frame no constructor default gives: spun about the normal. + plane = _plane(controller, scene, up=(0.0, 1.0, 0.4)) + visual.render_planes = (plane,) + widget = _make(toolkit, controller, visual) + + _act(widget, "position", 0, 7.0) + moved = visual.render_planes[0] + assert moved.in_plane_axis_0 == plane.in_plane_axis_0 + assert moved.in_plane_axis_1 == plane.in_plane_axis_1 + assert moved.origin == (7.0, *plane.origin[1:]) + + _act(widget, "component", 0, [1, 1.0]) # the normal (1, 0, 0) -> (1, 1, 0) + turned = visual.render_planes[0] + expected = with_normal(moved, (1.0, 1.0, 0.0)) + np.testing.assert_allclose(_frame(turned), _frame(expected), atol=1e-9) + assert turned.origin == moved.origin + assert widget.error == "" + + +@pytest.mark.parametrize("toolkit", ["qt", "anywidget"]) +def test_a_flip_changes_the_model_and_nothing_drawn(controller, toolkit): + scene, visual = _add_image(controller) + plane = _plane(controller, scene, extent_1=(-3.0, 8.0)) + visual.render_planes = (plane,) + widget = _make(toolkit, controller, visual) + _act(widget, "flip", 0) + assert visual.render_planes[0] == flipped(plane) + assert widget.editor.rows[0]["normal"] == [-1.0, 0.0, 0.0] + assert widget.editor.rows[0]["position"] == -CENTRE[0] + + +@pytest.mark.parametrize("toolkit", ["qt", "anywidget"]) +def test_the_extents_and_their_unbounded_boxes(controller, toolkit): + scene, visual = _add_image(controller) + plane = _plane(controller, scene) # x = 4.5; axis 0 is y, axis 1 is x + visual.render_planes = (plane,) + widget = _make(toolkit, controller, visual) + + # Unticking "unbounded" fills in where the data box cuts the plane. + _act(widget, "bounded", 0, [0, 0, True]) + assert visual.render_planes[0].extent_0 == (-10.0, None) + _act(widget, "bounded", 0, [0, 1, True]) + assert visual.render_planes[0].extent_0 == (-10.0, 10.0) + _act(widget, "bounded", 0, [1, 1, True]) + assert visual.render_planes[0].extent_1 == (None, 20.0) + + _act(widget, "side", 0, [0, 1, 4.0]) + assert visual.render_planes[0].extent_0 == (-10.0, 4.0) + assert widget.editor.rows[0]["extent_0"] == [-10.0, 4.0] + + # min >= max is refused like any bad edit: the last rows, and the error. + _act(widget, "side", 0, [0, 0, 6.0]) + assert visual.render_planes[0].extent_0 == (-10.0, 4.0) + assert "min < max" in widget.error + assert widget.editor.rows[0]["extent_0"] == [-10.0, 4.0] + if toolkit == "qt": + assert widget.row(0).sides[(0, 0)].value() == -10.0 + + # Ticking the box clears the side. + _act(widget, "bounded", 0, [0, 0, False]) + assert visual.render_planes[0].extent_0 == (None, 4.0) + assert widget.error == "" + if toolkit == "qt": + row = widget.row(0) + assert row.unbounded[(0, 0)].isChecked() + assert not row.sides[(0, 0)].isEnabled() + assert row.sides[(0, 1)].isEnabled() + + +@pytest.mark.parametrize("toolkit", ["qt", "anywidget"]) +def test_add_is_disabled_at_four_planes(controller, toolkit): + _scene, visual = _add_image(controller) + widget = _make(toolkit, controller, visual) + for _ in range(3): + _act(widget, "add") + assert _can_add(widget) + _act(widget, "add") + assert len(visual.render_planes) == 4 + assert not _can_add(widget) + if toolkit == "anywidget": + _act(widget, "add") # what a stale front end might still send + assert len(visual.render_planes) == 4 + assert "at most 4" in widget.error + _act(widget, "remove", 0) + assert _can_add(widget) + + +@pytest.mark.parametrize("toolkit", ["qt", "anywidget"]) +def test_a_change_from_elsewhere_is_shown_and_sends_nothing(controller, toolkit): + scene, visual = _add_image(controller) + widget = _make(toolkit, controller, visual) + sent: list = [] + widget.changed.connect(sent.append) + visual.render_planes = ( + _plane(controller, scene, point=(7.0, 0.0, 0.0)), + _plane( + controller, scene, normal=(0, 0, 1), enabled=False, extent_0=(-1.0, 1.0) + ), + ) + assert _n_rows(widget) == 2 + assert widget.editor.rows[0]["position"] == 7.0 + assert widget.editor.rows[1]["enabled"] is False + assert widget.editor.rows[1]["extent_0"] == [-1.0, 1.0] + assert sent == [] + + +@pytest.mark.parametrize("toolkit", ["qt", "anywidget"]) +def test_a_refused_edit_shows_the_last_rows_and_the_error(controller, toolkit): + _scene, visual = _add_image(controller) + widget = _make(toolkit, controller, visual) + _act(widget, "add") + before = widget.editor.rows + # Zeroing the only non-zero entry leaves no direction. + _act(widget, "component", 0, [0, 0.0]) + assert "all zero" in widget.error + assert widget.editor.rows == before + assert len(visual.render_planes) == 1 + if toolkit == "qt": + assert widget.row(0).components[0].value() == 1.0 + _act(widget, "position", 0, 3.0) # the next good edit clears it + assert widget.error == "" + + +def test_the_qt_row_names_the_planes_own_axes(controller): + scene = controller.add_scene( + coordinate_system=[("t", "time"), *spatial_axes(*AXES)], dim="3d", name="tzyx" + ) + data = np.zeros((2, *SHAPE), dtype=np.float32) + visual = controller.add_image( + data=ImageMemoryStore(data=data), + scene_id=scene.id, + single=InMemoryImageSingleAppearance(render_mode="plane"), + ) + widget = _make("qt", controller, visual) + _act(widget, "add") + # A new plane is on the three axes the view displays: z, y, x, not t. + world = _world(controller, scene) + assert visual.render_planes[0].axes == tuple(axis.id for axis in world.axes[1:]) + assert [button.text() for button in widget.row(0).facing.values()] == [ + "+z", + "-z", + "+y", + "-y", + "+x", + "-x", + ] + assert widget.row(0).facing[(0, 1)].isChecked() + + +def test_a_moved_plane_keeps_its_row_widgets(controller): + _scene, visual = _add_image(controller) + widget = _make("qt", controller, visual) + _act(widget, "add") + row = widget.row(0) + slider = row.slider + _act(widget, "position", 0, 3.0) + visual.render_planes = ( + visual.render_planes[0].model_copy(update={"origin": (6.0, 0.0, 0.0)}), + ) + assert widget.row(0) is row + assert widget.row(0).slider is slider + assert row.position.value() == 6.0 + + +def test_the_anywidget_sends_one_update_for_an_edit_delivered_twice(controller): + _scene, visual = _add_image(controller) + widget = _make("anywidget", controller, visual) + _act(widget, "add") + sent: list = [] + widget.changed.connect(sent.append) + edit = {"action": "position", "index": 0, "value": 3.0, "serial": 99} + widget.edit = edit + widget.edit = dict(edit, serial=100) # the same edit, delivered again + assert len(sent) == 1 + + +def test_the_anywidget_puts_back_what_a_host_overwrites(controller): + """marimo sends the front end's whole state back with every edit.""" + _scene, visual = _add_image(controller) + widget = _make("anywidget", controller, visual) + _act(widget, "add") + stale = {"rows": [], "error": "old", "blocked": "old", "can_add": False} + shown = {name: getattr(widget, name) for name in stale} + for name, value in stale.items(): + setattr(widget, name, value) + assert getattr(widget, name) == shown[name] + assert len(widget.rows) == 1 + + +def test_the_anywidget_ignores_a_malformed_edit(controller): + _scene, visual = _add_image(controller) + widget = _make("anywidget", controller, visual) + _act(widget, "add") + before = visual.render_planes + for action, index, value in [ + ("position", 5, 1.0), + ("position", "0", 1.0), + ("facing", 0, [3, 1]), + ("component", 0, 1.0), + ("side", 0, [0, 2, 1.0]), + ("bounded", 0, [2, 0, True]), + ("side", 0, [0, 1]), + ("nonsense", 0, None), + ]: + _act(widget, action, index, value) + assert visual.render_planes == before + + +def test_a_group_control_edits_every_visual(controller): + scene, visual = _add_image(controller) + data = np.zeros(SHAPE, dtype=np.float32) + other = controller.add_image( + data=ImageMemoryStore(data=data), + scene_id=scene.id, + single=InMemoryImageSingleAppearance(render_mode="plane"), + ) + widget = _make("qt", controller, visual, visual_ids=[visual.id, other.id]) + _act(widget, "add") + assert visual.render_planes == other.render_planes + assert len(other.render_planes) == 1 + + +def test_both_toolkits_carry_the_shared_title(): + from cellier.gui.anywidget.visuals import AnywidgetRenderPlanesControls + from cellier.gui.qt.visuals import QtRenderPlanesControls + + assert RENDER_PLANES_TITLE == "Render planes" + assert QtRenderPlanesControls.DEFAULT_TITLE == RENDER_PLANES_TITLE + assert AnywidgetRenderPlanesControls.DEFAULT_TITLE == RENDER_PLANES_TITLE + assert AnywidgetRenderPlanesControls.title.default_value == RENDER_PLANES_TITLE + + +def test_the_explicit_api_is_exported_from_cellier_gui(): + import cellier.gui as gui + + assert gui.RenderPlaneGizmoTarget is RenderPlaneGizmoTarget + assert gui.get_render_planes_data_from_visual is get_render_planes_data_from_visual + assert gui.get_render_plane_gizmo_data is get_render_plane_gizmo_data + + +# -- disabled, with the reason -------------------------------------------------- + + +@pytest.mark.parametrize("toolkit", ["qt", "anywidget"]) +def test_the_control_is_disabled_outside_plane_mode(controller, toolkit): + scene, visual = _add_image(controller, render_mode="mip") + visual.render_planes = (_plane(controller, scene),) + widget = _make(toolkit, controller, visual) + assert "render mode" in _blocked(widget) + assert not _can_add(widget) + assert _n_rows(widget) == 1 # the rows are shown, not editable + if toolkit == "qt": + assert not widget._body.isEnabled() + assert widget._reason.text() == _blocked(widget) + else: + # A stale front end's edit is refused with the reason. + _act(widget, "position", 0, 1.0) + assert widget.error == _blocked(widget) + assert visual.render_planes[0].origin == CENTRE + + # Entering plane mode enables it, with no other prompt. + visual.single.render_mode = "plane" + assert _blocked(widget) == "" + assert _can_add(widget) + if toolkit == "qt": + assert widget._body.isEnabled() + assert widget._reason.isHidden() + _act(widget, "position", 0, 1.0) + assert visual.render_planes[0].origin[0] == 1.0 + assert widget.error == "" + + +@pytest.mark.parametrize("toolkit", ["qt", "anywidget"]) +def test_the_control_is_disabled_in_a_2d_view(controller, toolkit): + scene, visual = _add_image(controller) + widget = _make(toolkit, controller, visual) + _act(widget, "add") + controller.set_displayed_axes(scene.id, (1, 2)) + assert "2D" in _blocked(widget) + assert not _can_add(widget) + controller.set_displayed_axes(scene.id, (0, 1, 2)) + assert _blocked(widget) == "" + assert _can_add(widget) + + +@pytest.mark.parametrize("toolkit", ["qt", "anywidget"]) +async def test_a_caller_can_give_its_own_reason(controller, toolkit): + """What ``OrthoViewer``'s slice planes mode uses for read-only rows.""" + scene, visual = _add_image(controller, canvas=True) + visual.render_planes = (_plane(controller, scene),) + (canvas_id,) = controller.get_canvas_ids(scene.id) + reason = ["The planes follow the sliders."] + widget = _make( + toolkit, controller, visual, canvas_id=canvas_id, blocked=lambda: reason[0] + ) + assert _blocked(widget) == reason[0] + # The gizmo toggle is disabled with the same reason. + assert _gizmo_states(widget) == [(False, reason[0])] + reason[0] = "" + widget.refresh() + assert _blocked(widget) == "" + assert _gizmo_states(widget) == [(False, "")] + + +# -- the gizmo toggle: a radio across both kinds of plane ----------------------- + + +def _gizmo_scene(controller): + scene, visual = _add_image(controller, canvas=True) + visual.render_planes = ( + _plane(controller, scene), + _plane(controller, scene, normal=(0, 1, 0)), + ) + (canvas_id,) = controller.get_canvas_ids(scene.id) + return scene, visual, canvas_id + + +async def test_the_gizmo_data_is_for_the_visual_and_canvas_named(controller): + scene, visual, canvas_id = _gizmo_scene(controller) + first, second = visual.render_planes + data = get_render_plane_gizmo_data(controller, visual.id, canvas_id) + assert data["gizmo_target"] == RenderPlaneGizmoTarget( + visual_id=visual.id, canvas_id=canvas_id, scene_id=scene.id + ) + assert data["gizmo_plane"] is None + assert data["gizmo_blocked"](str(first.id)) == "" + controller.add_render_plane_gizmo(visual.id, canvas_id, second.id) + data = get_render_plane_gizmo_data(controller, visual.id, canvas_id) + assert data["gizmo_plane"] == str(second.id) + with pytest.raises(KeyError): + get_render_plane_gizmo_data(controller, visual.id, uuid4()) + + +@pytest.mark.parametrize("toolkit", ["qt", "anywidget"]) +def test_a_control_given_no_gizmo_target_draws_no_toggle(controller, toolkit): + _scene, visual = _add_image(controller) + widget = _make(toolkit, controller, visual) + _act(widget, "add") + assert not widget.editor.has_gizmo + if toolkit == "qt": + assert widget.row(0).gizmo.isHidden() + else: + assert "gizmo" not in widget.rows[0] + _act(widget, "gizmo", 0, True) # ignored + assert controller._plane_gizmos == {} + + +@pytest.mark.parametrize("toolkit", ["qt", "anywidget"]) +async def test_the_toggle_puts_the_gizmo_on_one_plane_at_a_time(controller, toolkit): + _scene, visual, canvas_id = _gizmo_scene(controller) + widget = _make(toolkit, controller, visual, canvas_id=canvas_id) + first, second = visual.render_planes + assert _gizmo_states(widget) == [(False, ""), (False, "")] + + _act(widget, "gizmo", 0, True) + session = controller.get_plane_gizmo(canvas_id) + assert (session.kind, session.plane_id) == ("render", first.id) + assert _gizmo_states(widget) == [(True, ""), (False, "")] + _act(widget, "gizmo", 1, True) + assert controller.get_plane_gizmo(canvas_id).plane_id == second.id + assert _gizmo_states(widget) == [(False, ""), (True, "")] + _act(widget, "gizmo", 1, False) + assert controller.get_plane_gizmo(canvas_id) is None + assert _gizmo_states(widget) == [(False, ""), (False, "")] + assert widget.error == "" + + +@pytest.mark.parametrize("toolkit", ["qt", "anywidget"]) +async def test_the_radio_runs_across_clipping_and_render_plane_controls( + controller, toolkit +): + """One gizmo per canvas: the two controls' toggles are one set (H4).""" + from cellier.gui._clipping_planes import ( + get_clipping_plane_gizmo_data, + get_clipping_planes_data_from_visual, + ) + + _scene, visual, canvas_id = _gizmo_scene(controller) + store = controller.get_data_store(UUID(str(visual.data_store_id))) + visual.clipping_planes = ( + ClippingPlane.from_point_normal( + store.data_coordinate_system, (0, 0, 20), (0, 0, 1), enabled=False + ), + ) + render = _make(toolkit, controller, visual, canvas_id=canvas_id) + data = { + **get_clipping_planes_data_from_visual(visual, store), + **get_clipping_plane_gizmo_data(controller, visual.id, canvas_id), + } + if toolkit == "qt": + from cellier.gui.qt.visuals import QtClippingPlanesControls + + clipping = QtClippingPlanesControls(visual.id, **data) + _QTBOT[-1].addWidget(clipping.widget) + else: + from cellier.gui.anywidget.visuals import AnywidgetClippingPlanesControls + + clipping = AnywidgetClippingPlanesControls(visual.id, **data) + controller.connect_widget( + clipping, subscription_specs=clipping.subscription_specs() + ) + + def press(widget, on): + if toolkit == "qt": + widget.row(0).gizmo.click() + else: + widget.edit = { + "action": "gizmo", + "index": 0, + "value": on, + "serial": next(_SERIALS), + } + + press(render, True) + assert _gizmo_states(render)[0] == (True, "") + assert _gizmo_states(clipping) == [(False, "")] + # The clipping row takes the canvas's gizmo: the render row lets go. + press(clipping, True) + assert controller.get_plane_gizmo(canvas_id).kind == "clipping" + assert _gizmo_states(clipping) == [(True, "")] + assert _gizmo_states(render) == [(False, ""), (False, "")] + press(render, True) + assert controller.get_plane_gizmo(canvas_id).kind == "render" + assert _gizmo_states(clipping) == [(False, "")] + assert _gizmo_states(render)[0] == (True, "") + + +@pytest.mark.parametrize("toolkit", ["qt", "anywidget"]) +async def test_the_toggle_follows_a_gizmo_opened_and_closed_elsewhere( + controller, toolkit +): + _scene, visual, canvas_id = _gizmo_scene(controller) + widget = _make(toolkit, controller, visual, canvas_id=canvas_id) + _first, second = visual.render_planes + session = controller.add_render_plane_gizmo(visual.id, canvas_id, second.id) + assert _gizmo_states(widget) == [(False, ""), (True, "")] + late = _make(toolkit, controller, visual, canvas_id=canvas_id) + assert _gizmo_states(late) == [(False, ""), (True, "")] + _act(widget, "remove", 1) + assert session.closed + assert _gizmo_states(widget) == [(False, "")] + assert _gizmo_states(late) == [(False, "")] + + +@pytest.mark.parametrize("toolkit", ["qt", "anywidget"]) +async def test_the_extent_fields_follow_a_gizmo_scale_drag(controller, toolkit): + """What a scale handle writes during a drag reaches the fields (14.9).""" + _scene, visual, canvas_id = _gizmo_scene(controller) + plane = visual.render_planes[0].model_copy(update={"extent_0": (-4.0, 6.0)}) + controller.set_render_plane(visual.id, plane.id, plane) + widget = _make(toolkit, controller, visual, canvas_id=canvas_id) + session = controller.add_render_plane_gizmo(visual.id, canvas_id, plane.id) + # The session's own write, as its scale handler makes it mid drag. + with controller.plane_interaction(visual.id): + session._assign({"extent_0": (-6.0, 9.0)}) + assert widget.editor.rows[0]["extent_0"] == [-6.0, 9.0] + if toolkit == "qt": + assert widget.row(0).sides[(0, 1)].value() == 9.0 + + +# -- 9.1: "Plane" in the render-mode combos ------------------------------------- + + +def test_the_image_rows_that_mean_nothing_in_plane_mode_are_hidden(): + from cellier.gui._image_controls import row_visible + + for field in ("iso_threshold", "attenuation"): + assert not row_visible(field, ["plane"], 3) + assert row_visible("render_mode", ["plane"], 3) + assert not row_visible("render_mode", ["plane"], 2) # 3D only, as today + assert row_visible("iso_threshold", ["iso"], 3) + + +def _requests(controller, widget_id): + """Send what a control sends for a render-mode choice.""" + + def single(visual, mode): + controller.incoming_events.emit( + SingleAppearanceUpdateEvent( + source_id=widget_id, + visual_id=visual.id, + field="render_mode", + value=mode, + ) + ) + + def channel(visual, index, mode): + controller.incoming_events.emit( + ChannelAppearanceUpdateEvent( + source_id=widget_id, + visual_id=visual.id, + channel_index=index, + field="render_mode", + value=mode, + ) + ) + + def labels(visual, mode): + controller.incoming_events.emit( + AppearanceUpdateEvent( + source_id=widget_id, + visual_id=visual.id, + field="render_mode", + value=mode, + ) + ) + + return single, channel, labels + + +def test_choosing_plane_on_a_visual_with_no_planes_adds_a_centred_one(controller): + _scene, visual = _add_image(controller, render_mode="mip") + single, _channel, _labels = _requests(controller, uuid4()) + single(visual, "plane") + assert visual.single.render_mode == "plane" + (plane,) = visual.render_planes + assert plane.origin == CENTRE + np.testing.assert_allclose(_frame(plane)[0], [1.0, 0.0, 0.0]) + assert plane.extent_0 == plane.extent_1 == (None, None) + + # Choosing it again, or with planes already there, adds nothing. + single(visual, "mip") + single(visual, "plane") + assert visual.render_planes == (plane,) + + +def test_the_model_does_not_add_a_plane_by_itself(controller): + """Only a control's request does: the mode set from code adds nothing.""" + _scene, visual = _add_image(controller, render_mode="mip") + visual.single.render_mode = "plane" + assert visual.render_planes == () + visual.single.render_mode = "mip" + controller.update_single_appearance_field(visual.id, "render_mode", "plane") + assert visual.render_planes == () + + +def test_choosing_plane_in_a_2d_view_adds_nothing(controller): + scene, visual = _add_image(controller, render_mode="mip") + controller.set_displayed_axes(scene.id, (1, 2)) + single, _channel, _labels = _requests(controller, uuid4()) + single(visual, "plane") + assert visual.single.render_mode == "plane" + assert visual.render_planes == () + + +def test_a_labels_combo_adds_a_plane_too(controller): + scene = controller.add_scene(dim="3d", name="labels") + data = np.zeros(SHAPE, dtype=np.int32) + visual = controller.add_labels( + data=LabelMemoryStore(data=data), + scene_id=scene.id, + appearance=InMemoryLabelsAppearance(), + ) + _single, _channel, labels = _requests(controller, uuid4()) + labels(visual, "plane") + assert visual.appearance.render_mode == "plane" + assert len(visual.render_planes) == 1 + assert visual.render_planes[0].origin == CENTRE + + +def test_one_channels_combo_moves_every_channel_and_adds_one_plane(controller): + """Composite: the combos move together (D-P53), and one plane is added.""" + scene = controller.add_scene( + coordinate_system=[("c", "channel"), *spatial_axes(*AXES)], + dim="3d", + name="channels", + ) + data = np.random.default_rng(0).random((3, *SHAPE), dtype=np.float32) + visual = controller.add_image( + data=ImageMemoryStore(data=data), + scene_id=scene.id, + channel_axis=0, + composite=True, + channels={ + index: InMemoryImageChannelAppearance(render_mode="mip") + for index in range(3) + }, + ) + _single, channel, _labels = _requests(controller, uuid4()) + channel(visual, 1, "plane") + assert [visual.channels[k].render_mode for k in range(3)] == ["plane"] * 3 + assert len(visual.render_planes) == 1 + assert visual.render_planes[0].origin == CENTRE + channel(visual, 0, "iso") + assert [visual.channels[k].render_mode for k in range(3)] == ["iso"] * 3 + assert len(visual.render_planes) == 1 # leaving the mode keeps the planes + + +@pytest.mark.parametrize("toolkit", ["qt", "anywidget"]) +def test_the_planes_control_shows_the_plane_a_combo_added(controller, toolkit): + _scene, visual = _add_image(controller, render_mode="mip") + widget = _make(toolkit, controller, visual) + assert _blocked(widget) != "" + single, _channel, _labels = _requests(controller, uuid4()) + single(visual, "plane") + assert _blocked(widget) == "" + assert _n_rows(widget) == 1 + assert widget.editor.rows[0]["position"] == CENTRE[0] diff --git a/tests/render/test_clipping_plane_gizmo_controller.py b/tests/render/test_clipping_plane_gizmo_controller.py index 94c96212..7b2b322f 100644 --- a/tests/render/test_clipping_plane_gizmo_controller.py +++ b/tests/render/test_clipping_plane_gizmo_controller.py @@ -15,9 +15,9 @@ from cellier.data import ImageMemoryStore from cellier.data._axes import scale_and_translation_transform from cellier.events import ( - ClippingPlaneGizmoChangedEvent, - ClippingPlaneGizmoUpdateEvent, ClippingPlanesChangedEvent, + PlaneGizmoChangedEvent, + PlaneGizmoUpdateEvent, PlaneInteractionEvent, ) from cellier.visuals import ClippingPlane, InMemoryImageSingleAppearance @@ -42,7 +42,7 @@ def __init__(self, **kwargs) -> None: self.visual.id, self.log.append, owner_id=owner ) for view in self.views: - self.controller.on_clipping_plane_gizmo_changed( + self.controller.on_plane_gizmo_changed( view.canvas_id, self.log.append, owner_id=owner ) @@ -152,8 +152,8 @@ async def test_the_gizmo_is_placed_on_the_plane(make_rig): np.testing.assert_allclose(normal, (1.0, 0.0, 0.0), atol=1e-6) np.testing.assert_allclose(point, (12.0, 15.5, 15.5), atol=1e-3) assert session.anchor == pytest.approx(point) - assert rig.controller.get_clipping_plane_gizmo(rig.view.canvas_id) is session - (event,) = rig.of(ClippingPlaneGizmoChangedEvent) + assert rig.controller.get_plane_gizmo(rig.view.canvas_id) is session + (event,) = rig.of(PlaneGizmoChangedEvent) assert (event.visual_id, event.plane_id) == (rig.visual.id, item.id) assert rig.of(ClippingPlanesChangedEvent) == [] @@ -193,8 +193,8 @@ async def test_what_is_refused(make_rig): rig.scene.dims.selection.displayed_axes = (1, 2) with pytest.raises(ValueError, match="3D canvas"): controller.add_clipping_plane_gizmo(rig.visual.id, canvas_id, item.id) - assert controller.get_clipping_plane_gizmo(canvas_id) is None - assert rig.of(ClippingPlaneGizmoChangedEvent) == [] + assert controller.get_plane_gizmo(canvas_id) is None + assert rig.of(PlaneGizmoChangedEvent) == [] assert rig.view._plane_gizmos == {} @@ -395,11 +395,11 @@ async def test_removing_the_plane_closes_the_session(make_rig): rig.visual.clipping_planes = (other,) assert session.closed assert rig.view._plane_gizmos == {} - assert rig.controller.get_clipping_plane_gizmo(rig.view.canvas_id) is None - (event,) = rig.of(ClippingPlaneGizmoChangedEvent) + assert rig.controller.get_plane_gizmo(rig.view.canvas_id) is None + (event,) = rig.of(PlaneGizmoChangedEvent) assert (event.visual_id, event.plane_id) == (None, None) session.close() # again: nothing - assert len(rig.of(ClippingPlaneGizmoChangedEvent)) == 1 + assert len(rig.of(PlaneGizmoChangedEvent)) == 1 async def test_leaving_3d_closes_the_session(make_rig): @@ -459,7 +459,7 @@ async def test_a_canvas_has_one_gizmo_and_a_new_one_replaces_it(make_rig): assert not second.closed assert list(rig.view._plane_gizmos) == [second.id] # One event for the replacement: the new plane. - (event,) = rig.of(ClippingPlaneGizmoChangedEvent) + (event,) = rig.of(PlaneGizmoChangedEvent) assert event.plane_id == other.id # A refused request leaves the gizmo there is. @@ -468,7 +468,7 @@ async def test_a_canvas_has_one_gizmo_and_a_new_one_replaces_it(make_rig): rig.visual.id, rig.view.canvas_id, uuid4() ) assert not second.closed - assert rig.controller.get_clipping_plane_gizmo(rig.view.canvas_id) is second + assert rig.controller.get_plane_gizmo(rig.view.canvas_id) is second async def test_the_request_event_opens_and_closes(make_rig): @@ -481,26 +481,27 @@ async def test_the_request_event_opens_and_closes(make_rig): def request(plane, enabled): controller._incoming_events.emit( - ClippingPlaneGizmoUpdateEvent( + PlaneGizmoUpdateEvent( source_id=widget, visual_id=rig.visual.id, plane_id=plane.id, canvas_id=canvas_id, + kind="clipping", enabled=enabled, ) ) request(item, True) - session = controller.get_clipping_plane_gizmo(canvas_id) + session = controller.get_plane_gizmo(canvas_id) assert session.plane_id == item.id - assert rig.of(ClippingPlaneGizmoChangedEvent)[-1].source_id == widget + assert rig.of(PlaneGizmoChangedEvent)[-1].source_id == widget # Switching off a plane that does not have the gizmo does nothing. request(other, False) - assert controller.get_clipping_plane_gizmo(canvas_id) is session + assert controller.get_plane_gizmo(canvas_id) is session request(item, False) assert session.closed - last = rig.of(ClippingPlaneGizmoChangedEvent)[-1] + last = rig.of(PlaneGizmoChangedEvent)[-1] assert (last.plane_id, last.source_id) == (None, widget) diff --git a/tests/render/test_render_plane_gizmo.py b/tests/render/test_render_plane_gizmo.py new file mode 100644 index 00000000..e4dae6e3 --- /dev/null +++ b/tests/render/test_render_plane_gizmo.py @@ -0,0 +1,956 @@ +"""The gizmo on a render plane (plane rendering design v3, 8.3; Phase 8). + +Headless, on the rig of ``test_plane_gizmo.py``: a drag is synthetic pointer +input on an offscreen canvas, picked and handled by pygfx's own gizmo code, +and what is checked is the model. +""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +from uuid import UUID, uuid4 + +import numpy as np +import pylinalg as la +import pytest + +from cellier.clipping import ( + ClippingPlaneGizmoController, + RenderPlaneGizmoController, + scale_handle_axes, + scaled_extent, +) +from cellier.controller import CellierController +from cellier.data import ImageMemoryStore +from cellier.events import ( + PlaneGizmoChangedEvent, + PlaneGizmoUpdateEvent, + PlaneInteractionEvent, + RenderPlanesChangedEvent, +) +from cellier.scene import spatial_axes +from cellier.visuals import ( + ClippingPlane, + InMemoryImageSingleAppearance, + MultiscaleImageAppearance, + MultiscaleImageRenderConfig, + MultiscaleImageSingleAppearance, + RenderPlane, +) +from tests import _plane_fixtures as fx +from tests._gpu_budget import SMALL_BUDGETS +from tests.render.conftest import drain_loading +from tests.render.test_plane_gizmo import CAPTURES, SIZE, Rig, _ball + +AXES = ("z", "y", "x") +#: World ``(z, y, x)`` of the middle of the (32, 32, 32) volume. +MIDDLE = (15.5, 15.5, 15.5) +SETTLE_S = 0.03 + + +def _directions(n: int) -> np.ndarray: + """*n* unit camera directions, the same every run.""" + vectors = np.random.default_rng(0).normal(size=(n, 3)) + return vectors / np.linalg.norm(vectors, axis=1, keepdims=True) + + +class PlaneRig(Rig): + """One in-memory image in ``"plane"`` mode, and a log of the bus.""" + + def __init__(self, *, n_canvases: int = 1, time_axis: bool = False) -> None: + self.controller = CellierController(gui="offscreen") + self.controller.camera_reslice_enabled = False + if time_axis: + self.scene = self.controller.add_scene( + coordinate_system=[("t", "time"), *spatial_axes(*AXES)], + dim="3d", + name="scene", + ) + data = np.stack([_ball(), _ball()]) + else: + self.scene = self.controller.add_scene(dim="3d", name="scene") + data = _ball() + self.world = self.controller._model.scenes[ + self.scene.id + ].dims.world_coordinate_system + self.visual = self.controller.add_image( + data=ImageMemoryStore(data=data), + scene_id=self.scene.id, + single=InMemoryImageSingleAppearance( + color_map="gray", clim=(0.0, 1.0), render_mode="plane" + ), + ) + self._finish(n_canvases) + + def _finish(self, n_canvases: int) -> None: + self.views = [] + for _ in range(n_canvases): + self.controller.add_canvas(self.scene.id, canvas_size=SIZE) + canvas_id = self.controller.get_canvas_ids(self.scene.id)[-1] + self.views.append(self.controller.get_canvas_view(canvas_id)) + self.view = self.views[0] + self.events = [] + self.in_draw = [] + self.log: list = [] + owner = uuid4() + self.controller.on_render_planes_changed( + self.visual.id, self.log.append, owner_id=owner + ) + self.controller.on_plane_interaction( + self.visual.id, self.log.append, owner_id=owner + ) + for view in self.views: + self.controller.on_plane_gizmo_changed( + view.canvas_id, self.log.append, owner_id=owner + ) + + # -- planes -------------------------------------------------------------- + + def plane(self, normal=(1.0, 0.3, 0.2), up=(0.0, 1.0, 0.1), **kwargs): + """An oblique plane through the middle, bounded on both axes.""" + kwargs.setdefault("extent_0", (-8.0, 10.0)) + kwargs.setdefault("extent_1", (-6.0, 7.0)) + return RenderPlane.from_point_normal( + self.world, MIDDLE, normal, axes=AXES, up=up, **kwargs + ) + + def show(self, *planes: RenderPlane) -> None: + self.controller.set_render_planes(self.visual.id, planes) + self.log.clear() + + def model(self, plane: RenderPlane) -> RenderPlane: + return self.controller.get_render_plane(self.visual.id, plane.id) + + def session(self, plane: RenderPlane, view=None) -> RenderPlaneGizmoController: + view = view or self.view + session = self.controller.add_render_plane_gizmo( + self.visual.id, view.canvas_id, plane.id + ) + self.frame(view=view, n=2) + return session + + def gizmo(self, session): + view = self.controller.get_canvas_view(session.canvas_id) + return view.get_plane_gizmo(session.id) + + def of(self, kind) -> list: + return [event for event in self.log if isinstance(event, kind)] + + # -- camera and drags ---------------------------------------------------- + + def look(self, direction) -> None: + """Look at the volume's middle from *direction* (rendered ``xyz``).""" + camera = self.view.camera + centre = np.array(MIDDLE[::-1]) + distance = float(np.linalg.norm(np.asarray(camera.world.position) - centre)) + camera.local.position = centre + np.asarray(direction) * distance + camera.look_at(tuple(centre)) + self.frame(n=2) + + def arrow(self, session, group: str, index: int): + """A handle's screen position and the unit direction out to it.""" + gizmo = self.gizmo(session) + at = np.array(self.handle(gizmo, group, index)) + origin = np.array(self.screen(gizmo.gizmo.world.position)) + out = at - origin + length = float(np.linalg.norm(out)) + return at, (out / length if length > 0 else out), length + + def drag(self, session, at, delta) -> tuple[str, object] | None: + """Press at *at*, move by *delta* in two framed steps, release. + + Returns the handle pygfx grabbed, ``(kind, axis)``, or ``None`` when + the press landed on no handle. + """ + self.send("pointer_down", at[0], at[1]) + if not session.dragging: + self.send("pointer_up", at[0], at[1], buttons=()) + return None + gizmo = self.gizmo(session) + grabbed = (gizmo.handle_kind, gizmo.handle_axis) + for step in (0.5, 1.0): + self.send("pointer_move", at[0] + delta[0] * step, at[1] + delta[1] * step) + self.frame() + self.send("pointer_up", at[0] + delta[0], at[1] + delta[1], buttons=()) + self.frame() + return grabbed + + +@pytest.fixture +def make_rig(): + rigs: list = [] + CAPTURES.clear() + + def _make(cls=PlaneRig, **kwargs): + rig = cls(**kwargs) + rigs.append(rig) + return rig + + yield _make + for rig in rigs: + rig.controller.close() + CAPTURES.clear() + + +def _frame_of(plane: RenderPlane) -> np.ndarray: + """``[normal, axis_0, axis_1]`` rows of a plane, on its own axes.""" + axis_0 = np.asarray(plane.in_plane_axis_0) + axis_1 = np.asarray(plane.in_plane_axis_1) + return np.array([np.cross(axis_0, axis_1), axis_0, axis_1]) + + +# -- pure rules ----------------------------------------------------------------- + + +def test_a_scale_handle_needs_both_sides_bounded(make_rig): + rig = make_rig() + assert scale_handle_axes(rig.plane()) == (1, 2) + assert scale_handle_axes(rig.plane(extent_0=(None, 4.0))) == (2,) + assert scale_handle_axes(rig.plane(extent_1=(-3.0, None))) == (1,) + assert ( + scale_handle_axes(rig.plane(extent_0=(None, None), extent_1=(None, None))) == () + ) + + +def test_a_scale_factor_multiplies_both_sides_about_the_origin(): + assert scaled_extent((-2.0, 6.0), 1.5) == (-3.0, 9.0) + assert scaled_extent((1.0, 4.0), 0.5) == (0.5, 2.0) + # An unbounded side has no size; a vanishing factor would leave min == max. + assert scaled_extent((None, 6.0), 2.0) == (None, 6.0) + assert scaled_extent((-2.0, 6.0), 0.0) == (-2.0, 6.0) + assert scaled_extent((-2.0, 6.0), float("nan")) == (-2.0, 6.0) + + +# -- the gizmo sits on the plane ------------------------------------------------ + + +async def test_the_gizmo_carries_the_planes_whole_frame(make_rig): + rig = make_rig() + await rig.start() + plane = rig.plane() + rig.show(plane) + session = rig.session(plane) + assert isinstance(session, RenderPlaneGizmoController) + assert session.kind == "render" + gizmo = rig.gizmo(session) + point, normal = gizmo.pose() + axis_0, axis_1, scale = gizmo.frame() + # Rendered (x, y, z) is the world's (z, y, x) reversed. + np.testing.assert_allclose(point, plane.origin[::-1], atol=1e-5) + np.testing.assert_allclose(axis_0, plane.in_plane_axis_0[::-1], atol=1e-6) + np.testing.assert_allclose(axis_1, plane.in_plane_axis_1[::-1], atol=1e-6) + np.testing.assert_allclose(normal, np.cross(axis_0, axis_1), atol=1e-6) + assert scale == (1.0, 1.0) + # Opening a gizmo changes no plane. + assert rig.of(RenderPlanesChangedEvent) == [] + assert rig.model(plane) == plane + + +async def test_a_plane_changed_elsewhere_moves_the_gizmo(make_rig): + rig = make_rig() + await rig.start() + plane = rig.plane() + rig.show(plane) + session = rig.session(plane) + moved = RenderPlane.from_point_normal( + rig.world, + (12.0, 18.0, 14.0), + (0.2, 1.0, -0.4), + axes=AXES, + id=plane.id, + extent_0=(-5.0, 5.0), + ) + rig.controller.set_render_plane(rig.visual.id, plane.id, moved) + gizmo = rig.gizmo(session) + np.testing.assert_allclose(gizmo.pose()[0], moved.origin[::-1], atol=1e-5) + axis_0, axis_1, _ = gizmo.frame() + np.testing.assert_allclose(axis_0, moved.in_plane_axis_0[::-1], atol=1e-6) + np.testing.assert_allclose(axis_1, moved.in_plane_axis_1[::-1], atol=1e-6) + # extent_1 became unbounded: its scale handle went. + assert gizmo.gizmo.scale_axes == {1} + assert not session.closed + + +async def test_a_disabled_plane_can_have_a_gizmo(make_rig): + rig = make_rig() + await rig.start() + plane = rig.plane(enabled=False) + rig.show(plane) + session = rig.session(plane) + assert not session.closed + np.testing.assert_allclose( + rig.gizmo(session).pose()[0], plane.origin[::-1], atol=1e-5 + ) + + +# -- hidden scale handles ------------------------------------------------------- + + +async def test_no_scale_handle_on_an_axis_with_an_unbounded_side(make_rig): + rig = make_rig() + await rig.start() + plane = rig.plane(extent_0=(-8.0, 10.0), extent_1=(None, 7.0)) + rig.show(plane) + session = rig.session(plane) + widget = rig.gizmo(session).gizmo + rig.look((0.5, 0.6, 0.62)) + visible = [child.visible for child in widget._scale_children] + # Axis 0 is the normal and never scales; axis 2 has an unbounded side. + assert visible == [False, True, False] + assert not widget._center_sphere.visible + + # Bounding the side in the model shows the handle; unbounding hides both. + rig.controller.set_render_plane( + rig.visual.id, plane.id, plane.model_copy(update={"extent_1": (-6.0, 7.0)}) + ) + rig.frame(n=2) + assert [child.visible for child in widget._scale_children] == [False, True, True] + rig.controller.set_render_plane( + rig.visual.id, + plane.id, + plane.model_copy(update={"extent_0": (None, None), "extent_1": (None, None)}), + ) + rig.frame(n=2) + assert [child.visible for child in widget._scale_children] == [False] * 3 + + +async def test_a_clipping_gizmo_still_has_no_scale_handles(make_rig): + rig = make_rig() + await rig.start() + store = rig.controller.get_data_store(UUID(str(rig.visual.data_store_id))) + item = ClippingPlane.from_point_normal( + store.data_coordinate_system, MIDDLE, (0, 0, 1) + ) + rig.visual.clipping_planes = (item,) + session = rig.controller.add_clipping_plane_gizmo( + rig.visual.id, rig.view.canvas_id, item.id + ) + rig.look((0.5, 0.6, 0.62)) + widget = rig.gizmo(session).gizmo + assert [child.visible for child in widget._scale_children] == [False] * 3 + + +# -- C6: each handle changes exactly its model fields ---------------------------- + +#: ``(group, index)`` of every handle a render plane gizmo shows. +HANDLES = [ + ("_translate1_children", 0), + ("_translate1_children", 1), + ("_translate1_children", 2), + ("_translate2_children", 0), + ("_translate2_children", 1), + ("_translate2_children", 2), + ("_rotate_children", 0), + ("_rotate_children", 1), + ("_rotate_children", 2), + ("_scale_children", 1), + ("_scale_children", 2), +] + + +def _wrong_fields(before: RenderPlane, after: RenderPlane, kind, axis, along) -> list: + """What a drag of handle ``(kind, axis)`` changed that it should not have. + + *along* says whether a scale drag went out along its handle's arrow. + """ + wrong: list[str] = [] + frame = _frame_of(before) + moved = np.asarray(after.origin) - np.asarray(before.origin) + same_axes = ( + after.in_plane_axis_0 == before.in_plane_axis_0 + and after.in_plane_axis_1 == before.in_plane_axis_1 + ) + same_extents = ( + after.extent_0 == before.extent_0 and after.extent_1 == before.extent_1 + ) + if kind == "translate": + axes = (axis,) if isinstance(axis, int) else tuple(axis) + outside = [i for i in range(3) if i not in axes] + if not same_axes: + wrong.append("axes") + if not same_extents: + wrong.append("extents") + if np.linalg.norm(moved) == 0.0: + wrong.append("origin did not move") + if np.abs(frame[outside] @ moved).max(initial=0.0) > 1e-4: + wrong.append("origin left the handle's axes") + elif kind == "rotate": + if after.origin != before.origin: + wrong.append("origin") + if not same_extents: + wrong.append("extents") + if same_axes: + wrong.append("axes did not turn") + # The frame turned about the handle's own axis. + if not np.allclose(_frame_of(after)[axis], frame[axis], atol=1e-5): + wrong.append("the rotation axis moved") + elif kind == "scale": + if after.origin != before.origin: + wrong.append("origin") + if not same_axes: + wrong.append("axes") + mine, other = ( + ("extent_0", "extent_1") if axis == 1 else ("extent_1", "extent_0") + ) + if getattr(after, other) != getattr(before, other): + wrong.append("the other extent") + low, high = getattr(before, mine) + new_low, new_high = getattr(after, mine) + factor = new_high / high + if not np.isclose(new_low / low, factor): + wrong.append("not symmetric about the origin") + if along and not factor > 1.0: + wrong.append("a drag along the arrow shrank the plane") + else: + wrong.append(f"unknown handle {kind}") + return wrong + + +async def test_each_handle_changes_exactly_its_model_fields(make_rig): + """C6: every handle, through pygfx's own picking and handlers, 40 views.""" + rig = make_rig() + await rig.start() + plane = rig.plane() + rig.show(plane) + session = rig.session(plane) + drags: dict[str, int] = {} + wrong: list = [] + for direction in _directions(40): + rig.look(direction) + for group, index in HANDLES: + at, out, length = rig.arrow(session, group, index) + if length < 12: + continue # seen end-on: no direction to drag along + # Rings are dragged across the arrow, the rest along it. + if group == "_rotate_children": + delta = 30 * np.array([-out[1], out[0]]) + else: + delta = 30 * out + grabbed = rig.drag(session, at, delta) + if grabbed is None: + continue # hidden at this angle, or behind another handle + kind, axis = grabbed + after = rig.model(plane) + along = (group, index) == ("_scale_children", axis) + for fault in _wrong_fields(plane, after, kind, axis, along): + wrong.append((tuple(np.round(direction, 2)), kind, axis, fault)) + label = ( + f"{kind} normal" + if axis == 0 + else f"{kind} in plane" + if kind != "scale" + else "scale" + ) + drags[label] = drags.get(label, 0) + 1 + # Back to the start for the next handle. + rig.controller.set_render_plane(rig.visual.id, plane.id, plane) + rig.frame(n=2) + assert wrong == [] + # Every kind of handle was exercised from many of the 40 views. + assert set(drags) == { + "translate normal", + "translate in plane", + "rotate normal", + "rotate in plane", + "scale", + } + assert min(drags.values()) >= 20, drags + assert sum(drags.values()) >= 250, drags + + +async def test_a_scale_drag_along_the_arrow_grows_at_every_camera_angle(make_rig): + """The pygfx fix: 0 drags reversed, where pygfx as it is reverses many.""" + import pygfx as gfx + + rig = make_rig() + await rig.start() + plane = rig.plane() + rig.show(plane) + session = rig.session(plane) + gizmo = rig.gizmo(session) + widget, proxy = gizmo.gizmo, gizmo.proxy + drags = shrank = shrank_unfixed = disagreements = 0 + for direction in _directions(40): + rig.look(direction) + for dim in (1, 2): + at, out, length = rig.arrow(session, "_scale_children", dim) + if length < 15: + continue + handle = widget._scale_children[dim] + disagreements += int( + (widget.local.scale[dim] < 0) != (widget.gizmo_scale[dim] < 0) + ) + end = SimpleNamespace(x=at[0] + 60 * out[0], y=at[1] + 60 * out[1]) + for fixed in (True, False): + proxy.local.scale = (1.0, 1.0, 1.0) + widget._handle_start("scale", SimpleNamespace(x=at[0], y=at[1]), handle) + if fixed: + widget._handle_scale_move(end) + else: + gfx.TransformGizmo._handle_scale_move(widget, end) + widget._ref = None + small = proxy.local.scale[dim] < 1.0 + if fixed: + shrank += int(small) + else: + shrank_unfixed += int(small) + proxy.local.scale = (1.0, 1.0, 1.0) + drags += 1 + assert drags >= 60 + assert shrank == 0 + # The test would pass for the wrong reason if pygfx never got it wrong. + assert shrank_unfixed == disagreements > 0 + + +# -- extents by the scale handles ----------------------------------------------- + + +async def test_a_scale_drag_multiplies_the_extent_and_folds_in_on_release(make_rig): + rig = make_rig() + await rig.start() + plane = rig.plane() + rig.show(plane) + session = rig.session(plane) + rig.look((0.5, 0.6, 0.62)) + gizmo = rig.gizmo(session) + at, out, _ = rig.arrow(session, "_scale_children", 1) + + rig.send("pointer_down", at[0], at[1]) + assert (gizmo.handle_kind, gizmo.handle_axis) == ("scale", 1) + rig.send("pointer_move", at[0] + 40 * out[0], at[1] + 40 * out[1]) + rig.frame() + # Held: the gizmo is scaled, and the model's extent follows it. + factor = gizmo.frame()[2][0] + assert factor > 1.05 + held = rig.model(plane) + np.testing.assert_allclose(held.extent_0, np.array(plane.extent_0) * factor) + assert held.extent_1 == plane.extent_1 + assert held.origin == plane.origin + # What a control hears during the drag is the growing extent. + assert rig.of(RenderPlanesChangedEvent)[-1].render_planes == (held,) + assert rig.of(RenderPlanesChangedEvent)[-1].source_id == session.id + + rig.send("pointer_up", at[0] + 40 * out[0], at[1] + 40 * out[1], buttons=()) + rig.frame() + # Released: the factor stays in the extent and the gizmo's scale is 1. + assert rig.model(plane) == held + assert gizmo.frame()[2] == (1.0, 1.0) + np.testing.assert_allclose(gizmo.proxy.local.scale, (1.0, 1.0, 1.0)) + + # A second drag starts from the size the first one left. + at, out, _ = rig.arrow(session, "_scale_children", 1) + rig.send("pointer_down", at[0], at[1]) + rig.send("pointer_move", at[0] + 30 * out[0], at[1] + 30 * out[1]) + rig.frame() + second = gizmo.frame()[2][0] + np.testing.assert_allclose( + rig.model(plane).extent_0, np.array(held.extent_0) * second + ) + rig.send("pointer_up", at[0] + 30 * out[0], at[1] + 30 * out[1], buttons=()) + assert gizmo.frame()[2] == (1.0, 1.0) + + +# -- one interaction scope per drag --------------------------------------------- + + +async def test_a_drag_is_one_plane_interaction(make_rig): + rig = make_rig() + await rig.start() + plane = rig.plane() + rig.show(plane) + session = rig.session(plane) + rig.look((0.5, 0.6, 0.62)) + at, out, _ = rig.arrow(session, "_translate1_children", 0) + assert rig.drag(session, at, 30 * out) == ("translate", 0) + interactions = [(e.phase, e.reason) for e in rig.of(PlaneInteractionEvent)] + assert interactions == [("start", None), ("end", "release")] + assert rig.controller.plane_interaction_state(rig.visual.id) == "idle" + assert all(e.source_id == session.id for e in rig.of(RenderPlanesChangedEvent)) + assert len(rig.of(RenderPlanesChangedEvent)) == 2 # one per framed move + + +class MultiscaleRig(PlaneRig): + """A multiscale image in plane mode: a drag holds its plan.""" + + def __init__(self, tmp_path, *, n_canvases: int = 1) -> None: + self.controller = CellierController(gui="offscreen") + self.controller.camera_reslice_enabled = False + self.controller._render_manager.config.scheduler.dims_settle_s = SETTLE_S + fx.write_pyramid(tmp_path, fx.ISO) + store, _ = fx.open_pyramid(tmp_path, fx.ISO) + self.scene = self.controller.add_scene(dim="3d", name="scene") + self.world = self.controller._model.scenes[ + self.scene.id + ].dims.world_coordinate_system + self.visual = self.controller.add_image_multiscale( + data=store, + scene_id=self.scene.id, + appearance=MultiscaleImageAppearance(force_level=1), + single=MultiscaleImageSingleAppearance( + color_map="viridis", clim=(0.0, 65535.0), render_mode="plane" + ), + render_config=MultiscaleImageRenderConfig(**SMALL_BUDGETS, block_size=16), + ) + self._finish(n_canvases) + + def plane(self, **kwargs): + centre = tuple(s / 2 for s in fx.ISO.shape0) + return RenderPlane.from_point_normal( + self.world, + centre, + (1.0, 0.3, 0.2), + axes=AXES, + up=(0.0, 1.0, 0.1), + extent_0=(-30.0, 30.0), + extent_1=(-30.0, 30.0), + **kwargs, + ) + + def look(self, direction) -> None: + camera = self.view.camera + centre = np.array([s / 2 for s in fx.ISO.shape0][::-1]) + distance = float(np.linalg.norm(np.asarray(camera.world.position) - centre)) + camera.local.position = centre + np.asarray(direction) * distance + camera.look_at(tuple(centre)) + self.frame(n=2) + + +def _record_plans(monkeypatch) -> list[str]: + from cellier.render.scene_manager import SceneManager + + plans: list[str] = [] + original = SceneManager.plan_chunked + + def spy(self, request, visual_configs): + for visual_id, cfg in visual_configs.items(): + targets = request.target_visual_ids + if targets is None or visual_id in targets: + plans.append(cfg.plan_mode.name) + return original(self, request, visual_configs) + + monkeypatch.setattr(SceneManager, "plan_chunked", spy) + return plans + + +async def test_a_gizmo_drag_holds_the_plan_and_loads_once_at_release( + make_rig, tmp_path, monkeypatch +): + """B1, through the real gizmo callbacks: counts only.""" + rig = make_rig(MultiscaleRig, tmp_path=tmp_path) + plane = rig.plane() + rig.controller.set_render_planes(rig.visual.id, (plane,)) + await rig.start() + rig.log.clear() + session = rig.session(plane) + rig.look((0.5, 0.6, 0.62)) + await drain_loading(rig.controller) + plans = _record_plans(monkeypatch) + scheduler = rig.controller._render_manager.scheduler + at, out, _ = rig.arrow(session, "_translate1_children", 0) + + rig.send("pointer_down", at[0], at[1]) + assert session.dragging + for step in (0.25, 0.5, 0.75, 1.0): + rig.send("pointer_move", at[0] + 40 * step * out[0], at[1] + 40 * step * out[1]) + rig.frame() + assert plans == [] # nothing planned while the handle moves + assert scheduler.core.idle() + assert rig.controller.plane_interaction_state(rig.visual.id) == "active" + assert rig.model(plane).origin != plane.origin + rig.send("pointer_up", at[0] + 40 * out[0], at[1] + 40 * out[1], buttons=()) + # The target loads once, at the release. + assert plans == ["FULL"] + interactions = [(e.phase, e.reason) for e in rig.of(PlaneInteractionEvent)] + assert interactions == [("start", None), ("end", "release")] + await drain_loading(rig.controller) + + +async def test_a_pause_ends_the_motion_and_the_next_move_starts_another( + make_rig, tmp_path, monkeypatch +): + rig = make_rig(MultiscaleRig, tmp_path=tmp_path) + plane = rig.plane() + rig.controller.set_render_planes(rig.visual.id, (plane,)) + await rig.start() + rig.log.clear() + session = rig.session(plane) + rig.look((0.5, 0.6, 0.62)) + await drain_loading(rig.controller) + plans = _record_plans(monkeypatch) + at, out, _ = rig.arrow(session, "_translate1_children", 0) + + rig.send("pointer_down", at[0], at[1]) + rig.send("pointer_move", at[0] + 15 * out[0], at[1] + 15 * out[1]) + rig.frame() + assert plans == [] + # The handle is held still: the motion settles and the plane refines. + await asyncio.sleep(SETTLE_S * 4) + assert session.dragging + assert plans == ["FULL"] + rig.send("pointer_move", at[0] + 30 * out[0], at[1] + 30 * out[1]) + rig.frame() + assert plans == ["FULL"] # moving again: held again + rig.send("pointer_up", at[0] + 30 * out[0], at[1] + 30 * out[1], buttons=()) + assert plans == ["FULL", "FULL"] + interactions = [(e.phase, e.reason) for e in rig.of(PlaneInteractionEvent)] + assert interactions == [ + ("start", None), + ("end", "settle"), + ("start", None), + ("end", "release"), + ] + await drain_loading(rig.controller) + + +# -- closing -------------------------------------------------------------------- + + +async def _open(make_rig, **kwargs): + rig = make_rig(**kwargs) + await rig.start() + plane = rig.plane() + other = rig.plane(normal=(0.0, 1.0, 0.0)) + rig.show(plane, other) + return rig, plane, other, rig.session(plane) + + +def _closed_event(rig) -> PlaneGizmoChangedEvent: + return rig.of(PlaneGizmoChangedEvent)[-1] + + +async def test_removing_the_plane_closes_the_session(make_rig): + rig, _plane, other, session = await _open(make_rig) + rig.controller.set_render_planes(rig.visual.id, (other,)) + assert session.closed + assert rig.controller.get_plane_gizmo(rig.view.canvas_id) is None + assert _closed_event(rig)[2:] == (None, None, None) + with pytest.raises(KeyError): + rig.view.get_plane_gizmo(session.id) + + +async def test_removing_another_plane_leaves_the_session(make_rig): + rig, plane, _other, session = await _open(make_rig) + rig.controller.set_render_planes(rig.visual.id, (plane,)) + assert not session.closed + + +async def test_leaving_plane_mode_closes_the_session(make_rig): + rig, _plane, _other, session = await _open(make_rig) + rig.visual.single.render_mode = "mip" + assert session.closed + assert _closed_event(rig).plane_id is None + + +async def test_leaving_3d_closes_the_session(make_rig): + rig, _plane, _other, session = await _open(make_rig) + rig.controller.set_displayed_axes(rig.scene.id, (1, 2)) + assert session.closed + assert _closed_event(rig).plane_id is None + + +async def test_other_displayed_axes_close_the_session(make_rig): + rig, _plane, _other, session = await _open(make_rig, time_axis=True) + assert not session.closed + # Still 3D, but t, y, x: the plane's z, y, x are not the displayed ones. + with pytest.warns(UserWarning, match="not drawn"): + rig.controller.set_displayed_axes(rig.scene.id, (0, 2, 3)) + assert session.closed + assert rig.controller.get_plane_gizmo(rig.view.canvas_id) is None + + +async def test_removing_the_visual_closes_the_session(make_rig): + rig, _plane, _other, session = await _open(make_rig) + rig.controller.remove_visual(rig.visual.id) + assert session.closed + + +async def test_closing_mid_drag_ends_the_interaction(make_rig): + rig, plane, other, session = await _open(make_rig) + rig.look((0.5, 0.6, 0.62)) + at, out, _ = rig.arrow(session, "_translate1_children", 0) + rig.send("pointer_down", at[0], at[1]) + rig.send("pointer_move", at[0] + 20 * out[0], at[1] + 20 * out[1]) + rig.frame() + assert rig.controller.plane_interaction_state(rig.visual.id) == "active" + rig.controller.set_render_planes(rig.visual.id, (other,)) + assert session.closed + assert not CAPTURES + del plane + + +# -- refusals ------------------------------------------------------------------- + + +async def test_a_gizmo_is_refused_outside_plane_mode(make_rig): + rig = make_rig() + await rig.start() + plane = rig.plane() + rig.show(plane) + rig.visual.single.render_mode = "mip" + canvas_id = rig.view.canvas_id + assert "plane" in rig.controller.render_plane_gizmo_blocked( + rig.visual.id, canvas_id, plane.id + ) + with pytest.raises(ValueError, match="'plane' render mode"): + rig.controller.add_render_plane_gizmo(rig.visual.id, canvas_id, plane.id) + assert rig.controller.get_plane_gizmo(canvas_id) is None + rig.visual.single.render_mode = "plane" + assert ( + rig.controller.render_plane_gizmo_blocked(rig.visual.id, canvas_id, plane.id) + == "" + ) + + +async def test_a_gizmo_is_refused_in_2d_and_for_an_unknown_plane(make_rig): + rig = make_rig() + await rig.start() + plane = rig.plane() + rig.show(plane) + canvas_id = rig.view.canvas_id + with pytest.raises(KeyError): + rig.controller.add_render_plane_gizmo(rig.visual.id, canvas_id, uuid4()) + rig.controller.set_displayed_axes(rig.scene.id, (1, 2)) + assert "2D" in rig.controller.render_plane_gizmo_blocked( + rig.visual.id, canvas_id, plane.id + ) + with pytest.raises(ValueError, match="2D"): + rig.controller.add_render_plane_gizmo(rig.visual.id, canvas_id, plane.id) + + +async def test_a_plane_off_the_displayed_axes_is_refused(make_rig): + rig = make_rig(time_axis=True) + await rig.start() + plane = RenderPlane.from_point_normal( + rig.world, (0.0, 15.5, 15.5), (0, 0, 1), axes=("t", "y", "x") + ) + with pytest.warns(UserWarning, match="not drawn"): + rig.show(plane) + canvas_id = rig.view.canvas_id + reason = rig.controller.render_plane_gizmo_blocked( + rig.visual.id, canvas_id, plane.id + ) + assert "axes other than" in reason + with pytest.raises(ValueError, match="three axes"): + rig.controller.add_render_plane_gizmo(rig.visual.id, canvas_id, plane.id) + + +# -- one gizmo per canvas, for both kinds --------------------------------------- + + +def _clip(rig) -> ClippingPlane: + store = rig.controller.get_data_store(UUID(str(rig.visual.data_store_id))) + item = ClippingPlane.from_point_normal( + store.data_coordinate_system, MIDDLE, (0, 0, 1), enabled=False + ) + rig.visual.clipping_planes = (item,) + rig.log.clear() + return item + + +async def test_a_clipping_and_a_render_gizmo_are_exclusive(make_rig): + rig = make_rig() + await rig.start() + plane = rig.plane() + rig.show(plane) + item = _clip(rig) + canvas_id = rig.view.canvas_id + + clipping = rig.controller.add_clipping_plane_gizmo( + rig.visual.id, canvas_id, item.id + ) + assert isinstance(clipping, ClippingPlaneGizmoController) + assert rig.controller.get_plane_gizmo(canvas_id).kind == "clipping" + render = rig.controller.add_render_plane_gizmo(rig.visual.id, canvas_id, plane.id) + assert clipping.closed + assert not render.closed + assert rig.controller.get_plane_gizmo(canvas_id) is render + again = rig.controller.add_clipping_plane_gizmo(rig.visual.id, canvas_id, item.id) + assert render.closed + assert rig.controller.get_plane_gizmo(canvas_id) is again + # One event per replacement, naming the kind and the plane: never "none". + assert [(e.kind, e.plane_id) for e in rig.of(PlaneGizmoChangedEvent)] == [ + ("clipping", item.id), + ("render", plane.id), + ("clipping", item.id), + ] + assert len(rig.view._plane_gizmos) == 1 + rig.controller.remove_plane_gizmo(canvas_id) + assert rig.of(PlaneGizmoChangedEvent)[-1][2:] == (None, None, None) + assert not rig.view._plane_gizmos + + +async def test_a_refused_gizmo_leaves_the_one_the_canvas_had(make_rig): + rig = make_rig() + await rig.start() + plane = rig.plane() + rig.show(plane) + item = _clip(rig) + canvas_id = rig.view.canvas_id + render = rig.controller.add_render_plane_gizmo(rig.visual.id, canvas_id, plane.id) + rig.log.clear() + with pytest.raises(KeyError): + rig.controller.add_clipping_plane_gizmo(rig.visual.id, canvas_id, uuid4()) + assert not render.closed + assert rig.controller.get_plane_gizmo(canvas_id) is render + assert rig.of(PlaneGizmoChangedEvent) == [] + del item + + +async def test_the_request_event_names_the_kind(make_rig): + rig = make_rig() + await rig.start() + plane = rig.plane() + rig.show(plane) + item = _clip(rig) + canvas_id = rig.view.canvas_id + widget = uuid4() + + def request(kind, plane_id, enabled=True): + rig.controller.incoming_events.emit( + PlaneGizmoUpdateEvent( + source_id=widget, + visual_id=rig.visual.id, + plane_id=plane_id, + canvas_id=canvas_id, + kind=kind, + enabled=enabled, + ) + ) + + request("render", plane.id) + session = rig.controller.get_plane_gizmo(canvas_id) + assert (session.kind, session.plane_id) == ("render", plane.id) + event = rig.of(PlaneGizmoChangedEvent)[-1] + assert (event.source_id, event.kind, event.plane_id) == (widget, "render", plane.id) + + # Switching a clipping toggle off does not close a render plane's gizmo. + request("clipping", plane.id, enabled=False) + assert rig.controller.get_plane_gizmo(canvas_id) is session + request("clipping", item.id) + assert rig.controller.get_plane_gizmo(canvas_id).kind == "clipping" + assert session.closed + request("clipping", item.id, enabled=False) + assert rig.controller.get_plane_gizmo(canvas_id) is None + with pytest.raises(Exception, match="Unknown plane gizmo kind"): + request("slab", plane.id) + + +async def test_each_canvas_has_its_own_gizmo(make_rig): + rig = make_rig(n_canvases=2) + await rig.start() + plane = rig.plane() + rig.show(plane) + first = rig.session(plane, view=rig.views[0]) + second = rig.session(plane, view=rig.views[1]) + assert not first.closed and not second.closed + # A drag in one canvas moves the gizmo of the other. + rig.look((0.5, 0.6, 0.62)) + at, out, _ = rig.arrow(first, "_translate1_children", 0) + assert rig.drag(first, at, 30 * out) == ("translate", 0) + moved = rig.model(plane) + assert moved.origin != plane.origin + np.testing.assert_allclose( + rig.gizmo(second).pose()[0], moved.origin[::-1], atol=1e-5 + ) + assert la.vec_dist(rig.gizmo(first).pose()[0], rig.gizmo(second).pose()[0]) < 1e-5 diff --git a/tests/render/test_render_planes_controller.py b/tests/render/test_render_planes_controller.py index f2c4e3ef..dbc41e0f 100644 --- a/tests/render/test_render_planes_controller.py +++ b/tests/render/test_render_planes_controller.py @@ -810,22 +810,18 @@ def test_an_in_memory_plane_drag_is_announced_and_reads_nothing( # -- nothing is drawn differently yet --------------------------------------------- -def test_the_gui_combos_do_not_offer_plane_yet(controller, image_volume, labels_volume): +def test_the_gui_combos_offer_plane(controller, image_volume, labels_volume): + """Design 9.1: "plane" is a choice of every render-mode combo.""" from cellier.gui._appearance_fields import literal_choices from cellier.gui._image_controls import image_control_values scene = controller.add_scene(dim="3d", name="scene") - image = controller.add_image( - data=image_volume, - scene_id=scene.id, - single=InMemoryImageSingleAppearance(render_mode="plane"), - ) + image = controller.add_image(data=image_volume, scene_id=scene.id) labels = controller.add_labels(data=labels_volume, scene_id=scene.id) assert ( - "plane" - not in image_control_values(image, fields=["render_mode"])["render_modes"] + "plane" in image_control_values(image, fields=["render_mode"])["render_modes"] ) - assert "plane" not in literal_choices(labels.appearance, "render_mode") + assert "plane" in literal_choices(labels.appearance, "render_mode") @pytest.mark.parametrize("kind", ["image", "labels"]) diff --git a/tests/test_plane_rendering_renames.py b/tests/test_plane_rendering_renames.py index 5feb4c6b..a43e65be 100644 --- a/tests/test_plane_rendering_renames.py +++ b/tests/test_plane_rendering_renames.py @@ -102,6 +102,36 @@ def test_the_interaction_event_is_renamed() -> None: assert "PlaneInteractionEvent" in cellier.events.__all__ +def test_the_clipping_gizmo_names_are_neutral() -> None: + """One gizmo per canvas serves both kinds of plane (design 8.3, Phase 8).""" + for old, new in ( + ("Clipping" + "PlaneGizmoChangedEvent", "PlaneGizmoChangedEvent"), + ("Clipping" + "PlaneGizmoUpdateEvent", "PlaneGizmoUpdateEvent"), + ): + assert not hasattr(cellier.events, old) + assert new in cellier.events.__all__ + for old, new in ( + ("get_" + "clipping_plane_gizmo", "get_plane_gizmo"), + ("remove_" + "clipping_plane_gizmo", "remove_plane_gizmo"), + ("on_" + "clipping_plane_gizmo_changed", "on_plane_gizmo_changed"), + ): + assert not hasattr(CellierController, old) + assert hasattr(CellierController, new) + # Adding stays per kind: the two take different planes. + assert hasattr(CellierController, "add_clipping_plane_gizmo") + assert hasattr(CellierController, "add_render_plane_gizmo") + + +def test_no_old_gizmo_name_is_left() -> None: + pattern = re.compile( + "Clipping" + "PlaneGizmo(Changed|Update)Event" + "|(get|remove)_" + "clipping_plane_gizmo(?!_data)" + "|on_" + "clipping_plane_gizmo_changed" + ) + hits = _hits(pattern, set()) + assert not hits, "\n".join(hits) + + def test_the_old_controls_key_is_refused() -> None: from cellier.convenience.gui._controls_config import ( MultiscaleImageControlsConfig, diff --git a/zensical.toml b/zensical.toml index 700a99d1..35fbaf0b 100644 --- a/zensical.toml +++ b/zensical.toml @@ -60,6 +60,7 @@ nav = [ { "Capture screenshots" = "How_To/capture_screenshots.md" }, { "Clip a visual with planes" = "How_To/clipping_planes.md" }, { "Debug events" = "How_To/debug_events.md" }, + { "Draw a volume on planes" = "How_To/render_planes.md" }, ] }, { "API Reference" = [ { "CellierController" = "api/controller.md" }, From 7c808b9245e3e68b61f4fb183d44b524d2033c7a Mon Sep 17 00:00:00 2001 From: Kevin Yamauchi Date: Thu, 8 Oct 2026 18:47:28 +0200 Subject: [PATCH 08/24] add plane outlines --- docs/How_To/clipping_planes.md | 75 ++- docs/api/visuals.md | 7 + src/cellier/controller.py | 75 ++- src/cellier/convenience/_ortho_planes.py | 67 ++- src/cellier/gui/_clipping_planes.py | 227 +++++++- src/cellier/gui/_render_planes.py | 61 ++- .../gui/anywidget/visuals/_clipping.py | 9 +- .../gui/anywidget/visuals/_render_planes.py | 9 +- .../visuals/static/clipping_planes.css | 19 + .../visuals/static/clipping_planes.js | 57 +- .../visuals/static/render_planes.css | 19 + .../anywidget/visuals/static/render_planes.js | 57 +- src/cellier/gui/qt/visuals/_clipping.py | 72 ++- src/cellier/gui/qt/visuals/_plane_outline.py | 81 +++ src/cellier/gui/qt/visuals/_render_planes.py | 71 ++- src/cellier/render/_clipping.py | 74 +++ src/cellier/render/_plane_outline.py | 427 +++++++++++++++ src/cellier/render/_render_planes.py | 31 ++ src/cellier/render/render_manager.py | 49 +- src/cellier/render/scene_manager.py | 72 ++- src/cellier/render/shaders/_plane_outline.py | 118 +++++ src/cellier/render/visuals/_graph_memory.py | 15 +- src/cellier/render/visuals/_image.py | 6 + src/cellier/render/visuals/_image_memory.py | 4 + src/cellier/render/visuals/_label_memory.py | 4 + .../render/visuals/_label_multiscale.py | 6 + src/cellier/render/visuals/_lines_memory.py | 10 +- src/cellier/render/visuals/_mesh.py | 15 +- src/cellier/render/visuals/_plane_gizmo.py | 58 +- src/cellier/render/visuals/_plane_outline.py | 250 +++++++++ src/cellier/render/visuals/_points_memory.py | 10 +- src/cellier/visuals/__init__.py | 2 + src/cellier/visuals/_clipping.py | 10 + src/cellier/visuals/_plane_outline.py | 98 ++++ src/cellier/visuals/_render_plane.py | 10 +- .../test_plane_rendering_examples.py | 106 ++++ .../convenience/test_render_planes_viewers.py | 140 +++++ tests/gui/test_clipping_planes_controls.py | 421 ++++++++++++++- tests/gui/test_plane_outline_js.py | 91 ++++ tests/gui/test_plane_position_js.py | 226 ++++++++ tests/gui/test_render_planes_controls.py | 313 ++++++++++- tests/render/_plane_outline_pixels.py | 86 +++ tests/render/test_plane_gizmo.py | 93 +++- tests/render/test_plane_outline_clipping.py | 498 ++++++++++++++++++ tests/render/test_plane_outline_controller.py | 233 ++++++++ tests/render/test_plane_outline_geometry.py | 373 +++++++++++++ tests/render/test_plane_outline_render.py | 384 ++++++++++++++ tests/render/test_plane_outline_scene.py | 479 +++++++++++++++++ tests/render/test_render_plane_gizmo.py | 36 +- 49 files changed, 5500 insertions(+), 154 deletions(-) create mode 100644 src/cellier/gui/qt/visuals/_plane_outline.py create mode 100644 src/cellier/render/_plane_outline.py create mode 100644 src/cellier/render/shaders/_plane_outline.py create mode 100644 src/cellier/render/visuals/_plane_outline.py create mode 100644 src/cellier/visuals/_plane_outline.py create mode 100644 tests/gui/test_plane_outline_js.py create mode 100644 tests/gui/test_plane_position_js.py create mode 100644 tests/render/_plane_outline_pixels.py create mode 100644 tests/render/test_plane_outline_clipping.py create mode 100644 tests/render/test_plane_outline_controller.py create mode 100644 tests/render/test_plane_outline_geometry.py create mode 100644 tests/render/test_plane_outline_render.py create mode 100644 tests/render/test_plane_outline_scene.py diff --git a/docs/How_To/clipping_planes.md b/docs/How_To/clipping_planes.md index 020400e5..e8fac4bb 100644 --- a/docs/How_To/clipping_planes.md +++ b/docs/How_To/clipping_planes.md @@ -130,6 +130,56 @@ clipped by where it really is, not by where it lands on the slice. The bounding-box wireframe and overlays are not clipped. Camera fit uses the whole data, not the kept part. +## An outline round a cut + +A clipping plane can have an outline: a line round its cut face, in a 3D +view. + +```python +from cellier.visuals import PlaneOutline + +plane = ClippingPlane.from_point_normal( + system, + point=(40, 0, 0), + normal=(1, 0, 0), + axes=("z", "y", "x"), + outline=PlaneOutline(enabled=True, color=(1.0, 0.8, 0.2, 1.0), width=2.0), +) +``` + +`enabled` is `False` by default, `color` is RGBA from 0 to 1, and `width` +is in screen pixels. + +The cut face is the plane where it crosses the visual's box, inside the +visual's other enabled clipping planes: two planes each end at the other. +The box depends on the visual: + +| Visual | The box | +|---|---| +| Image, labels | The data: the edges of its voxels | +| Mesh | The store's extent, the box its bounding-box wireframe draws | +| Points, lines, graph | The bounding box of what is drawn | + +So on a mesh or on points the outline is the plane's cut through that box, +not the outline of the cut geometry. + +- The outline is part of its plane: change it with + `item.model_copy(update={"outline": ...})` in a new tuple. **A change of + the outline alone reads nothing again.** +- It is drawn in a 3D view only, while the plane is enabled and the visual + is shown, in any render mode. A 2D view draws none. +- A plane with a component on a sliced axis moves its cut face, and its + outline, with the slider. +- **A mesh, points, lines or graph visual clipped across a sliced axis has + no outlines.** Such a visual is clipped as it is read (it may be drawn + through a slab or as a trail), and then has no one cut face. +- A plane several visuals share (an `OrthoViewer`'s linked panels) has one + outline where the cut face is the same. +- The line is depth tested and is not picked. A visual in the `"plane"` + render mode can have both: its render planes' outlines end at the cut, + and the cut face has its own. See [Draw a volume on + planes](render_planes.md#an-outline-round-a-plane). + ## What it costs - Moving a plane does not reload a mesh, points or an in-memory image in 3D: @@ -158,7 +208,10 @@ viewer.add_image( ``` The "Clipping planes" group has a row per plane: on or off, a flip button, -a remove button, the normal, and a position slider along the normal. +a remove button, the normal, a position slider along the normal, and +"Outline": a box that draws the plane's [outline](#an-outline-round-a-cut) +and its colour (red, green and blue; the alpha and the width are set from +code and are kept). The flag needs a dock to show the control in. On a `Viewer` the control is part of the appearance dock, so the config also needs `appearance` and the @@ -176,8 +229,18 @@ of `normal`. The axis names come from the store's data coordinate system - under them, the normal's entry on that axis, for an oblique plane. Only the direction of the normal matters; the entries are not rescaled as you type. -The position slider's range is the data's bounding box along the normal, and -follows the store when its extent changes. +The position slider shows the plane's **depth into the data's bounding +box**, in data units: 0 where a plane first touches the box, on the side +the normal points away from, and at the slider's far end the size of the +box along the normal. Dragging right moves the plane the way its normal +points, so it cuts away more. A flip turns a depth `d` into `span - d` and +leaves the plane where it is. The range follows the store when its extent +changes. + +A plane taken out of the box (by the gizmo, or from code) pins the slider at +the nearer end; the number, drawn in orange italics, is then below 0 or +above the slider's end. The depth is the control's own number: a plane's +`offset` is measured from the data's origin, as before. ## The gizmo @@ -196,6 +259,9 @@ panel's planes; the 2D panels follow when they are linked to it - The arrow along the normal slides the plane. The two rings that tilt the normal turn it about the gizmo. +- **The arrow is the normal**: it points to the side that is kept, from + whichever side the camera looks. It turns over when the plane is flipped + and can point away from you. - The other handles (the two in-plane arrows, the in-plane square, the ring about the normal) move the gizmo on the plane and leave the plane where it is. Use them to bring the gizmo to the part you are looking at. @@ -286,6 +352,9 @@ visuals["vol"].clipping_planes = (moved,) # "all": the 2D panels follow ortho.clipping_controller.mode = "2d" # at any time ``` +A plane's outline is part of the tuple, so it is linked with it. It is +drawn in the 3D panel. + - The mode is for the whole viewer. Links are within the panels of one `add_*` call, never between two datasets. - `add_*(clipping_planes=...)` gives the same planes to all four panels in diff --git a/docs/api/visuals.md b/docs/api/visuals.md index af20ee51..c3dba263 100644 --- a/docs/api/visuals.md +++ b/docs/api/visuals.md @@ -84,6 +84,13 @@ plane is in the scene's world space. ::: cellier.visuals.RenderPlane +## Plane outlines + +A clipping plane and a render plane each carry an `outline`: a line round +the polygon the plane makes in its visual, in a 3D view. + +::: cellier.visuals.PlaneOutline + ## Common ::: cellier.visuals.AABBParams diff --git a/src/cellier/controller.py b/src/cellier/controller.py index 015928f9..4eabdf8c 100644 --- a/src/cellier/controller.py +++ b/src/cellier/controller.py @@ -225,6 +225,7 @@ MeshVisual, MultiscaleMeshVisual, ) +from cellier.visuals._plane_outline import without_outlines from cellier.visuals._points_memory import PointsMarkerAppearance, PointsVisual from cellier.visuals._render_plane import ( MAX_RENDER_PLANES, @@ -2628,6 +2629,7 @@ def _register_visual( self._seed_visual_render(visual_model) self._check_slider_axes(scene_id) self._refresh_scene_overlays(scene_id) + self._refresh_plane_outlines(scene_id) # EventBus subscriptions — only subscribe when the GFX visual implements # the handler so new visual types get wired automatically. @@ -4862,6 +4864,9 @@ def _wire_clipping_planes(self, visual: BaseVisual) -> None: number of planes changed). A visual whose reads depend on the planes is resliced. Emits ``ClippingPlanesChangedEvent``. + A change of the planes' outlines alone is not a move (plane outline + design 4.3): it is announced and drawn, and nothing is read again. + A tuple that fails the check is put back to the last good one and the error raised. """ @@ -4875,12 +4880,16 @@ def _on_clipping_planes(planes: tuple) -> None: with visual.events.clipping_planes.blocked(): visual.clipping_planes = accepted[0] raise - accepted[0] = planes + previous, accepted[0] = accepted[0], planes + moved = without_outlines(previous) != without_outlines(planes) source_id = _clipping_source_id_override.get() or self._id - # A tick of a drag while a scope is open; otherwise a jump. The - # tracker hears of it first, so a drag's start is announced ahead - # of its first change. - self._plane_interaction_driver.tick(visual_id, source_id, interactive=False) + if moved: + # A tick of a drag while a scope is open; otherwise a jump. + # The tracker hears of it first, so a drag's start is + # announced ahead of its first change. + self._plane_interaction_driver.tick( + visual_id, source_id, interactive=False + ) self._outgoing_events.emit( ClippingPlanesChangedEvent( source_id=source_id, @@ -4889,14 +4898,17 @@ def _on_clipping_planes(planes: tuple) -> None: ) ) if visual_id in self._visual_to_scene: - if self._render_manager.clipping_planes_affect_request(visual_id): + if moved and self._render_manager.clipping_planes_affect_request( + visual_id + ): # A tick of a drag keeps the last plan when the view # plans coarsest while moving; the drag's end plans. if not self._hold_plan_for_plane_drag(visual): self.reslice_visual(visual_id) self._request_draw_for_visual(visual_id) - # No event loop means no timer: the drag ends here. - self._plane_interaction_driver.settle_without_loop(visual_id) + if moved: + # No event loop means no timer: the drag ends here. + self._plane_interaction_driver.settle_without_loop(visual_id) visual.events.clipping_planes.connect(_on_clipping_planes) self._visual_psygnal_handlers.setdefault(visual_id, []).append( @@ -5105,6 +5117,41 @@ def _refresh_plane_mode(self, visual_id: UUID, *, reslice: bool = True) -> None: if shown != was and reslice: self.reslice_visual(visual_id) self._request_draw_for_visual(visual_id) + self._refresh_plane_outlines(self._visual_to_scene[visual_id]) + + def _refresh_plane_outlines(self, scene_id: UUID) -> None: + """Tell the render layer which visuals show their planes' outlines. + + Plane outline design 3.3 and 6.4. In a 3D view a visible visual + shows the outlines of its clipping planes, and of its render planes + while it is drawn in plane mode. A 2D view shows none. What an + outline looks like, and where it is, the + render layer follows by itself: this is called when what is *shown* + may have changed (a visual added, shown or hidden; a render mode; + the displayed axes). + + Parameters + ---------- + scene_id : UUID + The scene. An unregistered scene is ignored. + """ + if scene_id not in self._model.scenes: + return + kinds: dict[UUID, tuple[str, ...]] = {} + if self._n_displayed_dims(scene_id) == 3: + for visual_id, visual_scene_id in self._visual_to_scene.items(): + if visual_scene_id != scene_id: + continue + visual = self.get_visual_model(visual_id) + appearance = getattr(visual, "appearance", None) + if not getattr(appearance, "visible", True): + continue + kinds[visual_id] = ( + ("clipping", "render") + if self._draws_planes(visual) + else ("clipping",) + ) + self._render_manager.set_plane_outline_kinds(scene_id, kinds) def _wire_render_planes(self, visual: BaseVisual) -> None: """Act on a replaced ``render_planes`` tuple (design 7.4, 8.2). @@ -5116,6 +5163,9 @@ def _wire_render_planes(self, visual: BaseVisual) -> None: drag's end when the view plans coarsest while moving. Otherwise the tuple is stored and announced and nothing else happens. + A change of the planes' outlines alone is not a move (plane outline + design 4.3): it is announced and drawn, and nothing is read again. + A tuple that fails the check is put back to the last good one and the error raised. """ @@ -5131,9 +5181,10 @@ def _on_render_planes(planes: tuple) -> None: visual.render_planes = accepted[0] raise previous, accepted[0] = accepted[0], planes + moved = without_outlines(previous) != without_outlines(planes) source_id = _render_planes_source_id_override.get() or self._id drawn = self._draws_planes(visual) - if drawn: + if drawn and moved: # The tracker hears of it first, so a drag's start is # announced ahead of its first change. self._plane_interaction_driver.tick( @@ -5148,6 +5199,9 @@ def _on_render_planes(planes: tuple) -> None: ) if not drawn: return + if not moved: + self._request_draw_for_visual(visual_id) + return self._warn_render_planes_not_displayed(visual) scene_id = self._visual_to_scene[visual_id] if isinstance(visual, (MultiscaleImageVisual, MultiscaleLabelVisual)): @@ -6043,6 +6097,9 @@ def _on_appearance_psygnal(info: EmissionInfo) -> None: and isinstance(self.get_visual_model(visual_id), _SKIP_WHEN_HIDDEN) ): self.reslice_visual(visual_id) + if visual_id in self._visual_to_scene: + # A hidden visual's planes have no outline. + self._refresh_plane_outlines(self._visual_to_scene[visual_id]) else: self._outgoing_events.emit( AppearanceChangedEvent( diff --git a/src/cellier/convenience/_ortho_planes.py b/src/cellier/convenience/_ortho_planes.py index b171a899..7e144a82 100644 --- a/src/cellier/convenience/_ortho_planes.py +++ b/src/cellier/convenience/_ortho_planes.py @@ -12,7 +12,7 @@ import numpy as np from psygnal import Signal -from cellier.visuals import RenderPlane +from cellier.visuals import PlaneOutline, RenderPlane if TYPE_CHECKING: from collections.abc import Callable, Mapping, Sequence @@ -41,6 +41,23 @@ """Why a render planes control is read-only in ``"slices"`` mode.""" +def _three_outlines( + outlines: PlaneOutline | Sequence[PlaneOutline] | None, +) -> tuple[PlaneOutline, PlaneOutline, PlaneOutline]: + """One outline per slice plane, from one for all, three, or none.""" + if outlines is None: + return (PlaneOutline(), PlaneOutline(), PlaneOutline()) + if isinstance(outlines, PlaneOutline): + return (outlines, outlines, outlines) + given = tuple(outlines) + if len(given) != 3 or not all(isinstance(o, PlaneOutline) for o in given): + raise ValueError( + "The slice planes' outlines are one PlaneOutline, for all three " + "planes, or three: one each for the xy, xz and yz panels' planes." + ) + return given + + def _check_mode(mode: object) -> None: if mode not in ("slices", None): raise ValueError(f'Unknown plane mode {mode!r}. Expected "slices" or None.') @@ -77,8 +94,8 @@ class OrthoPlaneController: on a slice plane, and the render planes control shows them read-only): the change is carried over. A plane moved along its normal moves its panel's slider, on every panel when the panels' - axes are linked. Its extents, its ``enabled`` flag and its spin are - copied to the other visuals. A tuple that is not the three slice + axes are linked. Its extents, its ``enabled`` flag, its spin and + its outline are copied to the other visuals. A tuple that is not the three slice planes (a plane tilted off its axis, added, removed or replaced) cannot be carried over: the visual is given the slice planes back, with a warning. @@ -107,6 +124,9 @@ class OrthoPlaneController: that slices that axis, and the 3D panel. mode : {"slices"} or None The starting mode. + outlines : PlaneOutline, three of them, or None + The outlines of the slice planes (:attr:`outlines`). ``None`` is + no outline. Attributes ---------- @@ -123,8 +143,10 @@ def __init__( spatial_axes: Sequence[int], dims_controller: Callable[[], Any] | None = None, mode: OrthoPlaneMode = None, + outlines: PlaneOutline | Sequence[PlaneOutline] | None = None, ) -> None: _check_mode(mode) + self._outlines = _three_outlines(outlines) self._id: UUID = uuid4() self._controller = controller self._scenes = dict(scenes) @@ -188,6 +210,37 @@ def mode(self, mode: OrthoPlaneMode) -> None: self._write(visual_id, planes) self.mode_changed.emit(mode) + @property + def outlines(self) -> tuple[PlaneOutline, PlaneOutline, PlaneOutline]: + """The outlines of the slice planes of the ``xy``, ``xz`` and ``yz`` panels. + + A line around each slice where it crosses the data, in the 3D + panel. Set one ``PlaneOutline`` for all three planes, or three, or + ``None`` for no outline. In ``"slices"`` mode the planes of every + visual are given them at once; in mode ``None`` they are what the + slice planes are built with when the mode is next turned on. + + The render planes control shows the slice planes read-only, so + this is where their outlines are set. An outline assigned to a + slice plane of one visual from code is copied to the others, as a + plane's extents are, and is then what this returns. + """ + return self._outlines + + @outlines.setter + def outlines(self, outlines: PlaneOutline | Sequence[PlaneOutline] | None) -> None: + self._outlines = _three_outlines(outlines) + if self._mode != "slices" or not self._visuals: + return + planes = tuple( + plane.model_copy(update={"outline": outline}) + for plane, outline in zip( + self._current_planes(), self._outlines, strict=True + ) + ) + for visual_id in self._visuals: + self._write(visual_id, planes) + @property def visual_ids(self) -> tuple[UUID, ...]: """The 3D panel's visuals whose planes the mode writes.""" @@ -222,8 +275,9 @@ def slice_planes( ---------- like : Sequence[RenderPlane] or None Slice planes to keep everything of but the position along the - normal (their extents, ``enabled`` and spin). ``None`` builds - new ones: through the point the three slices meet at, unbounded. + normal (their extents, ``enabled``, spin and outline). ``None`` + builds new ones: through the point the three slices meet at, + unbounded, with :attr:`outlines`. Returns ------- @@ -255,6 +309,7 @@ def slice_planes( # axis k. in_plane_axis_0=tuple(unit[(k + 1) % 3]), in_plane_axis_1=tuple(unit[(k + 2) % 3]), + outline=self._outlines[k], ) for k in range(3) ) @@ -390,6 +445,8 @@ def _on_planes(self, event: RenderPlanesChangedEvent) -> None: ) self._write(visual_id, self._current_planes(skip=visual_id)) return + # An outline given to a plane by hand is the planes' outline now. + self._outlines = tuple(plane.outline for plane in like) # The planes' positions become the panels' slice positions... moved = { axis: float(plane.origin[k]) diff --git a/src/cellier/gui/_clipping_planes.py b/src/cellier/gui/_clipping_planes.py index 9786ff11..8c66309b 100644 --- a/src/cellier/gui/_clipping_planes.py +++ b/src/cellier/gui/_clipping_planes.py @@ -26,6 +26,14 @@ sits along its unit normal: the plane is ``normal . p == position * |normal|``. Values are in data units (voxels for an image). +**The slider does not show the position.** The position is measured from +the data's origin, so it is negative whenever the normal has a negative +entry and changes sign when a plane is flipped. A control shows the +plane's depth into the data's bounding box instead (:func:`depth_of`): 0 +on the face the normal points away from, the box's size along the normal +on the opposite face. ``ClippingPlanesEditor.describe`` adds it to each +row and ``set_depth`` takes it. + Nothing here imports a toolkit. """ @@ -52,15 +60,118 @@ CLIPPING_PLANES_TITLE = "Clipping planes" """The control's name: its ``DEFAULT_TITLE`` on both toolkits.""" +OUTSIDE_TOOLTIP = "The plane is outside the bounding box." +"""What a position shown as outside the box says of itself.""" + Row = dict[str, Any] +def position_tooltip(units: str) -> str: + """What the position slider and its number mean, in *units* ("Data").""" + return ( + "How far the plane is into the bounding box, along its normal. " + f"0 is the face the normal points away from. {units} units." + ) + + def _length(normal: Sequence[float]) -> float: return math.sqrt(sum(float(v) * float(v) for v in normal)) +def outline_row(outline: Any = None) -> dict[str, Any]: + """A plane's outline as plain values: a row's ``"outline"`` entry. + + *outline* is a ``PlaneOutline``, a mapping of its fields, or ``None`` + for no outline. + """ + from cellier.visuals import PlaneOutline + + if outline is None: + outline = PlaneOutline() + elif not isinstance(outline, PlaneOutline): + outline = PlaneOutline(**dict(outline)) + return { + "enabled": bool(outline.enabled), + "color": [float(v) for v in outline.color], + "width": float(outline.width), + } + + +OUTLINE_TOOLTIP = ( + "Draw a line around the plane where it crosses the data, in the 3D view." +) +"""What a row's "Outline" box does: the words of both toolkits.""" + +OUTLINE_COLOR_TOOLTIP = "The outline's colour." + + +def outline_hex(outline: Any) -> str: + """An outline's colour as ``#rrggbb``, for a colour picker. + + *outline* is whatever :func:`outline_row` takes. The alpha is not in + it: a picker edits the colour and the outline keeps its alpha. + """ + red, green, blue = outline_row(outline)["color"][:3] + return "#{:02x}{:02x}{:02x}".format( + *(round(255.0 * min(max(v, 0.0), 1.0)) for v in (red, green, blue)) + ) + + +def outline_color(color: Any, alpha: float = 1.0) -> list[float]: + """A colour from a front end as RGBA in ``[0, 1]``. + + Parameters + ---------- + color : str or sequence of float + ``#rrggbb`` (what a colour picker gives), or three or four numbers + in ``[0, 1]``. + alpha : float + The alpha to keep when *color* has none. + + Raises + ------ + ValueError + If *color* is neither. + """ + if isinstance(color, str): + text = color.strip().lstrip("#") + if len(text) != 6: + raise ValueError(f"A colour is written #rrggbb; got {color!r}.") + try: + values = [int(text[i : i + 2], 16) / 255.0 for i in (0, 2, 4)] + except ValueError: + raise ValueError(f"A colour is written #rrggbb; got {color!r}.") from None + return [*values, float(alpha)] + values = [float(v) for v in color] + if len(values) == 3: + return [*values, float(alpha)] + if len(values) == 4: + return values + raise ValueError(f"A colour is three or four numbers; got {color!r}.") + + +def with_outline_field(outline: Any, **changes: Any) -> dict[str, Any]: + """A row's ``"outline"`` entry with some of its fields replaced. + + ``color=`` may be anything :func:`outline_color` takes; without an + alpha it keeps the outline's. + + Raises + ------ + ValueError + If the result is not an outline (a colour out of range). + """ + entry = outline_row(outline) + if "color" in changes: + changes["color"] = outline_color(changes["color"], entry["color"][3]) + return outline_row({**entry, **changes}) + + def rows_from_planes(planes: Iterable[Any]) -> list[Row]: - """The rows that show *planes* (a visual's ``clipping_planes``).""" + """The rows that show *planes* (a visual's ``clipping_planes``). + + A row carries its plane's outline, so an edit of the row keeps it. + """ rows = [] for item in planes: normal = [float(v) for v in item.plane.normal] @@ -70,6 +181,7 @@ def rows_from_planes(planes: Iterable[Any]) -> list[Row]: "enabled": bool(item.enabled), "normal": normal, "position": float(item.plane.offset) / _length(normal), + "outline": outline_row(item.outline), } ) return rows @@ -84,7 +196,7 @@ def planes_from_rows(rows: Iterable[Mapping[str, Any]], coordinate_system: UUID) If a row's normal is zero or not finite. """ from cellier.transform import Plane - from cellier.visuals import ClippingPlane + from cellier.visuals import ClippingPlane, PlaneOutline planes = [] for row in rows: @@ -99,6 +211,7 @@ def planes_from_rows(rows: Iterable[Mapping[str, Any]], coordinate_system: UUID) offset=float(row["position"]) * _length(normal), ), enabled=bool(row.get("enabled", True)), + outline=PlaneOutline(**outline_row(row.get("outline"))), ) ) return tuple(planes) @@ -107,7 +220,8 @@ def planes_from_rows(rows: Iterable[Mapping[str, Any]], coordinate_system: UUID) def normalized_rows(rows: Iterable[Mapping[str, Any]]) -> list[Row]: """Rows as plain Python values, so two lists compare by value. - A row with no ``id`` is given a new one. + A row with no ``id`` is given a new one, and one with no ``outline`` + has none. """ return [ { @@ -115,6 +229,7 @@ def normalized_rows(rows: Iterable[Mapping[str, Any]]) -> list[Row]: "enabled": bool(row.get("enabled", True)), "normal": [float(v) for v in row["normal"]], "position": float(row["position"]), + "outline": outline_row(row.get("outline")), } for row in rows ] @@ -155,6 +270,57 @@ def position_range( return low, high +def depth_of( + position: float, normal: Sequence[float], bounds: Sequence[Sequence[float]] +) -> tuple[float, float]: + """How far a plane is into the bounding box, and how deep the box is. + + The position a row holds is measured from the coordinate origin, so it + is negative whenever the normal has a negative entry and changes sign + when a plane is flipped. The depth is measured from the box instead, + and is what a control shows. + + Parameters + ---------- + position : float + Where the plane sits along its unit normal. + normal : Sequence[float] + One entry per axis. + bounds : Sequence[Sequence[float]] + ``(low, high)`` per axis. + + Returns + ------- + depth : float + The distance along the normal from where a plane first touches the + box (on the side the normal points away from) to the plane. Below + 0 or above *span* for a plane outside the box. + span : float + The depth at which a plane last touches the box: the size of the + box along the normal. Never 0. + """ + low, high = position_range(normal, bounds) + return float(position) - low, high - low + + +def position_at( + depth: float, normal: Sequence[float], bounds: Sequence[Sequence[float]] +) -> float: + """The position of the plane *depth* into the box (see :func:`depth_of`).""" + low, _high = position_range(normal, bounds) + return low + float(depth) + + +def is_outside(depth: float, span: float) -> bool: + """Whether a plane at *depth* misses a box *span* deep. + + A plane on a face of the box is not outside, whatever the rounding of + the sums that put it there. + """ + tolerance = 1e-9 * max(abs(span), 1.0) + return depth < -tolerance or depth > span + tolerance + + def facing_of(normal: Sequence[float]) -> list[int] | None: """The signed data axis a normal lies along. @@ -486,7 +652,15 @@ def set_rows(self, rows: Iterable[Mapping[str, Any]]) -> None: twice (marimo does) sends one update. """ try: - rows = normalized_rows(rows) + # A front end that sends a row back without its outline (it has + # no control for one) means the outline the plane has. + known = {row["id"]: row.get("outline") for row in self.rows} + rows = normalized_rows( + row + if row.get("outline") is not None + else {**row, "outline": known.get(str(row.get("id")))} + for row in rows + ) if rows == self.rows: return planes = planes_from_rows(rows, self._coordinate_system) @@ -525,6 +699,11 @@ def set_position(self, index: int, position: float) -> None: """Move plane *index* along its normal.""" self.set_rows(self._edited(index, position=float(position))) + def set_depth(self, index: int, depth: float) -> None: + """Put plane *index* *depth* into the data's box (:func:`depth_of`).""" + normal = self.rows[index]["normal"] + self.set_position(index, position_at(depth, normal, self.bounds)) + def flip(self, index: int) -> None: """Keep the other side of plane *index*; the plane does not move.""" row = self.rows[index] @@ -579,6 +758,26 @@ def set_component(self, index: int, axis: int, value: float) -> None: normal[axis] = float(value) self.set_normal(index, normal) + def set_outline_enabled(self, index: int, enabled: bool) -> None: + """Draw, or stop drawing, the outline of plane *index*'s cut face.""" + self._set_outline(index, enabled=bool(enabled)) + + def set_outline_color(self, index: int, color: Any) -> None: + """Set the colour of plane *index*'s outline. + + *color* is ``#rrggbb`` or three or four numbers in ``[0, 1]``; + without an alpha the outline keeps its own. + """ + self._set_outline(index, color=color) + + def _set_outline(self, index: int, **changes: Any) -> None: + try: + outline = with_outline_field(self.rows[index]["outline"], **changes) + except Exception as error: + self._show(list(self.rows), error_message(error)) + return + self.set_rows(self._edited(index, outline=outline)) + def set_gizmo(self, index: int, enabled: bool) -> None: """Put the canvas's gizmo on plane *index*, or take it off. @@ -648,19 +847,25 @@ def on_store_changed(self, event: DataStoreMetadataChangedEvent) -> None: def describe(self) -> list[Row]: """The rows with what a front end needs to draw each one. - Adds ``facing`` (:func:`facing_of`) and the slider's ``low`` / - ``high``. With a gizmo toggle, also ``gizmo`` (whether this plane - has the canvas's gizmo) and ``gizmo_blocked`` (why it cannot have - one, or ``""``); without, neither key. + Adds ``facing`` (:func:`facing_of`), ``outline_hex`` (the outline's + colour as a picker takes it) and what the position slider + shows (:func:`depth_of`): ``depth``, ``span`` (the slider runs from + 0 to it) and ``outside`` (whether the plane misses the data's box, + where ``depth`` is below 0 or above ``span``). With a gizmo + toggle, also ``gizmo`` (whether this plane has the canvas's gizmo) + and ``gizmo_blocked`` (why it cannot have one, or ``""``); without, + neither key. """ described = [] for row in self.rows: - low, high = position_range(row["normal"], self.bounds) + depth, span = depth_of(row["position"], row["normal"], self.bounds) entry = { **row, + "outline_hex": outline_hex(row["outline"]), "facing": facing_of(row["normal"]), - "low": min(low, row["position"]), - "high": max(high, row["position"]), + "depth": depth, + "span": span, + "outside": is_outside(depth, span), } if self._gizmo is not None: entry["gizmo"] = row["id"] == self.gizmo_plane diff --git a/src/cellier/gui/_render_planes.py b/src/cellier/gui/_render_planes.py index 08d52860..7da45257 100644 --- a/src/cellier/gui/_render_planes.py +++ b/src/cellier/gui/_render_planes.py @@ -26,6 +26,10 @@ ``normal`` and ``position`` are on the plane's own three world axes, named by ``axes``; ``position`` is where the plane sits along its unit normal. +The slider does not show it: as in the "Clipping planes" control, it shows +the plane's depth into the visual's bounding box +(:func:`cellier.gui._clipping_planes.depth_of`), which ``describe`` adds to +each row and ``set_depth`` takes. ``extent_0`` and ``extent_1`` are ``[min, max]`` distances from the plane's origin along its two in-plane axes, in world units; ``None`` is unbounded. @@ -61,7 +65,15 @@ SubscriptionSpec, TransformChangedEvent, ) -from cellier.gui._clipping_planes import facing_of, position_range +from cellier.gui._clipping_planes import ( + depth_of, + facing_of, + is_outside, + outline_hex, + outline_row, + position_at, + with_outline_field, +) from cellier.gui._loading import error_message from cellier.visuals._render_plane import MAX_RENDER_PLANES, RenderPlane @@ -325,6 +337,7 @@ def rows_from_planes( "position": float(np.asarray(plane.origin) @ normal), "extent_0": _extent_row(plane.extent_0), "extent_1": _extent_row(plane.extent_1), + "outline": outline_row(plane.outline), } ) return rows @@ -766,6 +779,12 @@ def set_position(self, index: int, position: float) -> None: """Move plane *index* along its normal.""" self._edit(index, lambda p: with_position(p, position)) + def set_depth(self, index: int, depth: float) -> None: + """Put plane *index* *depth* into the visual's box (``depth_of``).""" + normal = self.rows[index]["normal"] + bounds = self._plane_bounds(self.planes[index]) + self.set_position(index, position_at(depth, normal, bounds)) + def flip(self, index: int) -> None: """Reverse the normal of plane *index*; nothing drawn changes.""" self._edit(index, flipped) @@ -800,6 +819,27 @@ def set_side(self, index: int, axis: int, side: int, value: float) -> None: """Set one bounded extent side of plane *index*, in world units.""" self._edit(index, lambda p: with_side(p, axis, side, value)) + def set_outline_enabled(self, index: int, enabled: bool) -> None: + """Draw, or stop drawing, the outline of plane *index*.""" + self._set_outline(index, enabled=bool(enabled)) + + def set_outline_color(self, index: int, color: Any) -> None: + """Set the colour of plane *index*'s outline. + + *color* is ``#rrggbb`` or three or four numbers in ``[0, 1]``; + without an alpha the outline keeps its own. + """ + self._set_outline(index, color=color) + + def _set_outline(self, index: int, **changes: Any) -> None: + from cellier.visuals import PlaneOutline + + def restyled(plane: RenderPlane) -> RenderPlane: + outline = PlaneOutline(**with_outline_field(plane.outline, **changes)) + return plane.model_copy(update={"outline": outline}) + + self._edit(index, restyled) + def set_gizmo(self, index: int, enabled: bool) -> None: """Put the canvas's gizmo on plane *index*, or take it off. @@ -873,19 +913,24 @@ def describe(self) -> list[Row]: """The rows with what a front end needs to draw each one. Adds ``facing`` (the signed axis the normal lies along, or - ``None``) and the position slider's ``low`` / ``high``. With a - gizmo toggle, also ``gizmo`` (whether this plane has the canvas's - gizmo) and ``gizmo_blocked`` (why it cannot have one, or ``""``); - without, neither key. + ``None``) and what the position slider shows (``depth_of``): + ``depth``, ``span`` (the slider runs from 0 to it) and ``outside`` + (whether the plane misses the visual's box, where ``depth`` is + below 0 or above ``span``). With a gizmo toggle, also ``gizmo`` + (whether this plane has the canvas's gizmo) and ``gizmo_blocked`` + (why it cannot have one, or ``""``); without, neither key. """ described = [] for plane, row in zip(self.planes, self.rows, strict=True): - low, high = position_range(row["normal"], self._plane_bounds(plane)) + bounds = self._plane_bounds(plane) + depth, span = depth_of(row["position"], row["normal"], bounds) entry = { **row, + "outline_hex": outline_hex(row["outline"]), "facing": facing_of(row["normal"]), - "low": min(low, row["position"]), - "high": max(high, row["position"]), + "depth": depth, + "span": span, + "outside": is_outside(depth, span), } if self._gizmo is not None: entry["gizmo"] = row["id"] == self.gizmo_plane diff --git a/src/cellier/gui/anywidget/visuals/_clipping.py b/src/cellier/gui/anywidget/visuals/_clipping.py index 4b42d5ae..bf83e7f8 100644 --- a/src/cellier/gui/anywidget/visuals/_clipping.py +++ b/src/cellier/gui/anywidget/visuals/_clipping.py @@ -184,12 +184,17 @@ def _on_edit(self, change) -> None: editor.remove(index) elif action == "enabled": editor.set_enabled(index, bool(value)) - elif action == "position": - editor.set_position(index, float(value)) + elif action == "depth": + editor.set_depth(index, float(value)) elif action == "flip": editor.flip(index) elif action == "gizmo": editor.set_gizmo(index, bool(value)) + elif action == "outline": + editor.set_outline_enabled(index, bool(value)) + elif action == "outline_color": + # ``#rrggbb``, as a colour input gives it. + editor.set_outline_color(index, str(value)) elif action in ("facing", "component"): # ``[axis index, sign]`` or ``[axis index, entry]``. if not isinstance(value, (list, tuple)) or len(value) != 2: diff --git a/src/cellier/gui/anywidget/visuals/_render_planes.py b/src/cellier/gui/anywidget/visuals/_render_planes.py index 5c54ac00..0717a44a 100644 --- a/src/cellier/gui/anywidget/visuals/_render_planes.py +++ b/src/cellier/gui/anywidget/visuals/_render_planes.py @@ -196,12 +196,17 @@ def _on_edit(self, change) -> None: editor.remove(index) elif action == "enabled": editor.set_enabled(index, bool(value)) - elif action == "position": - editor.set_position(index, float(value)) + elif action == "depth": + editor.set_depth(index, float(value)) elif action == "flip": editor.flip(index) elif action == "gizmo": editor.set_gizmo(index, bool(value)) + elif action == "outline": + editor.set_outline_enabled(index, bool(value)) + elif action == "outline_color": + # ``#rrggbb``, as a colour input gives it. + editor.set_outline_color(index, str(value)) elif action in ("facing", "component"): # ``[axis index, sign]`` or ``[axis index, entry]``. pair = _pair(value, 2) diff --git a/src/cellier/gui/anywidget/visuals/static/clipping_planes.css b/src/cellier/gui/anywidget/visuals/static/clipping_planes.css index 482a985d..dfeb468d 100644 --- a/src/cellier/gui/anywidget/visuals/static/clipping_planes.css +++ b/src/cellier/gui/anywidget/visuals/static/clipping_planes.css @@ -111,6 +111,12 @@ font-variant-numeric: tabular-nums; } +/* The plane is outside the bounding box. */ +.cellier-clipping-planes-outside { + color: #d08020; + font-style: italic; +} + .cellier-clipping-planes-add { margin-top: 8px; } @@ -119,3 +125,16 @@ color: #d04040; margin-top: 4px; } + +/* The plane's outline: a box that draws it, and its colour. */ +.cellier-clipping-planes-outline { + display: flex; + align-items: center; + gap: 8px; +} + +.cellier-clipping-planes-outline input[type="color"] { + width: 2.4em; + height: 1.5em; + padding: 0; +} diff --git a/src/cellier/gui/anywidget/visuals/static/clipping_planes.js b/src/cellier/gui/anywidget/visuals/static/clipping_planes.js index 8cfb81ef..bd18e921 100644 --- a/src/cellier/gui/anywidget/visuals/static/clipping_planes.js +++ b/src/cellier/gui/anywidget/visuals/static/clipping_planes.js @@ -7,7 +7,17 @@ // destroyed while it is dragged. Rows are only added or removed when the // number of planes changes. +// The same words as ``position_tooltip`` and ``OUTSIDE_TOOLTIP`` in +// ``cellier/gui/_clipping_planes.py``. +const POSITION_TOOLTIP = + "How far the plane is into the bounding box, along its normal. " + + "0 is the face the normal points away from. Data units."; +const OUTSIDE_TOOLTIP = "The plane is outside the bounding box."; const GIZMO_TOOLTIP = "Drag a gizmo in the 3D view to move and tilt this plane."; +// The same words as ``OUTLINE_TOOLTIP`` and ``OUTLINE_COLOR_TOOLTIP`` there. +const OUTLINE_TOOLTIP = + "Draw a line around the plane where it crosses the data, in the 3D view."; +const OUTLINE_COLOR_TOOLTIP = "The outline's colour."; // A normal's entry as the number input shows it: at most three decimals. function shown(value) { @@ -160,13 +170,15 @@ function render({ model, el }) { position.type = "range"; position.step = "any"; position.className = "cellier-clipping-planes-position"; - position.title = "Where the plane sits along its normal. Data units."; + // The slider and its number are the depth into the bounding box: 0 on + // the face the normal points away from, row.span on the opposite one. + position.title = POSITION_TOOLTIP; position.dataset.role = "position"; let dragging = false; position.addEventListener("input", () => { dragging = true; value.textContent = Number(position.value).toFixed(2); - send("position", index, Number(position.value)); + send("depth", index, Number(position.value)); }); position.addEventListener("change", () => { dragging = false; }); @@ -175,10 +187,34 @@ function render({ model, el }) { value.dataset.role = "value"; along.append(positionLabel, position, value); - root.append(header, normal, along); + // -- outline: a box that draws it, and its colour + const outline = document.createElement("div"); + outline.className = "cellier-clipping-planes-outline"; + const outlineLabel = document.createElement("label"); + const outlineOn = document.createElement("input"); + outlineOn.type = "checkbox"; + outlineOn.title = OUTLINE_TOOLTIP; + outlineOn.dataset.role = "outline"; + outlineOn.addEventListener("change", () => send("outline", index, outlineOn.checked)); + outlineLabel.append(outlineOn, " Outline"); + const outlineColor = document.createElement("input"); + outlineColor.type = "color"; + outlineColor.title = OUTLINE_COLOR_TOOLTIP; + outlineColor.dataset.role = "outline-color"; + // `change`, not `input`: one edit when the picker closes, not one per + // colour the pointer passes over. + outlineColor.addEventListener("change", () => + send("outline_color", index, outlineColor.value)); + outline.append(outlineLabel, outlineColor); + + root.append(header, normal, along, outline); function apply(row) { enabled.checked = Boolean(row.enabled); + outlineOn.checked = Boolean(row.outline && row.outline.enabled); + if (row.outline_hex && outlineColor.value !== row.outline_hex) { + outlineColor.value = row.outline_hex; + } gizmo.style.display = row.gizmo === undefined ? "none" : ""; gizmoOn = Boolean(row.gizmo); gizmo.classList.toggle("cellier-clipping-planes-on", gizmoOn); @@ -198,12 +234,19 @@ function render({ model, el }) { component.value = shown(current[axis]); } }); - position.min = String(row.low); - position.max = String(row.high); + const span = Number(row.span); + const depth = Number(row.depth); + position.min = "0"; + position.max = String(span); // While the thumb is held the element already shows the newest value; // writing an older echo back would make it jump. - if (!dragging) position.value = String(row.position); - value.textContent = Number(row.position).toFixed(2); + // Outside the box the thumb is pinned at the nearer end and the + // number shows how far past it the plane is. + if (!dragging) position.value = String(Math.min(Math.max(depth, 0), span)); + value.textContent = depth.toFixed(2); + const outside = Boolean(row.outside); + value.classList.toggle("cellier-clipping-planes-outside", outside); + value.title = outside ? `${OUTSIDE_TOOLTIP} ${POSITION_TOOLTIP}` : POSITION_TOOLTIP; } return { root, apply }; } diff --git a/src/cellier/gui/anywidget/visuals/static/render_planes.css b/src/cellier/gui/anywidget/visuals/static/render_planes.css index 732d40b7..2a18a82e 100644 --- a/src/cellier/gui/anywidget/visuals/static/render_planes.css +++ b/src/cellier/gui/anywidget/visuals/static/render_planes.css @@ -111,6 +111,12 @@ font-variant-numeric: tabular-nums; } +/* The plane is outside the bounding box. */ +.cellier-render-planes-outside { + color: #d08020; + font-style: italic; +} + .cellier-render-planes-add { margin-top: 8px; } @@ -156,3 +162,16 @@ opacity: 0.5; pointer-events: none; } + +/* The plane's outline: a box that draws it, and its colour. */ +.cellier-render-planes-outline { + display: flex; + align-items: center; + gap: 8px; +} + +.cellier-render-planes-outline input[type="color"] { + width: 2.4em; + height: 1.5em; + padding: 0; +} diff --git a/src/cellier/gui/anywidget/visuals/static/render_planes.js b/src/cellier/gui/anywidget/visuals/static/render_planes.js index cde50f09..769bb951 100644 --- a/src/cellier/gui/anywidget/visuals/static/render_planes.js +++ b/src/cellier/gui/anywidget/visuals/static/render_planes.js @@ -8,8 +8,18 @@ // dragged. Rows are only added or removed when the number of planes // changes. A plane has three axes, named by the row's `axes`. +// The same words as ``position_tooltip`` and ``OUTSIDE_TOOLTIP`` in +// ``cellier/gui/_clipping_planes.py``. +const POSITION_TOOLTIP = + "How far the plane is into the bounding box, along its normal. " + + "0 is the face the normal points away from. World units."; +const OUTSIDE_TOOLTIP = "The plane is outside the bounding box."; const GIZMO_TOOLTIP = "Drag a gizmo in the 3D view to move, turn and resize this plane."; +// The same words as ``OUTLINE_TOOLTIP`` and ``OUTLINE_COLOR_TOOLTIP`` there. +const OUTLINE_TOOLTIP = + "Draw a line around the plane where it crosses the data, in the 3D view."; +const OUTLINE_COLOR_TOOLTIP = "The outline's colour."; const SIDES = ["min", "max"]; // A number as an input shows it: at most three decimals. @@ -171,13 +181,15 @@ function render({ model, el }) { position.type = "range"; position.step = "any"; position.className = "cellier-render-planes-position"; - position.title = "Where the plane sits along its normal. World units."; + // The slider and its number are the depth into the bounding box: 0 on + // the face the normal points away from, row.span on the opposite one. + position.title = POSITION_TOOLTIP; position.dataset.role = "position"; let dragging = false; position.addEventListener("input", () => { dragging = true; value.textContent = Number(position.value).toFixed(2); - send("position", index, Number(position.value)); + send("depth", index, Number(position.value)); }); position.addEventListener("change", () => { dragging = false; }); @@ -239,9 +251,33 @@ function render({ model, el }) { extents.push(line); } - root.append(header, normal, along, ...extents); + // -- outline: a box that draws it, and its colour + const outline = document.createElement("div"); + outline.className = "cellier-render-planes-outline"; + const outlineLabel = document.createElement("label"); + const outlineOn = document.createElement("input"); + outlineOn.type = "checkbox"; + outlineOn.title = OUTLINE_TOOLTIP; + outlineOn.dataset.role = "outline"; + outlineOn.addEventListener("change", () => send("outline", index, outlineOn.checked)); + outlineLabel.append(outlineOn, " Outline"); + const outlineColor = document.createElement("input"); + outlineColor.type = "color"; + outlineColor.title = OUTLINE_COLOR_TOOLTIP; + outlineColor.dataset.role = "outline-color"; + // `change`, not `input`: one edit when the picker closes, not one per + // colour the pointer passes over. + outlineColor.addEventListener("change", () => + send("outline_color", index, outlineColor.value)); + outline.append(outlineLabel, outlineColor); + + root.append(header, normal, along, ...extents, outline); function apply(row) { + outlineOn.checked = Boolean(row.outline && row.outline.enabled); + if (row.outline_hex && outlineColor.value !== row.outline_hex) { + outlineColor.value = row.outline_hex; + } enabled.checked = Boolean(row.enabled); gizmo.style.display = row.gizmo === undefined ? "none" : ""; gizmoOn = Boolean(row.gizmo); @@ -266,12 +302,19 @@ function render({ model, el }) { component.value = shown(current[axis]); } }); - position.min = String(row.low); - position.max = String(row.high); + const span = Number(row.span); + const depth = Number(row.depth); + position.min = "0"; + position.max = String(span); // While the thumb is held the element already shows the newest value; // writing an older echo back would make it jump. - if (!dragging) position.value = String(row.position); - value.textContent = Number(row.position).toFixed(2); + // Outside the box the thumb is pinned at the nearer end and the + // number shows how far past it the plane is. + if (!dragging) position.value = String(Math.min(Math.max(depth, 0), span)); + value.textContent = depth.toFixed(2); + const outside = Boolean(row.outside); + value.classList.toggle("cellier-render-planes-outside", outside); + value.title = outside ? `${OUTSIDE_TOOLTIP} ${POSITION_TOOLTIP}` : POSITION_TOOLTIP; for (const entry of sides) { const amount = row[`extent_${entry.axis}`][entry.side]; const unbounded = amount === null || amount === undefined; diff --git a/src/cellier/gui/qt/visuals/_clipping.py b/src/cellier/gui/qt/visuals/_clipping.py index b24f5dc7..4c46eba4 100644 --- a/src/cellier/gui/qt/visuals/_clipping.py +++ b/src/cellier/gui/qt/visuals/_clipping.py @@ -15,8 +15,11 @@ from cellier.gui._clipping_planes import ( CLIPPING_PLANES_TITLE, GIZMO_TOOLTIP, + OUTSIDE_TOOLTIP, ClippingPlanesEditor, + position_tooltip, ) +from cellier.gui.qt.visuals._plane_outline import OutlineInputs if TYPE_CHECKING: from collections.abc import Callable, Mapping, Sequence @@ -27,6 +30,9 @@ #: Steps of the position slider (Qt sliders are integers). _SLIDER_STEPS = 1000 +#: The colour of the position's number when the plane is outside the +#: bounding box (it is also set in italics). +_OUTSIDE_COLOR = "#d08020" class _PlaneRow: @@ -34,7 +40,8 @@ class _PlaneRow: The normal is laid out one column per data axis: two buttons named after the axis (``+z`` and ``-z`` for an axis ``z``) that face the plane - along it, and under them the normal's entry on it. + along it, and under them the normal's entry on it. Last, the plane's + outline: a box that draws it and its colour. """ def __init__(self, owner: QtClippingPlanesControls, index: int, parent) -> None: @@ -52,7 +59,8 @@ def __init__(self, owner: QtClippingPlanesControls, index: int, parent) -> None: ) self.index = index - self._range = (0.0, 1.0) + #: The slider's far end: the box's size along the normal. + self._span = 1.0 editor = owner._editor self._editor = editor self.widget = QWidget(parent) @@ -133,14 +141,21 @@ def __init__(self, owner: QtClippingPlanesControls, index: int, parent) -> None: self.slider = QSlider(Qt.Orientation.Horizontal, self.widget) self.slider.setRange(0, _SLIDER_STEPS) - self.slider.setToolTip("Where the plane sits along its normal. Data units.") + self._tooltip = position_tooltip("Data") + self.slider.setToolTip(self._tooltip) self.slider.valueChanged.connect(self._on_slider) + # The number is the depth into the box (see ``depth_of``). Its range + # is not the slider's: a gizmo can take a plane out of the box, and + # a spin box cannot show a value outside its range. self.position = QDoubleSpinBox(self.widget) self.position.setDecimals(2) self.position.setRange(-1e9, 1e9) self.position.setKeyboardTracking(False) - self.position.valueChanged.connect(lambda v: editor.set_position(self.index, v)) + self.position.setToolTip(self._tooltip) + self.position.valueChanged.connect(lambda v: editor.set_depth(self.index, v)) + #: Whether the number is drawn as a plane's outside the box. + self.outside = False grid.addWidget(QLabel("Position", self.widget), 3, 0) along = QHBoxLayout() @@ -148,6 +163,15 @@ def __init__(self, owner: QtClippingPlanesControls, index: int, parent) -> None: along.addWidget(self.position) grid.addLayout(along, 3, 1, 1, n_axes) + #: The outline's check box and colour button. + self.outline = OutlineInputs( + self.widget, + lambda on: editor.set_outline_enabled(self.index, on), + lambda color: editor.set_outline_color(self.index, color), + ) + grid.addWidget(self.outline.enabled, 4, 0) + grid.addLayout(self.outline.layout, 4, 1, 1, n_axes) + self._inputs = ( self.enabled, *self.facing.values(), @@ -157,8 +181,27 @@ def __init__(self, owner: QtClippingPlanesControls, index: int, parent) -> None: ) def _on_slider(self, step: int) -> None: - low, high = self._range - self._editor.set_position(self.index, low + (high - low) * step / _SLIDER_STEPS) + self._editor.set_depth(self.index, self._span * step / _SLIDER_STEPS) + + def _mark_outside(self, outside: bool) -> None: + """Draw the number as a plane's that misses the box, or as usual.""" + if outside == self.outside: + return + from qtpy.QtGui import QColor, QPalette + + self.outside = outside + # Through the palette and the font, not a style sheet: a style + # sheet would take the spin box out of the platform's own style. + palette = QPalette() + if outside: + palette.setColor(QPalette.ColorRole.Text, QColor(_OUTSIDE_COLOR)) + self.position.setPalette(palette) + font = self.position.font() + font.setItalic(outside) + self.position.setFont(font) + self.position.setToolTip( + f"{OUTSIDE_TOOLTIP} {self._tooltip}" if outside else self._tooltip + ) def _on_facing(self, axis: int, sign: int) -> None: # A click on the button already shown un-checks it and changes @@ -177,17 +220,16 @@ def show(self, row: Mapping[str, Any]) -> None: button.setChecked(key == facing) for component, value in zip(self.components, row["normal"]): component.setValue(float(value)) - low, high = float(row["low"]), float(row["high"]) - self._range = (low, high) - span = high - low - step = ( - 0 - if span <= 0 - else round((row["position"] - low) / span * _SLIDER_STEPS) - ) + depth, span = float(row["depth"]), float(row["span"]) + self._span = span + # Outside the box the slider is pinned at the nearer end and + # the number shows how far past it the plane is. + step = 0 if span <= 0 else round(depth / span * _SLIDER_STEPS) self.slider.setValue(int(min(max(step, 0), _SLIDER_STEPS))) self.position.setSingleStep(max(span / 200.0, 0.01)) - self.position.setValue(float(row["position"])) + self.position.setValue(depth) + self._mark_outside(bool(row["outside"])) + self.outline.show(row) blocked = str(row.get("gizmo_blocked", "")) self.gizmo.setChecked(bool(row.get("gizmo", False))) self.gizmo.setEnabled(not blocked) diff --git a/src/cellier/gui/qt/visuals/_plane_outline.py b/src/cellier/gui/qt/visuals/_plane_outline.py new file mode 100644 index 00000000..f1bb198f --- /dev/null +++ b/src/cellier/gui/qt/visuals/_plane_outline.py @@ -0,0 +1,81 @@ +"""The "Outline" line of a plane's row, shared by the two Qt plane controls. + +A check box that draws the plane's outline and a button, filled with the +outline's colour, that opens a colour dialog (plane outline design, +section 7). The width of the line is not here: it is set from code. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from cellier.gui._clipping_planes import OUTLINE_COLOR_TOOLTIP, OUTLINE_TOOLTIP + +if TYPE_CHECKING: + from collections.abc import Callable, Mapping + + +class OutlineInputs: + """The check box and the colour button of one plane's outline. + + Parameters + ---------- + parent : QWidget + The row's widget. + on_enabled : Callable[[bool], None] + Called when the box is ticked or cleared by the user. + on_color : Callable[[str], None] + Called with ``#rrggbb`` when the user chooses a colour. + """ + + def __init__( + self, + parent: Any, + on_enabled: Callable[[bool], None], + on_color: Callable[[str], None], + ) -> None: + from qtpy.QtWidgets import QCheckBox, QHBoxLayout, QPushButton + + self._parent = parent + self._on_color = on_color + #: The colour shown, ``#rrggbb``. + self.hex = "#ffffff" + + self.enabled = QCheckBox("Outline", parent) + self.enabled.setToolTip(OUTLINE_TOOLTIP) + self.enabled.toggled.connect(on_enabled) + + self.color = QPushButton(parent) + self.color.setFixedSize(36, 18) + self.color.setToolTip(OUTLINE_COLOR_TOOLTIP) + self.color.clicked.connect(self.pick) + + #: The colour button and the space after it, for the row's grid. + self.layout = QHBoxLayout() + self.layout.addWidget(self.color) + self.layout.addStretch(1) + + def pick(self) -> None: + """Open a colour dialog; a chosen colour is sent, a cancel is not.""" + from qtpy.QtGui import QColor + from qtpy.QtWidgets import QColorDialog + + chosen = QColorDialog.getColor( + QColor(self.hex), self._parent, "Choose the outline's colour" + ) + if chosen.isValid(): + self._on_color(chosen.name()) + + def show(self, row: Mapping[str, Any]) -> None: + """Show a described row's outline; emits nothing.""" + self.enabled.blockSignals(True) + try: + self.enabled.setChecked(bool(row["outline"]["enabled"])) + finally: + self.enabled.blockSignals(False) + self.hex = str(row["outline_hex"]) + # A style sheet here is on a button nothing else styles: it is the + # only way to fill one with a colour on every platform style. + self.color.setStyleSheet( + f"background-color: {self.hex}; border: 1px solid #808080;" + ) diff --git a/src/cellier/gui/qt/visuals/_render_planes.py b/src/cellier/gui/qt/visuals/_render_planes.py index 4b7f33b3..15601797 100644 --- a/src/cellier/gui/qt/visuals/_render_planes.py +++ b/src/cellier/gui/qt/visuals/_render_planes.py @@ -13,11 +13,13 @@ from psygnal import Signal from cellier.gui._appearance_fields import VisualIdGroup +from cellier.gui._clipping_planes import OUTSIDE_TOOLTIP, position_tooltip from cellier.gui._render_planes import ( RENDER_PLANE_GIZMO_TOOLTIP, RENDER_PLANES_TITLE, RenderPlanesEditor, ) +from cellier.gui.qt.visuals._plane_outline import OutlineInputs if TYPE_CHECKING: from collections.abc import Callable, Mapping, Sequence @@ -28,6 +30,9 @@ #: Steps of the position slider (Qt sliders are integers). _SLIDER_STEPS = 1000 +#: The colour of the position's number when the plane is outside the +#: bounding box (it is also set in italics). +_OUTSIDE_COLOR = "#d08020" #: The label of each extent side, by ``side`` (0 the minimum, 1 the maximum). _SIDE_NAMES = ("min", "max") @@ -38,7 +43,8 @@ class _PlaneRow: The normal is laid out one column per axis of the plane: two buttons named after the axis (``+z`` and ``-z`` for an axis ``z``) that face the plane along it, and under them the normal's entry on it. Under the - position, one line per in-plane axis with its two extent sides. + position, one line per in-plane axis with its two extent sides, and + last the plane's outline: a box that draws it and its colour. """ def __init__(self, owner: QtRenderPlanesControls, index: int, parent) -> None: @@ -56,7 +62,8 @@ def __init__(self, owner: QtRenderPlanesControls, index: int, parent) -> None: ) self.index = index - self._range = (0.0, 1.0) + #: The slider's far end: the box's size along the normal. + self._span = 1.0 editor = owner._editor self._editor = editor self.widget = QWidget(parent) @@ -133,14 +140,21 @@ def __init__(self, owner: QtRenderPlanesControls, index: int, parent) -> None: self.slider = QSlider(Qt.Orientation.Horizontal, self.widget) self.slider.setRange(0, _SLIDER_STEPS) - self.slider.setToolTip("Where the plane sits along its normal. World units.") + self._tooltip = position_tooltip("World") + self.slider.setToolTip(self._tooltip) self.slider.valueChanged.connect(self._on_slider) + # The number is the depth into the box (see ``depth_of``). Its range + # is not the slider's: a gizmo can take a plane out of the box, and + # a spin box cannot show a value outside its range. self.position = QDoubleSpinBox(self.widget) self.position.setDecimals(2) self.position.setRange(-1e9, 1e9) self.position.setKeyboardTracking(False) - self.position.valueChanged.connect(lambda v: editor.set_position(self.index, v)) + self.position.setToolTip(self._tooltip) + self.position.valueChanged.connect(lambda v: editor.set_depth(self.index, v)) + #: Whether the number is drawn as a plane's outside the box. + self.outside = False grid.addWidget(QLabel("Position", self.widget), 3, 0) along = QHBoxLayout() @@ -187,6 +201,15 @@ def __init__(self, owner: QtRenderPlanesControls, index: int, parent) -> None: self.unbounded[(axis, side)] = box grid.addLayout(line, 4 + axis, 1, 1, 3) + #: The outline's check box and colour button. + self.outline = OutlineInputs( + self.widget, + lambda on: editor.set_outline_enabled(self.index, on), + lambda color: editor.set_outline_color(self.index, color), + ) + grid.addWidget(self.outline.enabled, 6, 0) + grid.addLayout(self.outline.layout, 6, 1, 1, 3) + self._inputs = ( self.enabled, *self.facing.values(), @@ -198,8 +221,27 @@ def __init__(self, owner: QtRenderPlanesControls, index: int, parent) -> None: ) def _on_slider(self, step: int) -> None: - low, high = self._range - self._editor.set_position(self.index, low + (high - low) * step / _SLIDER_STEPS) + self._editor.set_depth(self.index, self._span * step / _SLIDER_STEPS) + + def _mark_outside(self, outside: bool) -> None: + """Draw the number as a plane's that misses the box, or as usual.""" + if outside == self.outside: + return + from qtpy.QtGui import QColor, QPalette + + self.outside = outside + # Through the palette and the font, not a style sheet: a style + # sheet would take the spin box out of the platform's own style. + palette = QPalette() + if outside: + palette.setColor(QPalette.ColorRole.Text, QColor(_OUTSIDE_COLOR)) + self.position.setPalette(palette) + font = self.position.font() + font.setItalic(outside) + self.position.setFont(font) + self.position.setToolTip( + f"{OUTSIDE_TOOLTIP} {self._tooltip}" if outside else self._tooltip + ) def _on_facing(self, axis: int, sign: int) -> None: # A click on the button already shown un-checks it and changes @@ -225,17 +267,15 @@ def show(self, row: Mapping[str, Any]) -> None: ): component.setToolTip(f"The normal's entry on {names[axis]}.") component.setValue(float(value)) - low, high = float(row["low"]), float(row["high"]) - self._range = (low, high) - span = high - low - step = ( - 0 - if span <= 0 - else round((row["position"] - low) / span * _SLIDER_STEPS) - ) + depth, span = float(row["depth"]), float(row["span"]) + self._span = span + # Outside the box the slider is pinned at the nearer end and + # the number shows how far past it the plane is. + step = 0 if span <= 0 else round(depth / span * _SLIDER_STEPS) self.slider.setValue(int(min(max(step, 0), _SLIDER_STEPS))) self.position.setSingleStep(max(span / 200.0, 0.01)) - self.position.setValue(float(row["position"])) + self.position.setValue(depth) + self._mark_outside(bool(row["outside"])) for (axis, side), value in self.sides.items(): amount = row[f"extent_{axis}"][side] self.unbounded[(axis, side)].setChecked(amount is None) @@ -243,6 +283,7 @@ def show(self, row: Mapping[str, Any]) -> None: value.setSingleStep(max(span / 200.0, 0.01)) if amount is not None: value.setValue(float(amount)) + self.outline.show(row) blocked = str(row.get("gizmo_blocked", "")) self.gizmo.setChecked(bool(row.get("gizmo", False))) self.gizmo.setEnabled(not blocked) diff --git a/src/cellier/render/_clipping.py b/src/cellier/render/_clipping.py index 282e2be3..18c48386 100644 --- a/src/cellier/render/_clipping.py +++ b/src/cellier/render/_clipping.py @@ -226,6 +226,19 @@ def drawn_materials(*nodes: Any) -> list[Any]: return found +def notify_planes_placed(visual: Any) -> None: + """Tell whoever draws *visual*'s plane outlines that they may be stale. + + Called by the render planes and clipping planes mixins whenever a + visual has reduced its planes again: new planes, a new outline, a new + view, a node placed at another slice. The scene manager sets + ``_planes_placed_listener`` on the visuals it holds. + """ + listener = getattr(visual, "_planes_placed_listener", None) + if listener is not None: + listener() + + class ClippingPlanesMixin: """The one way clipping planes reach a render visual's materials. @@ -281,6 +294,38 @@ def clip_frame(self) -> tuple[Any, Any, dict[int, float]] | None: return spaces, transform, dict(constants) return None + def outline_box(self) -> tuple[np.ndarray, np.ndarray] | None: + """The visual's data box in level-0 data coordinates, if it has one. + + ``(low, high)`` over the retained data axes, as + :func:`~cellier.render._plane_outline.box_frame` takes them. The + volume visuals give the voxel-edge box their shaders cut with. + """ + return None + + def outline_frame(self) -> tuple[np.ndarray, np.ndarray] | None: + """The box this visual's plane outlines are cut by, in rendered space. + + Plane outline design 5.1 and 5.3. + + Returns + ------- + tuple[np.ndarray, np.ndarray] or None + ``(rows, corners)``: six half-space rows and eight corners, in + pygfx ``(x, y, z)``. ``None`` when the visual is not placed in + a 3D view or its box is not known. + """ + from cellier.render._plane_outline import box_frame + + frame = self.clip_frame() + box = self.outline_box() + if frame is None or box is None: + return None + spaces, transform, constants = frame + if len(spaces.retained_axes) != 3: + return None + return box_frame(spaces, transform, constants, *box) + def _clip_targets(self) -> Iterable[tuple[Iterable[Any], Mapping[int, float]]]: """Yield ``(materials, constants)`` groups. @@ -314,6 +359,9 @@ def _apply_clipping_planes(self) -> None: if not reduced and not material.clipping_planes: continue material.clipping_planes = reduced + # A render plane's outline is cut by these planes (plane outline + # design 3.1), at the slice they were just reduced for. + notify_planes_placed(self) class GeometryClippingMixin(ClippingPlanesMixin): @@ -331,6 +379,32 @@ class GeometryClippingMixin(ClippingPlanesMixin): #: Whether the last request was clipped in the read. _clip_on_cpu: bool = False + def outline_frame(self) -> tuple[np.ndarray, np.ndarray] | None: + """The box of the geometry drawn, in rendered space. + + A geometry visual has no shape: its box is the bounding box of the + vertices it draws, in its node's frame, as its bounding-box + wireframe is. ``None`` in a 2D view, before there is geometry, and + while the read clips: geometry flattened along an axis a plane + acts on has no one cut face. + """ + from cellier.render._plane_outline import node_box_frame + + if self._clip_on_cpu: + return None + frame = self.clip_frame() + if frame is None or len(frame[0].retained_axes) != 3: + return None + found = self._outline_node_box() + if found is None: + return None + node, low, high = found + return node_box_frame(np.asarray(node.world.matrix), low, high) + + def _outline_node_box(self) -> tuple[Any, Any, Any] | None: + """``(node, low, high)``: the drawn geometry's box in a node's frame.""" + raise NotImplementedError + def _wants_cpu_clip(self) -> bool: """Whether an enabled plane has a component on a collapsed axis.""" spaces = getattr(self, "_spaces", None) diff --git a/src/cellier/render/_plane_outline.py b/src/cellier/render/_plane_outline.py new file mode 100644 index 00000000..a51d1248 --- /dev/null +++ b/src/cellier/render/_plane_outline.py @@ -0,0 +1,427 @@ +"""The polygon a plane makes in a visual (plane outline design, section 5). + +A plane's outline is the edge of a convex polygon: the plane, cut by the +visual's data box and by its clipping planes, and for a render plane by +the plane's own extents. This module computes that polygon. It knows +nothing of pygfx objects or of the GPU. + +Everything is in the scene's rendered space, in pygfx ``(x, y, z)`` order +and float64. The box and the clipping planes are **half-space rows** +``(a, b, c, d)``, kept where ``a*x + b*y + c*z >= d``: the form +:func:`~cellier.render._clipping.reduce_clipping_planes` returns. The box +is written as six data-space planes and reduced by that same function +(:func:`box_frame`), so it follows whatever transform a clipping plane +follows. + +The box is closed: a plane lying in one of its faces has that face as its +polygon. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import TYPE_CHECKING + +import numpy as np + +from cellier.render._clipping import reduce_clipping_planes +from cellier.render._spaces import affine_for_node + +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + + from cellier.render._spaces import RenderSpaces + from cellier.transform import BaseTransform + +#: Lengths under this fraction of the box's diagonal are zero: a vertex +#: that close to a cutting plane is on it, and two vertices that close +#: together are one. +TOLERANCE = 1.0e-6 + +#: How far outside the box an unbounded side of a plane is put, in box +#: diagonals from the box's centre. +_REACH = 2.0 + +_EMPTY = np.zeros((0, 3), dtype=np.float64) + + +def voxel_edge_box( + shape: Sequence[int], retained_axes: Sequence[int] +) -> tuple[np.ndarray, np.ndarray]: + """The box a volume shader cuts a plane with, in level-0 data coordinates. + + The edges of the voxels, not their centres: ``-0.5`` to ``n - 0.5`` on + each axis, whatever level is drawn (design 5.3, measured). + + Parameters + ---------- + shape : Sequence[int] + The store's level-0 shape, one entry per data axis. + retained_axes : Sequence[int] + The three data axes the view keeps. + + Returns + ------- + low, high : np.ndarray + One entry per retained axis, in ascending axis order: what + :func:`box_frame` takes. + """ + sizes = np.array([float(shape[axis]) for axis in sorted(retained_axes)]) + return np.full(len(sizes), -0.5), sizes - 0.5 + + +def polygons_equal(first: np.ndarray, second: np.ndarray) -> bool: + """Whether two polygons are the same vertices in the same order. + + Two visuals with the same box and the same planes go through the same + steps and give the same order, so no rotation of the cycle is tried + (design 5.4). The tolerance is :data:`TOLERANCE` of the polygon's + extent. + """ + if first.shape != second.shape: + return False + if len(first) == 0: + return True + extent = float(np.linalg.norm(first.max(axis=0) - first.min(axis=0))) + return bool(np.abs(first - second).max() <= TOLERANCE * extent) + + +def box_frame( + spaces: RenderSpaces, + data_to_world: BaseTransform, + constants: Mapping[int, float], + low: Sequence[float], + high: Sequence[float], +) -> tuple[np.ndarray, np.ndarray]: + """A data-space box as half-space rows and corners in rendered space. + + Takes the same ``constants`` as + :func:`~cellier.render._clipping.reduce_clipping_planes` and must be + given the same values, so the box and the clipping planes are cut by + the same slice. + + Parameters + ---------- + spaces : RenderSpaces + The systems the visual is placed with, for a 3D view. + data_to_world : BaseTransform + The visual's own transform. + constants : Mapping[int, float] + ``{collapsed data axis: data position}``. + low, high : Sequence[float] + The box's corners in level-0 data coordinates, one entry per + retained data axis, in ascending axis order. + + Returns + ------- + rows : np.ndarray + ``(6, 4)``: the faces, kept where ``dot(row[:3], p) >= row[3]``. + corners : np.ndarray + ``(8, 3)``: the corners. + + Raises + ------ + ValueError + If the view does not keep three data axes. + """ + rendered = affine_for_node(data_to_world, constants).then( + spaces.world_to_rendered, spaces.world, spaces.rendered + ) + ndim = rendered.linear.shape[1] + retained = [axis for axis in range(ndim) if axis not in constants] + if len(retained) != 3: + raise ValueError( + "A plane's outline is computed for a 3D view only; this view " + f"keeps {len(retained)} data axes." + ) + low = np.asarray(low, dtype=np.float64) + high = np.asarray(high, dtype=np.float64) + + faces = [] + for k, axis in enumerate(retained): + for sign, bound in ((1.0, low[k]), (-1.0, high[k])): + normal = np.zeros(ndim, dtype=np.float64) + normal[axis] = sign + faces.append( + SimpleNamespace( + enabled=True, + plane=SimpleNamespace(normal=normal, offset=sign * float(bound)), + ) + ) + rows = np.asarray( + reduce_clipping_planes(spaces, data_to_world, constants, faces), + dtype=np.float64, + ) + + linear = np.asarray(rendered.linear, dtype=np.float64)[:, retained] + tau = np.asarray(rendered.translation, dtype=np.float64) + corners = np.empty((8, 3), dtype=np.float64) + for index in range(8): + pick = np.array([(index >> k) & 1 for k in range(3)], dtype=bool) + # Rendered order is pygfx order reversed. + corners[index] = (linear @ np.where(pick, high, low) + tau)[::-1] + return rows, corners + + +def node_box_frame( + matrix: np.ndarray, low: Sequence[float], high: Sequence[float] +) -> tuple[np.ndarray, np.ndarray] | None: + """A box in a node's own frame as half-space rows and corners. + + For a visual whose box is known where its geometry is: the bounding + box of the vertices a node draws, in that node's coordinates. + + Parameters + ---------- + matrix : np.ndarray + The node's 4 x 4 world matrix: its frame to rendered space. + low, high : Sequence[float] + The box's corners in the node's ``(x, y, z)``. + + Returns + ------- + tuple[np.ndarray, np.ndarray] or None + ``(rows, corners)`` as :func:`box_frame` returns them; ``None`` + when the matrix cannot be inverted (a node scaled to nothing). + """ + matrix = np.asarray(matrix, dtype=np.float64) + linear, tau = matrix[:3, :3], matrix[:3, 3] + try: + inverse_transpose = np.linalg.inv(linear).T + except np.linalg.LinAlgError: + return None + low = np.asarray(low, dtype=np.float64) + high = np.asarray(high, dtype=np.float64) + rows = np.empty((6, 4), dtype=np.float64) + for axis in range(3): + for k, (sign, bound) in enumerate(((1.0, low[axis]), (-1.0, high[axis]))): + # The plane sign * p[axis] >= sign * bound, carried through + # r = L p + tau: n' = L^-T n, d' = d + n' . tau. + normal = sign * inverse_transpose[:, axis] + rows[2 * axis + k, :3] = normal + rows[2 * axis + k, 3] = sign * bound + float(normal @ tau) + corners = np.empty((8, 3), dtype=np.float64) + for index in range(8): + pick = np.array([(index >> k) & 1 for k in range(3)], dtype=bool) + corners[index] = linear @ np.where(pick, high, low) + tau + return rows, corners + + +def box_diagonal(corners: np.ndarray) -> float: + """The length the tolerance is a fraction of: the box's extent.""" + corners = np.asarray(corners, dtype=np.float64) + return float(np.linalg.norm(corners.max(axis=0) - corners.min(axis=0))) + + +def clip_polygon(vertices: np.ndarray, rows: np.ndarray, scale: float) -> np.ndarray: + """Cut a convex, planar polygon by half-spaces. + + Sutherland-Hodgman, one half-space at a time; each cut adds at most + one vertex. A vertex within the tolerance of a cutting plane is on + it, which is what makes the box closed. + + Parameters + ---------- + vertices : np.ndarray + ``(n, 3)``, in order around the polygon. + rows : np.ndarray + ``(m, 4)``: kept where ``dot(row[:3], p) >= row[3]``. A row with + a zero normal keeps everything when ``row[3] <= 0`` and nothing + otherwise (a clipping plane parallel to the slice; a disabled + one). + scale : float + The box's diagonal (:func:`box_diagonal`). + + Returns + ------- + np.ndarray + ``(k, 3)`` float64, in order around the polygon; ``k`` is 0 when + nothing is left, and never 1 or 2. + """ + polygon = np.asarray(vertices, dtype=np.float64).reshape(-1, 3) + tolerance = TOLERANCE * float(scale) + for row in np.asarray(rows, dtype=np.float64).reshape(-1, 4): + if len(polygon) == 0: + return _EMPTY + normal, offset = row[:3], row[3] + length = float(np.linalg.norm(normal)) + if length == 0.0: + if offset > 0.0: + return _EMPTY + continue + distance = (polygon @ normal - offset) / length + distance[np.abs(distance) <= tolerance] = 0.0 + kept = [] + count = len(polygon) + for i in range(count): + j = (i + 1) % count + here, there = distance[i], distance[j] + if here >= 0.0: + kept.append(polygon[i]) + if (here > 0.0 and there < 0.0) or (here < 0.0 and there > 0.0): + kept.append( + polygon[i] + (polygon[j] - polygon[i]) * (here / (here - there)) + ) + polygon = np.asarray(kept, dtype=np.float64).reshape(-1, 3) + return _tidy(polygon, tolerance, float(scale)) + + +def _tidy(polygon: np.ndarray, tolerance: float, scale: float) -> np.ndarray: + """Drop repeated vertices; nothing when no area is left.""" + if len(polygon) < 3: + return _EMPTY + keep = [0] + for i in range(1, len(polygon)): + if np.linalg.norm(polygon[i] - polygon[keep[-1]]) > tolerance: + keep.append(i) + if len(keep) > 1 and np.linalg.norm(polygon[keep[-1]] - polygon[keep[0]]) <= ( + tolerance + ): + keep.pop() + polygon = polygon[keep] + if len(polygon) < 3: + return _EMPTY + fan = np.cross(polygon[1:-1] - polygon[0], polygon[2:] - polygon[0]) + area = 0.5 * float(np.linalg.norm(fan.sum(axis=0))) + if area <= tolerance * scale: + return _EMPTY + return polygon + + +def _quad( + origin: np.ndarray, + axis_0: np.ndarray, + axis_1: np.ndarray, + extent: Sequence[float], + bounded: Sequence[bool], + corners: np.ndarray, +) -> np.ndarray: + """A plane's rectangle, each unbounded side put outside the box.""" + centre = corners.mean(axis=0) + reach = _REACH * box_diagonal(corners) + c0 = float((centre - origin) @ axis_0) + c1 = float((centre - origin) @ axis_1) + min_0 = float(extent[0]) if bounded[0] else c0 - reach + max_0 = float(extent[1]) if bounded[1] else c0 + reach + min_1 = float(extent[2]) if bounded[2] else c1 - reach + max_1 = float(extent[3]) if bounded[3] else c1 + reach + return np.asarray( + [ + origin + min_0 * axis_0 + min_1 * axis_1, + origin + max_0 * axis_0 + min_1 * axis_1, + origin + max_0 * axis_0 + max_1 * axis_1, + origin + min_0 * axis_0 + max_1 * axis_1, + ], + dtype=np.float64, + ) + + +def render_plane_polygon( + origin: Sequence[float], + axis_0: Sequence[float], + axis_1: Sequence[float], + extent: Sequence[float], + bounded: Sequence[bool], + box_rows: np.ndarray, + corners: np.ndarray, + clipping_rows: np.ndarray | Sequence = (), +) -> np.ndarray: + """What a visual draws of one render plane, as a polygon (design 3.1). + + The plane's rectangle, cut by the data box and by the clipping planes. + + Parameters + ---------- + origin, axis_0, axis_1 : Sequence[float] + The plane's frame in rendered ``(x, y, z)``: a point on it and two + orthogonal unit vectors in it (one row of a ``ReducedPlanes``). + extent : Sequence[float] + ``(min_0, max_0, min_1, max_1)`` along the two axes, from the + origin. A side that is not *bounded* is not read. + bounded : Sequence[bool] + Which of the four sides are finite. + box_rows, corners : np.ndarray + The visual's data box, from :func:`box_frame`. + clipping_rows : array-like + ``(n, 4)``: the visual's clipping planes, as + :func:`~cellier.render._clipping.reduce_clipping_planes` returns + them (a disabled plane keeps everything). + + Returns + ------- + np.ndarray + ``(k, 3)`` float64 vertices in order around the polygon; ``k`` is + 0 when the plane draws nothing, otherwise from 3 to + ``10 + n``. + """ + corners = np.asarray(corners, dtype=np.float64) + quad = _quad( + np.asarray(origin, dtype=np.float64), + np.asarray(axis_0, dtype=np.float64), + np.asarray(axis_1, dtype=np.float64), + extent, + bounded, + corners, + ) + rows = np.vstack( + [ + np.asarray(box_rows, dtype=np.float64).reshape(-1, 4), + np.asarray(clipping_rows, dtype=np.float64).reshape(-1, 4), + ] + ) + return clip_polygon(quad, rows, box_diagonal(corners)) + + +def clipping_plane_polygon( + index: int, + clipping_rows: np.ndarray | Sequence, + box_rows: np.ndarray, + corners: np.ndarray, +) -> np.ndarray: + """The cut face of one clipping plane, as a polygon (design 3.2). + + The plane itself, cut by the data box and by the visual's other + clipping planes. + + Parameters + ---------- + index : int + Which row of *clipping_rows* the plane is. + clipping_rows : array-like + ``(n, 4)``: all of the visual's clipping planes, as + :func:`~cellier.render._clipping.reduce_clipping_planes` returns + them. + box_rows, corners : np.ndarray + The visual's data box, from :func:`box_frame`. + + Returns + ------- + np.ndarray + ``(k, 3)`` float64 vertices in order around the polygon; ``k`` is + 0 when the plane does not cross what is left of the box, or has + no normal in this view (a disabled plane, or one parallel to the + slice). + """ + clipping_rows = np.asarray(clipping_rows, dtype=np.float64).reshape(-1, 4) + corners = np.asarray(corners, dtype=np.float64) + normal, offset = clipping_rows[index, :3], clipping_rows[index, 3] + length = float(np.linalg.norm(normal)) + if length == 0.0: + return _EMPTY + unit = normal / length + # The point of the plane nearest the box's centre, and a frame there. + centre = corners.mean(axis=0) + origin = centre - (float(centre @ unit) - offset / length) * unit + seed = np.zeros(3, dtype=np.float64) + seed[int(np.argmin(np.abs(unit)))] = 1.0 + axis_0 = seed - float(seed @ unit) * unit + axis_0 /= np.linalg.norm(axis_0) + axis_1 = np.cross(unit, axis_0) + quad = _quad(origin, axis_0, axis_1, (0.0,) * 4, (False,) * 4, corners) + rows = np.vstack( + [ + np.asarray(box_rows, dtype=np.float64).reshape(-1, 4), + np.delete(clipping_rows, index, axis=0), + ] + ) + return clip_polygon(quad, rows, box_diagonal(corners)) diff --git a/src/cellier/render/_render_planes.py b/src/cellier/render/_render_planes.py index 41503807..2482c6d5 100644 --- a/src/cellier/render/_render_planes.py +++ b/src/cellier/render/_render_planes.py @@ -23,6 +23,7 @@ import pygfx as gfx from pygfx.utils import array_from_shadertype +from cellier.render._clipping import notify_planes_placed from cellier.visuals._render_plane import MAX_RENDER_PLANES if TYPE_CHECKING: @@ -335,3 +336,33 @@ def _apply_render_planes(self) -> None: """Reduce the stored planes for the view and upload them.""" self._reduced_render_planes = None write_render_planes(self.render_planes_buffer, self.drawn_render_planes) + notify_planes_placed(self) + + def outline_box(self) -> tuple[np.ndarray, np.ndarray] | None: + """The box this visual's planes are cut by, in data coordinates. + + The voxel-edge box of the store's level 0 on the three data axes a + 3D view keeps: the box the volume shaders cut a ray with (plane + outline design 5.3). + + Returns + ------- + tuple[np.ndarray, np.ndarray] or None + ``(low, high)`` as :func:`~cellier.render._plane_outline.box_frame` + takes them; ``None`` when the visual is not placed in a 3D view + or its store's shape is not known. + """ + from cellier.render._plane_outline import voxel_edge_box + + spaces = getattr(self, "_spaces", None) + shape = self._outline_shape() + if spaces is None or shape is None: + return None + retained = tuple(spaces.retained_axes) + if len(retained) != 3 or max(retained) >= len(shape): + return None + return voxel_edge_box(shape, retained) + + def _outline_shape(self) -> Sequence[int] | None: + """The store's level-0 shape, one entry per data axis, if known.""" + raise NotImplementedError diff --git a/src/cellier/render/render_manager.py b/src/cellier/render/render_manager.py index 1f031546..cee01d87 100644 --- a/src/cellier/render/render_manager.py +++ b/src/cellier/render/render_manager.py @@ -3,6 +3,7 @@ from __future__ import annotations import warnings +from functools import partial from typing import TYPE_CHECKING, Any, Callable, NamedTuple from uuid import uuid4 @@ -11,6 +12,7 @@ from cellier.events._events import AABBChangedEvent, ViewRay, _CanvasRawPointerEvent from cellier.render._clipping import expand_rendered_plane, reduce_clipping_planes from cellier.render._config import RenderManagerConfig +from cellier.render._gpu_lifetime import weak_callback from cellier.render._render_planes import expand_rendered_frame, rendered_plane_frame from cellier.render._scene_config import VisualRenderConfig from cellier.render._visual_lut import ( @@ -31,7 +33,7 @@ from cellier.slicer import AsyncSlicer if TYPE_CHECKING: - from collections.abc import Mapping + from collections.abc import Collection, Mapping from uuid import UUID import pygfx as gfx @@ -948,6 +950,10 @@ def add_scene( scene_id=scene_id, lighting=lighting, background=background ) self._scenes[scene_id] = scene_manager + # Held weakly: the scene manager must not keep this manager alive. + scene_manager._outlines_changed_listener = partial( + weak_callback(self._on_plane_outlines_changed), scene_id + ) return scene_manager def scene_has_lighting(self, scene_id: UUID) -> bool: @@ -1834,6 +1840,47 @@ def set_visual_render_planes(self, visual_id: UUID, planes: Any) -> None: if setter is not None: setter(planes) + def set_plane_outline_kinds( + self, scene_id: UUID, kinds: Mapping[UUID, Collection[str]] + ) -> bool: + """Say which planes each visual of a scene shows outlines of. + + The outlines are computed again and drawn (plane outline design + 6.4). Between calls the scene keeps them up to date itself as its + visuals' planes and placement change. + + Parameters + ---------- + scene_id : UUID + The scene. An unknown scene is ignored. + kinds : Mapping[UUID, Collection[str]] + ``{visual id: kinds}``; ``"render"`` shows the outlines of the + visual's render planes and ``"clipping"`` those of its clipping + planes. A visual left out shows none. + + Returns + ------- + bool + Whether anything drawn changed. + """ + scene_manager = self._scenes.get(scene_id) + if scene_manager is None: + return False + changed = scene_manager.set_plane_outline_kinds(kinds) + if changed: + self._on_plane_outlines_changed(scene_id) + return changed + + def _on_plane_outlines_changed(self, scene_id: UUID) -> None: + """The outlines of a scene changed: draw it, from a clean history. + + Accumulated frames would otherwise blend the old lines with the + new, as for a moved plane. + """ + for canvas_view in self._find_canvases_for_scene(scene_id): + canvas_view.invalidate_accumulation() + canvas_view.request_draw() + def clipping_planes_affect_request(self, visual_id: UUID) -> bool: """Whether a change of planes changes what *visual_id* reads.""" scene_id = self._visual_to_scene.get(visual_id) diff --git a/src/cellier/render/scene_manager.py b/src/cellier/render/scene_manager.py index c0e9530c..4db217b8 100644 --- a/src/cellier/render/scene_manager.py +++ b/src/cellier/render/scene_manager.py @@ -7,9 +7,14 @@ import numpy as np import pygfx as gfx +from cellier.render._gpu_lifetime import weak_callback from cellier.render._scene_config import VisualRenderConfig from cellier.render._spaces import data_slice_positions, visual_covers_position from cellier.render.scheduling import is_chunked_visual +from cellier.render.visuals._plane_outline import ( + GFXPlaneOutlines, + visual_plane_outlines, +) from cellier.scene._background import BackgroundAppearance from cellier.transform import ( NonAffineTransformError, @@ -17,7 +22,7 @@ ) if TYPE_CHECKING: - from collections.abc import Sequence + from collections.abc import Callable, Collection, Mapping, Sequence from uuid import UUID from cellier.data.image import ChunkRequest @@ -82,6 +87,14 @@ def __init__( # same gfx.Scene as the visuals but are not visuals: they are never # sliced and never picked. self._overlays: dict[UUID, GFXSceneOverlay] = {} + # The outlines of the visuals' planes (plane outline design 6.1): + # lines in the scene that belong to no one visual, because a plane + # shared by two visuals has one outline. ``_outline_kinds`` is + # which planes each visual shows now, as the controller last said. + self._plane_outlines = GFXPlaneOutlines() + self._scene.add(self._plane_outlines.node) + self._outline_kinds: dict[UUID, frozenset[str]] = {} + self._outlines_changed_listener: Callable[[], None] | None = None # Per-visual store extents, for the out-of-domain check in # build_slice_requests. Absent means "not known", which never skips. self._axis_extents: dict[UUID, Sequence[tuple[float, float]] | None] = {} @@ -174,6 +187,8 @@ def add_visual( self._scene.add(node) self._visuals[visual.visual_model_id] = visual self._active_nodes[visual.visual_model_id] = node + # Told whenever the visual reduces its planes again. + visual._planes_placed_listener = weak_callback(self._on_planes_placed) def set_axis_extents( self, @@ -259,6 +274,54 @@ def remove_overlay(self, overlay_id: UUID) -> None: if overlay is not None: self._scene.remove(overlay.node) + # ── Plane outlines ────────────────────────────────────────────────── + + @property + def plane_outlines(self) -> GFXPlaneOutlines: + """The outlines of the visuals' planes drawn in this scene.""" + return self._plane_outlines + + def set_plane_outline_kinds(self, kinds: Mapping[UUID, Collection[str]]) -> bool: + """Say which planes each visual shows outlines of, and redraw them. + + Parameters + ---------- + kinds : Mapping[UUID, Collection[str]] + ``{visual id: kinds}``, a kind being ``"render"`` for the + visual's render planes or ``"clipping"`` for its clipping + planes. A visual left out shows none: it is hidden, or not + drawn in a way that shows its planes. + + Returns + ------- + bool + Whether anything drawn changed. + """ + self._outline_kinds = { + visual_id: frozenset(shown) for visual_id, shown in kinds.items() if shown + } + return self._refresh_plane_outlines(notify=False) + + def _on_planes_placed(self) -> None: + """A visual reduced its planes again: its outlines may have moved.""" + self._refresh_plane_outlines() + + def _refresh_plane_outlines(self, *, notify: bool = True) -> bool: + """Compute every outline of the scene again (design 6.4). + + The visuals are asked in the order they were added, which is what + decides whose style a shared plane's outline takes. + """ + polygons = [] + for visual_id, visual in self._visuals.items(): + kinds = self._outline_kinds.get(visual_id) + if kinds: + polygons.extend(visual_plane_outlines(visual, kinds)) + changed = self._plane_outlines.update(polygons) + if changed and notify and self._outlines_changed_listener is not None: + self._outlines_changed_listener() + return changed + def get_overlay(self, overlay_id: UUID) -> GFXSceneOverlay | None: """Return the render-layer scene overlay for *overlay_id*, if any.""" return self._overlays.get(overlay_id) @@ -282,6 +345,9 @@ def remove_visual(self, visual_id: UUID) -> None: if active_node is not None: self._scene.remove(active_node) visual = self._visuals.pop(visual_id) + visual._planes_placed_listener = None + self._outline_kinds.pop(visual_id, None) + self._refresh_plane_outlines() # Explicit release for visuals that hold slots, caches or model # connections (unified image design 3.8), rather than trusting GC. close = getattr(visual, "close", None) @@ -296,10 +362,14 @@ def close(self) -> None: visuals are released here rather than left for when the manager dies. Safe to call more than once. """ + # Nobody is told of the outlines going: the scene is going too. + self._outlines_changed_listener = None for visual_id in list(self._visuals): self.remove_visual(visual_id) for overlay_id in list(self._overlays): self.remove_overlay(overlay_id) + self._outline_kinds.clear() + self._plane_outlines.clear() def get_visual_id_for_node(self, node: gfx.WorldObject) -> UUID | None: """Return the visual_id whose active scene-graph node is *node*. diff --git a/src/cellier/render/shaders/_plane_outline.py b/src/cellier/render/shaders/_plane_outline.py new file mode 100644 index 00000000..f0da041c --- /dev/null +++ b/src/cellier/render/shaders/_plane_outline.py @@ -0,0 +1,118 @@ +"""The line material of a plane outline (plane outline design 6.1, 6.2). + +A plane's outline lies in the plane it is drawn around, and a plane drawn +by a volume shader writes depth there. Two things keep the plane from +hiding its own outline, and both were measured (design 13.2): + +- **A late render queue.** The volume materials are in pygfx's + transparent queue, and pygfx sorts by queue before ``render_order``, so a + line in a line's usual queue is drawn first and painted over. The + outline is drawn after the visuals and writes no depth. +- **A depth offset sized in pixels.** pygfx gives the whole width of a + line the depth of its centre, while a tilted plane's depth changes across + that width; the half of the line inside the polygon would be behind the + plane. The line's depth is therefore moved toward the camera, in view + space, by the world size of :data:`OUTLINE_DEPTH_OFFSET_PX` pixels at its + own depth. Only the depth changes: the line stays where it is on the + screen. A mesh closer than that in front of the line does not hide it. + +Coupling: the substitution is anchored to one line of pygfx's ``line.wgsl``. +The anchor is asserted, as in ``_alpha_modulated.py``, so a pygfx bump that +moves it raises at shader-build time and names the anchor. + +Verified against pygfx 0.17.0. +""" + +from __future__ import annotations + +from typing import Any + +import pygfx as gfx +from pygfx.renderers.wgpu import register_wgpu_render_function +from pygfx.renderers.wgpu.shaders.lineshader import LineShader + +from cellier.render.shaders._alpha_modulated import ShaderAnchorError + +__all__ = [ + "OUTLINE_DEPTH_OFFSET_PX", + "OUTLINE_RENDER_QUEUE", + "PlaneOutlineMaterial", + "PlaneOutlineShader", +] + +#: How far the line's depth is moved toward the camera, in logical pixels' +#: worth of world distance at the line's depth (design B-D7). +OUTLINE_DEPTH_OFFSET_PX = 8.0 + +#: After every visual: pygfx's opaque queues end at 2600 and the volume +#: materials are at 3000. +OUTLINE_RENDER_QUEUE = 3500 + +#: Where pygfx's line vertex shader writes the clip-space position. +_POSITION_ANCHOR = "varyings.position = vec4(the_pos_n);" + +_POSITION_WITH_OFFSET = f""" + // cellier: the depth of a plane outline, moved toward the camera. + let outline_p = u_stdinfo.projection_transform; + // The world size of one logical pixel at this depth. + let outline_px = abs( + 2.0 * the_pos_n.w / (outline_p[1][1] * u_stdinfo.logical_size.y)); + let outline_d = {OUTLINE_DEPTH_OFFSET_PX!r} * outline_px; + // The clip-space depth of the point outline_d nearer the camera. + let outline_z = the_pos_n.z + outline_d * outline_p[2][2]; + let outline_w = the_pos_n.w + outline_d * outline_p[2][3]; + varyings.position = vec4( + the_pos_n.xy, outline_z / outline_w * the_pos_n.w, the_pos_n.w); +""" + + +class PlaneOutlineMaterial(gfx.LineMaterial): + """A closed line in screen pixels, drawn after the visuals. + + Depth tested, so a mesh in front hides it; it writes no depth and is + not picked. + + Parameters + ---------- + color : tuple of four float + RGBA. + thickness : float + The line's width in logical screen pixels. + """ + + def __init__(self, *, color: Any, thickness: float) -> None: + super().__init__( + color=color, + thickness=thickness, + thickness_space="screen", + loop=True, + aa=True, + pick_write=False, + depth_test=True, + depth_write=False, + render_queue=OUTLINE_RENDER_QUEUE, + ) + + +@register_wgpu_render_function(gfx.Line, PlaneOutlineMaterial) +class PlaneOutlineShader(LineShader): + """pygfx's line shader with the line's depth moved toward the camera.""" + + type = "render" + + def get_code(self) -> str: + """Pygfx's line wgsl with the position write replaced. + + Raises + ------ + ShaderAnchorError + If the line of pygfx's shader this replaces is no longer there. + """ + code = super().get_code() + if _POSITION_ANCHOR not in code: + raise ShaderAnchorError( + "pygfx shader anchor missing -- line.wgsl changed under us. " + "Rerun scripts/plane_border/v2_depth.py and re-anchor:\n" + f"{_POSITION_ANCHOR}" + ) + return code.replace(_POSITION_ANCHOR, _POSITION_WITH_OFFSET, 1) diff --git a/src/cellier/render/visuals/_graph_memory.py b/src/cellier/render/visuals/_graph_memory.py index 47f8cc37..c18f6753 100644 --- a/src/cellier/render/visuals/_graph_memory.py +++ b/src/cellier/render/visuals/_graph_memory.py @@ -9,7 +9,7 @@ import pygfx as gfx from cellier.data.graph._graph_requests import GraphSliceRequest -from cellier.render._clipping import GeometryClippingMixin +from cellier.render._clipping import GeometryClippingMixin, notify_planes_placed from cellier.render._spaces import ( RenderSpaces, data_slice_positions, @@ -24,6 +24,7 @@ AlphaPointsMaterial, ) from cellier.render.visuals._aabb import ( + geometry_bounds, make_aabb_line, refresh_aabb_line, ) @@ -864,6 +865,16 @@ def on_pick_write_changed(self, event: PickWriteChangedEvent) -> None: self._node_material.pick_write = event.pick_write self._edge_material.pick_write = event.pick_write + def _outline_node_box(self): + """The drawn vertices' box, for the outlines of clipping planes.""" + bounds = geometry_bounds( + ( + self.node_points, + self.node_edges, + ) + ) + return None if bounds is None else (self.node_points, *bounds) + def _refresh_aabb(self) -> None: """Resize the bounding box to the data that just committed. @@ -879,6 +890,8 @@ def _refresh_aabb(self) -> None: ), enabled=self._aabb_enabled, ) + # A clipping plane's outline is cut by this box. + notify_planes_placed(self) def on_aabb_changed(self, event: AABBChangedEvent) -> None: """Store AABB param changes; apply to the line node if it exists.""" diff --git a/src/cellier/render/visuals/_image.py b/src/cellier/render/visuals/_image.py index 08826254..2ff2fc6f 100644 --- a/src/cellier/render/visuals/_image.py +++ b/src/cellier/render/visuals/_image.py @@ -3365,6 +3365,12 @@ def _begin_plan(self, selection) -> tuple[list[int], list[_MultiscaleImageSlot]] self._apply_clipping_planes() return keys, slots + def _outline_shape(self): + """The store's level-0 shape: the box a plane's outline is cut by.""" + if not self._full_level_shapes: + return None + return tuple(self._full_level_shapes[0]) + def _clip_targets(self): """One group per channel slot (clipping planes design 4.1, 4.6). diff --git a/src/cellier/render/visuals/_image_memory.py b/src/cellier/render/visuals/_image_memory.py index 12395bb1..71718095 100644 --- a/src/cellier/render/visuals/_image_memory.py +++ b/src/cellier/render/visuals/_image_memory.py @@ -699,6 +699,10 @@ def _update_node_matrix(self, displayed_axes: tuple[int, ...]) -> None: self.node_2d.local.matrix = m self._apply_clipping_planes() + def _outline_shape(self): + """The store's shape: the box a plane's outline is cut by.""" + return tuple(self._data_store.shape) + def _clip_targets(self): """One group per channel slot (clipping planes design 4.1, 4.6). diff --git a/src/cellier/render/visuals/_label_memory.py b/src/cellier/render/visuals/_label_memory.py index 6a347dc4..65d16313 100644 --- a/src/cellier/render/visuals/_label_memory.py +++ b/src/cellier/render/visuals/_label_memory.py @@ -270,6 +270,10 @@ def _update_node_matrix(self, displayed_axes: tuple[int, ...]) -> None: self.node_2d.local.matrix = m self._apply_clipping_planes() + def _outline_shape(self): + """The store's shape: the box a plane's outline is cut by.""" + return tuple(self._data_store.shape) + def _clip_targets(self): """Both materials, at the slice the nodes are drawn at (design 4.6).""" if self._spaces is None: diff --git a/src/cellier/render/visuals/_label_multiscale.py b/src/cellier/render/visuals/_label_multiscale.py index 3926a6fe..8d17d767 100644 --- a/src/cellier/render/visuals/_label_multiscale.py +++ b/src/cellier/render/visuals/_label_multiscale.py @@ -840,6 +840,12 @@ def _update_node_matrix(self, displayed_axes: tuple[int, ...]) -> None: self.node_2d.local.matrix = plain self._apply_clipping_planes() + def _outline_shape(self): + """The store's level-0 shape: the box a plane's outline is cut by.""" + if not self._full_level_shapes: + return None + return tuple(self._full_level_shapes[0]) + def _clip_targets(self): """Both materials, at the planes last planned (design 4.1, 4.6).""" collapsed = () if self._spaces is None else self._spaces.collapsed_axes diff --git a/src/cellier/render/visuals/_lines_memory.py b/src/cellier/render/visuals/_lines_memory.py index 1b803943..db5b20d5 100644 --- a/src/cellier/render/visuals/_lines_memory.py +++ b/src/cellier/render/visuals/_lines_memory.py @@ -8,7 +8,7 @@ import pygfx as gfx from cellier.data.lines._lines_requests import LinesSliceRequest -from cellier.render._clipping import GeometryClippingMixin +from cellier.render._clipping import GeometryClippingMixin, notify_planes_placed from cellier.render._spaces import ( RenderSpaces, data_slice_positions, @@ -17,6 +17,7 @@ ) from cellier.render.shaders._alpha_modulated import AlphaLineSegmentMaterial from cellier.render.visuals._aabb import ( + geometry_bounds, make_aabb_line, refresh_aabb_line, ) @@ -527,6 +528,11 @@ def on_visibility_changed(self, event: VisualVisibilityChangedEvent) -> None: def on_pick_write_changed(self, event: PickWriteChangedEvent) -> None: self._material.pick_write = event.pick_write + def _outline_node_box(self): + """The drawn vertices' box, for the outlines of clipping planes.""" + bounds = geometry_bounds((self.node,)) + return None if bounds is None else (self.node, *bounds) + def _refresh_aabb(self) -> None: """Resize the bounding box to the data that just committed. @@ -537,6 +543,8 @@ def _refresh_aabb(self) -> None: self._aabb_has_bounds = refresh_aabb_line( self._aabb_line, (self.node,), enabled=self._aabb_enabled ) + # A clipping plane's outline is cut by this box. + notify_planes_placed(self) def on_aabb_changed(self, event: AABBChangedEvent) -> None: """Store AABB param changes; apply to line node if it exists.""" diff --git a/src/cellier/render/visuals/_mesh.py b/src/cellier/render/visuals/_mesh.py index 1c99a13f..981b06de 100644 --- a/src/cellier/render/visuals/_mesh.py +++ b/src/cellier/render/visuals/_mesh.py @@ -24,7 +24,7 @@ MeshSectionRequest, MeshSliceRequest, ) -from cellier.render._clipping import GeometryClippingMixin +from cellier.render._clipping import GeometryClippingMixin, notify_planes_placed from cellier.render._level_residency import LevelResidency from cellier.render._spaces import ( RenderSpaces, @@ -775,6 +775,17 @@ def _placeholder_corner(self) -> np.ndarray: box = self._local_extent() return np.zeros(3) if box is None else box[0] + def _outline_node_box(self): + """The store's extent in the 3D node's frame. + + The box the outlines of clipping planes are cut by: the box the + wireframe draws. + """ + box = self._local_extent() + if box is None or self.node_3d is None: + return None + return (self.node_3d, *box) + def _local_extent(self) -> tuple[np.ndarray, np.ndarray] | None: """The store's extent on the retained axes, in upload column order.""" if self._axis_extents is None or self._spaces is None: @@ -1129,6 +1140,8 @@ def _refresh_aabb(self) -> None: for nodes in self._levels.values(): nodes.move_placeholder(corner) self._aabb_has_bounds = box is not None + # A clipping plane's outline is cut by this box. + notify_planes_placed(self) # Only the line of the current dimensionality: the extent is in that # node's frame, and the other node is not in the scene. line = self._aabb_line diff --git a/src/cellier/render/visuals/_plane_gizmo.py b/src/cellier/render/visuals/_plane_gizmo.py index 57b8b3c0..0273680a 100644 --- a/src/cellier/render/visuals/_plane_gizmo.py +++ b/src/cellier/render/visuals/_plane_gizmo.py @@ -37,14 +37,16 @@ class PlaneTransformGizmo(gfx.TransformGizmo): clipping plane has no size; a render plane has one along each in-plane axis that is bounded on both sides (plane rendering design v3, 8.3). Axis 0, the normal, never has one. - - A scale drag along a handle's arrow always grows the object, whatever - the camera angle (see :meth:`_handle_scale_move`). - The frame stays ``"object"``: axis 0 is the normal. + - The handles point along the object's own axes, whatever the camera + angle: the arrow of axis 0 is the plane's normal (see + :meth:`_update_gizmo_transform`). So a scale drag along a handle's + arrow always grows the object. - It tells its owner when a handle is grabbed, moved and released. Relies on private pygfx names: ``_ref``, ``_camera``, ``_viewport``, - ``_update_visibility``, ``_highlight``, ``_center_sphere``, - ``_scale_children``, ``_handle_scale_move``, ``_object_to_control`` and + ``_update_visibility``, ``_update_gizmo_transform``, ``_highlight``, + ``_center_sphere``, ``_scale_children``, ``_object_to_control`` and ``gizmo_scale``. Parameters @@ -67,6 +69,13 @@ def __init__( self.toggle_mode("object") for element in self.children: element.material.depth_test = False + # With no depth test the element drawn last is the one seen and the + # one picked. A handle that points away from the camera can be + # behind an in-plane translate square on screen (pygfx turns its + # handles to the camera, so it never meets this): the one-axis + # handles are drawn after everything else, so they can be grabbed. + for element in (*self._translate1_children, *self._scale_children): + element.render_order = 1 def _update_visibility(self) -> None: super()._update_visibility() @@ -75,30 +84,25 @@ def _update_visibility(self) -> None: if dim not in self.scale_axes: element.visible = False - def _handle_scale_move(self, event: Any) -> None: - """Scale as pygfx does, with the drag's direction put right. - - pygfx flips a handle that would point away from the camera and - means its scale drag to flip with it. It reads the flip from the - sign of the gizmo's ``local.scale``, but a node's matrix is kept as - translation, rotation and scale, and a flip does not come back from - it as a negative scale on the axis that was flipped (two flipped - axes come back as a half turn). ``gizmo_scale`` is the array the - flip was written to, so its sign is the truth: where the two - disagree, the factor pygfx applied is inverted. + def _update_gizmo_transform(self) -> None: + """Place the gizmo as pygfx does, without turning it to the camera. + + pygfx flips every handle that would point away from the camera, so + an arrow shows where the viewer is and not which way its axis + goes: it does not turn over when the plane is flipped, and it does + when the camera passes the plane. Here the flip is dropped and + the size on screen kept. + + The rotation is written again, before the scale: a flip that was + written to the node's matrix does not come back out of it as a + scale, so a positive scale written over it would leave the gizmo + turned. """ - super()._handle_scale_move(event) - dim = self._ref["dim"] - if dim is None: - return - assumed = self.local.scale[dim] < 0 - flipped = self.gizmo_scale[dim] < 0 - if assumed != flipped: - target = self._object_to_control - scale = np.array(target.local.scale, dtype=np.float64) - start = float(self._ref["scale"][dim]) - scale[dim] = start * start / scale[dim] - target.local.scale = scale + super()._update_gizmo_transform() + np.abs(self.gizmo_scale, out=self.gizmo_scale) + self.world.scale = 1 + self.world.rotation = self._object_to_control.world.rotation + self.world.scale = self.gizmo_scale def process_event(self, event: Any) -> None: """Handle the event as pygfx does, then report what it did.""" diff --git a/src/cellier/render/visuals/_plane_outline.py b/src/cellier/render/visuals/_plane_outline.py new file mode 100644 index 00000000..949f7d16 --- /dev/null +++ b/src/cellier/render/visuals/_plane_outline.py @@ -0,0 +1,250 @@ +"""Plane outlines in a scene (plane outline design, sections 5.4 and 6). + +``visual_plane_outlines`` asks one render visual for the polygons of its +outlined planes. ``GFXPlaneOutlines`` holds the lines of one scene: it is +given every visual's polygons, drops the duplicates a shared plane makes, +and keeps one ``gfx.Line`` per outline drawn. + +The lines are in the scene's rendered space, so they need no node matrix. +They are not visuals: never sliced, never picked. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +import numpy as np +import pygfx as gfx + +from cellier.render._clipping import reduce_clipping_planes +from cellier.render._plane_outline import ( + clipping_plane_polygon, + polygons_equal, + render_plane_polygon, +) +from cellier.render._render_planes import reduce_render_planes +from cellier.render.shaders._plane_outline import PlaneOutlineMaterial + +if TYPE_CHECKING: + from collections.abc import Collection, Iterable + from uuid import UUID + +#: The kinds of plane a visual can show outlines of. +RENDER = "render" +CLIPPING = "clipping" + + +@dataclass(frozen=True) +class PlaneOutlinePolygon: + """One visual's polygon of one outlined plane. + + Parameters + ---------- + kind : str + ``"render"`` or ``"clipping"``. + plane_id : UUID + The plane's id. + vertices : np.ndarray + ``(k, 3)`` float64, in rendered ``(x, y, z)``, in order around the + polygon. + color : tuple of four float + The outline's RGBA. + width : float + The outline's width in screen pixels. + """ + + kind: str + plane_id: UUID + vertices: np.ndarray + color: tuple[float, float, float, float] + width: float + + +def visual_plane_outlines( + gfx_visual: Any, kinds: Collection[str] +) -> list[PlaneOutlinePolygon]: + """The polygons of a render visual's outlined planes, for the view drawn. + + Parameters + ---------- + gfx_visual : + A render visual. One that takes no planes, or is not placed in a + 3D view, gives nothing. + kinds : Collection[str] + Which of the visual's planes are shown now (design 3.3): + ``"render"`` when it draws its render planes, ``"clipping"`` when + its clipping planes cut what it draws. + + Returns + ------- + list[PlaneOutlinePolygon] + One per plane with an enabled outline and a polygon: the render + planes in their order, then the clipping planes in theirs. + """ + render_planes = [] + if RENDER in kinds: + render_planes = [ + plane + for plane in getattr(gfx_visual, "render_planes", ()) + if plane.enabled and plane.outline.enabled + ] + clipping = list(getattr(gfx_visual, "clipping_planes", ())) + outlined_cuts = [] + if CLIPPING in kinds: + outlined_cuts = [ + index + for index, item in enumerate(clipping) + if item.enabled and item.outline.enabled + ] + if not render_planes and not outlined_cuts: + return [] + frame = getattr(gfx_visual, "clip_frame", lambda: None)() + box = getattr(gfx_visual, "outline_frame", lambda: None)() + if frame is None or box is None: + return [] + spaces, transform, constants = frame + box_rows, corners = box + # Every clipping plane, as the materials are given them: a disabled + # one keeps everything. + clipping_rows = ( + reduce_clipping_planes(spaces, transform, constants, clipping) + if clipping + else () + ) + found = [] + for plane in render_planes: + # Nothing when the view does not display the plane's axes. + reduced = reduce_render_planes(spaces, [plane]) + if len(reduced) == 0: + continue + vertices = render_plane_polygon( + reduced.origin[0], + reduced.axis_0[0], + reduced.axis_1[0], + reduced.extent[0], + reduced.bounded[0], + box_rows, + corners, + clipping_rows, + ) + if len(vertices): + found.append(_polygon(RENDER, plane, vertices)) + for index in outlined_cuts: + vertices = clipping_plane_polygon(index, clipping_rows, box_rows, corners) + if len(vertices): + found.append(_polygon(CLIPPING, clipping[index], vertices)) + return found + + +def _polygon(kind: str, plane: Any, vertices: np.ndarray) -> PlaneOutlinePolygon: + return PlaneOutlinePolygon( + kind=kind, + plane_id=plane.id, + vertices=vertices, + color=tuple(plane.outline.color), + width=float(plane.outline.width), + ) + + +def drop_duplicates( + polygons: Iterable[PlaneOutlinePolygon], +) -> dict[tuple[str, UUID, int], PlaneOutlinePolygon]: + """The outlines to draw: equal polygons of one plane, once (design 5.4). + + Parameters + ---------- + polygons : Iterable[PlaneOutlinePolygon] + Every visual's polygons, the visuals in the scene's order. + + Returns + ------- + dict + ``(kind, plane id, n) -> polygon``: the ``n``-th different polygon + of that plane. The first visual to give a polygon gives its style. + """ + kept: dict[tuple[str, UUID, int], PlaneOutlinePolygon] = {} + counts: dict[tuple[str, UUID], int] = {} + for polygon in polygons: + plane = (polygon.kind, polygon.plane_id) + count = counts.get(plane, 0) + if any( + polygons_equal(kept[(*plane, n)].vertices, polygon.vertices) + for n in range(count) + ): + continue + kept[(*plane, count)] = polygon + counts[plane] = count + 1 + return kept + + +class GFXPlaneOutlines: + """The plane outlines of one scene, as lines under one group.""" + + def __init__(self) -> None: + self._group = gfx.Group() + self._lines: dict[tuple[str, UUID, int], gfx.Line] = {} + self._drawn: dict[tuple[str, UUID, int], PlaneOutlinePolygon] = {} + + @property + def node(self) -> gfx.Group: + """The group holding the lines; a child of the scene.""" + return self._group + + @property + def drawn(self) -> dict[tuple[str, UUID, int], PlaneOutlinePolygon]: + """What is drawn now, by ``(kind, plane id, n)``.""" + return dict(self._drawn) + + def line(self, key: tuple[str, UUID, int]) -> gfx.Line | None: + """The line of one outline, if it is drawn.""" + return self._lines.get(key) + + def update(self, polygons: Iterable[PlaneOutlinePolygon]) -> bool: + """Draw exactly the outlines of *polygons*, duplicates dropped. + + A line is made when its polygon first exists and removed when it no + longer does: there is never a placeholder in the scene. + + Returns + ------- + bool + Whether anything drawn changed. + """ + wanted = drop_duplicates(polygons) + changed = False + for key in [key for key in self._lines if key not in wanted]: + self._group.remove(self._lines.pop(key)) + del self._drawn[key] + changed = True + for key, polygon in wanted.items(): + before = self._drawn.get(key) + line = self._lines.get(key) + if line is None: + line = gfx.Line( + _geometry(polygon.vertices), + PlaneOutlineMaterial(color=polygon.color, thickness=polygon.width), + ) + self._lines[key] = line + self._group.add(line) + changed = True + else: + if not np.array_equal(before.vertices, polygon.vertices): + line.geometry = _geometry(polygon.vertices) + changed = True + if before.color != polygon.color: + line.material.color = polygon.color + changed = True + if before.width != polygon.width: + line.material.thickness = polygon.width + changed = True + self._drawn[key] = polygon + return changed + + def clear(self) -> bool: + """Remove every line.""" + return self.update(()) + + +def _geometry(vertices: np.ndarray) -> gfx.Geometry: + return gfx.Geometry(positions=np.ascontiguousarray(vertices, dtype=np.float32)) diff --git a/src/cellier/render/visuals/_points_memory.py b/src/cellier/render/visuals/_points_memory.py index 1ed18d35..5a6ad4e9 100644 --- a/src/cellier/render/visuals/_points_memory.py +++ b/src/cellier/render/visuals/_points_memory.py @@ -8,7 +8,7 @@ import pygfx as gfx from cellier.data.points._points_requests import PointsSliceRequest -from cellier.render._clipping import GeometryClippingMixin +from cellier.render._clipping import GeometryClippingMixin, notify_planes_placed from cellier.render._spaces import ( RenderSpaces, data_slice_positions, @@ -17,6 +17,7 @@ ) from cellier.render.shaders._alpha_modulated import AlphaPointsMaterial from cellier.render.visuals._aabb import ( + geometry_bounds, make_aabb_line, refresh_aabb_line, ) @@ -553,6 +554,11 @@ def on_visibility_changed(self, event: VisualVisibilityChangedEvent) -> None: def on_pick_write_changed(self, event: PickWriteChangedEvent) -> None: self._material.pick_write = event.pick_write + def _outline_node_box(self): + """The drawn vertices' box, for the outlines of clipping planes.""" + bounds = geometry_bounds((self.node,)) + return None if bounds is None else (self.node, *bounds) + def _refresh_aabb(self) -> None: """Resize the bounding box to the data that just committed. @@ -563,6 +569,8 @@ def _refresh_aabb(self) -> None: self._aabb_has_bounds = refresh_aabb_line( self._aabb_line, (self.node,), enabled=self._aabb_enabled ) + # A clipping plane's outline is cut by this box. + notify_planes_placed(self) def on_aabb_changed(self, event: AABBChangedEvent) -> None: """Store AABB param changes; apply to line node if it exists.""" diff --git a/src/cellier/visuals/__init__.py b/src/cellier/visuals/__init__.py index e595b232..3b5662c3 100644 --- a/src/cellier/visuals/__init__.py +++ b/src/cellier/visuals/__init__.py @@ -51,6 +51,7 @@ MultiscaleMeshVisual, ) from cellier.visuals._overlay_types import CanvasOverlayType, SceneOverlayType +from cellier.visuals._plane_outline import PlaneOutline from cellier.visuals._points_memory import PointsMarkerAppearance, PointsVisual from cellier.visuals._render_plane import MAX_RENDER_PLANES, RenderPlane from cellier.visuals._scene_overlay import ( @@ -98,6 +99,7 @@ "MultiscaleLabelsAppearance", "MultiscaleMeshVisual", "OutlineMode", + "PlaneOutline", "PointsMarkerAppearance", "PointsVisual", "ProgressiveLoadingConfig", diff --git a/src/cellier/visuals/_clipping.py b/src/cellier/visuals/_clipping.py index 7a8a2771..bdb5edbf 100644 --- a/src/cellier/visuals/_clipping.py +++ b/src/cellier/visuals/_clipping.py @@ -8,6 +8,7 @@ from pydantic import UUID4, BaseModel, ConfigDict, Field, TypeAdapter from cellier.transform import Plane +from cellier.visuals._plane_outline import PlaneOutline if TYPE_CHECKING: from collections.abc import Sequence @@ -52,6 +53,10 @@ class ClippingPlane(BaseModel): enabled : bool A disabled plane stays in the list and clips nothing. Toggling it costs no shader compile. Default ``True``. + outline : PlaneOutline + The line around the plane's cut face: the plane where it crosses + the visual's data, inside the visual's other clipping planes. Off + by default. It is drawn in a 3D view, while the plane is enabled. """ model_config = ConfigDict(frozen=True) @@ -59,6 +64,7 @@ class ClippingPlane(BaseModel): id: UUID4 = Field(default_factory=uuid4) plane: Plane enabled: bool = True + outline: PlaneOutline = Field(default_factory=PlaneOutline) @classmethod def from_point_normal( @@ -69,6 +75,7 @@ def from_point_normal( axes: Sequence[AxisRef] | None = None, *, enabled: bool = True, + outline: PlaneOutline | None = None, ) -> ClippingPlane: """Build the plane through *point* that keeps the side *normal* points to. @@ -88,6 +95,8 @@ def from_point_normal( ``None`` means every axis of the system. enabled : bool Whether the plane clips. Default ``True``. + outline : PlaneOutline or None + The line around the plane's cut face. ``None`` is no outline. Returns ------- @@ -96,6 +105,7 @@ def from_point_normal( return cls( plane=Plane.from_point_normal(coordinate_system, point, normal, axes), enabled=enabled, + outline=PlaneOutline() if outline is None else outline, ) diff --git a/src/cellier/visuals/_plane_outline.py b/src/cellier/visuals/_plane_outline.py new file mode 100644 index 00000000..c9cd2cea --- /dev/null +++ b/src/cellier/visuals/_plane_outline.py @@ -0,0 +1,98 @@ +"""Plane outlines: the line around a render plane or a clipping plane. + +Plane outline design (``plans/plane_outline_design.md``), section 4. An +outline is a field of the plane it is drawn around, so it travels with the +plane: through the controller, the events, the linkers and a saved scene. + +Not to be confused with the screen-space outline pass +(:class:`~cellier.visuals.VisualOutline`), which draws the contour of what +a visual drew. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import numpy as np +from pydantic import BaseModel, ConfigDict, Field, field_validator + +if TYPE_CHECKING: + from collections.abc import Iterable + +Rgba = tuple[float, float, float, float] + + +class PlaneOutline(BaseModel): + """The line along the edge of the polygon a plane makes in its visual. + + For a render plane the polygon is what is drawn of it: the plane cut by + its extents, by the visual's data box and by the visual's enabled + clipping planes. For a clipping plane it is the cut face: the plane + cut by the data box and by the visual's other enabled clipping planes. + + The model is frozen, as the planes are. An outline is changed the way + a plane is moved: ``plane.model_copy(update={"outline": ...})`` in a + new tuple, which keeps the plane's id. A change of the outline alone + redraws; it does not load data again. + + Parameters + ---------- + enabled : bool + Whether the outline is drawn. Default ``False``. Switching it off + keeps the colour and the width. + color : tuple of four float + RGBA, each in ``[0, 1]``. Default opaque white. + width : float + The line's width in screen pixels. Default ``2.0``. + """ + + model_config = ConfigDict(frozen=True) + + enabled: bool = False + color: Rgba = (1.0, 1.0, 1.0, 1.0) + width: float = Field(default=2.0, gt=0.0) + + @field_validator("color") + @classmethod + def _valid_color(cls, value: Rgba) -> Rgba: + if not all(np.isfinite(value)) or not all(0.0 <= v <= 1.0 for v in value): + raise ValueError( + f"PlaneOutline.color is four numbers in [0, 1] (RGBA); got {value}." + ) + return value + + @field_validator("width") + @classmethod + def _finite_width(cls, value: float) -> float: + if not np.isfinite(value): + raise ValueError(f"PlaneOutline.width must be finite; got {value}.") + return value + + +#: What every plane carries until it is given an outline: not drawn. +NO_OUTLINE = PlaneOutline() + + +def without_outlines(planes: Iterable[Any]) -> tuple: + """*planes* with every outline set aside. + + Two tuples that are equal after this differ in their outlines at most: + the planes are where they were, so nothing has to be read again + (plane outline design 4.3). + + Parameters + ---------- + planes : Iterable[RenderPlane] or Iterable[ClippingPlane] + A visual's ``render_planes`` or ``clipping_planes``. + + Returns + ------- + tuple + The same planes, in order, each with the default outline. + """ + return tuple( + plane + if plane.outline == NO_OUTLINE + else plane.model_copy(update={"outline": NO_OUTLINE}) + for plane in planes + ) diff --git a/src/cellier/visuals/_render_plane.py b/src/cellier/visuals/_render_plane.py index 294d602b..8af2eaff 100644 --- a/src/cellier/visuals/_render_plane.py +++ b/src/cellier/visuals/_render_plane.py @@ -25,6 +25,7 @@ from typing_extensions import Self from cellier.transform import AxisRef +from cellier.visuals._plane_outline import PlaneOutline if TYPE_CHECKING: from collections.abc import Sequence @@ -139,6 +140,10 @@ class RenderPlane(BaseModel): enabled : bool A disabled plane stays in the tuple and is not drawn. Default ``True``. + outline : PlaneOutline + The line around what is drawn of the plane. Off by default. It is + drawn while the plane is: in a 3D view of the plane's axes, with + the visual in the ``"plane"`` render mode. """ model_config = ConfigDict(frozen=True) @@ -152,6 +157,7 @@ class RenderPlane(BaseModel): extent_0: Extent = (None, None) extent_1: Extent = (None, None) enabled: bool = True + outline: PlaneOutline = Field(default_factory=PlaneOutline) @field_validator("axes", mode="before") @classmethod @@ -255,7 +261,7 @@ def from_point_normal( A direction, on *axes*, whose projection into the plane becomes ``in_plane_axis_0``. It must not be parallel to the normal. **kwargs : - ``extent_0``, ``extent_1``, ``enabled``, ``id``. + ``extent_0``, ``extent_1``, ``enabled``, ``outline``, ``id``. Returns ------- @@ -312,7 +318,7 @@ def from_rotation( The world axes the pose is given on. ``None`` means the system's axes, which must then be three. **kwargs : - ``extent_0``, ``extent_1``, ``enabled``, ``id``. + ``extent_0``, ``extent_1``, ``enabled``, ``outline``, ``id``. Returns ------- diff --git a/tests/convenience/test_plane_rendering_examples.py b/tests/convenience/test_plane_rendering_examples.py index f91f7afb..6acefc3d 100644 --- a/tests/convenience/test_plane_rendering_examples.py +++ b/tests/convenience/test_plane_rendering_examples.py @@ -365,6 +365,69 @@ async def test_the_validation_scene_lines_up_at_every_time_point( assert _blob(of_image, dim)[1] == 0, (t, other, octant) +def _plane_controls(module) -> dict: + """The dock's render planes control of the image and of the labels.""" + import gc + + from cellier.gui.qt.visuals import QtRenderPlanesControls + + names = {module.image_visual.id: "image", module.labels_visual.id: "labels"} + return { + names[control.editor._visual_ids[0]]: control + for control in gc.get_objects() + if isinstance(control, QtRenderPlanesControls) + and control.editor._visual_ids[0] in names + } + + +def _normal(visual) -> list[float]: + plane = visual.render_planes[0] + return np.round(np.cross(plane.in_plane_axis_0, plane.in_plane_axis_1), 6).tolist() + + +@pytest.mark.parametrize("edited", ["image", "labels"]) +async def test_either_control_of_the_validation_scene_turns_the_one_plane( + edited, load_example, qtbot, offscreen_gpu +): + module = load_example("plane_slicing_validation") + await _show(module, qtbot) + viewer, controller = module.viewer, module.viewer.controller + image, labels = module.image_visual, module.labels_visual + controls = _plane_controls(module) + assert set(controls) == {"image", "labels"} + session = controller.get_plane_gizmo(_canvas_id(viewer)) + image.aabb.enabled = False + controller.update_background_field(viewer.scene.id, "visible", False) + + # Face the plane along y, x and back along z, with the control's buttons. + for axis in (1, 2, 0): + controls[edited]._rows[0].facing[(axis, 1)].click() + expected = [float(k == axis) for k in range(3)] + assert _normal(image) == expected + assert _normal(labels) == expected + assert image.render_planes == labels.render_planes + # Both controls show it, and the gizmo is still on the plane. + for control in controls.values(): + assert list(control.editor.rows[0]["normal"]) == expected + assert controller.get_plane_gizmo(_canvas_id(viewer)) is session + # The plane turned about a point, so it passes through the four + # points of the high half of that axis, and through no other sphere. + await drain_loading(controller) + of_labels = await _only(module, labels) + of_image = await _only(module, image) + for octant in range(8): + color = module.COLORS[0, octant] + on_plane = bool((octant >> (2 - axis)) & 1) + assert (_blob(of_labels, color)[1] > 0) == on_plane, (axis, octant) + dim = color * module.IMAGE_INTENSITY + assert (_blob(of_image, dim)[1] > 0) == on_plane, (axis, octant) + + # A typed entry of the normal goes to both visuals as well. + controls[edited]._rows[0].components[1].setValue(1.0) + assert _normal(image) == _normal(labels) + assert _normal(image) == pytest.approx([2**-0.5, 2**-0.5, 0.0], abs=1e-6) + + def test_the_validation_scene_moves_its_points_and_changes_its_colours( load_example, qtbot ): @@ -441,3 +504,46 @@ async def test_the_marimo_motion_is_one_interaction_and_one_plan(run_marimo_exam "plane interaction: end (release)", "plan", ] + + +# -- Plane outlines in the examples (plane outline design 8) -------------------- + + +def _outlines(viewer, scene) -> dict: + scene_manager = viewer.controller._render_manager._scenes[scene.id] + return scene_manager.plane_outlines.drawn + + +async def test_the_ortho_example_outlines_each_slice_in_the_3d_panel( + load_example, qtbot +): + module = load_example("ortho_slice_planes") + await _show(module, qtbot) + viewer = module.viewer + drawn = _outlines(viewer, viewer.scenes["vol"]) + # One line a slice plane, each in its own colour. + assert {key[1] for key in drawn} == set(viewer.plane_controller.plane_ids) + assert len({polygon.color for polygon in drawn.values()}) == 3 + for name in ("xy", "xz", "yz"): + assert _outlines(viewer, viewer.scenes[name]) == {} + # A slider moves its slice's outline. + viewer.set_slice_positions({0: 20.0}) + first = viewer.plane_controller.plane_ids[0] + moved = _outlines(viewer, viewer.scenes["vol"])[("render", first, 0)] + np.testing.assert_allclose(moved.vertices[:, 2], 20.0) + + +async def test_the_validation_scene_has_one_outline_for_its_shared_plane( + load_example, qtbot +): + """The image and the labels carry the same plane and have the same box.""" + module = load_example("plane_slicing_validation") + await _show(module, qtbot) + viewer = module.viewer + drawn = _outlines(viewer, viewer.scene) + assert list(drawn) == [("render", module.plane.id, 0)] + assert module.image_visual.render_planes[0].outline.enabled + assert module.labels_visual.render_planes[0].outline.enabled + # The plane's square through the volume: four corners on its box. + (polygon,) = drawn.values() + assert len(polygon.vertices) == 4 diff --git a/tests/convenience/test_render_planes_viewers.py b/tests/convenience/test_render_planes_viewers.py index 389df7fb..93caa1e5 100644 --- a/tests/convenience/test_render_planes_viewers.py +++ b/tests/convenience/test_render_planes_viewers.py @@ -678,3 +678,143 @@ async def test_a_2d_scrub_holds_the_vol_plan_and_loads_once( assert drags == [] assert vol.render_planes[0].origin[0] == 56.0 await drain_loading(controller) + + +# -- the slice planes' outlines (plane outline design 8) -------------------------- + + +def _canvases(ortho) -> None: + """Give every panel its canvas: a visual is placed, and its planes' + outlines drawn, once its scene has one.""" + for scene in ortho.scenes.values(): + ortho.controller.add_canvas(scene.id) + + +def _vol_outlines(ortho): + """The plane outlines drawn in the 3D panel's scene.""" + scene_manager = ortho.controller._render_manager._scenes[ortho.scenes["vol"].id] + return scene_manager.plane_outlines.drawn + + +async def test_the_slice_planes_have_no_outline_unless_given_one(make_viewer): + from cellier.visuals import PlaneOutline + + ortho, visuals = _slices(make_viewer) + _canvases(ortho) + assert ortho.plane_controller.outlines == (PlaneOutline(),) * 3 + assert all(not p.outline.enabled for p in visuals["vol"].render_planes) + assert _vol_outlines(ortho) == {} + + +async def test_outlines_set_on_the_plane_controller_are_drawn_once_a_plane( + make_viewer, +): + """Three outlines however many visuals carry the planes (5.4).""" + from cellier.visuals import PlaneOutline + + ortho, visuals = _slices(make_viewer) + _canvases(ortho) + labels = ortho.add_labels( + LabelMemoryStore(data=np.ones(SHAPE, np.int32)), + appearance=InMemoryLabelsAppearance(render_mode="plane"), + ) + colours = [(1.0, 0.0, 0.0, 1.0), (0.0, 1.0, 0.0, 1.0), (0.0, 0.0, 1.0, 1.0)] + outlines = tuple(PlaneOutline(enabled=True, color=c) for c in colours) + + ortho.plane_controller.outlines = outlines + assert ortho.plane_controller.outlines == outlines + planes = visuals["vol"].render_planes + assert tuple(p.outline for p in planes) == outlines + assert tuple(p.id for p in planes) == ortho.plane_controller.plane_ids + assert labels["vol"].render_planes == planes + # The planes did not move, and the 2D panels have none. + assert _positions(planes) == [3.0, 5.0, 7.0] + assert visuals["xy"].render_planes == () + + drawn = _vol_outlines(ortho) + assert sorted(key[:1] + key[2:] for key in drawn) == [("render", 0)] * 3 + by_plane = {key[1]: polygon.color for key, polygon in drawn.items()} + assert [by_plane[i] for i in ortho.plane_controller.plane_ids] == colours + + # A slider moves a plane and its outline, and keeps the colour. + ortho.dims_controller.set_slice_position(0, 4.0) + assert visuals["vol"].render_planes[0].outline == outlines[0] + moved = _vol_outlines(ortho)[("render", ortho.plane_controller.plane_ids[0], 0)] + np.testing.assert_allclose(moved.vertices[:, 2], 4.0) + + # One outline for all three; None for none. + grey = PlaneOutline(enabled=True, color=(0.5, 0.5, 0.5, 1.0)) + ortho.plane_controller.outlines = grey + assert {p.outline for p in visuals["vol"].render_planes} == {grey} + ortho.plane_controller.outlines = None + assert _vol_outlines(ortho) == {} + + +async def test_outlines_set_before_the_mode_are_the_planes_when_it_is_turned_on( + make_viewer, +): + from cellier.visuals import PlaneOutline + + ortho, visuals = _slices(make_viewer, mode=False) + _canvases(ortho) + white = PlaneOutline(enabled=True) + ortho.plane_controller.outlines = white + assert visuals["vol"].render_planes == () # nothing is written in mode None + ortho.plane_controller.mode = "slices" + assert {p.outline for p in visuals["vol"].render_planes} == {white} + assert len(_vol_outlines(ortho)) == 3 + with pytest.raises(ValueError, match="one PlaneOutline"): + ortho.plane_controller.outlines = (white, white) + + +async def test_an_outline_given_to_a_slice_plane_by_hand_is_copied_and_kept( + make_viewer, +): + """As a plane's extents are: the planes are the source of truth.""" + from cellier.visuals import PlaneOutline + + ortho, visuals = _slices(make_viewer) + _canvases(ortho) + other = ortho.add_labels(LabelMemoryStore(data=np.ones(SHAPE, np.int32))) + vol = visuals["vol"] + red = PlaneOutline(enabled=True, color=(1.0, 0.0, 0.0, 1.0)) + first = vol.render_planes[0] + ortho.controller.set_render_plane( + vol.id, first.id, first.model_copy(update={"outline": red}) + ) + assert other["vol"].render_planes == vol.render_planes + assert ortho.plane_controller.outlines == (red, PlaneOutline(), PlaneOutline()) + ortho.dims_controller.set_slice_position(1, 6.0) + assert vol.render_planes[0].outline == red + # Off and on again: the planes are built with the outlines they had. + ortho.plane_controller.mode = None + ortho.controller.set_render_planes(vol.id, ()) + ortho.controller.set_render_planes(other["vol"].id, ()) + ortho.plane_controller.mode = "slices" + assert vol.render_planes[0].outline == red + + +async def test_the_clipping_link_carries_a_plane_s_outline(make_viewer): + """``OrthoClippingController`` mirrors whole tuples: an outline set on + one panel's clipping plane reaches the others, and so does a restyle.""" + from cellier.visuals import PlaneOutline + + ortho = make_viewer(OrthoViewer) + visuals = ortho.add_image(_image()) + _canvases(ortho) + store = ortho.controller.get_data_store(UUID(str(visuals["vol"].data_store_id))) + red = PlaneOutline(enabled=True, color=(1.0, 0.0, 0.0, 1.0)) + item = ClippingPlane.from_point_normal( + store.data_coordinate_system, (4, 6, 8), (0, 0, 1), outline=red + ) + visuals["xz"].clipping_planes = (item,) + assert {v.clipping_planes for v in visuals.values()} == {(item,)} + + blue = red.model_copy(update={"color": (0.0, 0.0, 1.0, 1.0)}) + restyled = item.model_copy(update={"outline": blue}) + ortho.controller.set_clipping_planes(visuals["vol"].id, (restyled,)) + assert {v.clipping_planes for v in visuals.values()} == {(restyled,)} + # Drawn in the 3D panel, as one line. + drawn = _vol_outlines(ortho) + assert list(drawn) == [("clipping", item.id, 0)] + assert drawn[("clipping", item.id, 0)].color == blue.color diff --git a/tests/gui/test_clipping_planes_controls.py b/tests/gui/test_clipping_planes_controls.py index 9a46235e..d53cab91 100644 --- a/tests/gui/test_clipping_planes_controls.py +++ b/tests/gui/test_clipping_planes_controls.py @@ -17,10 +17,13 @@ from cellier.gui._clipping_planes import ( CLIPPING_PLANES_TITLE, ClippingPlaneGizmoTarget, + depth_of, facing_of, get_clipping_plane_gizmo_data, get_clipping_planes_data_from_visual, + is_outside, planes_from_rows, + position_at, position_range, rows_from_planes, ) @@ -76,7 +79,16 @@ def _make(toolkit, visual_ids, visual, store): def _act(widget, action, index=None, value=None) -> None: - """Do one thing as a user would.""" + """Do one thing as a user would. + + ``"position"`` puts the plane at position *value*. A control shows and + sends the depth into the box, which differs from the position by a + constant, so it is done as the depth of that position. + """ + if action == "position": + described = widget.editor.describe()[index] + action = "depth" + value = value - described["position"] + described["depth"] if hasattr(widget, "comm"): # anywidget: what the front end sends widget.edit = { "action": action, @@ -100,10 +112,38 @@ def _act(widget, action, index=None, value=None) -> None: row.enabled.setChecked(value) elif action == "facing": row.facing[tuple(value)].click() - elif action == "position": + elif action == "depth": row.position.setValue(value) elif action == "component": row.components[value[0]].setValue(value[1]) + elif action == "outline": + row.outline.enabled.setChecked(value) + elif action == "outline_color": + _pick_colour(row, value) + + +def _pick_colour(row, colour) -> None: + """Choose *colour* in a Qt row's colour dialog; ``None`` cancels it.""" + from qtpy.QtGui import QColor + from qtpy.QtWidgets import QColorDialog + + original = QColorDialog.getColor + QColorDialog.getColor = staticmethod( + lambda *args, **kwargs: QColor() if colour is None else QColor(colour) + ) + try: + row.outline.color.click() + finally: + QColorDialog.getColor = original + + +def _outline_shown(widget, index) -> tuple[bool, str]: + """``(ticked, #rrggbb)`` as the row shows them.""" + if hasattr(widget, "comm"): + row = widget.rows[index] + return bool(row["outline"]["enabled"]), row["outline_hex"] + row = widget.row(index) + return row.outline.enabled.isChecked(), row.outline.hex def _set_normal(widget, index, normal) -> None: @@ -153,6 +193,60 @@ def test_the_position_range_is_the_box_projected_on_the_normal(): assert (low, high) == pytest.approx((0.0, 60 / np.sqrt(2))) +#: A box that does not start at the coordinate origin. +_OFFSET_BOX = [[2, 10], [0, 20], [5, 40]] + + +@pytest.mark.parametrize( + ("normal", "near", "far", "span"), + [ + ([0, 0, 1], 5.0, 40.0, 35.0), + # Flipped: the same two faces, the other one first. + ([0, 0, -1], -40.0, -5.0, 35.0), + # Not a unit normal: the depth is along the unit one. + ([0, 0, -3], -40.0, -5.0, 35.0), + ([0, 1, 1], 5 / np.sqrt(2), 60 / np.sqrt(2), 55 / np.sqrt(2)), + ], +) +def test_the_depth_is_0_on_the_near_face_and_the_span_on_the_far_one( + normal, near, far, span +): + assert depth_of(near, normal, _OFFSET_BOX) == pytest.approx((0.0, span)) + assert depth_of(far, normal, _OFFSET_BOX) == pytest.approx((span, span)) + assert position_at(0.0, normal, _OFFSET_BOX) == pytest.approx(near) + assert position_at(span, normal, _OFFSET_BOX) == pytest.approx(far) + + +def test_a_depth_and_its_position_convert_both_ways(): + normal = [1, -2, 0.5] + for depth in (-4.0, 0.0, 3.25, 100.0): + position = position_at(depth, normal, _OFFSET_BOX) + assert depth_of(position, normal, _OFFSET_BOX)[0] == pytest.approx(depth) + + +def test_a_flip_mirrors_the_depth_in_the_box(): + # The plane x == 12, seen from each side. + depth, span = depth_of(12.0, [0, 0, 1], _OFFSET_BOX) + flipped_depth, flipped_span = depth_of(-12.0, [0, 0, -1], _OFFSET_BOX) + assert (depth, span) == (7.0, 35.0) + assert (flipped_depth, flipped_span) == (span - depth, span) + + +def test_the_depth_of_a_zero_normal_has_a_span(): + assert depth_of(0.25, [0, 0, 0], _OFFSET_BOX) == (0.25, 1.0) + + +def test_a_plane_on_a_face_is_not_outside(): + assert not is_outside(0.0, 35.0) + assert not is_outside(35.0, 35.0) + assert not is_outside(17.0, 35.0) + # The rounding of the sums that put a plane on a face. + assert not is_outside(-1e-12, 35.0) + assert not is_outside(35.0 + 1e-12, 35.0) + assert is_outside(-0.01, 35.0) + assert is_outside(35.01, 35.0) + + def test_the_facing_of_a_normal(): assert facing_of([0, -3, 0]) == [1, -1] assert facing_of([0, 0, 2]) == [2, 1] @@ -219,6 +313,57 @@ def test_it_adds_moves_toggles_and_removes_planes(controller, toolkit): widget.close() +@pytest.mark.parametrize("toolkit", ["qt", "anywidget"]) +def test_the_editor_describes_and_sets_the_depth(controller, toolkit): + visual, store = _add_points(controller) # x runs from 0 to 40 + widget = _make(toolkit, [visual.id], visual, store) + controller.connect_widget(widget, subscription_specs=widget.subscription_specs()) + editor = widget.editor + + _act(widget, "add") # across x, through the middle + described = editor.describe()[0] + assert (described["depth"], described["span"]) == (20.0, 40.0) + assert described["outside"] is False + + editor.set_depth(0, 10.0) + assert visual.clipping_planes[0].plane.offset == 10.0 + assert editor.describe()[0]["depth"] == 10.0 + + # A flip keeps the plane and mirrors its depth. + _act(widget, "flip", 0) + assert visual.clipping_planes[0].plane.offset == -10.0 + described = editor.describe()[0] + assert (described["position"], described["depth"]) == (-10.0, 30.0) + assert described["span"] == 40.0 + # Depth 0 is now the face x == 40, the one the normal points away from. + editor.set_depth(0, 0.0) + assert visual.clipping_planes[0].plane.offset == -40.0 + + +@pytest.mark.parametrize("toolkit", ["qt", "anywidget"]) +def test_a_plane_outside_the_box_has_a_depth_past_the_span(controller, toolkit): + visual, store = _add_points(controller) + widget = _make(toolkit, [visual.id], visual, store) + controller.connect_widget(widget, subscription_specs=widget.subscription_specs()) + editor = widget.editor + _act(widget, "add") + + editor.set_depth(0, 46.0) + assert visual.clipping_planes[0].plane.offset == 46.0 + described = editor.describe()[0] + # The span is the box's, not widened to hold the plane. + assert (described["depth"], described["span"]) == (46.0, 40.0) + assert described["outside"] is True + + editor.set_depth(0, -3.0) + described = editor.describe()[0] + assert (described["depth"], described["span"]) == (-3.0, 40.0) + assert described["outside"] is True + + editor.set_depth(0, 40.0) + assert editor.describe()[0]["outside"] is False + + @pytest.mark.parametrize("toolkit", ["qt", "anywidget"]) def test_turning_the_normal_keeps_the_plane_through_the_middle(controller, toolkit): visual, store = _add_points(controller) @@ -251,6 +396,77 @@ def test_a_change_from_elsewhere_is_shown_and_sends_nothing(controller, toolkit) widget.close() +def _qt(controller): + """A Qt control on points whose x runs from 0 to 40, with one plane.""" + visual, store = _add_points(controller) + widget = _make("qt", [visual.id], visual, store) + controller.connect_widget(widget, subscription_specs=widget.subscription_specs()) + _act(widget, "add") # across x, through the middle + return visual, store, widget, widget.row(0) + + +def test_the_qt_slider_runs_from_one_face_of_the_box_to_the_other(controller): + visual, _store, _widget, row = _qt(controller) + assert (row.slider.value(), row.position.value()) == (500, 20.0) + row.slider.setValue(0) + assert visual.clipping_planes[0].plane.offset == 0.0 + row.slider.setValue(row.slider.maximum()) + assert visual.clipping_planes[0].plane.offset == 40.0 + assert row.position.value() == 40.0 + assert not row.outside + + # Flipped, the slider runs the other way: along the new normal. + row.flip.click() + assert (row.slider.value(), row.position.value()) == (0, 0.0) + row.slider.setValue(250) + np.testing.assert_array_equal(visual.clipping_planes[0].plane.normal, [0, 0, -1]) + assert visual.clipping_planes[0].plane.offset == -30.0 # the plane x == 30 + assert row.position.value() == 10.0 + + +def test_the_qt_number_is_the_depth_and_typing_it_moves_the_plane(controller): + visual, _store, _widget, row = _qt(controller) + row.flip.click() + # The plane x == 20, seen along -x: its position is -20, its depth 20. + assert visual.clipping_planes[0].plane.offset == -20.0 + assert row.position.value() == 20.0 + row.position.setValue(5.0) + assert visual.clipping_planes[0].plane.offset == -35.0 + assert row.slider.value() == 125 + + +def test_the_qt_row_marks_a_plane_outside_the_box(controller): + visual, store, widget, row = _qt(controller) + usual = row.position.toolTip() + assert row.slider.toolTip() == usual != "" + + # Moved from elsewhere (a gizmo, code) to 6 past the far face. + visual.clipping_planes = ( + ClippingPlane.from_point_normal( + store.data_coordinate_system, (0, 0, 46), (0, 0, 1) + ), + ) + assert row.outside + assert row.slider.value() == row.slider.maximum() # pinned + assert row.position.value() == 46.0 # the true depth + assert row.position.font().italic() + assert row.position.toolTip().startswith("The plane is outside") + assert widget.editor.describe()[0]["span"] == 40.0 + + # Before the near face: pinned at the other end, the number negative. + row.position.setValue(-3.0) + assert visual.clipping_planes[0].plane.offset == -3.0 + assert (row.slider.value(), row.position.value()) == (0, -3.0) + assert row.outside + + # Touching the slider brings the plane back inside. + row.slider.setValue(100) + assert visual.clipping_planes[0].plane.offset == 4.0 + assert not row.outside + assert not row.position.font().italic() + assert row.position.toolTip() == usual + + def test_the_qt_row_labels_the_normal_with_the_stores_axis_names(controller): visual, store = _add_points(controller, names="tzyx") widget = _make("qt", [visual.id], visual, store) @@ -317,6 +533,36 @@ def test_a_zeroed_normal_is_refused_with_a_reason(controller, toolkit): widget.close() +def test_the_anywidget_front_end_sends_the_depth(controller): + visual, store = _add_points(controller) # x runs from 0 to 40 + widget = _make("anywidget", [visual.id], visual, store) + controller.connect_widget(widget, subscription_specs=widget.subscription_specs()) + _act(widget, "add") + _act(widget, "flip", 0) + # Along -x, depth 5 is the plane x == 35. + _act(widget, "depth", 0, 5.0) + assert visual.clipping_planes[0].plane.offset == -35.0 + assert (widget.rows[0]["depth"], widget.rows[0]["span"]) == (5.0, 40.0) + # Past the far face: the row says so, and its span is still the box's. + _act(widget, "depth", 0, 46.0) + assert visual.clipping_planes[0].plane.offset == 6.0 + assert widget.rows[0]["outside"] is True + assert (widget.rows[0]["depth"], widget.rows[0]["span"]) == (46.0, 40.0) + widget.close() + + +def test_the_anywidget_no_longer_takes_a_position(controller): + """The action a front end from before the depth would send.""" + visual, store = _add_points(controller) + widget = _make("anywidget", [visual.id], visual, store) + controller.connect_widget(widget, subscription_specs=widget.subscription_specs()) + _act(widget, "add") + before = visual.clipping_planes + widget.edit = {"action": "position", "index": 0, "value": 5.0, "serial": 9999} + assert visual.clipping_planes == before + widget.close() + + def test_the_anywidget_ignores_a_malformed_edit(controller): visual, store = _add_points(controller) widget = _make("anywidget", [visual.id], visual, store) @@ -355,13 +601,14 @@ async def test_the_position_range_follows_the_stores_extent(controller, toolkit) controller.connect_widget(widget, subscription_specs=widget.subscription_specs()) _act(widget, "add") described = widget.editor.describe()[0] - assert (described["low"], described["high"]) == (0.0, 40.0) + assert (described["depth"], described["span"]) == (20.0, 40.0) sent: list = [] widget.changed.connect(sent.append) store.positions = np.array([[0, 0, 0], [10, 20, 80]], dtype=np.float32) described = widget.editor.describe()[0] - assert (described["low"], described["high"]) == (0.0, 80.0) + # The plane stayed at x == 20, in a box now twice as deep. + assert (described["depth"], described["span"]) == (20.0, 80.0) assert sent == [] # the plane did not move widget.close() @@ -392,7 +639,9 @@ def test_the_anywidget_sends_one_update_for_an_edit_delivered_twice(controller): assert len(sent) == 1 # What the front end draws a row from. assert widget.rows[0]["facing"] == [2, 1] - assert (widget.rows[0]["low"], widget.rows[0]["high"]) == (0.0, 40.0) + assert (widget.rows[0]["depth"], widget.rows[0]["span"]) == (5.0, 40.0) + assert widget.rows[0]["outside"] is False + assert "low" not in widget.rows[0] and "high" not in widget.rows[0] assert widget.axis_names == ["z", "y", "x"] widget.close() @@ -446,9 +695,7 @@ def test_a_control_for_five_axes(controller, toolkit): described = widget.editor.describe()[0] assert described["normal"] == [0.0, 0.0, 1.0, 1.0, 1.0] assert described["facing"] is None - assert (described["low"], described["high"]) == pytest.approx( - (0.0, (10 + 20 + 40) / np.sqrt(3)) - ) + assert described["span"] == pytest.approx((10 + 20 + 40) / np.sqrt(3)) widget.close() @@ -791,3 +1038,161 @@ def test_the_explicit_api_is_exported_from_cellier_gui(): ): assert name in gui.__all__ assert getattr(gui, name) is getattr(_clipping_planes, name) + + +# -- a plane's outline (plane outline design 4.1) ------------------------------- + + +def _outlined_plane(): + from cellier.transform import Axis, CoordinateSystem + from cellier.visuals import ClippingPlane, PlaneOutline + + system = CoordinateSystem( + name="data", axes=tuple(Axis(name=n, axis_type="space") for n in "zyx") + ) + outline = PlaneOutline(enabled=True, color=(1.0, 0.0, 0.0, 1.0), width=3.0) + plane = ClippingPlane.from_point_normal( + system, (4, 0, 0), (1, 0, 0), outline=outline + ) + return system, plane, outline + + +def test_a_row_carries_its_plane_s_outline_both_ways(): + system, plane, outline = _outlined_plane() + rows = rows_from_planes([plane]) + assert rows[0]["outline"] == { + "enabled": True, + "color": [1.0, 0.0, 0.0, 1.0], + "width": 3.0, + } + assert planes_from_rows(rows, system.id)[0].outline == outline + # A row with no outline entry is a plane with none. + del rows[0]["outline"] + assert planes_from_rows(rows, system.id)[0].outline.enabled is False + + +def test_an_edit_of_a_row_keeps_the_plane_s_outline(): + """The front ends have no control for an outline yet and may send a row + back without one: the plane keeps the outline it has.""" + from cellier.gui._clipping_planes import ClippingPlanesEditor + + system, plane, outline = _outlined_plane() + sent: list = [] + editor = ClippingPlanesEditor( + visual_ids=[uuid4()], + coordinate_system=system.id, + axis_names=["z", "y", "x"], + bounds=[[0, 10], [0, 10], [0, 10]], + planes=rows_from_planes([plane]), + source_id=uuid4(), + emit=sent.append, + show=lambda rows, error: None, + ) + editor.set_position(0, 6.0) + assert sent[-1].clipping_planes[0].outline == outline + assert sent[-1].clipping_planes[0].id == plane.id + + bare = [{k: v for k, v in row.items() if k != "outline"} for row in editor.rows] + bare[0]["enabled"] = False + editor.set_rows(bare) + assert sent[-1].clipping_planes[0].enabled is False + assert sent[-1].clipping_planes[0].outline == outline + + +@pytest.mark.parametrize("toolkit", ["qt", "anywidget"]) +def test_a_row_draws_and_colours_its_plane_s_outline(controller, toolkit): + """Plane outline design 7: a box and a colour per row.""" + from cellier.visuals import PlaneOutline + + visual, store = _add_points(controller) + faint = PlaneOutline(color=(1.0, 1.0, 1.0, 0.5), width=5.0) + plane = ClippingPlane.from_point_normal( + store.data_coordinate_system, (0, 0, 20), (0, 0, 1), outline=faint + ) + controller.set_clipping_planes(visual.id, (plane,)) + widget = _make(toolkit, [visual.id], visual, store) + controller.connect_widget(widget, subscription_specs=widget.subscription_specs()) + assert _outline_shown(widget, 0) == (False, "#ffffff") + + _act(widget, "outline", 0, True) + assert visual.clipping_planes[0].outline.enabled is True + assert visual.clipping_planes[0].id == plane.id + + _act(widget, "outline_color", 0, "#ff8000") + outline = visual.clipping_planes[0].outline + # The alpha and the width, which the row has no entry for, are kept. + assert outline.color == pytest.approx((1.0, 128 / 255, 0.0, 0.5)) + assert outline.width == 5.0 + assert outline.enabled is True + assert _outline_shown(widget, 0) == (True, "#ff8000") + assert visual.clipping_planes[0].plane == plane.plane # it did not move + + # A move from the control keeps the outline. + _act(widget, "position", 0, 5.0) + assert visual.clipping_planes[0].plane.offset == 5.0 + assert visual.clipping_planes[0].outline == outline + + _act(widget, "outline", 0, False) + assert visual.clipping_planes[0].outline.enabled is False + assert visual.clipping_planes[0].outline.color == pytest.approx(outline.color) + assert widget.error == "" + widget.close() + + +@pytest.mark.parametrize("toolkit", ["qt", "anywidget"]) +def test_an_outline_set_from_code_is_shown_and_sends_nothing(controller, toolkit): + from cellier.visuals import PlaneOutline + + visual, store = _add_points(controller) + plane = ClippingPlane.from_point_normal( + store.data_coordinate_system, (0, 0, 20), (0, 0, 1) + ) + controller.set_clipping_planes(visual.id, (plane,)) + widget = _make(toolkit, [visual.id], visual, store) + controller.connect_widget(widget, subscription_specs=widget.subscription_specs()) + updates: list = [] + widget.changed.connect(updates.append) + + green = PlaneOutline(enabled=True, color=(0.0, 1.0, 0.0, 1.0)) + controller.set_clipping_planes( + visual.id, (plane.model_copy(update={"outline": green}),) + ) + assert _outline_shown(widget, 0) == (True, "#00ff00") + assert updates == [] + widget.close() + + +def test_a_cancelled_colour_dialog_changes_nothing(controller): + visual, store = _add_points(controller) + plane = ClippingPlane.from_point_normal( + store.data_coordinate_system, (0, 0, 20), (0, 0, 1) + ) + controller.set_clipping_planes(visual.id, (plane,)) + widget = _make("qt", [visual.id], visual, store) + controller.connect_widget(widget, subscription_specs=widget.subscription_specs()) + _act(widget, "outline_color", 0, None) + assert visual.clipping_planes == (plane,) + widget.close() + + +def test_outline_colours_convert_between_a_picker_and_a_plane(): + from cellier.gui._clipping_planes import ( + outline_color, + outline_hex, + with_outline_field, + ) + + assert outline_hex({"color": [1.0, 0.5, 0.0, 0.25]}) == "#ff8000" + assert outline_hex(None) == "#ffffff" + assert outline_color("#ff8000", alpha=0.25) == pytest.approx( + [1.0, 128 / 255, 0.0, 0.25] + ) + assert outline_color((0.1, 0.2, 0.3), alpha=0.5) == [0.1, 0.2, 0.3, 0.5] + assert outline_color((0.1, 0.2, 0.3, 0.9), alpha=0.5) == [0.1, 0.2, 0.3, 0.9] + for bad in ("red", "#12345", "#gggggg", (1.0, 0.0)): + with pytest.raises(ValueError, match="colour"): + outline_color(bad) + entry = with_outline_field({"color": [0, 0, 0, 0.5], "width": 4.0}, color="#ffffff") + assert entry == {"enabled": False, "color": [1.0, 1.0, 1.0, 0.5], "width": 4.0} + with pytest.raises(ValueError): + with_outline_field(None, color=(2.0, 0.0, 0.0)) diff --git a/tests/gui/test_plane_outline_js.py b/tests/gui/test_plane_outline_js.py new file mode 100644 index 00000000..394fd665 --- /dev/null +++ b/tests/gui/test_plane_outline_js.py @@ -0,0 +1,91 @@ +"""The outline box and colour of the anywidget plane controls, in Qt's JS +engine. + +``clipping_planes.js`` and ``render_planes.js`` are run against the fake +DOM and model of ``test_plane_position_js.py``. Checked here: a row shows +its plane's outline, and sends ``"outline"`` and ``"outline_color"``. +""" + +from __future__ import annotations + +import json + +import pytest + +from tests.gui.test_plane_position_js import CLIPPING, FAKE_DOM, RENDER, STATIC + +QtQml = pytest.importorskip("PySide6.QtQml") + +SCENARIO = r""" +function row(extra) { + var r = JSON.parse(JSON.stringify(BASE)); + for (var k in (extra || {})) r[k] = extra[k]; + return r; +} +var OFF = { enabled: false, color: [1, 1, 1, 1], width: 2 }; +var RED = { enabled: true, color: [1, 0, 0, 1], width: 2 }; +var el = new Element("div"); +state = JSON.parse(JSON.stringify(STATE)); +state.rows = [row({ outline: OFF, outline_hex: "#ffffff" })]; +render({ model: model, el: el }); +var box = find(el, "outline")[0]; +var colour = find(el, "outline-color")[0]; +function seen() { return { checked: box.checked, colour: colour.value }; } +var out = { + type: [box.type, colour.type], + title: [box.title, colour.title], + off: seen(), +}; + +// The model changed. +push("rows", [row({ outline: RED, outline_hex: "#ff0000" })]); +out.red = seen(); + +// The user clears the box, then chooses a colour. +box.checked = false; box.fire("change"); +out.box_edit = JSON.parse(JSON.stringify(state.edit)); +colour.value = "#00ff00"; colour.fire("change"); +out.colour_edit = JSON.parse(JSON.stringify(state.edit)); + +// A row from a Python side with no outline entry draws, with the box clear. +push("rows", [row()]); +out.bare = seen(); +JSON.stringify(out); +""" + + +def _run(case: dict) -> dict: + source = (STATIC / case["file"]).read_text() + assert "export default { render };" in source + source = source.replace("export default { render };", "") + setup = ( + f"var BASE = {json.dumps(case['base'])};\n" + f"var STATE = {json.dumps(case['state'])};\n" + ) + # Kept in a name: a value outlives its engine as ``undefined``. + engine = QtQml.QJSEngine() + result = engine.evaluate(FAKE_DOM + source + setup + SCENARIO) + assert not result.isError(), ( + f"line {result.property('lineNumber').toInt()}: {result.toString()}" + ) + return json.loads(result.toString()) + + +@pytest.mark.parametrize("case", [CLIPPING, RENDER], ids=["clipping", "render"]) +def test_a_row_shows_and_sends_its_outline(case, qapp): + from cellier.gui._clipping_planes import OUTLINE_COLOR_TOOLTIP, OUTLINE_TOOLTIP + + out = _run(case) + assert out["type"] == ["checkbox", "color"] + # The words are the Qt control's. + assert out["title"] == [OUTLINE_TOOLTIP, OUTLINE_COLOR_TOOLTIP] + assert out["off"] == {"checked": False, "colour": "#ffffff"} + assert out["red"] == {"checked": True, "colour": "#ff0000"} + assert out["box_edit"]["action"] == "outline" + assert out["box_edit"]["index"] == 0 + assert out["box_edit"]["value"] is False + assert out["colour_edit"]["action"] == "outline_color" + assert out["colour_edit"]["value"] == "#00ff00" + assert out["colour_edit"]["serial"] > out["box_edit"]["serial"] + # The colour stays what it was; the box is clear. + assert out["bare"] == {"checked": False, "colour": "#00ff00"} diff --git a/tests/gui/test_plane_position_js.py b/tests/gui/test_plane_position_js.py new file mode 100644 index 00000000..0fc30ec2 --- /dev/null +++ b/tests/gui/test_plane_position_js.py @@ -0,0 +1,226 @@ +"""The position slider of the anywidget plane controls, in Qt's JS engine. + +``clipping_planes.js`` and ``render_planes.js`` are run against a fake DOM +and model. Checked here: the slider runs from 0 to the row's ``span``, +shows its ``depth``, is pinned and marked for a plane outside the box, and +sends ``"depth"``. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +import cellier.gui.anywidget.visuals as anywidget_visuals + +QtQml = pytest.importorskip("PySide6.QtQml") + +STATIC = Path(anywidget_visuals.__file__).parent / "static" + +FAKE_DOM = r""" +function Element(tag) { + this.tag = tag; this.children = []; this.listeners = {}; this.dataset = {}; + this.style = {}; this.attributes = {}; this.className = ""; this.textContent = ""; + this.value = ""; this.checked = false; this.disabled = false; this.parent = null; + this.title = ""; + var classes = {}; + this.classList = { + add: function (c) { classes[c] = true; }, + toggle: function (c, on) { if (on) classes[c] = true; else delete classes[c]; }, + contains: function (c) { return Boolean(classes[c]); }, + }; +} +Element.prototype.appendChild = function (c) { + c.parent = this; this.children.push(c); return c; +}; +Element.prototype.append = function () { + for (var i = 0; i < arguments.length; i++) { + var c = arguments[i]; + if (typeof c === "string") { + var t = new Element("#text"); t.textContent = c; c = t; + } + this.appendChild(c); + } +}; +Element.prototype.remove = function () { + if (!this.parent) return; + var k = this.parent.children.indexOf(this); + if (k >= 0) this.parent.children.splice(k, 1); +}; +Element.prototype.addEventListener = function (n, f) { + (this.listeners[n] = this.listeners[n] || []).push(f); +}; +Element.prototype.setAttribute = function (n, v) { this.attributes[n] = v; }; +Element.prototype.fire = function (n) { + (this.listeners[n] || []).forEach(function (f) { f({}); }); +}; +function find(root, role) { + var out = []; + (function walk(e) { + if (e.dataset && e.dataset.role === role) out.push(e); + (e.children || []).forEach(walk); + })(root); + return out; +} +var document = { createElement: function (tag) { return new Element(tag); } }; +var state = {}; +var handlers = {}; +var model = { + get: function (k) { return state[k]; }, + set: function (k, v) { state[k] = v; }, + save_changes: function () {}, + on: function (name, f) { (handlers[name] = handlers[name] || []).push(f); }, +}; +function push(k, v) { + state[k] = v; + (handlers["change:" + k] || []).forEach(function (f) { f(); }); +} +""" + +SCENARIO = r""" +function row(extra) { + var r = JSON.parse(JSON.stringify(BASE)); + for (var k in (extra || {})) r[k] = extra[k]; + return r; +} +var el = new Element("div"); +state = JSON.parse(JSON.stringify(STATE)); +state.rows = [row()]; +render({ model: model, el: el }); +var position = find(el, "position")[0]; +var value = find(el, "value")[0]; +function seen() { + return { + min: position.min, max: position.max, slider: position.value, + text: value.textContent, marked: value.classList.contains(OUTSIDE_CLASS), + title: value.title, + }; +} +var out = { slider_title: position.title, inside: seen() }; + +// A flipped plane: only the depth changed, the slider's ends did not. +push("rows", [row({ normal: FLIPPED, position: -12.0, depth: 28.0 })]); +out.flipped = seen(); + +// Past the far face, then before the near one. +push("rows", [row({ position: 46.0, depth: 46.0, outside: true })]); +out.past = seen(); +push("rows", [row({ position: -3.0, depth: -3.0, outside: true })]); +out.before = seen(); +push("rows", [row()]); +out.back = seen(); + +// A drag sends the depth, and an older echo does not move a held thumb. +position.value = "7.25"; position.fire("input"); +out.edit = JSON.parse(JSON.stringify(state.edit)); +out.text_while_dragging = value.textContent; +push("rows", [row({ position: 6.5, depth: 6.5 })]); +out.slider_while_dragging = position.value; +position.fire("change"); +push("rows", [row({ position: 7.25, depth: 7.25 })]); +out.slider_after_release = position.value; +JSON.stringify(out); +""" + +CLIPPING = { + "file": "clipping_planes.js", + "outside_class": "cellier-clipping-planes-outside", + "units": "Data", + "state": { + "title": "Clipping planes", + "axis_names": ["z", "y", "x"], + "error": "", + "edit": {}, + }, + "base": { + "id": "a", + "enabled": True, + "normal": [0, 0, 1], + "position": 12.0, + "facing": [2, 1], + "depth": 12.0, + "span": 40.0, + "outside": False, + }, + "flipped": [0, 0, -1], +} +RENDER = { + "file": "render_planes.js", + "outside_class": "cellier-render-planes-outside", + "units": "World", + "state": { + "title": "Render planes", + "error": "", + "blocked": "", + "can_add": True, + "edit": {}, + }, + "base": { + "id": "a", + "enabled": True, + "axes": ["z", "y", "x"], + "normal": [0, 0, 1], + "position": 12.0, + "extent_0": [None, None], + "extent_1": [-3, 8], + "facing": [2, 1], + "depth": 12.0, + "span": 40.0, + "outside": False, + }, + "flipped": [0, 0, -1], +} + + +def _run(case: dict) -> dict: + source = (STATIC / case["file"]).read_text() + assert "export default { render };" in source + source = source.replace("export default { render };", "") + setup = ( + f"var BASE = {json.dumps(case['base'])};\n" + f"var STATE = {json.dumps(case['state'])};\n" + f"var FLIPPED = {json.dumps(case['flipped'])};\n" + f"var OUTSIDE_CLASS = {json.dumps(case['outside_class'])};\n" + ) + # Kept in a name: a value outlives its engine as ``undefined``. + engine = QtQml.QJSEngine() + result = engine.evaluate(FAKE_DOM + source + setup + SCENARIO) + assert not result.isError(), ( + f"line {result.property('lineNumber').toInt()}: {result.toString()}" + ) + return json.loads(result.toString()) + + +@pytest.mark.parametrize("case", [CLIPPING, RENDER], ids=["clipping", "render"]) +def test_the_slider_shows_and_sends_the_depth(case, qapp): + from cellier.gui._clipping_planes import OUTSIDE_TOOLTIP, position_tooltip + + usual = position_tooltip(case["units"]) + out = _run(case) + # The front end says what the Python side says. + assert out["slider_title"] == usual + + def shown(slider, text, marked): + return { + "min": "0", + "max": "40", + "slider": slider, + "text": text, + "marked": marked, + "title": f"{OUTSIDE_TOOLTIP} {usual}" if marked else usual, + } + + assert out["inside"] == shown("12", "12.00", False) + assert out["flipped"] == shown("28", "28.00", False) + # Outside: pinned at the nearer end, the true depth, marked. + assert out["past"] == shown("40", "46.00", True) + assert out["before"] == shown("0", "-3.00", True) + assert out["back"] == shown("12", "12.00", False) + + assert out["edit"]["action"] == "depth" + assert (out["edit"]["index"], out["edit"]["value"]) == (0, 7.25) + assert out["text_while_dragging"] == "7.25" + assert out["slider_while_dragging"] == "7.25" + assert out["slider_after_release"] == "7.25" diff --git a/tests/gui/test_render_planes_controls.py b/tests/gui/test_render_planes_controls.py index 303e887c..14fb3cc4 100644 --- a/tests/gui/test_render_planes_controls.py +++ b/tests/gui/test_render_planes_controls.py @@ -112,7 +112,16 @@ def _make(toolkit, controller, visual, *, visual_ids=None, canvas_id=None, **ext def _act(widget, action, index=None, value=None) -> None: - """Do one thing as a user would.""" + """Do one thing as a user would. + + ``"position"`` puts the plane at position *value*. A control shows and + sends the depth into the box, which differs from the position by a + constant, so it is done as the depth of that position. + """ + if action == "position": + described = widget.editor.describe()[index] + action = "depth" + value = value - described["position"] + described["depth"] if hasattr(widget, "comm"): # anywidget: what the front end sends widget.edit = { "action": action, @@ -136,7 +145,7 @@ def _act(widget, action, index=None, value=None) -> None: row.enabled.setChecked(value) elif action == "facing": row.facing[tuple(value)].click() - elif action == "position": + elif action == "depth": row.position.setValue(value) elif action == "component": row.components[value[0]].setValue(value[1]) @@ -144,6 +153,34 @@ def _act(widget, action, index=None, value=None) -> None: row.sides[(value[0], value[1])].setValue(value[2]) elif action == "bounded": row.unbounded[(value[0], value[1])].setChecked(not value[2]) + elif action == "outline": + row.outline.enabled.setChecked(value) + elif action == "outline_color": + _pick_colour(row, value) + + +def _pick_colour(row, colour) -> None: + """Choose *colour* in a Qt row's colour dialog; ``None`` cancels it.""" + from qtpy.QtGui import QColor + from qtpy.QtWidgets import QColorDialog + + original = QColorDialog.getColor + QColorDialog.getColor = staticmethod( + lambda *args, **kwargs: QColor() if colour is None else QColor(colour) + ) + try: + row.outline.color.click() + finally: + QColorDialog.getColor = original + + +def _outline_shown(widget, index) -> tuple[bool, str]: + """``(ticked, #rrggbb)`` as the row shows them.""" + if hasattr(widget, "comm"): + row = widget.rows[index] + return bool(row["outline"]["enabled"]), row["outline_hex"] + row = widget.row(index) + return row.outline.enabled.isChecked(), row.outline.hex def _n_rows(widget) -> int: @@ -197,6 +234,7 @@ def test_a_row_shows_the_normal_the_position_and_the_extents(): "position": 3.0, "extent_0": [None, None], "extent_1": [-2.0, 6.0], + "outline": {"enabled": False, "color": [1.0, 1.0, 1.0, 1.0], "width": 2.0}, } @@ -371,6 +409,68 @@ def test_position_and_normal_edits_keep_the_frames_spin(controller, toolkit): assert widget.error == "" +@pytest.mark.parametrize("toolkit", ["qt", "anywidget"]) +def test_the_editor_describes_and_sets_the_depth(controller, toolkit): + scene, visual = _add_image(controller) + # z runs from -0.5 to 9.5; the plane is through the middle, at z == 4.5. + visual.render_planes = (_plane(controller, scene),) + widget = _make(toolkit, controller, visual) + editor = widget.editor + described = editor.describe()[0] + assert (described["depth"], described["span"]) == (5.0, 10.0) + assert described["outside"] is False + + editor.set_depth(0, 2.0) + # Along the normal only: the origin keeps its in-plane coordinates. + np.testing.assert_allclose(visual.render_planes[0].origin, (1.5, *CENTRE[1:])) + assert editor.describe()[0]["depth"] == pytest.approx(2.0) + + # A flip moves nothing and mirrors the depth. + _act(widget, "flip", 0) + np.testing.assert_allclose(visual.render_planes[0].origin, (1.5, *CENTRE[1:])) + described = editor.describe()[0] + assert described["position"] == pytest.approx(-1.5) + assert (described["depth"], described["span"]) == pytest.approx((8.0, 10.0)) + # Depth 0 is now the face z == 9.5, the one the normal points away from. + editor.set_depth(0, 0.0) + np.testing.assert_allclose(visual.render_planes[0].origin, (9.5, *CENTRE[1:])) + assert editor.describe()[0]["outside"] is False + + +@pytest.mark.parametrize("toolkit", ["qt", "anywidget"]) +def test_a_plane_outside_the_box_has_a_depth_past_the_span(controller, toolkit): + scene, visual = _add_image(controller) + visual.render_planes = (_plane(controller, scene),) + widget = _make(toolkit, controller, visual) + editor = widget.editor + + editor.set_depth(0, 12.0) + np.testing.assert_allclose(visual.render_planes[0].origin, (11.5, *CENTRE[1:])) + described = editor.describe()[0] + # The span is the box's, not widened to hold the plane. + assert (described["depth"], described["span"]) == pytest.approx((12.0, 10.0)) + assert described["outside"] is True + + editor.set_depth(0, -3.0) + described = editor.describe()[0] + assert (described["depth"], described["span"]) == pytest.approx((-3.0, 10.0)) + assert described["outside"] is True + + +def test_the_depth_of_a_tilted_plane_is_along_its_normal(controller): + scene, visual = _add_image(controller) + # Through the box's corner (-0.5, -0.5, 39.5), which is where a plane of + # this normal first touches the box. + corner = (-0.5, -0.5, 19.5) + visual.render_planes = (_plane(controller, scene, point=corner, normal=(1, 1, 0)),) + widget = _make("anywidget", controller, visual) + described = widget.editor.describe()[0] + assert described["depth"] == pytest.approx(0.0, abs=1e-9) + # The box is 10 along z and 20 along y. + assert described["span"] == pytest.approx(30 / np.sqrt(2)) + assert described["outside"] is False + + @pytest.mark.parametrize("toolkit", ["qt", "anywidget"]) def test_a_flip_changes_the_model_and_nothing_drawn(controller, toolkit): scene, visual = _add_image(controller) @@ -475,6 +575,72 @@ def test_a_refused_edit_shows_the_last_rows_and_the_error(controller, toolkit): assert widget.error == "" +def _qt(controller): + """A Qt control on an image whose z runs from -0.5 to 9.5, one plane.""" + scene, visual = _add_image(controller) + visual.render_planes = (_plane(controller, scene),) + widget = _make("qt", controller, visual) + return visual, widget, widget.row(0) + + +def test_the_qt_slider_runs_from_one_face_of_the_box_to_the_other(controller): + visual, _widget, row = _qt(controller) + assert (row.slider.value(), row.position.value()) == (500, 5.0) + row.slider.setValue(0) + np.testing.assert_allclose(visual.render_planes[0].origin, (-0.5, *CENTRE[1:])) + row.slider.setValue(row.slider.maximum()) + np.testing.assert_allclose(visual.render_planes[0].origin, (9.5, *CENTRE[1:])) + assert row.position.value() == 10.0 + assert not row.outside + + # Flipped, the slider runs the other way: along the new normal. + row.flip.click() + assert (row.slider.value(), row.position.value()) == (0, 0.0) + row.slider.setValue(250) + np.testing.assert_allclose(visual.render_planes[0].origin, (7.0, *CENTRE[1:])) + assert row.position.value() == 2.5 + + +def test_the_qt_number_is_the_depth_and_typing_it_moves_the_plane(controller): + visual, widget, row = _qt(controller) + row.flip.click() + # The plane z == 4.5, seen along -z: its position is -4.5, its depth 5. + assert widget.editor.rows[0]["position"] == -CENTRE[0] + assert row.position.value() == 5.0 + row.position.setValue(2.0) + np.testing.assert_allclose(visual.render_planes[0].origin, (7.5, *CENTRE[1:])) + assert row.slider.value() == 200 + + +def test_the_qt_row_marks_a_plane_outside_the_box(controller): + visual, widget, row = _qt(controller) + usual = row.position.toolTip() + assert row.slider.toolTip() == usual != "" + + # Moved from elsewhere (a gizmo, code) to 2 past the far face. + plane = visual.render_planes[0] + visual.render_planes = (with_position(plane, 11.5),) + assert row.outside + assert row.slider.value() == row.slider.maximum() # pinned + assert row.position.value() == 12.0 # the true depth + assert row.position.font().italic() + assert row.position.toolTip().startswith("The plane is outside") + assert widget.editor.describe()[0]["span"] == 10.0 + + # Before the near face: pinned at the other end, the number negative. + row.position.setValue(-3.0) + np.testing.assert_allclose(visual.render_planes[0].origin, (-3.5, *CENTRE[1:])) + assert (row.slider.value(), row.position.value()) == (0, -3.0) + assert row.outside + + # Touching the slider brings the plane back inside. + row.slider.setValue(100) + np.testing.assert_allclose(visual.render_planes[0].origin, (0.5, *CENTRE[1:])) + assert not row.outside + assert not row.position.font().italic() + assert row.position.toolTip() == usual + + def test_the_qt_row_names_the_planes_own_axes(controller): scene = controller.add_scene( coordinate_system=[("t", "time"), *spatial_axes(*AXES)], dim="3d", name="tzyx" @@ -513,7 +679,8 @@ def test_a_moved_plane_keeps_its_row_widgets(controller): ) assert widget.row(0) is row assert widget.row(0).slider is slider - assert row.position.value() == 6.0 + # The number is the depth: z == 6 is 6.5 into a box from z == -0.5. + assert row.position.value() == 6.5 def test_the_anywidget_sends_one_update_for_an_edit_delivered_twice(controller): @@ -522,7 +689,7 @@ def test_the_anywidget_sends_one_update_for_an_edit_delivered_twice(controller): _act(widget, "add") sent: list = [] widget.changed.connect(sent.append) - edit = {"action": "position", "index": 0, "value": 3.0, "serial": 99} + edit = {"action": "depth", "index": 0, "value": 3.0, "serial": 99} widget.edit = edit widget.edit = dict(edit, serial=100) # the same edit, delivered again assert len(sent) == 1 @@ -541,14 +708,44 @@ def test_the_anywidget_puts_back_what_a_host_overwrites(controller): assert len(widget.rows) == 1 +def test_the_anywidget_front_end_sends_the_depth(controller): + scene, visual = _add_image(controller) # z runs from -0.5 to 9.5 + visual.render_planes = (_plane(controller, scene),) + widget = _make("anywidget", controller, visual) + _act(widget, "flip", 0) + # Along -z, depth 2 is the plane z == 7.5. + _act(widget, "depth", 0, 2.0) + np.testing.assert_allclose(visual.render_planes[0].origin, (7.5, *CENTRE[1:])) + row = widget.rows[0] + assert (row["depth"], row["span"]) == pytest.approx((2.0, 10.0)) + assert row["outside"] is False + assert "low" not in row and "high" not in row + # Past the far face: the row says so, and its span is still the box's. + _act(widget, "depth", 0, 12.0) + np.testing.assert_allclose(visual.render_planes[0].origin, (-2.5, *CENTRE[1:])) + row = widget.rows[0] + assert (row["depth"], row["span"]) == pytest.approx((12.0, 10.0)) + assert row["outside"] is True + + +def test_the_anywidget_no_longer_takes_a_position(controller): + """The action a front end from before the depth would send.""" + scene, visual = _add_image(controller) + visual.render_planes = (_plane(controller, scene),) + widget = _make("anywidget", controller, visual) + before = visual.render_planes + widget.edit = {"action": "position", "index": 0, "value": 3.0, "serial": 9999} + assert visual.render_planes == before + + def test_the_anywidget_ignores_a_malformed_edit(controller): _scene, visual = _add_image(controller) widget = _make("anywidget", controller, visual) _act(widget, "add") before = visual.render_planes for action, index, value in [ - ("position", 5, 1.0), - ("position", "0", 1.0), + ("depth", 5, 1.0), + ("depth", "0", 1.0), ("facing", 0, [3, 1]), ("component", 0, 1.0), ("side", 0, [0, 2, 1.0]), @@ -950,3 +1147,107 @@ def test_the_planes_control_shows_the_plane_a_combo_added(controller, toolkit): assert _blocked(widget) == "" assert _n_rows(widget) == 1 assert widget.editor.rows[0]["position"] == CENTRE[0] + + +# -- a plane's outline (plane outline design 4.1) ------------------------------- + + +def test_every_edit_of_a_plane_keeps_its_outline(): + """The control edits planes, not rows: an edit must not drop a field it + has no entry for.""" + from cellier.gui._render_planes import with_side + from cellier.visuals import PlaneOutline + + outline = PlaneOutline(enabled=True, color=(1.0, 0.0, 0.0, 1.0), width=3.0) + plane = _free_plane(extent_0=(-4.0, 4.0)).model_copy(update={"outline": outline}) + bounds = [[0.0, 10.0], [0.0, 10.0], [0.0, 10.0]] + edits = { + "position": with_position(plane, 5.0), + "normal": with_normal(plane, (0.0, 1.0, 1.0)), + "flip": flipped(plane), + "side": with_side(plane, 0, 1, 6.0), + "bound": with_side_bounded(plane, 1, 0, True, bounds), + "unbound": with_side_bounded(plane, 0, 0, False, bounds), + } + for name, edited in edits.items(): + assert edited != plane, name + assert edited.outline == outline, name + assert edited.id == plane.id, name + + +@pytest.mark.parametrize("toolkit", ["qt", "anywidget"]) +async def test_a_row_draws_and_colours_its_plane_s_outline(controller, toolkit): + """Plane outline design 7: a box and a colour per row.""" + from cellier.visuals import PlaneOutline + + scene, visual = _add_image(controller, canvas=True) + faint = PlaneOutline(color=(1.0, 1.0, 1.0, 0.5), width=5.0) + plane = _plane(controller, scene, outline=faint) + controller.set_render_planes(visual.id, (plane,)) + widget = _make(toolkit, controller, visual) + assert _outline_shown(widget, 0) == (False, "#ffffff") + outlines = controller._render_manager._scenes[scene.id].plane_outlines + + _act(widget, "outline", 0, True) + assert visual.render_planes[0].outline.enabled is True + assert visual.render_planes[0].id == plane.id + assert len(outlines.drawn) == 1 # and it is drawn + + _act(widget, "outline_color", 0, "#ff8000") + outline = visual.render_planes[0].outline + # The colour is the one chosen; the alpha and the width, which the row + # has no entry for, are the plane's own. + assert outline.color == pytest.approx((1.0, 128 / 255, 0.0, 0.5)) + assert outline.width == 5.0 + assert outline.enabled is True + assert _outline_shown(widget, 0) == (True, "#ff8000") + # Nothing else of the plane moved. + assert visual.render_planes[0].origin == plane.origin + + _act(widget, "outline", 0, False) + assert visual.render_planes[0].outline.enabled is False + assert visual.render_planes[0].outline.color == pytest.approx(outline.color) + assert outlines.drawn == {} + assert widget.error == "" + widget.close() + + +@pytest.mark.parametrize("toolkit", ["qt", "anywidget"]) +async def test_an_outline_set_from_code_is_shown_and_sends_nothing(controller, toolkit): + from cellier.visuals import PlaneOutline + + scene, visual = _add_image(controller) + plane = _plane(controller, scene) + controller.set_render_planes(visual.id, (plane,)) + widget = _make(toolkit, controller, visual) + updates: list = [] + widget.changed.connect(updates.append) + + green = PlaneOutline(enabled=True, color=(0.0, 1.0, 0.0, 1.0)) + controller.set_render_planes( + visual.id, (plane.model_copy(update={"outline": green}),) + ) + assert _outline_shown(widget, 0) == (True, "#00ff00") + assert updates == [] + widget.close() + + +async def test_a_cancelled_colour_dialog_changes_nothing(controller): + scene, visual = _add_image(controller) + plane = _plane(controller, scene) + controller.set_render_planes(visual.id, (plane,)) + widget = _make("qt", controller, visual) + _act(widget, "outline_color", 0, None) + assert visual.render_planes == (plane,) + widget.close() + + +async def test_an_outline_colour_that_is_not_a_colour_is_refused(controller): + scene, visual = _add_image(controller) + plane = _plane(controller, scene) + controller.set_render_planes(visual.id, (plane,)) + widget = _make("anywidget", controller, visual) + _act(widget, "outline_color", 0, "red") + assert visual.render_planes == (plane,) + assert "#rrggbb" in widget.error + widget.close() diff --git a/tests/render/_plane_outline_pixels.py b/tests/render/_plane_outline_pixels.py new file mode 100644 index 00000000..ae32658c --- /dev/null +++ b/tests/render/_plane_outline_pixels.py @@ -0,0 +1,86 @@ +"""Where a plane's outline should be on the screen, for the pixel tests. + +A polygon in rendered ``(x, y, z)`` is projected with a camera's matrix and +every pixel centre is given its distance to the polygon's edge. Nothing +here is shared with the render layer. +""" + +from __future__ import annotations + +import numpy as np + + +def to_pixels(points: np.ndarray, camera, size: tuple[int, int]) -> np.ndarray: + """Project rendered ``(x, y, z)`` points to pixel ``(col, row)`` floats.""" + width, height = size + matrix = np.asarray(camera.camera_matrix, dtype=np.float64) + homogeneous = np.hstack([points, np.ones((len(points), 1))]) @ matrix.T + ndc = homogeneous[:, :3] / homogeneous[:, 3:4] + return np.column_stack( + [(ndc[:, 0] + 1.0) / 2.0 * width, (1.0 - ndc[:, 1]) / 2.0 * height] + ) + + +def signed_distance(polygon_px: np.ndarray, size: tuple[int, int]) -> np.ndarray: + """Per pixel centre: its distance to the polygon's edge, positive inside. + + ``(H, W)`` float. The polygon is convex. + """ + width, height = size + cols, rows = np.meshgrid(np.arange(width) + 0.5, np.arange(height) + 0.5) + pixels = np.stack([cols, rows], axis=-1) + count = len(polygon_px) + turn = sum( + polygon_px[i][0] * polygon_px[(i + 1) % count][1] + - polygon_px[(i + 1) % count][0] * polygon_px[i][1] + for i in range(count) + ) + sign = 1.0 if turn > 0 else -1.0 + inside = np.full((height, width), np.inf) + nearest = np.full((height, width), np.inf) + for i in range(count): + a, b = polygon_px[i], polygon_px[(i + 1) % count] + edge = b - a + length = np.linalg.norm(edge) + rel = pixels - a + across = sign * (edge[0] * rel[..., 1] - edge[1] * rel[..., 0]) / length + inside = np.minimum(inside, across) + along = np.clip((rel @ edge) / length**2, 0.0, 1.0) + closest = a + along[..., None] * edge + nearest = np.minimum(nearest, np.linalg.norm(pixels - closest, axis=-1)) + return np.where(inside >= 0.0, nearest, -nearest) + + +def edge_distance(polygon: np.ndarray, camera, size: tuple[int, int]) -> np.ndarray: + """:func:`signed_distance` of a polygon given in rendered space.""" + return signed_distance(to_pixels(polygon, camera, size), size) + + +def is_red(frame: np.ndarray) -> np.ndarray: + """Pixels of a pure red line (the tests' outline colour).""" + return (frame[..., 0] > 200) & (frame[..., 1] < 70) & (frame[..., 2] < 70) + + +def line_shares(frame: np.ndarray, distance: np.ndarray, width: float) -> dict: + """How much of a red line of *width* pixels along the edge is drawn. + + The line's two halves are counted apart: the half inside the polygon is + over the plane, which is where a plane could hide its own outline. + + Returns + ------- + dict + ``inner`` and ``outer``: the share of each half's pixels that are + red (its core, clear of the anti-aliased rim); ``stray``: red + pixels more than a pixel outside the line. + """ + red = is_red(frame) + core = width / 2.0 - 0.75 + inner = (distance > 0.1) & (distance < core) + outer = (distance < -0.1) & (distance > -core) + return { + "inner": float(red[inner].mean()), + "outer": float(red[outer].mean()), + "stray": int((red & (np.abs(distance) > width / 2.0 + 1.0)).sum()), + "pixels": int(inner.sum() + outer.sum()), + } diff --git a/tests/render/test_plane_gizmo.py b/tests/render/test_plane_gizmo.py index b8a1546b..98b07043 100644 --- a/tests/render/test_plane_gizmo.py +++ b/tests/render/test_plane_gizmo.py @@ -201,6 +201,89 @@ async def test_a_rotate_drag_turns_the_normal_about_the_point(make_rig): assert not np.allclose(end.normal, normal, atol=1e-3) +def _look(rig, direction) -> None: + """Look at the volume's centre from *direction* (rendered ``xyz``).""" + camera = rig.view.camera + centre = np.array(CENTRE) + distance = float(np.linalg.norm(np.asarray(camera.world.position) - centre)) + direction = np.asarray(direction, dtype=np.float64) + camera.local.position = centre + direction / np.linalg.norm(direction) * distance + camera.look_at(tuple(centre)) + rig.frame(n=2) + + +def _arrow(gizmo) -> np.ndarray: + """From the gizmo's centre out to the arrow of axis 0, in the world.""" + widget = gizmo.gizmo + arrow = np.asarray(widget._translate1_children[0].world.position) + return arrow - np.asarray(widget.world.position) + + +#: Camera directions in front of the plane of normal ``+x`` and behind it, +#: oblique so that no handle is seen end-on. +_FRONT = (0.5, 0.6, 0.62) +_BEHIND = (-0.5, 0.6, 0.62) + + +@pytest.mark.parametrize("direction", [_FRONT, _BEHIND]) +async def test_the_arrow_is_the_normal_whichever_side_the_camera_is( + make_rig, direction +): + """pygfx would turn every handle that points away toward the camera.""" + rig = make_rig() + await rig.start() + gizmo = rig.add_gizmo() + _look(rig, direction) + assert (gizmo.gizmo.gizmo_scale > 0).all() + _point, normal = gizmo.pose() + assert _arrow(gizmo) @ np.asarray(normal) > 0 + # The in-plane handles are the proxy's own +y and +z. + axis_0, axis_1, _scale = gizmo.frame() + widget = gizmo.gizmo + for dim, axis in ((1, axis_0), (2, axis_1)): + out = np.asarray(widget._translate1_children[dim].world.position) + assert (out - np.asarray(widget.world.position)) @ np.asarray(axis) > 0 + + +@pytest.mark.parametrize("direction", [_FRONT, _BEHIND]) +async def test_the_arrow_turns_over_when_the_normal_is_reversed(make_rig, direction): + rig = make_rig() + await rig.start() + gizmo = rig.add_gizmo() + _look(rig, direction) + before = _arrow(gizmo) + gizmo.set_pose(CENTRE, (-1.0, 0.0, 0.0)) + rig.frame(n=2) + after = _arrow(gizmo) + # The opposite direction. Not the same length: the gizmo is sized on + # screen, and an arrow toward the camera is drawn longer than one away. + np.testing.assert_allclose( + after / np.linalg.norm(after), -before / np.linalg.norm(before), atol=1e-6 + ) + assert after @ np.array([-1.0, 0.0, 0.0]) > 0 + + +async def test_a_drag_along_an_arrow_that_points_away_follows_the_pointer(make_rig): + rig = make_rig() + await rig.start() + gizmo = rig.add_gizmo() + _look(rig, _BEHIND) + start, normal = gizmo.pose() + assert _arrow(gizmo) @ (np.asarray(rig.view.camera.world.position) - CENTRE) < 0 + assert gizmo.gizmo._translate1_children[0].visible + x, y = rig.handle(gizmo, "_translate1_children", 0) + centre = rig.screen(gizmo.gizmo.world.position) + out = np.array([x - centre[0], y - centre[1]]) + out = 30 * out / np.linalg.norm(out) + rig.send("pointer_down", x, y) + assert (rig.events[0].handle_kind, rig.events[0].handle_axis) == ("translate", 0) + rig.send("pointer_move", x + out[0], y + out[1]) + rig.send("pointer_up", x + out[0], y + out[1], buttons=()) + moved = np.asarray(gizmo.pose()[0]) - np.asarray(start) + # Out along the arrow is along the normal, though it points away. + assert moved @ np.asarray(normal) > 1e-3 + + async def test_a_click_without_a_move_is_a_start_and_an_end(make_rig): rig = make_rig() await rig.start() @@ -392,7 +475,15 @@ def test_the_private_pygfx_names_the_gizmo_relies_on_exist(): "_rotate_children", ): assert hasattr(inner, name), name - for name in ("_update_visibility", "_highlight", "update_gizmo", "process_event"): + assert inner.gizmo_scale.shape == (3,) + assert inner._object_to_control is not None + for name in ( + "_update_visibility", + "_update_gizmo_transform", + "_highlight", + "update_gizmo", + "process_event", + ): assert callable(getattr(gfx.TransformGizmo, name)), name assert isinstance(CAPTURES, dict) # A handle's axis: an index, or a pair for a two-axis translate handle. diff --git a/tests/render/test_plane_outline_clipping.py b/tests/render/test_plane_outline_clipping.py new file mode 100644 index 00000000..ae5667c0 --- /dev/null +++ b/tests/render/test_plane_outline_clipping.py @@ -0,0 +1,498 @@ +"""The outline of a clipping plane: its cut face (plane outline design 3.2). + +The plane, cut by the visual's data box and by the visual's other +clipping planes. Most tests read the lines the scene holds; the last few +draw. +""" + +from __future__ import annotations + +from uuid import UUID + +import numpy as np +import pytest + +from cellier.data import ( + GraphMemoryStore, + ImageMemoryStore, + LinesMemoryStore, + MeshMemoryStore, + PointsMemoryStore, +) +from cellier.scene import spatial_axes +from cellier.visuals import ( + ClippingPlane, + InMemoryImageSingleAppearance, + MeshFlatAppearance, + PlaneOutline, + RenderPlane, +) +from tests._meshes import uv_sphere +from tests.render import _plane_outline_pixels as px +from tests.render import _plane_rig as rig_module +from tests.render import _planes as h +from tests.render.conftest import drain_loading + +SHAPE = (8, 12, 16) +RED = PlaneOutline(enabled=True, color=(1.0, 0.0, 0.0, 1.0), width=4.0) +BLUE = PlaneOutline(enabled=True, color=(0.0, 0.0, 1.0, 1.0), width=2.0) +#: The corners of a box from the origin to (8, 12, 16), data ``(z, y, x)``. +CORNERS = np.array( + [[z, y, x] for z in (0.0, 8.0) for y in (0.0, 12.0) for x in (0.0, 16.0)], + dtype=np.float32, +) + + +def _store(controller, visual): + return controller.get_data_store(UUID(str(visual.data_store_id))) + + +def _clip(controller, visual, x: float, outline=RED, **kwargs) -> ClippingPlane: + """The clipping plane keeping ``x >= x``, on the store's last three axes.""" + system = _store(controller, visual).data_coordinate_systems[0] + return ClippingPlane.from_point_normal( + system, (0, 0, x), (0, 0, 1), axes=("z", "y", "x"), outline=outline, **kwargs + ) + + +def _image(controller, scene, *, shape=SHAPE, render_mode="mip", **kwargs): + store = ImageMemoryStore(data=np.ones(shape, dtype=np.float32), name="image") + return controller.add_image( + data=store, + scene_id=scene.id, + single=InMemoryImageSingleAppearance( + render_mode=render_mode, color_map="gray", clim=(0.0, 2.0) + ), + **kwargs, + ) + + +def _drawn(controller, scene) -> dict: + return controller._render_manager._scenes[scene.id].plane_outlines.drawn + + +def _same(vertices, expected) -> bool: + expected = np.asarray(expected, dtype=float) + if vertices.shape != expected.shape: + return False + return any( + np.abs(vertices - np.roll(candidate, shift, axis=0)).max() < 1e-5 + for candidate in (expected, expected[::-1]) + for shift in range(len(expected)) + ) + + +def _x_face(x: float, low=(-0.5, -0.5), high=(11.5, 7.5)): + """The face at *x* of a box over y and z, in rendered (x, y, z).""" + (y0, z0), (y1, z1) = low, high + return [(x, y0, z0), (x, y1, z0), (x, y1, z1), (x, y0, z1)] + + +async def _loaded(controller, scene) -> None: + controller.fit_camera(scene.id) + controller.reslice_all() + await drain_loading(controller) + + +# -- a volume ---------------------------------------------------------------------- + + +async def test_an_outlined_clipping_plane_has_a_line_round_its_cut_face(controller): + scene = controller.add_scene(dim="3d", name="scene") + visual = _image(controller, scene) + controller.add_canvas(scene_id=scene.id) + assert _drawn(controller, scene) == {} + + plane = _clip(controller, visual, 4.0) + controller.set_clipping_planes(visual.id, (plane,)) + drawn = _drawn(controller, scene) + assert list(drawn) == [("clipping", plane.id, 0)] + polygon = drawn[("clipping", plane.id, 0)] + # The voxel-edge box of an 8 x 12 x 16 volume, cut at x = 4. + assert _same(polygon.vertices, _x_face(4.0)) + assert polygon.color == RED.color + assert polygon.width == RED.width + + +async def test_a_clipping_plane_s_outline_follows_its_switches(controller): + scene = controller.add_scene(dim="3d", name="scene") + visual = _image(controller, scene) + controller.add_canvas(scene_id=scene.id) + plane = _clip(controller, visual, 4.0) + + controller.set_clipping_planes(visual.id, (plane,)) + assert len(_drawn(controller, scene)) == 1 + off = RED.model_copy(update={"enabled": False}) + controller.set_clipping_planes( + visual.id, (plane.model_copy(update={"outline": off}),) + ) + assert _drawn(controller, scene) == {} + # A disabled plane cuts nothing and has no face. + controller.set_clipping_planes( + visual.id, (plane.model_copy(update={"enabled": False}),) + ) + assert _drawn(controller, scene) == {} + controller.set_clipping_planes(visual.id, (plane,)) + + visual.appearance.visible = False + assert _drawn(controller, scene) == {} + visual.appearance.visible = True + assert len(_drawn(controller, scene)) == 1 + + # B-P8: 3D views only. + controller.set_displayed_axes(scene.id, (1, 2)) + assert _drawn(controller, scene) == {} + controller.set_displayed_axes(scene.id, (0, 1, 2)) + assert len(_drawn(controller, scene)) == 1 + + +async def test_the_cut_face_follows_the_plane_and_its_style(controller): + scene = controller.add_scene(dim="3d", name="scene") + visual = _image(controller, scene) + controller.add_canvas(scene_id=scene.id) + plane = _clip(controller, visual, 4.0) + controller.set_clipping_planes(visual.id, (plane,)) + outlines = controller._render_manager._scenes[scene.id].plane_outlines + line = outlines.line(("clipping", plane.id, 0)) + + moved = _clip(controller, visual, 9.0).model_copy(update={"id": plane.id}) + controller.set_clipping_planes(visual.id, (moved,)) + (polygon,) = _drawn(controller, scene).values() + assert _same(polygon.vertices, _x_face(9.0)) + assert outlines.line(("clipping", plane.id, 0)) is line + + controller.set_clipping_planes( + visual.id, (moved.model_copy(update={"outline": BLUE}),) + ) + assert tuple(line.material.color) == pytest.approx(BLUE.color) + assert line.material.thickness == BLUE.width + + # Past the box: no face. + controller.set_clipping_planes( + visual.id, + (_clip(controller, visual, 40.0).model_copy(update={"id": plane.id}),), + ) + assert _drawn(controller, scene) == {} + + +async def test_two_clipping_planes_each_end_at_the_other(controller): + scene = controller.add_scene(dim="3d", name="scene") + visual = _image(controller, scene) + controller.add_canvas(scene_id=scene.id) + system = _store(controller, visual).data_coordinate_systems[0] + along_x = _clip(controller, visual, 4.0) + # Kept where z >= 3. + along_z = ClippingPlane.from_point_normal( + system, (3.0, 0, 0), (1, 0, 0), outline=BLUE + ) + controller.set_clipping_planes(visual.id, (along_x, along_z)) + drawn = _drawn(controller, scene) + assert _same( + drawn[("clipping", along_x.id, 0)].vertices, + _x_face(4.0, low=(-0.5, 3.0), high=(11.5, 7.5)), + ) + assert _same( + drawn[("clipping", along_z.id, 0)].vertices, + [(4.0, -0.5, 3.0), (15.5, -0.5, 3.0), (15.5, 11.5, 3.0), (4.0, 11.5, 3.0)], + ) + # One of them without an outline still ends the other. + controller.set_clipping_planes( + visual.id, (along_x, along_z.model_copy(update={"outline": PlaneOutline()})) + ) + drawn = _drawn(controller, scene) + assert list(drawn) == [("clipping", along_x.id, 0)] + assert drawn[("clipping", along_x.id, 0)].vertices[:, 2].min() == pytest.approx(3.0) + + +async def test_a_render_plane_and_a_clipping_plane_are_outlined_together(controller): + """Plane mode: the render plane's outline ends at the cut, and the cut + face has an outline of its own.""" + scene = controller.add_scene(dim="3d", name="scene") + world = h.world_of(controller, scene) + render_plane = RenderPlane.from_point_normal( + world, (3.0, 5.0, 7.0), (1, 0, 0), axes=("z", "y", "x"), outline=BLUE + ) + visual = _image( + controller, scene, render_mode="plane", render_planes=(render_plane,) + ) + controller.add_canvas(scene_id=scene.id) + clip = _clip(controller, visual, 4.0) + controller.set_clipping_planes(visual.id, (clip,)) + + drawn = _drawn(controller, scene) + assert list(drawn) == [("render", render_plane.id, 0), ("clipping", clip.id, 0)] + assert _same( + drawn[("render", render_plane.id, 0)].vertices, + [(4.0, -0.5, 3.0), (15.5, -0.5, 3.0), (15.5, 11.5, 3.0), (4.0, 11.5, 3.0)], + ) + assert _same(drawn[("clipping", clip.id, 0)].vertices, _x_face(4.0)) + + +async def test_a_clipping_plane_shared_by_two_visuals_has_one_outline(controller): + """What the ortho linker does: the same tuple, the same ids.""" + scene = controller.add_scene(dim="3d", name="scene") + first = _image(controller, scene) + second = _image(controller, scene) + small = _image(controller, scene, shape=(8, 6, 16)) + controller.add_canvas(scene_id=scene.id) + plane = _clip(controller, first, 4.0) + for visual in (first, second): + # Each store has a coordinate system of its own; the id is shared. + same = _clip(controller, visual, 4.0).model_copy(update={"id": plane.id}) + controller.set_clipping_planes(visual.id, (same,)) + assert list(_drawn(controller, scene)) == [("clipping", plane.id, 0)] + + # A third visual with another box: the face is another polygon. + same_plane = _clip(controller, small, 4.0).model_copy(update={"id": plane.id}) + controller.set_clipping_planes(small.id, (same_plane,)) + drawn = _drawn(controller, scene) + assert list(drawn) == [("clipping", plane.id, 0), ("clipping", plane.id, 1)] + assert _same( + drawn[("clipping", plane.id, 1)].vertices, _x_face(4.0, high=(5.5, 7.5)) + ) + + +async def test_a_slider_moves_a_cut_across_a_sliced_axis(controller): + """Kept where t + z >= 5, in the zyx view of a tzyx image: the face is + at z = 5 - t, and moves when the time slider does.""" + scene = controller.add_scene( + coordinate_system=[("t", "time"), *spatial_axes("z", "y", "x")], + dim="3d", + name="scene", + ) + visual = _image(controller, scene, shape=(4, *SHAPE)) + controller.add_canvas(scene_id=scene.id) + system = _store(controller, visual).data_coordinate_systems[0] + plane = ClippingPlane.from_point_normal( + system, (5, 0, 0, 0), (1, 1, 0, 0), outline=RED + ) + controller.set_clipping_planes(visual.id, (plane,)) + + for t, z in ((1, 4.0), (3, 2.0), (0, 5.0)): + controller.update_slice_indices(scene.id, {0: t}) + await drain_loading(controller) + (polygon,) = _drawn(controller, scene).values() + assert _same( + polygon.vertices, + [(-0.5, -0.5, z), (15.5, -0.5, z), (15.5, 11.5, z), (-0.5, 11.5, z)], + ), t + + +async def test_a_pyramid_s_cut_face_is_outlined(controller, tmp_path): + """A multiscale image in a volume mode, before and after loading.""" + spec = rig_module.ISO + rig = await rig_module.make_rig( + controller, + tmp_path, + spec, + lambda world, centre: [], + single={"render_mode": "mip"}, + appearance={"force_level": 1}, + ) + plane = _clip(controller, rig.visual, 30.0) + controller.set_clipping_planes(rig.visual.id, (plane,)) + z, y, _x = spec.shape0 + expected = _x_face(30.0, high=(y - 0.5, z - 0.5)) + assert _same( + _drawn(controller, rig.scene)[("clipping", plane.id, 0)].vertices, expected + ) + await rig.settle() + assert _same( + _drawn(controller, rig.scene)[("clipping", plane.id, 0)].vertices, expected + ) + + +# -- geometry ---------------------------------------------------------------------- + + +def _geometry_visual(controller, scene, kind): + if kind == "points": + return controller.add_points( + data=PointsMemoryStore(positions=CORNERS), scene_id=scene.id + ) + if kind == "lines": + return controller.add_lines( + data=LinesMemoryStore(positions=CORNERS), scene_id=scene.id + ) + return controller.add_graph( + data=GraphMemoryStore( + positions=CORNERS, edges=np.stack([np.arange(7), np.arange(1, 8)], axis=1) + ), + scene_id=scene.id, + ) + + +@pytest.mark.parametrize("kind", ["points", "lines", "graph"]) +async def test_a_geometry_visual_s_cut_face_is_cut_by_the_box_of_its_vertices( + controller, kind +): + """No shape, so the box is that of the vertices drawn: from the origin + to (16, 12, 8) in rendered (x, y, z).""" + scene = controller.add_scene(dim="3d", name="scene") + visual = _geometry_visual(controller, scene, kind) + controller.add_canvas(scene_id=scene.id) + plane = _clip(controller, visual, 4.0) + controller.set_clipping_planes(visual.id, (plane,)) + await _loaded(controller, scene) + + (polygon,) = _drawn(controller, scene).values() + assert _same(polygon.vertices, _x_face(4.0, low=(0.0, 0.0), high=(12.0, 8.0))) + + +async def test_the_cut_face_follows_the_points_that_arrive(controller): + scene = controller.add_scene(dim="3d", name="scene") + visual = _geometry_visual(controller, scene, "points") + controller.add_canvas(scene_id=scene.id) + controller.set_clipping_planes(visual.id, (_clip(controller, visual, 4.0),)) + await _loaded(controller, scene) + + # Twice as far along y. + _store(controller, visual).positions = CORNERS * np.array( + [1.0, 2.0, 1.0], dtype=np.float32 + ) + await drain_loading(controller) + (polygon,) = _drawn(controller, scene).values() + assert _same(polygon.vertices, _x_face(4.0, low=(0.0, 0.0), high=(24.0, 8.0))) + + +async def test_the_cut_face_follows_a_geometry_visual_s_transform(controller): + scene = controller.add_scene(dim="3d", name="scene") + visual = _geometry_visual(controller, scene, "points") + controller.add_canvas(scene_id=scene.id) + controller.set_clipping_planes(visual.id, (_clip(controller, visual, 4.0),)) + await _loaded(controller, scene) + + # World x is twice data x, then shifted by 10: the cut at data x = 4 + # is at world x = 18. + visual.transform = h.scale_translation_transform( + controller, + scene.id, + _store(controller, visual), + (1.0, 1.0, 2.0), + (0.0, 0.0, 10.0), + ) + await drain_loading(controller) + (polygon,) = _drawn(controller, scene).values() + assert _same(polygon.vertices, _x_face(18.0, low=(0.0, 0.0), high=(12.0, 8.0))) + + +async def test_a_mesh_s_cut_face_is_cut_by_the_store_s_extent(controller): + """A sphere of radius 6 about (8, 8, 8): the box is 2 to 14 on each axis, + as the mesh's bounding-box wireframe is.""" + positions, indices = uv_sphere( + radius=6.0, centre=(8.0, 8.0, 8.0), n_lat=8, n_lon=16 + ) + scene = controller.add_scene(dim="3d", name="scene") + visual = controller.add_mesh( + data=MeshMemoryStore(positions=positions, indices=indices), + scene_id=scene.id, + appearance=MeshFlatAppearance(), + ) + controller.add_canvas(scene_id=scene.id) + controller.set_clipping_planes(visual.id, (_clip(controller, visual, 8.0),)) + await _loaded(controller, scene) + (polygon,) = _drawn(controller, scene).values() + assert _same(polygon.vertices, _x_face(8.0, low=(2.0, 2.0), high=(14.0, 14.0))) + + +async def test_geometry_clipped_by_the_read_has_no_outline(controller): + """A plane across a sliced axis cuts points as they are read (they may + be flattened along that axis): there is no one cut face.""" + scene = controller.add_scene( + coordinate_system=[("t", "time"), *spatial_axes("z", "y", "x")], + dim="3d", + name="scene", + ) + positions = np.hstack([np.zeros((8, 1), dtype=np.float32), CORNERS]) + visual = controller.add_points( + data=PointsMemoryStore(positions=positions), scene_id=scene.id + ) + controller.add_canvas(scene_id=scene.id) + system = _store(controller, visual).data_coordinate_systems[0] + across_time = ClippingPlane.from_point_normal( + system, (-1, 0, 0, 4.0), (1, 0, 0, 1), outline=RED + ) + controller.set_clipping_planes(visual.id, (across_time,)) + await _loaded(controller, scene) + assert _drawn(controller, scene) == {} + + # A plane on the displayed axes alone is outlined as usual. + controller.set_clipping_planes(visual.id, (_clip(controller, visual, 4.0),)) + await drain_loading(controller) + assert len(_drawn(controller, scene)) == 1 + + +# -- on screen --------------------------------------------------------------------- + +SIZE = (320, 320) +OBLIQUE = (0.6, -0.5, 1.0) + + +async def test_the_cut_face_is_where_a_plane_drawn_there_ends(controller): + """Agreement with the shader: a render plane is drawn a hair inside an + oblique clipping plane, with a second clipping plane across both. The + pixels it is drawn on are the clipping plane's polygon.""" + shape = (20, 28, 36) + scene = controller.add_scene(dim="3d", name="scene") + world = h.world_of(controller, scene) + centre = (np.array(shape) - 1) / 2 + 0.3 + normal = np.array(OBLIQUE) / np.linalg.norm(OBLIQUE) + store = ImageMemoryStore(data=h.image_data(shape), name="image") + visual = controller.add_image( + data=store, + scene_id=scene.id, + single=InMemoryImageSingleAppearance( + render_mode="plane", color_map="gray", clim=(0.0, 1.0) + ), + render_planes=(h.plane_zyx(world, centre + 1e-3 * normal, OBLIQUE),), + ) + controller.add_canvas(scene_id=scene.id, canvas_size=SIZE) + system = _store(controller, visual).data_coordinate_systems[0] + # The render plane is on the kept side of this one. + cut = ClippingPlane.from_point_normal(system, centre, OBLIQUE) + other = ClippingPlane.from_point_normal(system, (6.0, 0.0, 20.0), (0.0, 0.2, -1.0)) + controller.set_clipping_planes(visual.id, (cut, other)) + await _loaded(controller, scene) + shot = h.shoot(controller, scene, size=SIZE) + + controller.set_clipping_planes( + visual.id, (cut.model_copy(update={"outline": RED}), other) + ) + polygon = _drawn(controller, scene)[("clipping", cut.id, 0)].vertices + distance = px.edge_distance(polygon, shot.camera, SIZE) + inside = distance > 0.0 + differ = inside != shot.drawn + assert inside.sum() > 5000 + # A few more than for a render plane's own outline: the plane drawn is + # a thousandth of a voxel off the one outlined. + assert differ.sum() <= 6, int(differ.sum()) + assert not (np.abs(distance[differ]) > 0.5).any() + + +@pytest.mark.parametrize("render_mode", ["iso", "mip"]) +async def test_the_line_is_whole_on_a_cut_face(controller, render_mode): + """An isosurface writes depth on its cut face, where the line is; a + maximum projection writes none. Both halves of the line are drawn.""" + shape = (20, 28, 36) + size = (480, 480) + scene = controller.add_scene(dim="3d", name="scene") + visual = _image(controller, scene, shape=shape, render_mode=render_mode) + controller.add_canvas(scene_id=scene.id, canvas_size=size) + system = _store(controller, visual).data_coordinate_systems[0] + centre = (np.array(shape) - 1) / 2 + 0.3 + # The normal points away from the camera below: the cut face is seen. + cut = ClippingPlane.from_point_normal(system, centre, OBLIQUE, outline=RED) + controller.set_clipping_planes(visual.id, (cut,)) + await _loaded(controller, scene) + view_dir = tuple(np.array(OBLIQUE[::-1]) + np.array([0.3, 0.2, 0.0])) + shot = h.shoot(controller, scene, size=size, view_dir=view_dir) + + polygon = _drawn(controller, scene)[("clipping", cut.id, 0)].vertices + shares = px.line_shares( + shot.frame, px.edge_distance(polygon, shot.camera, size), RED.width + ) + assert shot.drawn.sum() > 20000 + assert shares["pixels"] > 800, shares + assert shares["inner"] > 0.97, shares + assert shares["outer"] > 0.97, shares + assert shares["stray"] == 0, shares diff --git a/tests/render/test_plane_outline_controller.py b/tests/render/test_plane_outline_controller.py new file mode 100644 index 00000000..ca0ef8c9 --- /dev/null +++ b/tests/render/test_plane_outline_controller.py @@ -0,0 +1,233 @@ +"""Plane outlines on the controller: a change of an outline is not a move. + +Plane outline design (``plans/plane_outline_design.md``), section 4.3. An +outline is a field of its plane, so a new outline is a new tuple; the +controller must not take it for a new pose. +""" + +from __future__ import annotations + +from uuid import uuid4 + +import pytest + +from cellier.visuals import ( + ClippingPlane, + MultiscaleImageAppearance, + MultiscaleImageRenderConfig, + MultiscaleImageSingleAppearance, + PlaneOutline, + RenderPlane, +) +from tests import _plane_fixtures as fx +from tests._gpu_budget import SMALL_BUDGETS +from tests.render.conftest import drain_loading + +RED = PlaneOutline(enabled=True, color=(1.0, 0.0, 0.0, 1.0), width=3.0) +SETTLE_S = 0.03 + + +async def _image(controller, tmp_path, *, render_mode="plane"): + """A loaded multiscale image with one render plane and one clipping plane.""" + controller._render_manager.config.scheduler.dims_settle_s = SETTLE_S + fx.write_pyramid(tmp_path, fx.ISO) + store, _ = fx.open_pyramid(tmp_path, fx.ISO) + scene = controller.add_scene(dim="3d", name="scene") + world = controller._model.scenes[scene.id].dims.world_coordinate_system + visual = controller.add_image_multiscale( + data=store, + scene_id=scene.id, + appearance=MultiscaleImageAppearance(force_level=1), + render_config=MultiscaleImageRenderConfig(**SMALL_BUDGETS, block_size=16), + single=MultiscaleImageSingleAppearance( + color_map="viridis", clim=(0.0, 65535.0), render_mode=render_mode + ), + render_planes=( + RenderPlane.from_point_normal( + world, (60, 0, 0), (1, 0, 0), axes=("z", "y", "x") + ), + ), + clipping_planes=( + ClippingPlane.from_point_normal( + store.data_coordinate_systems[0], + (10, 0, 0), + (1, 0, 0), + axes=("z", "y", "x"), + ), + ), + ) + controller.add_canvas(scene_id=scene.id, canvas_size=(200, 125)) + controller.fit_camera(scene.id) + controller.reslice_all() + await drain_loading(controller) + return scene, visual + + +class _Spy: + """What the controller did about one change of a visual's planes.""" + + def __init__(self, controller, visual, monkeypatch) -> None: + self.changed: list = [] + self.interactions: list = [] + self.reslices: list = [] + self.draws: list = [] + self.ticks: list = [] + owner = uuid4() + controller.on_render_planes_changed( + visual.id, self.changed.append, owner_id=owner + ) + controller.on_clipping_planes_changed( + visual.id, self.changed.append, owner_id=owner + ) + controller.on_plane_interaction( + visual.id, self.interactions.append, owner_id=owner + ) + reslice = controller.reslice_visual + monkeypatch.setattr( + controller, + "reslice_visual", + lambda vid: (self.reslices.append(vid), reslice(vid))[1], + ) + draw = controller._request_draw_for_visual + monkeypatch.setattr( + controller, + "_request_draw_for_visual", + lambda vid: (self.draws.append(vid), draw(vid))[1], + ) + driver = controller._plane_interaction_driver + tick = driver.tick + monkeypatch.setattr( + driver, + "tick", + lambda *args, **kwargs: (self.ticks.append(args), tick(*args, **kwargs))[1], + ) + + +def _planes(visual, kind): + return visual.render_planes if kind == "render" else visual.clipping_planes + + +def _set(controller, visual, kind, planes) -> None: + if kind == "render": + controller.set_render_planes(visual.id, planes) + else: + controller.set_clipping_planes(visual.id, planes) + + +@pytest.mark.parametrize("kind", ["render", "clipping"]) +async def test_a_new_outline_is_announced_and_drawn_and_reads_nothing( + controller, tmp_path, monkeypatch, kind +): + _scene, visual = await _image(controller, tmp_path) + spy = _Spy(controller, visual, monkeypatch) + plane = _planes(visual, kind)[0] + + _set(controller, visual, kind, (plane.model_copy(update={"outline": RED}),)) + + assert _planes(visual, kind)[0].outline == RED + assert _planes(visual, kind)[0].id == plane.id + assert len(spy.changed) == 1 + announced = getattr(spy.changed[0], f"{kind}_planes") + assert announced[0].outline == RED + assert spy.reslices == [] + assert spy.ticks == [] + assert spy.interactions == [] + assert spy.draws == [visual.id] + + +@pytest.mark.parametrize("kind", ["render", "clipping"]) +async def test_the_render_visual_is_given_the_outline(controller, tmp_path, kind): + scene, visual = await _image(controller, tmp_path) + plane = _planes(visual, kind)[0] + _set(controller, visual, kind, (plane.model_copy(update={"outline": RED}),)) + gfx_visual = controller._render_manager._scenes[scene.id].get_visual(visual.id) + assert getattr(gfx_visual, f"{kind}_planes")[0].outline == RED + + +@pytest.mark.parametrize("kind", ["render", "clipping"]) +async def test_a_move_still_reads_again(controller, tmp_path, monkeypatch, kind): + """The other half of the rule: a pose change is treated as before.""" + _scene, visual = await _image(controller, tmp_path) + spy = _Spy(controller, visual, monkeypatch) + plane = _planes(visual, kind)[0] + if kind == "render": + moved = plane.model_copy(update={"origin": (70.0, 0.0, 0.0)}) + else: + moved = plane.model_copy( + update={ + "plane": plane.plane.model_copy( + update={"offset": plane.plane.offset + 10.0} + ) + } + ) + + _set(controller, visual, kind, (moved,)) + + assert spy.reslices == [visual.id] + assert len(spy.ticks) == 1 + await drain_loading(controller) + + +@pytest.mark.parametrize("kind", ["render", "clipping"]) +async def test_a_move_with_a_new_outline_is_a_move( + controller, tmp_path, monkeypatch, kind +): + _scene, visual = await _image(controller, tmp_path) + spy = _Spy(controller, visual, monkeypatch) + plane = _planes(visual, kind)[0] + _set( + controller, + visual, + kind, + (plane.model_copy(update={"outline": RED, "enabled": False}),), + ) + assert spy.reslices == [visual.id] + assert len(spy.ticks) == 1 + await drain_loading(controller) + + +@pytest.mark.parametrize("kind", ["render", "clipping"]) +async def test_a_new_outline_inside_a_drag_is_not_a_tick_of_it( + controller, tmp_path, monkeypatch, kind +): + """A drag's scope is open (a gizmo is held): restyling does not extend + the drag or plan.""" + _scene, visual = await _image(controller, tmp_path) + spy = _Spy(controller, visual, monkeypatch) + plane = _planes(visual, kind)[0] + with controller.plane_interaction(visual.id): + _set(controller, visual, kind, (plane.model_copy(update={"outline": RED}),)) + assert spy.ticks == [] + assert spy.reslices == [] + await drain_loading(controller) + + +async def test_a_new_outline_on_a_plane_that_is_not_drawn_is_only_announced( + controller, tmp_path, monkeypatch +): + """Outside plane mode the render planes are stored and nothing more.""" + _scene, visual = await _image(controller, tmp_path, render_mode="mip") + spy = _Spy(controller, visual, monkeypatch) + plane = visual.render_planes[0] + controller.set_render_planes( + visual.id, (plane.model_copy(update={"outline": RED}),) + ) + assert len(spy.changed) == 1 + assert spy.reslices == [] + assert spy.draws == [] + + +async def test_a_direct_assignment_follows_the_same_rule( + controller, tmp_path, monkeypatch +): + _scene, visual = await _image(controller, tmp_path) + spy = _Spy(controller, visual, monkeypatch) + visual.render_planes = ( + visual.render_planes[0].model_copy(update={"outline": RED}), + ) + visual.clipping_planes = ( + visual.clipping_planes[0].model_copy(update={"outline": RED}), + ) + assert len(spy.changed) == 2 + assert spy.reslices == [] + assert spy.ticks == [] diff --git a/tests/render/test_plane_outline_geometry.py b/tests/render/test_plane_outline_geometry.py new file mode 100644 index 00000000..5e4aa84e --- /dev/null +++ b/tests/render/test_plane_outline_geometry.py @@ -0,0 +1,373 @@ +"""The polygon a plane makes in a visual (plane outline design, section 5). + +The expected polygons are worked out by hand and written here as numbers. +Coordinates are rendered space, pygfx ``(x, y, z)``. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from cellier.render._clipping import KEEP_EVERYTHING, reduce_clipping_planes +from cellier.render._plane_outline import ( + box_diagonal, + box_frame, + clip_polygon, + clipping_plane_polygon, + node_box_frame, + render_plane_polygon, +) +from cellier.render._render_planes import reduce_render_planes +from cellier.visuals import ClippingPlane, RenderPlane +from tests.render.test_clipping_reduction import _points_visual + +#: A box from the origin to (4, 6, 8), as rows and corners. +BOX_HIGH = (4.0, 6.0, 8.0) +BOX_ROWS = np.array( + [ + [1.0, 0.0, 0.0, 0.0], + [-1.0, 0.0, 0.0, -4.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, -1.0, 0.0, -6.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, -1.0, -8.0], + ] +) +BOX_CORNERS = np.array( + [[x, y, z] for x in (0.0, 4.0) for y in (0.0, 6.0) for z in (0.0, 8.0)] +) +UNIT_ROWS = np.array( + [ + [1.0, 0.0, 0.0, 0.0], + [-1.0, 0.0, 0.0, -1.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, -1.0, 0.0, -1.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, -1.0, -1.0], + ] +) +UNIT_CORNERS = np.array( + [[x, y, z] for x in (0.0, 1.0) for y in (0.0, 1.0) for z in (0.0, 1.0)] +) +X, Y, Z = np.eye(3) +UNBOUNDED = (False, False, False, False) +BOUNDED = (True, True, True, True) +NO_EXTENT = (0.0, 0.0, 0.0, 0.0) + + +def assert_polygon(found: np.ndarray, expected, tolerance: float = 1e-9) -> None: + """The same vertices in the same cyclic order, either way round.""" + expected = np.asarray(expected, dtype=np.float64) + assert found.shape == expected.shape, found + for candidate in (expected, expected[::-1]): + for shift in range(len(candidate)): + if np.abs(found - np.roll(candidate, shift, axis=0)).max() <= tolerance: + return + raise AssertionError(f"polygons differ:\n{found}\nexpected\n{expected}") + + +def _xy_plane(z: float, extent=NO_EXTENT, bounded=UNBOUNDED, clipping=()): + """The polygon of the plane at height *z*, through (2, 3, z).""" + return render_plane_polygon( + (2.0, 3.0, z), X, Y, extent, bounded, BOX_ROWS, BOX_CORNERS, clipping + ) + + +# -- a render plane --------------------------------------------------------------- + + +def test_an_axis_aligned_plane_gives_the_box_s_cross_section(): + assert_polygon(_xy_plane(3.0), [(0, 0, 3), (4, 0, 3), (4, 6, 3), (0, 6, 3)]) + + +def test_a_plane_across_the_cube_s_diagonal_gives_the_regular_hexagon(): + polygon = render_plane_polygon( + (0.5, 0.5, 0.5), + np.array([1.0, -1.0, 0.0]) / np.sqrt(2.0), + np.array([1.0, 1.0, -2.0]) / np.sqrt(6.0), + NO_EXTENT, + UNBOUNDED, + UNIT_ROWS, + UNIT_CORNERS, + ) + assert_polygon( + polygon, + [ + (1.0, 0.5, 0.0), + (0.5, 1.0, 0.0), + (0.0, 1.0, 0.5), + (0.0, 0.5, 1.0), + (0.5, 0.0, 1.0), + (1.0, 0.0, 0.5), + ], + ) + + +def test_extents_inside_the_box_give_the_extent_rectangle(): + polygon = _xy_plane(3.0, (-1.0, 1.0, -2.0, 2.0), BOUNDED) + assert_polygon(polygon, [(1, 1, 3), (3, 1, 3), (3, 5, 3), (1, 5, 3)]) + + +def test_an_unbounded_side_is_ended_by_the_box(): + polygon = _xy_plane(3.0, (-1.0, 0.0, -2.0, 2.0), (True, False, True, True)) + assert_polygon(polygon, [(1, 1, 3), (4, 1, 3), (4, 5, 3), (1, 5, 3)]) + + +def test_extents_past_the_box_are_ended_by_the_box(): + polygon = _xy_plane(3.0, (-50.0, 1.0, -2.0, 50.0), BOUNDED) + assert_polygon(polygon, [(0, 1, 3), (3, 1, 3), (3, 6, 3), (0, 6, 3)]) + + +def test_a_clipping_plane_across_a_corner_gives_a_pentagon(): + # Kept where x + y >= 3: the corner at (1, 1) is cut off. + polygon = _xy_plane( + 3.0, (-1.0, 1.0, -2.0, 2.0), BOUNDED, clipping=[(1.0, 1.0, 0.0, 3.0)] + ) + assert_polygon(polygon, [(2, 1, 3), (3, 1, 3), (3, 5, 3), (1, 5, 3), (1, 2, 3)]) + + +@pytest.mark.parametrize( + ("z", "clipping"), + [ + (20.0, ()), # the plane misses the box + (-0.01, ()), + (3.0, [(1.0, 0.0, 0.0, 10.0)]), # clipped away: kept where x >= 10 + (3.0, [(0.0, 0.0, 1.0, 5.0)]), # the plane is on the cut side + ], +) +def test_a_plane_with_nothing_to_draw_gives_no_polygon(z, clipping): + polygon = _xy_plane(z, clipping=clipping) + assert polygon.shape == (0, 3) + + +@pytest.mark.parametrize("z", [0.0, 8.0, 8.0 * (1.0 + 1e-12), -1e-12]) +def test_a_plane_lying_in_a_face_gives_the_face(z): + """The box is closed, and a rounding error does not open it.""" + polygon = _xy_plane(z) + face = round(z) + assert_polygon( + polygon, [(0, 0, face), (4, 0, face), (4, 6, face), (0, 6, face)], 1e-9 + ) + + +def test_a_row_with_no_normal_keeps_everything_or_nothing(): + """A disabled clipping plane, and one parallel to the slice.""" + whole = [(0, 0, 3), (4, 0, 3), (4, 6, 3), (0, 6, 3)] + assert_polygon(_xy_plane(3.0, clipping=[KEEP_EVERYTHING]), whole) + assert_polygon(_xy_plane(3.0, clipping=[(0.0, 0.0, 0.0, 0.0)]), whole) + assert _xy_plane(3.0, clipping=[(0.0, 0.0, 0.0, 1.0)]).shape == (0, 3) + + +def test_a_normal_of_any_length_cuts_at_the_same_place(): + """A reduced clipping plane's normal is not a unit vector.""" + expected = [(2, 1, 3), (3, 1, 3), (3, 5, 3), (1, 5, 3), (1, 2, 3)] + for factor in (1e-3, 1.0, 250.0): + row = factor * np.array([1.0, 1.0, 0.0, 3.0]) + polygon = _xy_plane(3.0, (-1.0, 1.0, -2.0, 2.0), BOUNDED, clipping=[row]) + assert_polygon(polygon, expected) + + +def test_any_plane_gives_a_polygon_on_it_and_inside_every_cut(): + """Whatever the pose: the vertices are on the plane, inside the box and + the clipping planes, and at most four, plus one per cut.""" + rng = np.random.default_rng(7) + scale = box_diagonal(BOX_CORNERS) + drawn = 0 + for _ in range(200): + normal = rng.normal(size=3) + normal /= np.linalg.norm(normal) + axis_0 = np.cross(normal, rng.normal(size=3)) + axis_0 /= np.linalg.norm(axis_0) + axis_1 = np.cross(normal, axis_0) + origin = rng.uniform((-1, -1, -1), (5, 7, 9)) + clipping = np.column_stack([rng.normal(size=(2, 3)), rng.uniform(-3, 3, 2)]) + polygon = render_plane_polygon( + origin, + axis_0, + axis_1, + rng.uniform(1.0, 6.0, 4) * (-1, 1, -1, 1), + rng.random(4) < 0.5, + BOX_ROWS, + BOX_CORNERS, + clipping, + ) + if len(polygon) == 0: + continue + drawn += 1 + assert 3 <= len(polygon) <= 4 + 6 + 2 + assert np.abs((polygon - origin) @ normal).max() < 1e-9 * scale + for row in np.vstack([BOX_ROWS, clipping]): + inside = (polygon @ row[:3] - row[3]) / np.linalg.norm(row[:3]) + assert inside.min() > -1e-6 * scale + assert drawn > 60 + + +# -- a clipping plane --------------------------------------------------------------- + + +def test_a_clipping_plane_s_polygon_is_its_cut_face(): + # Kept where z >= 3. + polygon = clipping_plane_polygon(0, [(0.0, 0.0, 1.0, 3.0)], BOX_ROWS, BOX_CORNERS) + assert_polygon(polygon, [(0, 0, 3), (4, 0, 3), (4, 6, 3), (0, 6, 3)]) + + +def test_two_clipping_planes_each_end_at_the_other(): + rows = [(0.0, 0.0, 1.0, 3.0), (1.0, 0.0, 0.0, 1.0)] # z >= 3 and x >= 1 + assert_polygon( + clipping_plane_polygon(0, rows, BOX_ROWS, BOX_CORNERS), + [(1, 0, 3), (4, 0, 3), (4, 6, 3), (1, 6, 3)], + ) + assert_polygon( + clipping_plane_polygon(1, rows, BOX_ROWS, BOX_CORNERS), + [(1, 0, 3), (1, 6, 3), (1, 6, 8), (1, 0, 8)], + ) + + +def test_a_tilted_clipping_plane_with_a_long_normal(): + # Kept where 2x + 2z >= 8, the plane x + z = 4. + polygon = clipping_plane_polygon(0, [(2.0, 0.0, 2.0, 8.0)], BOX_ROWS, BOX_CORNERS) + assert_polygon(polygon, [(0, 0, 4), (4, 0, 0), (4, 6, 0), (0, 6, 4)]) + + +@pytest.mark.parametrize( + "rows", + [ + [(0.0, 0.0, 1.0, 20.0)], # misses the box + [KEEP_EVERYTHING], # disabled + [(0.0, 0.0, 0.0, 1.0)], # parallel to the slice + [(0.0, 0.0, 1.0, 3.0), (0.0, 0.0, 1.0, 5.0)], # behind another plane + ], +) +def test_a_clipping_plane_with_no_face_gives_no_polygon(rows): + assert clipping_plane_polygon(0, rows, BOX_ROWS, BOX_CORNERS).shape == (0, 3) + + +# -- cutting ------------------------------------------------------------------------ + + +def test_clip_polygon_never_returns_a_point_or_a_line(): + square = np.array([(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)], dtype=float) + # Kept where x >= 1: one edge of the square is left. + assert clip_polygon(square, [(1.0, 0.0, 0.0, 1.0)], 1.0).shape == (0, 3) + # Kept where x + y >= 2: one corner is left. + assert clip_polygon(square, [(1.0, 1.0, 0.0, 2.0)], 1.0).shape == (0, 3) + assert clip_polygon(square, np.zeros((0, 4)), 1.0).shape == (4, 3) + + +# -- the box of a placed visual ----------------------------------------------------- + +IDENTITY = {"t": 1.0, "z": 1.0, "y": 1.0, "x": 1.0} +NO_SHIFT = {"t": 0.0, "z": 0.0, "y": 0.0, "x": 0.0} +#: A 4 x 6 x 8 voxel volume's edges, data ``(z, y, x)``. +VOXEL_LOW = (-0.5, -0.5, -0.5) +VOXEL_HIGH = (3.5, 5.5, 7.5) + + +def test_the_box_follows_the_visual_s_transform(controller): + """Voxels twice as tall in z, shifted in x: data (z, y, x) to world.""" + scale = dict(IDENTITY, z=2.0) + shift = dict(NO_SHIFT, x=100.0) + visual, _system, spaces = _points_visual( + controller, "zyx", "zyx", "zyx", scale, shift + ) + rows, corners = box_frame(spaces, visual.transform, {}, VOXEL_LOW, VOXEL_HIGH) + assert rows.shape == (6, 4) + np.testing.assert_allclose(corners.min(axis=0), (99.5, -0.5, -1.0)) + np.testing.assert_allclose(corners.max(axis=0), (107.5, 5.5, 7.0)) + # Each corner is on three faces and inside the other three. + inside = corners @ rows[:, :3].T - rows[:, 3] + assert inside.min() > -1e-9 + assert (np.abs(inside) < 1e-9).sum(axis=1).tolist() == [3] * 8 + assert box_diagonal(corners) == pytest.approx(np.sqrt(8.0**2 + 6.0**2 + 8.0**2)) + + +def test_a_tilted_plane_through_anisotropic_voxels(controller): + """Voxels of size (2, 1, 1): the box is world z -1..7, y and x as data. + + The plane x + z = 5 leaves it at x = -0.5 (z = 5.5) and z = -1 (x = 6). + """ + visual, _system, spaces = _points_visual( + controller, "zyx", "zyx", "zyx", dict(IDENTITY, z=2.0), NO_SHIFT + ) + rows, corners = box_frame(spaces, visual.transform, {}, VOXEL_LOW, VOXEL_HIGH) + plane = RenderPlane.from_point_normal( + spaces.world, (2.0, 2.5, 3.0), (1.0, 0.0, 1.0), axes=("z", "y", "x") + ) + reduced = reduce_render_planes(spaces, [plane]) + polygon = render_plane_polygon( + reduced.origin[0], + reduced.axis_0[0], + reduced.axis_1[0], + reduced.extent[0], + reduced.bounded[0], + rows, + corners, + ) + assert_polygon( + polygon, + [(-0.5, -0.5, 5.5), (6.0, -0.5, -1.0), (6.0, 5.5, -1.0), (-0.5, 5.5, 5.5)], + ) + + +@pytest.mark.parametrize(("t", "z"), [(1.0, 4.0), (3.0, 2.0)]) +def test_a_clipping_plane_across_a_sliced_axis_moves_with_the_slice(controller, t, z): + """Kept where t + z >= 5, in a zyx view of tzyx data: at time *t* the + cut face is at z = 5 - t.""" + visual, system, spaces = _points_visual( + controller, "tzyx", "tzyx", "zyx", IDENTITY, NO_SHIFT + ) + constants = {0: t} + rows, corners = box_frame(spaces, visual.transform, constants, (0, 0, 0), (8, 6, 4)) + item = ClippingPlane.from_point_normal(system, (5, 0, 0, 0), (1, 1, 0, 0)) + clipping = reduce_clipping_planes(spaces, visual.transform, constants, [item]) + polygon = clipping_plane_polygon(0, clipping, rows, corners) + assert_polygon(polygon, [(0, 0, z), (4, 0, z), (4, 6, z), (0, 6, z)]) + + +def test_the_box_is_for_a_3d_view(controller): + visual, _system, spaces = _points_visual( + controller, "zyx", "zyx", "yx", IDENTITY, NO_SHIFT + ) + with pytest.raises(ValueError, match="3D view"): + box_frame(spaces, visual.transform, {0: 0.0}, (0, 0), (1, 1)) + + +# -- a box given in a node's frame --------------------------------------------------- + + +def test_a_box_in_a_node_s_frame_is_carried_by_the_node_s_matrix(): + """A node that doubles x and shifts y by 10: the unit cube becomes the + box from (0, 10, 0) to (2, 11, 1).""" + matrix = np.diag([2.0, 1.0, 1.0, 1.0]) + matrix[1, 3] = 10.0 + rows, corners = node_box_frame(matrix, (0.0, 0.0, 0.0), (1.0, 1.0, 1.0)) + np.testing.assert_allclose(corners.min(axis=0), (0.0, 10.0, 0.0)) + np.testing.assert_allclose(corners.max(axis=0), (2.0, 11.0, 1.0)) + inside = corners @ rows[:, :3].T - rows[:, 3] + assert inside.min() > -1e-12 + assert (np.abs(inside) < 1e-12).sum(axis=1).tolist() == [3] * 8 + # Kept where x >= 1: the face at x = 1, half way along the box. + polygon = clipping_plane_polygon(0, [(1.0, 0.0, 0.0, 1.0)], rows, corners) + assert_polygon(polygon, [(1, 10, 0), (1, 11, 0), (1, 11, 1), (1, 10, 1)]) + + +def test_a_box_in_a_turned_node_s_frame_is_a_turned_box(): + """A quarter turn about z: the box's x side lies along y.""" + matrix = np.array( + [ + [0.0, -1.0, 0.0, 0.0], + [1.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0, 0, 0, 1], + ] + ) + rows, corners = node_box_frame(matrix, (0.0, 0.0, 0.0), (4.0, 1.0, 1.0)) + np.testing.assert_allclose(corners.min(axis=0), (-1.0, 0.0, 0.0), atol=1e-12) + np.testing.assert_allclose(corners.max(axis=0), (0.0, 4.0, 1.0), atol=1e-12) + inside = corners @ rows[:, :3].T - rows[:, 3] + assert inside.min() > -1e-12 + + +def test_a_node_scaled_to_nothing_has_no_box(): + assert node_box_frame(np.diag([1.0, 0.0, 1.0, 1.0]), (0, 0, 0), (1, 1, 1)) is None diff --git a/tests/render/test_plane_outline_render.py b/tests/render/test_plane_outline_render.py new file mode 100644 index 00000000..a9c80d22 --- /dev/null +++ b/tests/render/test_plane_outline_render.py @@ -0,0 +1,384 @@ +"""Plane outlines on screen (plane outline design, sections 5.3, 6 and 10). + +Two things are checked against pixels. That the polygon an outline +follows is where each of the four volume shaders stops drawing the plane: +the outline is computed on the CPU and the plane is drawn by a shader, and +they must agree. And that the line is drawn on that edge: whole on the +plane it lies in, hidden by what is in front of it. +""" + +from __future__ import annotations + +import numpy as np +import pygfx as gfx +import pylinalg as la +import pytest + +from cellier.data import ImageMemoryStore, LabelMemoryStore +from cellier.render.shaders._plane_outline import ( + OUTLINE_DEPTH_OFFSET_PX, + OUTLINE_RENDER_QUEUE, + PlaneOutlineMaterial, + PlaneOutlineShader, +) +from cellier.visuals import ( + ClippingPlane, + InMemoryImageSingleAppearance, + InMemoryLabelsAppearance, + PlaneOutline, +) +from tests.render import _plane_outline_pixels as px +from tests.render import _plane_rig as rig_module +from tests.render import _planes as h +from tests.render.conftest import drain_loading + +SIZE = (320, 320) +SHAPE = (20, 28, 36) +OBLIQUE = (0.6, -0.5, 1.0) +#: The oblique plane's unit normal in rendered ``(x, y, z)``. +NORMAL = np.array(OBLIQUE[::-1]) / np.linalg.norm(OBLIQUE) +WIDTH = 4.0 +RED = PlaneOutline(enabled=True, color=(1.0, 0.0, 0.0, 1.0), width=WIDTH) +TRANSFORMS = { + "identity": ((1.0, 1.0, 1.0), (0.0, 0.0, 0.0)), + "anisotropic": ((2.5, 1.0, 1.0), (0.0, 4.0, -7.0)), +} +KINDS = ("memory_image", "memory_labels", "multiscale_image", "multiscale_labels") + + +def _scene_manager(controller, scene): + return controller._render_manager._scenes[scene.id] + + +def _polygon(controller, scene, plane) -> np.ndarray: + """The polygon the scene draws the outline of *plane* on.""" + drawn = _scene_manager(controller, scene).plane_outlines.drawn + return drawn[("render", plane.id, 0)].vertices + + +async def _memory(controller, kind, make_planes, *, transform="identity", clipping=()): + """An in-memory image or labels visual in plane mode, loaded. + + The labels have no background, so a plane draws on every pixel of it. + """ + scale, shift = TRANSFORMS[transform] + scene = controller.add_scene(dim="3d", name="scene") + world = h.world_of(controller, scene) + if kind == "memory_image": + store = ImageMemoryStore(data=h.image_data(SHAPE), name="image") + else: + store = LabelMemoryStore(data=np.full(SHAPE, 3, dtype=np.int32), name="labels") + centre = ((np.array(SHAPE) - 1) / 2 + 0.3) * np.array(scale) + np.array(shift) + planes = tuple(make_planes(world, centre)) + transform_model = h.scale_translation_transform( + controller, scene.id, store, scale, shift + ) + system = store.data_coordinate_systems[0] + clip = tuple( + ClippingPlane.from_point_normal(system, p, n, axes=("z", "y", "x")) + for p, n in clipping + ) + if kind == "memory_image": + visual = controller.add_image( + data=store, + scene_id=scene.id, + single=InMemoryImageSingleAppearance( + render_mode="plane", color_map="gray", clim=(0.0, 1.0) + ), + transform=transform_model, + render_planes=planes, + clipping_planes=clip, + ) + else: + visual = controller.add_labels( + data=store, + scene_id=scene.id, + appearance=InMemoryLabelsAppearance( + render_mode="plane", + colormap_mode="direct", + color_dict=h.label_colours(), + ), + transform=transform_model, + render_planes=planes, + clipping_planes=clip, + ) + controller.add_canvas(scene_id=scene.id, canvas_size=SIZE) + controller.fit_camera(scene.id) + controller.reslice_all() + await drain_loading(controller) + return scene, visual + + +def _oblique(world, centre): + return [h.plane_zyx(world, centre, OBLIQUE)] + + +def _cut(wide: float): + """An oblique plane with three bounded sides, *wide* times a base size.""" + + def make(world, centre): + return [ + h.plane_zyx( + world, + centre, + OBLIQUE, + extent_0=(-8.0 * wide, 12.0 * wide), + extent_1=(None, 10.0 * wide), + ) + ] + + return make + + +#: ``case -> (planes, clipping, options)``, per family of visual. +MEMORY_CASES = { + "oblique": (_oblique, (), {}), + "extents_and_clipping": (_cut(1.0), [((6.0, 0.0, 20.0), (0.0, 0.2, -1.0))], {}), + "anisotropic": ( + _cut(1.0), + [((6.0, 0.0, 20.0), (0.0, 0.2, -1.0))], + {"transform": "anisotropic"}, + ), +} +MULTISCALE_CASES = { + "oblique": (_oblique, (), {}), + "extents_and_clipping": (_cut(2.5), [((0.0, 0.0, 60.0), (0.0, 0.2, -1.0))], {}), + "coarse_level": (_oblique, [((0.0, 0.0, 60.0), (0.0, 0.2, -1.0))], {"level": 3}), +} + + +async def _shot_without_outline(controller, tmp_path, kind, case): + """Draw the plane with no outline; returns what is needed to add one.""" + if kind.startswith("memory"): + make_planes, clipping, options = MEMORY_CASES[case] + scene, visual = await _memory( + controller, kind, make_planes, clipping=clipping, **options + ) + shot = h.shoot(controller, scene, size=SIZE) + return scene, visual, shot + make_planes, clipping, options = MULTISCALE_CASES[case] + labels = kind == "multiscale_labels" + spec = rig_module.ANISO + rig = await rig_module.make_rig( + controller, + tmp_path, + spec, + make_planes, + labels=labels, + fill=(lambda level: np.full(spec.level_shape(level), 3)) if labels else None, + clipping=clipping, + appearance={"force_level": options.get("level", 1)}, + ) + shot = await rig.settle() + return rig.scene, rig.visual, shot + + +# -- the polygon is where the shader stops --------------------------------------- + + +@pytest.mark.parametrize("kind", KINDS) +@pytest.mark.parametrize("case", ["oblique", "extents_and_clipping", "third"]) +async def test_the_outline_s_polygon_is_the_edge_of_what_the_shader_draws( + controller, tmp_path, kind, case +): + """Agreement with the shader (5.3): the filled polygon and the pixels a + plane is drawn on differ only within half a pixel of the edge. + + On voxel edges and not centres, on anisotropic voxels, with extents and + a clipping plane, and at a coarse level of a pyramid that never + downsamples z. + """ + if case == "third": + case = "anisotropic" if kind.startswith("memory") else "coarse_level" + scene, visual, shot = await _shot_without_outline(controller, tmp_path, kind, case) + plane = visual.render_planes[0] + assert _scene_manager(controller, scene).plane_outlines.drawn == {} + + controller.set_render_planes( + visual.id, (plane.model_copy(update={"outline": RED}),) + ) + polygon = _polygon(controller, scene, plane) + distance = px.edge_distance(polygon, shot.camera, SIZE) + inside = distance > 0.0 + differ = inside != shot.drawn + + assert inside.sum() > 5000 + assert differ.sum() <= 2, int(differ.sum()) + assert not (np.abs(distance[differ]) > 0.5).any() + + +@pytest.mark.parametrize("kind", ["memory_image", "memory_labels"]) +async def test_the_box_is_the_voxels_edges(controller, kind): + scene, visual = await _memory(controller, kind, _oblique) + gfx_visual = _scene_manager(controller, scene).get_visual(visual.id) + low, high = gfx_visual.outline_box() + np.testing.assert_array_equal(low, (-0.5, -0.5, -0.5)) + np.testing.assert_array_equal(high, np.array(SHAPE) - 0.5) + + +@pytest.mark.parametrize("labels", [False, True]) +async def test_the_box_of_a_pyramid_is_its_level_0_voxels_edges( + controller, tmp_path, labels +): + spec = rig_module.ANISO + rig = await rig_module.make_rig(controller, tmp_path, spec, _oblique, labels=labels) + low, high = rig.gfx.outline_box() + np.testing.assert_array_equal(low, (-0.5, -0.5, -0.5)) + np.testing.assert_array_equal(high, np.array(spec.shape0) - 0.5) + + +# -- the line --------------------------------------------------------------------- + + +def _views() -> dict: + side = np.cross(NORMAL, [0.0, 0.0, 1.0]) + side /= np.linalg.norm(side) + return { + "usual": (-0.5, -0.4, -1.0), + "facing": tuple(-NORMAL), + "from_behind": tuple(NORMAL + 0.2 * side), + "tilted_60": tuple(-0.5 * NORMAL + 0.866 * side), + } + + +async def _outlined_image(controller, **kwargs): + def make(world, centre): + return [h.plane_zyx(world, centre, OBLIQUE, outline=RED)] + + scene, visual = await _memory(controller, "memory_image", make, **kwargs) + return scene, visual, _polygon(controller, scene, visual.render_planes[0]) + + +@pytest.mark.parametrize("view", list(_views())) +async def test_the_line_is_whole_on_the_plane_it_lies_in(controller, view): + """Depth (6.2): an opaque plane writes depth where the line is. Both + halves of the line are drawn, from in front, from behind and at a tilt, + and nothing red is drawn off the edge.""" + scene, _visual, polygon = await _outlined_image(controller) + size = (480, 480) + shot = h.shoot(controller, scene, size=size, view_dir=_views()[view]) + shares = px.line_shares( + shot.frame, px.edge_distance(polygon, shot.camera, size), WIDTH + ) + assert shares["pixels"] > 800, shares + assert shares["inner"] > 0.97, shares + assert shares["outer"] > 0.97, shares + assert shares["stray"] == 0, shares + + +async def test_the_line_has_its_width_in_pixels_at_any_distance(controller): + scene, _visual, polygon = await _outlined_image(controller) + size = (480, 480) + for factor in (1.0, 2.5): + shot = h.shoot(controller, scene, size=size, distance_factor=factor) + pixels = px.to_pixels(polygon, shot.camera, size) + perimeter = sum( + np.linalg.norm(pixels[i] - pixels[(i + 1) % len(pixels)]) + for i in range(len(pixels)) + ) + red = int(px.is_red(shot.frame).sum()) + # The corners add a little to a perimeter times a width. + assert 0.9 < red / (perimeter * WIDTH) < 1.15, (factor, red, perimeter) + + +async def test_a_mesh_in_front_hides_the_line(controller): + """The line is depth tested (B-D7): a quad three units toward the + camera, over one corner of the polygon, hides the line behind it.""" + scene, _visual, polygon = await _outlined_image(controller) + size = (480, 480) + gfx_scene = _scene_manager(controller, scene).scene + for view_dir in (_views()["usual"], _views()["from_behind"]): + toward = -np.sign(np.asarray(view_dir) @ NORMAL) * NORMAL + quad = gfx.Mesh( + gfx.plane_geometry(10.0, 10.0), + gfx.MeshBasicMaterial(color=(0.0, 1.0, 0.0, 1.0), side="both"), + ) + quad.local.position = tuple(polygon[0] + 3.0 * toward) + quad.local.rotation = la.quat_from_vecs((0.0, 0.0, 1.0), tuple(NORMAL)) + gfx_scene.add(quad) + try: + shot = h.shoot(controller, scene, size=size, view_dir=view_dir) + finally: + gfx_scene.remove(quad) + frame = shot.frame + green = (frame[..., 1] > 200) & (frame[..., 0] < 70) & (frame[..., 2] < 70) + distance = px.edge_distance(polygon, shot.camera, size) + on_line = np.abs(distance) < WIDTH / 2.0 - 0.75 + red = px.is_red(frame) + # The quad covers part of the line: there the quad is seen, not + # the line. Everywhere else the line is drawn. + assert (on_line & green).sum() > 30 + assert (on_line & red).sum() > 300 + assert (on_line & ~red & ~green).sum() == 0 + + +async def test_the_line_is_whole_through_the_canvas_s_own_passes(controller): + """Through the real pipeline: a perspective camera, an orthographic one + (a field of view of 0), and ambient occlusion with accumulated frames.""" + scene, _visual, polygon = await _outlined_image(controller) + size = (480, 480) + canvas_id = controller.get_canvas_ids(scene.id)[0] + view = controller._render_manager._canvases[canvas_id] + + def shares(frames: int) -> dict: + frame = np.asarray(controller.screenshot(canvas_id, size=size, frames=frames)) + return px.line_shares( + frame, px.edge_distance(polygon, view.camera, size), WIDTH + ) + + for step in ("perspective", "orthographic", "ambient occlusion"): + frames = 1 + if step == "orthographic": + view.camera.fov = 0.0 + controller.fit_camera(scene.id) + elif step == "ambient occlusion": + controller._render_manager.ambient_occlusion_enabled = True + frames = 8 + found = shares(frames) + assert found["pixels"] > 800, (step, found) + assert found["inner"] > 0.97, (step, found) + assert found["outer"] > 0.97, (step, found) + assert found["stray"] == 0, (step, found) + + +async def test_the_line_is_not_picked_and_writes_no_depth(controller): + scene, _visual, _polygon = await _outlined_image(controller) + outlines = _scene_manager(controller, scene).plane_outlines + (line,) = outlines.node.children + material = line.material + assert isinstance(material, PlaneOutlineMaterial) + assert material.pick_write is False + assert material.depth_test is True + assert material.depth_write is False + assert material.render_queue == OUTLINE_RENDER_QUEUE + assert material.thickness == WIDTH + assert material.thickness_space == "screen" + assert material.loop is True + # After the volume materials, whatever their queue. + from cellier.render._clipping import drawn_materials + + assert all( + m.render_queue < OUTLINE_RENDER_QUEUE + for m in drawn_materials(_scene_manager(controller, scene).scene) + ) + + +def test_the_shader_patch_names_its_anchor_when_pygfx_moves_it(monkeypatch): + """The pygfx-bump canary: the depth offset replaces one line of pygfx's + line shader, and says so if that line is gone.""" + from pygfx.renderers.wgpu.shaders.lineshader import LineShader + + from cellier.render.shaders._alpha_modulated import ShaderAnchorError + + line = gfx.Line( + gfx.Geometry(positions=np.zeros((3, 3), dtype=np.float32)), + PlaneOutlineMaterial(color=(1.0, 0.0, 0.0, 1.0), thickness=2.0), + ) + shader = PlaneOutlineShader(line) + code = shader.get_code() + assert repr(OUTLINE_DEPTH_OFFSET_PX) in code + assert "outline_z / outline_w" in code + + monkeypatch.setattr(LineShader, "get_code", lambda self: "// nothing here") + with pytest.raises(ShaderAnchorError, match=r"varyings\.position"): + shader.get_code() diff --git a/tests/render/test_plane_outline_scene.py b/tests/render/test_plane_outline_scene.py new file mode 100644 index 00000000..13312be7 --- /dev/null +++ b/tests/render/test_plane_outline_scene.py @@ -0,0 +1,479 @@ +"""Which plane outlines a scene draws, and when (plane outline design 3.3, +5.4, 6.1 and 6.4). + +Nothing is drawn here: the tests read the lines the scene holds. What a +line looks like on screen is in ``test_plane_outline_render.py``. +""" + +from __future__ import annotations + +from uuid import UUID + +import numpy as np +import pytest + +from cellier.data import ImageMemoryStore, LabelMemoryStore +from cellier.scene import spatial_axes +from cellier.visuals import ( + ClippingPlane, + InMemoryImageChannelAppearance, + InMemoryImageSingleAppearance, + InMemoryLabelsAppearance, + PlaneOutline, + RenderPlane, +) +from tests.render import _plane_rig as rig_module +from tests.render import _planes as h +from tests.render.conftest import drain_loading + +SHAPE = (8, 12, 16) +RED = PlaneOutline(enabled=True, color=(1.0, 0.0, 0.0, 1.0), width=3.0) +BLUE = PlaneOutline(enabled=True, color=(0.0, 0.0, 1.0, 0.5), width=5.0) +#: The plane z = 3 through a volume of ``SHAPE``, as rendered (x, y, z): +#: the voxel-edge box is -0.5 to 15.5, 11.5 and 7.5. +SQUARE = [(-0.5, -0.5, 3.0), (15.5, -0.5, 3.0), (15.5, 11.5, 3.0), (-0.5, 11.5, 3.0)] + + +def _world(controller, scene): + return h.world_of(controller, scene) + + +def _plane(world, z: float = 3.0, outline=RED, **kwargs) -> RenderPlane: + return RenderPlane.from_point_normal( + world, (z, 5.0, 7.0), (1, 0, 0), axes=("z", "y", "x"), outline=outline, **kwargs + ) + + +def _image(controller, scene, planes, *, shape=SHAPE, render_mode="plane", **kwargs): + store = ImageMemoryStore(data=np.ones(shape, dtype=np.float32), name="image") + return controller.add_image( + data=store, + scene_id=scene.id, + single=InMemoryImageSingleAppearance( + render_mode=render_mode, color_map="gray", clim=(0.0, 1.0) + ), + render_planes=tuple(planes), + **kwargs, + ) + + +def _labels(controller, scene, planes, *, shape=SHAPE, data=None, **kwargs): + if data is None: + data = np.ones(shape, dtype=np.int32) + store = LabelMemoryStore(data=data, name="labels") + return controller.add_labels( + data=store, + scene_id=scene.id, + appearance=InMemoryLabelsAppearance(render_mode="plane"), + render_planes=tuple(planes), + **kwargs, + ) + + +def _scene(controller): + scene = controller.add_scene(dim="3d", name="scene") + return scene, _world(controller, scene) + + +def _store(controller, visual): + return controller.get_data_store(UUID(str(visual.data_store_id))) + + +def _outlines(controller, scene): + return controller._render_manager._scenes[scene.id].plane_outlines + + +def _drawn(controller, scene) -> dict: + return _outlines(controller, scene).drawn + + +def _same(vertices, expected) -> bool: + expected = np.asarray(expected, dtype=float) + if vertices.shape != expected.shape: + return False + return any( + np.abs(vertices - np.roll(candidate, shift, axis=0)).max() < 1e-6 + for candidate in (expected, expected[::-1]) + for shift in range(len(expected)) + ) + + +def _square(z: float): + return [(x, y, z) for x, y, _ in SQUARE] + + +# -- when an outline is shown (3.3) ------------------------------------------------ + + +async def test_an_outlined_plane_has_one_line_on_its_polygon(controller): + scene, world = _scene(controller) + plane = _plane(world) + _image(controller, scene, [plane]) + controller.add_canvas(scene_id=scene.id) + + drawn = _drawn(controller, scene) + assert list(drawn) == [("render", plane.id, 0)] + polygon = drawn[("render", plane.id, 0)] + assert _same(polygon.vertices, SQUARE) + assert polygon.color == RED.color + assert polygon.width == RED.width + assert len(_outlines(controller, scene).node.children) == 1 + + +async def test_a_plane_with_no_outline_has_no_line_and_no_node(controller): + """Nothing is put in the scene for a plane that is not outlined: a + hidden node would still count toward the scene's bounding box.""" + scene, world = _scene(controller) + _image(controller, scene, [_plane(world, outline=PlaneOutline())]) + controller.add_canvas(scene_id=scene.id) + assert _drawn(controller, scene) == {} + assert _outlines(controller, scene).node.children == () + + +async def test_the_outline_follows_its_switch_and_the_plane_s(controller): + scene, world = _scene(controller) + plane = _plane(world) + visual = _image(controller, scene, [plane]) + controller.add_canvas(scene_id=scene.id) + + off = plane.model_copy( + update={"outline": RED.model_copy(update={"enabled": False})} + ) + controller.set_render_planes(visual.id, (off,)) + assert _drawn(controller, scene) == {} + controller.set_render_planes(visual.id, (plane,)) + assert len(_drawn(controller, scene)) == 1 + + # A disabled plane is not drawn, and neither is its outline. + controller.set_render_planes( + visual.id, (plane.model_copy(update={"enabled": False}),) + ) + assert _drawn(controller, scene) == {} + + +async def test_a_hidden_visual_s_planes_have_no_outline(controller): + scene, world = _scene(controller) + visual = _image(controller, scene, [_plane(world)]) + controller.add_canvas(scene_id=scene.id) + visual.appearance.visible = False + assert _drawn(controller, scene) == {} + visual.appearance.visible = True + assert len(_drawn(controller, scene)) == 1 + + +async def test_an_outline_is_shown_only_in_plane_mode(controller): + scene, world = _scene(controller) + visual = _image(controller, scene, [_plane(world)], render_mode="mip") + controller.add_canvas(scene_id=scene.id) + assert _drawn(controller, scene) == {} + visual.single.render_mode = "plane" + assert len(_drawn(controller, scene)) == 1 + visual.single.render_mode = "mip" + assert _drawn(controller, scene) == {} + + +async def test_an_outline_is_shown_only_in_a_3d_view(controller): + scene, world = _scene(controller) + _image(controller, scene, [_plane(world)]) + controller.add_canvas(scene_id=scene.id) + controller.set_displayed_axes(scene.id, (1, 2)) + assert _drawn(controller, scene) == {} + controller.set_displayed_axes(scene.id, (0, 1, 2)) + assert len(_drawn(controller, scene)) == 1 + + +async def test_an_outline_is_shown_only_on_the_plane_s_own_axes(controller): + """A tzyx scene: a zyx plane is drawn in the zyx view and not in tyx.""" + scene = controller.add_scene( + coordinate_system=[("t", "time"), *spatial_axes("z", "y", "x")], + dim="3d", + name="scene", + ) + world = _world(controller, scene) + with pytest.warns(UserWarning, match="not drawn"): + _image(controller, scene, [_plane(world)], shape=(4, *SHAPE)) + controller.add_canvas(scene_id=scene.id) + assert len(_drawn(controller, scene)) == 1 + controller.set_displayed_axes(scene.id, (0, 2, 3)) + assert _drawn(controller, scene) == {} + controller.set_displayed_axes(scene.id, (1, 2, 3)) + assert len(_drawn(controller, scene)) == 1 + + +async def test_an_outline_does_not_wait_for_data(controller): + """B-D6: a labels plane that is all background draws nothing and still + has its outline.""" + scene, world = _scene(controller) + _labels(controller, scene, [_plane(world)], data=np.zeros(SHAPE, dtype=np.int32)) + controller.add_canvas(scene_id=scene.id) + (polygon,) = _drawn(controller, scene).values() + assert _same(polygon.vertices, SQUARE) + + +async def test_a_pyramid_s_outline_is_there_before_any_brick(controller, tmp_path): + """B-D6: nothing has been planned or loaded.""" + + def make(world, centre): + return [h.plane_zyx(world, centre, (1.0, 0.0, 0.0), outline=RED)] + + rig = await rig_module.make_rig(controller, tmp_path, rig_module.ISO, make) + (polygon,) = _drawn(controller, rig.scene).values() + assert len(polygon.vertices) == 4 + await rig.settle() + assert len(_drawn(controller, rig.scene)) == 1 + + +async def test_a_composite_has_one_outline_whatever_its_channels(controller): + scene = controller.add_scene( + coordinate_system=[("c", "channel"), *spatial_axes("z", "y", "x")], + dim="3d", + name="scene", + ) + world = _world(controller, scene) + store = ImageMemoryStore(data=np.ones((3, *SHAPE), dtype=np.float32)) + controller.add_image( + data=store, + scene_id=scene.id, + channel_axis=0, + composite=True, + channels={ + k: InMemoryImageChannelAppearance( + color_map=c, clim=(0.0, 1.0), render_mode="plane" + ) + for k, c in enumerate(("red", "green", "blue")) + }, + render_planes=(_plane(world),), + ) + controller.add_canvas(scene_id=scene.id) + (polygon,) = _drawn(controller, scene).values() + assert _same(polygon.vertices, SQUARE) + + +# -- what moves or restyles a line (6.4) ------------------------------------------- + + +async def test_the_line_follows_its_plane(controller): + scene, world = _scene(controller) + plane = _plane(world) + visual = _image(controller, scene, [plane]) + controller.add_canvas(scene_id=scene.id) + line = _outlines(controller, scene).line(("render", plane.id, 0)) + + controller.set_render_planes( + visual.id, (plane.model_copy(update={"origin": (5.0, 5.0, 7.0)}),) + ) + (polygon,) = _drawn(controller, scene).values() + assert _same(polygon.vertices, _square(5.0)) + # The same line object, moved. + assert _outlines(controller, scene).line(("render", plane.id, 0)) is line + np.testing.assert_allclose(line.geometry.positions.data[:, 2], 5.0) + + # Out of the box: no polygon, no line. + controller.set_render_planes( + visual.id, (plane.model_copy(update={"origin": (50.0, 5.0, 7.0)}),) + ) + assert _drawn(controller, scene) == {} + + +async def test_a_restyle_recolours_the_line_and_keeps_it(controller): + scene, world = _scene(controller) + plane = _plane(world) + visual = _image(controller, scene, [plane]) + controller.add_canvas(scene_id=scene.id) + line = _outlines(controller, scene).line(("render", plane.id, 0)) + geometry = line.geometry + + controller.set_render_planes( + visual.id, (plane.model_copy(update={"outline": BLUE}),) + ) + assert _outlines(controller, scene).line(("render", plane.id, 0)) is line + assert line.geometry is geometry + assert tuple(line.material.color) == pytest.approx(BLUE.color) + assert line.material.thickness == BLUE.width + + +async def test_the_line_ends_at_a_clipping_plane_and_follows_it(controller): + scene, world = _scene(controller) + plane = _plane(world) + visual = _image(controller, scene, [plane]) + controller.add_canvas(scene_id=scene.id) + system = _store(controller, visual).data_coordinate_systems[0] + + def clip(x: float) -> ClippingPlane: + # Kept where x >= x. + return ClippingPlane.from_point_normal( + system, (0, 0, x), (0, 0, 1), axes=("z", "y", "x") + ) + + first = clip(4.0) + controller.set_clipping_planes(visual.id, (first,)) + (polygon,) = _drawn(controller, scene).values() + assert _same( + polygon.vertices, + [(4.0, -0.5, 3.0), (15.5, -0.5, 3.0), (15.5, 11.5, 3.0), (4.0, 11.5, 3.0)], + ) + + controller.set_clipping_planes( + visual.id, (clip(9.0).model_copy(update={"id": first.id}),) + ) + (polygon,) = _drawn(controller, scene).values() + assert polygon.vertices[:, 0].min() == pytest.approx(9.0) + + # A disabled clipping plane cuts nothing. + controller.set_clipping_planes( + visual.id, (first.model_copy(update={"enabled": False}),) + ) + (polygon,) = _drawn(controller, scene).values() + assert _same(polygon.vertices, SQUARE) + + +async def test_the_line_follows_the_visual_s_transform(controller): + """Voxels made twice as wide in x: the box, and so the polygon, grows.""" + scene, world = _scene(controller) + visual = _image(controller, scene, [_plane(world)]) + controller.add_canvas(scene_id=scene.id) + store = _store(controller, visual) + visual.transform = h.scale_translation_transform( + controller, scene.id, store, (1.0, 1.0, 2.0), (0.0, 0.0, 0.0) + ) + (polygon,) = _drawn(controller, scene).values() + assert _same( + polygon.vertices, + [(-1.0, -0.5, 3.0), (31.0, -0.5, 3.0), (31.0, 11.5, 3.0), (-1.0, 11.5, 3.0)], + ) + + +async def test_a_change_draws_the_scene_from_a_clean_history(controller, monkeypatch): + """A moved or restyled line asks for a frame and discards the frames + accumulated with the old one.""" + scene, world = _scene(controller) + plane = _plane(world) + visual = _image(controller, scene, [plane]) + controller.add_canvas(scene_id=scene.id) + view = controller._render_manager._canvases[controller.get_canvas_ids(scene.id)[0]] + calls: list[str] = [] + monkeypatch.setattr(view, "invalidate_accumulation", lambda: calls.append("reset")) + monkeypatch.setattr(view, "request_draw", lambda: calls.append("draw")) + + controller.set_render_planes( + visual.id, (plane.model_copy(update={"outline": BLUE}),) + ) + assert "reset" in calls + assert "draw" in calls + + +async def test_removing_the_visual_removes_its_lines(controller): + scene, world = _scene(controller) + visual = _image(controller, scene, [_plane(world)]) + other = _image(controller, scene, [_plane(world, z=5.0)]) + controller.add_canvas(scene_id=scene.id) + assert len(_drawn(controller, scene)) == 2 + controller.remove_visual(visual.id) + (key,) = _drawn(controller, scene) + assert key[1] == other.render_planes[0].id + controller.remove_visual(other.id) + assert _drawn(controller, scene) == {} + assert _outlines(controller, scene).node.children == () + + +async def test_closing_the_scene_leaves_no_line(controller): + scene, world = _scene(controller) + _image(controller, scene, [_plane(world)]) + controller.add_canvas(scene_id=scene.id) + scene_manager = controller._render_manager._scenes[scene.id] + scene_manager.close() + assert scene_manager.plane_outlines.drawn == {} + assert scene_manager.plane_outlines.node.children == () + + +# -- a plane shared by two visuals (5.4) -------------------------------------------- + + +def _pair(controller, *, labels_shape=SHAPE, labels_outline=BLUE): + """An image and labels sharing one plane id; the image is added first.""" + scene, world = _scene(controller) + plane = _plane(world) + image = _image(controller, scene, [plane]) + labels = _labels( + controller, + scene, + [plane.model_copy(update={"outline": labels_outline})], + shape=labels_shape, + ) + controller.add_canvas(scene_id=scene.id) + return scene, plane, image, labels + + +async def test_a_shared_plane_with_equal_polygons_has_one_outline(controller): + scene, plane, _image, _labels = _pair(controller) + drawn = _drawn(controller, scene) + assert list(drawn) == [("render", plane.id, 0)] + # The first visual's style. + assert drawn[("render", plane.id, 0)].color == RED.color + assert len(_outlines(controller, scene).node.children) == 1 + + +async def test_a_shared_plane_in_two_boxes_has_two_outlines(controller): + scene, plane, _image, _labels = _pair(controller, labels_shape=(8, 6, 8)) + drawn = _drawn(controller, scene) + assert list(drawn) == [("render", plane.id, 0), ("render", plane.id, 1)] + assert _same(drawn[("render", plane.id, 0)].vertices, SQUARE) + assert _same( + drawn[("render", plane.id, 1)].vertices, + [(-0.5, -0.5, 3.0), (7.5, -0.5, 3.0), (7.5, 5.5, 3.0), (-0.5, 5.5, 3.0)], + ) + # Each in its own visual's style. + assert drawn[("render", plane.id, 0)].color == RED.color + assert drawn[("render", plane.id, 1)].color == BLUE.color + + +async def test_a_shared_plane_clipped_in_one_visual_has_two_outlines(controller): + scene, plane, _image, labels = _pair(controller) + system = _store(controller, labels).data_coordinate_systems[0] + controller.set_clipping_planes( + labels.id, + ( + ClippingPlane.from_point_normal( + system, (0, 0, 4.0), (0, 0, 1), axes=("z", "y", "x") + ), + ), + ) + drawn = _drawn(controller, scene) + assert len(drawn) == 2 + assert _same(drawn[("render", plane.id, 0)].vertices, SQUARE) + assert drawn[("render", plane.id, 1)].vertices[:, 0].min() == pytest.approx(4.0) + + +async def test_a_shared_outline_stays_while_any_visual_shows_it(controller): + scene, plane, image, labels = _pair(controller) + image.appearance.visible = False + drawn = _drawn(controller, scene) + assert list(drawn) == [("render", plane.id, 0)] + # Now the second visual's style: the first is not shown. + assert drawn[("render", plane.id, 0)].color == BLUE.color + labels.appearance.visible = False + assert _drawn(controller, scene) == {} + image.appearance.visible = True + assert _drawn(controller, scene)[("render", plane.id, 0)].color == RED.color + + +async def test_two_planes_have_two_outlines(controller): + scene, world = _scene(controller) + first, second = _plane(world), _plane(world, z=5.0, outline=BLUE) + _image(controller, scene, [first, second]) + controller.add_canvas(scene_id=scene.id) + drawn = _drawn(controller, scene) + assert set(drawn) == {("render", first.id, 0), ("render", second.id, 0)} + assert _same(drawn[("render", second.id, 0)].vertices, _square(5.0)) + + +async def test_a_loaded_scene_keeps_its_outline(controller): + """Loading places the nodes again; the outline is still the plane's.""" + scene, world = _scene(controller) + _image(controller, scene, [_plane(world)]) + controller.add_canvas(scene_id=scene.id) + controller.fit_camera(scene.id) + controller.reslice_all() + await drain_loading(controller) + (polygon,) = _drawn(controller, scene).values() + assert _same(polygon.vertices, SQUARE) diff --git a/tests/render/test_render_plane_gizmo.py b/tests/render/test_render_plane_gizmo.py index e4dae6e3..5292537b 100644 --- a/tests/render/test_render_plane_gizmo.py +++ b/tests/render/test_render_plane_gizmo.py @@ -460,9 +460,12 @@ async def test_each_handle_changes_exactly_its_model_fields(make_rig): async def test_a_scale_drag_along_the_arrow_grows_at_every_camera_angle(make_rig): - """The pygfx fix: 0 drags reversed, where pygfx as it is reverses many.""" - import pygfx as gfx + """The handles are not turned to the camera, so pygfx's own drag is right. + pygfx reverses a scale drag on a handle it has flipped, and reads the + flip from a scale that does not hold it; with no flip there is nothing + to get wrong. + """ rig = make_rig() await rig.start() plane = rig.plane() @@ -470,37 +473,30 @@ async def test_a_scale_drag_along_the_arrow_grows_at_every_camera_angle(make_rig session = rig.session(plane) gizmo = rig.gizmo(session) widget, proxy = gizmo.gizmo, gizmo.proxy - drags = shrank = shrank_unfixed = disagreements = 0 + drags = shrank = away = 0 for direction in _directions(40): rig.look(direction) + assert (widget.gizmo_scale > 0).all() for dim in (1, 2): at, out, length = rig.arrow(session, "_scale_children", dim) if length < 15: continue handle = widget._scale_children[dim] - disagreements += int( - (widget.local.scale[dim] < 0) != (widget.gizmo_scale[dim] < 0) + offset = np.asarray(handle.world.position) - np.asarray( + widget.world.position ) + away += int(offset @ np.asarray(direction) < 0) end = SimpleNamespace(x=at[0] + 60 * out[0], y=at[1] + 60 * out[1]) - for fixed in (True, False): - proxy.local.scale = (1.0, 1.0, 1.0) - widget._handle_start("scale", SimpleNamespace(x=at[0], y=at[1]), handle) - if fixed: - widget._handle_scale_move(end) - else: - gfx.TransformGizmo._handle_scale_move(widget, end) - widget._ref = None - small = proxy.local.scale[dim] < 1.0 - if fixed: - shrank += int(small) - else: - shrank_unfixed += int(small) + widget._handle_start("scale", SimpleNamespace(x=at[0], y=at[1]), handle) + widget._handle_scale_move(end) + widget._ref = None + shrank += int(proxy.local.scale[dim] < 1.0) proxy.local.scale = (1.0, 1.0, 1.0) drags += 1 assert drags >= 60 assert shrank == 0 - # The test would pass for the wrong reason if pygfx never got it wrong. - assert shrank_unfixed == disagreements > 0 + # The test would pass for the wrong reason if no handle pointed away. + assert away >= 20 # -- extents by the scale handles ----------------------------------------------- From 048d4781be632119ce474c8b563b3878203d2ed3 Mon Sep 17 00:00:00 2001 From: Kevin Yamauchi Date: Fri, 9 Oct 2026 19:55:10 +0200 Subject: [PATCH 09/24] add outlie test --- tests/visuals/test_plane_outline_model.py | 132 ++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 tests/visuals/test_plane_outline_model.py diff --git a/tests/visuals/test_plane_outline_model.py b/tests/visuals/test_plane_outline_model.py new file mode 100644 index 00000000..7b936243 --- /dev/null +++ b/tests/visuals/test_plane_outline_model.py @@ -0,0 +1,132 @@ +"""``PlaneOutline`` and the ``outline`` field of the two kinds of plane. + +Plane outline design (``plans/plane_outline_design.md``), section 4. +""" + +from __future__ import annotations + +from uuid import uuid4 + +import pytest +from pydantic import ValidationError + +from cellier.transform import Axis, CoordinateSystem +from cellier.visuals import ClippingPlane, PlaneOutline, RenderPlane +from cellier.visuals._clipping import validate_clipping_planes +from cellier.visuals._plane_outline import NO_OUTLINE, without_outlines +from cellier.visuals._render_plane import validate_render_planes + +WORLD = CoordinateSystem( + name="world", axes=tuple(Axis(name=n, axis_type="space") for n in "zyx") +) +RED = PlaneOutline(enabled=True, color=(1.0, 0.0, 0.0, 1.0), width=3.0) + + +def _render_plane(**kwargs) -> RenderPlane: + return RenderPlane.from_point_normal(WORLD, (4, 0, 0), (1, 0, 0), **kwargs) + + +def _clipping_plane(**kwargs) -> ClippingPlane: + return ClippingPlane.from_point_normal(WORLD, (4, 0, 0), (1, 0, 0), **kwargs) + + +# -- the outline ---------------------------------------------------------------- + + +def test_an_outline_is_off_white_and_two_pixels_wide_by_default(): + outline = PlaneOutline() + assert outline.enabled is False + assert outline.color == (1.0, 1.0, 1.0, 1.0) + assert outline.width == 2.0 + assert outline == NO_OUTLINE + + +def test_an_outline_is_frozen(): + with pytest.raises(ValidationError): + RED.enabled = False + + +@pytest.mark.parametrize("width", [0.0, -1.0, float("inf"), float("nan")]) +def test_a_width_is_a_positive_finite_number(width): + with pytest.raises(ValidationError): + PlaneOutline(width=width) + + +@pytest.mark.parametrize( + "color", + [ + (1.0, 0.0, 0.0), + (1.5, 0.0, 0.0, 1.0), + (0.0, -0.1, 0.0, 1.0), + (0, 0, 0, float("nan")), + ], +) +def test_a_colour_is_four_numbers_between_zero_and_one(color): + with pytest.raises(ValidationError): + PlaneOutline(color=color) + + +# -- on the planes ---------------------------------------------------------------- + + +@pytest.mark.parametrize("make", [_render_plane, _clipping_plane]) +def test_a_plane_has_no_outline_until_given_one(make): + assert make().outline == NO_OUTLINE + assert make(outline=RED).outline == RED + + +def test_from_rotation_takes_an_outline(): + plane = RenderPlane.from_rotation( + WORLD, (4, 0, 0), [[1, 0, 0], [0, 1, 0], [0, 0, 1]], outline=RED + ) + assert plane.outline == RED + + +@pytest.mark.parametrize("make", [_render_plane, _clipping_plane]) +def test_an_outline_is_changed_with_a_copy_that_keeps_the_id(make): + plane = make() + outlined = plane.model_copy(update={"outline": RED}) + assert outlined.id == plane.id + assert outlined != plane + assert outlined.model_copy(update={"outline": NO_OUTLINE}) == plane + + +@pytest.mark.parametrize( + ("make", "validate"), + [ + (_render_plane, validate_render_planes), + (_clipping_plane, validate_clipping_planes), + ], +) +def test_an_outline_survives_a_json_round_trip(make, validate): + plane = make(outline=RED) + loaded = type(plane).model_validate_json(plane.model_dump_json()) + assert loaded.outline == RED + assert loaded.id == plane.id + # And as an item of a visual's tuple, from plain values. + assert validate([plane.model_dump(mode="json")])[0].outline == RED + + +@pytest.mark.parametrize("make", [_render_plane, _clipping_plane]) +def test_a_plane_saved_before_outlines_loads_with_none(make): + """A scene file written before the field has no ``outline`` key.""" + saved = make(outline=RED).model_dump(mode="json") + del saved["outline"] + assert type(make()).model_validate(saved).outline == NO_OUTLINE + + +# -- setting the outlines aside --------------------------------------------------- + + +@pytest.mark.parametrize("make", [_render_plane, _clipping_plane]) +def test_without_outlines_tells_a_restyle_from_a_move(make): + plane = make() + restyled = plane.model_copy(update={"outline": RED}) + assert without_outlines((plane,)) == without_outlines((restyled,)) + assert without_outlines((restyled,))[0].id == plane.id + + disabled = restyled.model_copy(update={"enabled": False}) + assert without_outlines((plane,)) != without_outlines((disabled,)) + other = make().model_copy(update={"id": uuid4()}) + assert without_outlines((plane,)) != without_outlines((other,)) + assert without_outlines((plane,)) != without_outlines((plane, other)) From 6ae9cdbee7b240188214397ccfcd48fb570ed88a Mon Sep 17 00:00:00 2001 From: Kevin Yamauchi Date: Fri, 9 Oct 2026 19:58:36 +0200 Subject: [PATCH 10/24] add clipping plane examples --- .../clipping_planes/clipping_plane_gizmo.py | 76 +++++++++++ .../ortho_clipping_planes_viewer.py | 121 ++++++++++++++++++ 2 files changed, 197 insertions(+) create mode 100644 examples/clipping_planes/clipping_plane_gizmo.py create mode 100644 examples/clipping_planes/ortho_clipping_planes_viewer.py diff --git a/examples/clipping_planes/clipping_plane_gizmo.py b/examples/clipping_planes/clipping_plane_gizmo.py new file mode 100644 index 00000000..d30924e4 --- /dev/null +++ b/examples/clipping_planes/clipping_plane_gizmo.py @@ -0,0 +1,76 @@ +"""Move a clipping plane by dragging a gizmo in the 3D view.""" + +from __future__ import annotations + +from uuid import uuid4 + +import numpy as np +from skimage.data import binary_blobs + +from cellier.convenience import ( + AppearanceControls, + InMemoryImageControlsConfig, + Layout, + Viewer, + axis_values_from_viewer, + run, +) +from cellier.convenience.gui import build_canvas_widget +from cellier.data import ImageMemoryStore +from cellier.scene.dims import spatial_axes +from cellier.transform import Axis, DataCoordinateSystem +from cellier.visuals import ClippingPlane, InMemoryImageSingleAppearance + +SIDE = 128 +AXES = ("z", "y", "x") + +blobs = binary_blobs(length=SIDE, n_dim=3, blob_size_fraction=0.2, rng=0) + +# The store owns the coordinate system its planes are written in. +store = ImageMemoryStore( + data=blobs.astype(np.float32), + name="image", + data_coordinate_systems=[ + DataCoordinateSystem( + name="image", + datastore_id=uuid4(), + axes=tuple( + Axis(name=n, axis_type="space", sampling="discrete") for n in AXES + ), + ) + ], +) +system = store.data_coordinate_systems[0] +centre = (SIDE / 2, SIDE / 2, SIDE / 2) +planes = ( + ClippingPlane.from_point_normal(system, centre, (0, 0, 1), axes=AXES), + # Off at first: its gizmo can place it before it clips. + ClippingPlane.from_point_normal( + system, centre, (0, 1, 0), axes=AXES, enabled=False + ), +) + +viewer = Viewer(spatial_axes(*AXES), dim="3d") +visual = viewer.add_image( + store, + single=InMemoryImageSingleAppearance( + color_map="viridis", clim=(0.0, 1.0), render_mode="iso", iso_threshold=0.5 + ), + name="image (ISO)", + clipping_planes=planes, + controls=InMemoryImageControlsConfig(appearance=True, clipping_controls=True), +) +visual.aabb.enabled = True + +canvas_widget = build_canvas_widget(viewer, axis_values_from_viewer(viewer)) +layout = Layout( + center=canvas_widget, + right_dock=AppearanceControls(), + right_dock_min_width=360, +) + +# The gizmo needs the plane on screen: add it once the first data is drawn. +viewer.on_ready(lambda: viewer.add_clipping_plane_gizmo(visual, planes[0])) + +if __name__ == "__main__": + run(viewer, layout, fit="ready") diff --git a/examples/clipping_planes/ortho_clipping_planes_viewer.py b/examples/clipping_planes/ortho_clipping_planes_viewer.py new file mode 100644 index 00000000..611f56da --- /dev/null +++ b/examples/clipping_planes/ortho_clipping_planes_viewer.py @@ -0,0 +1,121 @@ +"""Clipping planes in an OrthoViewer, linked across its panels. + +The right dock has the clipping plane controls. The "Link" combobox +selects how the clipping planes are shared between canvases: + +- "All views": one control per dataset moves the cut in all four panels, + and its "Gizmo" toggle puts a gizmo on a plane in the 3D panel. +- "2D views": the three 2D panels are linked and the 3D panel has its own + planes and its own control. +- "Not linked": one control per panel. +""" + +from __future__ import annotations + +from uuid import uuid4 + +import numpy as np + +from cellier.convenience import ( + InMemoryImageControlsConfig, + LabelsControlsConfig, + Layout, + OrthoClippingControls, + OrthoViewer, + axis_values_from_ortho, + run, +) +from cellier.convenience.gui import build_ortho_grid_widget +from cellier.data import ImageMemoryStore, LabelMemoryStore +from cellier.scene.dims import spatial_axes +from cellier.transform import Axis, DataCoordinateSystem +from cellier.visuals import ClippingPlane, InMemoryImageSingleAppearance + +SIDE = 96 +AXES = ("z", "y", "x") +BALL_RADIUS = 11 +BALL_OFFSET = 30 + +# --------------------------------------------------------------------------- +# Data +# --------------------------------------------------------------------------- + +# Six balls on the axes through the centre, one label each. +grid = np.indices((SIDE, SIDE, SIDE)) +centre = SIDE // 2 +balls = np.zeros((SIDE, SIDE, SIDE), dtype=np.int32) +ball_label = 0 +for axis in range(3): + for sign in (-1, 1): + ball_label += 1 + ball_centre = [centre, centre, centre] + ball_centre[axis] += sign * BALL_OFFSET + distance = np.sqrt(sum((g - c) ** 2 for g, c in zip(grid, ball_centre))) + balls[distance <= BALL_RADIUS] = ball_label + +# A soft ball at the centre, between the six: no voxel is in both datasets. +radius = np.sqrt(sum((g - centre) ** 2 for g in grid)) +core = np.exp(-((radius / 14.0) ** 2)).astype(np.float32) + + +def coordinate_system(name: str) -> DataCoordinateSystem: + """The system a store's clipping planes are written in.""" + return DataCoordinateSystem( + name=name, + datastore_id=uuid4(), + axes=tuple(Axis(name=n, axis_type="space", sampling="discrete") for n in AXES), + ) + + +core_system, balls_system = coordinate_system("core"), coordinate_system("balls") +core_store = ImageMemoryStore( + data=core, name="core", data_coordinate_systems=[core_system] +) +balls_store = LabelMemoryStore(data=balls, data_coordinate_systems=[balls_system]) + + +def plane_across_x(system: DataCoordinateSystem) -> ClippingPlane: + """Keep ``x >= centre``.""" + return ClippingPlane.from_point_normal(system, (centre,), (1.0,), axes=("x",)) + + +# --------------------------------------------------------------------------- +# Orthoviewer model +# --------------------------------------------------------------------------- + +# "all" is the default; "2d" and None are the other modes. +viewer = OrthoViewer(spatial_axes(*AXES), link_clipping_planes="all") + +# clipping_controls=True puts a dataset in the OrthoClippingControls() dock. +# The labels are added before the image: added after it, the image's black +# background covers them in the 2D panels. +viewer.add_labels( + balls_store, + name="balls", + clipping_planes=(plane_across_x(balls_system),), + controls=LabelsControlsConfig(appearance=False, clipping_controls=True), +) +viewer.add_image( + core_store, + single=InMemoryImageSingleAppearance( + color_map="gray", clim=(0.0, 1.0), render_mode="iso", iso_threshold=0.5 + ), + name="core", + clipping_planes=(plane_across_x(core_system),), + controls=InMemoryImageControlsConfig(appearance=False, clipping_controls=True), +) +viewer.center_slices() + +# --------------------------------------------------------------------------- +# Canvas + layout +# --------------------------------------------------------------------------- + +canvas_widgets = build_ortho_grid_widget(viewer, axis_values_from_ortho(viewer)) +layout = Layout( + center=canvas_widgets, + right_dock=OrthoClippingControls(), + right_dock_min_width=380, +) + +if __name__ == "__main__": + run(viewer, layout, fit="ready") From 4460e6833442ec2a263f26e3771be34c335eaa39 Mon Sep 17 00:00:00 2001 From: Kevin Yamauchi Date: Fri, 9 Oct 2026 21:24:14 +0200 Subject: [PATCH 11/24] add plane rendering examples --- .../plane_rendering/ortho_slice_planes.py | 146 +++++ examples/plane_rendering/plane_gizmo.py | 144 +++++ .../plane_interaction_script.py | 225 +++++++ .../plane_rendering/plane_labels_composite.py | 142 +++++ .../plane_slicing_validation.py | 399 +++++++++++++ .../plane_slicing_validation_marimo.py | 556 ++++++++++++++++++ 6 files changed, 1612 insertions(+) create mode 100644 examples/plane_rendering/ortho_slice_planes.py create mode 100644 examples/plane_rendering/plane_gizmo.py create mode 100644 examples/plane_rendering/plane_interaction_script.py create mode 100644 examples/plane_rendering/plane_labels_composite.py create mode 100644 examples/plane_rendering/plane_slicing_validation.py create mode 100644 examples/plane_rendering/plane_slicing_validation_marimo.py diff --git a/examples/plane_rendering/ortho_slice_planes.py b/examples/plane_rendering/ortho_slice_planes.py new file mode 100644 index 00000000..c811bd23 --- /dev/null +++ b/examples/plane_rendering/ortho_slice_planes.py @@ -0,0 +1,146 @@ +"""A mulitscale orthoviewer where the 3d view has each 2d view rendered as a plane.""" + +from __future__ import annotations + +import tempfile +from pathlib import Path + +import numpy as np +import tensorstore as ts +from skimage.data import binary_blobs + +from cellier.convenience import ( + AppearanceControls, + Layout, + MultiscaleImageControlsConfig, + OrthoViewer, + axis_values_from_ortho, + run, +) +from cellier.convenience.gui import build_ortho_grid_widget +from cellier.data.image._zarr_multiscale_store import MultiscaleZarrDataStore +from cellier.scene.dims import spatial_axes +from cellier.visuals import ( + MultiscaleImageAppearance, + MultiscaleImageRenderConfig, + MultiscaleImageSingleAppearance, + PlaneOutline, +) + +SIDE = 128 +AXES = ("z", "y", "x") +#: Downscale factor of each level, finest first. +FACTORS = (1, 2, 4) + +# --------------------------------------------------------------------------- +# Data helpers +# --------------------------------------------------------------------------- + + +def block_average(array: np.ndarray, factor: int) -> np.ndarray: + """Downscale by averaging ``factor``-sized blocks.""" + if factor == 1: + return array + n = array.shape[0] // factor + blocks = array.reshape(n, factor, n, factor, n, factor) + return blocks.mean(axis=(1, 3, 5)).astype(array.dtype) + + +def write_pyramid(root: Path, levels: list[np.ndarray]) -> list[str]: + """Write one zarr v3 array per level under *root*; returns their names.""" + names = [] + for index, data in enumerate(levels): + name = f"s{index}" + spec = { + "driver": "zarr3", + "kvstore": {"driver": "file", "path": str(root / name)}, + "metadata": { + "shape": list(data.shape), + "data_type": str(data.dtype), + "chunk_grid": { + "name": "regular", + "configuration": {"chunk_shape": [16, 16, 16]}, + }, + }, + "create": True, + "delete_existing": True, + } + ts.open(spec).result()[...].write(data).result() + names.append(name) + return names + + +# --------------------------------------------------------------------------- +# Store +# --------------------------------------------------------------------------- + +blobs = binary_blobs(length=SIDE, n_dim=3, blob_size_fraction=0.2, rng=0) +levels = [block_average(blobs.astype(np.float32), factor) for factor in FACTORS] + +root = Path(tempfile.mkdtemp()) / "image" +root.mkdir() +store = MultiscaleZarrDataStore.from_scale_and_translation( + zarr_path=str(root), + scale_names=write_pyramid(root, levels), + level_scales=[(float(f),) * 3 for f in FACTORS], + # A level-k voxel centre sits at (k_factor - 1) / 2 level-0 voxels. + level_translations=[((f - 1) / 2.0,) * 3 for f in FACTORS], + name="image", +) + +# --------------------------------------------------------------------------- +# Orthoviewer model +# --------------------------------------------------------------------------- + +viewer = OrthoViewer(spatial_axes(*AXES)) + +# One call makes four visuals, one per panel. render_mode="plane" is given +# to all four and acts in the 3D panel alone. +visuals = viewer.add_image_multiscale( + store, + appearance=MultiscaleImageAppearance(), + single=MultiscaleImageSingleAppearance( + color_map="viridis", clim=(0.0, 1.0), render_mode="plane" + ), + render_config=MultiscaleImageRenderConfig(block_size=16), + name="image", + controls=MultiscaleImageControlsConfig( + appearance=["visible", "color_map", "render_mode", "level_of_detail"], + render_plane_controls=True, + ), +) +vol_visual = visuals["vol"] +vol_visual.aabb.enabled = True + +# Put the 2D slices in the middle, then tie the 3D panel's planes to them. +# The mode writes the three planes; it sets no render mode. +# +# The positions are whole voxels on purpose. viewer.center_slices() would +# give 63.5 here, a voxel face: a 2D panel rounds that to one slice, but a +# plane drawn with nearest interpolation picks the voxel on either side +# pixel by pixel, and looks speckled. +viewer.set_slice_positions(dict.fromkeys(range(3), SIDE / 2)) +viewer.plane_controller.mode = "slices" + +# An outline round each slice in the 3D panel, in a colour of its own: the +# planes of the xy, xz and yz panels. The "Render planes" control shows the +# slice planes read-only, so their outlines are set here. +viewer.plane_controller.outlines = ( + PlaneOutline(enabled=True, color=(1.0, 0.8, 0.2, 1.0)), + PlaneOutline(enabled=True, color=(0.3, 0.9, 1.0, 1.0)), + PlaneOutline(enabled=True, color=(1.0, 0.4, 0.8, 1.0)), +) + +# --------------------------------------------------------------------------- +# Canvas + layout +# --------------------------------------------------------------------------- + +canvas_widgets = build_ortho_grid_widget(viewer, axis_values_from_ortho(viewer)) +layout = Layout( + center=canvas_widgets, + right_dock=AppearanceControls(), + right_dock_min_width=380, +) + +if __name__ == "__main__": + run(viewer, layout, fit="ready") diff --git a/examples/plane_rendering/plane_gizmo.py b/examples/plane_rendering/plane_gizmo.py new file mode 100644 index 00000000..8384e290 --- /dev/null +++ b/examples/plane_rendering/plane_gizmo.py @@ -0,0 +1,144 @@ +"""Control the plane being rendered in a multiscale image with a gizmo.""" + +from __future__ import annotations + +import tempfile +from pathlib import Path + +import numpy as np +import tensorstore as ts +from skimage.data import binary_blobs + +from cellier.convenience import ( + AppearanceControls, + Layout, + MultiscaleImageControlsConfig, + Viewer, + axis_values_from_viewer, + run, +) +from cellier.convenience.gui import build_canvas_widget +from cellier.data.image._zarr_multiscale_store import MultiscaleZarrDataStore +from cellier.scene.dims import spatial_axes +from cellier.visuals import ( + MultiscaleImageAppearance, + MultiscaleImageRenderConfig, + MultiscaleImageSingleAppearance, + RenderPlane, +) + +SIDE = 128 +AXES = ("z", "y", "x") +#: Downscale factor of each level, finest first. +FACTORS = (1, 2, 4) + +# --------------------------------------------------------------------------- +# Data helpers +# --------------------------------------------------------------------------- + + +def block_average(array: np.ndarray, factor: int) -> np.ndarray: + """Downscale by averaging ``factor``-sized blocks.""" + if factor == 1: + return array + n = array.shape[0] // factor + blocks = array.reshape(n, factor, n, factor, n, factor) + return blocks.mean(axis=(1, 3, 5)).astype(array.dtype) + + +def write_pyramid(root: Path, levels: list[np.ndarray]) -> list[str]: + """Write one zarr v3 array per level under *root*; returns their names.""" + names = [] + for index, data in enumerate(levels): + name = f"s{index}" + spec = { + "driver": "zarr3", + "kvstore": {"driver": "file", "path": str(root / name)}, + "metadata": { + "shape": list(data.shape), + "data_type": str(data.dtype), + "chunk_grid": { + "name": "regular", + "configuration": {"chunk_shape": [16, 16, 16]}, + }, + }, + "create": True, + "delete_existing": True, + } + ts.open(spec).result()[...].write(data).result() + names.append(name) + return names + + +# --------------------------------------------------------------------------- +# Store +# --------------------------------------------------------------------------- + +blobs = binary_blobs(length=SIDE, n_dim=3, blob_size_fraction=0.2, rng=0) +levels = [block_average(blobs.astype(np.float32), factor) for factor in FACTORS] + +root = Path(tempfile.mkdtemp()) / "image" +root.mkdir() +store = MultiscaleZarrDataStore.from_scale_and_translation( + zarr_path=str(root), + scale_names=write_pyramid(root, levels), + level_scales=[(float(f),) * 3 for f in FACTORS], + # A level-k voxel centre sits at (k_factor - 1) / 2 level-0 voxels. + level_translations=[((f - 1) / 2.0,) * 3 for f in FACTORS], + name="image", +) + +# --------------------------------------------------------------------------- +# Viewer, plane and visual +# --------------------------------------------------------------------------- + +viewer = Viewer(spatial_axes(*AXES), dim="3d") + +# A render plane is written in the scene's world coordinates, on three named +# world axes. Its rectangle is 96 world units a side, about the plane's +# origin; an extent side left as None would run to the edge of the data. +world = viewer.scene.dims.world_coordinate_system +plane = RenderPlane.from_point_normal( + world, + point=(SIDE / 2, SIDE / 2, SIDE / 2), + normal=(1.0, 0.0, 0.5), + axes=AXES, + extent_0=(-48.0, 48.0), + extent_1=(-48.0, 48.0), +) + +visual = viewer.add_image_multiscale( + store, + appearance=MultiscaleImageAppearance(), + single=MultiscaleImageSingleAppearance( + color_map="viridis", clim=(0.0, 1.0), render_mode="plane" + ), + render_config=MultiscaleImageRenderConfig(block_size=16), + name="image (plane)", + render_planes=(plane,), + controls=MultiscaleImageControlsConfig( + appearance=["visible", "render_mode", "color_map", "level_of_detail"], + render_plane_controls=True, + loading_indicator=True, + ), +) +# The box of the whole data: the plane is cut by it. +visual.aabb.enabled = True + + +# --------------------------------------------------------------------------- +# Canvas + layout +# --------------------------------------------------------------------------- + +canvas_widget = build_canvas_widget(viewer, axis_values_from_viewer(viewer)) +layout = Layout( + center=canvas_widget, + right_dock=AppearanceControls(), + right_dock_min_width=380, +) + +# The gizmo needs the plane on screen: add it once the first data is drawn. +viewer.on_ready(lambda: viewer.add_render_plane_gizmo(visual, plane)) + +if __name__ == "__main__": + run(viewer, layout, fit="ready") diff --git a/examples/plane_rendering/plane_interaction_script.py b/examples/plane_rendering/plane_interaction_script.py new file mode 100644 index 00000000..99f618e3 --- /dev/null +++ b/examples/plane_rendering/plane_interaction_script.py @@ -0,0 +1,225 @@ +"""Move a render plane and a clipping plane from a script, as one drag.""" + +from __future__ import annotations + +import asyncio +import contextlib +import tempfile +from pathlib import Path +from uuid import uuid4 + +import numpy as np +import tensorstore as ts +from skimage.data import binary_blobs + +from cellier.convenience import ( + AppearanceControls, + Layout, + MultiscaleImageControlsConfig, + Viewer, + axis_values_from_viewer, + run, +) +from cellier.convenience.gui import build_canvas_widget +from cellier.data.image._zarr_multiscale_store import MultiscaleZarrDataStore +from cellier.scene.dims import spatial_axes +from cellier.transform import Axis, DataCoordinateSystem +from cellier.visuals import ( + ClippingPlane, + MultiscaleImageAppearance, + MultiscaleImageRenderConfig, + MultiscaleImageSingleAppearance, + RenderPlane, +) + +SIDE = 128 +AXES = ("z", "y", "x") +#: Downscale factor of each level, finest first. +FACTORS = (1, 2, 4) + +# --------------------------------------------------------------------------- +# Data helpers +# --------------------------------------------------------------------------- + + +def block_average(array: np.ndarray, factor: int) -> np.ndarray: + """Downscale by averaging ``factor``-sized blocks.""" + if factor == 1: + return array + n = array.shape[0] // factor + blocks = array.reshape(n, factor, n, factor, n, factor) + return blocks.mean(axis=(1, 3, 5)).astype(array.dtype) + + +def write_pyramid(root: Path, levels: list[np.ndarray]) -> list[str]: + """Write one zarr v3 array per level under *root*; returns their names.""" + names = [] + for index, data in enumerate(levels): + name = f"s{index}" + spec = { + "driver": "zarr3", + "kvstore": {"driver": "file", "path": str(root / name)}, + "metadata": { + "shape": list(data.shape), + "data_type": str(data.dtype), + "chunk_grid": { + "name": "regular", + "configuration": {"chunk_shape": [16, 16, 16]}, + }, + }, + "create": True, + "delete_existing": True, + } + ts.open(spec).result()[...].write(data).result() + names.append(name) + return names + + +#: Steps of each of the two motions, and the pause between two steps. +STEPS = 20 +STEP_S = 1 / 30 +#: False moves the planes with no interaction scope: every step is a jump. +USE_SCOPE = True + +# --------------------------------------------------------------------------- +# Store +# --------------------------------------------------------------------------- + +blobs = binary_blobs(length=SIDE, n_dim=3, blob_size_fraction=0.2, rng=0) +levels = [block_average(blobs.astype(np.float32), factor) for factor in FACTORS] + +# The store owns the coordinate system its clipping planes are written in. +system = DataCoordinateSystem( + name="image", + datastore_id=uuid4(), + axes=tuple(Axis(name=n, axis_type="space", sampling="discrete") for n in AXES), +) +root = Path(tempfile.mkdtemp()) / "image" +root.mkdir() +store = MultiscaleZarrDataStore.from_scale_and_translation( + zarr_path=str(root), + scale_names=write_pyramid(root, levels), + level_scales=[(float(f),) * 3 for f in FACTORS], + # A level-k voxel centre sits at (k_factor - 1) / 2 level-0 voxels. + level_translations=[((f - 1) / 2.0,) * 3 for f in FACTORS], + data_coordinate_system=system, + name="image", +) + +# --------------------------------------------------------------------------- +# Viewer, planes and visual +# --------------------------------------------------------------------------- + +viewer = Viewer(spatial_axes(*AXES), dim="3d") +controller = viewer.controller + +# The render plane is in world coordinates and faces z; it runs to the edge +# of the data on every side (the extents are left unbounded). +world = viewer.scene.dims.world_coordinate_system +Z_START, Z_END = 0.25 * SIDE, 0.75 * SIDE +render_plane = RenderPlane.from_point_normal( + world, point=(Z_START, SIDE / 2, SIDE / 2), normal=(1, 0, 0), axes=AXES +) + +# The clipping plane is in the store's data coordinates and keeps the side +# its normal points to: x >= X_START. It cuts the render plane as it cuts +# a volume rendering. +X_START, X_END = 0.0, 0.5 * SIDE +clipping_plane = ClippingPlane.from_point_normal( + system, (0, 0, X_START), (0, 0, 1), axes=AXES +) + +visual = viewer.add_image_multiscale( + store, + appearance=MultiscaleImageAppearance(), + single=MultiscaleImageSingleAppearance( + color_map="viridis", clim=(0.0, 1.0), render_mode="plane" + ), + render_config=MultiscaleImageRenderConfig(block_size=16), + name="image (plane)", + render_planes=(render_plane,), + clipping_planes=(clipping_plane,), + controls=MultiscaleImageControlsConfig( + appearance=["visible", "level_of_detail"], + render_plane_controls=True, + clipping_controls=True, + loading_indicator=True, + ), +) +visual.aabb.enabled = True + +# --------------------------------------------------------------------------- +# The motion +# --------------------------------------------------------------------------- + + +async def move_planes() -> None: + """Slide the render plane along z, then the clipping plane along x.""" + scope = ( + controller.plane_interaction(visual.id) + if USE_SCOPE + else contextlib.nullcontext() + ) + with scope: + for z in np.linspace(Z_START, Z_END, STEPS): + # A plane is frozen: a move is a copy, which keeps the id. + moved = render_plane.model_copy( + update={"origin": (float(z), SIDE / 2, SIDE / 2)} + ) + controller.set_render_plane(visual.id, render_plane.id, moved) + # Give the event loop a frame to draw. + await asyncio.sleep(STEP_S) + for x in np.linspace(X_START, X_END, STEPS): + moved = clipping_plane.plane.model_copy(update={"offset": float(x)}) + controller.set_clipping_plane(visual.id, clipping_plane.id, moved) + await asyncio.sleep(STEP_S) + + +# --------------------------------------------------------------------------- +# Attach a callback to the plane and slicing events +# --------------------------------------------------------------------------- + +owner_id = uuid4() + + +def print_interaction(event) -> None: + """Print the start and the end of each interaction.""" + reason = "" if event.reason is None else f" ({event.reason})" + print(f"plane interaction: {event.phase}{reason}") + + +def print_plan(event) -> None: + """Print each plan that includes the visual.""" + if visual.id in event.visual_ids: + print("plan: the visual's bricks are planned") + + +controller.on_plane_interaction(visual.id, print_interaction, owner_id=owner_id) +controller.on_reslice_started(viewer.scene.id, print_plan, owner_id=owner_id) + +# --------------------------------------------------------------------------- +# Canvas + layout +# --------------------------------------------------------------------------- + +canvas_widget = build_canvas_widget(viewer, axis_values_from_viewer(viewer)) +layout = Layout( + center=canvas_widget, + right_dock=AppearanceControls(), + right_dock_min_width=380, +) + +#: The running motion: a task is kept, or the event loop may drop it. +motions: list[asyncio.Task] = [] + + +def start_motion() -> None: + """Start the motion on the running event loop.""" + print("-- the motion starts --") + motions.append(asyncio.ensure_future(move_planes())) + + +# Move once the first data is drawn. +viewer.on_ready(start_motion) + +if __name__ == "__main__": + run(viewer, layout, fit="ready") diff --git a/examples/plane_rendering/plane_labels_composite.py b/examples/plane_rendering/plane_labels_composite.py new file mode 100644 index 00000000..e6e6fa44 --- /dev/null +++ b/examples/plane_rendering/plane_labels_composite.py @@ -0,0 +1,142 @@ +"""Labels and a two-channel composite image drawn on planes, in memory.""" + +from __future__ import annotations + +from uuid import uuid4 + +import numpy as np +from scipy.ndimage import gaussian_filter, gaussian_gradient_magnitude +from skimage.data import binary_blobs +from skimage.measure import label + +from cellier.convenience import ( + AppearanceControls, + InMemoryImageControlsConfig, + LabelsControlsConfig, + Layout, + Viewer, + axis_values_from_viewer, + run, +) +from cellier.convenience.gui import build_canvas_widget +from cellier.data import ImageMemoryStore, LabelMemoryStore +from cellier.scene.dims import spatial_axes +from cellier.transform import AffineTransform, Axis, DataCoordinateSystem +from cellier.visuals import ( + InMemoryImageChannelAppearance, + InMemoryLabelsAppearance, + RenderPlane, +) + +SIDE = 96 +AXES = ("z", "y", "x") +#: Where the labels sit along x, in world units. +LABELS_X = 1.2 * SIDE + +# --------------------------------------------------------------------------- +# Data +# --------------------------------------------------------------------------- + +# Sparse enough that the blobs are separate and get a label each. +blobs = binary_blobs( + length=SIDE, n_dim=3, blob_size_fraction=0.25, volume_fraction=0.2, rng=0 +).astype(np.float32) + + +def normalised(array: np.ndarray) -> np.ndarray: + """Scale to 0..1.""" + return ((array - array.min()) / (array.max() - array.min())).astype(np.float32) + + +# Channel 0: the blob interiors. Channel 1: the blob boundaries. +interiors = normalised(gaussian_filter(blobs, 1.5)) +boundaries = normalised(gaussian_gradient_magnitude(blobs, 1.5)) +cells = np.stack([interiors, boundaries]) # (c, z, y, x) + +image_store = ImageMemoryStore(data=cells, name="cells") +labels_store = LabelMemoryStore( + data=label(blobs > 0.5).astype(np.int32), + data_coordinate_systems=[ + DataCoordinateSystem( + name="labels", + datastore_id=uuid4(), + axes=tuple( + Axis(name=n, axis_type="space", sampling="discrete") for n in AXES + ), + ) + ], +) + +# --------------------------------------------------------------------------- +# Viewer and planes +# --------------------------------------------------------------------------- + +# The channel axis is a world axis: a composite draws along it, and a plane +# on (z, y, x) does not constrain it. +viewer = Viewer([("c", "channel"), *spatial_axes(*AXES)], dim="3d") +world = viewer.scene.dims.world_coordinate_system + + +def planes_at(x_offset: float) -> tuple[RenderPlane, RenderPlane]: + """Two planes through the middle of a volume that starts at *x_offset*.""" + centre = (SIDE / 2, SIDE / 2, x_offset + SIDE / 2) + return ( + RenderPlane.from_point_normal(world, centre, (1, 0, 0), axes=AXES), + RenderPlane.from_point_normal(world, centre, (0, 1, 1), axes=AXES), + ) + + +# --------------------------------------------------------------------------- +# Visuals +# --------------------------------------------------------------------------- + +image_visual = viewer.add_image( + image_store, + name="cells", + channel_axis=0, + composite=True, + # One render mode for every channel: a mix is refused. + channels={ + 0: InMemoryImageChannelAppearance( + color_map="magenta", clim=(0.0, 1.0), render_mode="plane" + ), + 1: InMemoryImageChannelAppearance( + color_map="green", clim=(0.0, 1.0), render_mode="plane" + ), + }, + render_planes=planes_at(0.0), + controls=InMemoryImageControlsConfig(appearance=True, render_plane_controls=True), +) + +labels_visual = viewer.add_labels( + labels_store, + appearance=InMemoryLabelsAppearance(render_mode="plane"), + name="labels", + transform=AffineTransform.from_axis_map( + labels_store.data_coordinate_systems[0], + world, + axis_map={name: name for name in AXES}, + translation={"x": LABELS_X}, + # The labels have no channel axis: they are the same on every channel. + broadcast_output_axes=("c",), + ), + render_planes=planes_at(LABELS_X), + controls=LabelsControlsConfig(appearance=True, render_plane_controls=True), +) + +for _visual in (image_visual, labels_visual): + _visual.aabb.enabled = True + +# --------------------------------------------------------------------------- +# Canvas + layout +# --------------------------------------------------------------------------- + +canvas_widget = build_canvas_widget(viewer, axis_values_from_viewer(viewer)) +layout = Layout( + center=canvas_widget, + right_dock=AppearanceControls(presentation="collapsible_sections"), + right_dock_min_width=380, +) + +if __name__ == "__main__": + run(viewer, layout, fit="ready") diff --git a/examples/plane_rendering/plane_slicing_validation.py b/examples/plane_rendering/plane_slicing_validation.py new file mode 100644 index 00000000..86aad2c7 --- /dev/null +++ b/examples/plane_rendering/plane_slicing_validation.py @@ -0,0 +1,399 @@ +"""Plane rendering validation example. + +A `tczyx` scene with three time points and three visuals that read three +different stores: + +- points: eight markers per time point, one in each octant + of the volume; +- labels: a sphere centred on each point; +- image, multiscale, three channels rendered as a composite: a larger + sphere centred on each point. + +Each octant has a colour of its own at each time point. The points, +labels, and image should be aligned and have the same color at each time point. +""" + +from __future__ import annotations + +import colorsys +import tempfile +from pathlib import Path +from uuid import uuid4 + +import numpy as np +import tensorstore as ts + +from cellier.convenience import ( + AppearanceControls, + Layout, + MultiscaleImageControlsConfig, + MultiscaleLabelsControlsConfig, + PointsControlsConfig, + Viewer, + axis_values_from_viewer, + run, +) +from cellier.convenience.gui import build_canvas_widget +from cellier.data import PointsMemoryStore +from cellier.data.image._zarr_multiscale_store import MultiscaleZarrDataStore +from cellier.render import OutlineConfig, RenderManagerConfig +from cellier.scene.dims import spatial_axes +from cellier.transform import AffineTransform, Axis, DataCoordinateSystem +from cellier.visuals import ( + MultiscaleImageAppearance, + MultiscaleImageChannelAppearance, + MultiscaleImageRenderConfig, + MultiscaleLabelRenderConfig, + MultiscaleLabelsAppearance, + PlaneOutline, + PointsMarkerAppearance, + RenderPlane, + VisualOutline, +) + +SPACE = ("z", "y", "x") +N_TIMES = 3 +#: Level-0 voxels along z, y and x, and the world size of one of them. +SHAPE = (64, 128, 128) +VOXEL_SIZE = (2.0, 1.0, 1.0) +#: The volume's side in world units, the same on every axis. +SIDE = 128.0 +#: Downscale factor of each level along z, y and x. z is never downsampled. +FACTORS = ((1, 1, 1), (1, 2, 2), (1, 4, 4)) +#: Sphere radii, in world units. +LABEL_RADIUS = 10.0 +IMAGE_RADIUS = 18.0 +#: The image holds its octant's colour at this fraction of full intensity, +#: so that a label disc can be told from the image disc around it. +IMAGE_INTENSITY = 0.5 +#: Where the points sit, per time point, from the centre of their octant +#: (z, y, x; world units). Even numbers, so that a plane through a point is +#: through voxel centres and not along a voxel face. +OFFSETS = ((-8.0, -6.0, 8.0), (8.0, 10.0, -6.0), (0.0, -10.0, 10.0)) + +# --------------------------------------------------------------------------- +# What is where: the one table the three stores are built from +# --------------------------------------------------------------------------- + + +def octant_colors() -> np.ndarray: + """A colour per time point and octant, shape ``(N_TIMES, 8, 3)``. + + 24 hues around the colour wheel, dealt out so that the eight octants of + a time point are far apart in hue and no octant keeps its colour from + one time point to the next. + """ + colors = np.empty((N_TIMES, 8, 3), dtype=np.float32) + for t in range(N_TIMES): + for octant in range(8): + hue = (octant * N_TIMES + t) / (8 * N_TIMES) + colors[t, octant] = colorsys.hsv_to_rgb(hue, 1.0, 1.0) + return colors + + +def point_positions() -> np.ndarray: + """The points' world ``(z, y, x)``, shape ``(N_TIMES, 8, 3)``. + + Octant ``k`` is the low or high half of z, y and x by the bits of ``k``. + """ + positions = np.empty((N_TIMES, 8, 3), dtype=np.float32) + for t in range(N_TIMES): + for octant in range(8): + for axis in range(3): + high = (octant >> (2 - axis)) & 1 + centre = SIDE * (0.25 + 0.5 * high) + positions[t, octant, axis] = centre + OFFSETS[t][axis] + return positions + + +COLORS = octant_colors() +POSITIONS = point_positions() + + +def label_id(t: int, octant: int) -> int: + """The label of an octant's sphere: a value of its own per time point.""" + return 1 + 8 * t + octant + + +def srgb_to_linear(color: np.ndarray) -> np.ndarray: + """Undo the sRGB encoding of a colour. + + A label's ``color_dict`` entry is taken as linear light and encoded for + the screen, where a point's colour and a colour map's are taken as they + are. The labels are given the decoded colour, so that the three look + the same. + """ + color = np.asarray(color, dtype=np.float64) + low = color / 12.92 + high = ((color + 0.055) / 1.055) ** 2.4 + return np.where(color <= 0.04045, low, high) + + +# --------------------------------------------------------------------------- +# Data helpers +# --------------------------------------------------------------------------- + + +def level_world_coordinates(factor: tuple[int, int, int]) -> list[np.ndarray]: + """The world coordinate of every voxel centre of a level, per axis. + + A level voxel covers ``factor`` level-0 voxels, so its centre is at + level-0 index ``i * factor + (factor - 1) / 2``. + """ + grids = [] + for size, f, voxel in zip(SHAPE, factor, VOXEL_SIZE): + index = np.arange(size // f, dtype=np.float32) * f + (f - 1) / 2.0 + grids.append(index * voxel) + return grids + + +def render_level(factor: tuple[int, int, int]) -> tuple[np.ndarray, np.ndarray]: + """One level: the image ``(t, c, z, y, x)`` and the labels ``(t, z, y, x)``. + + Each level is drawn from the table, not downsampled from the level + below, so every level holds spheres about the same world centres. + """ + z, y, x = np.meshgrid(*level_world_coordinates(factor), indexing="ij") + shape = z.shape + image = np.zeros((N_TIMES, 3, *shape), dtype=np.float32) + labels = np.zeros((N_TIMES, *shape), dtype=np.int32) + for t in range(N_TIMES): + for octant in range(8): + cz, cy, cx = POSITIONS[t, octant] + distance = np.sqrt((z - cz) ** 2 + (y - cy) ** 2 + (x - cx) ** 2) + inside = distance <= IMAGE_RADIUS + for channel in range(3): + value = IMAGE_INTENSITY * COLORS[t, octant, channel] + image[t, channel][inside] = value + labels[t][distance <= LABEL_RADIUS] = label_id(t, octant) + return image, labels + + +def write_pyramid(root: Path, levels: list[np.ndarray]) -> list[str]: + """Write one zarr v3 array per level under *root*; returns their names.""" + root.mkdir() + names = [] + for index, data in enumerate(levels): + name = f"s{index}" + # One time point and one channel a chunk; 16 voxels a side in space. + chunks = [1] * (data.ndim - 3) + [16, 16, 16] + spec = { + "driver": "zarr3", + "kvstore": {"driver": "file", "path": str(root / name)}, + "metadata": { + "shape": list(data.shape), + "data_type": str(data.dtype), + "chunk_grid": { + "name": "regular", + "configuration": {"chunk_shape": chunks}, + }, + }, + "create": True, + "delete_existing": True, + } + ts.open(spec).result()[...].write(data).result() + names.append(name) + return names + + +def data_system(name: str, axes: tuple[str, ...]) -> DataCoordinateSystem: + """A store's level-0 coordinate system: voxel indices on *axes*.""" + kinds = {"t": "time", "c": "channel"} + return DataCoordinateSystem( + name=name, + datastore_id=uuid4(), + axes=tuple( + Axis(name=n, axis_type=kinds.get(n, "space"), sampling="discrete") + for n in axes + ), + ) + + +def pyramid_store( + root: Path, levels: list[np.ndarray], name: str, axes: tuple[str, ...] +) -> MultiscaleZarrDataStore: + """A multiscale zarr store over *levels*, whose last three axes are space.""" + lead = len(axes) - 3 + return MultiscaleZarrDataStore.from_scale_and_translation( + zarr_path=str(root), + scale_names=write_pyramid(root, levels), + level_scales=[(1.0,) * lead + tuple(float(f) for f in fs) for fs in FACTORS], + level_translations=[ + (0.0,) * lead + tuple((f - 1) / 2.0 for f in fs) for fs in FACTORS + ], + data_coordinate_system=data_system(name, axes), + name=name, + ) + + +# --------------------------------------------------------------------------- +# Stores +# --------------------------------------------------------------------------- + +rendered = [render_level(factor) for factor in FACTORS] +tmpdir = Path(tempfile.mkdtemp()) +image_store = pyramid_store( + tmpdir / "image", [image for image, _ in rendered], "image", ("t", "c", *SPACE) +) +labels_store = pyramid_store( + tmpdir / "labels", [labels for _, labels in rendered], "labels", ("t", *SPACE) +) + +# One row per point: (t, z, y, x), in world units already. +times = np.repeat(np.arange(N_TIMES, dtype=np.float32), 8)[:, None] +points_store = PointsMemoryStore( + positions=np.hstack([times, POSITIONS.reshape(-1, 3)]), + colors=np.hstack([COLORS.reshape(-1, 3), np.ones((N_TIMES * 8, 1), np.float32)]), + name="points", + data_coordinate_systems=[data_system("points", ("t", *SPACE))], +) + +# --------------------------------------------------------------------------- +# Viewer +# --------------------------------------------------------------------------- + +# A 3D view displays the last three axes; t has a slider, and the composite +# draws along c. +# The outline pass is on for the markers alone (below): a marker has its +# label's colour, and without a contour it could not be seen on the label. +viewer = Viewer( + [("t", "time"), ("c", "channel"), *spatial_axes(*SPACE)], + dim="3d", + render_config=RenderManagerConfig( + outline=OutlineConfig(enabled=True, palette=[(1.0, 1.0, 1.0, 1.0)]) + ), +) +world = viewer.scene.dims.world_coordinate_system + + +def to_world(store, *, voxels: bool) -> AffineTransform: + """A store's data axes onto the world axes of the same names. + + The pyramids are in voxels, so their space axes are scaled by the voxel + size; the points are in world units already. A store with no channel + axis is the same on every channel. + """ + system = store.data_coordinate_systems[0] + names = [axis.name for axis in system.axes] + return AffineTransform.from_axis_map( + system, + world, + axis_map={name: name for name in names}, + scale=dict(zip(SPACE, VOXEL_SIZE)) if voxels else None, + broadcast_output_axes=() if "c" in names else ("c",), + ) + + +# One plane for the image and the labels: a plane is a value in world +# coordinates, so two visuals of a scene can be given the same one. +# +# Its origin is on a point: the one in the octant that is the high half of +# z, y and x, at t = 0. The control turns a plane about its origin, and the +# points of a time point are at one place in every octant, so the plane +# facing z, y or x still passes through four points. (An origin at the +# centre of the volume is between the spheres: a plane turned there cuts +# none of them, and an empty plane is black on black.) +# +# The plane has an outline: a yellow line where it ends at the data's box. +# Most of the plane is background, black on black; the outline shows where +# it is. The two visuals have the same box, so there is one line. +plane = RenderPlane.from_point_normal( + world, + point=POSITIONS[0, 7], + normal=(1, 0, 0), + axes=SPACE, + outline=PlaneOutline(enabled=True, color=(1.0, 0.8, 0.2, 1.0)), +) + +# --------------------------------------------------------------------------- +# Visuals +# --------------------------------------------------------------------------- + +image_visual = viewer.add_image_multiscale( + image_store, + appearance=MultiscaleImageAppearance(), + name="image", + render_config=MultiscaleImageRenderConfig(block_size=16), + transform=to_world(image_store, voxels=True), + channel_axis=1, + composite=True, + # A channel per colour component: added, the three give the colour back. + channels={ + channel: MultiscaleImageChannelAppearance( + color_map=color_map, clim=(0.0, 1.0), render_mode="plane" + ) + for channel, color_map in enumerate(("red", "green", "blue")) + }, + render_planes=(plane,), + controls=MultiscaleImageControlsConfig( + appearance=["visible", "render_mode", "level_of_detail"], + render_plane_controls=True, + ), +) + +labels_visual = viewer.add_labels_multiscale( + labels_store, + MultiscaleLabelsAppearance( + render_mode="plane", + colormap_mode="direct", + color_dict={ + label_id(t, octant): (*srgb_to_linear(COLORS[t, octant]).tolist(), 1.0) + for t in range(N_TIMES) + for octant in range(8) + }, + ), + name="labels", + render_config=MultiscaleLabelRenderConfig(block_size=16), + transform=to_world(labels_store, voxels=True), + render_planes=(plane,), + controls=MultiscaleLabelsControlsConfig( + appearance=True, render_plane_controls=True + ), +) + +points_visual = viewer.add_points( + points_store, + PointsMarkerAppearance(size=10.0, size_space="screen", color_mode="vertex"), + name="points", + transform=to_world(points_store, voxels=False), + # A white contour, in the palette's first colour. + outline=VisualOutline(slot=1), + controls=PointsControlsConfig(appearance=True), +) + +image_visual.aabb.enabled = True + +# --------------------------------------------------------------------------- +# Canvas + layout +# --------------------------------------------------------------------------- + +canvas_widget = build_canvas_widget(viewer, axis_values_from_viewer(viewer)) +layout = Layout( + center=canvas_widget, + right_dock=AppearanceControls(presentation="collapsible_sections"), + right_dock_min_width=380, +) + +# The gizmo needs the plane on screen: add it once the first data is drawn. +# It is on the labels' plane; the image's plane is kept equal to it below. +viewer.on_ready(lambda: viewer.add_render_plane_gizmo(labels_visual, plane)) + +# The gizmo and each "Render planes" control edit the planes of one visual. +# Carry every change of either visual's planes over to the other, so that +# the two stay on one plane whichever is edited. This cannot loop: +# assigning planes equal to the ones a visual has does nothing. +link_id = uuid4() +for source, target in ( + (labels_visual, image_visual), + (image_visual, labels_visual), +): + viewer.controller.on_render_planes_changed( + source.id, + lambda event, target=target: viewer.controller.set_render_planes( + target.id, event.render_planes + ), + owner_id=link_id, + ) + +if __name__ == "__main__": + run(viewer, layout, fit="ready") diff --git a/examples/plane_rendering/plane_slicing_validation_marimo.py b/examples/plane_rendering/plane_slicing_validation_marimo.py new file mode 100644 index 00000000..107b5c8d --- /dev/null +++ b/examples/plane_rendering/plane_slicing_validation_marimo.py @@ -0,0 +1,556 @@ +"""Points, labels and an image that must line up on a plane, as a marimo app.""" + +import marimo + +__generated_with = "0.23.14" +app = marimo.App(width="medium") + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r""" + # Plane rendering validation example. + + A `tczyx` scene with three time points and three visuals that read three + different stores: + + - **points**, in memory: eight markers per time point, one in each octant + of the volume; + - **labels**, multiscale: a sphere centred on each point; + - **image**, multiscale, three channels rendered as a composite: a larger + sphere centred on each point. + + Each octant has a colour of its own at each time point. The points, + labels, and image should be aligned and have the same color at each time point. + """) + return + + +@app.cell +def _(): + import colorsys + import tempfile + from pathlib import Path + from uuid import uuid4 + + import numpy as np + import tensorstore as ts + + from cellier.convenience import ( + AppearanceControls, + Layout, + MultiscaleImageControlsConfig, + MultiscaleLabelsControlsConfig, + PointsControlsConfig, + Viewer, + axis_values_from_viewer, + display, + ) + from cellier.convenience.gui import build_canvas_widget + from cellier.data import PointsMemoryStore + from cellier.data.image._zarr_multiscale_store import MultiscaleZarrDataStore + from cellier.render import OutlineConfig, RenderManagerConfig + from cellier.scene.dims import spatial_axes + from cellier.transform import AffineTransform, Axis, DataCoordinateSystem + from cellier.visuals import ( + MultiscaleImageAppearance, + MultiscaleImageChannelAppearance, + MultiscaleImageRenderConfig, + MultiscaleLabelRenderConfig, + MultiscaleLabelsAppearance, + PlaneOutline, + PointsMarkerAppearance, + RenderPlane, + VisualOutline, + ) + + return ( + AffineTransform, + AppearanceControls, + Axis, + DataCoordinateSystem, + Layout, + MultiscaleImageAppearance, + MultiscaleImageChannelAppearance, + MultiscaleImageControlsConfig, + MultiscaleImageRenderConfig, + MultiscaleLabelRenderConfig, + MultiscaleLabelsAppearance, + MultiscaleLabelsControlsConfig, + MultiscaleZarrDataStore, + OutlineConfig, + Path, + PlaneOutline, + PointsControlsConfig, + PointsMarkerAppearance, + PointsMemoryStore, + RenderManagerConfig, + RenderPlane, + Viewer, + VisualOutline, + axis_values_from_viewer, + build_canvas_widget, + colorsys, + display, + np, + spatial_axes, + tempfile, + ts, + uuid4, + ) + + +@app.cell(hide_code=True) +def _(mo): + mo.md(""" + ## The table everything is built from + + A colour and a position per time point and octant. + """) + return + + +@app.cell +def _(colorsys, np): + SPACE = ("z", "y", "x") + N_TIMES = 3 + #: Level-0 voxels along z, y and x, and the world size of one of them. + SHAPE = (64, 128, 128) + VOXEL_SIZE = (2.0, 1.0, 1.0) + #: The volume's side in world units, the same on every axis. + SIDE = 128.0 + #: Downscale factor of each level along z, y and x. z is never downsampled. + FACTORS = ((1, 1, 1), (1, 2, 2), (1, 4, 4)) + #: Sphere radii, in world units. + LABEL_RADIUS = 10.0 + IMAGE_RADIUS = 18.0 + #: The image holds its octant's colour at this fraction of full intensity, + #: so that a label disc can be told from the image disc around it. + IMAGE_INTENSITY = 0.5 + #: Where the points sit, per time point, from the centre of their octant + #: (z, y, x; world units). Even numbers, so that a plane through a point is + #: through voxel centres and not along a voxel face. + OFFSETS = ((-8.0, -6.0, 8.0), (8.0, 10.0, -6.0), (0.0, -10.0, 10.0)) + + def octant_colors() -> np.ndarray: + """A colour per time point and octant, shape ``(N_TIMES, 8, 3)``. + + 24 hues around the colour wheel, dealt out so that the eight octants of + a time point are far apart in hue and no octant keeps its colour from + one time point to the next. + """ + colors = np.empty((N_TIMES, 8, 3), dtype=np.float32) + for t in range(N_TIMES): + for octant in range(8): + hue = (octant * N_TIMES + t) / (8 * N_TIMES) + colors[t, octant] = colorsys.hsv_to_rgb(hue, 1.0, 1.0) + return colors + + def point_positions() -> np.ndarray: + """The points' world ``(z, y, x)``, shape ``(N_TIMES, 8, 3)``. + + Octant ``k`` is the low or high half of z, y and x by the bits of ``k``. + """ + positions = np.empty((N_TIMES, 8, 3), dtype=np.float32) + for t in range(N_TIMES): + for octant in range(8): + for axis in range(3): + high = (octant >> (2 - axis)) & 1 + centre = SIDE * (0.25 + 0.5 * high) + positions[t, octant, axis] = centre + OFFSETS[t][axis] + return positions + + COLORS = octant_colors() + POSITIONS = point_positions() + + def label_id(t: int, octant: int) -> int: + """The label of an octant's sphere: a value of its own per time point.""" + return 1 + 8 * t + octant + + def srgb_to_linear(color: np.ndarray) -> np.ndarray: + """Undo the sRGB encoding of a colour. + + A label's ``color_dict`` entry is taken as linear light and encoded for + the screen, where a point's colour and a colour map's are taken as they + are. The labels are given the decoded colour, so that the three look + the same. + """ + color = np.asarray(color, dtype=np.float64) + low = color / 12.92 + high = ((color + 0.055) / 1.055) ** 2.4 + return np.where(color <= 0.04045, low, high) + + return ( + COLORS, + FACTORS, + IMAGE_INTENSITY, + IMAGE_RADIUS, + LABEL_RADIUS, + N_TIMES, + POSITIONS, + SHAPE, + SIDE, + SPACE, + VOXEL_SIZE, + label_id, + srgb_to_linear, + ) + + +@app.cell(hide_code=True) +def _( + Axis, + COLORS, + DataCoordinateSystem, + FACTORS, + IMAGE_INTENSITY, + IMAGE_RADIUS, + LABEL_RADIUS, + MultiscaleZarrDataStore, + N_TIMES, + POSITIONS, + Path, + SHAPE, + VOXEL_SIZE, + label_id, + np, + ts, + uuid4, +): + def level_world_coordinates(factor: tuple[int, int, int]) -> list[np.ndarray]: + """The world coordinate of every voxel centre of a level, per axis. + + A level voxel covers ``factor`` level-0 voxels, so its centre is at + level-0 index ``i * factor + (factor - 1) / 2``. + """ + grids = [] + for size, f, voxel in zip(SHAPE, factor, VOXEL_SIZE): + index = np.arange(size // f, dtype=np.float32) * f + (f - 1) / 2.0 + grids.append(index * voxel) + return grids + + def render_level(factor: tuple[int, int, int]) -> tuple[np.ndarray, np.ndarray]: + """One level: the image ``(t, c, z, y, x)`` and the labels ``(t, z, y, x)``. + + Each level is drawn from the table, not downsampled from the level + below, so every level holds spheres about the same world centres. + """ + z, y, x = np.meshgrid(*level_world_coordinates(factor), indexing="ij") + shape = z.shape + image = np.zeros((N_TIMES, 3, *shape), dtype=np.float32) + labels = np.zeros((N_TIMES, *shape), dtype=np.int32) + for t in range(N_TIMES): + for octant in range(8): + cz, cy, cx = POSITIONS[t, octant] + distance = np.sqrt((z - cz) ** 2 + (y - cy) ** 2 + (x - cx) ** 2) + inside = distance <= IMAGE_RADIUS + for channel in range(3): + value = IMAGE_INTENSITY * COLORS[t, octant, channel] + image[t, channel][inside] = value + labels[t][distance <= LABEL_RADIUS] = label_id(t, octant) + return image, labels + + def write_pyramid(root: Path, levels: list[np.ndarray]) -> list[str]: + """Write one zarr v3 array per level under *root*; returns their names.""" + root.mkdir() + names = [] + for index, data in enumerate(levels): + name = f"s{index}" + # One time point and one channel a chunk; 16 voxels a side in space. + chunks = [1] * (data.ndim - 3) + [16, 16, 16] + spec = { + "driver": "zarr3", + "kvstore": {"driver": "file", "path": str(root / name)}, + "metadata": { + "shape": list(data.shape), + "data_type": str(data.dtype), + "chunk_grid": { + "name": "regular", + "configuration": {"chunk_shape": chunks}, + }, + }, + "create": True, + "delete_existing": True, + } + ts.open(spec).result()[...].write(data).result() + names.append(name) + return names + + def data_system(name: str, axes: tuple[str, ...]) -> DataCoordinateSystem: + """A store's level-0 coordinate system: voxel indices on *axes*.""" + kinds = {"t": "time", "c": "channel"} + return DataCoordinateSystem( + name=name, + datastore_id=uuid4(), + axes=tuple( + Axis(name=n, axis_type=kinds.get(n, "space"), sampling="discrete") + for n in axes + ), + ) + + def pyramid_store( + root: Path, levels: list[np.ndarray], name: str, axes: tuple[str, ...] + ) -> MultiscaleZarrDataStore: + """A multiscale zarr store over *levels*, whose last three axes are space.""" + lead = len(axes) - 3 + return MultiscaleZarrDataStore.from_scale_and_translation( + zarr_path=str(root), + scale_names=write_pyramid(root, levels), + level_scales=[ + (1.0,) * lead + tuple(float(f) for f in fs) for fs in FACTORS + ], + level_translations=[ + (0.0,) * lead + tuple((f - 1) / 2.0 for f in fs) for fs in FACTORS + ], + data_coordinate_system=data_system(name, axes), + name=name, + ) + + return data_system, pyramid_store, render_level + + +@app.cell(hide_code=True) +def _(mo): + mo.md(""" + ## Stores + + Every pyramid level is drawn from the table, not downsampled from the + level below, so each holds spheres about the same world centres. + """) + return + + +@app.cell +def _( + COLORS, + FACTORS, + N_TIMES, + POSITIONS, + Path, + PointsMemoryStore, + SPACE, + data_system, + np, + pyramid_store, + render_level, + tempfile, +): + rendered = [render_level(factor) for factor in FACTORS] + tmpdir = Path(tempfile.mkdtemp()) + image_store = pyramid_store( + tmpdir / "image", [image for image, _ in rendered], "image", ("t", "c", *SPACE) + ) + labels_store = pyramid_store( + tmpdir / "labels", [labels for _, labels in rendered], "labels", ("t", *SPACE) + ) + + # One row per point: (t, z, y, x), in world units already. + times = np.repeat(np.arange(N_TIMES, dtype=np.float32), 8)[:, None] + points_store = PointsMemoryStore( + positions=np.hstack([times, POSITIONS.reshape(-1, 3)]), + colors=np.hstack( + [COLORS.reshape(-1, 3), np.ones((N_TIMES * 8, 1), np.float32)] + ), + name="points", + data_coordinate_systems=[data_system("points", ("t", *SPACE))], + ) + return image_store, labels_store, points_store + + +@app.cell(hide_code=True) +def _(mo): + mo.md(""" + ## The plane, the visuals and the dock + + One plane, given to the image and to the labels. The gizmo is on the + labels' plane. A change of either visual's planes is carried over to the + other, so both "Render planes" controls and the gizmo move one plane. + """) + return + + +@app.cell +def _( + AffineTransform, + AppearanceControls, + COLORS, + Layout, + MultiscaleImageAppearance, + MultiscaleImageChannelAppearance, + MultiscaleImageControlsConfig, + MultiscaleImageRenderConfig, + MultiscaleLabelRenderConfig, + MultiscaleLabelsAppearance, + MultiscaleLabelsControlsConfig, + N_TIMES, + OutlineConfig, + POSITIONS, + PlaneOutline, + PointsControlsConfig, + PointsMarkerAppearance, + RenderManagerConfig, + RenderPlane, + SIDE, + SPACE, + VOXEL_SIZE, + Viewer, + VisualOutline, + axis_values_from_viewer, + build_canvas_widget, + display, + image_store, + label_id, + labels_store, + points_store, + spatial_axes, + srgb_to_linear, + uuid4, +): + # The outline pass is on for the markers alone (below): a marker has its + # label's colour, and without a contour it could not be seen on the label. + viewer = Viewer( + [("t", "time"), ("c", "channel"), *spatial_axes(*SPACE)], + dim="3d", + gui="anywidget", + render_config=RenderManagerConfig( + outline=OutlineConfig(enabled=True, palette=[(1.0, 1.0, 1.0, 1.0)]) + ), + ) + world = viewer.scene.dims.world_coordinate_system + + def to_world(store, *, voxels: bool) -> AffineTransform: + """A store's data axes onto the world axes of the same names. + + The pyramids are in voxels, so their space axes are scaled by the voxel + size; the points are in world units already. A store with no channel + axis is the same on every channel. + """ + system = store.data_coordinate_systems[0] + names = [axis.name for axis in system.axes] + return AffineTransform.from_axis_map( + system, + world, + axis_map={name: name for name in names}, + scale=dict(zip(SPACE, VOXEL_SIZE)) if voxels else None, + broadcast_output_axes=() if "c" in names else ("c",), + ) + + # One plane for the image and the labels: a plane is a value in world + # coordinates, so two visuals of a scene can be given the same one. + # + # Its origin is on a point: the one in the octant that is the high half of + # z, y and x, at t = 0. The control turns a plane about its origin, and the + # points of a time point are at one place in every octant, so the plane + # facing z, y or x still passes through four points. (An origin at the + # centre of the volume is between the spheres: a plane turned there cuts + # none of them, and an empty plane is black on black.) + # + # The plane has an outline: a yellow line where it ends at the data's box. + # Most of the plane is background, black on black; the outline shows where + # it is. The two visuals have the same box, so there is one line. + plane = RenderPlane.from_point_normal( + world, + point=POSITIONS[0, 7], + normal=(1, 0, 0), + axes=SPACE, + outline=PlaneOutline(enabled=True, color=(1.0, 0.8, 0.2, 1.0)), + ) + + image_visual = viewer.add_image_multiscale( + image_store, + appearance=MultiscaleImageAppearance(), + name="image", + render_config=MultiscaleImageRenderConfig(block_size=16), + transform=to_world(image_store, voxels=True), + channel_axis=1, + composite=True, + # A channel per colour component: added, the three give the colour back. + channels={ + channel: MultiscaleImageChannelAppearance( + color_map=color_map, clim=(0.0, 1.0), render_mode="plane" + ) + for channel, color_map in enumerate(("red", "green", "blue")) + }, + render_planes=(plane,), + controls=MultiscaleImageControlsConfig( + appearance=["visible", "render_mode", "level_of_detail"], + render_plane_controls=True, + ), + ) + + labels_visual = viewer.add_labels_multiscale( + labels_store, + MultiscaleLabelsAppearance( + render_mode="plane", + colormap_mode="direct", + color_dict={ + label_id(t, octant): (*srgb_to_linear(COLORS[t, octant]).tolist(), 1.0) + for t in range(N_TIMES) + for octant in range(8) + }, + ), + name="labels", + render_config=MultiscaleLabelRenderConfig(block_size=16), + transform=to_world(labels_store, voxels=True), + render_planes=(plane,), + controls=MultiscaleLabelsControlsConfig( + appearance=True, render_plane_controls=True + ), + ) + + points_visual = viewer.add_points( + points_store, + PointsMarkerAppearance(size=10.0, size_space="screen", color_mode="vertex"), + name="points", + transform=to_world(points_store, voxels=False), + # A white contour, in the palette's first colour. + outline=VisualOutline(slot=1), + controls=PointsControlsConfig(appearance=True), + ) + + image_visual.aabb.enabled = True + + # The gizmo needs the plane on screen: add it once the first data is drawn. + # It is on the labels' plane; the image's plane is kept equal to it below. + viewer.on_ready(lambda: viewer.add_render_plane_gizmo(labels_visual, plane)) + + # The gizmo and each "Render planes" control edit the planes of one visual. + # Carry every change of either visual's planes over to the other, so that + # the two stay on one plane whichever is edited. This cannot loop: + # assigning planes equal to the ones a visual has does nothing. + link_id = uuid4() + for _source, _target in ( + (labels_visual, image_visual), + (image_visual, labels_visual), + ): + viewer.controller.on_render_planes_changed( + _source.id, + lambda event, target=_target: viewer.controller.set_render_planes( + target.id, event.render_planes + ), + owner_id=link_id, + ) + + display( + viewer, + Layout( + center=build_canvas_widget( + viewer, axis_values_from_viewer(viewer), canvas_size=(520, 520) + ), + right_dock=AppearanceControls(presentation="collapsible_sections"), + right_dock_min_width=380, + ), + fit="ready", + ) + return image_visual, labels_visual, plane, points_visual, viewer + + +if __name__ == "__main__": + app.run() From 9d52645ee47ec1a433c286e82bdc3cf593f64058 Mon Sep 17 00:00:00 2001 From: Kevin Yamauchi Date: Fri, 9 Oct 2026 21:31:38 +0200 Subject: [PATCH 12/24] update expected examples --- .../test_plane_rendering_examples.py | 36 ++++--------------- 1 file changed, 6 insertions(+), 30 deletions(-) diff --git a/tests/convenience/test_plane_rendering_examples.py b/tests/convenience/test_plane_rendering_examples.py index 6acefc3d..842ec333 100644 --- a/tests/convenience/test_plane_rendering_examples.py +++ b/tests/convenience/test_plane_rendering_examples.py @@ -30,6 +30,9 @@ "plane_slicing_validation", ) +#: The examples that keep a marimo twin (``_marimo.py``). +MARIMO_TWINS = ("plane_slicing_validation",) + @pytest.fixture def load_example(monkeypatch, tmp_path, qtbot): @@ -125,7 +128,7 @@ def test_every_example_is_listed(): } assert scripts == set(QT_EXAMPLES) twins = {path.stem for path in EXAMPLES.glob("*_marimo.py")} - assert twins == {f"{name}_marimo" for name in QT_EXAMPLES} + assert twins == {f"{name}_marimo" for name in MARIMO_TWINS} @pytest.mark.parametrize("name", QT_EXAMPLES) @@ -148,7 +151,7 @@ def _canvas_id(viewer): async def test_plane_gizmo_puts_a_gizmo_on_its_plane(load_example, qtbot): module = load_example("plane_gizmo") await _show(module, qtbot) - session = module.controller.get_plane_gizmo(_canvas_id(module.viewer)) + session = module.viewer.controller.get_plane_gizmo(_canvas_id(module.viewer)) assert session is not None assert session.kind == "render" assert session.plane_id == module.plane.id @@ -467,9 +470,7 @@ def _colours(picture: np.ndarray) -> int: return len(np.unique(picture.reshape(-1, picture.shape[-1]), axis=0)) -@pytest.mark.parametrize( - "name", [name for name in QT_EXAMPLES if name != "ortho_slice_planes"] -) +@pytest.mark.parametrize("name", MARIMO_TWINS) async def test_the_marimo_twin_runs_and_its_viewer_draws( name, run_marimo_example, offscreen_gpu ): @@ -481,31 +482,6 @@ async def test_the_marimo_twin_runs_and_its_viewer_draws( assert _colours(viewer.screenshot(size=(300, 300))) > 50 -async def test_the_ortho_marimo_twin_runs_and_its_3d_panel_draws( - run_marimo_example, offscreen_gpu -): - defs = run_marimo_example("ortho_slice_planes") - viewer = defs["viewer"] - assert viewer.plane_controller.mode == "slices" - assert len(defs["visuals"]["vol"].render_planes) == 3 - await _load(viewer) - assert _colours(viewer.screenshot(panel="vol", size=(300, 300))) > 50 - - -async def test_the_marimo_motion_is_one_interaction_and_one_plan(run_marimo_example): - defs = run_marimo_example("plane_interaction_script") - viewer, log = defs["viewer"], defs["log"] - await _load(viewer) - log.clear() - await defs["move_planes"]() - await drain_loading(viewer.controller) - assert log == [ - "plane interaction: start", - "plane interaction: end (release)", - "plan", - ] - - # -- Plane outlines in the examples (plane outline design 8) -------------------- From e34add2da524ad4f7de6449317f2f0575ba6fd7d Mon Sep 17 00:00:00 2001 From: Kevin Yamauchi Date: Fri, 9 Oct 2026 22:13:08 +0200 Subject: [PATCH 13/24] fix shader encoding and speedup tests --- src/cellier/render/_level_of_detail_2d.py | 4 +- src/cellier/render/_outline.py | 2 +- src/cellier/render/_ssao.py | 2 +- src/cellier/render/shaders/__init__.py | 2 +- src/cellier/render/shaders/_block_image.py | 2 +- src/cellier/render/shaders/_image_volume.py | 2 +- src/cellier/render/shaders/_label_image.py | 2 +- .../render/shaders/_label_multiscale.py | 6 +- src/cellier/render/shaders/_label_volume.py | 2 +- .../shaders/_multiscale_volume_brick.py | 2 +- tests/conftest.py | 25 +++++ .../test_plane_rendering_examples.py | 100 ++++++++++++++---- .../convenience/test_render_planes_viewers.py | 10 +- tests/data/plane_golden/plans.npz | Bin 73254 -> 73286 bytes tests/render/test_cull_first_planner.py | 15 ++- tests/render/test_moving_policy.py | 16 +-- tests/render/test_plane_golden_replay.py | 56 +++++----- tests/render/test_plane_outline_controller.py | 33 +++--- tests/render/test_plane_planning.py | 7 +- tests/render/test_render_plane_gizmo.py | 13 ++- tests/render/test_render_planes_controller.py | 56 +++++----- 21 files changed, 222 insertions(+), 135 deletions(-) diff --git a/src/cellier/render/_level_of_detail_2d.py b/src/cellier/render/_level_of_detail_2d.py index 94d7983e..a048cb01 100644 --- a/src/cellier/render/_level_of_detail_2d.py +++ b/src/cellier/render/_level_of_detail_2d.py @@ -228,7 +228,9 @@ def sort_tiles_by_distance_2d( dy = centres[:, 1] - float(camera_pos[1]) dist_sq = dx * dx + dy * dy - order = np.argsort(dist_sq) + # Stable: tiles symmetric about the camera tie, and the default sort breaks + # ties differently on each platform. + order = np.argsort(dist_sq, kind="stable") return arr[order] diff --git a/src/cellier/render/_outline.py b/src/cellier/render/_outline.py index 06f2ac62..82a3f421 100644 --- a/src/cellier/render/_outline.py +++ b/src/cellier/render/_outline.py @@ -52,7 +52,7 @@ from cellier.render._visual_lut import VisualLut _WGSL_DIR = Path(__file__).parent / "shaders" / "wgsl" -OUTLINE_WGSL: str = (_WGSL_DIR / "outline.wgsl").read_text() +OUTLINE_WGSL: str = (_WGSL_DIR / "outline.wgsl").read_text(encoding="utf-8") RGBA = tuple[float, float, float, float] diff --git a/src/cellier/render/_ssao.py b/src/cellier/render/_ssao.py index 25449c3d..98afbb7f 100644 --- a/src/cellier/render/_ssao.py +++ b/src/cellier/render/_ssao.py @@ -56,7 +56,7 @@ from cellier.render._config import AmbientOcclusionConfig _WGSL_DIR = Path(__file__).parent / "shaders" / "wgsl" -SSAO_WGSL: str = (_WGSL_DIR / "ssao.wgsl").read_text() +SSAO_WGSL: str = (_WGSL_DIR / "ssao.wgsl").read_text(encoding="utf-8") #: Entries the kernel uniform always carries, whatever ``n_samples`` is. #: Fixing the array size keeps a sample-count change from altering the diff --git a/src/cellier/render/shaders/__init__.py b/src/cellier/render/shaders/__init__.py index 177660db..9d81f94d 100644 --- a/src/cellier/render/shaders/__init__.py +++ b/src/cellier/render/shaders/__init__.py @@ -26,7 +26,7 @@ def _load_cellier_wgsl(name: str) -> str: path = _WGSL_DIR / name if not path.is_file(): raise FileNotFoundError(f"no cellier wgsl snippet named {name!r}") - return path.read_text() + return path.read_text(encoding="utf-8") # Guard against a double registration: pygfx raises rather than replacing, diff --git a/src/cellier/render/shaders/_block_image.py b/src/cellier/render/shaders/_block_image.py index 0ed76d35..cf9fa41e 100644 --- a/src/cellier/render/shaders/_block_image.py +++ b/src/cellier/render/shaders/_block_image.py @@ -25,7 +25,7 @@ from pygfx.resources import Buffer _WGSL_DIR = Path(__file__).parent / "wgsl" -IMAGE_BLOCK_WGSL = (_WGSL_DIR / "image_block.wgsl").read_text() +IMAGE_BLOCK_WGSL = (_WGSL_DIR / "image_block.wgsl").read_text(encoding="utf-8") class ImageBlockMaterial(gfx.ImageBasicMaterial): diff --git a/src/cellier/render/shaders/_image_volume.py b/src/cellier/render/shaders/_image_volume.py index 476038df..5a64e74a 100644 --- a/src/cellier/render/shaders/_image_volume.py +++ b/src/cellier/render/shaders/_image_volume.py @@ -40,7 +40,7 @@ _WGSL_DIR = Path(__file__).parent / "wgsl" -IMAGE_VOLUME_WGSL: str = (_WGSL_DIR / "image_volume.wgsl").read_text() +IMAGE_VOLUME_WGSL: str = (_WGSL_DIR / "image_volume.wgsl").read_text(encoding="utf-8") class ImageVolumeMipMaterial(gfx.VolumeMipMaterial): diff --git a/src/cellier/render/shaders/_label_image.py b/src/cellier/render/shaders/_label_image.py index e70a8882..2dd6e832 100644 --- a/src/cellier/render/shaders/_label_image.py +++ b/src/cellier/render/shaders/_label_image.py @@ -20,7 +20,7 @@ _WGSL_DIR = Path(__file__).parent / "wgsl" -_LABEL_IMAGE_WGSL = (_WGSL_DIR / "label_image.wgsl").read_text() +_LABEL_IMAGE_WGSL = (_WGSL_DIR / "label_image.wgsl").read_text(encoding="utf-8") _VERTEX_AND_FRAGMENT = wgpu.ShaderStage.VERTEX | wgpu.ShaderStage.FRAGMENT diff --git a/src/cellier/render/shaders/_label_multiscale.py b/src/cellier/render/shaders/_label_multiscale.py index 98704259..316bdaf3 100644 --- a/src/cellier/render/shaders/_label_multiscale.py +++ b/src/cellier/render/shaders/_label_multiscale.py @@ -39,8 +39,10 @@ _WGSL_DIR = Path(__file__).parent / "wgsl" -_LABEL_VOLUME_BRICK_WGSL = (_WGSL_DIR / "label_volume_brick.wgsl").read_text() -_LABEL_BLOCK_WGSL = (_WGSL_DIR / "label_block.wgsl").read_text() +_LABEL_VOLUME_BRICK_WGSL = (_WGSL_DIR / "label_volume_brick.wgsl").read_text( + encoding="utf-8" +) +_LABEL_BLOCK_WGSL = (_WGSL_DIR / "label_block.wgsl").read_text(encoding="utf-8") _vertex_and_fragment = wgpu.ShaderStage.VERTEX | wgpu.ShaderStage.FRAGMENT diff --git a/src/cellier/render/shaders/_label_volume.py b/src/cellier/render/shaders/_label_volume.py index a478c4b3..b49ef139 100644 --- a/src/cellier/render/shaders/_label_volume.py +++ b/src/cellier/render/shaders/_label_volume.py @@ -25,7 +25,7 @@ _WGSL_DIR = Path(__file__).parent / "wgsl" -_LABEL_VOLUME_WGSL = (_WGSL_DIR / "label_volume.wgsl").read_text() +_LABEL_VOLUME_WGSL = (_WGSL_DIR / "label_volume.wgsl").read_text(encoding="utf-8") _VERTEX_AND_FRAGMENT = wgpu.ShaderStage.VERTEX | wgpu.ShaderStage.FRAGMENT diff --git a/src/cellier/render/shaders/_multiscale_volume_brick.py b/src/cellier/render/shaders/_multiscale_volume_brick.py index 79dbffa1..cddef511 100644 --- a/src/cellier/render/shaders/_multiscale_volume_brick.py +++ b/src/cellier/render/shaders/_multiscale_volume_brick.py @@ -44,7 +44,7 @@ from cellier.render.lut_indirection import BlockLayout3D _WGSL_PATH = Path(__file__).parent / "wgsl" / "multiscale_volume_brick.wgsl" -_WGSL_SOURCE = _WGSL_PATH.read_text() +_WGSL_SOURCE = _WGSL_PATH.read_text(encoding="utf-8") _vertex_and_fragment = wgpu.ShaderStage.VERTEX | wgpu.ShaderStage.FRAGMENT diff --git a/tests/conftest.py b/tests/conftest.py index ebfdfd5e..0b14b2c6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -122,6 +122,31 @@ def offscreen_gpu() -> None: pytest.skip(f"no usable wgpu offscreen adapter: {exc}") +@pytest.fixture(scope="session") +def pyramid_root(tmp_path_factory): + """Return ``root_of(spec, labels=False)``: where a pyramid is on disk. + + Each ``(spec, labels)`` pyramid of ``tests/_plane_fixtures`` is written once + per session and shared: a pyramid is thousands of chunk files, and writing + one per test dominated the plane tests on the Windows runners. The stores + are for reading only; a test that writes to its store must write its own + with ``write_pyramid``. + """ + from tests._plane_fixtures import write_pyramid + + written = {} + + def root_of(spec, *, labels: bool = False): + key = (spec, labels) + if key not in written: + root = tmp_path_factory.mktemp("pyramid") + write_pyramid(root, spec, labels=labels) + written[key] = root + return written[key] + + return root_of + + @pytest.fixture def small_zarr_store(tmp_path): """A minimal 2-level multiscale zarr v3 store on disk (zeros, float32). diff --git a/tests/convenience/test_plane_rendering_examples.py b/tests/convenience/test_plane_rendering_examples.py index 842ec333..bb3e02d3 100644 --- a/tests/convenience/test_plane_rendering_examples.py +++ b/tests/convenience/test_plane_rendering_examples.py @@ -15,6 +15,7 @@ import numpy as np import pytest +import pytest_asyncio from cellier.convenience import screenshot_window from cellier.convenience.layout._qt_renderer import render_qt @@ -34,6 +35,14 @@ MARIMO_TWINS = ("plane_slicing_validation",) +def _import_example(name: str): + path = EXAMPLES / f"{name}.py" + spec = importlib.util.spec_from_file_location(f"_example_{name}", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + @pytest.fixture def load_example(monkeypatch, tmp_path, qtbot): """Import an example by file name, its temporary stores under tmp_path. @@ -53,10 +62,7 @@ def _mkdtemp(*args, **kwargs) -> str: loaded = [] def _load(name: str): - path = EXAMPLES / f"{name}.py" - spec = importlib.util.spec_from_file_location(f"_example_{name}", path) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) + module = _import_example(name) loaded.append(module) return module @@ -94,13 +100,17 @@ def _scenes(viewer) -> list: return list(scenes.values()) if scenes is not None else [viewer.scene] -async def _show(module, qtbot): - """Do what ``run`` does up to the first loaded frame; return the window.""" +async def _show(module, qtbot=None): + """Do what ``run`` does up to the first loaded frame; return the window. + + Without a *qtbot* the caller closes the window. + """ viewer = module.viewer controller = viewer.controller window = render_qt(module.layout, viewer) module.window = window - qtbot.addWidget(window) + if qtbot is not None: + qtbot.addWidget(window) window.resize(1000, 700) window.show() for scene in _scenes(viewer): @@ -261,6 +271,60 @@ async def test_the_ortho_example_ties_three_planes_to_the_sliders(load_example, _SHOT = 480 +@pytest.fixture(scope="module") +def validation_module(tmp_path_factory, qapp): + """The validation example, imported once for every test of its scene. + + Importing it renders and writes two pyramids and builds the viewer, which + is most of what a test of the scene costs on the CI runners. + """ + root = tmp_path_factory.mktemp("validation") + with pytest.MonkeyPatch.context() as patch: + patch.setattr(tempfile, "mkdtemp", lambda *args, **kwargs: str(root)) + module = _import_example("plane_slicing_validation") + yield module + window = getattr(module, "window", None) + if window is not None: + window.close() + module.viewer.controller.close() + + +@pytest_asyncio.fixture(scope="module", loop_scope="module") +async def _shown_validation(validation_module): + """The example in its window with its data loaded, and its first camera.""" + module = validation_module + await _show(module) + viewer = module.viewer + return module, viewer.controller.get_camera_state(_canvas_id(viewer)) + + +@pytest_asyncio.fixture(loop_scope="module") +async def validation_scene(_shown_validation): + """The shown example, put back to how ``run`` leaves it. + + The tests of the scene share one window (and one event loop, which the + controller's loading tasks are on), so each starts by undoing what the + ones before it changed: the time point, the plane, the camera, the gizmo + and what is drawn. + """ + module, camera = _shown_validation + viewer, controller = module.viewer, module.viewer.controller + canvas_id = _canvas_id(viewer) + for visual in (module.image_visual, module.labels_visual, module.points_visual): + visual.appearance.visible = True + module.image_visual.aabb.enabled = True + controller.update_background_field(viewer.scene.id, "visible", True) + viewer.set_slice_positions({0: 0.0}) + # The example carries the labels' planes over to the image. + controller.set_render_planes(module.labels_visual.id, (module.plane,)) + controller.set_camera_state(canvas_id, camera) + if controller.get_plane_gizmo(canvas_id) is None: + viewer.add_render_plane_gizmo(module.labels_visual, module.plane) + controller.reslice_scene(viewer.scene.id) + await drain_loading(controller) + return module + + def _look_down_z(module) -> None: """Look at the volume along -z. @@ -296,11 +360,11 @@ def _blob(picture: np.ndarray, color) -> tuple[np.ndarray, int]: return np.array([rows.mean(), cols.mean()]), int(rows.size) +@pytest.mark.asyncio(loop_scope="module") async def test_the_validation_scene_lines_up_at_every_time_point( - load_example, qtbot, offscreen_gpu + validation_scene, qtbot, offscreen_gpu ): - module = load_example("plane_slicing_validation") - await _show(module, qtbot) + module = validation_scene viewer, controller = module.viewer, module.viewer.controller image, labels, points = ( module.image_visual, @@ -389,11 +453,11 @@ def _normal(visual) -> list[float]: @pytest.mark.parametrize("edited", ["image", "labels"]) +@pytest.mark.asyncio(loop_scope="module") async def test_either_control_of_the_validation_scene_turns_the_one_plane( - edited, load_example, qtbot, offscreen_gpu + edited, validation_scene, qtbot, offscreen_gpu ): - module = load_example("plane_slicing_validation") - await _show(module, qtbot) + module = validation_scene viewer, controller = module.viewer, module.viewer.controller image, labels = module.image_visual, module.labels_visual controls = _plane_controls(module) @@ -432,9 +496,9 @@ async def test_either_control_of_the_validation_scene_turns_the_one_plane( def test_the_validation_scene_moves_its_points_and_changes_its_colours( - load_example, qtbot + validation_module, ): - module = load_example("plane_slicing_validation") + module = validation_module positions, colors = module.POSITIONS, module.COLORS for t in range(module.N_TIMES): for octant in range(8): @@ -509,12 +573,12 @@ async def test_the_ortho_example_outlines_each_slice_in_the_3d_panel( np.testing.assert_allclose(moved.vertices[:, 2], 20.0) +@pytest.mark.asyncio(loop_scope="module") async def test_the_validation_scene_has_one_outline_for_its_shared_plane( - load_example, qtbot + validation_scene, qtbot ): """The image and the labels carry the same plane and have the same box.""" - module = load_example("plane_slicing_validation") - await _show(module, qtbot) + module = validation_scene viewer = module.viewer drawn = _outlines(viewer, viewer.scene) assert list(drawn) == [("render", module.plane.id, 0)] diff --git a/tests/convenience/test_render_planes_viewers.py b/tests/convenience/test_render_planes_viewers.py index 93caa1e5..ab11ff26 100644 --- a/tests/convenience/test_render_planes_viewers.py +++ b/tests/convenience/test_render_planes_viewers.py @@ -113,12 +113,11 @@ def test_the_viewer_passes_render_planes_and_the_mode(make_viewer): def test_the_viewer_passes_render_planes_to_the_multiscale_visuals( - make_viewer, tmp_path + make_viewer, pyramid_root ): viewer = make_viewer() plane = _plane(viewer) - fx.write_pyramid(tmp_path / "image", fx.ISO) - store, _ = fx.open_pyramid(tmp_path / "image", fx.ISO) + store, _ = fx.open_pyramid(pyramid_root(fx.ISO), fx.ISO) image = viewer.add_image_multiscale( store, single=MultiscaleImageSingleAppearance(render_mode="plane"), @@ -611,13 +610,12 @@ def spy(self, request, visual_configs): async def test_a_2d_scrub_holds_the_vol_plan_and_loads_once( - make_viewer, tmp_path, monkeypatch + make_viewer, pyramid_root, monkeypatch ): ortho = make_viewer(OrthoViewer) controller = ortho.controller controller._render_manager.config.scheduler.dims_settle_s = SETTLE_S - fx.write_pyramid(tmp_path, fx.ISO) - store, _ = fx.open_pyramid(tmp_path, fx.ISO) + store, _ = fx.open_pyramid(pyramid_root(fx.ISO), fx.ISO) visuals = ortho.add_image_multiscale( store, appearance=MultiscaleImageAppearance(force_level=1), diff --git a/tests/data/plane_golden/plans.npz b/tests/data/plane_golden/plans.npz index a48e47a03a2e7b08a98190784121487fefa26a2f..954221f438bebe2d9be6041954c931e0bd915d6a 100644 GIT binary patch delta 1421 zcmZ3shvnEFmJKhTvfPV4^JVherv~+zOblQ!cam?Ou%k%J?(|)=ef>K!?Qw8AvPn?7<%pnCA)~ya`w79C78yl%g_M1>m>--CK6_<#c<4O) z{QG+&_ilgI{?jrp=bL=_Ga&-_170)?-buDe#8Eac?a|9c>ewF_v;s~ujP;br(|*H{lo7+GCvxh4KBK4 z;D55v`1|Y!>VLNUxLDU9e?T_kpYWd+`*!=E?+>23{wVyHoGpJ}!c*@L!gbPr{E8K> zPqduy+T!`?55_>g*^lgx>h^uV`Sv!%F_b^xs{`7jzi_#o@pq$2sZS^NyPt1Xj`(T( z=gi(|)t~BTA9!_q()CBn5B9&a{IY!Kg6+}In(pPzWZv0VysszjS=_@o#r3M|UG*1; z16^_7zcKzq_Q}(5^ zJa+ejmiZ4>pKqG?@BFE~Uh!@Frkr2+{D5u5->@I2(oZVy=lw2Sz?S}lb>BYzX8XJO zJD2a_Tw^0z=X!t9`roWEJJNTk|7F|DHs|1{@Q2>_7c8&kzTd>X0gD(w7Q{!lA9LUr-x#NYv=G-?t2CSg+F>`vI3}ZB7 z-}GBDj4q72)8%Cub)XUfvW%k4`(H9lKJ!v)`g$2gnd$Rn8Tput-!M!rcq2Jk;IE9J d6r@}QlMRf*Zy6>Fyp^6VAjc@cw(KoP6ae``-w6N! delta 1367 zcmX@Mhh^CwmJKhTvUr&MOrHGqsX=`t69X8`o#NdUoG8%xdf%iw=~bNNVy!$b0EGP|qY5MulE1^_vz}- zXQg8Ck6yn@s{i|KLzUIKd9@XrqWV{tPj5fpes{U~^!Dz*_W!%@9)JCJ?*CtZ-_>W; zr{Ays@#5pJe^r0X{(WEHc{yLWP`JBebBFc^wP!bq-$;IIxqW1Pr+>wf{i^Rd@=jVG zx~=x_$^8SrC%!VflKa8hzjeP-zT@1kU7|IwuiY@+_SyW2>6KjnA2we#Uj|P~zu)=Y z_-yumwm9b+N87I%FVp3}YIIJ2a`gevylr34uCM=adq1o9qfhF;9d{*{ABxZP&n?w| zAo@ht;eD9F_8WrX;jhm+JTKS#THHTrr}Z;khu0@i0Kj%aFCI9+1KgpW=&!^N+`=`$K#`0_Nt0`B${@#9`r>^(y{{syA z%IoSRt8Xn=^q2jzdiEcldAmvz8vQJPS*+jn^~m1p3;3ilRK{zcg6|- z^L%gA!T!(lx`ze(Rp)v4qZ_;{Obbh6H?^0S-sQ46-M|0+sbdP6KF{i$@PdzXc33pL(>%YAan<_b^abzT_Wz#uVRP>b2S!Me zyZF*@G|7!7IhrTA$-OV6H}}5bF<=Fibdl5N$uLGU)=mEGq zy#6J_9ht@LyQIYt4t IwznWr0PVxsN&o-= diff --git a/tests/render/test_cull_first_planner.py b/tests/render/test_cull_first_planner.py index 8c20c489..02ce7359 100644 --- a/tests/render/test_cull_first_planner.py +++ b/tests/render/test_cull_first_planner.py @@ -35,7 +35,7 @@ MultiscaleLabelsAppearance, ) from tests._gpu_budget import SMALL_BUDGETS -from tests._plane_fixtures import ANISO, open_pyramid, write_pyramid +from tests._plane_fixtures import ANISO, open_pyramid BLOCK_SIZE = 8 SHAPES = [(40, 96, 96), (40, 48, 48), (40, 24, 24), (40, 12, 12)] @@ -167,9 +167,8 @@ def _clip(store, point, normal): ) -def _build(controller, tmp_path, *, labels, clipped): - write_pyramid(tmp_path, ANISO, labels=labels) - store, _ = open_pyramid(tmp_path, ANISO) +def _build(controller, pyramid_root, *, labels, clipped): + store, _ = open_pyramid(pyramid_root(ANISO, labels=labels), ANISO) scene = controller.add_scene(dim="3d", name="s") controller._ensure_data_coordinate_systems(scene.id, store) planes = (_clip(store, (32, 128, 128), (-0.6, 0.5, 1.0)),) if clipped else () @@ -228,10 +227,10 @@ def spy(arr, *args, **kwargs): @pytest.mark.parametrize("labels", [False, True], ids=["image", "labels"]) def test_a_clipped_visual_ranks_only_the_bricks_the_cull_kept( - controller, tmp_path, monkeypatch, labels + controller, pyramid_root, monkeypatch, labels ): module = _label_multiscale if labels else _image - scene, visual = _build(controller, tmp_path, labels=labels, clipped=True) + scene, visual = _build(controller, pyramid_root, labels=labels, clipped=True) ranked, kept, total = _plan_counting(controller, scene, visual, monkeypatch, module) assert ranked <= kept assert 0 < kept < total @@ -239,9 +238,9 @@ def test_a_clipped_visual_ranks_only_the_bricks_the_cull_kept( @pytest.mark.parametrize("labels", [False, True], ids=["image", "labels"]) def test_an_unclipped_visual_ranks_every_brick( - controller, tmp_path, monkeypatch, labels + controller, pyramid_root, monkeypatch, labels ): module = _label_multiscale if labels else _image - scene, visual = _build(controller, tmp_path, labels=labels, clipped=False) + scene, visual = _build(controller, pyramid_root, labels=labels, clipped=False) ranked, kept, total = _plan_counting(controller, scene, visual, monkeypatch, module) assert ranked == kept == total diff --git a/tests/render/test_moving_policy.py b/tests/render/test_moving_policy.py index ff793268..ae8fe282 100644 --- a/tests/render/test_moving_policy.py +++ b/tests/render/test_moving_policy.py @@ -61,12 +61,13 @@ def _modes(plans: list[tuple], visual_id) -> list[str]: class Rig: """A 3D scene with one multiscale visual of a small pyramid, one canvas.""" - def __init__(self, controller, tmp_path, *, kind="image", spec=fx.ANISO, **moving): + def __init__( + self, controller, pyramid_root, *, kind="image", spec=fx.ANISO, **moving + ): self.controller = controller controller._render_manager.config.scheduler.dims_settle_s = SETTLE_S labels = kind == "labels" - fx.write_pyramid(tmp_path, spec, labels=labels) - self.store, _voxel = fx.open_pyramid(tmp_path, spec) + self.store, _voxel = fx.open_pyramid(pyramid_root(spec, labels=labels), spec) self.system = self.store.data_coordinate_systems[0] if "t" in spec.axes: self.scene = controller.add_scene( @@ -150,9 +151,9 @@ def wanted(self) -> dict[int, frozenset[int]]: @pytest.fixture -def make_rig(controller, tmp_path): +def make_rig(controller, pyramid_root): def _make(**kwargs) -> Rig: - return Rig(controller, tmp_path, **kwargs) + return Rig(controller, pyramid_root, **kwargs) return _make @@ -504,10 +505,9 @@ async def test_an_eager_3d_scrub_plans_in_full_per_tick(make_rig, monkeypatch): async def test_a_2d_view_plans_a_clipping_change_on_every_tick( - controller, tmp_path, monkeypatch + controller, pyramid_root, monkeypatch ): - fx.write_pyramid(tmp_path, fx.ISO) - store, _ = fx.open_pyramid(tmp_path, fx.ISO) + store, _ = fx.open_pyramid(pyramid_root(fx.ISO), fx.ISO) scene = controller.add_scene(dim="2d", name="scene") system = store.data_coordinate_systems[0] diff --git a/tests/render/test_plane_golden_replay.py b/tests/render/test_plane_golden_replay.py index dc5e6c3e..23b52b22 100644 --- a/tests/render/test_plane_golden_replay.py +++ b/tests/render/test_plane_golden_replay.py @@ -13,6 +13,11 @@ The golden files are written only with ``PLANE_GOLDEN_WRITE=1`` and must never be regenerated to make a test pass: a difference is the finding. + +One exception is on record: the two ``2d_*/fit::0_keys`` arrays were captured +again when the 2D tile sort became stable. They held the same keys in the tie +order of the machine that wrote them (256 tiles at 32 distinct distances), an +order no other platform reproduced. """ from __future__ import annotations @@ -38,7 +43,7 @@ MultiscaleLabelsAppearance, ) from tests._gpu_budget import SMALL_BUDGETS -from tests._plane_fixtures import ANISO, ISO, open_pyramid, write_pyramid +from tests._plane_fixtures import ANISO, ISO, open_pyramid GOLDEN = Path(__file__).resolve().parents[1] / "data" / "plane_golden" WRITE = os.environ.get("PLANE_GOLDEN_WRITE") == "1" @@ -74,11 +79,8 @@ def _transform(controller, scene_id, store, voxel): ) -def _add(controller, tmp_path, spec, *, dim, labels=False, bias=1.0, planes=()): - root = tmp_path / ("labels" if labels else "image") - root.mkdir() - write_pyramid(root, spec, labels=labels) - store, voxel = open_pyramid(root, spec) +def _add(controller, pyramid_root, spec, *, dim, labels=False, bias=1.0, planes=()): + store, voxel = open_pyramid(pyramid_root(spec, labels=labels), spec) scene = controller.add_scene(dim=dim, name="s") transform = _transform(controller, scene.id, store, voxel) system = store.data_coordinate_systems[0] @@ -155,15 +157,14 @@ def _place(view, spec, which): camera.look_at(tuple(centre)) -def _cases(controller, tmp_path_factory): +def _cases(controller, pyramid_root): """Yield ``(name, arrays)`` for every plan case.""" # 3D perspective: image, clipped and not, two biases, three cameras. for clip, planes in CLIPPING.items(): for bias in (1.0, 0.5): for which in ("fit", "side", "close"): - path = tmp_path_factory.mktemp("g") scene, visual, spec = _add( - controller, path, ANISO, dim="3d", bias=bias, planes=planes + controller, pyramid_root, ANISO, dim="3d", bias=bias, planes=planes ) view = controller._render_manager._canvases[ controller.get_canvas_ids(scene.id)[0] @@ -177,9 +178,13 @@ def _cases(controller, tmp_path_factory): # 3D labels, with and without a clip. for clip in ("none", "oblique"): for which in ("fit", "side"): - path = tmp_path_factory.mktemp("g") scene, visual, spec = _add( - controller, path, ANISO, dim="3d", labels=True, planes=CLIPPING[clip] + controller, + pyramid_root, + ANISO, + dim="3d", + labels=True, + planes=CLIPPING[clip], ) view = controller._render_manager._canvases[ controller.get_canvas_ids(scene.id)[0] @@ -188,8 +193,7 @@ def _cases(controller, tmp_path_factory): yield f"3d_labels/{clip}/{which}", _plan(controller, scene, visual) controller.remove_scene(scene.id) # An isotropic pyramid. - path = tmp_path_factory.mktemp("g") - scene, visual, spec = _add(controller, path, ISO, dim="3d") + scene, visual, spec = _add(controller, pyramid_root, ISO, dim="3d") view = controller._render_manager._canvases[controller.get_canvas_ids(scene.id)[0]] _place(view, spec, "side") yield "3d_iso/none/side", _plan(controller, scene, visual) @@ -197,9 +201,8 @@ def _cases(controller, tmp_path_factory): # Orthographic 3D at three zooms (view height scales the camera). for zoom in (1.0, 2.0, 4.0): for clip in ("none", "oblique"): - path = tmp_path_factory.mktemp("g") scene, visual, spec = _add( - controller, path, ANISO, dim="3d", planes=CLIPPING[clip] + controller, pyramid_root, ANISO, dim="3d", planes=CLIPPING[clip] ) view = controller._render_manager._canvases[ controller.get_canvas_ids(scene.id)[0] @@ -211,8 +214,9 @@ def _cases(controller, tmp_path_factory): controller.remove_scene(scene.id) # 2D slices. for labels in (False, True): - path = tmp_path_factory.mktemp("g") - scene, visual, spec = _add(controller, path, ANISO, dim="2d", labels=labels) + scene, visual, spec = _add( + controller, pyramid_root, ANISO, dim="2d", labels=labels + ) yield ( f"2d_{'labels' if labels else 'image'}/fit", _plan(controller, scene, visual), @@ -228,8 +232,8 @@ def _flatten(cases): return flat -def test_plans_equal_the_golden(controller, tmp_path_factory): - flat = _flatten(_cases(controller, tmp_path_factory)) +def test_plans_equal_the_golden(controller, pyramid_root): + flat = _flatten(_cases(controller, pyramid_root)) path = GOLDEN / "plans.npz" if WRITE: GOLDEN.mkdir(parents=True, exist_ok=True) @@ -270,7 +274,7 @@ def spy(self, **kw): return seen -def _wgsl_cases(controller, tmp_path_factory, render_scene): +def _wgsl_cases(controller, pyramid_root, render_scene): """``{case: {shader class: (sha256, length)}}``.""" out = {} @@ -315,9 +319,7 @@ def build_labels(): for mode in ("iso", "mip", "smooth_iso", "attenuated_mip"): # The mode lives on the single appearance; build with it directly. def build_mode(mode=mode): - path = tmp_path_factory.mktemp("w") - write_pyramid(path, ANISO) - store, _ = open_pyramid(path, ANISO) + store, _ = open_pyramid(pyramid_root(ANISO), ANISO) scene = controller.add_scene(dim="3d", name=mode) controller.add_image_multiscale( data=store, @@ -337,9 +339,7 @@ def build_mode(mode=mode): for mode in ("iso_categorical", "flat_categorical", "gradient_debug", "smooth_iso"): def build_mode(mode=mode): - path = tmp_path_factory.mktemp("w") - write_pyramid(path, ANISO, labels=True) - store, _ = open_pyramid(path, ANISO) + store, _ = open_pyramid(pyramid_root(ANISO, labels=True), ANISO) scene = controller.add_scene(dim="3d", name=mode) controller.add_labels_multiscale( data=store, @@ -357,8 +357,8 @@ def build_mode(mode=mode): return out -def test_shader_text_equals_the_golden(controller, tmp_path_factory, render_scene): - cases = _wgsl_cases(controller, tmp_path_factory, render_scene) +def test_shader_text_equals_the_golden(controller, pyramid_root, render_scene): + cases = _wgsl_cases(controller, pyramid_root, render_scene) empty = [k for k, v in cases.items() if not v] assert not empty, f"no shader captured for {empty}" path = GOLDEN / "wgsl.json" diff --git a/tests/render/test_plane_outline_controller.py b/tests/render/test_plane_outline_controller.py index ca0ef8c9..067136a7 100644 --- a/tests/render/test_plane_outline_controller.py +++ b/tests/render/test_plane_outline_controller.py @@ -27,11 +27,10 @@ SETTLE_S = 0.03 -async def _image(controller, tmp_path, *, render_mode="plane"): +async def _image(controller, pyramid_root, *, render_mode="plane"): """A loaded multiscale image with one render plane and one clipping plane.""" controller._render_manager.config.scheduler.dims_settle_s = SETTLE_S - fx.write_pyramid(tmp_path, fx.ISO) - store, _ = fx.open_pyramid(tmp_path, fx.ISO) + store, _ = fx.open_pyramid(pyramid_root(fx.ISO), fx.ISO) scene = controller.add_scene(dim="3d", name="scene") world = controller._model.scenes[scene.id].dims.world_coordinate_system visual = controller.add_image_multiscale( @@ -116,9 +115,9 @@ def _set(controller, visual, kind, planes) -> None: @pytest.mark.parametrize("kind", ["render", "clipping"]) async def test_a_new_outline_is_announced_and_drawn_and_reads_nothing( - controller, tmp_path, monkeypatch, kind + controller, pyramid_root, monkeypatch, kind ): - _scene, visual = await _image(controller, tmp_path) + _scene, visual = await _image(controller, pyramid_root) spy = _Spy(controller, visual, monkeypatch) plane = _planes(visual, kind)[0] @@ -136,8 +135,8 @@ async def test_a_new_outline_is_announced_and_drawn_and_reads_nothing( @pytest.mark.parametrize("kind", ["render", "clipping"]) -async def test_the_render_visual_is_given_the_outline(controller, tmp_path, kind): - scene, visual = await _image(controller, tmp_path) +async def test_the_render_visual_is_given_the_outline(controller, pyramid_root, kind): + scene, visual = await _image(controller, pyramid_root) plane = _planes(visual, kind)[0] _set(controller, visual, kind, (plane.model_copy(update={"outline": RED}),)) gfx_visual = controller._render_manager._scenes[scene.id].get_visual(visual.id) @@ -145,9 +144,9 @@ async def test_the_render_visual_is_given_the_outline(controller, tmp_path, kind @pytest.mark.parametrize("kind", ["render", "clipping"]) -async def test_a_move_still_reads_again(controller, tmp_path, monkeypatch, kind): +async def test_a_move_still_reads_again(controller, pyramid_root, monkeypatch, kind): """The other half of the rule: a pose change is treated as before.""" - _scene, visual = await _image(controller, tmp_path) + _scene, visual = await _image(controller, pyramid_root) spy = _Spy(controller, visual, monkeypatch) plane = _planes(visual, kind)[0] if kind == "render": @@ -170,9 +169,9 @@ async def test_a_move_still_reads_again(controller, tmp_path, monkeypatch, kind) @pytest.mark.parametrize("kind", ["render", "clipping"]) async def test_a_move_with_a_new_outline_is_a_move( - controller, tmp_path, monkeypatch, kind + controller, pyramid_root, monkeypatch, kind ): - _scene, visual = await _image(controller, tmp_path) + _scene, visual = await _image(controller, pyramid_root) spy = _Spy(controller, visual, monkeypatch) plane = _planes(visual, kind)[0] _set( @@ -188,11 +187,11 @@ async def test_a_move_with_a_new_outline_is_a_move( @pytest.mark.parametrize("kind", ["render", "clipping"]) async def test_a_new_outline_inside_a_drag_is_not_a_tick_of_it( - controller, tmp_path, monkeypatch, kind + controller, pyramid_root, monkeypatch, kind ): """A drag's scope is open (a gizmo is held): restyling does not extend the drag or plan.""" - _scene, visual = await _image(controller, tmp_path) + _scene, visual = await _image(controller, pyramid_root) spy = _Spy(controller, visual, monkeypatch) plane = _planes(visual, kind)[0] with controller.plane_interaction(visual.id): @@ -203,10 +202,10 @@ async def test_a_new_outline_inside_a_drag_is_not_a_tick_of_it( async def test_a_new_outline_on_a_plane_that_is_not_drawn_is_only_announced( - controller, tmp_path, monkeypatch + controller, pyramid_root, monkeypatch ): """Outside plane mode the render planes are stored and nothing more.""" - _scene, visual = await _image(controller, tmp_path, render_mode="mip") + _scene, visual = await _image(controller, pyramid_root, render_mode="mip") spy = _Spy(controller, visual, monkeypatch) plane = visual.render_planes[0] controller.set_render_planes( @@ -218,9 +217,9 @@ async def test_a_new_outline_on_a_plane_that_is_not_drawn_is_only_announced( async def test_a_direct_assignment_follows_the_same_rule( - controller, tmp_path, monkeypatch + controller, pyramid_root, monkeypatch ): - _scene, visual = await _image(controller, tmp_path) + _scene, visual = await _image(controller, pyramid_root) spy = _Spy(controller, visual, monkeypatch) visual.render_planes = ( visual.render_planes[0].model_copy(update={"outline": RED}), diff --git a/tests/render/test_plane_planning.py b/tests/render/test_plane_planning.py index 65eaa9ae..339eb71b 100644 --- a/tests/render/test_plane_planning.py +++ b/tests/render/test_plane_planning.py @@ -615,7 +615,7 @@ async def test_labels_plan_the_same_bricks_as_an_image(controller, tmp_path): async def test_a_composite_splits_the_cache_and_every_channel_plans_the_same_bricks( - controller, tmp_path + controller, pyramid_root ): """B4: the budget is split between the drawn channels, so a plan that fits one channel is truncated with two; each channel gets the same @@ -635,10 +635,7 @@ async def test_a_composite_splits_the_cache_and_every_channel_plans_the_same_bri plans = {} for n_channels in (1, 2): spec = fx.channel_spec(n_channels) - root = tmp_path / f"c{n_channels}" - root.mkdir() - fx.write_pyramid(root, spec) - store, voxel = fx.open_pyramid(root, spec, name=f"c{n_channels}") + store, voxel = fx.open_pyramid(pyramid_root(spec), spec, name=f"c{n_channels}") scene = controller.add_scene( coordinate_system=spatial_axes("c", "z", "y", "x"), dim="3d", diff --git a/tests/render/test_render_plane_gizmo.py b/tests/render/test_render_plane_gizmo.py index 5292537b..2b68f0d8 100644 --- a/tests/render/test_render_plane_gizmo.py +++ b/tests/render/test_render_plane_gizmo.py @@ -569,12 +569,11 @@ async def test_a_drag_is_one_plane_interaction(make_rig): class MultiscaleRig(PlaneRig): """A multiscale image in plane mode: a drag holds its plan.""" - def __init__(self, tmp_path, *, n_canvases: int = 1) -> None: + def __init__(self, pyramid_root, *, n_canvases: int = 1) -> None: self.controller = CellierController(gui="offscreen") self.controller.camera_reslice_enabled = False self.controller._render_manager.config.scheduler.dims_settle_s = SETTLE_S - fx.write_pyramid(tmp_path, fx.ISO) - store, _ = fx.open_pyramid(tmp_path, fx.ISO) + store, _ = fx.open_pyramid(pyramid_root(fx.ISO), fx.ISO) self.scene = self.controller.add_scene(dim="3d", name="scene") self.world = self.controller._model.scenes[ self.scene.id @@ -630,10 +629,10 @@ def spy(self, request, visual_configs): async def test_a_gizmo_drag_holds_the_plan_and_loads_once_at_release( - make_rig, tmp_path, monkeypatch + make_rig, pyramid_root, monkeypatch ): """B1, through the real gizmo callbacks: counts only.""" - rig = make_rig(MultiscaleRig, tmp_path=tmp_path) + rig = make_rig(MultiscaleRig, pyramid_root=pyramid_root) plane = rig.plane() rig.controller.set_render_planes(rig.visual.id, (plane,)) await rig.start() @@ -663,9 +662,9 @@ async def test_a_gizmo_drag_holds_the_plan_and_loads_once_at_release( async def test_a_pause_ends_the_motion_and_the_next_move_starts_another( - make_rig, tmp_path, monkeypatch + make_rig, pyramid_root, monkeypatch ): - rig = make_rig(MultiscaleRig, tmp_path=tmp_path) + rig = make_rig(MultiscaleRig, pyramid_root=pyramid_root) plane = rig.plane() rig.controller.set_render_planes(rig.visual.id, (plane,)) await rig.start() diff --git a/tests/render/test_render_planes_controller.py b/tests/render/test_render_planes_controller.py index dbc41e0f..9295d598 100644 --- a/tests/render/test_render_planes_controller.py +++ b/tests/render/test_render_planes_controller.py @@ -82,11 +82,10 @@ def _scene(controller, spec=fx.ISO, dim="3d"): return controller.add_scene(dim=dim, name="scene") -def _multiscale_image(controller, tmp_path, *, spec=fx.ISO, dim="3d", **kwargs): +def _multiscale_image(controller, pyramid_root, *, spec=fx.ISO, dim="3d", **kwargs): """A multiscale image in ``"plane"`` mode; returns ``(scene, visual)``.""" controller._render_manager.config.scheduler.dims_settle_s = SETTLE_S - fx.write_pyramid(tmp_path, spec) - store, _ = fx.open_pyramid(tmp_path, spec) + store, _ = fx.open_pyramid(pyramid_root(spec), spec) scene = _scene(controller, spec, dim) kwargs.setdefault( "single", @@ -142,12 +141,10 @@ async def _loaded(controller, scene) -> None: def test_the_four_visuals_take_render_planes_and_plane_mode( - controller, tmp_path, image_volume, labels_volume + controller, pyramid_root, image_volume, labels_volume ): - fx.write_pyramid(tmp_path / "i", fx.ISO) - fx.write_pyramid(tmp_path / "l", fx.ISO, labels=True) - image_store, _ = fx.open_pyramid(tmp_path / "i", fx.ISO) - label_store, _ = fx.open_pyramid(tmp_path / "l", fx.ISO) + image_store, _ = fx.open_pyramid(pyramid_root(fx.ISO), fx.ISO) + label_store, _ = fx.open_pyramid(pyramid_root(fx.ISO, labels=True), fx.ISO) scene = controller.add_scene(dim="3d", name="scene") plane = _plane(_world(controller, scene)) @@ -420,11 +417,10 @@ def test_only_an_image_has_a_channel_render_mode(controller, labels_volume): async def test_the_multiscale_channels_follow_the_rule_and_reslice_once( - controller, tmp_path, monkeypatch + controller, pyramid_root, monkeypatch ): spec = fx.channel_spec(2, fx.ISO) - fx.write_pyramid(tmp_path, spec) - store, _ = fx.open_pyramid(tmp_path, spec) + store, _ = fx.open_pyramid(pyramid_root(spec), spec) scene = controller.add_scene( coordinate_system=spatial_axes("c", "z", "y", "x"), dim="3d", name="scene" ) @@ -511,10 +507,12 @@ def test_set_render_plane_replaces_one_plane_and_keeps_its_id(controller, image_ # -- 8.2: a change while not in plane mode is stored, announced, inert ------------- -async def test_a_change_outside_plane_mode_is_inert(controller, tmp_path, monkeypatch): +async def test_a_change_outside_plane_mode_is_inert( + controller, pyramid_root, monkeypatch +): scene, visual = _multiscale_image( controller, - tmp_path, + pyramid_root, single=MultiscaleImageSingleAppearance( color_map="viridis", clim=(0.0, 65535.0), render_mode="mip" ), @@ -532,9 +530,11 @@ async def test_a_change_outside_plane_mode_is_inert(controller, tmp_path, monkey await drain_loading(controller) -async def test_plane_mode_is_ignored_in_a_2d_view(controller, tmp_path, monkeypatch): +async def test_plane_mode_is_ignored_in_a_2d_view( + controller, pyramid_root, monkeypatch +): """D-P19: a 2D view shows the normal slice, planes or not.""" - scene, visual = _multiscale_image(controller, tmp_path, dim="2d") + scene, visual = _multiscale_image(controller, pyramid_root, dim="2d") assert visual.plane_mode() is True assert controller._draws_planes(visual) is False world = _world(controller, scene) @@ -552,9 +552,9 @@ async def test_plane_mode_is_ignored_in_a_2d_view(controller, tmp_path, monkeypa async def test_plane_mode_with_no_plane_is_not_sliced_until_one_appears( - controller, tmp_path, monkeypatch + controller, pyramid_root, monkeypatch ): - scene, visual = _multiscale_image(controller, tmp_path) + scene, visual = _multiscale_image(controller, pyramid_root) world = _world(controller, scene) plans = _record_plans(monkeypatch) await _loaded(controller, scene) @@ -583,11 +583,11 @@ async def test_plane_mode_with_no_plane_is_not_sliced_until_one_appears( async def test_entering_and_leaving_plane_mode_reslices( - controller, tmp_path, monkeypatch + controller, pyramid_root, monkeypatch ): scene, visual = _multiscale_image( controller, - tmp_path, + pyramid_root, single=MultiscaleImageSingleAppearance( color_map="viridis", clim=(0.0, 65535.0), render_mode="mip" ), @@ -635,9 +635,9 @@ async def test_an_in_memory_visual_is_sliced_when_its_first_plane_appears( async def test_a_plane_off_the_displayed_axes_is_not_drawn_and_warns_once( - controller, tmp_path, monkeypatch + controller, pyramid_root, monkeypatch ): - scene, visual = _multiscale_image(controller, tmp_path, spec=fx.TZYX) + scene, visual = _multiscale_image(controller, pyramid_root, spec=fx.TZYX) world = _world(controller, scene) zyx = RenderPlane.from_point_normal( world, (8, 0, 0), (1, 0, 0), axes=("z", "y", "x") @@ -691,8 +691,10 @@ async def test_a_plane_off_the_displayed_axes_is_not_drawn_and_warns_once( # -- 7.4: a render plane change is a tick, and holds the plan ---------------------- -async def test_a_render_plane_drag_holds_the_plan(controller, tmp_path, monkeypatch): - scene, visual = _multiscale_image(controller, tmp_path) +async def test_a_render_plane_drag_holds_the_plan( + controller, pyramid_root, monkeypatch +): + scene, visual = _multiscale_image(controller, pyramid_root) world = _world(controller, scene) plane = _plane(world, 20.0) controller.set_render_planes(visual.id, (plane,)) @@ -729,12 +731,12 @@ async def test_a_render_plane_drag_holds_the_plan(controller, tmp_path, monkeypa async def test_a_render_plane_and_a_clipping_plane_share_one_drag( - controller, tmp_path, monkeypatch + controller, pyramid_root, monkeypatch ): """One tracker per visual serves both kinds of plane (D-P17).""" from cellier.visuals import ClippingPlane - scene, visual = _multiscale_image(controller, tmp_path) + scene, visual = _multiscale_image(controller, pyramid_root) world = _world(controller, scene) plane = _plane(world, 20.0) controller.set_render_planes(visual.id, (plane,)) @@ -766,9 +768,9 @@ async def test_a_render_plane_and_a_clipping_plane_share_one_drag( async def test_an_eager_render_plane_drag_plans_every_tick( - controller, tmp_path, monkeypatch + controller, pyramid_root, monkeypatch ): - scene, visual = _multiscale_image(controller, tmp_path) + scene, visual = _multiscale_image(controller, pyramid_root) visual.appearance.coarsest_while_moving_3d = False world = _world(controller, scene) controller.set_render_planes(visual.id, (_plane(world, 20.0),)) From bef32eeae8420ec111760dbd9be67b7f6996b805 Mon Sep 17 00:00:00 2001 From: Kevin Yamauchi Date: Fri, 9 Oct 2026 22:35:04 +0200 Subject: [PATCH 14/24] make timeout a failure --- pyproject.toml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index beeded30..eb68ba3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,6 +72,7 @@ test = [ "pytest-asyncio>=1.4.0", "pytest-cov", "pytest-qt", + "pytest-timeout", ] # add anything else you like to have in your dev environment here dev = [ @@ -176,6 +177,13 @@ asyncio_default_fixture_loop_scope = "function" # A test still running after this many seconds dumps every thread's stack to # stderr (it is not stopped). faulthandler_timeout = 300 +# A test still running after this many seconds fails the run (pytest-timeout). +# "thread" dumps every stack and exits the process: the default signal method +# raises inside whatever Python is running, and an exception raised inside a +# Qt callback is swallowed, so a test spinning in the Qt event loop would not +# stop. +timeout = 600 +timeout_method = "thread" # https://coverage.readthedocs.io/ [tool.coverage.report] From 674b106541ee179575638800b0f2992c8655ff4f Mon Sep 17 00:00:00 2001 From: Kevin Yamauchi Date: Fri, 9 Oct 2026 22:46:14 +0200 Subject: [PATCH 15/24] add ci profiling job --- .github/workflows/profile.yml | 158 +++++++++++++++++++ scripts/ci_profiling/compare.py | 209 ++++++++++++++++++++++++++ scripts/ci_profiling/probe_file_io.py | 164 ++++++++++++++++++++ scripts/ci_profiling/probe_render.py | 177 ++++++++++++++++++++++ 4 files changed, 708 insertions(+) create mode 100644 .github/workflows/profile.yml create mode 100644 scripts/ci_profiling/compare.py create mode 100644 scripts/ci_profiling/probe_file_io.py create mode 100644 scripts/ci_profiling/probe_render.py diff --git a/.github/workflows/profile.yml b/.github/workflows/profile.yml new file mode 100644 index 00000000..6160944c --- /dev/null +++ b/.github/workflows/profile.yml @@ -0,0 +1,158 @@ +name: Profile + +# Measures why the tests take longer on one platform than another. Not part of +# CI: run it by hand (Actions -> Profile -> Run workflow), or push a change to +# this file or to scripts/ci_profiling/ in a pull request. +# +# Each platform uploads a `profile-` artifact; compare them with +# scripts/ci_profiling/compare.py (see its docstring). + +on: + workflow_dispatch: + inputs: + tests: + description: "Test files to profile (space separated)" + default: >- + tests/convenience/test_plane_rendering_examples.py + tests/render/test_render_plane_gizmo.py + tests/render/test_multiscale_level_alignment.py + tests/render/test_plane_planning.py + full_suite: + description: "Also run the whole suite as CI does, with per-test times" + type: boolean + default: true + pull_request: + paths: + - ".github/workflows/profile.yml" + - "scripts/ci_profiling/**" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + profile: + name: profile ${{ matrix.platform }} + runs-on: ${{ matrix.platform }} + strategy: + fail-fast: false + matrix: + platform: [ubuntu-latest, macos-latest, windows-latest] + + env: + # As in ci.yml. + WGPU_BACKEND_TYPE: ${{ matrix.platform == 'windows-latest' && 'Vulkan' || '' }} + SPATIAL_GRAPH_NO_PREBUILT: "1" + DEFAULT_TESTS: >- + tests/convenience/test_plane_rendering_examples.py + tests/render/test_render_plane_gizmo.py + tests/render/test_multiscale_level_alignment.py + tests/render/test_plane_planning.py + INPUT_TESTS: ${{ inputs.tests }} + OUT: profile_out + + defaults: + run: + shell: bash + + steps: + - uses: actions/checkout@v7 + + # The environment below is ci.yml's, step for step, so the numbers are + # the ones CI sees. + - name: Set up headless GUI + uses: pyvista/setup-headless-display-action@v5 + with: + qt: true + + - name: Install llvmpipe and lavapipe for offscreen canvas + if: matrix.platform == 'ubuntu-latest' + run: | + sudo apt-get update -y -qq + sudo apt install -y libegl1-mesa-dev libgl1-mesa-dri libxcb-xfixes0-dev mesa-vulkan-drivers + + - name: Install lavapipe for offscreen canvas + if: matrix.platform == 'windows-latest' + uses: jakoch/install-vulkan-sdk-action@v1 + with: + install_lavapipe: true + stripdown: true + cache: true + + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: "3.13" + cache-dependency-path: "pyproject.toml" + cache: "pip" + + - name: Install Dependencies + run: | + python -m pip install -U pip + python -m pip install .[test,pyside,remote,anywidget,examples,graph] + + - name: Machine + run: | + mkdir -p "$OUT/junit" "$OUT/prof" + echo "TESTS=${INPUT_TESTS:-$DEFAULT_TESTS}" >> "$GITHUB_ENV" + python -c "import os, platform, tempfile; print(platform.platform()); print('cpus:', os.cpu_count()); print('temp:', tempfile.gettempdir()); print('cwd:', os.getcwd())" + python -c "from pygfx.renderers.wgpu import get_shared; print('adapter:', get_shared().device.adapter.summary)" + + # --- Micro-probes: seconds each, no cellier code involved ------------- + + - name: Probe - file writes + if: always() + run: python scripts/ci_profiling/probe_file_io.py --out "$OUT/probe_file_io.json" + + - name: Probe - frame time + if: always() + run: python scripts/ci_profiling/probe_render.py --thread-sweep default,1,2,4 --out "$OUT/probe_render.json" + + # --- cProfile of each test file --------------------------------------- + # Also warms every cache, so the timed runs below start equal. + + - name: cProfile each test file + if: always() + run: | + for test in $TESTS; do + name=$(basename "$test" .py) + echo "::group::$name" + python -m cProfile -o "$OUT/prof/$name.prof" -m pytest "$test" -q --durations=10 || true + echo "::endgroup::" + done + + # --- The same test files, one thing changed at a time ----------------- + + - name: Timed - baseline (no coverage) + if: always() + run: pytest $TESTS -q --junitxml="$OUT/junit/baseline.xml" || true + + - name: Timed - with coverage + if: always() + run: pytest $TESTS -q --cov --cov-report= --junitxml="$OUT/junit/coverage.xml" || true + + # pytest's tmp_path is under the system temp directory, which on the + # Windows runner is on C:; the workspace is on another drive. + - name: Timed - tmp_path on the workspace drive + if: always() + run: pytest $TESTS -q --basetemp="${{ github.workspace }}/.pytest_basetemp" --junitxml="$OUT/junit/basetemp_workspace.xml" || true + + - name: Timed - llvmpipe on one thread + if: always() && matrix.platform != 'macos-latest' + env: + LP_NUM_THREADS: "1" + run: pytest $TESTS -q --junitxml="$OUT/junit/lp_threads_1.xml" || true + + # --- The whole suite, as CI runs it, with every test's time ----------- + + - name: Timed - full suite as CI + if: always() && (github.event_name != 'workflow_dispatch' || inputs.full_suite) + run: pytest --color=yes --durations=50 --cov --cov-report= --junitxml="$OUT/junit/full.xml" || true + + - name: Upload + if: always() + uses: actions/upload-artifact@v6 + with: + name: profile-${{ matrix.platform }} + path: ${{ env.OUT }} + if-no-files-found: error diff --git a/scripts/ci_profiling/compare.py b/scripts/ci_profiling/compare.py new file mode 100644 index 00000000..fff12d87 --- /dev/null +++ b/scripts/ci_profiling/compare.py @@ -0,0 +1,209 @@ +"""Compare what the ``Profile`` workflow measured on each platform. + +Download a run's artifacts, then point this at the directory: + + gh run download -D profile_out + python scripts/ci_profiling/compare.py probes profile_out + python scripts/ci_profiling/compare.py variants profile_out + python scripts/ci_profiling/compare.py timings profile_out --variant baseline + python scripts/ci_profiling/compare.py profiles profile_out test_plane_planning + +The directory holds one ``profile-`` folder per job: + + probe_render.json, probe_file_io.json + junit/.xml per-test times (setup + call + teardown) + prof/.prof cProfile of one test file + +``probes`` the two micro-probes, side by side. +``variants`` seconds per test file, for every variant of every platform. +``timings`` the test files and tests one platform is slowest at. +``profiles`` the functions a test file spends its own time in, per platform. +""" + +from __future__ import annotations + +import argparse +import json +import pstats +import xml.etree.ElementTree as ET +from collections import defaultdict +from pathlib import Path + +PREFIX = "profile-" + + +def _platforms(root: Path) -> dict[str, Path]: + found = { + p.name.removeprefix(PREFIX): p + for p in sorted(root.iterdir()) + if p.is_dir() and p.name.startswith(PREFIX) + } + if not found: + raise SystemExit(f"no {PREFIX}* folders under {root}") + return found + + +def _junit(path: Path) -> dict[str, float]: + """``{"tests/x/test_y.py::test_z[case]": seconds}`` from a junit file.""" + times: dict[str, float] = {} + for case in ET.parse(path).getroot().iter("testcase"): + module = case.get("classname", "").replace(".", "/") + ".py" + times[f"{module}::{case.get('name')}"] = float(case.get("time", 0.0)) + return times + + +def _by_file(times: dict[str, float]) -> dict[str, float]: + files: dict[str, float] = defaultdict(float) + for test, seconds in times.items(): + files[test.split("::")[0]] += seconds + return dict(files) + + +def _table(header: list[str], rows: list[list], width: int = 62) -> None: + def cell(value): + return f"{value:9.2f}" if isinstance(value, float) else f"{value!s:>9}" + + print(f"{header[0]:{width}s}" + "".join(f"{h[:9]:>10}" for h in header[1:])) + for row in rows: + name = str(row[0]) + name = name if len(name) <= width else "..." + name[-(width - 3) :] + print(f"{name:{width}s}" + "".join(" " + cell(v) for v in row[1:])) + + +def probes(root: Path) -> None: + platforms = _platforms(root) + print("## render probe (ms)") + rows = [] + for name, folder in platforms.items(): + path = folder / "probe_render.json" + if not path.exists(): + continue + for result in json.loads(path.read_text()): + for scene, row in result["scenes"].items(): + rows.append( + [ + f"{name} threads={result['LP_NUM_THREADS']} {scene}", + row["first_frame_ms"], + row["steady_median_ms"], + ] + ) + adapter = result["adapter"] + print(f"{name}: {adapter}") + _table(["platform / LP_NUM_THREADS / scene", "first", "steady"], rows) + + print("\n## file probe (ms per pyramid)") + rows = [] + keys: list[str] = [] + for name, folder in platforms.items(): + path = folder / "probe_file_io.json" + if not path.exists(): + continue + for directory, row in json.loads(path.read_text())["directories"].items(): + keys = [k for k in row if k.endswith("_ms")] + rows.append([f"{name} {directory}"] + [float(row[k]) for k in keys]) + short = [k.removesuffix("_ms").replace("tensorstore", "ts") for k in keys] + _table(["platform / directory", *short], rows) + + +def variants(root: Path) -> None: + for name, folder in _platforms(root).items(): + files = sorted((folder / "junit").glob("*.xml")) + per = {f.stem: _by_file(_junit(f)) for f in files if f.stem != "full"} + if not per: + continue + names = sorted({t for v in per.values() for t in v}) + print(f"\n## {name}: seconds per test file, by variant") + rows = [[t] + [v.get(t, 0.0) for v in per.values()] for t in names] + rows.append(["total"] + [sum(v.values()) for v in per.values()]) + _table(["file", *per], rows) + + +def timings(root: Path, variant: str, slow: str, top: int) -> None: + per = {} + for name, folder in _platforms(root).items(): + path = folder / "junit" / f"{variant}.xml" + if path.exists(): + per[name] = _junit(path) + if not per: + raise SystemExit(f"no junit/{variant}.xml in any folder under {root}") + slow_name = next((n for n in per if slow in n), None) + if slow_name is None: + raise SystemExit(f"no platform matching {slow!r}; have {list(per)}") + others = [n for n in per if n != slow_name] + + def excess(row: dict[str, float]) -> float: + rest = [row.get(n, 0.0) for n in others] + return row.get(slow_name, 0.0) - (sum(rest) / len(rest) if rest else 0.0) + + for title, tables in ( + ("test files", {n: _by_file(t) for n, t in per.items()}), + ("tests", per), + ): + names = {t for v in tables.values() for t in v} + rows = [{n: tables[n].get(t, 0.0) for n in tables} | {"": t} for t in names] + rows.sort(key=excess, reverse=True) + print(f"\n## {title} where {slow_name} is slowest ({variant}), seconds") + _table( + [title, *per, "excess"], + [[r[""]] + [r[n] for n in per] + [excess(r)] for r in rows[:top]], + ) + print( + "\ntotals: " + ", ".join(f"{n} {sum(t.values()):.0f}s" for n, t in per.items()) + ) + + +def profiles(root: Path, test_file: str, top: int) -> None: + per: dict[str, dict[str, float]] = {} + totals = {} + for name, folder in _platforms(root).items(): + path = folder / "prof" / f"{test_file}.prof" + if not path.exists(): + continue + stats = pstats.Stats(str(path)) + stats.strip_dirs() + totals[name] = stats.total_tt + per[name] = defaultdict(float) + for (filename, _line, function), row in stats.stats.items(): + # No line number: it differs between platforms' library versions. + per[name][f"{filename}:{function}"] += row[2] + if not per: + raise SystemExit(f"no prof/{test_file}.prof in any folder under {root}") + names = {f for v in per.values() for f in v} + ranked = sorted(names, key=lambda f: -max(v.get(f, 0.0) for v in per.values())) + print(f"## {test_file}: own time per function, seconds") + rows = [[f] + [per[n].get(f, 0.0) for n in per] for f in ranked[:top]] + rows.append(["total"] + [totals[n] for n in per]) + _table(["function", *per], rows) + print( + "\n_helpers.py:proxy_func is every call into wgpu-native; see its callers" + '\nwith: python -c "import pstats,sys; pstats.Stats(sys.argv[1])' + ".strip_dirs().print_callers('proxy_func')\" " + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + commands = parser.add_subparsers(dest="command", required=True) + for name in ("probes", "variants", "timings", "profiles"): + sub = commands.add_parser(name) + sub.add_argument("root", type=Path) + if name == "timings": + sub.add_argument("--variant", default="baseline") + sub.add_argument("--slow", default="windows", help="the slow platform") + sub.add_argument("--top", type=int, default=30) + if name == "profiles": + sub.add_argument("test_file", help="e.g. test_plane_planning") + sub.add_argument("--top", type=int, default=30) + args = parser.parse_args() + if args.command == "probes": + probes(args.root) + elif args.command == "variants": + variants(args.root) + elif args.command == "timings": + timings(args.root, args.variant, args.slow, args.top) + else: + profiles(args.root, args.test_file, args.top) + + +if __name__ == "__main__": + main() diff --git a/scripts/ci_profiling/probe_file_io.py b/scripts/ci_profiling/probe_file_io.py new file mode 100644 index 00000000..3dbba2f0 --- /dev/null +++ b/scripts/ci_profiling/probe_file_io.py @@ -0,0 +1,164 @@ +"""Time the small-file writes the multiscale tests do, on this machine's disk. + +Run on every CI platform. Many render tests write a fresh zarr pyramid into +``tmp_path`` (``tests/_plane_fixtures.write_pyramid``, +``tests/render/_plane_rig.write_levels``): a few hundred chunk files of a few +hundred kB each. This probe times that write, and tells apart the three +things that could make it slow: + +``tensorstore`` + The same write the tests do, with tensorstore's ``file_io_sync`` left at + its default (every chunk is synced to disk) and switched off. +``raw`` + The same number of files of the same size written with ``open``, with and + without ``os.fsync``. Separates the file system from tensorstore. +``remove`` + Deleting the tree again. + +Each is run in every directory given with ``--dir`` (by default the system +temp directory, where pytest's ``tmp_path`` lives, and the current directory, +which on a GitHub runner is on the workspace drive). + +Usage +----- + python scripts/ci_profiling/probe_file_io.py --out probe_file_io.json +""" + +from __future__ import annotations + +import argparse +import json +import os +import platform +import shutil +import statistics +import tempfile +import time +from pathlib import Path + +import numpy as np + +#: The pyramid of ``tests/_plane_fixtures.ANISO``: level shapes, 32^3 chunks. +LEVEL_SHAPES = [ + (64, 256, 256), + (64, 128, 128), + (64, 64, 64), + (64, 32, 32), + (64, 16, 16), +] +CHUNK = 32 + + +def _write_tensorstore(root: Path, sync: bool | None) -> None: + import tensorstore as ts + + for level, shape in enumerate(LEVEL_SHAPES): + kvstore = {"driver": "file", "path": str(root / f"s{level}")} + if sync is not None: + kvstore["file_io_sync"] = sync + array = ts.open( + { + "driver": "zarr3", + "kvstore": kvstore, + "metadata": { + "shape": list(shape), + "data_type": "float32", + "chunk_grid": { + "name": "regular", + "configuration": { + "chunk_shape": [min(s, CHUNK) for s in shape] + }, + }, + }, + "create": True, + "delete_existing": True, + } + ).result() + data = np.random.default_rng(level).random(shape, dtype=np.float32) + array[...].write(data).result() + + +def _write_raw(root: Path, n_files: int, n_bytes: int, sync: bool) -> None: + payload = os.urandom(n_bytes) + root.mkdir(parents=True) + for index in range(n_files): + with open(root / f"chunk_{index}", "wb") as handle: + handle.write(payload) + if sync: + handle.flush() + os.fsync(handle.fileno()) + + +def _count(root: Path) -> tuple[int, int]: + files = [p for p in root.rglob("*") if p.is_file()] + return len(files), sum(p.stat().st_size for p in files) + + +def _timed(function) -> float: + start = time.perf_counter() + function() + return (time.perf_counter() - start) * 1000 + + +def measure_dir(base: Path, repeats: int) -> dict: + """Run every case *repeats* times under *base*; return median ms.""" + rows: dict[str, list[float]] = {} + n_files = n_bytes = 0 + with tempfile.TemporaryDirectory(dir=base, prefix="probe_file_io_") as scratch: + scratch = Path(scratch) + for repeat in range(repeats): + for label, sync in (("default", None), ("sync_off", False)): + root = scratch / f"ts_{label}_{repeat}" + rows.setdefault(f"tensorstore_{label}_ms", []).append( + _timed(lambda r=root, s=sync: _write_tensorstore(r, s)) + ) + n_files, n_bytes = _count(root) + rows.setdefault("remove_ms", []).append( + _timed(lambda r=root: shutil.rmtree(r)) + ) + per_file = n_bytes // max(n_files, 1) + for label, sync in (("fsync", True), ("no_fsync", False)): + root = scratch / f"raw_{label}_{repeat}" + rows.setdefault(f"raw_{label}_ms", []).append( + _timed( + lambda r=root, s=sync, n=n_files, b=per_file: _write_raw( + r, n, b, s + ) + ) + ) + shutil.rmtree(root) + result = {name: round(statistics.median(times), 1) for name, times in rows.items()} + result |= {"n_files": n_files, "n_bytes": n_bytes, "repeats": repeats} + return result + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "--dir", + action="append", + help="a directory to write under (repeatable); default: temp dir and cwd", + ) + parser.add_argument("--repeats", type=int, default=5) + parser.add_argument("--out", help="write the results here as JSON") + args = parser.parse_args() + + directories = args.dir or [tempfile.gettempdir(), os.getcwd()] + results = {"platform": platform.platform(), "directories": {}} + print(f"platform: {results['platform']}") + for directory in dict.fromkeys(str(Path(d).resolve()) for d in directories): + row = measure_dir(Path(directory), args.repeats) + results["directories"][directory] = row + print(f"\n{directory}") + print(f" {row['n_files']} files, {row['n_bytes'] / 1e6:.1f} MB per pyramid") + for name, value in row.items(): + if name.endswith("_ms"): + print(f" {name:28s} {value:9.1f}") + + if args.out: + with open(args.out, "w") as handle: + json.dump(results, handle, indent=2) + + +if __name__ == "__main__": + main() diff --git a/scripts/ci_profiling/probe_render.py b/scripts/ci_profiling/probe_render.py new file mode 100644 index 00000000..c19cf7d7 --- /dev/null +++ b/scripts/ci_profiling/probe_render.py @@ -0,0 +1,177 @@ +"""Time offscreen frames of two fixed pygfx scenes on this machine's adapter. + +Run on every CI platform to compare the cost of a frame where nothing but +the GPU driver differs. It uses pygfx alone (no cellier), so a difference +between platforms here is the driver's, not ours. + +Two scenes are drawn at the size the render tests use: + +``mesh`` + A lit sphere. Cheap fragments; mostly per-frame overhead. +``volume`` + A 96^3 volume, MIP ray cast. Expensive fragments; mostly rasteriser time. + +For each scene the probe reports the first frame (which builds the shader +and the pipeline) and the median of the frames after it. + +With ``--thread-sweep`` the probe runs itself once per ``LP_NUM_THREADS`` +value, which sets how many threads llvmpipe rasterises with. The variable +is read when the driver loads, hence the subprocesses. If the frame time +does not change with the thread count, the driver is not using its threads. + +Usage +----- + python scripts/ci_profiling/probe_render.py --out probe_render.json + python scripts/ci_profiling/probe_render.py --thread-sweep default,1,2,4 +""" + +from __future__ import annotations + +import argparse +import json +import os +import platform +import statistics +import subprocess +import sys +import time + +SIZE = (320, 320) + + +def _scenes(): + import numpy as np + import pygfx as gfx + + mesh_scene = gfx.Scene() + mesh_scene.add(gfx.AmbientLight(), gfx.DirectionalLight()) + mesh_scene.add( + gfx.Mesh( + gfx.sphere_geometry(radius=40, width_segments=64, height_segments=32), + gfx.MeshPhongMaterial(color="#3a7"), + ) + ) + + z, y, x = np.indices((96, 96, 96), dtype=np.float32) + data = (np.sin(x / 7) * np.sin(y / 9) * np.sin(z / 11) * 0.5 + 0.5).astype( + np.float32 + ) + volume_scene = gfx.Scene() + volume_scene.add( + gfx.Volume( + gfx.Geometry(grid=gfx.Texture(data, dim=3)), + gfx.VolumeMipMaterial(clim=(0.0, 1.0), map=gfx.cm.viridis), + ) + ) + return {"mesh": mesh_scene, "volume": volume_scene} + + +def measure(n_frames: int) -> dict: + """Draw each scene *n_frames* times and return the timings in ms.""" + import numpy as np + import pygfx as gfx + from pygfx.renderers.wgpu import get_shared + from rendercanvas.offscreen import RenderCanvas + + result = { + "platform": platform.platform(), + "python": platform.python_version(), + "cpu_count": os.cpu_count(), + "LP_NUM_THREADS": os.environ.get("LP_NUM_THREADS", "default"), + "adapter": get_shared().device.adapter.summary, + "size": list(SIZE), + "scenes": {}, + } + for name, scene in _scenes().items(): + canvas = RenderCanvas(size=SIZE, pixel_ratio=1) + renderer = gfx.WgpuRenderer(canvas) + camera = gfx.PerspectiveCamera(50) + camera.show_object(scene, view_dir=(-0.5, -0.4, -1.0)) + canvas.request_draw(lambda r=renderer, s=scene, c=camera: r.render(s, c)) + + times = [] + for index in range(n_frames): + # Turn the camera a little so no frame can be reused. + camera.local.x += 0.01 * index + start = time.perf_counter() + frame = np.asarray(canvas.draw()) + times.append((time.perf_counter() - start) * 1000) + if not frame[..., 3].any(): + raise RuntimeError(f"the {name} scene drew nothing") + steady = times[1:] + result["scenes"][name] = { + "first_frame_ms": round(times[0], 2), + "steady_median_ms": round(statistics.median(steady), 2), + "steady_min_ms": round(min(steady), 2), + "steady_max_ms": round(max(steady), 2), + "n_frames": n_frames, + } + canvas.close() + return result + + +def _print(result: dict) -> None: + print(f"adapter: {result['adapter']}") + print( + f"cpu_count: {result['cpu_count']} LP_NUM_THREADS: {result['LP_NUM_THREADS']}" + ) + for name, row in result["scenes"].items(): + print( + f" {name:7s} first {row['first_frame_ms']:9.1f} ms " + f"steady median {row['steady_median_ms']:8.2f} ms " + f"(min {row['steady_min_ms']:.2f}, max {row['steady_max_ms']:.2f})" + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--frames", type=int, default=120) + parser.add_argument("--out", help="write the results here as JSON") + parser.add_argument( + "--thread-sweep", + help="comma-separated LP_NUM_THREADS values; 'default' leaves it unset", + ) + parser.add_argument("--json-stdout", action="store_true", help=argparse.SUPPRESS) + args = parser.parse_args() + + if args.json_stdout: + print(json.dumps(measure(args.frames))) + return + + if args.thread_sweep: + results = [] + for threads in args.thread_sweep.split(","): + env = dict(os.environ) + env.pop("LP_NUM_THREADS", None) + if threads != "default": + env["LP_NUM_THREADS"] = threads + done = subprocess.run( + [ + sys.executable, + __file__, + "--json-stdout", + "--frames", + str(args.frames), + ], + env=env, + capture_output=True, + text=True, + check=False, + ) + if done.returncode != 0: + print(f"LP_NUM_THREADS={threads} failed:\n{done.stderr}") + continue + result = json.loads(done.stdout.strip().splitlines()[-1]) + _print(result) + results.append(result) + else: + results = [measure(args.frames)] + _print(results[0]) + + if args.out: + with open(args.out, "w") as handle: + json.dump(results, handle, indent=2) + + +if __name__ == "__main__": + main() From 7e8d210ae1bfc4a32b4173d4795f4ac92f22e6a7 Mon Sep 17 00:00:00 2001 From: Kevin Yamauchi Date: Sat, 10 Oct 2026 07:01:24 +0200 Subject: [PATCH 16/24] speed up writes and picks on CI --- .github/workflows/ci.yml | 8 + .gitignore | 1 + scripts/ci_profiling/compare.py | 6 +- src/cellier/data/_tensorstore_cache.py | 125 ++++++++-- .../data/image/_ome_zarr_image_store.py | 39 ++- .../data/label/_ome_zarr_label_store.py | 17 +- .../gui/anywidget/static/dataset_info.css | 14 ++ src/cellier/gui/qt/_dataset_info.py | 105 ++++++++- tests/conftest.py | 39 +++ tests/convenience/test_non_image_controls.py | 92 ++++++++ tests/render/_pick.py | 73 ++++++ tests/render/test_clipping_cut_face.py | 5 +- tests/render/test_pick_frame.py | 61 +++++ tests/v2/data/test_tensorstore_cache.py | 222 ++++++++++++++++++ 14 files changed, 772 insertions(+), 35 deletions(-) create mode 100644 tests/render/_pick.py create mode 100644 tests/render/test_pick_frame.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 55ecc751..64bdb98f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,14 @@ jobs: # Empty elsewhere, which wgpu treats as unset. WGPU_BACKEND_TYPE: ${{ matrix.platform == 'windows-latest' && 'Vulkan' || '' }} + # Put pytest's tmp_path in the workspace (D:) on Windows. The default, + # the temp directory on C:, takes about 7 ms to sync a file, and the + # render tests write a few hundred zarr chunks each: 1.4 s a pyramid + # against 0.5 s here (scripts/ci_profiling/ci_profiling_investigation.md). + # A relative path, because pytest splits this like a shell would and a + # Windows path's backslashes would not survive. + PYTEST_ADDOPTS: ${{ matrix.platform == 'windows-latest' && '--basetemp=.pytest_basetemp' || '' }} + # spatial-graph >=0.1.0 ships prebuilt rtree wheels, # but I was getting incompatible CPython errors. SPATIAL_GRAPH_NO_PREBUILT: "1" diff --git a/.gitignore b/.gitignore index c3f6173e..c34f4367 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,7 @@ coverage.xml *.cover .hypothesis/ .pytest_cache/ +.pytest_basetemp/ # Translations *.mo diff --git a/scripts/ci_profiling/compare.py b/scripts/ci_profiling/compare.py index fff12d87..b3434a3f 100644 --- a/scripts/ci_profiling/compare.py +++ b/scripts/ci_profiling/compare.py @@ -160,12 +160,14 @@ def profiles(root: Path, test_file: str, top: int) -> None: if not path.exists(): continue stats = pstats.Stats(str(path)) - stats.strip_dirs() totals[name] = stats.total_tt per[name] = defaultdict(float) for (filename, _line, function), row in stats.stats.items(): # No line number: it differs between platforms' library versions. - per[name][f"{filename}:{function}"] += row[2] + # The base name by hand: a profile written on Windows has + # backslashes, which strip_dirs leaves alone on other platforms. + base = filename.replace("\\", "/").rsplit("/", 1)[-1] + per[name][f"{base}:{function}"] += row[2] if not per: raise SystemExit(f"no prof/{test_file}.prof in any folder under {root}") names = {f for v in per.values() for f in v} diff --git a/src/cellier/data/_tensorstore_cache.py b/src/cellier/data/_tensorstore_cache.py index b457bc56..543626ac 100644 --- a/src/cellier/data/_tensorstore_cache.py +++ b/src/cellier/data/_tensorstore_cache.py @@ -22,6 +22,23 @@ limit is a cap, not an allocation: an unused pool costs nothing and fills lazily under LRU. +Concurrency +----------- +The context also carries two limits on how much I/O tensorstore runs at +once, each a store field: + +- ``request_concurrency``: requests outstanding against a remote kvstore + (``gs://``, ``s3://``, ``http(s)://``). On a remote store a read costs a + round trip of hundreds of milliseconds and almost no CPU, so throughput is + the number of requests in flight divided by that latency. The scheduler's + ``SchedulerConfig.max_in_flight`` bounds the reads cellier issues; this + bounds the requests tensorstore sends for them. The smaller of the two + wins, so raise them together. +- ``file_io_concurrency``: reads outstanding against the local filesystem. + +Like the pool size, both are part of the context, so changing one builds a +new context and starts with an empty pool. + Rechecks -------- By default tensorstore revalidates a cached chunk against the kvstore on @@ -72,9 +89,28 @@ #: caching, restoring the one-batch dedup window described above. DEFAULT_CACHE_POOL_BYTES: int = 512 * 1024**2 - -def build_context(cache_pool_bytes: int) -> ts.Context: - """Return a ``ts.Context`` whose chunk cache is capped at *cache_pool_bytes*. +#: Default limit on requests outstanding against a remote kvstore. +#: +#: Tensorstore's own default, so a store that does not set the field behaves +#: as it did before the field existed. +DEFAULT_REQUEST_CONCURRENCY: int = 32 + +#: The context resources ``request_concurrency`` sets, one per remote +#: kvstore driver. A store uses one driver, and a limit on a driver it does +#: not use costs nothing, so one number covers all three. +_REQUEST_CONCURRENCY_RESOURCES: tuple[str, ...] = ( + "gcs_request_concurrency", + "s3_request_concurrency", + "http_request_concurrency", +) + + +def build_context( + cache_pool_bytes: int, + request_concurrency: int = DEFAULT_REQUEST_CONCURRENCY, + file_io_concurrency: int | None = None, +) -> ts.Context: + """Return a ``ts.Context`` carrying a store's cache cap and I/O limits. Pass the result to every ``ts.open`` of a single store, so all of its resolution levels share one budget. @@ -83,6 +119,13 @@ def build_context(cache_pool_bytes: int) -> ts.Context: ---------- cache_pool_bytes : int Cache cap in bytes. ``0`` disables caching. + request_concurrency : int + Requests outstanding at once against a remote kvstore, whichever of + the ``gcs``, ``s3`` and ``http`` drivers the store uses. + file_io_concurrency : int or None + Reads outstanding at once against the local filesystem. ``None`` + leaves tensorstore's default, which it derives from the machine's + core count. Returns ------- @@ -91,7 +134,14 @@ def build_context(cache_pool_bytes: int) -> ts.Context: means building a new one and reopening the handles against it -- which is cheap, as tensorstore caches the array metadata too. """ - return ts.Context({"cache_pool": {"total_bytes_limit": int(cache_pool_bytes)}}) + spec: dict[str, Any] = { + "cache_pool": {"total_bytes_limit": int(cache_pool_bytes)}, + } + for resource in _REQUEST_CONCURRENCY_RESOURCES: + spec[resource] = {"limit": int(request_concurrency)} + if file_io_concurrency is not None: + spec["file_io_concurrency"] = {"limit": int(file_io_concurrency)} + return ts.Context(spec) Recheck = bool | Literal["open"] @@ -163,6 +213,10 @@ class TensorStoreCacheMixin(BaseModel): store.cache_pool_bytes = 2 * 1024**3 + ``request_concurrency`` and ``file_io_concurrency`` are set the same + way and are part of the same context, so assigning either also starts + the pool empty. + It also manages the recheck policy described in the module docstring. The subclass supplies :meth:`_open_ts_handles`, which opens one handle @@ -175,6 +229,18 @@ class TensorStoreCacheMixin(BaseModel): Chunk cache cap for this store, in bytes. Shared by all of its resolution levels. ``0`` disables caching. Defaults to :data:`DEFAULT_CACHE_POOL_BYTES`. + request_concurrency : int + Requests this store keeps outstanding against a remote kvstore + (``gs://``, ``s3://``, ``http(s)://``). Raise it together with + ``SchedulerConfig.max_in_flight``: the smaller of the two bounds the + throughput of a remote store. Has no effect on a local store. + Defaults to :data:`DEFAULT_REQUEST_CONCURRENCY`, tensorstore's own + default. + file_io_concurrency : int or None + Reads this store keeps outstanding against the local filesystem. + ``None`` (the default) leaves tensorstore's default, which it + derives from the machine's core count. Has no effect on a remote + store. recheck_cached_data : bool Revalidate cached chunks on every read, permanently. Set it when another process writes this data while it is open and cannot say @@ -186,6 +252,8 @@ class TensorStoreCacheMixin(BaseModel): """ cache_pool_bytes: int = DEFAULT_CACHE_POOL_BYTES + request_concurrency: int = DEFAULT_REQUEST_CONCURRENCY + file_io_concurrency: int | None = None recheck_cached_data: bool = False #: Set by a paint controller while it holds this store open for writing. @@ -210,6 +278,22 @@ def _check_cache_pool_bytes(cls, value: int) -> int: raise ValueError(f"cache_pool_bytes must be >= 0, got {value}.") return value + @field_validator("request_concurrency") + @classmethod + def _check_request_concurrency(cls, value: int) -> int: + """Reject a limit that would allow no requests.""" + if value < 1: + raise ValueError(f"request_concurrency must be >= 1, got {value}.") + return value + + @field_validator("file_io_concurrency") + @classmethod + def _check_file_io_concurrency(cls, value: int | None) -> int | None: + """Reject a limit that would allow no reads.""" + if value is not None and value < 1: + raise ValueError(f"file_io_concurrency must be >= 1, got {value}.") + return value + def model_post_init(self, __context: Any) -> None: """Hand off to the next ``model_post_init`` in the MRO. @@ -302,12 +386,17 @@ def _wants_rechecks(self) -> bool: return self.recheck_cached_data or self._paint_writer_ref is not None def _reopen_ts_stores(self) -> None: - """Open the handles on a new context sized by ``cache_pool_bytes``. + """Open the handles on a new context built from the context fields. - Used at construction and when the budget changes. The new context - starts with an empty pool. + Used at construction and when ``cache_pool_bytes``, + ``request_concurrency`` or ``file_io_concurrency`` changes. The new + context starts with an empty pool. """ - context = build_context(self.cache_pool_bytes) + context = build_context( + self.cache_pool_bytes, + request_concurrency=self.request_concurrency, + file_io_concurrency=self.file_io_concurrency, + ) recheck = self._wants_rechecks() self._ts_stores = self._open_ts_handles(context, recheck) self._ts_context = context @@ -354,7 +443,8 @@ def _invalidate_caches(self, kind: Any) -> None: def __setattr__(self, key: str, value: Any) -> None: """Set a field, reopening the handles when the cache settings change. - ``cache_pool_bytes`` rebuilds the pool on a new context. + ``cache_pool_bytes``, ``request_concurrency`` and + ``file_io_concurrency`` rebuild the pool on a new context. ``recheck_cached_data`` reopens on the same context. Every other field is set as usual. Assigning the value a field already has is a no-op, so a redundant write does not throw away a warm cache. @@ -362,7 +452,7 @@ def __setattr__(self, key: str, value: Any) -> None: Raises ------ ValueError - If *value* is negative. + If *value* is out of range for the field. RuntimeError If a paint transaction is open on this store. Reopening would leave the paint write buffer holding a handle bound to the @@ -375,17 +465,24 @@ def __setattr__(self, key: str, value: Any) -> None: # pool and an open transaction is not a hazard. self._sync_rechecks() return - if key != "cache_pool_bytes": + if key == "cache_pool_bytes": + value = self._check_cache_pool_bytes(int(value)) + elif key == "request_concurrency": + value = self._check_request_concurrency(int(value)) + elif key == "file_io_concurrency": + value = self._check_file_io_concurrency( + None if value is None else int(value) + ) + else: super().__setattr__(key, value) return - value = self._check_cache_pool_bytes(int(value)) - if value == self.__dict__.get("cache_pool_bytes"): + if value == self.__dict__.get(key): # Nothing to do, and reopening would discard a warm cache. return if self._has_open_paint_transaction(): raise RuntimeError( - "Cannot change cache_pool_bytes while a paint transaction is " + f"Cannot change {key} while a paint transaction is " "open on this store: the paint write buffer holds a handle " "bound to the current cache pool. Commit or abort the stroke " "first." diff --git a/src/cellier/data/image/_ome_zarr_image_store.py b/src/cellier/data/image/_ome_zarr_image_store.py index 18c514bd..14df4d0b 100644 --- a/src/cellier/data/image/_ome_zarr_image_store.py +++ b/src/cellier/data/image/_ome_zarr_image_store.py @@ -21,6 +21,7 @@ from cellier.data._dataset_info import DatasetInfo, ome_zarr_dataset_info from cellier.data._tensorstore_cache import ( DEFAULT_CACHE_POOL_BYTES, + DEFAULT_REQUEST_CONCURRENCY, Recheck, TensorStoreCacheMixin, recheck_spec_options, @@ -182,8 +183,10 @@ def _open_ome_ts_stores( Context every level is opened on, so they share one chunk cache (:func:`~cellier.data._tensorstore_cache.build_context`). anonymous : bool - When True, use anonymous credentials for S3/GCS access - (for public buckets). Default False. + When True, read an ``s3://`` store without signing requests (for + public buckets). ``gs://`` needs no setting: a public bucket is + read unauthenticated whenever no Google credentials are found. + Default False. recheck : bool Whether cached chunks are revalidated on read (:func:`~cellier.data._tensorstore_cache.recheck_spec_options`). @@ -198,14 +201,13 @@ def _open_ome_ts_stores( "kvstore": _build_kvstore_spec(zarr_path, name), **recheck_spec_options(recheck), } - # Use anonymous credentials for public cloud buckets. - if anonymous and scheme in ("s3", "gs", "gcs"): - if scheme == "s3": - spec.setdefault("context", {})["aws_credentials"] = { - "anonymous": True, - } - else: - spec.setdefault("context", {})["gcs_user_project"] = "" + # S3 needs to be told not to sign requests. GCS does not: the + # ``gcs`` driver sends unauthenticated requests whenever it finds no + # credentials, and has no setting that forces it to. + if anonymous and scheme == "s3": + spec.setdefault("context", {})["aws_credentials"] = { + "type": "anonymous", + } store = ts.open(spec, context=context).result() stores.append(store) return stores @@ -458,6 +460,8 @@ def from_path( series_index: int = 0, anonymous: bool = False, cache_pool_bytes: int = DEFAULT_CACHE_POOL_BYTES, + request_concurrency: int = DEFAULT_REQUEST_CONCURRENCY, + file_io_concurrency: int | None = None, recheck_cached_data: bool = False, data_coordinate_system: DataCoordinateSystem | None = None, name: str = "ome zarr image data store", @@ -480,11 +484,20 @@ def from_path( For Bf2Raw containers, which image series to open. Ignored for standard Image stores. Defaults to 0. anonymous : bool - When True, use anonymous credentials for S3/GCS access - (for public buckets). Default False. + When True, read an ``s3://`` store without signing requests (for + public buckets). ``gs://`` needs no setting: a public bucket is + read unauthenticated whenever no Google credentials are found. + Default False. cache_pool_bytes : int Chunk cache cap for this store, in bytes, shared by all of its resolution levels. ``0`` disables caching. + request_concurrency : int + Requests outstanding at once against a remote kvstore. Raise it + together with ``SchedulerConfig.max_in_flight``. No effect on a + local store. + file_io_concurrency : int or None + Reads outstanding at once against the local filesystem. ``None`` + leaves tensorstore's default. No effect on a remote store. recheck_cached_data : bool Revalidate cached chunks on every read. Set it when another process writes this data while it is open. Default False. @@ -578,6 +591,8 @@ def from_path( physical_translation=physical_translation, anonymous=anonymous, cache_pool_bytes=cache_pool_bytes, + request_concurrency=request_concurrency, + file_io_concurrency=file_io_concurrency, recheck_cached_data=recheck_cached_data, name=name, ) diff --git a/src/cellier/data/label/_ome_zarr_label_store.py b/src/cellier/data/label/_ome_zarr_label_store.py index b2f4dc7a..45ed1b77 100644 --- a/src/cellier/data/label/_ome_zarr_label_store.py +++ b/src/cellier/data/label/_ome_zarr_label_store.py @@ -20,6 +20,7 @@ from cellier.data._dataset_info import DatasetInfo, ome_zarr_dataset_info from cellier.data._tensorstore_cache import ( DEFAULT_CACHE_POOL_BYTES, + DEFAULT_REQUEST_CONCURRENCY, Recheck, TensorStoreCacheMixin, ) @@ -122,6 +123,8 @@ def from_path( multiscale_index: int = 0, anonymous: bool = False, cache_pool_bytes: int = DEFAULT_CACHE_POOL_BYTES, + request_concurrency: int = DEFAULT_REQUEST_CONCURRENCY, + file_io_concurrency: int | None = None, recheck_cached_data: bool = False, data_coordinate_system: DataCoordinateSystem | None = None, name: str = "ome zarr label data store", @@ -140,10 +143,20 @@ def from_path( multiscale_index : int Which ``multiscales[]`` entry to use. Defaults to 0. anonymous : bool - When True, use anonymous credentials for S3/GCS access. + When True, read an ``s3://`` store without signing requests (for + public buckets). ``gs://`` needs no setting: a public bucket is + read unauthenticated whenever no Google credentials are found. + Default False. cache_pool_bytes : int Chunk cache cap for this store, in bytes, shared by all of its resolution levels. ``0`` disables caching. + request_concurrency : int + Requests outstanding at once against a remote kvstore. Raise it + together with ``SchedulerConfig.max_in_flight``. No effect on a + local store. + file_io_concurrency : int or None + Reads outstanding at once against the local filesystem. ``None`` + leaves tensorstore's default. No effect on a remote store. recheck_cached_data : bool Revalidate cached chunks on every read. Set it when another process writes this data while it is open. Default False. @@ -220,6 +233,8 @@ def from_path( physical_translation=physical_translation, anonymous=anonymous, cache_pool_bytes=cache_pool_bytes, + request_concurrency=request_concurrency, + file_io_concurrency=file_io_concurrency, recheck_cached_data=recheck_cached_data, name=name, ) diff --git a/src/cellier/gui/anywidget/static/dataset_info.css b/src/cellier/gui/anywidget/static/dataset_info.css index 2dc98035..8c4a31f3 100644 --- a/src/cellier/gui/anywidget/static/dataset_info.css +++ b/src/cellier/gui/anywidget/static/dataset_info.css @@ -17,6 +17,7 @@ .cellier-dataset-info table { border-collapse: collapse; margin-top: 4px; + max-width: 100%; } .cellier-dataset-info td, @@ -26,6 +27,17 @@ text-align: left; } +/* A long value (a store's URL has no spaces to break at) wraps inside its + cell instead of pushing the table, and the dock with it, wider. The row + label keeps to one line so the wrapping all lands in the value. */ +.cellier-dataset-info td { + overflow-wrap: anywhere; +} + +.cellier-dataset-info th { + white-space: nowrap; +} + /* Nested section blocks (axes, scale levels, geff properties). */ .cellier-dataset-info details details { margin: 4px 0 4px 8px; @@ -36,4 +48,6 @@ .cellier-dataset-info table.cellier-matrix td { text-align: right; font-variant-numeric: tabular-nums; + overflow-wrap: normal; + white-space: nowrap; } diff --git a/src/cellier/gui/qt/_dataset_info.py b/src/cellier/gui/qt/_dataset_info.py index 50c121a2..345e5b44 100644 --- a/src/cellier/gui/qt/_dataset_info.py +++ b/src/cellier/gui/qt/_dataset_info.py @@ -2,6 +2,7 @@ from __future__ import annotations +import functools from typing import TYPE_CHECKING from cellier.gui._dataset_info import ( @@ -22,6 +23,97 @@ ] +#: The least a value cell asks for, in average characters. Below this a +#: middle-elided value stops being recognisable. +VALUE_MIN_CHARS = 8 + +#: The most a value cell asks for, in average characters. A longer value is +#: elided in the middle unless the layout has spare width to give it. +VALUE_PREFERRED_CHARS = 28 + + +@functools.cache +def _value_label_class() -> type: + """Return the ``QLabel`` subclass used for a row's value. + + Built on first use so importing this module does not import Qt, like + every other Qt import in the file. + """ + from qtpy.QtCore import QEvent, QSize, Qt + from qtpy.QtGui import QPainter + from qtpy.QtWidgets import QApplication, QLabel, QMenu + + class _ElidedValueLabel(QLabel): + """A value that elides in the middle rather than widening its parent. + + A plain ``QLabel`` is never narrower than its text, and a side dock + is as wide as the widest thing in it, so one long value (a store's + URL) set the width of the whole dock, even with the block collapsed. + This label asks for a bounded width, draws what fits with the middle + elided (both ends of a path are worth reading), and gives the whole + value in a tooltip while it is cut short. + + ``text()`` is always the full value. The drawn text may be elided, + so it is not mouse-selectable; the context menu copies the full + value instead. + """ + + def __init__(self, text: str = "", parent=None) -> None: + super().__init__(parent) + # Displayed, never interpreted as markup. + self.setTextFormat(Qt.TextFormat.PlainText) + self.setText(text) + + def _text_width(self) -> int: + return self.fontMetrics().horizontalAdvance(self.text()) + + def _bounded_width(self, n_chars: int) -> int: + margins = self.contentsMargins() + limit = self.fontMetrics().averageCharWidth() * n_chars + return ( + min(self._text_width(), limit) + + margins.left() + + margins.right() + + 2 * self.margin() + ) + + def minimumSizeHint(self) -> QSize: + height = super().minimumSizeHint().height() + return QSize(self._bounded_width(VALUE_MIN_CHARS), height) + + def sizeHint(self) -> QSize: + height = super().sizeHint().height() + return QSize(self._bounded_width(VALUE_PREFERRED_CHARS), height) + + def is_elided(self) -> bool: + """Whether the value is currently drawn cut short.""" + return self._text_width() > self.contentsRect().width() + + def event(self, event) -> bool: + # Decided when the tooltip is asked for, so it follows the width + # the label has now rather than the last resize it was sent. + if event.type() == QEvent.Type.ToolTip: + self.setToolTip(self.text() if self.is_elided() else "") + return super().event(event) + + def paintEvent(self, event) -> None: + rect = self.contentsRect() + drawn = self.fontMetrics().elidedText( + self.text(), Qt.TextElideMode.ElideMiddle, rect.width() + ) + painter = QPainter(self) + painter.drawText(rect, int(self.alignment()), drawn) + painter.end() + + def contextMenuEvent(self, event) -> None: + menu = QMenu(self) + copy = menu.addAction("Copy") + if menu.exec(event.globalPos()) is copy: + QApplication.clipboard().setText(self.text()) + + return _ElidedValueLabel + + class QtDatasetInfo: """Read-only display widget for dataset metadata. @@ -113,13 +205,16 @@ def section_labels(self) -> list[str]: def _form_widget(self, rows: Sequence[tuple[str, str]]): """A ``(label, value)`` block as one widget.""" - from qtpy.QtWidgets import QFormLayout, QLabel, QWidget + from qtpy.QtWidgets import QFormLayout, QWidget content = QWidget() form = QFormLayout(content) form.setContentsMargins(4, 4, 4, 4) + # Some styles (macOS) keep a field at its size hint. A value cell's + # hint is bounded, so let it take spare width and elide less. + form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) for label, value in rows: - form.addRow(str(label), QLabel(str(value))) + form.addRow(str(label), _value_label_class()(str(value))) return content, form def _add_rows(self, rows: Sequence[tuple[str, str]], *, label: str | None) -> None: @@ -129,14 +224,14 @@ def _add_rows(self, rows: Sequence[tuple[str, str]], *, label: str | None) -> No list, which is what a store means by declaring rows with no label. A labelled block always starts a new one. """ - from qtpy.QtWidgets import QLabel - if label is None: if self._inline_form is None: content, self._inline_form = self._form_widget(()) self._collapsible.addWidget(content) for row_label, value in rows: - self._inline_form.addRow(str(row_label), QLabel(str(value))) + self._inline_form.addRow( + str(row_label), _value_label_class()(str(value)) + ) self._section_labels.append(str(rows[0][0]) if rows else "") return diff --git a/tests/conftest.py b/tests/conftest.py index 0b14b2c6..d0a28c72 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -25,6 +25,45 @@ def _tracking_init(self, *args, **kwargs): return created +@pytest.hookimpl(wrapper=True) +def pytest_pyfunc_call(pyfuncitem): + """Give rendercanvas its asyncgen hooks back after an async test. + + rendercanvas picks its sleep by reading ``sys.get_asyncgen_hooks()``, and + its Qt loop installs hooks of its own when it starts. asyncio saves the + hooks when a loop starts running and puts the saved ones back when it + stops. So when the Qt loop starts *inside* an async test (the test pumps + Qt, ``canvas.force_draw()`` for one), asyncio wipes the hooks rendercanvas + just installed as the test returns, and rendercanvas does not notice. Its + sleep is then a no-op, the canvas scheduler never yields, and the next + ``processEvents`` -- pytest-qt calls one right after the test body -- + draws frames forever. + + This runs between the test body and that ``processEvents``. An app is not + exposed: ``QtAsyncio`` leaves the hooks alone, and its loop lasts as long + as the app does. + """ + try: + return (yield) + finally: + _restore_rendercanvas_asyncgen_hooks() + + +def _restore_rendercanvas_asyncgen_hooks() -> None: + qt_backend = sys.modules.get("rendercanvas.qt") + if qt_backend is None: + return + loop = qt_backend.loop + # Name-mangled private state: there is no public way to ask whether the + # loop believes its hooks are installed. + believes_installed = getattr(loop, "_BaseLoop__hook_data", None) is not None + if believes_installed and sys.get_asyncgen_hooks().firstiter is None: + sys.set_asyncgen_hooks( + firstiter=loop._asyncgen_firstiter_hook, + finalizer=loop._asyncgen_finalizer_hook, + ) + + @pytest.fixture(autouse=True) def _close_cellier_objects(monkeypatch): """Close every ``CellierController`` and ``CanvasView`` a test creates. diff --git a/tests/convenience/test_non_image_controls.py b/tests/convenience/test_non_image_controls.py index d033593c..c4d03bec 100644 --- a/tests/convenience/test_non_image_controls.py +++ b/tests/convenience/test_non_image_controls.py @@ -685,3 +685,95 @@ def test_both_front_ends_agree_on_matrix_formatting(qtbot): for row in range(table.rowCount()) ] assert qt_values == any_values + + +# -- Long values must not widen the dock --------------------------------------- + +LONG_PATH = "https://example.org/" + "/".join(["a-long-path-segment"] * 20) + + +class _PathStore: + """The least a store needs for ``QtDatasetInfo.from_store``.""" + + def __init__(self, path: str) -> None: + self._path = path + + def dataset_info(self): + from cellier.data._dataset_info import DatasetInfo, RowSection + + return DatasetInfo( + sections=[ + RowSection(None, [("Path", self._path), ("Data type", "uint16")]), + RowSection("Scale levels", [("s0", self._path)], collapsed=True), + ] + ) + + +def test_a_long_path_does_not_widen_the_dataset_info_block(qtbot): + """A store's URL is elided, not allowed to set the width of the block.""" + from PySide6.QtCore import QEvent, QPoint + from PySide6.QtGui import QHelpEvent + from PySide6.QtWidgets import QApplication, QLabel + + from cellier.gui.qt import QtDatasetInfo + + short = QtDatasetInfo.from_store(_PathStore("a-long-path-segment")) + long = QtDatasetInfo.from_store(_PathStore(LONG_PATH)) + + assert long.widget.minimumSizeHint().width() == ( + short.widget.minimumSizeHint().width() + ) + # The preferred width is bounded too: past the cap, length adds nothing. + medium = QtDatasetInfo.from_store(_PathStore(LONG_PATH[:60])) + assert long.widget.sizeHint().width() == medium.widget.sizeHint().width() + + # The full value is still there to read and copy. + label = next( + child for child in long.widget.findChildren(QLabel) if child.text() == LONG_PATH + ) + + def ask_for_tooltip() -> str: + point = QPoint(2, 2) + help_event = QHelpEvent(QEvent.Type.ToolTip, point, label.mapToGlobal(point)) + QApplication.sendEvent(label, help_event) + return label.toolTip() + + label.resize(120, label.sizeHint().height()) + assert label.is_elided() + assert ask_for_tooltip() == LONG_PATH + label.resize(label.fontMetrics().horizontalAdvance(LONG_PATH) + 20, 20) + assert not label.is_elided() + assert ask_for_tooltip() == "" + + +def test_a_long_path_does_not_widen_the_qt_dock(qtbot, multiscale_image_store): + """The side dock asks for the same width whatever the path's length. + + The dock is as wide as its content's minimum size hint, so one unbroken + value used to push it across most of the window, block collapsed or not. + """ + from cellier.convenience.layout._qt_renderer import ( + _scroll_dock_widget, + _wrap_dock_widget, + ) + + def dock_width(path: str) -> int: + viewer = Viewer(spatial_axes("z", "y", "x"), gui="qt") + viewer.add_image_multiscale( + multiscale_image_store, + appearance=MultiscaleImageAppearance(), + controls=MultiscaleImageControlsConfig( + appearance=["color_map"], + dataset_info=[("Path", path), ("Data type", "float32")], + ), + single=MultiscaleImageSingleAppearance( + color_map="viridis", clim=(0.0, 1.0) + ), + ) + container = render_dock(AppearanceControls(), viewer, QtLayoutHost(), []) + content = _wrap_dock_widget(container, "left") + content.setMinimumWidth(QtLayoutHost.DEFAULT_DOCK_MIN_WIDTH) + area = _scroll_dock_widget(content) + return area.minimumSizeHint().width() + + assert dock_width(LONG_PATH) == dock_width("a.zarr") diff --git a/tests/render/_pick.py b/tests/render/_pick.py new file mode 100644 index 00000000..470f9006 --- /dev/null +++ b/tests/render/_pick.py @@ -0,0 +1,73 @@ +"""Pick many pixels of one drawn frame with a single GPU read-back. + +``renderer.get_pick_info`` copies one pixel to a buffer, submits and waits +for the buffer to map: a round trip to the GPU per pixel. On a software +rasteriser that is a memory copy, but on a real (or virtualised) GPU it is +milliseconds, and a test that picks hundreds of pixels spends all its time +waiting (``scripts/ci_profiling/ci_profiling_investigation.md``). + +`PickFrame` reads the renderer's whole pick target once and decodes a pixel +the way ``get_pick_info`` does. It reaches into pygfx's private parts to do +so (the blender's ``"pick"`` texture, the object id provider and +``_wgpu_get_pick_info``), so ``tests/render/test_pick_frame.py`` checks it +against ``get_pick_info`` itself. +""" + +from __future__ import annotations + +import numpy as np +from pygfx.objects._base import id_provider +from pygfx.renderers.wgpu import get_shared + +#: The low bits of a pick value that hold the world object's id. +_OBJECT_ID_MASK = 2**20 - 1 + + +class PickFrame: + """The pick target of the frame *renderer* last drew. + + Take it after the draw and before the next one: it is a copy, and does + not follow later frames. + + Parameters + ---------- + renderer : pygfx.WgpuRenderer + The renderer, after a draw. + """ + + def __init__(self, renderer) -> None: + texture = renderer._blender.get_texture("pick") + width, height, _depth = texture.size + # rgba16uint: 8 bytes a pixel, read here as one 64-bit value. + raw = get_shared().device.queue.read_texture( + {"texture": texture, "mip_level": 0, "origin": (0, 0, 0)}, + {"offset": 0, "bytes_per_row": 8 * width, "rows_per_image": height}, + (width, height, 1), + ) + self.values = np.frombuffer(raw, np.uint64).reshape(height, width) + self._logical_size = tuple(renderer.logical_size) + + def info(self, pos: tuple[float, float]) -> dict: + """What ``renderer.get_pick_info(pos)`` returns, without ``"rgba"``. + + Parameters + ---------- + pos : tuple[float, float] + ``(x, y)`` in logical pixels, origin at the top left. + + Returns + ------- + info : dict + ``"world_object"`` (``None`` where nothing pickable was drawn) + and the object's own pick fields. + """ + height, width = self.values.shape + # The mapping of pygfx's ``_copy_pixel``. + x = max(0, min(width - 1, int(pos[0] / self._logical_size[0] * width))) + y = max(0, min(height - 1, int(pos[1] / self._logical_size[1] * height))) + pick_value = int(self.values[y, x]) + wobject = id_provider.get_object_from_id(pick_value & _OBJECT_ID_MASK) + info = {"world_object": wobject} + if wobject: + info.update(wobject._wgpu_get_pick_info(pick_value)) + return info diff --git a/tests/render/test_clipping_cut_face.py b/tests/render/test_clipping_cut_face.py index f8422d5c..7f94c5c1 100644 --- a/tests/render/test_clipping_cut_face.py +++ b/tests/render/test_clipping_cut_face.py @@ -16,6 +16,7 @@ from cellier.render._cellier_blender import NORMAL_TARGET, install_cellier_blender from cellier.render._clipping import reduce_clipping_planes from tests.render import _clipping as h +from tests.render._pick import PickFrame SIZE = 192 KINDS = [ @@ -111,6 +112,8 @@ async def test_the_cut_face_is_on_the_plane_with_its_normal( canvas.request_draw(lambda: renderer.render(gfx_scene, camera)) frame = np.asarray(canvas.draw()) normals = _read_normal_target(renderer, SIZE) + # One read-back for every pixel picked below, not one each. + picks = PickFrame(renderer) manager = controller._render_manager._scenes[scene.id] render_visual = manager.get_visual(visual.id) @@ -129,7 +132,7 @@ async def test_the_cut_face_is_on_the_plane_with_its_normal( take = rng.choice(len(rows), size=min(500, len(rows)), replace=False) angles, behind = [], [] for row, col in zip(rows[take], cols[take]): - info = renderer.get_pick_info((col + 0.5, row + 0.5)) + info = picks.info((col + 0.5, row + 0.5)) hit = info.get("world_object") if hit is None: continue diff --git a/tests/render/test_pick_frame.py b/tests/render/test_pick_frame.py new file mode 100644 index 00000000..bbbcc76b --- /dev/null +++ b/tests/render/test_pick_frame.py @@ -0,0 +1,61 @@ +"""`PickFrame` decodes a pixel exactly as ``renderer.get_pick_info`` does. + +`PickFrame` reads the pick target once where ``get_pick_info`` reads a pixel +per call, and uses pygfx's private parts to decode it; this is the check +that the two agree, on every kind of volume the tests pick from. +""" + +from __future__ import annotations + +import numpy as np +import pygfx as gfx +import pytest +from rendercanvas.offscreen import RenderCanvas as OffscreenRenderCanvas + +from tests.render import _clipping as h +from tests.render._pick import PickFrame + +#: Not square, and not a multiple of 256 bytes a row. +SIZE = (150, 97) +KINDS = [ + "image_memory_iso", + "image_multiscale_iso", + "labels_memory", + "labels_multiscale", +] + + +@pytest.mark.parametrize("kind", KINDS) +async def test_a_pick_frame_agrees_with_get_pick_info( + kind, controller, reslice, tmp_path +): + scene = controller.add_scene(dim="3d", name=kind) + h.add_visual(kind, controller, scene.id, h.data_for(kind), tmp_path, kind) + controller.add_canvas(scene_id=scene.id) + await reslice(controller, scene.id) + controller.fit_camera(scene.id) + gfx_scene, camera = h.gfx_scene(controller, scene.id) + + canvas = OffscreenRenderCanvas(size=SIZE, pixel_ratio=1) + renderer = gfx.WgpuRenderer(canvas) + renderer.pixel_scale = 1 + canvas.request_draw(lambda: renderer.render(gfx_scene, camera)) + frame = np.asarray(canvas.draw()) + picks = PickFrame(renderer) + + drawn_rows, drawn_cols = np.nonzero(frame[..., 3] > 0) + assert len(drawn_rows) > 100 + rng = np.random.default_rng(0) + take = rng.choice(len(drawn_rows), size=20, replace=False) + pixels = list(zip(drawn_rows[take], drawn_cols[take])) + # And the corners, where nothing is drawn. + pixels += [(0, 0), (SIZE[1] - 1, SIZE[0] - 1)] + + hits = 0 + for row, col in pixels: + pos = (col + 0.5, row + 0.5) + expected = renderer.get_pick_info(pos) + expected.pop("rgba") + assert picks.info(pos) == expected + hits += expected["world_object"] is not None + assert hits >= 10 diff --git a/tests/v2/data/test_tensorstore_cache.py b/tests/v2/data/test_tensorstore_cache.py index 58c7c5c0..96814323 100644 --- a/tests/v2/data/test_tensorstore_cache.py +++ b/tests/v2/data/test_tensorstore_cache.py @@ -22,6 +22,7 @@ from cellier.data._tensorstore_cache import ( DEFAULT_CACHE_POOL_BYTES, + DEFAULT_REQUEST_CONCURRENCY, build_context, cache_metrics, ) @@ -698,6 +699,227 @@ def test_build_context_sets_the_limit() -> None: assert context["cache_pool"].to_json()["total_bytes_limit"] == 1234 +def test_build_context_sets_the_request_limit_for_every_remote_driver() -> None: + context = build_context(0, request_concurrency=96) + for resource in ( + "gcs_request_concurrency", + "s3_request_concurrency", + "http_request_concurrency", + ): + assert context[resource].to_json()["limit"] == 96 + + +def test_build_context_defaults_to_tensorstores_request_limit() -> None: + context = build_context(0) + limit = context["http_request_concurrency"].to_json()["limit"] + assert limit == DEFAULT_REQUEST_CONCURRENCY + + +def test_build_context_sets_the_file_io_limit() -> None: + context = build_context(0, file_io_concurrency=7) + assert context["file_io_concurrency"].to_json()["limit"] == 7 + + +def test_build_context_leaves_the_file_io_limit_alone_by_default() -> None: + assert "file_io_concurrency" not in build_context(0).spec.to_json() + + +# --------------------------------------------------------------------------- +# Concurrency fields +# --------------------------------------------------------------------------- + + +def _opened_contexts(monkeypatch) -> list[object]: + """Record the context of every ``ts.open`` the image store makes.""" + import cellier.data.image._ome_zarr_image_store as module + + seen: list[object] = [] + original = module.ts.open + + def _spy(spec, *args, **kwargs): + seen.append(kwargs.get("context")) + return original(spec, *args, **kwargs) + + monkeypatch.setattr(module.ts, "open", _spy) + return seen + + +def test_concurrency_defaults(single_chunk_uri: str) -> None: + store = OMEZarrImageDataStore.from_path(single_chunk_uri) + assert store.request_concurrency == DEFAULT_REQUEST_CONCURRENCY + assert store.file_io_concurrency is None + + +def test_concurrency_fields_reach_the_context( + single_chunk_uri: str, monkeypatch +) -> None: + seen = _opened_contexts(monkeypatch) + OMEZarrImageDataStore.from_path( + single_chunk_uri, request_concurrency=128, file_io_concurrency=6 + ) + context = seen[0] + assert context["http_request_concurrency"].to_json()["limit"] == 128 + assert context["file_io_concurrency"].to_json()["limit"] == 6 + + +def test_label_store_takes_the_concurrency_fields( + single_chunk_label_uri: str, +) -> None: + store = OMEZarrLabelDataStore.from_path( + single_chunk_label_uri, request_concurrency=64, file_io_concurrency=3 + ) + assert store.request_concurrency == 64 + assert store.file_io_concurrency == 3 + limit = store._ts_context["gcs_request_concurrency"].to_json()["limit"] + assert limit == 64 + + +@pytest.mark.parametrize( + ("field", "value"), + [("request_concurrency", 128), ("file_io_concurrency", 4)], +) +def test_concurrency_assignment_rebuilds_the_context( + single_chunk_uri: str, field: str, value: int +) -> None: + """Assigning either limit reopens the handles on a new, empty context.""" + store = OMEZarrImageDataStore.from_path(single_chunk_uri) + _read_windows(store, 2) + handle_before = store._ts_stores[0] + context_before = store._ts_context + + setattr(store, field, value) + + assert getattr(store, field) == value + assert store._ts_stores[0] is not handle_before + assert store._ts_context is not context_before + # The warm pool went with the old context: the first read misses. + hits, misses = _hits_and_misses(store, 3) + assert misses == 1 + assert hits == 2 + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("request_concurrency", DEFAULT_REQUEST_CONCURRENCY), + ("file_io_concurrency", None), + ], +) +def test_assigning_the_same_concurrency_keeps_the_warm_cache( + single_chunk_uri: str, field: str, value: int | None +) -> None: + store = OMEZarrImageDataStore.from_path(single_chunk_uri) + _read_windows(store, 1) + handle_before = store._ts_stores[0] + + setattr(store, field, value) + + assert store._ts_stores[0] is handle_before + hits, misses = _hits_and_misses(store, 3) + assert hits == 3 + assert misses == 0 + + +def test_file_io_concurrency_can_be_reset_to_the_default( + single_chunk_uri: str, +) -> None: + store = OMEZarrImageDataStore.from_path(single_chunk_uri, file_io_concurrency=4) + store.file_io_concurrency = None + assert store.file_io_concurrency is None + assert "file_io_concurrency" not in store._ts_context.spec.to_json() + + +@pytest.mark.parametrize("field", ["request_concurrency", "file_io_concurrency"]) +def test_zero_concurrency_rejected_at_construction( + single_chunk_uri: str, field: str +) -> None: + with pytest.raises(Exception, match=field): + OMEZarrImageDataStore.from_path(single_chunk_uri, **{field: 0}) + + +@pytest.mark.parametrize("field", ["request_concurrency", "file_io_concurrency"]) +def test_zero_concurrency_rejected_on_assignment( + single_chunk_uri: str, field: str +) -> None: + store = OMEZarrImageDataStore.from_path(single_chunk_uri) + handle_before = store._ts_stores[0] + before = getattr(store, field) + with pytest.raises(ValueError, match=f"{field} must be >= 1"): + setattr(store, field, 0) + assert getattr(store, field) == before + assert store._ts_stores[0] is handle_before + + +def test_concurrency_assignment_refused_during_a_paint_transaction( + single_chunk_uri: str, +) -> None: + store = OMEZarrImageDataStore.from_path(single_chunk_uri) + writer = _FakeWriteBuffer() + store.register_paint_writer(writer) + writer.transaction = object() + with pytest.raises(RuntimeError, match="request_concurrency"): + store.request_concurrency = 64 + assert store.request_concurrency == DEFAULT_REQUEST_CONCURRENCY + + +def test_concurrency_roundtrips_through_serialization(single_chunk_uri: str) -> None: + store = OMEZarrImageDataStore.from_path( + single_chunk_uri, request_concurrency=48, file_io_concurrency=5 + ) + dumped = store.model_dump() + assert dumped["request_concurrency"] == 48 + assert dumped["file_io_concurrency"] == 5 + restored = OMEZarrImageDataStore.model_validate(dumped) + assert restored.request_concurrency == 48 + assert restored.file_io_concurrency == 5 + + +# --------------------------------------------------------------------------- +# Anonymous access +# --------------------------------------------------------------------------- + + +def _specs_opened_for(uri: str, monkeypatch, *, anonymous: bool) -> list[dict]: + """Return the specs ``_open_ome_ts_stores`` would open, without any I/O.""" + import cellier.data.image._ome_zarr_image_store as module + + specs: list[dict] = [] + + class _Done: + def result(self): + return object() + + def _fake_open(spec, *args, **kwargs): + specs.append(spec) + return _Done() + + monkeypatch.setattr(module, "_detect_zarr_driver", lambda *_: "zarr3") + monkeypatch.setattr(module.ts, "open", _fake_open) + module._open_ome_ts_stores(uri, ["0"], build_context(0), anonymous=anonymous) + return specs + + +def test_anonymous_s3_spec_is_one_tensorstore_accepts(monkeypatch) -> None: + """The credentials resource must parse: a bad one fails every open.""" + import tensorstore as ts + + (spec,) = _specs_opened_for( + "s3://bucket/image.ome.zarr", monkeypatch, anonymous=True + ) + resource = ts.Context(spec["context"])["aws_credentials"] + assert resource.to_json() == {"type": "anonymous"} + + +@pytest.mark.parametrize("scheme", ["gs", "gcs"]) +def test_anonymous_gcs_adds_no_context(monkeypatch, scheme: str) -> None: + """GCS reads unauthenticated on its own; it once got a spec that failed.""" + (spec,) = _specs_opened_for( + f"{scheme}://bucket/image.ome.zarr", monkeypatch, anonymous=True + ) + assert "context" not in spec + assert spec["kvstore"]["driver"] == "gcs" + + def test_cache_metrics_returns_two_counts() -> None: hits, misses = cache_metrics() assert isinstance(hits, int) From fdcd48834a221982fba29954481d98585fbe9e95 Mon Sep 17 00:00:00 2001 From: Kevin Yamauchi Date: Sat, 10 Oct 2026 07:54:11 +0200 Subject: [PATCH 17/24] reduce writing time in tests --- .github/workflows/ci.yml | 8 ++ .github/workflows/profile.yml | 102 +++++++++++++++++-- scripts/ci_profiling/compare.py | 68 +++++++++++-- src/cellier/gui/qt/visuals/_render_planes.py | 67 +++++++++--- tests/conftest.py | 17 ++++ tests/render/_plane_rig.py | 18 +++- tests/render/test_plane_planning.py | 85 ++++++++-------- 7 files changed, 289 insertions(+), 76 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 64bdb98f..30c7e879 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -100,6 +100,14 @@ jobs: run: | python -c "from pygfx.renderers.wgpu import get_shared; print('adapter:', get_shared().device.adapter.summary)" + # Two jobs on the same code have differed by 1.6 times. This logs what + # could explain it: the CPU the runner drew, a fixed piece of work timed + # on it, and the coverage core (pytest's header repeats the core the run + # itself uses). + - name: Machine and coverage core + shell: bash + run: python scripts/ci_profiling/machine_info.py + - name: 🧪 Run Tests run: pytest --color=yes --durations=25 --cov --cov-report=xml --cov-report=term-missing diff --git a/.github/workflows/profile.yml b/.github/workflows/profile.yml index 6160944c..cac5c7a6 100644 --- a/.github/workflows/profile.yml +++ b/.github/workflows/profile.yml @@ -4,7 +4,8 @@ name: Profile # CI: run it by hand (Actions -> Profile -> Run workflow), or push a change to # this file or to scripts/ci_profiling/ in a pull request. # -# Each platform uploads a `profile-` artifact; compare them with +# Each platform uploads a `probes-` artifact (the micro-probes, a +# few minutes) and a `profile-` one; compare them with # scripts/ci_profiling/compare.py (see its docstring). on: @@ -31,6 +32,94 @@ concurrency: cancel-in-progress: true jobs: + # Minutes, not tens of minutes: the micro-probes alone, so their numbers are + # in before the profile job below is done. Uploads `probes-`. + probes: + name: probes ${{ matrix.platform }} + runs-on: ${{ matrix.platform }} + strategy: + fail-fast: false + matrix: + platform: [ubuntu-latest, macos-latest, windows-latest] + + env: + # As in ci.yml. + WGPU_BACKEND_TYPE: ${{ matrix.platform == 'windows-latest' && 'Vulkan' || '' }} + SPATIAL_GRAPH_NO_PREBUILT: "1" + OUT: probes_out + + defaults: + run: + shell: bash + + steps: + - uses: actions/checkout@v7 + + # The environment below is ci.yml's, step for step, so the numbers are + # the ones CI sees. + - name: Set up headless GUI + uses: pyvista/setup-headless-display-action@v5 + with: + qt: true + + - name: Install llvmpipe and lavapipe for offscreen canvas + if: matrix.platform == 'ubuntu-latest' + run: | + sudo apt-get update -y -qq + sudo apt install -y libegl1-mesa-dev libgl1-mesa-dri libxcb-xfixes0-dev mesa-vulkan-drivers + + - name: Install lavapipe for offscreen canvas + if: matrix.platform == 'windows-latest' + uses: jakoch/install-vulkan-sdk-action@v1 + with: + install_lavapipe: true + stripdown: true + cache: true + + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: "3.13" + cache-dependency-path: "pyproject.toml" + cache: "pip" + + - name: Install Dependencies + run: | + python -m pip install -U pip + python -m pip install .[test,pyside,remote,anywidget,examples,graph] + + - name: Machine + run: | + mkdir -p "$OUT" + python -c "from pygfx.renderers.wgpu import get_shared; print('adapter:', get_shared().device.adapter.summary)" + python scripts/ci_profiling/machine_info.py | tee "$OUT/machine_info.txt" + + # No cellier code involved in the first three. + - name: Probe - file writes + if: always() + run: python scripts/ci_profiling/probe_file_io.py --out "$OUT/probe_file_io.json" + + - name: Probe - frame time + if: always() + run: python scripts/ci_profiling/probe_render.py --thread-sweep default,1,2,4 --out "$OUT/probe_render.json" + + - name: Probe - single wgpu calls + if: always() + run: python scripts/ci_profiling/probe_gpu_calls.py --out "$OUT/probe_gpu_calls.json" + + # Reads the datasets of two test files through cellier's stores. + - name: Probe - brick reads + if: always() + run: python scripts/ci_profiling/probe_chunk_read.py --out "$OUT/probe_chunk_read.json" + + - name: Upload + if: always() + uses: actions/upload-artifact@v6 + with: + name: probes-${{ matrix.platform }} + path: ${{ env.OUT }} + if-no-files-found: error + profile: name: profile ${{ matrix.platform }} runs-on: ${{ matrix.platform }} @@ -97,16 +186,7 @@ jobs: echo "TESTS=${INPUT_TESTS:-$DEFAULT_TESTS}" >> "$GITHUB_ENV" python -c "import os, platform, tempfile; print(platform.platform()); print('cpus:', os.cpu_count()); print('temp:', tempfile.gettempdir()); print('cwd:', os.getcwd())" python -c "from pygfx.renderers.wgpu import get_shared; print('adapter:', get_shared().device.adapter.summary)" - - # --- Micro-probes: seconds each, no cellier code involved ------------- - - - name: Probe - file writes - if: always() - run: python scripts/ci_profiling/probe_file_io.py --out "$OUT/probe_file_io.json" - - - name: Probe - frame time - if: always() - run: python scripts/ci_profiling/probe_render.py --thread-sweep default,1,2,4 --out "$OUT/probe_render.json" + python scripts/ci_profiling/machine_info.py # --- cProfile of each test file --------------------------------------- # Also warms every cache, so the timed runs below start equal. diff --git a/scripts/ci_profiling/compare.py b/scripts/ci_profiling/compare.py index b3434a3f..9565a1dd 100644 --- a/scripts/ci_profiling/compare.py +++ b/scripts/ci_profiling/compare.py @@ -8,13 +8,17 @@ python scripts/ci_profiling/compare.py timings profile_out --variant baseline python scripts/ci_profiling/compare.py profiles profile_out test_plane_planning -The directory holds one ``profile-`` folder per job: +The directory holds a ``probes-`` and a ``profile-`` +folder per platform: - probe_render.json, probe_file_io.json - junit/.xml per-test times (setup + call + teardown) - prof/.prof cProfile of one test file + probes-/ + machine_info.txt, probe_render.json, probe_file_io.json, + probe_gpu_calls.json, probe_chunk_read.json + profile-/ + junit/.xml per-test times (setup + call + teardown) + prof/.prof cProfile of one test file -``probes`` the two micro-probes, side by side. +``probes`` the micro-probes, side by side. ``variants`` seconds per test file, for every variant of every platform. ``timings`` the test files and tests one platform is slowest at. ``profiles`` the functions a test file spends its own time in, per platform. @@ -30,19 +34,28 @@ from pathlib import Path PREFIX = "profile-" +PROBES_PREFIX = "probes-" -def _platforms(root: Path) -> dict[str, Path]: +def _platforms(root: Path, prefix: str = PREFIX) -> dict[str, Path]: found = { - p.name.removeprefix(PREFIX): p + p.name.removeprefix(prefix): p for p in sorted(root.iterdir()) - if p.is_dir() and p.name.startswith(PREFIX) + if p.is_dir() and p.name.startswith(prefix) } if not found: - raise SystemExit(f"no {PREFIX}* folders under {root}") + raise SystemExit(f"no {prefix}* folders under {root}") return found +def _probe_folders(root: Path) -> dict[str, Path]: + """The folders holding probe results: ``probes-*``, else ``profile-*``.""" + try: + return _platforms(root, PROBES_PREFIX) + except SystemExit: + return _platforms(root) + + def _junit(path: Path) -> dict[str, float]: """``{"tests/x/test_y.py::test_z[case]": seconds}`` from a junit file.""" times: dict[str, float] = {} @@ -71,7 +84,7 @@ def cell(value): def probes(root: Path) -> None: - platforms = _platforms(root) + platforms = _probe_folders(root) print("## render probe (ms)") rows = [] for name, folder in platforms.items(): @@ -104,6 +117,41 @@ def probes(root: Path) -> None: short = [k.removesuffix("_ms").replace("tensorstore", "ts") for k in keys] _table(["platform / directory", *short], rows) + per = {} + for name, folder in platforms.items(): + path = folder / "probe_gpu_calls.json" + if path.exists(): + per[name] = json.loads(path.read_text())["calls"] + if per: + print("\n## single wgpu calls (median ms per call)") + calls = list(dict.fromkeys(c for v in per.values() for c in v)) + rows = [ + [c] + [float(per[n][c]["median_ms"]) if c in per[n] else "-" for n in per] + for c in calls + ] + _table(["call", *per], rows, width=44) + + rows = [] + keys = [] + for name, folder in platforms.items(): + path = folder / "probe_chunk_read.json" + if not path.exists(): + continue + for directory, cases in json.loads(path.read_text())["directories"].items(): + for case, row in cases.items(): + keys = [k for k in row if k.endswith("_ms")] + rows.append( + [f"{name} {case} {directory}"] + [float(row[k]) for k in keys] + ) + if rows: + print("\n## brick reads (ms)") + _table(["platform / dataset / directory", *[k[:-3] for k in keys]], rows) + + for name, folder in platforms.items(): + path = folder / "machine_info.txt" + if path.exists(): + print(f"\n## machine: {name}\n{path.read_text().rstrip()}") + def variants(root: Path) -> None: for name, folder in _platforms(root).items(): diff --git a/src/cellier/gui/qt/visuals/_render_planes.py b/src/cellier/gui/qt/visuals/_render_planes.py index 15601797..0d0eb046 100644 --- a/src/cellier/gui/qt/visuals/_render_planes.py +++ b/src/cellier/gui/qt/visuals/_render_planes.py @@ -35,6 +35,15 @@ _OUTSIDE_COLOR = "#d08020" #: The label of each extent side, by ``side`` (0 the minimum, 1 the maximum). _SIDE_NAMES = ("min", "max") +#: The least width of an extent side's number, in pixels. Its range would +#: otherwise size it for a ten digit number and widen the whole dock. +_SIDE_WIDTH = 90 +#: The gap between a side's number and its toggle, and what the gap between +#: the two sides has on top of it, in pixels. +_SIDE_GAP = 4 +_SIDES_GAP = 6 +#: The text of an extent side's "unbounded" toggle. +_UNBOUNDED_TEXT = "inf" class _PlaneRow: @@ -43,8 +52,10 @@ class _PlaneRow: The normal is laid out one column per axis of the plane: two buttons named after the axis (``+z`` and ``-z`` for an axis ``z``) that face the plane along it, and under them the normal's entry on it. Under the - position, one line per in-plane axis with its two extent sides, and - last the plane's outline: a box that draws it and its colour. + position, the extents: a column per side (``min`` and ``max``) and a + line per in-plane axis, each side a number and an ``inf`` toggle that + makes it unbounded. Last the plane's outline: a box that draws it and + its colour. """ def __init__(self, owner: QtRenderPlanesControls, index: int, parent) -> None: @@ -56,6 +67,7 @@ def __init__(self, owner: QtRenderPlanesControls, index: int, parent) -> None: QHBoxLayout, QLabel, QPushButton, + QSizePolicy, QSlider, QToolButton, QWidget, @@ -163,9 +175,17 @@ def __init__(self, owner: QtRenderPlanesControls, index: int, parent) -> None: grid.addLayout(along, 3, 1, 1, 3) #: ``(in-plane axis, side)`` -> the side's value, and its - #: "unbounded" box. + #: "unbounded" toggle. self.sides: dict[tuple[int, int], Any] = {} self.unbounded: dict[tuple[int, int], Any] = {} + + def narrow(widget) -> None: + # As wide as its share of the line, and no narrower than + # ``_SIDE_WIDTH``; its own size hint is not asked. + widget.setMinimumWidth(_SIDE_WIDTH) + widget.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Fixed) + + toggle_width = 0 for axis in (0, 1): label = QLabel(f"Extent {axis}", self.widget) label.setToolTip( @@ -173,9 +193,12 @@ def __init__(self, owner: QtRenderPlanesControls, index: int, parent) -> None: f"axis {axis}, in world units. An unbounded side ends at the " "data." ) - grid.addWidget(label, 4 + axis, 0) + grid.addWidget(label, 5 + axis, 0) line = QHBoxLayout() + line.setSpacing(_SIDE_GAP) for side in (0, 1): + if side: + line.addSpacing(_SIDES_GAP) value = QDoubleSpinBox(self.widget) value.setDecimals(2) value.setRange(-1e9, 1e9) @@ -184,22 +207,41 @@ def __init__(self, owner: QtRenderPlanesControls, index: int, parent) -> None: value.valueChanged.connect( lambda v, a=axis, s=side: editor.set_side(self.index, a, s, v) ) - box = QCheckBox("unbounded", self.widget) + narrow(value) + box = QToolButton(self.widget) + box.setText(_UNBOUNDED_TEXT) + box.setCheckable(True) box.setToolTip( - f"No {_SIDE_NAMES[side]}: the plane ends at the data on " - "this side. Unticking fills in where the data ends now." + f"Unbounded: no {_SIDE_NAMES[side]}, the plane ends at the " + "data on this side. Switching it off fills in where the " + "data ends now." ) box.toggled.connect( lambda on, a=axis, s=side: editor.set_side_bounded( self.index, a, s, not on ) ) - line.addWidget(QLabel(_SIDE_NAMES[side], self.widget)) + toggle_width = max(toggle_width, box.sizeHint().width()) line.addWidget(value, 1) line.addWidget(box) self.sides[(axis, side)] = value self.unbounded[(axis, side)] = box - grid.addLayout(line, 4 + axis, 1, 1, 3) + grid.addLayout(line, 5 + axis, 1, 1, 3) + + # The column heads: each as wide as the number under it, then the + # width of the toggle beside that. + heads = QHBoxLayout() + heads.setSpacing(0) + for side in (0, 1): + if side: + heads.addSpacing(_SIDES_GAP + _SIDE_GAP) + head = QLabel(_SIDE_NAMES[side], self.widget) + narrow(head) + heads.addWidget(head, 1) + heads.addSpacing(_SIDE_GAP + toggle_width) + for box in self.unbounded.values(): + box.setFixedWidth(toggle_width) + grid.addLayout(heads, 4, 1, 1, 3) #: The outline's check box and colour button. self.outline = OutlineInputs( @@ -207,8 +249,8 @@ def __init__(self, owner: QtRenderPlanesControls, index: int, parent) -> None: lambda on: editor.set_outline_enabled(self.index, on), lambda color: editor.set_outline_color(self.index, color), ) - grid.addWidget(self.outline.enabled, 6, 0) - grid.addLayout(self.outline.layout, 6, 1, 1, 3) + grid.addWidget(self.outline.enabled, 7, 0) + grid.addLayout(self.outline.layout, 7, 1, 1, 3) self._inputs = ( self.enabled, @@ -301,7 +343,8 @@ class QtRenderPlanesControls(VisualIdGroup): checkbox, a flip button, a remove button, the normal (two buttons and an entry per axis of the plane), a position slider along the normal, and for each of the plane's two in-plane axes a minimum and a maximum, each - with an "unbounded" box. Values are in world units. Given + with an "inf" toggle that makes it unbounded. Values are in world + units. Given *gizmo_target*, a row also has a "Gizmo" toggle that puts the canvas's gizmo on its plane; one plane of a canvas has it at a time, clipping planes included. diff --git a/tests/conftest.py b/tests/conftest.py index d0a28c72..609db334 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -25,6 +25,23 @@ def _tracking_init(self, *args, **kwargs): return created +def pytest_report_header(config): + """Say which coverage core this run measures with, if it measures. + + ``SysMonitor`` is cheap; ``CTracer`` and ``PyTracer`` pay a call on + every line run and make a CI job half again as slow + (``scripts/ci_profiling/ci_profiling_investigation.md``). + """ + try: + import coverage + except ImportError: + return None + cov = coverage.Coverage.current() + if cov is None: + return None + return f"coverage core: {dict(cov.sys_info()).get('core')}" + + @pytest.hookimpl(wrapper=True) def pytest_pyfunc_call(pyfuncitem): """Give rendercanvas its asyncgen hooks back after an async test. diff --git a/tests/render/_plane_rig.py b/tests/render/_plane_rig.py index a94aa38d..607bd6d2 100644 --- a/tests/render/_plane_rig.py +++ b/tests/render/_plane_rig.py @@ -220,10 +220,22 @@ async def make_rig( fov: float | None = None, size: tuple[int, int] = SIZE, block_size: int = BLOCK, + root=None, ) -> Rig: - root = tmp_path / "pyramid" - root.mkdir(parents=True, exist_ok=True) - write_levels(root, spec, labels=labels, fill=fill) + """Build a rig over a pyramid of *spec*. + + By default the pyramid is written under *tmp_path* with `write_levels`. + Pass *root*, a pyramid of *spec* already on disk (the session's + ``pyramid_root(spec)``, say), to write nothing: for tests that plan but + never look at a voxel's value, since that pyramid holds another pattern. + *tmp_path* and *fill* are then unused, and the store must not be written. + """ + if root is None: + root = tmp_path / "pyramid" + root.mkdir(parents=True, exist_ok=True) + write_levels(root, spec, labels=labels, fill=fill) + elif fill is not None: + raise ValueError("fill has no effect on a pyramid that is already written") store, voxel = fx.open_pyramid(root, spec) scene = controller.add_scene(dim="3d", name="scene") world = h.world_of(controller, scene) diff --git a/tests/render/test_plane_planning.py b/tests/render/test_plane_planning.py index 339eb71b..30d3e336 100644 --- a/tests/render/test_plane_planning.py +++ b/tests/render/test_plane_planning.py @@ -210,13 +210,20 @@ def _pixels_per_voxel(rig, rows, request, plane, sizes_of=None) -> np.ndarray: return size[rows[:, 0] - 1] * _focal(request) / distance -async def _plane_rig(controller, tmp_path, spec, normals, **kwargs): +async def _plane_rig(controller, pyramid_root, spec, normals, **kwargs): + """A rig over the session's shared pyramid of *spec*. + + These tests plan and never look at a voxel's value, so they need no + pyramid of their own: writing one each was most of this file's time on + the Windows runners. + """ return await make_rig( controller, - tmp_path, + None, spec, lambda world, centre: [h.plane_zyx(world, centre, n) for n in normals], size=VIEW, + root=pyramid_root(spec, labels=kwargs.get("labels", False)), **kwargs, ) @@ -224,10 +231,10 @@ async def _plane_rig(controller, tmp_path, spec, normals, **kwargs): @pytest.mark.parametrize("spec", [fx.ANISO, fx.ISO], ids=["aniso", "iso"]) @pytest.mark.parametrize("pose", list(POSES)) async def test_the_plane_rule_draws_about_a_pixel_per_voxel( - controller, tmp_path, spec, pose + controller, pyramid_root, spec, pose ): """Level rule (14.8): 0.7 to 1.6 screen pixels per voxel at bias 1.0.""" - rig = await _plane_rig(controller, tmp_path, spec, [POSES[pose]]) + rig = await _plane_rig(controller, pyramid_root, spec, [POSES[pose]]) (desired,), request = rig.plan() rows = target_rows(desired) assert len(rows) > 4 @@ -244,11 +251,11 @@ async def test_the_plane_rule_draws_about_a_pixel_per_voxel( @pytest.mark.parametrize("pose", list(POSES)) async def test_the_3d_rule_is_several_pixels_per_voxel_coarse_on_a_plane( - controller, tmp_path, pose + controller, pyramid_root, pose ): """Why 6.3 exists: on a pyramid that never downsamples z the shipping thresholds, applied to the same bricks, pick levels far too coarse.""" - rig = await _plane_rig(controller, tmp_path, fx.ANISO, [POSES[pose]]) + rig = await _plane_rig(controller, pyramid_root, fx.ANISO, [POSES[pose]]) (desired,), request = rig.plan() plane_rows = target_rows(desired) by_plane_rule = np.median( @@ -280,8 +287,10 @@ async def test_the_3d_rule_is_several_pixels_per_voxel_coarse_on_a_plane( @pytest.mark.parametrize("pose", ["xz", "oblique"]) -async def test_the_plan_does_not_depend_on_the_frame_s_spin(controller, tmp_path, pose): - rig = await _plane_rig(controller, tmp_path, fx.ANISO, [POSES[pose]]) +async def test_the_plan_does_not_depend_on_the_frame_s_spin( + controller, pyramid_root, pose +): + rig = await _plane_rig(controller, pyramid_root, fx.ANISO, [POSES[pose]]) start = rig.planes[0] plans = [] for spin in (0.0, 22.5, 45.0, 90.0): @@ -305,11 +314,11 @@ async def test_the_plan_does_not_depend_on_the_frame_s_spin(controller, tmp_path async def test_no_plane_is_drawn_coarser_than_asked_when_there_are_several( - controller, tmp_path + controller, pyramid_root ): """One set of thresholds serves the visual: the finest any plane asks.""" normals = [POSES["xy"], POSES["xz"]] - rig = await _plane_rig(controller, tmp_path, fx.ANISO, normals) + rig = await _plane_rig(controller, pyramid_root, fx.ANISO, normals) (desired,), request = rig.plan() rows = target_rows(desired) for plane in rig.planes: @@ -338,7 +347,7 @@ async def test_no_plane_is_drawn_coarser_than_asked_when_there_are_several( @pytest.mark.parametrize("case", list(CULL_CASES)) @pytest.mark.parametrize("bias", [1.0, 0.5]) async def test_the_cull_first_plane_plan_equals_ranking_every_brick_then_culling( - controller, tmp_path, case, bias + controller, pyramid_root, case, bias ): """Cull equivalence (14.8): the same rows in the same order.""" options = CULL_CASES[case] @@ -355,10 +364,11 @@ def planes(world, centre): rig = await make_rig( controller, - tmp_path, + None, fx.ANISO, planes, size=VIEW, + root=pyramid_root(fx.ANISO), clipping=options.get("clipping", ()), appearance={"settled_lod_bias": bias}, ) @@ -435,10 +445,10 @@ def planes(world, centre): ) -async def test_force_level_replaces_the_plane_rule(controller, tmp_path): +async def test_force_level_replaces_the_plane_rule(controller, pyramid_root): rig = await _plane_rig( controller, - tmp_path, + pyramid_root, fx.ANISO, [POSES["oblique"]], appearance={"force_level": 3}, @@ -453,24 +463,24 @@ async def test_force_level_replaces_the_plane_rule(controller, tmp_path): @pytest.mark.parametrize("normals", [[POSES["oblique"]], list(POSES.values())]) -async def test_one_channel_fits_the_cache(controller, tmp_path, normals): - rig = await _plane_rig(controller, tmp_path, fx.ANISO, normals) +async def test_one_channel_fits_the_cache(controller, pyramid_root, normals): + rig = await _plane_rig(controller, pyramid_root, fx.ANISO, normals) (desired,), _request = rig.plan() assert len(target_rows(desired)) > 4 assert desired.n_truncated_target == 0 -async def test_a_plan_over_the_budget_is_truncated_nearest_first(controller, tmp_path): +async def test_a_plan_over_the_budget_is_truncated_nearest_first( + controller, pyramid_root +): """D-P10: a plan over the budget is truncated; the part of the plane farthest from the camera, in world units, is left to the backstop.""" normals = [POSES["oblique"]] - full = await _plane_rig(controller, tmp_path / "full", fx.ANISO, normals) + full = await _plane_rig(controller, pyramid_root, fx.ANISO, normals) (wanted,), request = full.plan() wanted_rows = target_rows(wanted) - - (tmp_path / "small").mkdir() small = await _plane_rig( - controller, tmp_path / "small", fx.ANISO, normals, budget=2 * 1024**2 + controller, pyramid_root, fx.ANISO, normals, budget=2 * 1024**2 ) (desired,), _request = small.plan() kept = target_rows(desired) @@ -491,7 +501,7 @@ async def test_a_plan_over_the_budget_is_truncated_nearest_first(controller, tmp async def test_foreshortening_plans_at_most_half_again_as_many_bricks( - controller, tmp_path + controller, pyramid_root ): """B8: a plane seen at a slant is planned finer than the screen shows, never coarser, and within 1.6 times the face-on count. @@ -501,7 +511,7 @@ async def test_foreshortening_plans_at_most_half_again_as_many_bricks( the near half into the next band is a fourfold step there and no step at the next distance. """ - rig = await _plane_rig(controller, tmp_path, fx.ANISO, [POSES["xy"]]) + rig = await _plane_rig(controller, pyramid_root, fx.ANISO, [POSES["xy"]]) distances = [2.0 ** (step / 4.0) for step in range(-4, 6)] totals = {} for tilt in (0.0, 35.0, 60.0, 75.0, 85.0): @@ -526,9 +536,9 @@ async def test_foreshortening_plans_at_most_half_again_as_many_bricks( @pytest.mark.parametrize("pose", list(POSES)) async def test_an_orthographic_plane_takes_one_level_in_the_2d_rule_s_band( - controller, tmp_path, pose + controller, pyramid_root, pose ): - rig = await _plane_rig(controller, tmp_path, fx.ANISO, [POSES[pose]], fov=0.0) + rig = await _plane_rig(controller, pyramid_root, fx.ANISO, [POSES[pose]], fov=0.0) (desired,), request = rig.plan() rows = target_rows(desired) assert len(rows) > 2 @@ -552,21 +562,17 @@ async def test_an_orthographic_plane_takes_one_level_in_the_2d_rule_s_band( async def test_an_orthographic_plan_that_does_not_fit_takes_a_coarser_level( - controller, tmp_path + controller, pyramid_root ): """P19 (D-P48): step to the next coarser level until the plan fits.""" normals = [POSES["oblique"]] kwargs = {"fov": 0.0, "appearance": {"settled_lod_bias": 0.25}} - roomy = await _plane_rig( - controller, tmp_path / "roomy", fx.ANISO, normals, **kwargs - ) + roomy = await _plane_rig(controller, pyramid_root, fx.ANISO, normals, **kwargs) (wanted,), _request = roomy.plan() rule_level = int(target_rows(wanted)[0, 0]) assert roomy.planner._last_plane_plan["levels_stepped"] == 0 - - (tmp_path / "tight").mkdir() tight = await _plane_rig( - controller, tmp_path / "tight", fx.ANISO, normals, budget=2 * 1024**2, **kwargs + controller, pyramid_root, fx.ANISO, normals, budget=2 * 1024**2, **kwargs ) (desired,), _request = tight.plan() rows = target_rows(desired) @@ -584,10 +590,12 @@ async def test_an_orthographic_plan_that_does_not_fit_takes_a_coarser_level( assert desired.n_truncated_target == 0 -async def test_a_perspective_plan_over_the_budget_is_not_stepped(controller, tmp_path): +async def test_a_perspective_plan_over_the_budget_is_not_stepped( + controller, pyramid_root +): rig = await _plane_rig( controller, - tmp_path, + pyramid_root, fx.ANISO, [POSES["oblique"]], budget=2 * 1024**2, @@ -601,13 +609,10 @@ async def test_a_perspective_plan_over_the_budget_is_not_stepped(controller, tmp # -- labels, composite --------------------------------------------------------------- -async def test_labels_plan_the_same_bricks_as_an_image(controller, tmp_path): +async def test_labels_plan_the_same_bricks_as_an_image(controller, pyramid_root): normals = [POSES["oblique"]] - image = await _plane_rig(controller, tmp_path / "image", fx.ANISO, normals) - (tmp_path / "labels").mkdir() - labels = await _plane_rig( - controller, tmp_path / "labels", fx.ANISO, normals, labels=True - ) + image = await _plane_rig(controller, pyramid_root, fx.ANISO, normals) + labels = await _plane_rig(controller, pyramid_root, fx.ANISO, normals, labels=True) (of_image,), _ = image.plan() (of_labels,), _ = labels.plan() assert len(target_rows(of_image)) > 4 From 5cab1c93a8b4b7559f2e3b0eb7ed5bb893182e03 Mon Sep 17 00:00:00 2001 From: Kevin Yamauchi Date: Sat, 10 Oct 2026 08:02:56 +0200 Subject: [PATCH 18/24] add missing scripts --- scripts/ci_profiling/machine_info.py | 146 ++++++++++++ scripts/ci_profiling/probe_chunk_read.py | 196 ++++++++++++++++ scripts/ci_profiling/probe_gpu_calls.py | 272 +++++++++++++++++++++++ 3 files changed, 614 insertions(+) create mode 100644 scripts/ci_profiling/machine_info.py create mode 100644 scripts/ci_profiling/probe_chunk_read.py create mode 100644 scripts/ci_profiling/probe_gpu_calls.py diff --git a/scripts/ci_profiling/machine_info.py b/scripts/ci_profiling/machine_info.py new file mode 100644 index 00000000..c588f0ff --- /dev/null +++ b/scripts/ci_profiling/machine_info.py @@ -0,0 +1,146 @@ +"""Print what this CI machine is and how coverage would measure on it. + +Two jobs running the same tests have differed by 1.6 times on GitHub +Actions. This prints the three things that could explain it, so a slow job +can be told from a fast one in its log: + +``cpu`` + The CPU model, core count and memory of the runner. +``speed`` + A fixed piece of work, timed: a pure-Python loop and a numpy product. + Run without coverage, so it is the machine's speed alone. +``coverage core`` + The core coverage.py starts with this project's configuration + (``[tool.coverage.run] core`` in pyproject.toml). ``SysMonitor`` is the + cheap one; ``CTracer`` and ``PyTracer`` pay a call on every line run. + +Run from the repository root, so coverage finds pyproject.toml. + +Usage +----- + python scripts/ci_profiling/machine_info.py +""" + +from __future__ import annotations + +import os +import platform +import subprocess +import sys +import time + + +def cpu_model() -> str: + """The CPU's marketing name, or what the platform can tell of it.""" + try: + if sys.platform == "linux": + with open("/proc/cpuinfo") as handle: + for line in handle: + if line.startswith("model name"): + return line.split(":", 1)[1].strip() + elif sys.platform == "darwin": + return subprocess.check_output( + ["sysctl", "-n", "machdep.cpu.brand_string"], text=True + ).strip() + elif sys.platform == "win32": + import winreg + + key = winreg.OpenKey( + winreg.HKEY_LOCAL_MACHINE, + r"HARDWARE\DESCRIPTION\System\CentralProcessor\0", + ) + return str(winreg.QueryValueEx(key, "ProcessorNameString")[0]).strip() + except Exception as error: + return f"unknown ({error!r})" + return platform.processor() or "unknown" + + +def memory_gb() -> float | None: + """Physical memory in GB, or None where it cannot be read.""" + try: + if sys.platform == "win32": + import ctypes + + class Status(ctypes.Structure): + _fields_ = [ + ("length", ctypes.c_ulong), + ("load", ctypes.c_ulong), + ("total", ctypes.c_ulonglong), + ("rest", ctypes.c_ulonglong * 6), + ] + + status = Status() + status.length = ctypes.sizeof(status) + ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(status)) + return status.total / 1e9 + return os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES") / 1e9 + except Exception: + return None + + +def python_loop_ms() -> float: + """Best of three of a fixed pure-Python loop.""" + + def work() -> int: + total = 0 + for index in range(2_000_000): + total += index % 7 + return total + + best = float("inf") + for _ in range(3): + start = time.perf_counter() + work() + best = min(best, time.perf_counter() - start) + return best * 1000 + + +def numpy_ms() -> float: + """Best of three of a fixed numpy sort and matrix product.""" + import numpy as np + + rng = np.random.default_rng(0) + values = rng.random(2_000_000) + matrix = rng.random((400, 400)) + best = float("inf") + for _ in range(3): + start = time.perf_counter() + np.sort(values) + matrix @ matrix + best = min(best, time.perf_counter() - start) + return best * 1000 + + +def coverage_core() -> str: + """The core coverage.py starts under this directory's configuration.""" + try: + import coverage + except ImportError: + return "coverage is not installed" + cov = coverage.Coverage(data_file=None) + cov.start() + try: + info = dict(cov.sys_info()) + finally: + cov.stop() + return ( + f"{info.get('core')} (configured: {cov.config.core!r}, " + f"COVERAGE_CORE={os.environ.get('COVERAGE_CORE', 'unset')}, " + f"coverage {coverage.__version__})" + ) + + +def main() -> None: + memory = memory_gb() + print(f"platform: {platform.platform()}") + print(f"python: {platform.python_version()}") + print(f"cpu: {cpu_model()}") + print(f"cpu count: {os.cpu_count()}") + print(f"memory: {'unknown' if memory is None else f'{memory:.1f} GB'}") + print(f"speed, python: {python_loop_ms():.0f} ms") + print(f"speed, numpy: {numpy_ms():.0f} ms") + print(f"coverage core: {coverage_core()}") + + +if __name__ == "__main__": + main() diff --git a/scripts/ci_profiling/probe_chunk_read.py b/scripts/ci_profiling/probe_chunk_read.py new file mode 100644 index 00000000..612da5db --- /dev/null +++ b/scripts/ci_profiling/probe_chunk_read.py @@ -0,0 +1,196 @@ +"""Time the brick reads the multiscale tests do, on this machine's disk. + +Run on every CI platform, from the repository root. A brick read took 63 ms +on the Windows runner in ``tests/render/test_multiscale_level_alignment.py`` +(19 s over 304 calls) and next to nothing elsewhere, and the plane examples +lost 12 s to reads (``scripts/ci_profiling/ci_profiling_investigation.md``). +This probe builds the two datasets those tests read, with the tests' own +writers, and times the read the stores do, part by part. + +``alignment`` + The dataset of ``test_multiscale_level_alignment.py``: an OME-Zarr 0.5 + group written with zarr-python, every level ONE compressed chunk, opened + by ``OMEZarrImageDataStore.from_path`` through a ``file://`` URI. +``pyramid`` + The pyramid of ``tests/_plane_fixtures``: zarr v3 written with + tensorstore, 32^3 chunks, opened by ``MultiscaleZarrDataStore``. + +For each, in each directory: + +``open_ms`` + Opening the store (metadata reads, one tensorstore handle per level). +``first_read_ms`` + The first brick read: nothing is cached yet. +``new_brick_ms`` + The median read of a brick not read before. For ``alignment`` the one + chunk is cached by then, so this should cost a copy. +``same_brick_ms`` + The median of reading one brick again and again: everything is cached. +``get_data_ms`` + The same bricks through the store's own ``get_data``, on an event loop, + as the scheduler calls it. +``index_ms`` / ``start_ms`` / ``wait_ms`` + The read split in three: indexing the tensorstore handle, starting the + read, and waiting for its result. + +Usage +----- + python scripts/ci_profiling/probe_chunk_read.py --out probe_chunk_read.json +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import platform +import statistics +import sys +import tempfile +import time +from pathlib import Path +from uuid import uuid4 + +import numpy as np + +# The probe reuses the tests' own dataset writers. +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +BRICK = 32 +PAD = 1 + + +def _bricks(shape: tuple[int, ...]) -> list[tuple[tuple[int, int], ...]]: + """Padded brick selections tiling *shape*, as the planner asks for them.""" + grids = [range(0, max(n, 1), BRICK) for n in shape] + return [ + tuple((start - PAD, start + BRICK + PAD) for start in corner) + for corner in np.stack(np.meshgrid(*grids, indexing="ij"), -1).reshape(-1, 3) + ] + + +def _clamped(selection, shape) -> tuple[slice, ...]: + return tuple( + slice(max(start, 0), min(stop, n)) for (start, stop), n in zip(selection, shape) + ) + + +def _median(values: list[float]) -> float: + return round(statistics.median(values), 3) if values else float("nan") + + +def _time_reads(store, level: int = 0) -> dict: + from cellier.data.image._image_requests import ChunkRequest + + handle = store._ts_stores[level] + shape = tuple(int(n) for n in handle.domain.shape) + bricks = _bricks(shape) + + def read(selection) -> tuple[float, float, float]: + t0 = time.perf_counter() + view = handle[_clamped(selection, shape)] + t1 = time.perf_counter() + future = view.read() + t2 = time.perf_counter() + np.asarray(future.result(), dtype=np.float32) + t3 = time.perf_counter() + return (t1 - t0) * 1000, (t2 - t1) * 1000, (t3 - t2) * 1000 + + first = read(bricks[0]) + new = [read(selection) for selection in bricks[1:]] + same = [read(bricks[0]) for _ in range(50)] + + async def through_the_store() -> list[float]: + times = [] + for selection in bricks: + request = ChunkRequest(uuid4(), uuid4(), level, selection) + start = time.perf_counter() + await store.get_data(request) + times.append((time.perf_counter() - start) * 1000) + return times + + get_data = asyncio.run(through_the_store()) + return { + "level_shape": list(shape), + "n_bricks": len(bricks), + "first_read_ms": round(sum(first), 3), + "new_brick_ms": _median([sum(parts) for parts in new]), + "same_brick_ms": _median([sum(parts) for parts in same]), + "get_data_ms": _median(get_data), + "get_data_max_ms": round(max(get_data), 3), + "index_ms": _median([parts[0] for parts in new + same]), + "start_ms": _median([parts[1] for parts in new + same]), + "wait_ms": _median([parts[2] for parts in new + same]), + } + + +def _alignment(root: Path) -> dict: + from tests.render.test_multiscale_level_alignment import _write_dataset + + from cellier.data import OMEZarrImageDataStore + + start = time.perf_counter() + path = _write_dataset(root) + write_ms = (time.perf_counter() - start) * 1000 + start = time.perf_counter() + store = OMEZarrImageDataStore.from_path(path.as_uri()) + open_ms = (time.perf_counter() - start) * 1000 + return { + "write_ms": round(write_ms, 1), + "open_ms": round(open_ms, 1), + } | _time_reads(store) + + +def _pyramid(root: Path) -> dict: + from tests import _plane_fixtures as fx + + start = time.perf_counter() + fx.write_pyramid(root, fx.ANISO) + write_ms = (time.perf_counter() - start) * 1000 + start = time.perf_counter() + store, _voxel = fx.open_pyramid(root, fx.ANISO) + open_ms = (time.perf_counter() - start) * 1000 + return { + "write_ms": round(write_ms, 1), + "open_ms": round(open_ms, 1), + } | _time_reads(store) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "--dir", + action="append", + help="a directory to write under (repeatable); default: temp dir and cwd", + ) + parser.add_argument("--out", help="write the results here as JSON") + args = parser.parse_args() + + directories = args.dir or [tempfile.gettempdir(), os.getcwd()] + results = {"platform": platform.platform(), "directories": {}} + print(f"platform: {results['platform']}") + for directory in dict.fromkeys(str(Path(d).resolve()) for d in directories): + rows = {} + with tempfile.TemporaryDirectory( + dir=directory, prefix="probe_chunk_read_", ignore_cleanup_errors=True + ) as scratch: + for name, build in (("alignment", _alignment), ("pyramid", _pyramid)): + root = Path(scratch) / name + root.mkdir() + rows[name] = build(root) + results["directories"][directory] = rows + print(f"\n{directory}") + for name, row in rows.items(): + print(f" {name}: level 0 {row['level_shape']}, {row['n_bricks']} bricks") + for key, value in row.items(): + if key.endswith("_ms"): + print(f" {key:18s} {value:10.3f}") + + if args.out: + with open(args.out, "w") as handle: + json.dump(results, handle, indent=2) + + +if __name__ == "__main__": + main() diff --git a/scripts/ci_profiling/probe_gpu_calls.py b/scripts/ci_profiling/probe_gpu_calls.py new file mode 100644 index 00000000..c5c04eb0 --- /dev/null +++ b/scripts/ci_profiling/probe_gpu_calls.py @@ -0,0 +1,272 @@ +"""Time single wgpu calls on this machine's adapter, with no rendering. + +Run on every CI platform. In the slow render tests on Windows the time is +inside three wgpu calls, not in drawing as such: ``queue.submit`` (3.5 times +Ubuntu's), ``create_texture`` (5 times) and ``create_view`` (8 times), while +a plain frame is only 1.2 to 1.5 times slower +(``scripts/ci_profiling/ci_profiling_investigation.md``). This probe times +those calls alone, with wgpu directly (no pygfx scene, no cellier), to find +which of them the driver is slow at and whether it grows with texture size. + +``create_texture`` + Creating (and destroying) a texture, over a range of sizes up to the + brick caches' (a 3D float texture of tens of MB). +``first_use`` + Writing one 32^3 brick into a fresh 3D texture and reading a texel + back, against doing the same a second time. wgpu clears a texture the + first time part of it is used, so the difference is the cost of that + clear: it lands in ``write_texture`` or ``submit``, not in + ``create_texture``. +``create_view`` + Creating a view of a texture. +``submit`` + Submitting an empty command buffer, a render pass that only clears a + 320 x 320 colour and depth target, and the same followed by reading a + pixel back (which is what a pick does). +``write_texture`` / ``write_buffer`` + Uploading one 32^3 float brick; writing a 256 byte uniform buffer. + +Every figure is the median over the repeats, in milliseconds per call. + +Usage +----- + python scripts/ci_profiling/probe_gpu_calls.py --out probe_gpu_calls.json +""" + +from __future__ import annotations + +import argparse +import json +import os +import platform +import statistics +import time + +import numpy as np + +BRICK = 32 + +#: ``label -> (size, dimension, format, bytes per texel)``. +TEXTURES = { + "2d 320x320 rgba8": ((320, 320, 1), "2d", "rgba8unorm", 4), + "2d 2048x2048 rgba8": ((2048, 2048, 1), "2d", "rgba8unorm", 4), + "3d 64^3 r32float": ((64, 64, 64), "3d", "r32float", 4), + "3d 128^3 r32float": ((128, 128, 128), "3d", "r32float", 4), + "3d 256^3 r32float": ((256, 256, 256), "3d", "r32float", 4), + "3d 256^3 r8": ((256, 256, 256), "3d", "r8unorm", 1), +} + + +def _ms(function, repeats: int) -> dict: + """Call *function* *repeats* times; median, min and max in ms.""" + times = [] + for _ in range(repeats): + start = time.perf_counter() + function() + times.append((time.perf_counter() - start) * 1000) + return { + "median_ms": round(statistics.median(times), 4), + "min_ms": round(min(times), 4), + "max_ms": round(max(times), 4), + "repeats": repeats, + } + + +def measure(repeats: int) -> dict: + import wgpu + from pygfx.renderers.wgpu import get_shared + + # The device the render tests draw with. + device = get_shared().device + queue = device.queue + usage = wgpu.TextureUsage + result = { + "platform": platform.platform(), + "cpu_count": os.cpu_count(), + "adapter": device.adapter.summary, + "calls": {}, + } + calls = result["calls"] + + def read_texel(texture) -> None: + """Block until everything submitted so far has run.""" + queue.read_texture( + {"texture": texture, "mip_level": 0, "origin": (0, 0, 0)}, + {"offset": 0, "bytes_per_row": 256, "rows_per_image": 1}, + (1, 1, 1), + ) + + # --- create_texture, by size ------------------------------------------ + for label, (size, dimension, texture_format, n_bytes) in TEXTURES.items(): + megabytes = size[0] * size[1] * size[2] * n_bytes / 1e6 + made = [] + + def create(size=size, dimension=dimension, fmt=texture_format, made=made): + made.append( + device.create_texture( + size=size, + dimension=dimension, + format=fmt, + usage=usage.COPY_DST | usage.COPY_SRC | usage.TEXTURE_BINDING, + ) + ) + + def destroy(made=made): + made.pop().destroy() + + # One at a time, so the largest never holds more than one copy. + create_times, destroy_times = [], [] + for _ in range(min(repeats, 20)): + create_times.append(_ms(create, 1)["median_ms"]) + destroy_times.append(_ms(destroy, 1)["median_ms"]) + calls[f"create_texture {label}"] = { + "median_ms": round(statistics.median(create_times), 4), + "max_ms": round(max(create_times), 4), + "megabytes": round(megabytes, 1), + } + calls[f"destroy_texture {label}"] = { + "median_ms": round(statistics.median(destroy_times), 4), + "max_ms": round(max(destroy_times), 4), + "megabytes": round(megabytes, 1), + } + + # --- first use of a 3D texture: the clear wgpu owes it ---------------- + brick = np.random.default_rng(0).random((BRICK,) * 3, dtype=np.float32) + + def write_brick(texture) -> None: + queue.write_texture( + {"texture": texture, "mip_level": 0, "origin": (0, 0, 0)}, + brick, + {"offset": 0, "bytes_per_row": 4 * BRICK, "rows_per_image": BRICK}, + (BRICK, BRICK, BRICK), + ) + + for edge in (64, 128, 256): + first, second = [], [] + for _ in range(5): + texture = device.create_texture( + size=(edge, edge, edge), + dimension="3d", + format="r32float", + usage=usage.COPY_DST | usage.COPY_SRC | usage.TEXTURE_BINDING, + ) + + def use(texture=texture): + write_brick(texture) + read_texel(texture) + + first.append(_ms(use, 1)["median_ms"]) + second.append(_ms(use, 1)["median_ms"]) + texture.destroy() + megabytes = round(edge**3 * 4 / 1e6, 1) + calls[f"first_use 3d {edge}^3 r32float"] = { + "median_ms": round(statistics.median(first), 4), + "max_ms": round(max(first), 4), + "megabytes": megabytes, + } + calls[f"second_use 3d {edge}^3 r32float"] = { + "median_ms": round(statistics.median(second), 4), + "max_ms": round(max(second), 4), + "megabytes": megabytes, + } + + # --- create_view ------------------------------------------------------ + colour = device.create_texture( + size=(320, 320, 1), + dimension="2d", + format="rgba8unorm", + usage=usage.RENDER_ATTACHMENT | usage.COPY_SRC | usage.TEXTURE_BINDING, + ) + depth = device.create_texture( + size=(320, 320, 1), + dimension="2d", + format="depth32float", + usage=usage.RENDER_ATTACHMENT, + ) + calls["create_view 2d 320x320"] = _ms(colour.create_view, repeats * 5) + + # --- submit ----------------------------------------------------------- + colour_view = colour.create_view() + depth_view = depth.create_view() + + def submit_empty(): + queue.submit([device.create_command_encoder().finish()]) + + def submit_clear(): + encoder = device.create_command_encoder() + render_pass = encoder.begin_render_pass( + color_attachments=[ + { + "view": colour_view, + "resolve_target": None, + "clear_value": (0.0, 0.0, 0.0, 1.0), + "load_op": "clear", + "store_op": "store", + } + ], + depth_stencil_attachment={ + "view": depth_view, + "depth_clear_value": 1.0, + "depth_load_op": "clear", + "depth_store_op": "store", + }, + ) + render_pass.end() + queue.submit([encoder.finish()]) + + def submit_clear_and_read(): + submit_clear() + read_texel(colour) + + calls["submit empty"] = _ms(submit_empty, repeats) + read_texel(colour) + calls["submit clear pass 320x320"] = _ms(submit_clear, repeats) + read_texel(colour) + calls["submit clear pass + read a pixel"] = _ms(submit_clear_and_read, repeats) + calls["read a pixel"] = _ms(lambda: read_texel(colour), repeats) + + # --- uploads ---------------------------------------------------------- + cache = device.create_texture( + size=(128, 128, 128), + dimension="3d", + format="r32float", + usage=usage.COPY_DST | usage.COPY_SRC | usage.TEXTURE_BINDING, + ) + write_brick(cache) + read_texel(cache) + calls["write_texture 32^3 r32float brick"] = _ms( + lambda: write_brick(cache), repeats + ) + read_texel(cache) + + uniform = device.create_buffer( + size=256, usage=wgpu.BufferUsage.UNIFORM | wgpu.BufferUsage.COPY_DST + ) + payload = bytes(256) + calls["write_buffer 256 bytes"] = _ms( + lambda: queue.write_buffer(uniform, 0, payload), repeats * 5 + ) + read_texel(colour) + return result + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--repeats", type=int, default=200) + parser.add_argument("--out", help="write the results here as JSON") + args = parser.parse_args() + + result = measure(args.repeats) + print(f"adapter: {result['adapter']}") + print(f"{'call':42s} {'median ms':>10} {'max ms':>10} {'MB':>7}") + for name, row in result["calls"].items(): + megabytes = f"{row['megabytes']:7.1f}" if "megabytes" in row else "" + print(f"{name:42s} {row['median_ms']:10.3f} {row['max_ms']:10.3f} {megabytes}") + + if args.out: + with open(args.out, "w") as handle: + json.dump(result, handle, indent=2) + + +if __name__ == "__main__": + main() From 237f527e74b20bdfa0486dc9fa0cc088a0f5e8d4 Mon Sep 17 00:00:00 2001 From: Kevin Yamauchi Date: Sat, 10 Oct 2026 08:43:20 +0200 Subject: [PATCH 19/24] cache witty compile --- .github/workflows/ci.yml | 33 +++++++++++++ .gitignore | 1 + scripts/ci_profiling/probe_gpu_calls.py | 61 +++++++++++++++++++++++++ 3 files changed, 95 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30c7e879..4dd8e0a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,11 @@ jobs: # Windows path's backslashes would not survive. PYTEST_ADDOPTS: ${{ matrix.platform == 'windows-latest' && '--basetemp=.pytest_basetemp' || '' }} + # Where spatial-graph (through witty) keeps the C++ modules it compiles on + # first use; saved between runs by the "Restore compiled graph modules" + # step below. + WITTY_CACHE_DIR: ${{ github.workspace }}/.witty_cache + # spatial-graph >=0.1.0 ships prebuilt rtree wheels, # but I was getting incompatible CPython errors. SPATIAL_GRAPH_NO_PREBUILT: "1" @@ -92,6 +97,34 @@ jobs: python -m pip install -U pip python -m pip install .[test,pyside,remote,anywidget,examples,graph] + # spatial-graph compiles a Cython/C++ module the first time each graph or + # rtree configuration is used. With nothing cached that is 30 to 50 s a + # job, all of it charged to whichever tests come first + # (test_roi_edge_pick_resolves_the_row_on_demand and + # test_mask_and_roi_agree[1-1-extent0]). witty finds a module by the hash + # of its source, so a stale cache only means compiling again. The key + # holds the versions the modules were built against; a new version starts + # from the last cache of the same Python and adds to it. + - name: Compiled graph modules - versions + id: witty + shell: bash + run: | + python -c " + import hashlib, sys + from importlib.metadata import version + v = ' '.join(f'{p}={version(p)}' for p in ('spatial-graph', 'witty', 'cython', 'numpy')) + print(v, file=sys.stderr) + print('versions=' + hashlib.sha256((v + sys.version).encode()).hexdigest()[:16]) + " >> "$GITHUB_OUTPUT" + + - name: Restore compiled graph modules + uses: actions/cache@v5 + with: + path: .witty_cache + key: witty-${{ runner.os }}-${{ runner.arch }}-py${{ matrix.python-version }}-${{ steps.witty.outputs.versions }} + restore-keys: | + witty-${{ runner.os }}-${{ runner.arch }}-py${{ matrix.python-version }}- + # The render tests skip themselves when no adapter resolves, so a backend # that fails to load would turn ~450 tests into a silent green. Fail here # instead, and log which adapter each platform actually picked. diff --git a/.gitignore b/.gitignore index c34f4367..0e0ffb9c 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,7 @@ coverage.xml .hypothesis/ .pytest_cache/ .pytest_basetemp/ +.witty_cache/ # Translations *.mo diff --git a/scripts/ci_profiling/probe_gpu_calls.py b/scripts/ci_profiling/probe_gpu_calls.py index c5c04eb0..e7467211 100644 --- a/scripts/ci_profiling/probe_gpu_calls.py +++ b/scripts/ci_profiling/probe_gpu_calls.py @@ -17,6 +17,13 @@ first time part of it is used, so the difference is the cost of that clear: it lands in ``write_texture`` or ``submit``, not in ``create_texture``. +``render_target`` + What a new renderer costs: creating the colour, pick and depth targets + of a 1920 x 1920 frame (the size ``test_multiscale_level_alignment.py`` + draws at, 88 times over), clearing them in a first pass, clearing them + again, and destroying them. Unlike the textures above these have the + ``RENDER_ATTACHMENT`` usage, and a software rasteriser keeps them in + main memory: 59 MB a set. ``create_view`` Creating a view of a texture. ``submit`` @@ -170,6 +177,60 @@ def use(texture=texture): "megabytes": megabytes, } + # --- a renderer's targets: create, first clear, second clear, destroy -- + target_usage = usage.RENDER_ATTACHMENT | usage.COPY_SRC | usage.TEXTURE_BINDING + for edge in (320, 1920): + timings = {"create": [], "first_clear": [], "second_clear": [], "destroy": []} + for _ in range(10): + start = time.perf_counter() + targets = [ + device.create_texture( + size=(edge, edge, 1), + dimension="2d", + format=texture_format, + usage=target_usage, + ) + for texture_format in ("rgba8unorm-srgb", "rgba16uint", "depth32float") + ] + timings["create"].append((time.perf_counter() - start) * 1000) + + def clear(targets=targets): + encoder = device.create_command_encoder() + encoder.begin_render_pass( + color_attachments=[ + { + "view": target.create_view(), + "resolve_target": None, + "clear_value": (0.0, 0.0, 0.0, 0.0), + "load_op": "clear", + "store_op": "store", + } + for target in targets[:2] + ], + depth_stencil_attachment={ + "view": targets[2].create_view(), + "depth_clear_value": 1.0, + "depth_load_op": "clear", + "depth_store_op": "store", + }, + ).end() + queue.submit([encoder.finish()]) + read_texel(targets[0]) + + timings["first_clear"].append(_ms(clear, 1)["median_ms"]) + timings["second_clear"].append(_ms(clear, 1)["median_ms"]) + start = time.perf_counter() + for target in targets: + target.destroy() + timings["destroy"].append((time.perf_counter() - start) * 1000) + megabytes = round(edge * edge * 16 / 1e6, 1) + for step, times in timings.items(): + calls[f"render_target {edge}x{edge} {step}"] = { + "median_ms": round(statistics.median(times), 4), + "max_ms": round(max(times), 4), + "megabytes": megabytes, + } + # --- create_view ------------------------------------------------------ colour = device.create_texture( size=(320, 320, 1), From ebb530125afd283158c385bac145d5e89c8bd0c9 Mon Sep 17 00:00:00 2001 From: Kevin Yamauchi Date: Sat, 10 Oct 2026 08:54:38 +0200 Subject: [PATCH 20/24] reduce gizmo test cases --- tests/render/test_render_plane_gizmo.py | 46 +++++++++++++++++++------ 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/tests/render/test_render_plane_gizmo.py b/tests/render/test_render_plane_gizmo.py index 2b68f0d8..0e005edd 100644 --- a/tests/render/test_render_plane_gizmo.py +++ b/tests/render/test_render_plane_gizmo.py @@ -49,6 +49,21 @@ SETTLE_S = 0.03 +#: Camera directions (rendered ``xyz``) the every-handle test drags from: two +#: along an axis, where some handles are seen edge-on, and three oblique ones +#: in different octants. Five, not the forty this test once used: the frames +#: it drew made it the slowest test on every CI platform, and the fields a +#: handle changes do not depend on the view. Not along z: from exactly there +#: no handle is grabbed at all. +VIEW_DIRECTIONS = ( + (1.0, 0.0, 0.0), + (0.0, 1.0, 0.0), + (0.5, 0.6, 0.62), + (-0.6, 0.4, 0.7), + (0.4, -0.7, -0.6), +) + + def _directions(n: int) -> np.ndarray: """*n* unit camera directions, the same every run.""" vectors = np.random.default_rng(0).normal(size=(n, 3)) @@ -157,8 +172,12 @@ def arrow(self, session, group: str, index: int): length = float(np.linalg.norm(out)) return at, (out / length if length > 0 else out), length - def drag(self, session, at, delta) -> tuple[str, object] | None: - """Press at *at*, move by *delta* in two framed steps, release. + def drag(self, session, at, delta, steps=(0.5, 1.0)) -> tuple[str, object] | None: + """Press at *at*, move by *delta* in framed steps, release. + + *steps* are the fractions of *delta* the pointer is moved to, a frame + drawn after each: two by default, one (``(1.0,)``) for a test that + only reads where the drag ended. Returns the handle pygfx grabbed, ``(kind, axis)``, or ``None`` when the press landed on no handle. @@ -169,7 +188,7 @@ def drag(self, session, at, delta) -> tuple[str, object] | None: return None gizmo = self.gizmo(session) grabbed = (gizmo.handle_kind, gizmo.handle_axis) - for step in (0.5, 1.0): + for step in steps: self.send("pointer_move", at[0] + delta[0] * step, at[1] + delta[1] * step) self.frame() self.send("pointer_up", at[0] + delta[0], at[1] + delta[1], buttons=()) @@ -408,7 +427,11 @@ def _wrong_fields(before: RenderPlane, after: RenderPlane, kind, axis, along) -> async def test_each_handle_changes_exactly_its_model_fields(make_rig): - """C6: every handle, through pygfx's own picking and handlers, 40 views.""" + """C6: every handle, through pygfx's own picking and handlers, 5 views. + + Three frames a drag: one for the move, one for the release, one after + the plane is put back. + """ rig = make_rig() await rig.start() plane = rig.plane() @@ -416,8 +439,8 @@ async def test_each_handle_changes_exactly_its_model_fields(make_rig): session = rig.session(plane) drags: dict[str, int] = {} wrong: list = [] - for direction in _directions(40): - rig.look(direction) + for direction in VIEW_DIRECTIONS: + rig.look(np.asarray(direction) / np.linalg.norm(direction)) for group, index in HANDLES: at, out, length = rig.arrow(session, group, index) if length < 12: @@ -427,7 +450,7 @@ async def test_each_handle_changes_exactly_its_model_fields(make_rig): delta = 30 * np.array([-out[1], out[0]]) else: delta = 30 * out - grabbed = rig.drag(session, at, delta) + grabbed = rig.drag(session, at, delta, steps=(1.0,)) if grabbed is None: continue # hidden at this angle, or behind another handle kind, axis = grabbed @@ -445,9 +468,10 @@ async def test_each_handle_changes_exactly_its_model_fields(make_rig): drags[label] = drags.get(label, 0) + 1 # Back to the start for the next handle. rig.controller.set_render_plane(rig.visual.id, plane.id, plane) - rig.frame(n=2) + rig.frame() assert wrong == [] - # Every kind of handle was exercised from many of the 40 views. + # Every kind of handle was exercised, from most of the views (51 drags + # as written; the fewest of a kind is 5, the handle along the normal). assert set(drags) == { "translate normal", "translate in plane", @@ -455,8 +479,8 @@ async def test_each_handle_changes_exactly_its_model_fields(make_rig): "rotate in plane", "scale", } - assert min(drags.values()) >= 20, drags - assert sum(drags.values()) >= 250, drags + assert min(drags.values()) >= 3, drags + assert sum(drags.values()) >= 35, drags async def test_a_scale_drag_along_the_arrow_grows_at_every_camera_angle(make_rig): From fe8b36ff0f5c10243d9caffb5941e833e55aa9a2 Mon Sep 17 00:00:00 2001 From: Kevin Yamauchi Date: Sat, 10 Oct 2026 09:23:32 +0200 Subject: [PATCH 21/24] fix flaky async --- tests/conftest.py | 53 +++++++++++++++++++++++ tests/test_asyncgen_hook_guard.py | 72 +++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 tests/test_asyncgen_hook_guard.py diff --git a/tests/conftest.py b/tests/conftest.py index 609db334..0cf9da79 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ """Test fixtures for Cellier.""" +import asyncio import sys import weakref @@ -59,13 +60,65 @@ def pytest_pyfunc_call(pyfuncitem): This runs between the test body and that ``processEvents``. An app is not exposed: ``QtAsyncio`` leaves the hooks alone, and its loop lasts as long as the app does. + + The same start has a second, later cost, dealt with first: see + `_forget_finished_asyncio_hooks`. """ try: return (yield) finally: + _forget_finished_asyncio_hooks() _restore_rendercanvas_asyncgen_hooks() +def _is_finished_asyncio_hook(hook) -> bool: + """Whether an asyncgen hook belongs to an asyncio loop that is not running. + + asyncio installs a loop's hooks only while the loop runs, so such a hook + is one somebody else saved and would put back. + """ + owner = getattr(hook, "__self__", None) + return isinstance(owner, asyncio.AbstractEventLoop) and not owner.is_running() + + +def _forget_finished_asyncio_hooks() -> None: + """Stop rendercanvas from putting a finished test's asyncio hooks back. + + A rendercanvas loop saves the asyncgen hooks it finds when it starts and + puts them back when it stops, and it stops by itself a moment after the + last canvas closes. Started inside an async test, what it saved is that + test's asyncio loop; stopped any time later (between two tests, when every + canvas has been closed and Qt events are pumped), it installs the hooks of + a loop that is closed by then. wgpu reads those hooks to decide where a + promise resolves, so from then on every synchronous read-back in a test + without a loop of its own raises ``RuntimeError: Event loop is closed`` + inside wgpu's map callback. Whether the loop gets to stop depends on + timing: on CI it failed 56 render tests of one macOS job and none of the + re-run (``scripts/ci_profiling/repro_stale_asyncgen_hooks.py`` shows it + without pytest). + + So, after each test body: drop such hooks from what each rendercanvas loop + would restore, and from the interpreter if they are already installed. + """ + current = sys.get_asyncgen_hooks() + if _is_finished_asyncio_hook(current.firstiter): + sys.set_asyncgen_hooks(None, None) + for name, module in list(sys.modules.items()): + if not name.startswith("rendercanvas."): + continue + loop = getattr(module, "loop", None) + # Name-mangled private state, as below: the saved hooks have no + # public accessor. + saved = getattr(loop, "_BaseLoop__hook_data", None) + if saved is None: + continue + saved_asyncgen_hooks, saved_interrupt_hooks = saved + if saved_asyncgen_hooks is not None and _is_finished_asyncio_hook( + saved_asyncgen_hooks[0] + ): + loop._BaseLoop__hook_data = ((None, None), saved_interrupt_hooks) + + def _restore_rendercanvas_asyncgen_hooks() -> None: qt_backend = sys.modules.get("rendercanvas.qt") if qt_backend is None: diff --git a/tests/test_asyncgen_hook_guard.py b/tests/test_asyncgen_hook_guard.py new file mode 100644 index 00000000..2bcd82ae --- /dev/null +++ b/tests/test_asyncgen_hook_guard.py @@ -0,0 +1,72 @@ +"""The guard that keeps a finished test's asyncio hooks from coming back. + +See `tests.conftest._forget_finished_asyncio_hooks` for the failure it +prevents; ``scripts/ci_profiling/repro_stale_asyncgen_hooks.py`` shows that +failure end to end. These tests build the two states the guard repairs by +hand, so they do not depend on which canvases earlier tests left behind. +""" + +from __future__ import annotations + +import asyncio +import sys +from types import SimpleNamespace + +import pytest + +from tests.conftest import _forget_finished_asyncio_hooks + + +@pytest.fixture +def closed_loop_hooks(): + """The asyncgen hooks of an asyncio loop that has run and is closed.""" + loop = asyncio.new_event_loop() + seen = {} + + async def body(): + seen["hooks"] = sys.get_asyncgen_hooks() + + loop.run_until_complete(body()) + loop.close() + return seen["hooks"] + + +@pytest.fixture +def keep_asyncgen_hooks(): + before = sys.get_asyncgen_hooks() + yield + sys.set_asyncgen_hooks(*before) + + +def test_installed_hooks_of_a_closed_loop_are_dropped( + closed_loop_hooks, keep_asyncgen_hooks +): + sys.set_asyncgen_hooks(*closed_loop_hooks) + _forget_finished_asyncio_hooks() + assert sys.get_asyncgen_hooks().firstiter is None + + +def test_a_rendercanvas_loop_will_not_put_them_back( + closed_loop_hooks, keep_asyncgen_hooks, monkeypatch +): + interrupt_hooks = object() + loop = SimpleNamespace(_BaseLoop__hook_data=(closed_loop_hooks, interrupt_hooks)) + monkeypatch.setitem(sys.modules, "rendercanvas.fake", SimpleNamespace(loop=loop)) + _forget_finished_asyncio_hooks() + assert loop._BaseLoop__hook_data == ((None, None), interrupt_hooks) + + +def test_other_hooks_are_left_alone(keep_asyncgen_hooks, monkeypatch): + def firstiter(agen): + pass + + def finalizer(agen): + pass + + saved = ((firstiter, finalizer), None) + loop = SimpleNamespace(_BaseLoop__hook_data=saved) + monkeypatch.setitem(sys.modules, "rendercanvas.fake", SimpleNamespace(loop=loop)) + sys.set_asyncgen_hooks(firstiter, finalizer) + _forget_finished_asyncio_hooks() + assert sys.get_asyncgen_hooks().firstiter is firstiter + assert loop._BaseLoop__hook_data is saved From 1e48ad7d84d527f70995ecd017605037be124f31 Mon Sep 17 00:00:00 2001 From: Kevin Yamauchi Date: Sat, 10 Oct 2026 12:53:15 +0200 Subject: [PATCH 22/24] speed up rendering tests --- tests/render/_planes.py | 19 +++++++++-- tests/render/test_camera_interaction.py | 33 +++++++++++++++++-- .../render/test_multiscale_level_alignment.py | 9 +++-- 3 files changed, 54 insertions(+), 7 deletions(-) diff --git a/tests/render/_planes.py b/tests/render/_planes.py index 1c313907..7825d782 100644 --- a/tests/render/_planes.py +++ b/tests/render/_planes.py @@ -25,6 +25,7 @@ from cellier.transform import AffineTransform from cellier.visuals import RenderPlane +from tests.render._pick import PickFrame if TYPE_CHECKING: from collections.abc import Sequence @@ -113,11 +114,25 @@ class Shot: ray_origin: np.ndarray ray_vector: np.ndarray canvas: object = field(repr=False, default=None) + _picks: PickFrame | None = field(repr=False, default=None) @property def drawn(self) -> np.ndarray: return self.frame[..., 3] > 0 + def pick_info(self, row: int, col: int) -> dict: + """What ``renderer.get_pick_info`` gives at a pixel, less ``"rgba"``. + + The pick target is read back once, at the first pick, and every pick + after that is decoded from the copy: a round trip to the GPU per + pixel made the pick tests the slowest on a real GPU (see + ``tests/render/_pick.py``). A shot is of one frame; drawing again + with its renderer does not update the copy. + """ + if self._picks is None: + self._picks = PickFrame(self.renderer) + return self._picks.info((col + 0.5, row + 0.5)) + def shoot( controller, @@ -394,7 +409,7 @@ def compare_greys( def pick_voxel(shot: Shot, gfx_visual, row: int, col: int): """The data voxel ``(z, y, x)`` a pick at a pixel names, or ``None``.""" - info = shot.renderer.get_pick_info((col + 0.5, row + 0.5)) + info = shot.pick_info(row, col) coordinate = gfx_visual.pick_data_coordinate(info.get("world_object"), info) if coordinate is None: return None @@ -407,7 +422,7 @@ def pick_coordinate(shot: Shot, gfx_visual, row: int, col: int): In the centre convention (voxel ``i`` spans ``[i - 0.5, i + 0.5)``). """ - info = shot.renderer.get_pick_info((col + 0.5, row + 0.5)) + info = shot.pick_info(row, col) coordinate = gfx_visual.pick_data_coordinate(info.get("world_object"), info) if coordinate is None: return None diff --git a/tests/render/test_camera_interaction.py b/tests/render/test_camera_interaction.py index e9517771..e606d50a 100644 --- a/tests/render/test_camera_interaction.py +++ b/tests/render/test_camera_interaction.py @@ -78,7 +78,11 @@ def __init__( self.view = self.views[0] self.requested = [False] * n_canvases + self.request_count = [0] * n_canvases self.frames = [0] * n_canvases + #: Per canvas, whether the last `step` drew a frame that showed + #: something new (see `step`). + self.changed = [False] * n_canvases for index, view in enumerate(self.views): self._track_requests(index, view) @@ -133,6 +137,7 @@ def _track_requests(self, index: int, view) -> None: def request_draw(draw_function=None): self.requested[index] = True + self.request_count[index] += 1 return original(draw_function) canvas.request_draw = request_draw @@ -154,18 +159,38 @@ def submit(self, event_type: str, *, canvas: int = 0, **fields) -> None: self.views[canvas].widget.submit_event(event) def step(self, *, draw: bool = True) -> list[bool]: - """One vsync: advance the clock, deliver input, draw what asked to.""" + """One vsync: advance the clock, deliver input, draw what asked to. + + Returns which canvases drew. ``self.changed`` says which of those + frames showed something new: all of them, but for the frame of a + button held still. While a button is down pygfx's controller keeps + reporting a state and the canvas asks for the next frame each time, + camera moving or not, so "nothing asked for a frame" never comes; + the loops that wait for quiet (`run_until_idle`, the tail of + `press_and_move_now`) wait on ``changed`` instead. Such a frame is + one the controller drove, that left the camera where it was, and + after which nothing but the controller asked for another. + """ self.clock.now += DT drawn = [] for index, view in enumerate(self.views): view.widget._process_events() if draw and self.requested[index]: self.requested[index] = False + before = view.capture_camera_state() + requests = self.request_count[index] view.widget.draw() self.frames[index] += 1 drawn.append(True) + held_still = ( + view._driving + and self.request_count[index] - requests == 1 + and view.capture_camera_state() == before + ) + self.changed[index] = not held_still else: drawn.append(False) + self.changed[index] = False return drawn async def run(self, n: int, **kwargs) -> None: @@ -176,7 +201,8 @@ async def run(self, n: int, **kwargs) -> None: async def run_until_idle(self, limit: int = 400) -> None: idle = 0 for _ in range(limit): - if any(self.step()): + self.step() + if any(self.changed): idle = 0 else: idle += 1 @@ -237,7 +263,8 @@ def press_and_move_now( self.step() idle = 0 for _ in range(400 if tail else 0): - idle = 0 if any(self.step()) else idle + 1 + self.step() + idle = 0 if any(self.changed) else idle + 1 if idle >= 5: break return x diff --git a/tests/render/test_multiscale_level_alignment.py b/tests/render/test_multiscale_level_alignment.py index be718c6d..78aec5b6 100644 --- a/tests/render/test_multiscale_level_alignment.py +++ b/tests/render/test_multiscale_level_alignment.py @@ -99,7 +99,12 @@ def _paths(kind: str) -> tuple[str, ...]: SLICE_Z = 16 SLICE_SPHERES = (1,) -SIZE = (960, 960) +#: Frame size. The error does not grow from 960 down to 640 (worst 3D error +#: 0.123 voxels at 640, 0.128 at 960; 2D 0.048 and 0.033), and each frame is +#: rendered at twice this, about 90 of them: on a software rasteriser the +#: pixels are this file's time. At 480 the 3D error is 0.177, too near +#: ``TOL_VOXELS``. +SIZE = (640, 640) #: 3D field of view, degrees. Near-orthographic, so a symmetric blob projects #: to a symmetric silhouette wherever it sits in the frame. (``fov = 0``, @@ -110,7 +115,7 @@ def _paths(kind: str) -> tuple[str, ...]: #: Pass threshold, in level-0 voxels per axis. Every convention error this #: test exists to catch is at least half a level-0 voxel. Measured noise -#: (Phase 0, 960 px): 2D <= 0.06; 3D <= 0.10 once ray steps are adequate +#: (Phase 0, at 960 px): 2D <= 0.06; 3D <= 0.10 once ray steps are adequate #: (with today's step starvation, S1, up to 0.27 at level 2). 0.2 is twice #: the 3D noise and 2.5x below the smallest error. TOL_VOXELS = 0.2 From 312ceb630c38923b889eec0d64b68dbc8200ba3e Mon Sep 17 00:00:00 2001 From: Kevin Yamauchi Date: Sat, 10 Oct 2026 14:21:32 +0200 Subject: [PATCH 23/24] lighten tests --- .github/workflows/profile.yml | 2 - .../test_plane_rendering_examples.py | 589 ------------------ tests/render/scheduling/test_trace_guard.py | 15 +- tests/render/test_clipping_cut_face.py | 24 +- tests/render/test_clipping_planes_render.py | 53 +- .../render/test_plane_rendering_multiscale.py | 30 +- 6 files changed, 109 insertions(+), 604 deletions(-) delete mode 100644 tests/convenience/test_plane_rendering_examples.py diff --git a/.github/workflows/profile.yml b/.github/workflows/profile.yml index cac5c7a6..4640dc77 100644 --- a/.github/workflows/profile.yml +++ b/.github/workflows/profile.yml @@ -14,7 +14,6 @@ on: tests: description: "Test files to profile (space separated)" default: >- - tests/convenience/test_plane_rendering_examples.py tests/render/test_render_plane_gizmo.py tests/render/test_multiscale_level_alignment.py tests/render/test_plane_planning.py @@ -133,7 +132,6 @@ jobs: WGPU_BACKEND_TYPE: ${{ matrix.platform == 'windows-latest' && 'Vulkan' || '' }} SPATIAL_GRAPH_NO_PREBUILT: "1" DEFAULT_TESTS: >- - tests/convenience/test_plane_rendering_examples.py tests/render/test_render_plane_gizmo.py tests/render/test_multiscale_level_alignment.py tests/render/test_plane_planning.py diff --git a/tests/convenience/test_plane_rendering_examples.py b/tests/convenience/test_plane_rendering_examples.py deleted file mode 100644 index bb3e02d3..00000000 --- a/tests/convenience/test_plane_rendering_examples.py +++ /dev/null @@ -1,589 +0,0 @@ -"""The plane rendering examples build, draw a frame and exit. - -``examples/plane_rendering/`` (plane rendering design 10.3, 14.12). Each Qt -example builds its viewer and its layout at import and calls ``run`` only -under ``__main__``, so a test imports it, renders the layout into a window, -loads the data, runs the viewer's ready callbacks and captures the window. -""" - -from __future__ import annotations - -import importlib.util -import itertools -import tempfile -from pathlib import Path - -import numpy as np -import pytest -import pytest_asyncio - -from cellier.convenience import screenshot_window -from cellier.convenience.layout._qt_renderer import render_qt -from tests.render.conftest import drain_loading - -EXAMPLES = Path(__file__).parents[2] / "examples" / "plane_rendering" - -QT_EXAMPLES = ( - "plane_gizmo", - "plane_interaction_script", - "plane_labels_composite", - "ortho_slice_planes", - "plane_slicing_validation", -) - -#: The examples that keep a marimo twin (``_marimo.py``). -MARIMO_TWINS = ("plane_slicing_validation",) - - -def _import_example(name: str): - path = EXAMPLES / f"{name}.py" - spec = importlib.util.spec_from_file_location(f"_example_{name}", path) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -@pytest.fixture -def load_example(monkeypatch, tmp_path, qtbot): - """Import an example by file name, its temporary stores under tmp_path. - - The modules are kept until teardown, and each keeps its window (see - ``_show``): a window dropped without being closed takes its controls' - widgets with it and leaves them subscribed to the controller. - """ - counter = itertools.count() - - def _mkdtemp(*args, **kwargs) -> str: - path = tmp_path / f"example_{next(counter)}" - path.mkdir() - return str(path) - - monkeypatch.setattr(tempfile, "mkdtemp", _mkdtemp) - loaded = [] - - def _load(name: str): - module = _import_example(name) - loaded.append(module) - return module - - yield _load - for module in loaded: - module.viewer.controller.close() - - -@pytest.fixture -def run_marimo_example(monkeypatch, load_example): - """Run every cell of a marimo twin; returns what its cells defined. - - ``app.run()`` runs the cells as a script, where marimo does not report a - running notebook, so the host is named for ``display``. - """ - pytest.importorskip("marimo") - viewers = [] - - def _run(name: str) -> dict: - monkeypatch.setattr("cellier.convenience._hosts._marimo_running", lambda: True) - module = load_example(f"{name}_marimo") - outputs, defs = module.app.run() - # What load_example closes. - module.viewer = defs["viewer"] - viewers.append(defs["viewer"]) - # The last thing shown before any button is the composed layout. - assert any(type(output).__name__.endswith("Html") for output in outputs) - return defs - - return _run - - -def _scenes(viewer) -> list: - scenes = getattr(viewer, "scenes", None) - return list(scenes.values()) if scenes is not None else [viewer.scene] - - -async def _show(module, qtbot=None): - """Do what ``run`` does up to the first loaded frame; return the window. - - Without a *qtbot* the caller closes the window. - """ - viewer = module.viewer - controller = viewer.controller - window = render_qt(module.layout, viewer) - module.window = window - if qtbot is not None: - qtbot.addWidget(window) - window.resize(1000, 700) - window.show() - for scene in _scenes(viewer): - controller.fit_camera(scene.id) - controller.reslice_scene(scene.id) - await drain_loading(controller) - # fit="ready" fits again once the data is loaded. - for scene in _scenes(viewer): - controller.fit_camera(scene.id) - await drain_loading(controller) - for callback in viewer._ready_callbacks: - callback() - return window - - -def _drawn_colours(window, controller) -> int: - """How many distinct colours the captured window holds.""" - picture = screenshot_window(window, controller) - return len(np.unique(picture.reshape(-1, picture.shape[-1]), axis=0)) - - -def test_every_example_is_listed(): - scripts = { - path.stem for path in EXAMPLES.glob("*.py") if not path.stem.endswith("_marimo") - } - assert scripts == set(QT_EXAMPLES) - twins = {path.stem for path in EXAMPLES.glob("*_marimo.py")} - assert twins == {f"{name}_marimo" for name in MARIMO_TWINS} - - -@pytest.mark.parametrize("name", QT_EXAMPLES) -async def test_the_example_builds_and_draws(name, load_example, qtbot, offscreen_gpu): - module = load_example(name) - window = await _show(module, qtbot) - # A blank canvas beside the dock's widgets is a few dozen colours; the - # data drawn through a colour map is hundreds. - assert _drawn_colours(window, module.viewer.controller) > 200 - - -# -- What each example is there to show ---------------------------------------- - - -def _canvas_id(viewer): - (canvas_id,) = viewer.canvases - return canvas_id - - -async def test_plane_gizmo_puts_a_gizmo_on_its_plane(load_example, qtbot): - module = load_example("plane_gizmo") - await _show(module, qtbot) - session = module.viewer.controller.get_plane_gizmo(_canvas_id(module.viewer)) - assert session is not None - assert session.kind == "render" - assert session.plane_id == module.plane.id - assert module.visual.plane_mode() - # Both extents are bounded, so both scale handles are there. - assert all(None not in extent for extent in _extents(module.plane)) - - -def _extents(plane): - return (plane.extent_0, plane.extent_1) - - -async def test_the_scripted_motion_is_one_interaction_and_one_plan( - load_example, qtbot, capsys -): - module = load_example("plane_interaction_script") - controller, visual = module.controller, module.visual - log: list[str] = [] - owner_id = controller._id - controller.on_plane_interaction( - visual.id, lambda event: log.append(event.phase), owner_id=owner_id - ) - await _show(module, qtbot) - controller.on_reslice_started( - module.viewer.scene.id, - lambda event: visual.id in event.visual_ids and log.append("plan"), - owner_id=owner_id, - ) - (motion,) = module.motions - await motion - await drain_loading(controller) - - assert log == ["start", "end", "plan"] - # Both planes are where the motion ends. - assert visual.render_planes[0].origin[0] == module.Z_END - assert visual.clipping_planes[0].plane.offset == module.X_END - printed = capsys.readouterr().out - assert "plane interaction: start" in printed - assert "plane interaction: end (release)" in printed - - -async def test_the_scripted_motion_without_a_scope_plans_every_step( - load_example, qtbot, monkeypatch -): - module = load_example("plane_interaction_script") - monkeypatch.setattr(module, "USE_SCOPE", False) - monkeypatch.setattr(module, "STEPS", 3) - controller, visual = module.controller, module.visual - await _show(module, qtbot) - log: list[str] = [] - owner_id = controller._id - controller.on_plane_interaction( - visual.id, lambda event: log.append(event.phase), owner_id=owner_id - ) - controller.on_reslice_started( - module.viewer.scene.id, - lambda event: visual.id in event.visual_ids and log.append("plan"), - owner_id=owner_id, - ) - (motion,) = module.motions - await motion - await drain_loading(controller) - # Six jumps. The first step of each motion is where its plane already - # is, which changes nothing. - assert log == ["plan"] * 4 - - -async def test_the_composite_and_the_labels_are_on_their_own_planes( - load_example, qtbot -): - module = load_example("plane_labels_composite") - await _show(module, qtbot) - image, labels = module.image_visual, module.labels_visual - assert image.composite - assert image.plane_mode() - assert labels.plane_mode() - assert {channel.render_mode for channel in image.channels.values()} == {"plane"} - assert len(image.render_planes) == len(labels.render_planes) == 2 - # The labels' planes are where the labels are, in world. - assert labels.render_planes[0].origin[2] == module.LABELS_X + module.SIDE / 2 - extent = module.viewer.controller.visual_world_extent(labels.id) - low, high = extent[-1] - assert low < labels.render_planes[0].origin[2] < high - - -async def test_the_ortho_example_ties_three_planes_to_the_sliders(load_example, qtbot): - module = load_example("ortho_slice_planes") - await _show(module, qtbot) - viewer, vol = module.viewer, module.vol_visual - assert viewer.plane_controller.mode == "slices" - assert vol.plane_mode() - half = module.SIDE / 2 - assert [plane.origin[k] for k, plane in enumerate(vol.render_planes)] == [half] * 3 - # Only the 3D panel's visual carries planes. - for name in ("xy", "xz", "yz"): - assert module.visuals[name].render_planes == () - viewer.set_slice_positions({0: 20.0}) - assert vol.render_planes[0].origin[0] == 20.0 - assert [plane.origin[k] for k, plane in enumerate(vol.render_planes)][1:] == [ - half, - half, - ] - - -# -- The validation scene: points, labels and image agree at every time -------- - -_SHOT = 480 - - -@pytest.fixture(scope="module") -def validation_module(tmp_path_factory, qapp): - """The validation example, imported once for every test of its scene. - - Importing it renders and writes two pyramids and builds the viewer, which - is most of what a test of the scene costs on the CI runners. - """ - root = tmp_path_factory.mktemp("validation") - with pytest.MonkeyPatch.context() as patch: - patch.setattr(tempfile, "mkdtemp", lambda *args, **kwargs: str(root)) - module = _import_example("plane_slicing_validation") - yield module - window = getattr(module, "window", None) - if window is not None: - window.close() - module.viewer.controller.close() - - -@pytest_asyncio.fixture(scope="module", loop_scope="module") -async def _shown_validation(validation_module): - """The example in its window with its data loaded, and its first camera.""" - module = validation_module - await _show(module) - viewer = module.viewer - return module, viewer.controller.get_camera_state(_canvas_id(viewer)) - - -@pytest_asyncio.fixture(loop_scope="module") -async def validation_scene(_shown_validation): - """The shown example, put back to how ``run`` leaves it. - - The tests of the scene share one window (and one event loop, which the - controller's loading tasks are on), so each starts by undoing what the - ones before it changed: the time point, the plane, the camera, the gizmo - and what is drawn. - """ - module, camera = _shown_validation - viewer, controller = module.viewer, module.viewer.controller - canvas_id = _canvas_id(viewer) - for visual in (module.image_visual, module.labels_visual, module.points_visual): - visual.appearance.visible = True - module.image_visual.aabb.enabled = True - controller.update_background_field(viewer.scene.id, "visible", True) - viewer.set_slice_positions({0: 0.0}) - # The example carries the labels' planes over to the image. - controller.set_render_planes(module.labels_visual.id, (module.plane,)) - controller.set_camera_state(canvas_id, camera) - if controller.get_plane_gizmo(canvas_id) is None: - viewer.add_render_plane_gizmo(module.labels_visual, module.plane) - controller.reslice_scene(viewer.scene.id) - await drain_loading(controller) - return module - - -def _look_down_z(module) -> None: - """Look at the volume along -z. - - The plane faces z, so everything on it is at one depth and a disc on it - is a circle on screen, centred where its centre projects. - """ - viewer = module.viewer - # Rendered space is (x, y, z). - viewer.controller.look_at_visual( - module.image_visual.id, - _canvas_id(viewer), - view_direction=(0, 0, -1), - up=(0, 1, 0), - ) - - -async def _only(module, visual) -> np.ndarray: - """A picture of the scene with one of the three visuals drawn.""" - viewer = module.viewer - for other in (module.image_visual, module.labels_visual, module.points_visual): - other.appearance.visible = other is visual - viewer.controller.reslice_scene(viewer.scene.id) - await drain_loading(viewer.controller) - return viewer.screenshot(size=(_SHOT, _SHOT))[..., :3].astype(int) - - -def _blob(picture: np.ndarray, color) -> tuple[np.ndarray, int]: - """The centroid ``(row, col)`` and the size of the pixels of one colour.""" - wanted = np.round(np.asarray(color, dtype=float) * 255).astype(int) - rows, cols = np.nonzero((np.abs(picture - wanted) <= 3).all(axis=-1)) - if rows.size == 0: - return np.array([np.nan, np.nan]), 0 - return np.array([rows.mean(), cols.mean()]), int(rows.size) - - -@pytest.mark.asyncio(loop_scope="module") -async def test_the_validation_scene_lines_up_at_every_time_point( - validation_scene, qtbot, offscreen_gpu -): - module = validation_scene - viewer, controller = module.viewer, module.viewer.controller - image, labels, points = ( - module.image_visual, - module.labels_visual, - module.points_visual, - ) - # The frame and the gizmo are not part of what is compared. - viewer.remove_plane_gizmo() - image.aabb.enabled = False - controller.update_background_field(viewer.scene.id, "visible", False) - _look_down_z(module) - - upper, lower = range(4, 8), range(4) - for t in range(module.N_TIMES): - viewer.set_slice_positions({0: float(t)}) - # The plane through the four points of the upper half of z. It is - # given to the labels; the example carries it over to the image. - z = float(module.POSITIONS[t, 4, 0]) - plane = labels.render_planes[0].model_copy( - update={"origin": (z, module.SIDE / 2, module.SIDE / 2)} - ) - controller.set_render_plane(labels.id, plane.id, plane) - assert image.render_planes == labels.render_planes - await drain_loading(controller) - - of_points = await _only(module, points) - of_labels = await _only(module, labels) - of_image = await _only(module, image) - - for octant in upper: - color = module.COLORS[t, octant] - marker, marker_size = _blob(of_points, color) - label, label_size = _blob(of_labels, color) - sphere, sphere_size = _blob(of_image, color * module.IMAGE_INTENSITY) - assert marker_size > 0, (t, octant) - # The label and the image are centred on the marker. - assert np.abs(label - marker).max() < 1.5, (t, octant, label, marker) - assert np.abs(sphere - marker).max() < 1.5, (t, octant, sphere, marker) - # And are discs of the two radii: a sphere cut through its centre. - ratio = (module.IMAGE_RADIUS / module.LABEL_RADIUS) ** 2 - assert sphere_size / label_size == pytest.approx(ratio, rel=0.15) - # A circle on screen, though a voxel is twice as deep as wide. - rows, cols = np.nonzero( - (np.abs(of_image - np.round(color * 127.5)) <= 3).all(axis=-1) - ) - assert np.ptp(rows) == pytest.approx(np.ptp(cols), abs=3) - - # The plane does not reach the lower half: its markers are drawn - # (a marker is not on the plane) and nothing else of theirs is. - for octant in lower: - color = module.COLORS[t, octant] - assert _blob(of_points, color)[1] > 0 - assert _blob(of_labels, color)[1] == 0 - assert _blob(of_image, color * module.IMAGE_INTENSITY)[1] == 0 - - # Nothing of another time point is drawn by any of the three. - for other in range(module.N_TIMES): - if other == t: - continue - for octant in range(8): - color = module.COLORS[other, octant] - assert _blob(of_points, color)[1] == 0, (t, other, octant) - assert _blob(of_labels, color)[1] == 0, (t, other, octant) - dim = color * module.IMAGE_INTENSITY - assert _blob(of_image, dim)[1] == 0, (t, other, octant) - - -def _plane_controls(module) -> dict: - """The dock's render planes control of the image and of the labels.""" - import gc - - from cellier.gui.qt.visuals import QtRenderPlanesControls - - names = {module.image_visual.id: "image", module.labels_visual.id: "labels"} - return { - names[control.editor._visual_ids[0]]: control - for control in gc.get_objects() - if isinstance(control, QtRenderPlanesControls) - and control.editor._visual_ids[0] in names - } - - -def _normal(visual) -> list[float]: - plane = visual.render_planes[0] - return np.round(np.cross(plane.in_plane_axis_0, plane.in_plane_axis_1), 6).tolist() - - -@pytest.mark.parametrize("edited", ["image", "labels"]) -@pytest.mark.asyncio(loop_scope="module") -async def test_either_control_of_the_validation_scene_turns_the_one_plane( - edited, validation_scene, qtbot, offscreen_gpu -): - module = validation_scene - viewer, controller = module.viewer, module.viewer.controller - image, labels = module.image_visual, module.labels_visual - controls = _plane_controls(module) - assert set(controls) == {"image", "labels"} - session = controller.get_plane_gizmo(_canvas_id(viewer)) - image.aabb.enabled = False - controller.update_background_field(viewer.scene.id, "visible", False) - - # Face the plane along y, x and back along z, with the control's buttons. - for axis in (1, 2, 0): - controls[edited]._rows[0].facing[(axis, 1)].click() - expected = [float(k == axis) for k in range(3)] - assert _normal(image) == expected - assert _normal(labels) == expected - assert image.render_planes == labels.render_planes - # Both controls show it, and the gizmo is still on the plane. - for control in controls.values(): - assert list(control.editor.rows[0]["normal"]) == expected - assert controller.get_plane_gizmo(_canvas_id(viewer)) is session - # The plane turned about a point, so it passes through the four - # points of the high half of that axis, and through no other sphere. - await drain_loading(controller) - of_labels = await _only(module, labels) - of_image = await _only(module, image) - for octant in range(8): - color = module.COLORS[0, octant] - on_plane = bool((octant >> (2 - axis)) & 1) - assert (_blob(of_labels, color)[1] > 0) == on_plane, (axis, octant) - dim = color * module.IMAGE_INTENSITY - assert (_blob(of_image, dim)[1] > 0) == on_plane, (axis, octant) - - # A typed entry of the normal goes to both visuals as well. - controls[edited]._rows[0].components[1].setValue(1.0) - assert _normal(image) == _normal(labels) - assert _normal(image) == pytest.approx([2**-0.5, 2**-0.5, 0.0], abs=1e-6) - - -def test_the_validation_scene_moves_its_points_and_changes_its_colours( - validation_module, -): - module = validation_module - positions, colors = module.POSITIONS, module.COLORS - for t in range(module.N_TIMES): - for octant in range(8): - # In its octant, with room for the image sphere. - centre = np.array( - [0.25 + 0.5 * ((octant >> (2 - axis)) & 1) for axis in range(3)] - ) - offset = np.abs(positions[t, octant] - centre * module.SIDE) - assert (offset + module.IMAGE_RADIUS < module.SIDE / 4).all() - for a in range(module.N_TIMES): - for b in range(a + 1, module.N_TIMES): - assert (np.abs(positions[a] - positions[b]).max(axis=-1) > 0).all() - # 24 colours, all different, and none the dimmed form of another. - flat = colors.reshape(-1, 3) - both = np.vstack([flat, flat * module.IMAGE_INTENSITY]) - distance = np.abs(both[:, None] - both[None]).max(axis=-1) - assert distance[~np.eye(len(both), dtype=bool)].min() > 8 / 255 - - -# -- The marimo twins ---------------------------------------------------------- - - -async def _load(viewer) -> None: - """Load the viewer's data, as its first frame in a browser would.""" - controller = viewer.controller - for scene in _scenes(viewer): - controller.fit_camera(scene.id) - controller.reslice_scene(scene.id) - await drain_loading(controller) - - -def _colours(picture: np.ndarray) -> int: - return len(np.unique(picture.reshape(-1, picture.shape[-1]), axis=0)) - - -@pytest.mark.parametrize("name", MARIMO_TWINS) -async def test_the_marimo_twin_runs_and_its_viewer_draws( - name, run_marimo_example, offscreen_gpu -): - defs = run_marimo_example(name) - viewer = defs["viewer"] - assert viewer.gui == "anywidget" - await _load(viewer) - # The data through a colour map, not a blank frame. - assert _colours(viewer.screenshot(size=(300, 300))) > 50 - - -# -- Plane outlines in the examples (plane outline design 8) -------------------- - - -def _outlines(viewer, scene) -> dict: - scene_manager = viewer.controller._render_manager._scenes[scene.id] - return scene_manager.plane_outlines.drawn - - -async def test_the_ortho_example_outlines_each_slice_in_the_3d_panel( - load_example, qtbot -): - module = load_example("ortho_slice_planes") - await _show(module, qtbot) - viewer = module.viewer - drawn = _outlines(viewer, viewer.scenes["vol"]) - # One line a slice plane, each in its own colour. - assert {key[1] for key in drawn} == set(viewer.plane_controller.plane_ids) - assert len({polygon.color for polygon in drawn.values()}) == 3 - for name in ("xy", "xz", "yz"): - assert _outlines(viewer, viewer.scenes[name]) == {} - # A slider moves its slice's outline. - viewer.set_slice_positions({0: 20.0}) - first = viewer.plane_controller.plane_ids[0] - moved = _outlines(viewer, viewer.scenes["vol"])[("render", first, 0)] - np.testing.assert_allclose(moved.vertices[:, 2], 20.0) - - -@pytest.mark.asyncio(loop_scope="module") -async def test_the_validation_scene_has_one_outline_for_its_shared_plane( - validation_scene, qtbot -): - """The image and the labels carry the same plane and have the same box.""" - module = validation_scene - viewer = module.viewer - drawn = _outlines(viewer, viewer.scene) - assert list(drawn) == [("render", module.plane.id, 0)] - assert module.image_visual.render_planes[0].outline.enabled - assert module.labels_visual.render_planes[0].outline.enabled - # The plane's square through the volume: four corners on its box. - (polygon,) = drawn.values() - assert len(polygon.vertices) == 4 diff --git a/tests/render/scheduling/test_trace_guard.py b/tests/render/scheduling/test_trace_guard.py index 7ac7641a..75308062 100644 --- a/tests/render/scheduling/test_trace_guard.py +++ b/tests/render/scheduling/test_trace_guard.py @@ -8,6 +8,13 @@ ``trace_baseline.json`` was recorded from the core before the change. **A failure here is a stop condition, not a baseline to regenerate.** + +The baseline holds 40 traces. All 40 were replayed while the policies were +being added; that work is done, and replaying them was 10 s of every CI job. +`GUARDED` is the part still replayed: an empty and a full starting atlas at +both trace lengths. The baseline keeps every trace, so setting ``GUARDED = +CASES`` replays them all again, which is the thing to do before changing the +core's issue or eviction order. """ from __future__ import annotations @@ -18,6 +25,11 @@ from tests.render.scheduling._trace import BASELINE, CASES, trace_digest +#: The recorded traces replayed on every run. Odd seeds start from a full +#: atlas; seeds below 8 are 1500 steps long, the others 400. +GUARDED_SEEDS = (0, 1, 8, 9, 10, 11) +GUARDED = tuple(case for case in CASES if case[0] in GUARDED_SEEDS) + @pytest.fixture(scope="module") def baseline() -> dict[str, dict]: @@ -26,9 +38,10 @@ def baseline() -> dict[str, dict]: def test_every_case_has_a_recorded_digest(baseline) -> None: assert set(baseline) == {f"{seed}:{steps}" for seed, steps in CASES} + assert len(GUARDED) == len(GUARDED_SEEDS) -@pytest.mark.parametrize(("seed", "steps"), CASES) +@pytest.mark.parametrize(("seed", "steps"), GUARDED) def test_the_trace_equals_the_one_recorded_before_policies(seed, steps, baseline): recorded = baseline[f"{seed}:{steps}"] digest, events = trace_digest(seed, steps) diff --git a/tests/render/test_clipping_cut_face.py b/tests/render/test_clipping_cut_face.py index 7f94c5c1..7d7bc045 100644 --- a/tests/render/test_clipping_cut_face.py +++ b/tests/render/test_clipping_cut_face.py @@ -79,8 +79,28 @@ def _look_at_the_cut(camera, world_planes) -> None: camera.look_at(tuple(h.CENTRE)) -@pytest.mark.parametrize("case", list(CASES)) -@pytest.mark.parametrize("kind", KINDS) +#: Kinds every arrangement is drawn for: one image and one labels kind, both +#: multiscale. +ALL_ARRANGEMENTS = ("image_multiscale_iso", "labels_multiscale") + + +def _cases() -> list: + """Every kind at the corner; two kinds with every arrangement. + + Three planes meeting at a corner is the arrangement with the most to get + wrong (which plane a face belongs to), so every kind has it. The plane + arithmetic is shared by the volume shaders, so one plane on an axis, one + oblique and two at an edge are drawn for the kinds of `ALL_ARRANGEMENTS` + only. The full cross was 24 cases. + """ + cases = [(kind, "corner") for kind in KINDS] + cases += [ + (kind, case) for kind in ALL_ARRANGEMENTS for case in CASES if case != "corner" + ] + return [pytest.param(*case, id="-".join(case)) for case in cases] + + +@pytest.mark.parametrize(("kind", "case"), _cases()) async def test_the_cut_face_is_on_the_plane_with_its_normal( kind, case, controller, reslice, tmp_path ): diff --git a/tests/render/test_clipping_planes_render.py b/tests/render/test_clipping_planes_render.py index b20d7708..eaf844f2 100644 --- a/tests/render/test_clipping_planes_render.py +++ b/tests/render/test_clipping_planes_render.py @@ -30,8 +30,51 @@ async def _scenes(kind, controller, reslice, tmp_path, planes, dim): return out -@pytest.mark.parametrize("plane_name", list(h.PLANES)) -@pytest.mark.parametrize("kind", h.KINDS) +#: Kinds every plane arrangement is drawn for. One MIP, which is the kind +#: compared from inside the volume and by colour, and one labels kind, which +#: has a lit cut face; both multiscale, where a plane also meets the walk +#: from brick to brick. +ALL_ARRANGEMENTS = ("image_multiscale_mip", "labels_multiscale") + + +def _volume_cases() -> list: + """Every kind with the oblique plane; two kinds with every arrangement. + + The plane arithmetic is one shared shader (``ray_clip.wgsl``); what + differs by kind is how its volume shader calls it. So each kind is + drawn with the most general single plane, and the other arrangements + (along an axis, along z, a slab of two planes) with the kinds of + `ALL_ARRANGEMENTS` only. The full cross was 24 cases, each two scenes + drawn from up to three views. + """ + cases = [(kind, "oblique") for kind in h.KINDS] + cases += [ + (kind, plane_name) + for kind in ALL_ARRANGEMENTS + for plane_name in h.PLANES + if plane_name != "oblique" + ] + return [pytest.param(*case, id="-".join(case)) for case in cases] + + +#: 2D kinds, each cut at the middle slice; one of them at the others too. +SLICE_KINDS = ( + "image_memory_mip", + "image_multiscale_mip", + "labels_memory", + "labels_multiscale", +) +MIDDLE_SLICE = 16 +OTHER_SLICES = (8, 24) + + +def _slice_cases() -> list: + cases = [(kind, MIDDLE_SLICE) for kind in SLICE_KINDS] + cases += [("image_multiscale_mip", z) for z in OTHER_SLICES] + return [pytest.param(kind, z, id=f"{kind}-{z}") for kind, z in cases] + + +@pytest.mark.parametrize(("kind", "plane_name"), _volume_cases()) async def test_a_volume_is_cut_at_the_plane( kind, plane_name, controller, offscreen_renderer, reslice, tmp_path ): @@ -76,11 +119,7 @@ async def test_a_volume_is_cut_at_the_plane( assert result["colour_off_edge"] <= 4 * allowed, (view_name, result) -@pytest.mark.parametrize("z", [8, 16, 24]) -@pytest.mark.parametrize( - "kind", - ["image_memory_mip", "image_multiscale_mip", "labels_memory", "labels_multiscale"], -) +@pytest.mark.parametrize(("kind", "z"), _slice_cases()) async def test_a_slice_is_cut_at_the_clip_line( kind, z, controller, offscreen_renderer, reslice, tmp_path ): diff --git a/tests/render/test_plane_rendering_multiscale.py b/tests/render/test_plane_rendering_multiscale.py index 8c38ab57..65a2817c 100644 --- a/tests/render/test_plane_rendering_multiscale.py +++ b/tests/render/test_plane_rendering_multiscale.py @@ -34,9 +34,33 @@ # -- the right voxel ----------------------------------------------------------- -@pytest.mark.parametrize("spec_name", list(SPECS)) -@pytest.mark.parametrize("pose", list(POSES)) -@pytest.mark.parametrize("level", [0, 1, 2]) +#: The pyramid every pose is drawn on: the one that never downsamples z, +#: where a plane along an axis and the anisotropy meet. +EVERY_POSE = "aniso" + + +def _level_cases() -> list: + """Every pyramid with the oblique plane; one pyramid with every pose. + + The pyramids differ in how a level's voxels map to level 0, which is + what this test is about, so each is drawn at every level, through the + oblique plane: the pose that crosses every axis. The two poses along an + axis are drawn on `EVERY_POSE` only. The full cross was 36 cases. + """ + cases = [(level, "oblique", name) for name in SPECS for level in (0, 1, 2)] + cases += [ + (level, pose, EVERY_POSE) + for pose in POSES + if pose != "oblique" + for level in (0, 1, 2) + ] + return [ + pytest.param(level, pose, name, id=f"{level}-{pose}-{name}") + for level, pose, name in cases + ] + + +@pytest.mark.parametrize(("level", "pose", "spec_name"), _level_cases()) async def test_an_image_plane_shows_the_voxel_of_the_level_drawn( controller, tmp_path, spec_name, pose, level ): From b26c7e59bd66e8781271a6b239afa7737d482a72 Mon Sep 17 00:00:00 2001 From: Kevin Yamauchi Date: Sat, 10 Oct 2026 14:42:35 +0200 Subject: [PATCH 24/24] remove profiling from pr workflows --- .github/workflows/profile.yml | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/.github/workflows/profile.yml b/.github/workflows/profile.yml index 4640dc77..dd291ab5 100644 --- a/.github/workflows/profile.yml +++ b/.github/workflows/profile.yml @@ -1,8 +1,16 @@ name: Profile # Measures why the tests take longer on one platform than another. Not part of -# CI: run it by hand (Actions -> Profile -> Run workflow), or push a change to -# this file or to scripts/ci_profiling/ in a pull request. +# CI, and it never starts by itself: run it by hand (Actions -> Profile -> Run +# workflow, or `gh workflow run profile.yml --ref `). GitHub only +# offers that for a workflow file that is on the default branch. +# +# To have it run on a pull request again while investigating, add back: +# +# pull_request: +# paths: +# - ".github/workflows/profile.yml" +# - "scripts/ci_profiling/**" # # Each platform uploads a `probes-` artifact (the micro-probes, a # few minutes) and a `profile-` one; compare them with @@ -21,10 +29,6 @@ on: description: "Also run the whole suite as CI does, with per-test times" type: boolean default: true - pull_request: - paths: - - ".github/workflows/profile.yml" - - "scripts/ci_profiling/**" concurrency: group: ${{ github.workflow }}-${{ github.ref }}