To allow keyboard and mouse interaction with the

scene. This allows things like panning or zooming.
Also modified the message shown after a scene
finishes rendering giving clear tips on how to
perform the interaction.
This commit is contained in:
Colin Rubow 2026-06-20 16:33:01 -06:00
commit f0bf3f5833
5 changed files with 191 additions and 24 deletions

View file

@ -56,6 +56,11 @@ class Camera(Mobject, InvisibleMobject):
self.set_height(self.initial_frame_shape[1], stretch=True)
self.move_to(self.center_point)
def reset(self) -> None:
"""restores camera to default orientation and position"""
self.set_euler_angles(theta=-TAU / 4, phi=0.0, gamma=0.0)
self.init_points()
def interpolate(
self,
mobject1: Mobject,
@ -248,7 +253,7 @@ class Camera(Mobject, InvisibleMobject):
:class:`Camera`
The camera after incrementing its phi angle.
"""
return self.set_phi(self._phi + dgamma)
return self.set_phi(self._phi + dphi)
def get_gamma(self) -> float:
"""Get the angle gamma by which the camera is rotated while standing on

View file

@ -131,7 +131,7 @@ class Manager(Generic[SceneT]):
-------
A window if previewing, else None
"""
return Window() if config.preview else None
return Window(self.scene) if config.preview else None
def create_file_writer(self) -> FileWriterProtocol:
"""Create and return a file writer instance.
@ -242,9 +242,15 @@ class Manager(Generic[SceneT]):
if self.window is None:
return
logger.info(
"\nTips: Using the keys `d`, `f`, or `z` "
"you can interact with the scene. "
"Press `command + q` or `esc` to quit"
"""\nTips: To interact with the scene:
press the key `d` and drag the mouse to 3D pan,
press the key `f` and drag the mouse to shift the frame,
press the key `z` and scroll the mouse wheel to zoom,
press the key `r` to reset the camera to its default settings,
press the key `o` to print the current camera configuration that may be copied and pasted into self.camera.set_orientation() for quick configuration,
scroll the mouse wheel to shift the scene vertically,
press `command + q` or `esc` to quit
"""
)
last_time = time.perf_counter()
while not self.window.is_closing:

View file

@ -39,6 +39,7 @@ from manim.data_structures import MethodWithArgs
from manim.event_handler import EVENT_DISPATCHER
from manim.event_handler.event_listener import EventListener
from manim.event_handler.event_type import EventType
from manim.typing import Point3D
from manim.utils.bezier import integer_interpolate, interpolate
from manim.utils.color import *
from manim.utils.exceptions import MultiAnimationOverrideException

View file

@ -3,12 +3,14 @@ from __future__ import annotations
from typing import TYPE_CHECKING, TypeVar
import moderngl_window as mglw
import numpy as np
from moderngl_window.context.pyglet.window import Window as PygletWindow
from moderngl_window.timers.clock import Timer
from screeninfo import get_monitors
from manim import __version__, config
from manim.event_handler.window import WindowProtocol
from manim.scene.scene import Scene
if TYPE_CHECKING:
from typing import TypeGuard
@ -29,7 +31,7 @@ class Window(PygletWindow, WindowProtocol):
vsync: bool = True
cursor: bool = True
def __init__(self, window_size: str | tuple[int, ...] = config.window_size):
def __init__(self, scene: Scene, window_size: str | tuple[int, ...] = config.window_size):
# TODO: remove size argument from window init,
# move size computation below to config
@ -65,6 +67,7 @@ class Window(PygletWindow, WindowProtocol):
raise ValueError(invalid_window_size_error_message)
super().__init__(size=size)
self.scene = scene
self.pressed_keys: set = set()
self.title = f"Manim Community {__version__}"
self.size = size
@ -109,6 +112,121 @@ class Window(PygletWindow, WindowProtocol):
-monitor.y + char_to_n[custom_position[0]] * height_diff // 2,
)
def on_key_press(self, symbol: int, modifiers: int) -> bool:
"""tie key pressing to the scene response
Parameters
----------
symbol
the key that is pressed
modifiers
keys like shift or ctrl
Returns
-------
bool
whether pyglet handled the event or not
"""
self.scene.on_key_press(symbol, modifiers)
return super().on_key_press(symbol, modifiers)
def on_key_release(self, symbol: int, modifiers: int) -> None:
"""tie key release events to the scene response
Parameters
----------
symbol
the key that is released
modifiers
keys like shift or ctrl
"""
return super().on_key_release(symbol, modifiers)
def on_mouse_motion(self, x: int, y: int, dx: int, dy: int) -> None:
"""tie mouse motion events to the scene response
Parmeters
---------
x
x pixel coordinate
y
y pixel coordinate
dx
change of x pixel coordinates
dy
change of y pixel coordinates
"""
self.scene.on_mouse_motion(np.array([x, y, 0]), np.array([dx, dy, 0]))
def on_mouse_drag(self, x: int, y: int, dx: int, dy: int, buttons: int, modifiers: int) -> None:
"""tie mouse drag events to the scene response
Parmeters
---------
x
x pixel coordinate
y
y pixel coordinate
dx
change of x pixel coordinates
dy
change of y pixel coordinates
buttons
the mouse buttons currently pressed
modifiers
keys like shift or ctrl
"""
self.scene.on_mouse_drag(
np.array([x, y, 0]), np.array([dx, dy, 0]), buttons, modifiers
)
def on_mouse_press(self, x: int, y: int, button: int, mods: int) -> None:
"""tie mouse press events to the scene response
Parmeters
---------
x
x pixel coordinate
y
y pixel coordinate
button
the mouse button that was pressed
mods
keys like shift or ctrl
"""
self.scene.on_mouse_press(np.array([x, y, 0]), button, mods)
def on_mouse_release(self, x: int, y: int, button: int, mods: int) -> None:
"""tie mouse release events to the scene response
Parmeters
---------
x
x pixel coordinate
y
y pixel coordinate
button
the mouse button that was released
mods
keys like shift or ctrl
"""
self.scene.on_mouse_release(np.array([x, y, 0]), button, mods)
def on_mouse_scroll(self, x: int, y: int, x_offset: float, y_offset: float) -> None:
"""tie mouse scroll events to the scene response
Parmeters
---------
x
x pixel coordinate
y
y pixel coordinate
x_offset
number of horizontal wheel ticks (not useful for most mice)
y_offset
number of vertical wheel ticks
"""
self.scene.on_mouse_scroll(np.array([x, y, 0]), np.array([x_offset, y_offset, 0]))
def tuple_len_2(pos: tuple[T, ...]) -> TypeGuard[tuple[T, T]]:
return len(pos) == 2

