Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 77 additions & 31 deletions src/scenex/model/_nodes/camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
Expand Down Expand Up @@ -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)
)
Comment on lines +371 to +394
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In PanZoom.zoom(), factor is part of the public API but it is used as a divisor when computing pan (and as a scale multiplier). Calling this with factor <= 0 will either raise (division by zero) or behave nonsensically (negative scaling). Consider validating factor (e.g., raise ValueError when factor <= 0 or non-finite) early in the method.

Copilot uses AI. Check for mistakes.

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)


Expand Down Expand Up @@ -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:
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Orbit.zoom() also divides by factor (dr / factor). Since this is now a public API, please validate factor (e.g., reject factor <= 0 / non-finite) to avoid division-by-zero and undefined behavior.

Suggested change
) -> None:
) -> None:
if not math.isfinite(factor) or factor <= 0:
raise ValueError("factor must be a finite number greater than 0")

Copilot uses AI. Check for mistakes.
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)
3 changes: 3 additions & 0 deletions src/scenex/utils/projections.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spelling: "right-handned" should be "right-handed".

Suggested change
# NOTE: In a right-handned coordinate system, the camera looks down -Z, so we need
# NOTE: In a right-handed coordinate system, the camera looks down -Z, so we need

Copilot uses AI. Check for mistakes.
# 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))


Expand Down
171 changes: 131 additions & 40 deletions tests/model/_nodes/test_camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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]))
Comment on lines +137 to +147
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test hard-codes assumptions about the camera's default projection/transform (scaled(..., -1) and camera_center = np.zeros(2)). This makes the test brittle if defaults change. Prefer deriving expectations from cam.projection/cam.transform captured before calling zoom().

Suggested change
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]))
before_proj = cam.projection
before_transform = cam.transform
interaction.zoom(cam, factor, center=center)
# Projection should be scaled relative to the camera's prior projection
expected_proj = before_proj.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 = before_transform.root[3, :2]
delta_screen1 = zoom_center - camera_center
delta_screen2 = delta_screen1 * factor
pan = (delta_screen2 - delta_screen1) / factor
expected_transform = before_transform.translated((pan[0], pan[1]))

Copilot uses AI. Check for mistakes.
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))
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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))
Comment on lines +318 to +319
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These assignments create unused variables (zoom and desired_tform). Ruff enables F rules for tests, so this will fail linting. Remove the unused variables or use them in the assertion.

Suggested change
zoom = interaction._zoom_factor(delta)
desired_tform = snx.Transform().translated((0, 0, starting_dist))

Copilot uses AI. Check for mistakes.
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 in 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(),
Expand Down
Loading