diff --git a/examples/antialiasing.py b/examples/antialiasing.py index 1ddb6434..dc5001cf 100644 --- a/examples/antialiasing.py +++ b/examples/antialiasing.py @@ -66,8 +66,8 @@ def _toggle_antialias(event: Event) -> bool: return False -view.set_event_filter(_toggle_antialias) - -snx.show(view) +canvas = snx.show(view) +ci = snx.CanvasInteractor(canvas) +ci.set_view_filter(view, _toggle_antialias) view.camera.projection = projections.orthographic(2, 2, 1e5) snx.run() diff --git a/examples/basic_line.py b/examples/basic_line.py index 88570111..2b3b2ef9 100644 --- a/examples/basic_line.py +++ b/examples/basic_line.py @@ -33,10 +33,7 @@ def _create_line_data(angle: float = 0) -> np.ndarray: ) line = snx.Line(vertices=original_vertices, color=line_color_model) -view = snx.View( - scene=snx.Scene(children=[line]), - camera=snx.Camera(controller=snx.PanZoom(), interactive=True), -) +view = snx.View(scene=snx.Scene(children=[line])) pressed = False @@ -71,9 +68,9 @@ def _view_event_filter(event: Event) -> bool: return False -# Set up the event filter -view.set_event_filter(_view_event_filter) - -# Show and position camera -snx.show(view) +# Show and position camera, then set up interaction +canvas = snx.show(view) +ci = snx.CanvasInteractor(canvas) +ci.set_controller(view, snx.PanZoom()) +ci.set_view_filter(view, _view_event_filter) snx.run() diff --git a/examples/basic_mesh.py b/examples/basic_mesh.py index 59729c9a..09d51bf8 100644 --- a/examples/basic_mesh.py +++ b/examples/basic_mesh.py @@ -75,10 +75,7 @@ def create_grid_mesh( color=per_vertex_model, ) -view = snx.View( - scene=snx.Scene(children=[mesh]), - camera=snx.Camera(controller=snx.PanZoom(), interactive=True), -) +view = snx.View(scene=snx.Scene(children=[mesh])) def event_filter(event: Event) -> bool: @@ -107,11 +104,11 @@ def event_filter(event: Event) -> bool: return False -# Set up the event filter -view.set_event_filter(event_filter) - -# Show and position camera -snx.show(view) +# Show and position camera, then set up interaction +canvas = snx.show(view) +ci = snx.CanvasInteractor(canvas) +ci.set_controller(view, snx.PanZoom()) +ci.set_view_filter(view, event_filter) print("Interactive Mesh Demo:") print("- Move mouse over mesh to delete intersected faces") diff --git a/examples/basic_points.py b/examples/basic_points.py index 387a6d04..1ebdc27e 100644 --- a/examples/basic_points.py +++ b/examples/basic_points.py @@ -64,9 +64,7 @@ # Since ray-point intersections are computed in canvas space, we need view+canvas -view = snx.View( - scene=snx.Scene(children=[points]), camera=snx.Camera(controller=snx.PanZoom()) -) +view = snx.View(scene=snx.Scene(children=[points]), camera=snx.Camera()) def _on_view_event(event: Event) -> bool: @@ -83,10 +81,11 @@ def _on_view_event(event: Event) -> bool: return False -view.set_event_filter(_on_view_event) - # Show and position camera -snx.show(view) +canvas = snx.show(view) +ci = snx.CanvasInteractor(canvas) +ci.set_controller(view, snx.PanZoom()) +ci.set_view_filter(view, _on_view_event) view.camera.projection = projections.orthographic(2, 2, 1e5) view.camera.transform = snx.Transform().translated((0.5, 0.5)) snx.run() diff --git a/examples/basic_scene.py b/examples/basic_scene.py index e757b096..a5154c59 100644 --- a/examples/basic_scene.py +++ b/examples/basic_scene.py @@ -32,8 +32,7 @@ ), ] ), - camera=snx.Camera(controller=snx.PanZoom(), interactive=True), - on_resize=snx.Letterbox(), + camera=snx.Camera(), ) # example of adding an object to a scene @@ -46,7 +45,10 @@ # snx.use("pygfx") # snx.use("vispy") -snx.show(view) +canvas = snx.show(view) +ci = snx.CanvasInteractor(canvas) +ci.set_controller(view, snx.PanZoom()) +ci.set_resize_policy(view, snx.Letterbox()) if add_imgui_controls is not None: add_imgui_controls(view) diff --git a/examples/basic_text.py b/examples/basic_text.py index fb388ee9..90c78103 100644 --- a/examples/basic_text.py +++ b/examples/basic_text.py @@ -8,10 +8,12 @@ scene=snx.Scene( children=[snx.Text(text="Hello, Scenex!", color=cmap.Color("cyan"), size=24)] ), - camera=snx.Camera(controller=snx.PanZoom(), interactive=True), + camera=snx.Camera(), ) # Show and position camera -snx.show(view) +canvas = snx.show(view) +ci = snx.CanvasInteractor(canvas) +ci.set_controller(view, snx.PanZoom()) snx.run() diff --git a/examples/basic_volume.py b/examples/basic_volume.py index 532671d4..0b187b03 100644 --- a/examples/basic_volume.py +++ b/examples/basic_volume.py @@ -23,11 +23,12 @@ ) ] ), - camera=snx.Camera(interactive=True), - on_resize=snx.Letterbox(), + camera=snx.Camera(), ) -snx.show(view) +canvas = snx.show(view) +ci = snx.CanvasInteractor(canvas) +ci.set_resize_policy(view, snx.Letterbox()) # Orbit around the center of the volume orbit_center = np.mean(np.asarray(view.scene.bounding_box), axis=0) @@ -41,7 +42,7 @@ near=1, far=1_000_000, # Just need something big ) -view.camera.controller = snx.Orbit(center=orbit_center) +ci.set_controller(view, snx.Orbit(center=orbit_center)) snx.run() diff --git a/examples/blending.py b/examples/blending.py index b167942e..3c9549b4 100644 --- a/examples/blending.py +++ b/examples/blending.py @@ -51,7 +51,7 @@ children=[volume1, volume2], interactive=True, ), - camera=snx.Camera(interactive=True), + camera=snx.Camera(), ) blend_modes = list(scenex.model.BlendMode) @@ -75,11 +75,10 @@ def change_blend_mode(event: Event) -> bool: return False -view.set_event_filter(change_blend_mode) - - snx.use("vispy") -snx.show(view) +canvas = snx.show(view) +ci = snx.CanvasInteractor(canvas) +ci.set_view_filter(view, change_blend_mode) # Orbit around the center of the volume orbit_center = np.mean(np.asarray(view.scene.bounding_box), axis=0) @@ -93,6 +92,6 @@ def change_blend_mode(event: Event) -> bool: near=1, far=1_000_000, # Just need something big ) -view.camera.controller = snx.Orbit(center=orbit_center) +ci.set_controller(view, snx.Orbit(center=orbit_center)) snx.run() diff --git a/examples/cursor_points.py b/examples/cursor_points.py index 8c140765..6d6cb825 100644 --- a/examples/cursor_points.py +++ b/examples/cursor_points.py @@ -25,8 +25,9 @@ ) view = snx.View(scene=snx.Scene(children=[points])) -view.camera.controller = snx.PanZoom() canvas = snx.show(view) +ci = snx.CanvasInteractor(canvas) +ci.set_controller(view, snx.PanZoom()) def _cursor_filter(event: Event) -> bool: @@ -40,6 +41,6 @@ def _cursor_filter(event: Event) -> bool: return False -view.set_event_filter(_cursor_filter) +ci.set_view_filter(view, _cursor_filter) snx.run() diff --git a/examples/draggable_rect.py b/examples/draggable_rect.py index 3baaae26..c055e0bf 100644 --- a/examples/draggable_rect.py +++ b/examples/draggable_rect.py @@ -68,11 +68,10 @@ # ── Scene / View ────────────────────────────────────────────────────────────── scene = snx.Scene(children=[bg, rect_mesh]) -view = snx.View( - scene=scene, - camera=snx.Camera(controller=snx.PanZoom(), interactive=True), -) +view = snx.View(scene=scene, camera=snx.Camera()) canvas = snx.show(view) +ci = snx.CanvasInteractor(canvas) +ci.set_controller(view, snx.PanZoom()) # ── Drag state ──────────────────────────────────────────────────────────────── _anchor: tuple[float, float] | None = None # resize: fixed opposite corner @@ -214,7 +213,7 @@ def _on_vertices_changed(vertices: np.ndarray) -> None: # Connect the event filter to listen for user events -view.set_event_filter(_event_filter) +ci.set_view_filter(view, _event_filter) # Connect the vertex change callback _on_vertices_changed(rect_mesh.vertices) # initialize display diff --git a/examples/event_filters.py b/examples/event_filters.py index abeddf24..ba8b5cfc 100644 --- a/examples/event_filters.py +++ b/examples/event_filters.py @@ -57,7 +57,7 @@ def _view_filter(event: Event) -> bool: return True -view.set_event_filter(_view_filter) - -snx.show(view) +canvas = snx.show(view) +ci = snx.CanvasInteractor(canvas) +ci.set_view_filter(view, _view_filter) snx.run() diff --git a/examples/histogram.py b/examples/histogram.py index 9296ae21..5190bf0e 100644 --- a/examples/histogram.py +++ b/examples/histogram.py @@ -85,7 +85,7 @@ def __init__(self) -> None: # plot self.view = snx.View( scene=snx.Scene(name="main scene"), - camera=snx.Camera(interactive=True), + camera=snx.Camera(), ) self.view.layout.x_start = f"{_AXIS}px" self.view.layout.y_end = f"-{_AXIS}px" @@ -213,13 +213,11 @@ def _init_main_view(self) -> None: self.controls.order = 1 self.view.scene.add_child(self.controls) - # Set up event handlers and controllers - self.view.camera.controller = snx.PanZoom(lock_y=True) + self._pan_zoom = snx.PanZoom(lock_y=True) self.view.camera.events.transform.connect(self._update_x_axis) self.view.camera.events.projection.connect(self._update_x_axis) self.canvas.events.width.connect(self._update_x_axis) - self.view.set_event_filter(self._on_main_view) def _on_main_view(self, event: events.Event) -> bool: # If we have a mouse event... @@ -236,7 +234,7 @@ def _on_main_view(self, event: events.Event) -> bool: ] if len(intersections): self._grabbed = intersections[0] - self.view.camera.interactive = False + self.ci.set_controller(self.view, None) # ... and are double-pressing the gamma handle, reset the gamma elif isinstance(event, events.MouseDoublePressEvent): if ( @@ -281,7 +279,7 @@ def _on_main_view(self, event: events.Event) -> bool: # If we have a mouse release or leave event, end any drag if isinstance(event, events.MouseReleaseEvent | events.MouseLeaveEvent): self._grabbed = None - self.view.camera.interactive = True + self.ci.set_controller(self.view, self._pan_zoom) return False def set_clims(self, clims: tuple[float, float]) -> None: @@ -356,6 +354,12 @@ def set_data(self, source: np.ndarray) -> None: if first_data: self.set_range() + def setup_interactor(self, ci: snx.CanvasInteractor) -> None: + """Wire up the CanvasInteractor for the main view.""" + self.ci = ci + ci.set_controller(self.view, self._pan_zoom) + ci.set_view_filter(self.view, self._on_main_view) + def set_range(self) -> None: """Sets the range of the x axis.""" projections.zoom_to_fit(self.view, "orthographic", zoom_factor=1) @@ -500,8 +504,9 @@ def _hist_counts_to_mesh( # Create the histogram histogram = Histogram() -# Show the histogram +# Show the histogram and wire up interaction snx.show(histogram.canvas) +histogram.setup_interactor(snx.CanvasInteractor(histogram.canvas)) # Add some data data = gaussian_dataset(n=10000) histogram.set_data(data) diff --git a/examples/keyboard_pan_zoom.py b/examples/keyboard_pan_zoom.py index a818356e..10fffb83 100644 --- a/examples/keyboard_pan_zoom.py +++ b/examples/keyboard_pan_zoom.py @@ -17,11 +17,10 @@ for y, x in coords: data[y - 3 : y + 3, x - 3 : x + 3] = 255 -view = snx.View( - scene=snx.Scene(children=[snx.Image(data=data)]), - camera=snx.Camera(controller=snx.PanZoom(), interactive=True), -) +view = snx.View(scene=snx.Scene(children=[snx.Image(data=data)])) canvas = snx.Canvas(views=[view]) +ci = snx.CanvasInteractor(canvas) +ci.set_controller(view, snx.PanZoom()) _PAN_STEP = 20.0 # world units per arrow-key press _ZOOM_STEP = 1.25 # multiplicative factor per +/- press @@ -61,7 +60,7 @@ def _key_filter(event: Event) -> bool: return True -canvas.set_event_filter(_key_filter) +ci.set_event_filter(_key_filter) snx.show(canvas) snx.run() diff --git a/examples/multi_view.py b/examples/multi_view.py index e864f177..5ebda5de 100644 --- a/examples/multi_view.py +++ b/examples/multi_view.py @@ -59,14 +59,8 @@ def _make_scene() -> snx.Scene: # We'll make two views on the same scene -view1 = snx.View( - scene=_make_scene(), - camera=snx.Camera(interactive=True), -) -view2 = snx.View( - scene=_make_scene(), - camera=snx.Camera(interactive=True), -) +view1 = snx.View(scene=_make_scene(), camera=snx.Camera()) +view2 = snx.View(scene=_make_scene(), camera=snx.Camera()) # Partition the canvas into two halves for the first two views view1.layout.x_end = view2.layout.x_start = "50%" @@ -99,9 +93,9 @@ def _view1_event_filter(event: Event) -> bool: return False -view1.set_event_filter(_view1_event_filter) - snx.show(canvas) +ci = snx.CanvasInteractor(canvas) +ci.set_view_filter(view1, _view1_event_filter) # Orbit around the center of the volume orbit_center = np.mean(np.asarray(view2.scene.bounding_box), axis=0) @@ -115,7 +109,7 @@ def _view1_event_filter(event: Event) -> bool: near=1, far=1_000_000, # Just need something big ) -view1.camera.controller = snx.Orbit(center=orbit_center) +ci.set_controller(view1, snx.Orbit(center=orbit_center)) # The second camera can just look down (-z) at the center of the volume view2.camera.transform = Transform().translated(orbit_center).translated((0, 0, 300)) diff --git a/examples/regions.py b/examples/regions.py index 42fed8ff..a4eed145 100644 --- a/examples/regions.py +++ b/examples/regions.py @@ -68,12 +68,12 @@ def _on_click(event: events.Event) -> bool: return False -view.set_event_filter(_on_click) - name, *_ = REGIONS[region_idx] print(f"[{region_idx + 1}/{len(REGIONS)}] {name}") print("Click the view to cycle through layout options.") snx.show(canvas) +ci = snx.CanvasInteractor(canvas) +ci.set_view_filter(view, _on_click) zoom_to_fit(view) snx.run() diff --git a/examples/rgb.py b/examples/rgb.py index 34bc87fd..a9ca93c0 100644 --- a/examples/rgb.py +++ b/examples/rgb.py @@ -34,7 +34,7 @@ img, ] ), - camera=snx.Camera(controller=snx.PanZoom(), interactive=True), + camera=snx.Camera(), ) idx = 0 @@ -56,7 +56,8 @@ def _event_filter(event: events.Event) -> bool: return False -view.set_event_filter(_event_filter) - -snx.show(view) +canvas = snx.show(view) +ci = snx.CanvasInteractor(canvas) +ci.set_controller(view, snx.PanZoom()) +ci.set_view_filter(view, _event_filter) snx.run() diff --git a/src/scenex/__init__.py b/src/scenex/__init__.py index 22debc95..32f27934 100644 --- a/src/scenex/__init__.py +++ b/src/scenex/__init__.py @@ -29,6 +29,7 @@ See Also -------- - scenex.model: Core declarative model classes +- scenex.interaction: Interaction components and coordinator - scenex.adaptors: Backend adaptor implementations - scenex.app: Application and event handling """ @@ -43,6 +44,15 @@ __version__ = "uninstalled" from .adaptors import use +from .interaction import ( + CameraController, + CanvasInteractor, + Letterbox, + Orbit, + PanZoom, + ResizePolicy, + ViewInteractor, +) from .model._canvas import Canvas from .model._color import ( ColorModel, @@ -54,7 +64,7 @@ Coord, Layout, ) -from .model._nodes.camera import Camera, CameraController, Orbit, PanZoom +from .model._nodes.camera import Camera from .model._nodes.image import Image from .model._nodes.line import Line from .model._nodes.mesh import Mesh @@ -64,13 +74,14 @@ from .model._nodes.text import Text from .model._nodes.volume import Volume from .model._transform import Transform -from .model._view import Letterbox, ResizePolicy, View +from .model._view import View from .util import run, set_cursor, show __all__ = [ "Camera", "CameraController", "Canvas", + "CanvasInteractor", "ColorModel", "Coord", "FaceColors", @@ -90,6 +101,7 @@ "UniformColor", "VertexColors", "View", + "ViewInteractor", "Volume", "run", "set_cursor", diff --git a/src/scenex/adaptors/_base.py b/src/scenex/adaptors/_base.py index 49b41993..bd098f26 100644 --- a/src/scenex/adaptors/_base.py +++ b/src/scenex/adaptors/_base.py @@ -120,8 +120,6 @@ class CameraAdaptor(NodeAdaptor[TCamera, TNative]): @abstractmethod def _snx_set_projection(self, arg: model.Transform, /) -> None: ... - @abstractmethod - def _snx_set_controller(self, arg: model.CameraController | None, /) -> None: ... class ImageAdaptor(NodeAdaptor[TImage, TNative]): diff --git a/src/scenex/adaptors/_pygfx/_camera.py b/src/scenex/adaptors/_pygfx/_camera.py index 742496b5..f4c1f769 100644 --- a/src/scenex/adaptors/_pygfx/_camera.py +++ b/src/scenex/adaptors/_pygfx/_camera.py @@ -33,6 +33,3 @@ def _view_size(self) -> tuple[float, float] | None: def _snx_set_projection(self, arg: model.Transform) -> None: self._pygfx_node.projection_matrix = arg.root # pyright: ignore[reportAttributeAccessIssue] - - def _snx_set_controller(self, arg: model.CameraController | None) -> None: - pass diff --git a/src/scenex/adaptors/_pygfx/_canvas.py b/src/scenex/adaptors/_pygfx/_canvas.py index 55a8682b..d98fc172 100644 --- a/src/scenex/adaptors/_pygfx/_canvas.py +++ b/src/scenex/adaptors/_pygfx/_canvas.py @@ -90,10 +90,23 @@ def __init__(self, canvas: model.Canvas, **backend_kwargs: Any) -> None: self._views: list[model.View] = [] for view in canvas.views: self._snx_add_view(view) - self._filter = app().install_event_filter(self._snx_get_native(), canvas.handle) + self._filter = app().install_event_filter( + self._snx_get_native(), self._dispatch_event + ) self._renderer = pygfx.renderers.WgpuRenderer(self._wgpu_canvas) self._renderer.request_draw(self._draw) + def _dispatch_event(self, event: Any) -> bool: + from scenex.app.events import ResizeEvent + + if isinstance(event, ResizeEvent): + self._canvas.size = (event.width, event.height) + from scenex.interaction._coordinator import _interactor_by_canvas_id + + if ci := _interactor_by_canvas_id.get(self._canvas._model_id.hex): + return ci.handle(event) + return False + def _snx_get_native(self) -> Any: if subwdg := getattr(self._wgpu_canvas, "_subwidget", None): # wx backend has a _subwidget attribute that is the actual widget diff --git a/src/scenex/adaptors/_vispy/_camera.py b/src/scenex/adaptors/_vispy/_camera.py index 940b42f7..8739e915 100644 --- a/src/scenex/adaptors/_vispy/_camera.py +++ b/src/scenex/adaptors/_vispy/_camera.py @@ -72,9 +72,6 @@ def _snx_set_projection(self, arg: Transform) -> None: # Transforms self._update_vispy_node_tform() - def _snx_set_controller(self, arg: model.CameraController | None) -> None: - pass - def _update_vispy_node_tform(self) -> None: mat = self._transform @ self._projection.T @ self._from_NDC self._vispy_node.transform = vispy.scene.transforms.MatrixTransform(mat.root) diff --git a/src/scenex/adaptors/_vispy/_canvas.py b/src/scenex/adaptors/_vispy/_canvas.py index b8a861c5..bef6eb92 100644 --- a/src/scenex/adaptors/_vispy/_canvas.py +++ b/src/scenex/adaptors/_vispy/_canvas.py @@ -41,12 +41,25 @@ def __init__(self, canvas: model.Canvas, **backend_kwargs: Any) -> None: self._views: list[model.View] = [] for view in canvas.views: self._snx_add_view(view) - self._filter = app().install_event_filter(self._canvas.native, canvas.handle) + self._filter = app().install_event_filter( + self._canvas.native, self._dispatch_event + ) self._visual_to_node: dict[VisualNode, model.Node | None] = {} self._last_canvas_pos: tuple[float, float] | None = None self._model = canvas + def _dispatch_event(self, event: Any) -> bool: + from scenex.app.events import ResizeEvent + + if isinstance(event, ResizeEvent): + self._model.size = (event.width, event.height) + from scenex.interaction._coordinator import _interactor_by_canvas_id + + if ci := _interactor_by_canvas_id.get(self._model._model_id.hex): + return ci.handle(event) + return False + def _snx_get_native(self) -> Any: return self._canvas.native diff --git a/src/scenex/imgui/_controls.py b/src/scenex/imgui/_controls.py index f344119c..712e8b5f 100644 --- a/src/scenex/imgui/_controls.py +++ b/src/scenex/imgui/_controls.py @@ -199,7 +199,14 @@ def convert_btn(self, btn: MouseButton) -> int: return 0 imgui_filter = ImguiEventFilter() - imgui_filter.internal_filter = view.set_event_filter(imgui_filter) + from scenex.interaction._coordinator import _interactor_by_canvas_id + + ci = _interactor_by_canvas_id.get(snx_canvas_model._model_id.hex) + if ci is None: + from scenex.interaction import CanvasInteractor + + ci = CanvasInteractor(snx_canvas_model) + imgui_filter.internal_filter = ci.set_view_filter(view, imgui_filter) def _min_max(meta: list[Any], eps: float = 0) -> tuple[float | None, float | None]: diff --git a/src/scenex/interaction/__init__.py b/src/scenex/interaction/__init__.py new file mode 100644 index 00000000..3e8c796d --- /dev/null +++ b/src/scenex/interaction/__init__.py @@ -0,0 +1,48 @@ +"""Interaction module for scenex. + +Provides camera controllers, resize policies, and the CanvasInteractor coordinator +that routes events through a well-defined pipeline. + +Event Pipeline (in order; stops at the first step that returns True): + 1. Canvas-level event filter (via CanvasInteractor.set_event_filter) + 2. Per-view ResizePolicy (via CanvasInteractor.set_resize_policy) + 3. Per-view event filter (via CanvasInteractor.set_view_filter) + 4. Per-view CameraController (via CanvasInteractor.set_controller) + +Examples +-------- +Attach a pan/zoom controller and letterbox resize to a view:: + + from scenex.interaction import CanvasInteractor, PanZoom, Letterbox + + ci = CanvasInteractor(canvas) + ci.set_controller(view, PanZoom()) + ci.set_resize_policy(view, Letterbox()) + +Or use the per-view convenience wrapper:: + + from scenex.interaction import ViewInteractor, PanZoom, Letterbox + + vi = ViewInteractor(view, controller=PanZoom(), resize_policy=Letterbox()) +""" + +from scenex.interaction._controllers import ( + AnyController, + CameraController, + Orbit, + PanZoom, +) +from scenex.interaction._coordinator import CanvasInteractor, ViewInteractor +from scenex.interaction._resize import AnyResizePolicy, Letterbox, ResizePolicy + +__all__ = [ + "AnyController", + "AnyResizePolicy", + "CameraController", + "CanvasInteractor", + "Letterbox", + "Orbit", + "PanZoom", + "ResizePolicy", + "ViewInteractor", +] diff --git a/src/scenex/interaction/_controllers.py b/src/scenex/interaction/_controllers.py new file mode 100644 index 00000000..3776d21a --- /dev/null +++ b/src/scenex/interaction/_controllers.py @@ -0,0 +1,284 @@ +"""Camera controllers for interactive camera manipulation.""" + +from __future__ import annotations + +import math +from abc import abstractmethod +from typing import TYPE_CHECKING, Any, Literal + +import numpy as np +import pylinalg as la +from pydantic import Field, PrivateAttr + +from scenex.app.events import ( + MouseButton, + MouseEvent, + MouseMoveEvent, + MousePressEvent, + WheelEvent, +) +from scenex.model._base import EventedBase + +if TYPE_CHECKING: + from scenex.app.events import Event + from scenex.model._view import View + + +class CameraController(EventedBase): + """Base class defining how a camera responds to user interaction events. + + A CameraController handles user input (mouse, keyboard, wheel) to manipulate + camera transforms and projections. Controllers are registered with a + CanvasInteractor for a specific view and automatically receive events routed + to that view. + + Event handlers should return True if they fully handled the event (stopping + further propagation) or False if other handlers should continue processing. + + Examples + -------- + Register a controller with a CanvasInteractor:: + + ci = CanvasInteractor(canvas) + ci.set_controller(view, PanZoom()) + + See Also + -------- + PanZoom : 2D pan and zoom controller + Orbit : 3D orbit controller + CanvasInteractor : Coordinator that routes events to controllers + """ + + @abstractmethod + def handle_event(self, event: Event, view: View) -> bool: + """Handle a user interaction event to control the camera. + + Parameters + ---------- + event : Event + The input event to handle. + view : View + The view containing the camera to manipulate. + + Returns + ------- + bool + True if the event was fully handled and should not propagate, + False otherwise. + + Notes + ----- + A ``View`` is passed rather than a ``Camera`` directly because controllers + need ``view.to_ray()`` to unproject screen-space positions into world space. + """ + raise NotImplementedError + + +class PanZoom(CameraController): + """2D pan and zoom controller for orthographic views. + + - **Panning** (left mouse drag): Modifies camera.transform to translate the camera. + - **Zooming** (mouse wheel): Modifies camera.projection to scale the view, then + adjusts camera.transform to keep zoom centered on the cursor position. + + Attributes + ---------- + lock_x : bool + If True, prevent horizontal panning and zooming. + lock_y : bool + If True, prevent vertical panning and zooming. + + Examples + -------- + Register with CanvasInteractor:: + + ci = CanvasInteractor(canvas) + ci.set_controller(view, PanZoom()) + + See Also + -------- + Orbit : 3D orbit controller for perspective views + CameraController : Base class for camera controllers + """ + + lock_x: bool = Field( + default=False, + description="If True, prevent horizontal panning and zooming.", + ) + lock_y: bool = Field( + default=False, + description="If True, prevent vertical panning and zooming.", + ) + type: Literal["pan_zoom"] = Field(default="pan_zoom", repr=False) + + _drag_pos: tuple[float, float] | None = PrivateAttr(default=None) + + def handle_event(self, event: Event, view: View) -> bool: + """Handle mouse and wheel events to pan/zoom the camera.""" + handled = False + + if not isinstance(event, MouseEvent): + return False + if (ray := view.to_ray(event.pos)) is None: + return False + + if isinstance(event, MousePressEvent) and MouseButton.LEFT in event.buttons: + self._drag_pos = ray.origin[:2] + elif ( + isinstance(event, MouseMoveEvent) + and MouseButton.LEFT in event.buttons + and self._drag_pos + ): + new_pos = ray.origin[:2] + dx = self._drag_pos[0] - new_pos[0] + if not self.lock_x: + view.camera.transform = view.camera.transform.translated((dx, 0)) + dy = self._drag_pos[1] - new_pos[1] + if not self.lock_y: + view.camera.transform = view.camera.transform.translated((0, dy)) + handled = True + + elif isinstance(event, WheelEvent): + _, dy = event.angle_delta + if dy: + zoom = self._zoom_factor(dy) + view.camera.projection = view.camera.projection.scaled( + (1 if self.lock_x else zoom, 1 if self.lock_y else zoom, 1.0) + ) + zoom_center = np.asarray(ray.origin)[:2] + camera_center = np.asarray(view.camera.transform.map((0, 0)))[:2] + delta_screen1 = zoom_center - camera_center + delta_screen2 = delta_screen1 * zoom + pan = (delta_screen2 - delta_screen1) / zoom + view.camera.transform = view.camera.transform.translated( + ( + pan[0] if not self.lock_x else 0, + pan[1] if not self.lock_y else 0, + ) + ) + handled = True + + return handled + + def _zoom_factor(self, delta: float) -> float: + return 2 ** (delta * 0.001) + + +class Orbit(CameraController): + """3D orbit controller for rotating around a focal point. + + - **Left drag**: Orbit/rotate the camera around the center point + - **Right drag**: Pan the orbit center (translates the focal point) + - **Mouse wheel**: Zoom toward/away from center (change radius) + + Attributes + ---------- + center : tuple[float, float, float] + The point in 3D space around which the camera orbits. + polar_axis : tuple[float, float, float] + The axis defining the "up" direction for orbit calculations. + + Examples + -------- + Register with CanvasInteractor:: + + ci = CanvasInteractor(canvas) + ci.set_controller(view, Orbit(center=(0, 0, 0))) + + See Also + -------- + PanZoom : 2D pan and zoom controller for orthographic views + CameraController : Base class for camera controllers + """ + + center: tuple[float, float, float] = Field( + default=(0.0, 0.0, 0.0), + description="The point in 3D space around which the camera orbits.", + ) + polar_axis: tuple[float, float, float] = Field( + default=(0.0, 0.0, 1.0), + description='The axis defining the "up" direction for orbit calculations.', + ) + type: Literal["orbit"] = Field(default="orbit", repr=False) + + _last_canvas_pos: tuple[float, float] | None = PrivateAttr(default=None) + _pan_ray: Any = PrivateAttr(default=None) + + def handle_event(self, event: Event, view: View) -> bool: + """Handle mouse and wheel events to orbit the camera.""" + handled = False + center_array = np.asarray(self.center) + + if not isinstance(event, MouseEvent): + return False + if (ray := view.to_ray(event.pos)) is None: + return False + + if ( + isinstance(event, MouseMoveEvent) + and event.buttons == MouseButton.LEFT + and self._last_canvas_pos is not None + ): + orbit_mat = view.camera.transform.translated(-center_array) + position = la.mat_decompose(orbit_mat.T)[0] + camera_right = np.cross(view.camera.forward, view.camera.up) + + d_azimuth = self._last_canvas_pos[0] - event.pos[0] + d_elevation = self._last_canvas_pos[1] - event.pos[1] + + e_bound = float(la.vec_angle(position, (0, 0, 1)) * 180 / math.pi) + if e_bound + d_elevation < 0: + d_elevation = -e_bound + if e_bound + d_elevation > 180: + d_elevation = 180 - e_bound + + view.camera.transform = ( + view.camera.transform.translated(-center_array) + .rotated(d_elevation, camera_right) + .rotated(d_azimuth, self.polar_axis) + .translated(center_array) + ) + handled = True + + elif isinstance(event, MousePressEvent) and event.buttons == MouseButton.RIGHT: + self._pan_ray = ray + + elif ( + isinstance(event, MouseMoveEvent) + and event.buttons == MouseButton.RIGHT + and self._pan_ray is not None + ): + dr = np.linalg.norm(view.camera.transform.map((0, 0, 0))[:3] - center_array) + old_center = self._pan_ray.origin[:3] + np.multiply( + dr, self._pan_ray.direction + ) + new_center = ray.origin[:3] + np.multiply(dr, ray.direction) + diff = np.subtract(old_center, new_center) + view.camera.transform = view.camera.transform.translated(diff) + new_center_array = center_array + diff + self.center = ( + float(new_center_array[0]), + float(new_center_array[1]), + float(new_center_array[2]), + ) + handled = True + + elif isinstance(event, WheelEvent): + _, dy = event.angle_delta + if dy: + dr = view.camera.transform.map((0, 0, 0))[:3] - center_array + zoom = self._zoom_factor(dy) + view.camera.transform = view.camera.transform.translated( + dr * (zoom - 1) + ) + handled = True + + if isinstance(event, MouseEvent): + self._last_canvas_pos = event.pos + return handled + + def _zoom_factor(self, delta: float) -> float: + return 2 ** (-delta * 0.001) + + +AnyController = PanZoom | Orbit | None diff --git a/src/scenex/interaction/_coordinator.py b/src/scenex/interaction/_coordinator.py new file mode 100644 index 00000000..8664f126 --- /dev/null +++ b/src/scenex/interaction/_coordinator.py @@ -0,0 +1,375 @@ +"""CanvasInteractor and ViewInteractor: coordinators for interaction components.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import TYPE_CHECKING +from weakref import WeakValueDictionary + +from scenex.app.events import MouseEnterEvent, MouseEvent, MouseLeaveEvent + +if TYPE_CHECKING: + from collections.abc import Callable + + from scenex.app.events import Event + from scenex.interaction._controllers import CameraController + from scenex.interaction._resize import ResizePolicy + from scenex.model._canvas import Canvas + from scenex.model._view import View + +logger = logging.getLogger("scenex.interaction") + +# Global weak-value registry: maps canvas model_id (hex str) → CanvasInteractor. +# Canvas adaptors look up this registry in their _dispatch_event method so that +# event routing works regardless of whether the interactor was created before or +# after the adaptor. +_interactor_by_canvas_id: WeakValueDictionary[str, CanvasInteractor] = ( + WeakValueDictionary() +) + + +@dataclass +class _ViewState: + """Per-view interaction state owned by a CanvasInteractor.""" + + controller: CameraController | None = None + resize_policy: ResizePolicy | None = None + event_filter: Callable[[Event], bool] | None = None + # (signal, slot) pairs for resize signal subscriptions + _resize_connections: list = field(default_factory=list) + + def disconnect_resize(self) -> None: + for sig, slot in self._resize_connections: + try: + sig.disconnect(slot) + except Exception: + pass + self._resize_connections.clear() + + +class CanvasInteractor: + """Coordinator that manages interaction components for a Canvas. + + ``CanvasInteractor`` is the single entry point for all events on a canvas. + It owns the ordered event pipeline and manages per-view interaction state + (controllers, resize policies, and event filters). + + Event Pipeline (in order; stops at the first step that returns True): + + 1. **Canvas-level event filter** — user-defined callable registered via + ``set_event_filter()``. + 2. **ResizePolicy** — for each view, the registered resize policy is called + when the view's dimensions change (via signals) or on a ResizeEvent. + 3. **View-level event filter** — per-view user-defined callable registered + via ``set_view_filter()``. + 4. **CameraController** — per-view controller registered via + ``set_controller()``. + + Examples + -------- + Attach a pan/zoom controller and letterbox resize to a view:: + + from scenex.interaction import CanvasInteractor, PanZoom, Letterbox + + ci = CanvasInteractor(canvas) + ci.set_controller(view, PanZoom()) + ci.set_resize_policy(view, Letterbox()) + + Register a canvas-level event filter:: + + def my_filter(event): + print(event) + return False + + + ci.set_event_filter(my_filter) + """ + + def __init__(self, canvas: Canvas) -> None: + self._canvas = canvas + self._event_filter: Callable[[Event], bool] | None = None + self._view_states: dict[str, _ViewState] = {} # keyed by view._model_id.hex + self._last_mouse_view: View | None = None + + # Register in the global dict so canvas adaptors can find us. + _interactor_by_canvas_id[canvas._model_id.hex] = self + + # Track views added/removed from the canvas so we can manage their state. + canvas.views.item_inserted.connect(self._on_view_inserted) + canvas.views.item_removed.connect(self._on_view_removed) + + # ------------------------------------------------------------------ + # Public configuration API + # ------------------------------------------------------------------ + + def set_event_filter( + self, event_filter: Callable[[Event], bool] | None + ) -> Callable[[Event], bool] | None: + """Register a callable to filter all canvas events before view dispatch. + + Parameters + ---------- + event_filter : Callable[[Event], bool] | None + Callable that receives each Event and returns True if handled. + Pass None to remove any existing filter. + + Returns + ------- + Callable[[Event], bool] | None + The previous event filter. + """ + old, self._event_filter = self._event_filter, event_filter + return old + + def set_controller(self, view: View, controller: CameraController | None) -> None: + """Register a CameraController for a view. + + Parameters + ---------- + view : View + The view whose camera should be controlled. + controller : CameraController | None + The controller to use, or None to remove any existing controller. + """ + self._get_or_create_state(view).controller = controller + + def set_resize_policy(self, view: View, policy: ResizePolicy | None) -> None: + """Register a ResizePolicy for a view. + + Parameters + ---------- + view : View + The view whose projection should be adjusted on resize. + policy : ResizePolicy | None + The resize policy to use, or None to remove any existing policy. + """ + state = self._get_or_create_state(view) + state.disconnect_resize() + state.resize_policy = policy + + if policy is not None: + + def _on_resize(*_: object) -> None: + policy.handle_resize(view) + + for sig in ( + view.layout.events.x_start, + view.layout.events.x_end, + view.layout.events.y_start, + view.layout.events.y_end, + ): + sig.connect(_on_resize) + state._resize_connections.append((sig, _on_resize)) + + if view.canvas is not None: + for sig in (view.canvas.events.width, view.canvas.events.height): + sig.connect(_on_resize) + state._resize_connections.append((sig, _on_resize)) + + def set_view_filter( + self, view: View, event_filter: Callable[[Event], bool] | None + ) -> Callable[[Event], bool] | None: + """Register a per-view event filter. + + Parameters + ---------- + view : View + The view to attach the filter to. + event_filter : Callable[[Event], bool] | None + Callable that receives each Event routed to this view and returns + True if handled. Pass None to remove any existing filter. + + Returns + ------- + Callable[[Event], bool] | None + The previous view filter. + """ + state = self._get_or_create_state(view) + old, state.event_filter = state.event_filter, event_filter + return old + + # ------------------------------------------------------------------ + # Event pipeline entry point (called by canvas adaptors) + # ------------------------------------------------------------------ + + def handle(self, event: Event) -> bool: + """Process an event through the interaction pipeline. + + Called by canvas adaptors for every backend event. + + Parameters + ---------- + event : Event + The event to process. + + Returns + ------- + bool + True if the event was handled and should not propagate further. + """ + # Step 1: canvas-level event filter. + if self._canvas_filter(event): + return True + + # Steps 2-4 are per-view. + if isinstance(event, MouseEvent): + return self._handle_mouse_event(event) + if isinstance(event, MouseLeaveEvent): + return self._handle_mouse_leave(event) + + return False + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _canvas_filter(self, event: Event) -> bool: + if self._event_filter is None: + return False + handled = self._event_filter(event) + if not isinstance(handled, bool): + logger.warning( + "Canvas event filter %r did not return a bool; treating as False.", + self._event_filter, + ) + return False + return handled + + def _handle_mouse_event(self, event: MouseEvent) -> bool: + current_view = self._containing_view(event.pos) + + # Synthesize enter/leave transitions between views. + if self._last_mouse_view != current_view: + if self._last_mouse_view is not None: + self._view_filter(self._last_mouse_view, MouseLeaveEvent()) + if current_view is not None and not isinstance(event, MouseEnterEvent): + self._view_filter( + current_view, + MouseEnterEvent(pos=event.pos, buttons=event.buttons), + ) + self._last_mouse_view = current_view + + if current_view is None: + return False + + # Step 3: view-level event filter. + if self._view_filter(current_view, event): + return True + + # Step 4: camera controller. + state = self._view_states.get(current_view._model_id.hex) + if state and state.controller is not None: + return state.controller.handle_event(event, current_view) + + return False + + def _handle_mouse_leave(self, event: MouseLeaveEvent) -> bool: + if self._last_mouse_view is not None: + handled = self._view_filter(self._last_mouse_view, event) + self._last_mouse_view = None + return handled + return False + + def _view_filter(self, view: View, event: Event) -> bool: + state = self._view_states.get(view._model_id.hex) + if state is None or state.event_filter is None: + return False + handled = state.event_filter(event) + if not isinstance(handled, bool): + logger.warning( + "View event filter %r did not return a bool; treating as False.", + state.event_filter, + ) + return False + return handled + + def _containing_view(self, pos: tuple[float, float]) -> View | None: + for view in self._canvas.views: + if view.content_rect is None: + continue + x, y, w, h = view.content_rect + if x <= pos[0] <= x + w and y <= pos[1] <= y + h: + return view + return None + + def _get_or_create_state(self, view: View) -> _ViewState: + key = view._model_id.hex + if key not in self._view_states: + self._view_states[key] = _ViewState() + return self._view_states[key] + + def _on_view_inserted(self, _: int, view: View) -> None: + # If a resize policy is already registered for this view, reconnect + # canvas size signals now that the view has a canvas. + state = self._view_states.get(view._model_id.hex) + if state and state.resize_policy is not None: + self.set_resize_policy(view, state.resize_policy) + + def _on_view_removed(self, _: int, view: View) -> None: + state = self._view_states.pop(view._model_id.hex, None) + if state: + state.disconnect_resize() + + +class ViewInteractor: + """Convenience wrapper for single-view interaction configuration. + + ``ViewInteractor`` wraps a single ``View`` and configures a + ``CanvasInteractor`` on the view's canvas. If the view is not yet attached + to a canvas, the configuration is applied when it is. + + Examples + -------- + :: + + from scenex.interaction import ViewInteractor, PanZoom, Letterbox + + vi = ViewInteractor(view, controller=PanZoom(), resize_policy=Letterbox()) + + See Also + -------- + CanvasInteractor : Lower-level coordinator for multi-view canvases + """ + + def __init__( + self, + view: View, + *, + controller: CameraController | None = None, + resize_policy: ResizePolicy | None = None, + event_filter: Callable[[Event], bool] | None = None, + ) -> None: + self._view = view + self._pending_controller = controller + self._pending_resize_policy = resize_policy + self._pending_filter = event_filter + self._canvas_interactor: CanvasInteractor | None = None + + if view.canvas is not None: + self._attach(view.canvas) + else: + # Wait until the view is attached to a canvas. + view.events.connect(self._on_view_event) + + def _attach(self, canvas: Canvas) -> None: + ci = _interactor_by_canvas_id.get(canvas._model_id.hex) + if ci is None: + ci = CanvasInteractor(canvas) + self._canvas_interactor = ci + if self._pending_controller is not None: + ci.set_controller(self._view, self._pending_controller) + if self._pending_resize_policy is not None: + ci.set_resize_policy(self._view, self._pending_resize_policy) + if self._pending_filter is not None: + ci.set_view_filter(self._view, self._pending_filter) + + def _on_view_event(self, _: object) -> None: + # Re-check if view now has a canvas attached. + if self._view.canvas is not None and self._canvas_interactor is None: + self._attach(self._view.canvas) + + @property + def canvas_interactor(self) -> CanvasInteractor | None: + """The underlying CanvasInteractor, or None if the view has no canvas.""" + return self._canvas_interactor diff --git a/src/scenex/interaction/_resize.py b/src/scenex/interaction/_resize.py new file mode 100644 index 00000000..2aaa2362 --- /dev/null +++ b/src/scenex/interaction/_resize.py @@ -0,0 +1,108 @@ +"""Resize policies for adapting view projections to canvas size changes.""" + +from __future__ import annotations + +from abc import abstractmethod +from typing import TYPE_CHECKING, Literal + +from pydantic import Field, PrivateAttr + +from scenex.model._base import EventedBase + +if TYPE_CHECKING: + from scenex.model._transform import Transform + from scenex.model._view import View + + +class ResizePolicy(EventedBase): + """Base class defining how a view adapts to changes in its layout dimensions. + + A ResizePolicy is invoked automatically when a view's layout dimensions change, + providing a hook to adjust any aspect of the view in response. Policies are + registered with a CanvasInteractor and called whenever the layout width or height + changes. + + Examples + -------- + Register with CanvasInteractor:: + + ci = CanvasInteractor(canvas) + ci.set_resize_policy(view, Letterbox()) + + See Also + -------- + Letterbox : Resize policy that maintains aspect ratio + CanvasInteractor : Coordinator that manages resize policies + """ + + @abstractmethod + def handle_resize(self, view: View) -> None: + """Respond to view layout dimension changes. + + Parameters + ---------- + view : View + The view being resized. + """ + raise NotImplementedError + + +class Letterbox(ResizePolicy): + """Maintain content aspect ratio on resize via letterboxing/pillarboxing. + + The Letterbox policy preserves the original aspect ratio of the camera's + projection when the view is resized. When the view's aspect ratio differs from + the content's aspect ratio, the projection is expanded in the narrower dimension + to ensure all original content remains visible with black bars. + + Examples + -------- + Register with CanvasInteractor:: + + ci = CanvasInteractor(canvas) + ci.set_resize_policy(view, Letterbox()) + + See Also + -------- + ResizePolicy : Base class for resize policies + CanvasInteractor : Coordinator that manages resize policies + """ + + _reference: Transform | None = PrivateAttr(default=None) + _last_adjustment: Transform | None = PrivateAttr(default=None) + + type: Literal["letterbox"] = Field(default="letterbox", repr=False) + + def handle_resize(self, view: View) -> None: + """Handle view resize by adjusting projection to maintain aspect ratio.""" + if view.camera.projection != self._last_adjustment or self._reference is None: + self._reference = view.camera.projection + + if (view_rect := view.rect) is None or self._reference is None: + return + _, _, view_width, view_height = view_rect + if view_height == 0: + return + + ref_mat = self._reference.root + ref_x_scale = ref_mat[0, 0] + ref_y_scale = ref_mat[1, 1] + if ref_y_scale == 0: + return + + view_aspect = view_width / view_height + content_aspect = abs(ref_y_scale / ref_x_scale) + + if content_aspect < view_aspect: + adjusted_proj = self._reference.scaled( + (content_aspect / view_aspect, 1.0, 1.0) + ) + else: + adjusted_proj = self._reference.scaled( + (1.0, view_aspect / content_aspect, 1.0) + ) + + view.camera.projection = self._last_adjustment = adjusted_proj + + +AnyResizePolicy = Letterbox | None diff --git a/src/scenex/model/__init__.py b/src/scenex/model/__init__.py index d7ee5fe4..3f9c114f 100644 --- a/src/scenex/model/__init__.py +++ b/src/scenex/model/__init__.py @@ -61,11 +61,15 @@ ... ] ... ) -Create a view with interactive camera:: +Create a view with an interactive camera using CanvasInteractor:: - >>> from scenex.model import View, Camera, PanZoom + from scenex.model import View, Camera + from scenex.interaction import CanvasInteractor, PanZoom - >>> view = View(scene=scene, camera=Camera(controller=PanZoom(), interactive=True)) + view = View(scene=scene, camera=Camera()) + canvas = snx.show(view) + ci = CanvasInteractor(canvas) + ci.set_controller(view, PanZoom()) Notes ----- @@ -93,13 +97,7 @@ Coord, Layout, ) -from ._nodes.camera import ( - AnyController, - Camera, - CameraController, - Orbit, - PanZoom, -) +from ._nodes.camera import Camera from ._nodes.image import Image, InterpolationMode from ._nodes.line import Line from ._nodes.mesh import Mesh @@ -109,15 +107,12 @@ from ._nodes.text import Text from ._nodes.volume import RenderMode, Volume from ._transform import Transform -from ._view import AnyResizePolicy, Letterbox, ResizePolicy, View +from ._view import View __all__ = [ - "AnyController", "AnyNode", - "AnyResizePolicy", "BlendMode", "Camera", - "CameraController", "Canvas", "Color", "ColorModel", @@ -128,15 +123,11 @@ "Image", "InterpolationMode", "Layout", - "Letterbox", "Line", "Mesh", "Node", - "Orbit", - "PanZoom", "Points", "RenderMode", - "ResizePolicy", "ScalingMode", "Scene", "SymbolName", diff --git a/src/scenex/model/_canvas.py b/src/scenex/model/_canvas.py index dc824658..6ef61d9b 100644 --- a/src/scenex/model/_canvas.py +++ b/src/scenex/model/_canvas.py @@ -1,36 +1,24 @@ from __future__ import annotations -import logging from collections.abc import Sequence from typing import TYPE_CHECKING, Any, cast from cmap import Color -from pydantic import ConfigDict, Field, PrivateAttr +from pydantic import ConfigDict, Field from typing_extensions import Unpack -from scenex.app.events import ( - Event, - MouseEnterEvent, - MouseEvent, - MouseLeaveEvent, - ResizeEvent, -) from scenex.model._evented_list import EventedList from ._base import EventedBase from ._view import View # noqa: TC001 if TYPE_CHECKING: - from collections.abc import Callable, Iterable - import numpy as np from typing_extensions import TypedDict from scenex.adaptors._base import CanvasAdaptor class CanvasKwargs(TypedDict, total=False): - """TypedDict for Canvas kwargs.""" - width: int height: int background_color: Color @@ -38,9 +26,6 @@ class CanvasKwargs(TypedDict, total=False): title: str -logger = logging.getLogger(__name__) - - class Canvas(EventedBase): """A rendering surface that displays one or more views. @@ -49,6 +34,9 @@ class Canvas(EventedBase): it corresponds to a DOM element. Multiple views can be arranged on a single canvas using their layout parameters. + Canvas is a pure data model. Attach a ``CanvasInteractor`` to enable event + routing and interaction. + Examples -------- Create a simple canvas with default settings: @@ -59,6 +47,13 @@ class Canvas(EventedBase): Create a canvas with multiple views side-by-side: >>> canvas = Canvas(width=800, height=400, views=[View(), View()]) + + Attach interaction:: + + from scenex.interaction import CanvasInteractor, PanZoom + + ci = CanvasInteractor(canvas) + ci.set_controller(view, PanZoom()) """ width: int = Field(default=600, description="The width of the canvas in pixels") @@ -75,29 +70,22 @@ class Canvas(EventedBase): ) views: EventedList[View] = Field( default_factory=EventedList, - # Prevent reassigning this field - we'd lose our signal connections frozen=True, ) - # Private state for tracking mouse view transitions - _last_mouse_view: View | None = PrivateAttr(default=None) - _filter: Callable[[Event], bool] | None = PrivateAttr(default=None) - model_config = ConfigDict(extra="forbid") - # tell mypy and pyright that this takes children, just like Node if TYPE_CHECKING: def __init__( self, *, - views: Iterable[View] = (), + views: Sequence[View] = (), **data: Unpack[CanvasKwargs], ) -> None: ... def model_post_init(self, __context: Any) -> None: """Post-initialization hook for the model.""" - # Update all current views for view in self.views: view.canvas = self @@ -129,19 +117,17 @@ def content_rect_for(self, view: View) -> tuple[int, int, int, int]: offset = int(layout.padding + layout.border_width + layout.margin) return (x + offset, y + offset, w - 2 * offset, h - 2 * offset) - def _on_view_inserted(self, idx: int, view: View) -> None: - # Set canvas reference to this if it isn't set + def _on_view_inserted(self, _: int, view: View) -> None: if view.canvas is not self: view.canvas = self - def _on_view_removed(self, idx: int, view: View) -> None: - # Unset canvas reference to this if it is still set + def _on_view_removed(self, _: int, view: View) -> None: if view.canvas is self: view.canvas = None def _on_view_changed( self, - idx: int | slice, + _: int | slice, old_views: View | Sequence[View], new_views: View | Sequence[View], ) -> None: @@ -168,103 +154,7 @@ def size(self, value: tuple[int, int]) -> None: self.width, self.height = value def render(self) -> np.ndarray: - """Show the canvas.""" + """Render the canvas to a numpy array.""" if adaptors := self._get_adaptors(): return cast("CanvasAdaptor", adaptors[0])._snx_render() raise RuntimeError("No adaptor found for Canvas.") - - def set_event_filter( - self, event_filter: Callable[[Event], bool] | None - ) -> Callable[[Event], bool] | None: - """Register a callable to filter all canvas events before view dispatch. - - Parameters - ---------- - event_filter : Callable[[Event], bool] | None - A callable that takes an Event and returns True if the event was handled - and should not be propagated further, False otherwise. Pass None to remove - any existing filter. - - Returns - ------- - Callable[[Event], bool] | None - The previous event filter, or None if there was no filter. - """ - old, self._filter = self._filter, event_filter - return old - - def filter_event(self, event: Event) -> bool: - """Pass *event* through the canvas-level filter, if any. - - Returns True iff the event was handled and should not propagate. - """ - if self._filter: - handled = self._filter(event) - if not isinstance(handled, bool): - logger.warning( - f"Canvas event filter {self._filter} did not return a boolean. " - "Returning False." - ) - handled = False - return handled - return False - - def handle(self, event: Event) -> bool: - """Handle the passed event.""" - # 0. Handle events pertaining to the canvas model - if isinstance(event, ResizeEvent): - self.size = (event.width, event.height) - - # 1. Canvas-level filter sees all events first. - if self.filter_event(event): - return True - - # 2. Pass the event to the view under the mouse. - # NOTE: Currently, only mouse events have a position. Maybe other events should - # have them too? - if isinstance(event, MouseEvent): - # Find the view under the mouse, if any. - current_view = self._containing_view(event.pos) - - # If that view is different from the last view... - # TODO: Add a test for this once multiple views are better supported - if self._last_mouse_view != current_view: - # ...send a MouseLeaveEvent to the last view... - if self._last_mouse_view is not None: - self._last_mouse_view.filter_event(MouseLeaveEvent()) - # ...and a MouseEnterEvent to the new view (if the incoming event isn't - # one already). - if current_view is not None and not isinstance(event, MouseEnterEvent): - current_view.filter_event( - MouseEnterEvent(pos=event.pos, buttons=event.buttons) - ) - self._last_mouse_view = current_view - - if current_view is not None: - # 2a. Give the view under the mouse the chance to handle the event. - if current_view.filter_event(event): - return True - # 2b. If the view didn't handle the event, give any camera controller - # on the view the chance to handle it. - if current_view.camera.interactive: - if ctrl := current_view.camera.controller: - return ctrl.handle_event(event, current_view) - - # 3. MouseLeave events won't be on any view (because they have no position), - # so we need to handle them at the canvas level to clear the last_mouse_view. - elif isinstance(event, MouseLeaveEvent): - if self._last_mouse_view is not None: - handled = self._last_mouse_view.filter_event(event) - self._last_mouse_view = None - return handled - - return False - - def _containing_view(self, pos: tuple[float, float]) -> View | None: - for view in self.views: - if view.content_rect is None: - continue - x, y, w, h = view.content_rect - if x <= pos[0] <= x + w and y <= pos[1] <= y + h: - return view - return None diff --git a/src/scenex/model/_nodes/__init__.py b/src/scenex/model/_nodes/__init__.py index 85b060d9..3d46b237 100644 --- a/src/scenex/model/_nodes/__init__.py +++ b/src/scenex/model/_nodes/__init__.py @@ -67,7 +67,7 @@ """ from .node import Node # noqa: I001 must be imported first to avoid circular imports -from .camera import Camera, CameraController, Orbit, PanZoom +from .camera import Camera from .image import Image from .line import Line from .mesh import Mesh @@ -80,13 +80,10 @@ __all__ = [ "Camera", - "CameraController", "Image", "Line", "Mesh", "Node", - "Orbit", - "PanZoom", "Points", "Scene", "Text", diff --git a/src/scenex/model/_nodes/camera.py b/src/scenex/model/_nodes/camera.py index 31bec776..801e7807 100644 --- a/src/scenex/model/_nodes/camera.py +++ b/src/scenex/model/_nodes/camera.py @@ -1,28 +1,17 @@ from __future__ import annotations import math -from abc import abstractmethod -from typing import TYPE_CHECKING, Annotated, Any, Literal, Union +from typing import TYPE_CHECKING, Literal import numpy as np import pylinalg as la -from pydantic import Field, PrivateAttr +from pydantic import Field -from scenex.app.events import ( - MouseButton, - MouseEvent, - MouseMoveEvent, - MousePressEvent, - WheelEvent, -) -from scenex.model._base import EventedBase from scenex.utils import projections from .node import Node if TYPE_CHECKING: - from scenex import View - from scenex.app.events import Event from scenex.model._transform import Transform Position2D = tuple[float, float] @@ -30,10 +19,6 @@ Vector3D = tuple[float, float, float] Position = Position2D | Position3D -AnyController = Annotated[ - Union["PanZoom", "Orbit", "None"], Field(discriminator="type") -] - class Camera(Node): """A camera that defines the viewing perspective and projection for a scene. @@ -55,13 +40,7 @@ class Camera(Node): Examples -------- - Create a camera with pan-zoom controller: - >>> camera = Camera(controller=PanZoom(), interactive=True) - - Create a camera with orbit controller: - >>> camera = Camera(controller=Orbit(center=(0, 0, 0)), interactive=True) - - Position a camera and point it at a target: + Create a camera and point it at a target: >>> camera = Camera() >>> camera.transform = Transform().translated((10, 0, 0)) >>> camera.look_at((0, 0, 0), up=(0, 0, 1)) @@ -69,18 +48,17 @@ class Camera(Node): Create a perspective camera: >>> from scenex.utils.projections import perspective >>> camera = Camera(projection=perspective(fov=70, near=0.1, far=100)) + + Attach interaction via CanvasInteractor:: + + from scenex.interaction import CanvasInteractor, PanZoom + + ci = CanvasInteractor(canvas) + ci.set_controller(view, PanZoom()) """ node_type: Literal["camera"] = "camera" - controller: AnyController = Field( - default=None, - description="Describes how user interaction affects the camera", - ) - interactive: bool = Field( - default=True, - description="Whether the camera responds to user interaction events", - ) projection: Transform = Field( default_factory=lambda: projections.orthographic(2, 2, 2), description="Transformation mapping NDC to 3D rays in local space", @@ -102,17 +80,12 @@ def forward(self) -> Vector3D: @forward.setter def forward(self, arg: Vector3D) -> None: """Sets the forward direction of the camera.""" - # Check for no change - avoid divide-by-zeroes mag_old = np.linalg.norm(self.forward) mag_new = np.linalg.norm(arg) if abs(np.dot(self.forward, arg) / (mag_old * mag_new) - 1) < 1e-3: - return # No change needed - # Compute the quaternion needed to rotate from the current forward direction to - # the desired forward direction + return rot_quat = la.quat_from_vecs(self.forward, arg) rot_axis, rot_angle = la.quat_to_axis_angle(rot_quat) - - # Rotate around the camera's current position position = self.transform.map((0, 0, 0))[:3] self.transform = ( self.transform.translated(-position) @@ -127,22 +100,13 @@ def up(self) -> Vector3D: @up.setter def up(self, arg: Vector3D) -> None: - """Sets the up direction of the camera. - - Does not affect the forward direction of the camera so long as the new up - direction is perpendicular to the existing forward direction. - """ - # Check for no change - avoid divide-by-zeroes + """Sets the up direction of the camera.""" mag_old = np.linalg.norm(self.up) mag_new = np.linalg.norm(arg) if abs(np.dot(self.up, arg) / (mag_old * mag_new) - 1) < 1e-3: - return # No change needed - # Compute the quaternion needed to rotate from the current up direction to - # the desired up direction + return rot_quat = la.quat_from_vecs(self.up, arg) rot_axis, rot_angle = la.quat_to_axis_angle(rot_quat) - - # Rotate around the camera's current position position = self.transform.map((0, 0, 0))[:3] self.transform = ( self.transform.translated(-position) @@ -158,8 +122,8 @@ def look_at(self, target: Position3D, /, *, up: Vector3D | None = None) -> None: target: Position3D The position in 3D space that the camera should look at. up: Vector3D, optional - The up direction for the camera. If provided, this vector must be - perpendicular to the forward vector that results from looking at target. + The up direction for the camera. Must be perpendicular to the resulting + forward vector. """ position = self.transform.map((0, 0, 0))[:3] self.forward = tuple(target - position) @@ -169,416 +133,3 @@ def look_at(self, target: Position3D, /, *, up: Vector3D | None = None) -> None: if np.abs(np.dot(self.forward, up)) > 1e-6: raise ValueError("Up vector must be perpendicular to forward vector.") self.up = up - - -# ==================================================================================== -# Camera Controllers -# ==================================================================================== - - -class CameraController(EventedBase): - """Base class defining how a camera responds to user interaction events. - - A CameraController handles user input (mouse, keyboard, wheel) to manipulate - camera transforms and projections, enabling interactive behaviors like panning, - zooming, orbiting, or custom camera controls. Controllers are attached to Camera - instances via the `controller` field and automatically receive events when the - camera is marked as `interactive=True`. - - Event handlers should return True if they fully handled the event (stopping further - propagation) or False if other handlers should continue processing the event. - - Examples - -------- - Create a camera with pan/zoom controller: - >>> camera = Camera(controller=PanZoom(), interactive=True) - - Create a camera with orbit controller: - >>> camera = Camera(controller=Orbit(center=(0, 0, 0)), interactive=True) - - See Also - -------- - PanZoom : 2D pan and zoom controller - Orbit : 3D orbit controller - Camera : Camera class that uses controllers - """ - - @abstractmethod - def handle_event(self, event: Event, view: View) -> bool: - """ - Handle a user interaction event to control the camera. - - This method is called automatically on all events on the camera's view that were - not handled by previous handlers during scenex event processing. - - Parameters - ---------- - event : Event - The input event to handle (MouseMoveEvent, MousePressEvent, WheelEvent, - KeyPressEvent, etc.) - view : View - The view containing the camera to manipulate. - - Returns - ------- - bool - True if the event was fully handled and should not propagate to other - handlers, False if not handled or other handlers should process it. - - Notes - ----- - A ``View`` is passed rather than a ``Camera`` directly because controllers - need ``view.to_ray()`` to unproject screen-space event positions into world - space, which requires both the camera matrices and the viewport dimensions. - """ - raise NotImplementedError - - -class PanZoom(CameraController): - """2D pan and zoom controller for orthographic views. - - PanZoom provides intuitive mouse-based navigation for 2D scenes and orthographic - projections. - - The strategy operates in two complementary ways: - - **Panning** (left mouse drag): Modifies camera.transform to translate the camera - position, maintaining the scene coordinates under the cursor. - - **Zooming** (mouse wheel): Modifies camera.projection to scale the view, then - adjusts camera.transform to keep the zoom centered on the cursor position. - - Optional axis locking allows constraining interaction to horizontal or vertical - movement only - - Attributes - ---------- - lock_x : bool - If True, prevent horizontal panning and zooming. Movement is constrained to - the vertical axis only. Default is False. - lock_y : bool - If True, prevent vertical panning and zooming. Movement is constrained to - the horizontal axis only. Default is False. - - Examples - -------- - Standard 2D pan and zoom: - >>> camera = Camera(controller=PanZoom(), interactive=True) - - Lock horizontal movement for vertical scrolling only: - >>> camera = Camera(controller=PanZoom(lock_x=True), interactive=True) - - Create an image viewer with pan/zoom: - >>> import numpy as np - >>> from scenex.utils import projections - >>> my_data = np.random.rand(512, 512).astype(np.float32) - >>> view = View( - ... scene=Scene(children=[Image(data=my_data)]), - ... camera=Camera( - ... controller=PanZoom(), - ... interactive=True, - ... ), - ... ) - >>> projections.zoom_to_fit(view=view, type="orthographic") - - See Also - -------- - Orbit : 3D orbit controller for perspective views - CameraController : Base class for camera controllers - Camera : Camera class with controller field - """ - - lock_x: bool = Field( - default=False, - description="If True, prevent horizontal panning and zooming.", - ) - lock_y: bool = Field( - default=False, - description="If True, prevent vertical panning and zooming.", - ) - type: Literal["pan_zoom"] = Field(default="pan_zoom", repr=False) - - # Private state for tracking interactions - _drag_pos: tuple[float, float] | None = PrivateAttr(default=None) - - def handle_event(self, event: Event, view: View) -> bool: - """Handle mouse and wheel events to pan/zoom the camera.""" - if not view.camera.interactive: - return False - - handled = False - - if not isinstance(event, MouseEvent): - return False - if (ray := view.to_ray(event.pos)) is None: - return False - # Panning involves keeping a particular position underneath the cursor. - # That position is recorded on a left mouse button press. - if isinstance(event, MousePressEvent) and MouseButton.LEFT in event.buttons: - self._drag_pos = ray.origin[:2] - # Every time the cursor is moved, until the left mouse button is released, - # We translate the camera such that the position is back under the cursor - # (i.e. under the world ray origin) - elif ( - isinstance(event, MouseMoveEvent) - and MouseButton.LEFT in event.buttons - and self._drag_pos - ): - new_pos = ray.origin[:2] - dx = self._drag_pos[0] - new_pos[0] - if not self.lock_x: - view.camera.transform = view.camera.transform.translated((dx, 0)) - dy = self._drag_pos[1] - new_pos[1] - if not self.lock_y: - view.camera.transform = view.camera.transform.translated((0, dy)) - handled = True - - # Note that while panning adjusts the camera's transform matrix, zooming - # adjusts the projection matrix. - elif isinstance(event, WheelEvent): - # Zoom while keeping the position under the cursor fixed. - _dx, dy = event.angle_delta - if dy: - # Step 1: Adjust the projection matrix to zoom in or out. - zoom = self._zoom_factor(dy) - view.camera.projection = view.camera.projection.scaled( - (1 if self.lock_x else zoom, 1 if self.lock_y else zoom, 1.0) - ) - - # Step 2: Adjust the transform matrix to maintain the position - # under the cursor. The math is largely borrowed from - # https://github.com/pygfx/pygfx/blob/520af2d5bb2038ec309ef645e4a60d502f00d181/pygfx/controllers/_panzoom.py#L164 - - # Find the distance between the world ray and the camera - zoom_center = np.asarray(ray.origin)[:2] - camera_center = np.asarray(view.camera.transform.map((0, 0)))[:2] - # Compute the world distance before the zoom - delta_screen1 = zoom_center - camera_center - # Compute the world distance after the zoom - delta_screen2 = delta_screen1 * zoom - # The pan is the difference between the two - pan = (delta_screen2 - delta_screen1) / zoom - view.camera.transform = view.camera.transform.translated( - ( - pan[0] if not self.lock_x else 0, - pan[1] if not self.lock_y else 0, - ) - ) - handled = True - - return handled - - def _zoom_factor(self, delta: float) -> float: - # Magnifier stolen from pygfx - return 2 ** (delta * 0.001) - - -class Orbit(CameraController): - """3D orbit controller for rotating around a focal point. - - Orbit provides intuitive 3D navigation for perspective views by allowing the camera - to rotate around a fixed center point while maintaining its distance. - - The strategy uses spherical coordinates to control camera position: - - **Azimuth**: Rotation around the polar axis (typically Z), controlling left/right - movement around the scene - - **Elevation**: Angle from the polar axis, controlling up/down viewing angle - - **Distance**: Radius from the center point, controlled by zooming - - During rotation, foreground objects (between the camera and the center) move in the - direction of mouse movement, providing intuitive control where the visible scene - appears to rotate under the mouse. - - The right mouse button allows panning the orbit center itself, enabling exploration - of large scenes by moving the focal point while maintaining the camera's viewing - angle and distance. - - Attributes - ---------- - center : tuple[float, float, float] - The point in 3D space around which the camera orbits. This is the focal point - that remains stationary during rotation. Default is (0, 0, 0). - polar_axis : tuple[float, float, float] - The axis defining the "up" direction for orbit calculations. Azimuth rotations - occur around this axis. Default is (0, 0, 1) for Z-up orientation. - - Examples - -------- - Orbit around the origin: - >>> from scenex.utils import projections - >>> # Create a perspective camera... - >>> camera = Camera( - ... interactive=True, - ... projection=projections.perspective(fov=70, near=1, far=1000), - ... ) - >>> # ...positioned along the X axis... - >>> camera.transform = Transform().translated((100, 0, 0)) - >>> # ...looking at the origin... - >>> camera.look_at((0, 0, 0), up=(0, 0, 1)) - >>> # ...that orbits around the origin - >>> camera.controller = Orbit(center=(0, 0, 0)) - - Orbit around a data volume's center: - >>> import numpy as np - >>> my_data = np.random.rand(100, 100, 100).astype(np.float32) - >>> volume = Volume(data=my_data) - >>> center = np.mean(volume.bounding_box, axis=0) - >>> # Create a perspective camera... - >>> camera = Camera( - ... interactive=True, - ... projection=projections.perspective(fov=70, near=1, far=1000), - ... ) - >>> # ...positioned along the X axis from the volume center... - >>> camera.transform = Transform().translated(center).translated((100, 0, 0)) - >>> # ...looking at the center... - >>> camera.look_at(center, up=(0, 0, 1)) - >>> # ...that orbits around the center - >>> camera.controller = Orbit(center=center) - - Custom polar axis for Y-up scenes: - >>> camera = Camera( - ... controller=Orbit(center=(0, 0, 0), polar_axis=(0, 1, 0)), - ... interactive=True, - ... ) - - Interactions - ------------ - - **Left mouse drag**: Orbit/rotate the camera around the center point - - **Right mouse drag**: Pan the orbit center (translates the focal point) - - **Mouse wheel**: Zoom toward/away from center (change radius) - - Notes - ----- - Elevation is automatically clamped to [0°, 180°] to prevent the camera from - going upside down. Without this clamping, the camera could rotate past the - polar axis, causing horizontal mouse movement to make the foreground rotate - in the opposite direction to the actual mouse movement. - - See Also - -------- - PanZoom : 2D pan and zoom controller for orthographic views - CameraController : Base class for camera controllers - Camera : Camera class with controller field - Camera.look_at : Method to orient camera toward a point - """ - - center: tuple[float, float, float] = Field( - default=(0.0, 0.0, 0.0), - description="The point in 3D space around which the camera orbits.", - ) - polar_axis: tuple[float, float, float] = Field( - default=(0.0, 0.0, 1.0), - description='The axis defining the "up" direction for orbit calculations.', - ) - type: Literal["orbit"] = Field(default="orbit", repr=False) - - # Private state for tracking interactions - _last_canvas_pos: tuple[float, float] | None = PrivateAttr(default=None) - _pan_ray: Any = PrivateAttr(default=None) # Ray type - - def handle_event(self, event: Event, view: View) -> bool: - """Handle mouse and wheel events to orbit the camera.""" - if not view.camera.interactive: - return False - - handled = False - center_array = np.asarray(self.center) - - if not isinstance(event, MouseEvent): - return False - if (ray := view.to_ray(event.pos)) is None: - return False - # Orbit on mouse move with left button held - if ( - isinstance(event, MouseMoveEvent) - and event.buttons == MouseButton.LEFT - and self._last_canvas_pos is not None - ): - # The process of orbiting is as follows: - # 1. Compute the azimuth and elevation changes based on mouse movement. - # - Azimuth describes the angle between the the positive X axis and - # the projection of the camera's position onto the XY plane. - # - Elevation describes the angle between the camera's position and - # the positive Z axis. - # 2. Ensure these changes are clamped to valid ranges (only really - # applies to elevation). - # 3. Adjust the current transform by: - # a. Translating by the negative of the centerpoint, to take it out of - # the computation. - # b. Rotating to adjust the elevation. The axis of rotation is defined - # by the camera's right vector. Note that this is done before the - # azimuth adjustment because that adjustment will alter the - # camera's right vector. - # c. Rotating to adjust the azimuth. The axis of rotation is always - # the positive Z axis. - # d. Translating by the centerpoint, to reorient the camera around - # that centerpoint. - - # Step 0: Gather transform components, relative to camera center - orbit_mat = view.camera.transform.translated(-center_array) - position, _rotation, _scale = la.mat_decompose(orbit_mat.T) - camera_right = np.cross(view.camera.forward, view.camera.up) - - # Step 1 - d_azimuth = self._last_canvas_pos[0] - event.pos[0] - d_elevation = self._last_canvas_pos[1] - event.pos[1] - - # Step 2 - e_bound = float(la.vec_angle(position, (0, 0, 1)) * 180 / math.pi) - if e_bound + d_elevation < 0: - d_elevation = -e_bound - if e_bound + d_elevation > 180: - d_elevation = 180 - e_bound - - # Step 3 - view.camera.transform = ( - view.camera.transform.translated(-center_array) # 3a - .rotated(d_elevation, camera_right) # 3b - .rotated(d_azimuth, self.polar_axis) # 3c - .translated(center_array) # 3d - ) - - handled = True - - # Pan on mouse press with right button - elif isinstance(event, MousePressEvent) and event.buttons == MouseButton.RIGHT: - self._pan_ray = ray - - # Pan on mouse move with right button held - elif ( - isinstance(event, MouseMoveEvent) - and event.buttons == MouseButton.RIGHT - and self._pan_ray is not None - ): - dr = np.linalg.norm(view.camera.transform.map((0, 0, 0))[:3] - center_array) - old_center = self._pan_ray.origin[:3] + np.multiply( - dr, self._pan_ray.direction - ) - new_center = ray.origin[:3] + np.multiply(dr, ray.direction) - diff = np.subtract(old_center, new_center) - view.camera.transform = view.camera.transform.translated(diff) - # Update the center - new_center_array = center_array + diff - new_center_tuple = ( - float(new_center_array[0]), - float(new_center_array[1]), - float(new_center_array[2]), - ) - self.center = new_center_tuple - handled = True - - elif isinstance(event, WheelEvent): - _dx, dy = event.angle_delta - if dy: - dr = view.camera.transform.map((0, 0, 0))[:3] - center_array - zoom = self._zoom_factor(dy) - view.camera.transform = view.camera.transform.translated( - dr * (zoom - 1) - ) - handled = True - - if isinstance(event, MouseEvent): - self._last_canvas_pos = event.pos - return handled - - def _zoom_factor(self, delta: float) -> float: - # Magnifier stolen from pygfx - return 2 ** (-delta * 0.001) diff --git a/src/scenex/model/_view.py b/src/scenex/model/_view.py index 56f1c7c2..5fc8860a 100644 --- a/src/scenex/model/_view.py +++ b/src/scenex/model/_view.py @@ -1,10 +1,9 @@ -"""View model and resize strategies.""" +"""View model — a rectangular viewport displaying a scene through a camera.""" from __future__ import annotations import logging -from abc import abstractmethod -from typing import TYPE_CHECKING, Annotated, Any, Literal, Union, cast +from typing import TYPE_CHECKING, Any, cast import numpy as np import pylinalg as la @@ -18,18 +17,12 @@ from ._nodes.scene import Scene if TYPE_CHECKING: - from collections.abc import Callable - - from scenex import Transform from scenex.adaptors._base import ViewAdaptor - from scenex.app.events import Event from ._canvas import Canvas logger = logging.getLogger(__name__) -AnyResizePolicy = Annotated[Union["Letterbox", "None"], Field(discriminator="type")] - class View(EventedBase): """A rectangular viewport that displays a scene through a camera. @@ -48,16 +41,17 @@ class View(EventedBase): >>> scene = Scene(children=[Image(data=my_array)]) >>> view = View(scene=scene, camera=Camera()) - Create a view with interactive camera and letterbox resizing: - >>> view = View( - ... scene=scene, - ... camera=Camera(controller=PanZoom(), interactive=True), - ... on_resize=Letterbox(), - ... ) - Add a view to a canvas: >>> canvas = Canvas() >>> canvas.views.append(view) + + Attach interaction via CanvasInteractor:: + + from scenex.interaction import CanvasInteractor, PanZoom, Letterbox + + ci = CanvasInteractor(canvas) + ci.set_controller(view, PanZoom()) + ci.set_resize_policy(view, Letterbox()) """ scene: Scene = Field( @@ -68,10 +62,6 @@ class View(EventedBase): default_factory=Camera, description="The camera defining the viewing perspective and projection", ) - on_resize: AnyResizePolicy = Field( - default=None, - description="Policy for adjusting camera projection when the view is resized", - ) layout: Layout = Field( default_factory=Layout, frozen=True, @@ -81,27 +71,14 @@ class View(EventedBase): default=True, description="Whether the view is visible and should be rendered" ) - # Backreference to the canvas displaying this view. Used to make the View size - # concrete for canvas intersection and resizing policy computations. - # This variable should not be set directly; use the canvas property instead to - # ensure proper event connections. + # Backreference to the canvas displaying this view. Should not be set directly; + # use the canvas property to ensure proper event connections. _canvas: Canvas | None = PrivateAttr(None) def model_post_init(self, __context: Any) -> None: """Post-initialization hook for the model.""" super().model_post_init(__context) self.camera.parent = self.scene - # It is vital that whenever the view size changes, we allow the ResizePolicy to - # respond. That size can change when (a) the layout changes, or (b) the canvas - # resizes. We listen to (a) here and (b) in the canvas setter. - self.layout.events.x_start.connect(self._on_size_change) - self.layout.events.x_end.connect(self._on_size_change) - self.layout.events.y_start.connect(self._on_size_change) - self.layout.events.y_end.connect(self._on_size_change) - - def _on_size_change(self, *args: Any) -> None: - if on_resize := self.on_resize: - on_resize.handle_resize(self) @property def canvas(self) -> Canvas | None: @@ -112,17 +89,11 @@ def canvas(self) -> Canvas | None: def canvas(self, value: Canvas | None) -> None: old, self._canvas = self._canvas, value - # Disconnect old canvas events if old: - old.events.width.disconnect(self._on_size_change) - old.events.height.disconnect(self._on_size_change) if self in old.views: old.views.remove(self) - # Connect new canvas events if self._canvas: - self._canvas.events.width.connect(self._on_size_change) - self._canvas.events.height.connect(self._on_size_change) if self not in self._canvas.views: self._canvas.views.append(self) @@ -167,25 +138,15 @@ def to_ray(self, canvas_pos: tuple[float, float]) -> Ray | None: ------- Ray | None The world-space Ray, or None if this view has no canvas. - - Notes - ----- - If ``canvas_pos`` falls outside this view's rectangle, a Ray is still - returned — it simply points outside the visible frustum. Callers that - need to restrict to within-bounds positions should check - ``view.content_rect`` before calling this method. """ - # We need this view to be on a canvas to make sense of the canvas position. if self._canvas is None: logger.warning( "to_ray() called on a View not attached to a Canvas. " "Canvas coordinates have no meaning without a canvas." ) return None - # Convert canvas position to view position x, y = self._canvas.content_rect_for(self)[:2] view_pos = (canvas_pos[0] - x, canvas_pos[1] - y) - # Convert view position to NDC ndc = self._to_ndc(view_pos) if ndc is None: return None @@ -205,214 +166,3 @@ def render(self) -> np.ndarray: if adaptors := self._get_adaptors(): return cast("ViewAdaptor", adaptors[0])._snx_render() raise RuntimeError("No adaptor found for View.") - - _filter: Callable[[Event], bool] | None = PrivateAttr(default=None) - - def set_event_filter( - self, callable: Callable[[Event], bool] | None - ) -> Callable[[Event], bool] | None: - """ - Registers a callable to filter events. - - Parameters - ---------- - callable : Callable[[Event], bool] | None - A callable that takes an Event and returns True if the event was handled, - False otherwise. Passing None is equivalent to removing any existing filter. - By returning True, the callable indicates that the event has been handled - and should not be propagated to subsequent handlers. - - Returns - ------- - Callable[[Event], bool] | None - The previous event filter, or None if there was no filter. - - Note the name has parity with Node.filter_event, but there's not much filtering - going on. - """ - old, self._filter = self._filter, callable - return old - - def filter_event(self, event: Event) -> bool: - """ - Filters the event. - - This method allows the larger view to react to events that: - 1. Require summarization of multiple smaller event responses. - 2. Could not be picked up by a node (e.g. mouse leaving an image). - - Note the name has parity with Node.filter_event, but there's not much filtering - going on. - - Parameters - ---------- - event : Event - An event occurring in the view. - - Returns - ------- - bool - True iff the event should not be propagated to other handlers. - """ - if self._filter: - handled = self._filter(event) - if not isinstance(handled, bool): - # Some widget frameworks (i.e. Qt) get upset when non-booleans are - # returned. If the event-filter does not return a boolean, rather than - # letting that propagate upwards, we log a warning and return False. - logger.warning( - f"Event filter {self._filter} did not return a boolean. " - "Returning False." - ) - # Return False. We assume that if the user wanted to block future - # processing, they'd be less likely to forget a boolean return. - # Further, allowing downstream processing is a clear sign to they author - # that they forgot to block propagation. - handled = False - return handled - return False - - -# ==================================================================================== -# Resize Strategies -# ==================================================================================== - - -class ResizePolicy(EventedBase): - """Base class defining how a view adapts to changes in its layout dimensions. - - A ResizePolicy is invoked automatically when a view's layout dimensions change, - providing a hook to adjust any aspect of the view in response. While the most - common use case is adjusting the camera's projection matrix to maintain aspect - ratio or fit content, strategies have full access to the view and can modify the - camera, scene, layout, or any other properties as needed. - - Strategies are attached to View instances and called whenever the layout width - or height changes, whether from user interaction (window resize, splitter drag) - or programmatic updates. - - Examples - -------- - Maintain aspect ratio when view resizes: - >>> view = View(camera=Camera(), on_resize=Letterbox()) - - No resize behavior (omit the on_resize parameter): - >>> view = View(camera=Camera()) - - See Also - -------- - Letterbox : Resize strategy that maintains aspect ratio - View : View class that uses resize strategies - Camera : Camera class with projection property - """ - - @abstractmethod - def handle_resize(self, view: View) -> None: - """ - Respond to view layout dimension changes. - - This method is called automatically when the view's layout dimensions change. - Implementations have full access to the view and can modify any of its - properties. - - Parameters - ---------- - view : View - The view being resized. - """ - raise NotImplementedError - - -class Letterbox(ResizePolicy): - """Maintain content aspect ratio on resize via letterboxing/pillarboxing. - - The Letterbox strategy preserves the original aspect ratio of the camera's - projection when the view is resized. When the view's aspect ratio differs from - the content's aspect ratio, the projection is expanded in the narrower dimension - to ensure all original content remains visible with black bars (letterboxing for - wide views, pillarboxing for tall views). - - The strategy tracks resize sequences (e.g., dragging a window corner) by storing - the camera's projection as a reference at the start of that sequence. At any point - during the sequence, the projection matrix is expanded in either width or height to - retain the rectangle of that reference projection. A new sequence is defined by a - change in the projection matrix, either programmatically made or through user input, - signalled by a camera projection matrix different from that set during the last - resize operation. - - Examples - -------- - Create a view with letterbox resizing: - >>> from scenex.utils.projections import orthographic - >>> view = View( - ... camera=Camera(projection=orthographic(100, 100, 100)), - ... on_resize=Letterbox(), - ... ) - - When view is resized to 200x100 pixels, the projection expands horizontally - to maintain the 1:1 aspect ratio, showing more content on the sides rather - than stretching the image. - - Notes - ----- - This approach follows the conventions of vispy's PanZoomCamera and pygfx's - PerspectiveCamera. The projection matrix scales are inversely proportional to - the displayed region: smaller scale values show more content. - - See Also - -------- - ResizePolicy : Base class for resize policies - View : View class that uses resize strategies - Camera : Camera class with projection property - """ - - # Consider the context of a sequence of resizes (i.e. the user is clicking and - # dragging the window corner). - # This is the transform at the beginning of the resize sequence... - _reference: Transform | None = PrivateAttr(default=None) - # ...and this is the transform we applied in response to the last resize event. - _last_adjustment: Transform | None = PrivateAttr(default=None) - - type: Literal["letterbox"] = Field(default="letterbox", repr=False) - - def handle_resize(self, view: View) -> None: - """Handle view resize by adjusting projection to maintain aspect ratio.""" - # If the current projection differs from the last adjustment, or if there is no - # reference to begin with, this is a new resize sequence. - if view.camera.projection != self._last_adjustment or self._reference is None: - self._reference = view.camera.projection - - if (view_rect := view.rect) is None or self._reference is None: - # Nothing to do. - return - _, _, view_width, view_height = view_rect - if view_height == 0: - return - - # Extract projection scales that define the content aspect ratio - ref_mat = self._reference.root - ref_x_scale = ref_mat[0, 0] - ref_y_scale = ref_mat[1, 1] - if ref_y_scale == 0: - return - - # Compute aspect ratios - # NOTE: projection scales are inversely proportional to the displayed region, - # so content_aspect = y_scale / x_scale - view_aspect = view_width / view_height - content_aspect = abs(ref_y_scale / ref_x_scale) - - # Expand the narrower dimension to match the view aspect - if content_aspect < view_aspect: - # View is wider: expand horizontal frustum (reduce x scale) - adjusted_proj = self._reference.scaled( - (content_aspect / view_aspect, 1.0, 1.0) - ) - else: - # View is taller: expand vertical frustum (reduce y scale) - adjusted_proj = self._reference.scaled( - (1.0, view_aspect / content_aspect, 1.0) - ) - - # Store the adjustment before applying it - view.camera.projection = self._last_adjustment = adjusted_proj diff --git a/tests/app/test_qt.py b/tests/app/test_qt.py index cc7417f0..5bba4413 100644 --- a/tests/app/test_qt.py +++ b/tests/app/test_qt.py @@ -44,7 +44,7 @@ @pytest.fixture def evented_canvas(qtbot: QtBot) -> snx.Canvas: - camera = snx.Camera(transform=Transform(), interactive=True) + camera = snx.Camera(transform=Transform()) scene = snx.Scene(children=[]) view = snx.View(scene=scene, camera=camera) canvas = snx.Canvas(views=[view]) @@ -55,11 +55,18 @@ def evented_canvas(qtbot: QtBot) -> snx.Canvas: return canvas -def test_mouse_press(evented_canvas: snx.Canvas, qtbot: QtBot) -> None: +@pytest.fixture +def ci(evented_canvas: snx.Canvas) -> snx.CanvasInteractor: + return snx.CanvasInteractor(evented_canvas) + + +def test_mouse_press( + evented_canvas: snx.Canvas, ci: snx.CanvasInteractor, qtbot: QtBot +) -> None: adaptor = evented_canvas._get_adaptors(create=True)[0] native = cast("CanvasAdaptor", adaptor)._snx_get_native() mock_filter = MagicMock(return_value=False) - evented_canvas.set_event_filter(mock_filter) + ci.set_event_filter(mock_filter) press_point = (5, 10) # Press the left button @@ -76,11 +83,13 @@ def test_mouse_press(evented_canvas: snx.Canvas, qtbot: QtBot) -> None: ) -def test_mouse_release(evented_canvas: snx.Canvas, qtbot: QtBot) -> None: +def test_mouse_release( + evented_canvas: snx.Canvas, ci: snx.CanvasInteractor, qtbot: QtBot +) -> None: adaptor = evented_canvas._get_adaptors(create=True)[0] native = cast("CanvasAdaptor", adaptor)._snx_get_native() mock_filter = MagicMock(return_value=False) - evented_canvas.set_event_filter(mock_filter) + ci.set_event_filter(mock_filter) press_point = (5, 10) qtbot.mouseRelease(native, Qt.MouseButton.LeftButton, pos=QPoint(*press_point)) @@ -89,11 +98,13 @@ def test_mouse_release(evented_canvas: snx.Canvas, qtbot: QtBot) -> None: ) -def test_mouse_move(evented_canvas: snx.Canvas, qtbot: QtBot) -> None: +def test_mouse_move( + evented_canvas: snx.Canvas, ci: snx.CanvasInteractor, qtbot: QtBot +) -> None: adaptor = evented_canvas._get_adaptors(create=True)[0] native = cast("CanvasAdaptor", adaptor)._snx_get_native() mock_filter = MagicMock(return_value=False) - evented_canvas.set_event_filter(mock_filter) + ci.set_event_filter(mock_filter) press_point = (5, 10) # FIXME: For some reason the mouse press is necessary for processing events? @@ -106,11 +117,13 @@ def test_mouse_move(evented_canvas: snx.Canvas, qtbot: QtBot) -> None: ) -def test_mouse_click(evented_canvas: snx.Canvas, qtbot: QtBot) -> None: +def test_mouse_click( + evented_canvas: snx.Canvas, ci: snx.CanvasInteractor, qtbot: QtBot +) -> None: adaptor = evented_canvas._get_adaptors(create=True)[0] native = cast("CanvasAdaptor", adaptor)._snx_get_native() mock_filter = MagicMock(return_value=False) - evented_canvas.set_event_filter(mock_filter) + ci.set_event_filter(mock_filter) press_point = (5, 10) qtbot.mouseClick(native, Qt.MouseButton.LeftButton, pos=QPoint(*press_point)) @@ -123,11 +136,13 @@ def test_mouse_click(evented_canvas: snx.Canvas, qtbot: QtBot) -> None: ) -def test_mouse_double_click(evented_canvas: snx.Canvas, qtbot: QtBot) -> None: +def test_mouse_double_click( + evented_canvas: snx.Canvas, ci: snx.CanvasInteractor, qtbot: QtBot +) -> None: adaptor = evented_canvas._get_adaptors(create=True)[0] native = cast("CanvasAdaptor", adaptor)._snx_get_native() mock_filter = MagicMock(return_value=False) - evented_canvas.set_event_filter(mock_filter) + ci.set_event_filter(mock_filter) press_point = (5, 10) # Note that in Qt a double click does NOT implicitly imply a release as well. @@ -153,12 +168,14 @@ def test_resize(evented_canvas: snx.Canvas, qtbot: QtBot) -> None: assert evented_canvas.height == new_size[1] -def test_mouse_enter(evented_canvas: snx.Canvas, qtbot: QtBot) -> None: +def test_mouse_enter( + evented_canvas: snx.Canvas, ci: snx.CanvasInteractor, qtbot: QtBot +) -> None: adaptor = evented_canvas._get_adaptors(create=True)[0] native = cast("CanvasAdaptor", adaptor)._snx_get_native() qtbot.add_widget(native) mock_filter = MagicMock(return_value=False) - evented_canvas.set_event_filter(mock_filter) + ci.set_event_filter(mock_filter) # Simulate mouse enter event by posting to event queue # Note that qtbot does not have a method for this @@ -173,18 +190,20 @@ def test_mouse_enter(evented_canvas: snx.Canvas, qtbot: QtBot) -> None: qapp.postEvent(native, enter_event) qapp.processEvents() - # Verify MouseEnterEvent was passed to Canvas.handle + # Verify MouseEnterEvent was passed through the pipeline mock_filter.assert_called_once_with( MouseEnterEvent(pos=enter_point, buttons=MouseButton.NONE) ) -def test_mouse_leave(evented_canvas: snx.Canvas, qtbot: QtBot) -> None: +def test_mouse_leave( + evented_canvas: snx.Canvas, ci: snx.CanvasInteractor, qtbot: QtBot +) -> None: adaptor = evented_canvas._get_adaptors(create=True)[0] native = cast("CanvasAdaptor", adaptor)._snx_get_native() qtbot.add_widget(native) mock_filter = MagicMock(return_value=False) - evented_canvas.set_event_filter(mock_filter) + ci.set_event_filter(mock_filter) enter_point = (10, 15) enter_event = QEnterEvent( @@ -203,19 +222,21 @@ def test_mouse_leave(evented_canvas: snx.Canvas, qtbot: QtBot) -> None: qapp.processEvents() qtbot.wait(10) - # Verify MouseLeaveEvent was passed to Canvas.handle + # Verify MouseLeaveEvent was passed through the pipeline mock_filter.assert_called_once_with(MouseLeaveEvent()) # FIXME: This test is vulnerable to segfaults on CI # (somehow a partially deleted QMouseEvent is being processed during qtbot.keyPress) @pytest.mark.skipif(os.getenv("CI") == "true", reason="Skipped on CI") -def test_key_event(evented_canvas: snx.Canvas, qtbot: QtBot) -> None: +def test_key_event( + evented_canvas: snx.Canvas, ci: snx.CanvasInteractor, qtbot: QtBot +) -> None: adaptor = evented_canvas._get_adaptors(create=True)[0] native = cast("CanvasAdaptor", adaptor)._snx_get_native() qtbot.add_widget(native) mock_filter = MagicMock(return_value=False) - evented_canvas.set_event_filter(mock_filter) + ci.set_event_filter(mock_filter) qtbot.keyPress(native, Qt.Key.Key_A) qtbot.keyRelease(native, Qt.Key.Key_A) diff --git a/tests/model/_nodes/test_camera.py b/tests/model/_nodes/test_camera.py index ccff1f7a..9435df68 100644 --- a/tests/model/_nodes/test_camera.py +++ b/tests/model/_nodes/test_camera.py @@ -89,7 +89,7 @@ def ortho_view() -> Generator[snx.View, None, None]: def test_panzoom_pan(ortho_view: snx.View) -> None: """Tests panning behavior of PanZoom.""" - interaction = ortho_view.camera.controller = snx.PanZoom() + interaction = snx.PanZoom() # Simulate mouse press at canvas (0, 0), world (-50, 50) press_event = MousePressEvent( pos=(0, 0), @@ -110,7 +110,7 @@ def test_panzoom_pan(ortho_view: snx.View) -> None: def test_panzoom_zoom(ortho_view: snx.View) -> None: """Tests zooming behavior of PanZoom.""" - interaction = ortho_view.camera.controller = snx.PanZoom() + interaction = snx.PanZoom() # Simulate wheel event wheel_event = WheelEvent( pos=(0, 0), @@ -129,7 +129,7 @@ def test_orbit_orbiting() -> None: """Tests orbiting behavior of Orbit.""" # Camera is along the x axis, looking in the negative x direction at the center interaction = snx.Orbit(center=(0, 0, 0)) - cam = snx.Camera(interactive=True, controller=interaction) + cam = snx.Camera() # Add cam to the canvas view = snx.View(camera=cam) canvas = snx.Canvas(views=[view]) @@ -176,11 +176,7 @@ def test_orbit_orbiting() -> None: def test_orbit_zoom() -> None: center = (0.0, 0.0, 0.0) interaction = snx.Orbit(center=center) - cam = snx.Camera( - interactive=True, - transform=snx.Transform().translated((0, 0, 10)), - controller=interaction, - ) + cam = snx.Camera(transform=snx.Transform().translated((0, 0, 10))) # Add cam to the canvas view = snx.View(camera=cam) canvas = snx.Canvas(views=[view]) # noqa: F841 @@ -205,15 +201,13 @@ def test_orbit_zoom() -> None: ) interaction.handle_event(wheel_event, view) # The camera should have moved back to the starting point - zoom = interaction._zoom_factor(-120) - desired_tform = snx.Transform().translated((0, 0, 10)) np.testing.assert_allclose(cam.transform, tform_before) def test_orbit_pan() -> None: # Camera is along the x axis, looking in the negative x direction at the center interaction = snx.Orbit(center=(0, 0, 0)) - cam = snx.Camera(interactive=True, controller=interaction) + cam = snx.Camera() # Add cam to the canvas view = snx.View(camera=cam) canvas = snx.Canvas(views=[view]) @@ -260,26 +254,18 @@ def test_orbit_pan() -> None: def test_panzoom_serialization() -> None: - cam = snx.Camera( - controller=snx.PanZoom(), - interactive=True, - transform=snx.Transform().translated((10, 20, 30)).scaled((2, 2, 2)), - ) - json = cam.model_dump_json() - cam2 = snx.Camera.model_validate_json(json) - assert isinstance(cam2.controller, snx.PanZoom) + pz = snx.PanZoom() + json = pz.model_dump_json() + pz2 = snx.PanZoom.model_validate_json(json) + assert isinstance(pz2, snx.PanZoom) def test_orbit_serialization() -> None: center = (5, 5, 10) polar_axis = (1, 0, 0) - cam = snx.Camera( - controller=snx.Orbit(center=center, polar_axis=polar_axis), - interactive=True, - transform=snx.Transform().translated((10, 20, 30)).scaled((2, 2, 2)), - ) - json = cam.model_dump_json() - cam2 = snx.Camera.model_validate_json(json) - assert isinstance(cam2.controller, snx.Orbit) - assert cam2.controller.center == center - assert cam2.controller.polar_axis == polar_axis + orbit = snx.Orbit(center=center, polar_axis=polar_axis) + json = orbit.model_dump_json() + orbit2 = snx.Orbit.model_validate_json(json) + assert isinstance(orbit2, snx.Orbit) + assert orbit2.center == center + assert orbit2.polar_axis == polar_axis diff --git a/tests/model/test_canvas.py b/tests/model/test_canvas.py index c2b464c2..11fd2ffb 100644 --- a/tests/model/test_canvas.py +++ b/tests/model/test_canvas.py @@ -42,15 +42,17 @@ def test_event_filter() -> None: """Tests the ability to set a canvas-level event filter.""" view = snx.View() view_filter = Mock() - view.set_event_filter(view_filter) - canvas = snx.Canvas(views=[view]) + ci = snx.CanvasInteractor(canvas) + ci.set_view_filter(view, view_filter) + canvas_filter = Mock() canvas_filter.return_value = False - canvas.set_event_filter(canvas_filter) + ci.set_event_filter(canvas_filter) + # Ensure that the canvas can receive events evt = MouseMoveEvent(pos=(0, 0), buttons=MouseButton.NONE) - canvas.handle(evt) + ci.handle(evt) canvas_filter.assert_called_with(evt) view_filter.assert_called_with(evt) @@ -59,7 +61,7 @@ def test_event_filter() -> None: canvas_filter.reset_mock() canvas_filter.return_value = True - canvas.handle(evt) + ci.handle(evt) canvas_filter.assert_called_with(evt) view_filter.assert_not_called() @@ -76,24 +78,25 @@ def test_handle_view_events() -> None: view2 = snx.View() # Right half view2.layout.x = "50%", "100%" canvas = snx.Canvas(views=[view1, view2]) + ci = snx.CanvasInteractor(canvas) mock_filter = Mock() - view1.set_event_filter(mock_filter) + ci.set_view_filter(view1, mock_filter) # Assert MouseEnterEvents are directed to the correct view evt = MouseEnterEvent(pos=(0, 0), buttons=MouseButton.NONE) - canvas.handle(evt) + ci.handle(evt) mock_filter.assert_called_once_with(evt) mock_filter.reset_mock() # Assert MouseLeaveEvents are directed to the correct view evt = MouseLeaveEvent() - canvas.handle(evt) + ci.handle(evt) mock_filter.assert_called_once_with(evt) mock_filter.reset_mock() # Assert MouseEnterEvents are generated if another event type is sent to a new view evt = MouseMoveEvent(pos=(2, 0), buttons=MouseButton.NONE) - canvas.handle(evt) + ci.handle(evt) assert mock_filter.call_count == 2 assert mock_filter.call_args_list[0] == call( MouseEnterEvent(pos=evt.pos, buttons=MouseButton.NONE) @@ -103,9 +106,9 @@ def test_handle_view_events() -> None: # Assert MouseEnterEvents are generated when moving between views mock_filter2 = Mock() - view2.set_event_filter(mock_filter2) + ci.set_view_filter(view2, mock_filter2) evt = MouseMoveEvent(pos=(canvas.width - 1, 0), buttons=MouseButton.NONE) - canvas.handle(evt) + ci.handle(evt) mock_filter.assert_called_once_with(MouseLeaveEvent()) assert mock_filter2.call_count == 2 assert mock_filter2.call_args_list[0] == call( diff --git a/tests/model/test_view.py b/tests/model/test_view.py index 9175343f..60faa70a 100644 --- a/tests/model/test_view.py +++ b/tests/model/test_view.py @@ -16,7 +16,6 @@ def test_to_ray() -> None: camera = snx.Camera( transform=snx.Transform(), projection=projections.orthographic(2, 2, 2), - interactive=True, ) view = snx.View(scene=snx.Scene(children=[]), camera=camera) canvas = snx.Canvas(views=[view]) @@ -47,7 +46,6 @@ def test_to_ray_layout() -> None: camera = snx.Camera( transform=snx.Transform(), projection=projections.orthographic(2, 2, 2), - interactive=True, ) layout = snx.Layout(margin=10) view = snx.View(scene=snx.Scene(children=[]), camera=camera, layout=layout) @@ -63,7 +61,6 @@ def test_to_ray_translated() -> None: camera = snx.Camera( transform=snx.Transform().translated((1, 1, 1)), projection=projections.orthographic(2, 2, 2), - interactive=True, ) view = snx.View(scene=snx.Scene(children=[]), camera=camera) # NOTE: we need a canvas to convert to a ray. @@ -90,7 +87,6 @@ def test_to_ray_projection() -> None: camera = snx.Camera( transform=snx.Transform(), projection=projections.orthographic(1, 1, 1), - interactive=True, ) view = snx.View(scene=snx.Scene(children=[]), camera=camera) # NOTE: we need a canvas to convert to a ray. @@ -107,7 +103,6 @@ def test_events() -> None: view = snx.View(scene=snx.Scene(children=[img])) view_filter = MagicMock() view_filter.return_value = False - view.set_event_filter(view_filter) # Set up the camera such that the image is in the top right quadrant view.camera.transform = snx.Transform().translated((-0.5, -0.5)) @@ -115,6 +110,8 @@ def test_events() -> None: # Put it on a canvas canvas = snx.Canvas(views=[view]) + ci = snx.CanvasInteractor(canvas) + ci.set_view_filter(view, view_filter) _, _, w, _h = canvas.rect_for(view) # Mouse over that image in the top right corner @@ -124,7 +121,7 @@ def test_events() -> None: event = MouseMoveEvent(pos=canvas_pos, buttons=MouseButton.NONE) # And show the view saw the event - canvas.handle(event) + ci.handle(event) # NOTE that there will also be a MouseEnterEvent assert view_filter.call_count == 2 enter_event = MouseEnterEvent(pos=canvas_pos, buttons=MouseButton.NONE) @@ -137,24 +134,24 @@ def test_filter_returning_None() -> None: """Some widget backends (e.g. Qt) get upset when non-booleans are returned. This test ensures that if a faulty event filter is set that returns None, - the event is treated as handled (i.e. True is returned). + the handle call does not raise and returns a bool. """ view = snx.View() def faulty_filter(event: Event) -> bool: return None # type: ignore[return-value] - view.set_event_filter(faulty_filter) canvas = snx.Canvas(views=[view]) + ci = snx.CanvasInteractor(canvas) + ci.set_view_filter(view, faulty_filter) canvas_pos = (canvas.width // 2, canvas.height // 2) world_ray = view.to_ray(canvas_pos) assert world_ray is not None event = MouseMoveEvent(pos=canvas_pos, buttons=MouseButton.NONE) - handled = view.filter_event(event) + handled = ci.handle(event) assert isinstance(handled, bool) - assert handled is False def test_view_resizer() -> None: @@ -162,8 +159,10 @@ def test_view_resizer() -> None: camera = snx.Camera( projection=projections.orthographic(100, 100, 100), ) - view = snx.View(camera=camera, on_resize=snx.Letterbox()) + view = snx.View(camera=camera) canvas = snx.Canvas(width=400, height=400, views=[view]) + ci = snx.CanvasInteractor(canvas) + ci.set_resize_policy(view, snx.Letterbox()) # Initial aspect should be 1.0 (square) # Note that the aspect ratio is stored inversely in the projection matrix, @@ -182,7 +181,7 @@ def test_view_resizer() -> None: assert new_aspect == pytest.approx(2.0, rel=1e-6) # Remove resizer - view.on_resize = None + ci.set_resize_policy(view, None) # Resize canvas again canvas.height = 400 @@ -212,12 +211,9 @@ def test_view_canvas_assignment() -> None: assert view not in canvas.views -def test_view_serialization() -> None: +def test_letterbox_serialization() -> None: + """Letterbox can be round-trip serialized.""" resize_policy = snx.Letterbox() - view = snx.View(on_resize=resize_policy) - json = view.model_dump_json() - view2 = snx.View.model_validate_json(json) - # FIXME: there are tons of different errors in round trip serialization - # let's just make sure that Letterbox() can be round-trip serialized - # and leave the rest for later - assert isinstance(view2.on_resize, type(resize_policy)) + json = resize_policy.model_dump_json() + policy2 = snx.Letterbox.model_validate_json(json) + assert isinstance(policy2, type(resize_policy))