diff --git a/src/ndv/controllers/_array_viewer.py b/src/ndv/controllers/_array_viewer.py index 1910ec01..b7963e89 100644 --- a/src/ndv/controllers/_array_viewer.py +++ b/src/ndv/controllers/_array_viewer.py @@ -28,6 +28,7 @@ from ndv.views import _app if TYPE_CHECKING: + from collections.abc import Callable from typing import Any import cmap as cmap_mod @@ -37,7 +38,7 @@ from ndv._types import AxisKey, ChannelKey, KeyPressEvent, MouseMoveEvent from ndv.models._array_display_model import ArrayDisplayModelKwargs from ndv.models._viewer_model import ArrayViewerModelKwargs - from ndv.views.bases import HistogramCanvas, SharedHistogramCanvas + from ndv.views.bases import ArrayCanvas, HistogramCanvas, SharedHistogramCanvas from ndv.views.bases._graphics._canvas_elements import RectangularROIHandle @@ -190,6 +191,20 @@ def data_wrapper(self) -> Any: """Return the data wrapper object being used to interface with the data.""" return self._data_wrapper + @property + def canvas(self) -> ArrayCanvas: + """Return the renderer-independent canvas used by this viewer. + + This is the narrow integration surface for progressive data providers: + images and volumes can be added through the canvas while ndv continues + to select the concrete VisPy or pygfx implementation. + """ + return self._canvas + + def dispatch(self, callback: Callable[[], None]) -> None: + """Schedule ``callback`` on the active GUI frontend's main thread.""" + _app.ndv_app().call_in_main_thread(callback) + @property def data(self) -> Any: """Return data being displayed (the actual data, not the wrapper).""" diff --git a/src/ndv/views/_pygfx/_array_canvas.py b/src/ndv/views/_pygfx/_array_canvas.py index 5aebafe3..5184d999 100755 --- a/src/ndv/views/_pygfx/_array_canvas.py +++ b/src/ndv/views/_pygfx/_array_canvas.py @@ -93,6 +93,25 @@ def set_data(self, data: np.ndarray) -> None: if not is_three_d: self._material.map = None if self._is_rgb() else self._cmap.to_pygfx() + def set_world_transform( + self, + scales: tuple[float, ...], + origins: tuple[float, ...], + ) -> None: + """Set this visual's scale and translation in data-axis order.""" + scene_scales = list(reversed(scales)) + scene_origins = list(reversed(origins)) + while len(scene_scales) < 3: + scene_scales.append(1.0) + scene_origins.append(0.0) + factors = list(reversed(self._downsample_factors)) + while len(factors) < 3: + factors.append(1) + self._image.local.scale = tuple( + scale * factor for scale, factor in zip(scene_scales, factors, strict=True) + ) + self._image.local.position = tuple(scene_origins) + def visible(self) -> bool: return bool(self._image.visible) @@ -441,6 +460,8 @@ def __init__(self, viewer_model: ArrayViewerModel) -> None: self._last_roi_created: ReferenceType[PyGFXRectangle] | None = None # Per-axis world-space scales (x, y, z) used for coordinate conversion self._world_scales: tuple[float, float, float] = (1.0, 1.0, 1.0) + self._world_origins: tuple[float, float, float] = (0.0, 0.0, 0.0) + self._last_camera_signature: bytes | None = None def frontend_widget(self) -> Any: return self._canvas @@ -484,7 +505,9 @@ def set_ndim(self, ndim: Literal[2, 3]) -> None: if state := self._last_state.get(ndim): cam.set_state(state) - def add_image(self, data: np.ndarray | None = None) -> PyGFXImageHandle: + def add_image( + self, data: np.ndarray | None = None, *, reset_range: bool = True + ) -> PyGFXImageHandle: """Add a new Image node to the scene.""" data, downsample_factors = _downcast_and_downsample(data, three_d=False) tex = pygfx.Texture(data, dim=2) @@ -496,7 +519,7 @@ def add_image(self, data: np.ndarray | None = None) -> PyGFXImageHandle: if data is not None: self._current_shape, prev_shape = data.shape, self._current_shape - if not prev_shape: + if reset_range and not prev_shape: self.set_range() # FIXME: I suspect there are more performant ways to refresh the canvas @@ -506,7 +529,9 @@ def add_image(self, data: np.ndarray | None = None) -> PyGFXImageHandle: self._elements[image] = handle return handle - def add_volume(self, data: np.ndarray | None = None) -> PyGFXImageHandle: + def add_volume( + self, data: np.ndarray | None = None, *, reset_range: bool = True + ) -> PyGFXImageHandle: data, downsample_factors = _downcast_and_downsample(data, three_d=True) tex = pygfx.Texture(data, dim=3) vol = pygfx.Volume( @@ -520,7 +545,7 @@ def add_volume(self, data: np.ndarray | None = None) -> PyGFXImageHandle: if data is not None: vol.local_position = [-0.5 * i for i in data.shape[::-1]] self._current_shape, prev_shape = data.shape, self._current_shape - if len(prev_shape) != 3: + if reset_range and len(prev_shape) != 3: self.set_range() # FIXME: I suspect there are more performant ways to refresh the canvas @@ -543,7 +568,9 @@ def add_bounding_box(self) -> PyGFXRectangle: self._last_roi_created = ref(roi) return roi - def set_scales(self, scales: tuple[float, ...]) -> None: + def set_scales( + self, scales: tuple[float, ...], *, reset_range: bool = True + ) -> None: """Set per-visible-axis scale factors for rendering.""" if not scales: return @@ -556,26 +583,41 @@ def set_scales(self, scales: tuple[float, ...]) -> None: (sx, sy, sz) = gfx_scales[:3] self._world_scales = (sx, sy, sz) - has_visuals = False + self._apply_world_transform() + if reset_range: + self.set_range() + + def set_origins(self, origins: tuple[float, ...]) -> None: + """Set per-visible-axis world origins in data-axis order.""" + gfx_origins = list(reversed(origins)) + while len(gfx_origins) < 3: + gfx_origins.append(0.0) + self._world_origins = (gfx_origins[0], gfx_origins[1], gfx_origins[2]) + self._apply_world_transform() + + def _apply_world_transform(self) -> None: for handle in self._elements.values(): if not isinstance(handle, PyGFXImageHandle): continue - child = handle._image - if not isinstance(child, (pygfx.Image, pygfx.Volume)): - continue - _sx, _sy, _sz = sx, sy, sz - # compensate for downsampling so coordinates stay correct - # factors are in data order; pygfx order is (x, y, z) = reversed - factors = handle._downsample_factors - if factors and any(f > 1 for f in factors): - rev = list(reversed(factors)) - _sx *= rev[0] - _sy *= rev[1] if len(rev) > 1 else 1 - _sz *= rev[2] if len(rev) > 2 else 1 - child.local.scale = (_sx, _sy, _sz) - has_visuals = True - if has_visuals: - self.set_range() + handle.set_world_transform( + tuple(reversed(self._world_scales)), + tuple(reversed(self._world_origins)), + ) + + def camera_state(self) -> tuple[tuple[int, int], np.ndarray]: + """Return viewport and data-order world-to-clip matrix.""" + if self._camera is None: + raise RuntimeError("camera dimensionality has not been initialized") + width, height = self._canvas.get_logical_size() + scene_to_clip = np.asarray(self._camera.camera_matrix, dtype=np.float64) + ndim = self._ndim or 2 + permutation = np.zeros((4, 4), dtype=np.float64) + for axis in range(ndim): + permutation[ndim - axis - 1, axis] = 1.0 + for axis in range(ndim, 4): + permutation[axis, axis] = 1.0 + matrix = scene_to_clip @ permutation + return (int(width), int(height)), matrix def set_range( self, @@ -588,7 +630,11 @@ def set_range( When called with no arguments, the range is set to the full extent of the data. """ - if not self._scene.children or self._camera is None: + has_images = any( + isinstance(handle, PyGFXImageHandle) and handle.data() is not None + for handle in self._elements.values() + ) + if not has_images or self._camera is None: return cam = self._camera @@ -628,6 +674,13 @@ def refresh(self) -> None: def _animate(self) -> None: if self._camera is not None: self._renderer.render(self._scene, self._camera) + signature = np.asarray(self._camera.camera_matrix).tobytes() + if ( + self._last_camera_signature is not None + and signature != self._last_camera_signature + ): + self.cameraChanged.emit() + self._last_camera_signature = signature def _canvas_to_world_raw( self, pos_xy: tuple[float, float] diff --git a/src/ndv/views/_vispy/_array_canvas.py b/src/ndv/views/_vispy/_array_canvas.py index 74c27369..067d5dec 100755 --- a/src/ndv/views/_vispy/_array_canvas.py +++ b/src/ndv/views/_vispy/_array_canvas.py @@ -34,13 +34,30 @@ ) if TYPE_CHECKING: - from collections.abc import Sequence + from collections.abc import Callable, Sequence turn = np.sin(np.pi / 4) DEFAULT_QUATERNION = Quaternion(turn, turn, 0, 0) +class _CameraChangeMixin: + _ndv_changed: Callable[[], None] | None = None + + def view_changed(self) -> None: + super().view_changed() # type: ignore[misc] + if self._ndv_changed is not None: + self._ndv_changed() + + +class _NDVArcballCamera(_CameraChangeMixin, scene.ArcballCamera): + pass + + +class _NDVPanZoomCamera(_CameraChangeMixin, scene.PanZoomCamera): + pass + + class VispyImageHandle(ImageHandle): def __init__(self, visual: visuals.ImageVisual | visuals.VolumeVisual) -> None: self._visual = visual @@ -71,6 +88,28 @@ def set_data(self, data: np.ndarray) -> None: self._downsample_factors = downsample_factors self._visual.set_data(data) + def set_world_transform( + self, + scales: tuple[float, ...], + origins: tuple[float, ...], + ) -> None: + """Set this visual's scale and translation in data-axis order.""" + scene_scales = list(reversed(scales)) + scene_origins = list(reversed(origins)) + while len(scene_scales) < 3: + scene_scales.append(1.0) + scene_origins.append(0.0) + factors = list(reversed(self._downsample_factors)) + while len(factors) < 3: + factors.append(1) + effective = tuple( + scale * factor for scale, factor in zip(scene_scales, factors, strict=True) + ) + self._visual.transform = vispy.visuals.transforms.STTransform( + scale=effective, + translate=tuple(scene_origins), + ) + def visible(self) -> bool: return bool(self._visual.visible) @@ -316,6 +355,8 @@ def __init__(self, viewer_model: ArrayViewerModel) -> None: central_wdg: scene.Widget = self._canvas.central_widget self._view: scene.ViewBox = central_wdg.add_view() self._ndim: Literal[2, 3] | None = None + self._world_scales = (1.0, 1.0, 1.0) + self._world_origins = (0.0, 0.0, 0.0) # Maps vispy visuals (scene children) → CanvasElement handles. # Entries are added by add_image/add_volume/add_bounding_box. @@ -345,11 +386,13 @@ def set_ndim(self, ndim: Literal[2, 3]) -> None: self._ndim = ndim if ndim == 3: - cam = scene.ArcballCamera(fov=0) + cam = _NDVArcballCamera(fov=0) # this sets the initial view similar to what the panzoom view would have. cam._quaternion = DEFAULT_QUATERNION else: - cam = scene.PanZoomCamera(aspect=1, flip=(0, 1)) + cam = _NDVPanZoomCamera(aspect=1, flip=(0, 1)) + + cam._ndv_changed = self.cameraChanged.emit # restore the previous state if it exists if state := self._last_state.get(ndim): @@ -368,7 +411,9 @@ def close(self) -> None: def refresh(self) -> None: self._canvas.update() - def add_image(self, data: np.ndarray | None = None) -> VispyImageHandle: + def add_image( + self, data: np.ndarray | None = None, *, reset_range: bool = True + ) -> VispyImageHandle: """Add a new Image node to the scene.""" data, downsample_factors = _downcast_and_downsample(data, three_d=False) try: @@ -384,11 +429,13 @@ def add_image(self, data: np.ndarray | None = None) -> VispyImageHandle: handle = VispyImageHandle(img) handle._downsample_factors = downsample_factors self._elements[img] = handle - if data is not None: + if data is not None and reset_range: self.set_range() return handle - def add_volume(self, data: np.ndarray | None = None) -> VispyImageHandle: + def add_volume( + self, data: np.ndarray | None = None, *, reset_range: bool = True + ) -> VispyImageHandle: data, downsample_factors = _downcast_and_downsample(data, three_d=True) try: vol = scene.visuals.Volume( @@ -408,7 +455,7 @@ def add_volume(self, data: np.ndarray | None = None) -> VispyImageHandle: handle = VispyImageHandle(vol) handle._downsample_factors = downsample_factors self._elements[vol] = handle - if data is not None: + if data is not None and reset_range: self.set_range() return handle @@ -421,7 +468,9 @@ def add_bounding_box(self) -> VispyRectangle: self._last_roi_created = ReferenceType(roi) return roi - def set_scales(self, scales: tuple[float, ...]) -> None: + def set_scales( + self, scales: tuple[float, ...], *, reset_range: bool = True + ) -> None: """Set per-visible-axis scale factors for rendering.""" if not scales: return @@ -431,26 +480,51 @@ def set_scales(self, scales: tuple[float, ...]) -> None: # pad to 3 components while len(vis_scales) < 3: vis_scales.append(1.0) - sx, sy, sz = vis_scales[0], vis_scales[1], vis_scales[2] + self._world_scales = (vis_scales[0], vis_scales[1], vis_scales[2]) + self._apply_world_transform() + if reset_range: + self.set_range() + + def set_origins(self, origins: tuple[float, ...]) -> None: + """Set per-visible-axis world origins in data-axis order.""" + vis_origins = list(reversed(origins)) + while len(vis_origins) < 3: + vis_origins.append(0.0) + self._world_origins = (vis_origins[0], vis_origins[1], vis_origins[2]) + self._apply_world_transform() + + def _apply_world_transform(self) -> None: for handle in self._elements.values(): if not isinstance(handle, VispyImageHandle): continue - child = handle._visual - if not isinstance(child, (visuals.ImageVisual, visuals.VolumeVisual)): - continue - _sx, _sy, _sz = sx, sy, sz - # compensate for downsampling so coordinates stay correct - # factors are in data order; scene order is (x, y, z) = reversed - factors = handle._downsample_factors - if factors and any(f > 1 for f in factors): - rev = list(reversed(factors)) - _sx *= rev[0] - _sy *= rev[1] if len(rev) > 1 else 1 - _sz *= rev[2] if len(rev) > 2 else 1 - child.transform = vispy.visuals.transforms.STTransform( - scale=(_sx, _sy, _sz) + handle.set_world_transform( + tuple(reversed(self._world_scales)), + tuple(reversed(self._world_origins)), ) - self.set_range() + + def camera_state(self) -> tuple[tuple[int, int], np.ndarray]: + """Return viewport and data-order world-to-clip matrix.""" + width, height = (int(value) for value in self._canvas.size) + ndim = self._ndim or 2 + points = np.zeros((ndim + 1, 4), dtype=np.float64) + points[:, 3] = 1.0 + # VisPy scene order is XYZ; public data order is YX or ZYX. + for axis in range(ndim): + points[axis + 1, ndim - axis - 1] = 1.0 + mapped = np.asarray(self._view.scene.transform.map(points), dtype=np.float64) + # Keep homogeneous coordinates intact. Dividing each basis sample by + # ``w`` before reconstructing the matrix turns a perspective camera + # into an affine approximation around the data origin. That is badly + # wrong for camera-aware LOD and chunk priority away from the origin. + framebuffer = np.eye(4, dtype=np.float64) + framebuffer[:, -1] = mapped[0] + for axis in range(ndim): + framebuffer[:, axis] = mapped[axis + 1] - mapped[0] + + framebuffer_to_clip = np.eye(4, dtype=np.float64) + framebuffer_to_clip[0] = (2.0 / width, 0.0, 0.0, -1.0) + framebuffer_to_clip[1] = (0.0, -2.0 / height, 0.0, 1.0) + return (width, height), framebuffer_to_clip @ framebuffer def set_range( self, diff --git a/src/ndv/views/bases/_graphics/_canvas.py b/src/ndv/views/bases/_graphics/_canvas.py index fe6c5619..3f6bfb4f 100644 --- a/src/ndv/views/bases/_graphics/_canvas.py +++ b/src/ndv/views/bases/_graphics/_canvas.py @@ -55,20 +55,39 @@ def elements_at(self, pos_xy: tuple[float, float]) -> list[CanvasElement]: ... class ArrayCanvas(GraphicsCanvas): """ABC for canvases that show array data.""" + cameraChanged = Signal() + @abstractmethod def __init__(self, viewer_model: ArrayViewerModel | None = ...) -> None: ... @abstractmethod def set_ndim(self, ndim: Literal[2, 3]) -> None: ... @abstractmethod + def add_image( + self, data: np.ndarray | None = ..., *, reset_range: bool = ... + ) -> ImageHandle: ... @abstractmethod - def add_image(self, data: np.ndarray | None = ...) -> ImageHandle: ... - @abstractmethod - def add_volume(self, data: np.ndarray | None = ...) -> ImageHandle: ... + def add_volume( + self, data: np.ndarray | None = ..., *, reset_range: bool = ... + ) -> ImageHandle: ... @abstractmethod def add_bounding_box(self) -> RectangularROIHandle: ... - def set_scales(self, scales: tuple[float, ...]) -> None: - """Set per-visible-axis scale factors for rendering.""" + def set_scales( + self, scales: tuple[float, ...], *, reset_range: bool = True + ) -> None: + """Set per-visible-axis scales, optionally fitting the camera.""" + + def set_origins(self, origins: tuple[float, ...]) -> None: + """Set per-visible-axis world origins in data-axis order.""" + + def camera_state(self) -> tuple[tuple[int, int], np.ndarray]: + """Return ``(viewport, world_to_clip)`` in visible data-axis order. + + The matrix uses column-vector convention and maps world coordinates + ordered slowest-to-fastest (for example ZYX) into normalized clip + coordinates. Implementations emit :attr:`cameraChanged` when it changes. + """ + raise NotImplementedError class HistogramCanvas(GraphicsCanvas, LUTView): diff --git a/src/ndv/views/bases/_graphics/_canvas_elements.py b/src/ndv/views/bases/_graphics/_canvas_elements.py index 2b346eb0..b34ca77b 100644 --- a/src/ndv/views/bases/_graphics/_canvas_elements.py +++ b/src/ndv/views/bases/_graphics/_canvas_elements.py @@ -51,6 +51,14 @@ class ImageHandle(CanvasElement, LUTView): def data(self) -> np.ndarray: ... @abstractmethod def set_data(self, data: np.ndarray) -> None: ... + @abstractmethod + def set_world_transform( + self, + scales: tuple[float, ...], + origins: tuple[float, ...], + ) -> None: + """Set this element's world transform in data-axis order.""" + @abstractmethod def clims(self) -> tuple[float, float]: ... @abstractmethod diff --git a/tests/test_controller.py b/tests/test_controller.py index cb52cdb5..2f326d6c 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -87,6 +87,11 @@ def _patch_views(f: Callable) -> Callable: def test_controller() -> None: SHAPE = (10, 4, 10, 10) ctrl = ArrayViewer() + assert ctrl.canvas is ctrl._canvas + callback = MagicMock() + with patch.object(_app, "ndv_app") as ndv_app: + ctrl.dispatch(callback) + ndv_app.return_value.call_in_main_thread.assert_called_once_with(callback) ctrl._async = False model = ctrl.display_model mock_view = ctrl._view diff --git a/tests/views/_pygfx/test_array_canvas.py b/tests/views/_pygfx/test_array_canvas.py index 476ec6ae..bf5327ae 100644 --- a/tests/views/_pygfx/test_array_canvas.py +++ b/tests/views/_pygfx/test_array_canvas.py @@ -59,6 +59,18 @@ def test_zoom_center() -> None: canvas.close() +@pytest.mark.usefixtures("any_app") +def test_set_scales_before_image_is_safe() -> None: + """Initial model synchronization may set scales before data arrives.""" + canvas = GfxArrayCanvas(ArrayViewerModel()) + _force_canvas_size(canvas) + canvas.set_ndim(2) + + canvas.set_scales((2.0, 3.0)) + + canvas.close() + + @pytest.mark.usefixtures("any_app") def test_canvas_to_world_scale_aware_offset() -> None: """canvas_to_world pixel-center offset must scale with pixel size. diff --git a/tests/views/_pygfx/test_volume_downsample.py b/tests/views/_pygfx/test_volume_downsample.py index d4730c57..4fcf8dc9 100644 --- a/tests/views/_pygfx/test_volume_downsample.py +++ b/tests/views/_pygfx/test_volume_downsample.py @@ -110,6 +110,23 @@ def test_set_scales_compensates_for_volume_downsample() -> None: canvas.close() +@pytest.mark.usefixtures("any_app") +def test_image_handles_can_have_independent_world_transforms() -> None: + canvas = GfxArrayCanvas(ArrayViewerModel()) + _force_canvas_size(canvas) + canvas.set_ndim(3) + coarse = canvas.add_volume(np.zeros((4, 4, 4), dtype=np.float32)) + fine = canvas.add_volume(np.zeros((4, 4, 4), dtype=np.float32)) + + coarse.set_world_transform((4.0, 4.0, 4.0), (0.0, 0.0, 0.0)) + fine.set_world_transform((1.0, 1.0, 1.0), (8.0, 12.0, 16.0)) + + assert coarse._image.local.scale == pytest.approx((4.0, 4.0, 4.0)) + assert fine._image.local.scale == pytest.approx((1.0, 1.0, 1.0)) + assert fine._image.local.position == pytest.approx((16.0, 12.0, 8.0)) + canvas.close() + + @pytest.mark.usefixtures("any_app") def test_no_downsample_when_limits_none() -> None: """When GPU limits are unavailable, data should pass through unchanged.""" diff --git a/tests/views/_vispy/test_volume_downsample.py b/tests/views/_vispy/test_volume_downsample.py index 19ea0ec4..dd7c6035 100644 --- a/tests/views/_vispy/test_volume_downsample.py +++ b/tests/views/_vispy/test_volume_downsample.py @@ -102,6 +102,78 @@ def test_set_scales_compensates_for_volume_downsample() -> None: canvas.close() +@pytest.mark.usefixtures("any_app") +def test_world_origin_and_camera_state_are_public() -> None: + canvas = VispyArrayCanvas(ArrayViewerModel()) + changed = [] + canvas.cameraChanged.connect(lambda: changed.append(True)) + canvas.set_ndim(3) + handle = canvas.add_volume(np.zeros((10, 20, 30), dtype=np.float32)) + canvas.set_scales((2.0, 3.0, 4.0)) + canvas.set_origins((100.0, 200.0, 300.0)) + canvas.set_range() + + transform = handle._visual.transform + assert isinstance(transform, vispy.visuals.transforms.STTransform) + assert transform.scale[:3] == pytest.approx((4.0, 3.0, 2.0)) + assert transform.translate[:3] == pytest.approx((300.0, 200.0, 100.0)) + viewport, world_to_clip = canvas.camera_state() + assert viewport == tuple(int(value) for value in canvas._canvas.size) + assert all(value > 0 for value in viewport) + assert world_to_clip.shape == (4, 4) + assert np.isfinite(world_to_clip).all() + + before = world_to_clip.copy() + canvas._camera.scale_factor /= 2 + canvas._camera.view_changed() + assert changed + assert not np.allclose(before, canvas.camera_state()[1]) + canvas.close() + + +@pytest.mark.usefixtures("any_app") +def test_camera_state_preserves_projective_point_mapping() -> None: + canvas = VispyArrayCanvas(ArrayViewerModel()) + canvas.set_ndim(3) + canvas.add_volume(np.zeros((10, 20, 30), dtype=np.float32)) + canvas.set_range() + + viewport, world_to_clip = canvas.camera_state() + width, height = viewport + data_points = np.asarray(((0.0, 0.0, 0.0), (3.0, 7.0, 11.0), (8.0, 17.0, 27.0))) + # VisPy scene order is XYZ while the public camera matrix consumes ZYX. + scene_points = np.column_stack((data_points[:, ::-1], np.ones(len(data_points)))) + framebuffer = np.asarray( + canvas._view.scene.transform.map(scene_points), dtype=np.float64 + ) + expected = framebuffer[:, :3] / framebuffer[:, 3, np.newaxis] + expected[:, 0] = 2.0 * expected[:, 0] / width - 1.0 + expected[:, 1] = 1.0 - 2.0 * expected[:, 1] / height + + homogeneous = np.column_stack((data_points, np.ones(len(data_points)))) + actual = (world_to_clip @ homogeneous.T).T + actual = actual[:, :3] / actual[:, 3, np.newaxis] + + assert actual == pytest.approx(expected) + canvas.close() + + +@pytest.mark.usefixtures("any_app") +def test_image_handles_can_have_independent_world_transforms() -> None: + canvas = VispyArrayCanvas(ArrayViewerModel()) + canvas.set_ndim(3) + coarse = canvas.add_volume(np.zeros((4, 4, 4), dtype=np.float32)) + fine = canvas.add_volume(np.zeros((4, 4, 4), dtype=np.float32)) + + coarse.set_world_transform((4.0, 4.0, 4.0), (0.0, 0.0, 0.0)) + fine.set_world_transform((1.0, 1.0, 1.0), (8.0, 12.0, 16.0)) + + assert coarse._visual.transform.scale[:3] == pytest.approx((4.0, 4.0, 4.0)) + assert fine._visual.transform.scale[:3] == pytest.approx((1.0, 1.0, 1.0)) + assert fine._visual.transform.translate[:3] == pytest.approx((16.0, 12.0, 8.0)) + canvas.close() + + @pytest.mark.usefixtures("any_app") def test_set_range_correct_bounds_after_downsample() -> None: """set_range should compute world bounds as if data were full-resolution."""