View file

@ -10,6 +10,7 @@ import numpy as np
from pyglet.window import key
from manim import config, logger
from manim.animation import transform
from manim.animation.animation import Wait, prepare_animation
from manim.animation.scene_buffer import SceneBuffer, SceneOperation
from manim.camera.camera import Camera
@ -51,6 +52,7 @@ FRAME_SHIFT_KEY = "f"
ZOOM_KEY = "z"
RESET_FRAME_KEY = "r"
QUIT_KEY = "q"
GET_ORIENTATION_KEY = "o"
class Scene:
@ -525,6 +527,9 @@ class Scene:
# Event handling
def on_mouse_motion(self, point: Point3D, d_point: Vector3D) -> None:
point = self._pos_window_to_camera(point)
d_point = self._d_pos_window_to_camera(d_point)
self.mouse_point.move_to(point)
event_data = {"point": point, "d_point": d_point}
@ -534,25 +539,25 @@ class Scene:
if propagate_event is not None and propagate_event is False:
return
# TODO
return
frame = self.camera.frame
# Handle perspective changes
if self.window.is_key_pressed(ord(PAN_3D_KEY)):
frame.increment_theta(-self.pan_sensitivity * d_point[0])
frame.increment_phi(self.pan_sensitivity * d_point[1])
if EVENT_DISPATCHER.is_key_pressed(ord(PAN_3D_KEY)):
self.camera.increment_theta(-self.pan_sensitivity * d_point[0])
self.camera.increment_phi(self.pan_sensitivity * d_point[1])
# Handle frame movements
elif self.window.is_key_pressed(ord(FRAME_SHIFT_KEY)):
elif EVENT_DISPATCHER.is_key_pressed(ord(FRAME_SHIFT_KEY)):
shift = -d_point
shift[0] *= frame.get_width() / 2
shift[1] *= frame.get_height() / 2
transform = frame.get_inverse_camera_rotation_matrix()
shift[0] *= self.camera.get_width() / 2
shift[1] *= self.camera.get_height() / 2
transform = self.camera.get_inverse_rotation_matrix()
shift = np.dot(np.transpose(transform), shift)
frame.shift(shift)
self.camera.shift(shift)
def on_mouse_drag(
self, point: Point3D, d_point: Vector3D, buttons: int, modifiers: int
) -> None:
point = self._pos_window_to_camera(point)
d_point = self._d_pos_window_to_camera(d_point)
self.mouse_drag_point.move_to(point)
event_data = {
@ -568,6 +573,8 @@ class Scene:
return
def on_mouse_press(self, point: Point3D, button: int, mods: int) -> None:
point = self._pos_window_to_camera(point)
self.mouse_drag_point.move_to(point)
event_data = {"point": point, "button": button, "mods": mods}
propagate_event = EVENT_DISPATCHER.dispatch(
@ -577,6 +584,8 @@ class Scene:
return
def on_mouse_release(self, point: Point3D, button: int, mods: int) -> None:
point = self._pos_window_to_camera(point)
event_data = {"point": point, "button": button, "mods": mods}
propagate_event = EVENT_DISPATCHER.dispatch(
EventType.MouseReleaseEvent, **event_data
@ -585,6 +594,8 @@ class Scene:
return
def on_mouse_scroll(self, point: Point3D, offset: Vector3D) -> None:
point = self._pos_window_to_camera(point)
event_data = {"point": point, "offset": offset}
propagate_event = EVENT_DISPATCHER.dispatch(
EventType.MouseScrollEvent, **event_data
@ -592,14 +603,13 @@ class Scene:
if propagate_event is not None and propagate_event is False:
return
frame = self.camera.frame
if self.window.is_key_pressed(ord(ZOOM_KEY)):
factor = 1 + np.arctan(10 * offset[1])
frame.scale(1 / factor, about_point=point)
if EVENT_DISPATCHER.is_key_pressed(ord(ZOOM_KEY)):
factor = 1 / 1.25 if offset[1] > 0 else 1.25
self.camera.scale(factor, about_point=point)
else:
transform = frame.get_inverse_camera_rotation_matrix()
transform = self.camera.get_inverse_rotation_matrix()
shift = np.dot(np.transpose(transform), offset)
frame.shift(-20.0 * shift)
self.camera.shift(-shift / 2)
def on_key_release(self, symbol: int, modifiers: int) -> None:
event_data = {"symbol": symbol, "modifiers": modifiers}
@ -624,7 +634,12 @@ class Scene:
return
if char == RESET_FRAME_KEY:
self.play(self.camera.frame.animate.to_default_state())
self.camera.reset()
elif char == GET_ORIENTATION_KEY:
orientation = self.camera.get_orientation()
print(
f"theta={orientation['theta']}, phi={orientation['phi']}, gamma={orientation['gamma']}, zoom={orientation['zoom']}, focal_distance={orientation['focal_distance']}, frame_center=np.array([{orientation['frame_center'][0]}, {orientation['frame_center'][1]}, {orientation['frame_center'][2]}])"
)
elif char == "z" and modifiers == key.MOD_COMMAND:
self.undo()
elif char == "z" and modifiers == key.MOD_COMMAND | key.MOD_SHIFT:
@ -648,6 +663,28 @@ class Scene:
def on_close(self) -> None:
pass
def _pos_window_to_camera(self, point: Point3D) -> Point3D:
"""The window gives position coordinates in pixels, we need them in camera coordinates for intuitive interactions."""
return np.array(
[
point[0] / self.manager.window.size[0] * self.camera.get_width()
- self.camera.get_width() / 2,
point[1] / self.manager.window.size[1] * self.camera.get_height()
- self.camera.get_height() / 2,
0,
]
)
def _d_pos_window_to_camera(self, d_point: Point3D) -> Point3D:
"""The window gives positions differentials in pixels, we need them in camera units for untitive interactions."""
return np.array(
[
d_point[0] / self.manager.window.size[0] * self.camera.get_width(),
d_point[1] / self.manager.window.size[1] * self.camera.get_height(),
0,
]
)
class SceneState:
def __init__(self, scene: Scene, ignore: Iterable[Mobject] | None = None) -> None: