From 44680f4cbb5cdaa0eeeb3d6493fa00f42df657d4 Mon Sep 17 00:00:00 2001 From: Gabriel Selzer Date: Mon, 13 Apr 2026 17:25:32 -0500 Subject: [PATCH 1/3] Add a zoom API for CameraController Allows for programmatic control of the zoom --- src/scenex/model/_nodes/camera.py | 108 +++++++++++++------ src/scenex/utils/projections.py | 3 + tests/model/_nodes/test_camera.py | 171 +++++++++++++++++++++++------- 3 files changed, 211 insertions(+), 71 deletions(-) diff --git a/src/scenex/model/_nodes/camera.py b/src/scenex/model/_nodes/camera.py index 929007ff..3235a96d 100644 --- a/src/scenex/model/_nodes/camera.py +++ b/src/scenex/model/_nodes/camera.py @@ -202,6 +202,37 @@ class CameraController(EventedBase): Camera : Camera class that uses controllers """ + @abstractmethod + def zoom( + self, + camera: Camera, + factor: float, + center: Position3D | None = None, + ) -> None: + """Zoom the camera by a given factor. + + "Zoom" is not a single well-defined camera operation — it could mean changing + the field of view (shrinking how many world units are visible, as in an + orthographic projection) or moving the camera physically in relation to a focal + point (as in perspective orbit). The correct behavior really depends on the + interactive paradigm in place, hence the placement of this method here. + + Parameters + ---------- + camera : Camera + The camera to manipulate. + factor : float + Zoom factor. Values greater than 1 zoom in (fewer world units visible, + objects appear larger). Values less than 1 zoom out. A value of + 1.0 produces no change. + center : Position3D, optional + A 3D world-space anchor point that should remain fixed on screen + during the zoom. If None, zooms around the camera's current center. + Controllers that operate purely in 3D (e.g. Orbit) may ignore this + and use their own focal point instead. + """ + ... + @abstractmethod def handle_event(self, event: Event, camera: Camera) -> bool: """ @@ -330,40 +361,42 @@ def handle_event(self, event: Event, camera: Camera) -> bool: # 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) - camera.projection = 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(event.world_ray.origin)[:2] - camera_center = np.asarray(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 - camera.transform = camera.transform.translated( - ( - pan[0] if not self.lock_x else 0, - pan[1] if not self.lock_y else 0, - ) - ) + self.zoom(camera, self._zoom_factor(dy), center=event.world_ray.origin) handled = True return handled + def zoom( + self, + camera: Camera, + factor: float, + center: Position3D | None = None, + ) -> None: + # Step 1: Scale the projection matrix to zoom in or out. + camera.projection = camera.projection.scaled( + (1 if self.lock_x else factor, 1 if self.lock_y else factor, 1.0) + ) + if center is not None: + # Step 2: Translate the camera to keep `center` fixed on screen. + # Math borrowed from: + # https://github.com/pygfx/pygfx/blob/520af2d5bb2038ec309ef645e4a60d502f00d181/pygfx/controllers/_panzoom.py#L164 + zoom_center = np.asarray(center)[:2] + camera_center = np.asarray(camera.transform.map((0, 0)))[:2] + # Compute the world distance before and after the zoom + delta_screen1 = zoom_center - camera_center + delta_screen2 = delta_screen1 * factor + # The pan is the difference between the two + pan = (delta_screen2 - delta_screen1) / factor + camera.transform = camera.transform.translated( + (pan[0] if not self.lock_x else 0, pan[1] if not self.lock_y else 0) + ) + def _zoom_factor(self, delta: float) -> float: # Magnifier stolen from pygfx + # (one wheel click is typically +/-120, so this results in a zoom factor + # of 0.9x or 1.1x per click. Growth is exponential for faster scrolling) return 2 ** (delta * 0.001) @@ -562,15 +595,28 @@ def handle_event(self, event: Event, camera: Camera) -> bool: elif isinstance(event, WheelEvent): _dx, dy = event.angle_delta if dy: - dr = camera.transform.map((0, 0, 0))[:3] - center_array - zoom = self._zoom_factor(dy) - camera.transform = camera.transform.translated(dr * (zoom - 1)) - handled = True + # Magnifier stolen from pygfx + # (one wheel click is typically +/-120, so this results in a zoom factor + # of 0.9x or 1.1x per click. Growth is exponential for faster scrolling) + self.zoom(camera, self._zoom_factor(dy)) + handled = True if isinstance(event, MouseEvent): self._last_canvas_pos = event.canvas_pos return handled + def zoom( + self, + camera: Camera, + factor: float, + center: Position3D | None = None, + ) -> None: + center_array = np.asarray(self.center) + dr = camera.transform.map((0, 0, 0))[:3] - center_array + camera.transform = camera.transform.translated(-dr + dr / factor) + def _zoom_factor(self, delta: float) -> float: # Magnifier stolen from pygfx - return 2 ** (-delta * 0.001) + # (one wheel click is typically +/-120, so this results in a zoom factor + # of 0.9x or 1.1x per click. Growth is exponential for faster scrolling) + return 2 ** (delta * 0.001) diff --git a/src/scenex/utils/projections.py b/src/scenex/utils/projections.py index 99212293..cd7a966f 100644 --- a/src/scenex/utils/projections.py +++ b/src/scenex/utils/projections.py @@ -48,6 +48,9 @@ def orthographic(width: float = 1, height: float = 1, depth: float = 1) -> Trans width = width if width else 1e-6 height = height if height else 1e-6 depth = depth if depth else 1e-6 + # NOTE: In a right-handned coordinate system, the camera looks down -Z, so we need + # to flip the Z axis + # See https://www.scratchapixel.com/lessons/3d-basic-rendering/perspective-and-orthographic-projection-matrix/orthographic-projection-matrix.html return Transform().scaled((2 / width, 2 / height, -2 / depth)) diff --git a/tests/model/_nodes/test_camera.py b/tests/model/_nodes/test_camera.py index d38a3f9d..c0c7f437 100644 --- a/tests/model/_nodes/test_camera.py +++ b/tests/model/_nodes/test_camera.py @@ -73,7 +73,7 @@ def _validate_ray(maybe_ray: Ray | None) -> Ray: return maybe_ray -def test_panzoom_pan() -> None: +def test_panzoom_mouse() -> None: """Tests panning behavior of PanZoom.""" interaction = snx.PanZoom() cam = snx.Camera(interactive=True, controller=interaction) @@ -96,7 +96,7 @@ def test_panzoom_pan() -> None: np.testing.assert_allclose(cam.transform.root, expected.root) -def test_panzoom_zoom() -> None: +def test_panzoom_scroll() -> None: """Tests zooming behavior of PanZoom.""" interaction = snx.PanZoom() cam = snx.Camera(interactive=True, controller=interaction) @@ -115,7 +115,70 @@ def test_panzoom_zoom() -> None: np.testing.assert_allclose(cam.projection.root, expected.root) -def test_orbit_orbiting() -> None: +def test_panzoom_zoom() -> None: + """Tests zooming via the public zoom() API without a center.""" + interaction = snx.PanZoom() + cam = snx.Camera(interactive=True, controller=interaction) + factor = 0.5 + before = cam.projection + interaction.zoom(cam, factor) + expected = before.scaled((factor, factor, 1)) + np.testing.assert_allclose(cam.projection.root, expected.root) + # No center provided — transform should be unchanged + np.testing.assert_allclose(cam.transform.root, snx.Transform().root) + + +def test_panzoom_zoom_with_center() -> None: + """Tests that zoom() applies a compensating translation to keep center fixed.""" + interaction = snx.PanZoom() + cam = snx.Camera(interactive=True, controller=interaction) + factor = 0.5 + center = (0.5, 0.3, 0.0) + interaction.zoom(cam, factor, center=center) + # Projection should be scaled + expected_proj = snx.Transform().scaled((factor, factor, -1)) + np.testing.assert_allclose(cam.projection.root, expected_proj.root) + # Transform should have been panned by the compensating amount + zoom_center = np.array(center[:2]) + camera_center = np.zeros(2) # camera was at origin before zoom + delta_screen1 = zoom_center - camera_center + delta_screen2 = delta_screen1 * factor + pan = (delta_screen2 - delta_screen1) / factor + expected_transform = snx.Transform().translated((pan[0], pan[1])) + np.testing.assert_allclose(cam.transform.root, expected_transform.root) + + +def test_panzoom_zoom_lock_x() -> None: + """Tests that lock_x prevents x-axis scaling and x-axis panning.""" + interaction = snx.PanZoom(lock_x=True) + cam = snx.Camera(interactive=True, controller=interaction) + factor = 0.5 + center = (0.5, 0.3, 0.0) + before_proj = cam.projection + interaction.zoom(cam, factor, center=center) + # X axis of projection should be unchanged, Y axis should be scaled + expected_proj = before_proj.scaled((1, factor, 1)) + np.testing.assert_allclose(cam.projection.root, expected_proj.root) + # X component of the pan should be zero + np.testing.assert_allclose(cam.transform.root[3, 0], 0.0) + + +def test_panzoom_zoom_lock_y() -> None: + """Tests that lock_y prevents y-axis scaling and y-axis panning.""" + interaction = snx.PanZoom(lock_y=True) + cam = snx.Camera(interactive=True, controller=interaction) + factor = 0.5 + center = (0.5, 0.3, 0.0) + before_proj = cam.projection + interaction.zoom(cam, factor, center=center) + # Y axis of projection should be unchanged, X axis should be scaled + expected_proj = before_proj.scaled((factor, 1, 1)) + np.testing.assert_allclose(cam.projection.root, expected_proj.root) + # Y component of the pan should be zero + np.testing.assert_allclose(cam.transform.root[3, 1], 0.0) + + +def test_orbit_mouse_left() -> 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)) @@ -165,43 +228,8 @@ def test_orbit_orbiting() -> None: np.testing.assert_allclose(pos_after_act, pos_after_exp) -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, - ) - tform_before = cam.transform - # Simulate wheel event - wheel_event = WheelEvent( - canvas_pos=(0, 0), - world_ray=Ray((0, 0, 10), (0, 0, -1), source=MagicMock(spec=snx.View)), - buttons=MouseButton.NONE, - angle_delta=(0, 120), - ) - interaction.handle_event(wheel_event, cam) - # The camera should have moved closer to center - zoom = interaction._zoom_factor(120) - desired_tform = snx.Transform().translated((0, 0, 10 * zoom)) - np.testing.assert_allclose(cam.transform, desired_tform) - - # Simulate wheel event in other direction - wheel_event = WheelEvent( - canvas_pos=(0, 0), - world_ray=Ray((0, 0, 10), (0, 0, -1), source=MagicMock(spec=snx.View)), - buttons=MouseButton.NONE, - angle_delta=(0, -120), - ) - interaction.handle_event(wheel_event, cam) - # 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: +def test_orbit_mouse_right() -> None: + """Tests right-click panning""" # 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) @@ -252,6 +280,69 @@ def test_orbit_pan() -> None: np.testing.assert_allclose(interaction.center, desired_center) +def test_orbit_scroll() -> None: + """Tests zooming via mouse wheel events on Orbit.""" + center = (0.0, 0.0, 0.0) + interaction = snx.Orbit(center=center) + starting_dist = 10 + cam = snx.Camera( + interactive=True, + transform=snx.Transform().translated((0, 0, starting_dist)), + controller=interaction, + ) + tform_before = cam.transform + # Simulate wheel event + delta = 120 # one wheel click + wheel_event = WheelEvent( + canvas_pos=(0, 0), + world_ray=Ray((0, 0, 0), (0, 0, -1), source=MagicMock(spec=snx.View)), + buttons=MouseButton.NONE, + angle_delta=(0, delta), + ) + interaction.handle_event(wheel_event, cam) + # The camera should have moved closer to center + zoom = interaction._zoom_factor(delta) + desired_tform = snx.Transform().translated((0, 0, starting_dist / zoom)) + np.testing.assert_allclose(cam.transform, desired_tform) + + # Simulate wheel event in other direction + delta = -120 # one wheel click in the other direction + wheel_event = WheelEvent( + canvas_pos=(0, 0), + world_ray=Ray((0, 0, 0), (0, 0, -1), source=MagicMock(spec=snx.View)), + buttons=MouseButton.NONE, + angle_delta=(0, delta), + ) + interaction.handle_event(wheel_event, cam) + # The camera should have moved back to the starting point + zoom = interaction._zoom_factor(delta) + desired_tform = snx.Transform().translated((0, 0, starting_dist)) + np.testing.assert_allclose(cam.transform, tform_before) + + +def test_orbit_zoom() -> None: + """Tests zooming via the public zoom() API on Orbit.""" + center = (0.0, 0.0, 0.0) + interaction = snx.Orbit(center=center) + starting_dist = 10 + cam = snx.Camera( + interactive=True, + transform=snx.Transform().translated((0, 0, starting_dist)), + controller=interaction, + ) + factor = 0.5 # zoom out slightly + interaction.zoom(cam, factor) + # Camera should have moved along the camera-to-center axis by (1 - factor) + # So now it should be at `(1 + (1 - factor)) = 2 - factor` along the z axis + desired_tform = snx.Transform().translated((0, 0, 20)) + np.testing.assert_allclose(cam.transform, desired_tform) + + factor = 2 # zoom out slightly + interaction.zoom(cam, factor) + desired_tform = snx.Transform().translated((0, 0, 10)) + np.testing.assert_allclose(cam.transform, desired_tform) + + def test_panzoom_serialization() -> None: cam = snx.Camera( controller=snx.PanZoom(), From 2e1575d41938346a75ed9fa44aeb3d63ed158e35 Mon Sep 17 00:00:00 2001 From: Gabriel Selzer Date: Wed, 15 Apr 2026 08:49:47 -0500 Subject: [PATCH 2/3] Fix typo Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/model/_nodes/test_camera.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/model/_nodes/test_camera.py b/tests/model/_nodes/test_camera.py index c0c7f437..91b7b970 100644 --- a/tests/model/_nodes/test_camera.py +++ b/tests/model/_nodes/test_camera.py @@ -337,7 +337,7 @@ def test_orbit_zoom() -> None: desired_tform = snx.Transform().translated((0, 0, 20)) np.testing.assert_allclose(cam.transform, desired_tform) - factor = 2 # zoom out slightly + factor = 2 # zoom in slightly interaction.zoom(cam, factor) desired_tform = snx.Transform().translated((0, 0, 10)) np.testing.assert_allclose(cam.transform, desired_tform) From 2ac99e14ff0facdee8ba52a8e16071ebc7299af6 Mon Sep 17 00:00:00 2001 From: Gabriel Selzer Date: Thu, 16 Apr 2026 14:13:51 -0500 Subject: [PATCH 3/3] Validate zoom factor --- src/scenex/model/_nodes/camera.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/scenex/model/_nodes/camera.py b/src/scenex/model/_nodes/camera.py index 3235a96d..a7ce61ed 100644 --- a/src/scenex/model/_nodes/camera.py +++ b/src/scenex/model/_nodes/camera.py @@ -374,6 +374,9 @@ def zoom( factor: float, center: Position3D | None = None, ) -> None: + if not math.isfinite(factor) or factor <= 0: + raise ValueError("factor must be a finite number greater than 0") + # Step 1: Scale the projection matrix to zoom in or out. camera.projection = camera.projection.scaled( (1 if self.lock_x else factor, 1 if self.lock_y else factor, 1.0) @@ -611,7 +614,10 @@ def zoom( factor: float, center: Position3D | None = None, ) -> None: - center_array = np.asarray(self.center) + if not math.isfinite(factor) or factor <= 0: + raise ValueError("factor must be a finite number greater than 0") + + center_array = np.asarray(center or self.center) dr = camera.transform.map((0, 0, 0))[:3] - center_array camera.transform = camera.transform.translated(-dr + dr / factor)