3b1b-manim/manimlib/scene/interactive_scene.py
Grant Sanderson 744e695340
Misc. clean up (#2269)
* Comment tweak

* Directly print traceback

Since the shell.showtraceback is giving some issues

* Make InteracrtiveSceneEmbed into a class

This way it can keep track of it's internal shell; use of get_ipython has a finicky relationship with reloading.

* Move remaining checkpoint_paste logic into scene_embed.py

This involved making a few context managers for Scene: temp_record, temp_skip, temp_progress_bar, which seem useful in and of themselves.

* Change null key to be the empty string

* Ensure temporary svg paths for Text are deleted

* Remove unused dict_ops.py functions

* Remove break_into_partial_movies from file_writer configuration

* Rewrite guarantee_existence using Path

* Clean up SceneFileWriter

It had a number of vestigial functions no longer used, and some setup that could be made more organized.

* Remove --save_pngs CLI arg (which did nothing)

* Add --subdivide CLI arg

* Remove add_extension_if_not_present

* Remove get_sorted_integer_files

* Have find_file return Path

* Minor clean up

* Clean up num_tex_symbols

* Fix find_file

* Minor cleanup for extract_scene.py

* Add preview_frame_while_skipping option to scene config

* Use shell.showtraceback function

* Move keybindings to config, instead of in-place constants

* Replace DEGREES -> DEG
2024-12-12 08:39:54 -08:00

638 lines
23 KiB
Python

from __future__ import annotations
import itertools as it
import numpy as np
import pyperclip
from IPython.core.getipython import get_ipython
from pyglet.window import key as PygletWindowKeys
from manimlib.animation.fading import FadeIn
from manimlib.config import manim_config
from manimlib.constants import DL, DOWN, DR, LEFT, ORIGIN, RIGHT, UL, UP, UR
from manimlib.constants import FRAME_WIDTH, FRAME_HEIGHT, SMALL_BUFF
from manimlib.constants import PI
from manimlib.constants import DEG
from manimlib.constants import MANIM_COLORS, WHITE, GREY_A, GREY_C
from manimlib.mobject.geometry import Line
from manimlib.mobject.geometry import Rectangle
from manimlib.mobject.geometry import Square
from manimlib.mobject.mobject import Group
from manimlib.mobject.mobject import Mobject
from manimlib.mobject.numbers import DecimalNumber
from manimlib.mobject.svg.tex_mobject import Tex
from manimlib.mobject.svg.text_mobject import Text
from manimlib.mobject.types.dot_cloud import DotCloud
from manimlib.mobject.types.vectorized_mobject import VGroup
from manimlib.mobject.types.vectorized_mobject import VHighlight
from manimlib.mobject.types.vectorized_mobject import VMobject
from manimlib.scene.scene import Scene
from manimlib.scene.scene import SceneState
from manimlib.utils.family_ops import extract_mobject_family_members
from manimlib.utils.space_ops import get_norm
from manimlib.utils.tex_file_writing import LatexError
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from manimlib.typing import Vect3
SELECT_KEY = manim_config.key_bindings.select
UNSELECT_KEY = manim_config.key_bindings.unselect
GRAB_KEY = manim_config.key_bindings.grab
X_GRAB_KEY = manim_config.key_bindings.x_grab
Y_GRAB_KEY = manim_config.key_bindings.y_grab
GRAB_KEYS = [GRAB_KEY, X_GRAB_KEY, Y_GRAB_KEY]
RESIZE_KEY = manim_config.key_bindings.resize # TODO
COLOR_KEY = manim_config.key_bindings.color
INFORMATION_KEY = manim_config.key_bindings.information
CURSOR_KEY = manim_config.key_bindings.cursor
# For keyboard interactions
ARROW_SYMBOLS: list[int] = [
PygletWindowKeys.LEFT,
PygletWindowKeys.UP,
PygletWindowKeys.RIGHT,
PygletWindowKeys.DOWN,
]
ALL_MODIFIERS = PygletWindowKeys.MOD_CTRL | PygletWindowKeys.MOD_COMMAND | PygletWindowKeys.MOD_SHIFT
# Note, a lot of the functionality here is still buggy and very much a work in progress.
class InteractiveScene(Scene):
"""
To select mobjects on screen, hold ctrl and move the mouse to highlight a region,
or just tap ctrl to select the mobject under the cursor.
Pressing command + t will toggle between modes where you either select top level
mobjects part of the scene, or low level pieces.
Hold 'g' to grab the selection and move it around
Hold 'h' to drag it constrained in the horizontal direction
Hold 'v' to drag it constrained in the vertical direction
Hold 't' to resize selection, adding 'shift' to resize with respect to a corner
Command + 'c' copies the ids of selections to clipboard
Command + 'v' will paste either:
- The copied mobject
- A Tex mobject based on copied LaTeX
- A Text mobject based on copied Text
Command + 'z' restores selection back to its original state
Command + 's' saves the selected mobjects to file
"""
corner_dot_config = dict(
color=WHITE,
radius=0.05,
glow_factor=2.0,
)
selection_rectangle_stroke_color = WHITE
selection_rectangle_stroke_width = 1.0
palette_colors = MANIM_COLORS
selection_nudge_size = 0.05
cursor_location_config = dict(
font_size=24,
fill_color=GREY_C,
num_decimal_places=3,
)
time_label_config = dict(
font_size=24,
fill_color=GREY_C,
num_decimal_places=1,
)
crosshair_width = 0.2
crosshair_style = dict(
stroke_color=GREY_A,
stroke_width=[3, 0, 3],
)
def setup(self):
self.selection = Group()
self.selection_highlight = self.get_selection_highlight()
self.selection_rectangle = self.get_selection_rectangle()
self.crosshair = self.get_crosshair()
self.information_label = self.get_information_label()
self.color_palette = self.get_color_palette()
self.unselectables = [
self.selection,
self.selection_highlight,
self.selection_rectangle,
self.crosshair,
self.information_label,
self.camera.frame
]
self.select_top_level_mobs = True
self.regenerate_selection_search_set()
self.is_selecting = False
self.is_grabbing = False
self.add(self.selection_highlight)
def get_selection_rectangle(self):
rect = Rectangle(
stroke_color=self.selection_rectangle_stroke_color,
stroke_width=self.selection_rectangle_stroke_width,
)
rect.fix_in_frame()
rect.fixed_corner = ORIGIN
rect.add_updater(self.update_selection_rectangle)
return rect
def update_selection_rectangle(self, rect: Rectangle):
p1 = rect.fixed_corner
p2 = self.frame.to_fixed_frame_point(self.mouse_point.get_center())
rect.set_points_as_corners([
p1, np.array([p2[0], p1[1], 0]),
p2, np.array([p1[0], p2[1], 0]),
p1,
])
return rect
def get_selection_highlight(self):
result = Group()
result.tracked_mobjects = []
result.add_updater(self.update_selection_highlight)
return result
def update_selection_highlight(self, highlight: Mobject):
if set(highlight.tracked_mobjects) == set(self.selection):
return
# Otherwise, refresh contents of highlight
highlight.tracked_mobjects = list(self.selection)
highlight.set_submobjects([
self.get_highlight(mob)
for mob in self.selection
])
try:
index = min((
i for i, mob in enumerate(self.mobjects)
for sm in self.selection
if sm in mob.get_family()
))
self.mobjects.remove(highlight)
self.mobjects.insert(index - 1, highlight)
except ValueError:
pass
def get_crosshair(self):
lines = VMobject().replicate(2)
lines[0].set_points([LEFT, ORIGIN, RIGHT])
lines[1].set_points([UP, ORIGIN, DOWN])
crosshair = VGroup(*lines)
crosshair.set_width(self.crosshair_width)
crosshair.set_style(**self.crosshair_style)
crosshair.set_animating_status(True)
crosshair.fix_in_frame()
return crosshair
def get_color_palette(self):
palette = VGroup(*(
Square(fill_color=color, fill_opacity=1, side_length=1)
for color in self.palette_colors
))
palette.set_stroke(width=0)
palette.arrange(RIGHT, buff=0.5)
palette.set_width(FRAME_WIDTH - 0.5)
palette.to_edge(DOWN, buff=SMALL_BUFF)
palette.fix_in_frame()
return palette
def get_information_label(self):
loc_label = VGroup(*(
DecimalNumber(**self.cursor_location_config)
for n in range(3)
))
def update_coords(loc_label):
for mob, coord in zip(loc_label, self.mouse_point.get_location()):
mob.set_value(coord)
loc_label.arrange(RIGHT, buff=loc_label.get_height())
loc_label.to_corner(DR, buff=SMALL_BUFF)
loc_label.fix_in_frame()
return loc_label
loc_label.add_updater(update_coords)
time_label = DecimalNumber(0, **self.time_label_config)
time_label.to_corner(DL, buff=SMALL_BUFF)
time_label.fix_in_frame()
time_label.add_updater(lambda m, dt: m.increment_value(dt))
return VGroup(loc_label, time_label)
# Overrides
def get_state(self):
return SceneState(self, ignore=[
self.selection_highlight,
self.selection_rectangle,
self.crosshair,
])
def restore_state(self, scene_state: SceneState):
super().restore_state(scene_state)
self.mobjects.insert(0, self.selection_highlight)
def add(self, *mobjects: Mobject):
super().add(*mobjects)
self.regenerate_selection_search_set()
def remove(self, *mobjects: Mobject):
super().remove(*mobjects)
self.regenerate_selection_search_set()
# Related to selection
def toggle_selection_mode(self):
self.select_top_level_mobs = not self.select_top_level_mobs
self.refresh_selection_scope()
self.regenerate_selection_search_set()
def get_selection_search_set(self) -> list[Mobject]:
return self.selection_search_set
def regenerate_selection_search_set(self):
selectable = list(filter(
lambda m: m not in self.unselectables,
self.mobjects
))
if self.select_top_level_mobs:
self.selection_search_set = selectable
else:
self.selection_search_set = [
submob
for mob in selectable
for submob in mob.family_members_with_points()
]
def refresh_selection_scope(self):
curr = list(self.selection)
if self.select_top_level_mobs:
self.selection.set_submobjects([
mob
for mob in self.mobjects
if any(sm in mob.get_family() for sm in curr)
])
self.selection.refresh_bounding_box(recurse_down=True)
else:
self.selection.set_submobjects(
extract_mobject_family_members(
curr, exclude_pointless=True,
)
)
def get_corner_dots(self, mobject: Mobject) -> Mobject:
dots = DotCloud(**self.corner_dot_config)
radius = float(self.corner_dot_config["radius"])
if mobject.get_depth() < 1e-2:
vects = [DL, UL, UR, DR]
else:
vects = np.array(list(it.product(*3 * [[-1, 1]])))
dots.add_updater(lambda d: d.set_points([
mobject.get_corner(v) + v * radius
for v in vects
]))
return dots
def get_highlight(self, mobject: Mobject) -> Mobject:
if isinstance(mobject, VMobject) and mobject.has_points() and not self.select_top_level_mobs:
length = max([mobject.get_height(), mobject.get_width()])
result = VHighlight(
mobject,
max_stroke_addition=min([50 * length, 10]),
)
result.add_updater(lambda m: m.replace(mobject, stretch=True))
return result
elif isinstance(mobject, DotCloud):
return Mobject()
else:
return self.get_corner_dots(mobject)
def add_to_selection(self, *mobjects: Mobject):
mobs = list(filter(
lambda m: m not in self.unselectables and m not in self.selection,
mobjects
))
if len(mobs) == 0:
return
self.selection.add(*mobs)
for mob in mobs:
mob.set_animating_status(True)
def toggle_from_selection(self, *mobjects: Mobject):
for mob in mobjects:
if mob in self.selection:
self.selection.remove(mob)
mob.set_animating_status(False)
mob.refresh_bounding_box()
else:
self.add_to_selection(mob)
def clear_selection(self):
for mob in self.selection:
mob.set_animating_status(False)
mob.refresh_bounding_box()
self.selection.set_submobjects([])
def disable_interaction(self, *mobjects: Mobject):
for mob in mobjects:
for sm in mob.get_family():
self.unselectables.append(sm)
self.regenerate_selection_search_set()
def enable_interaction(self, *mobjects: Mobject):
for mob in mobjects:
for sm in mob.get_family():
if sm in self.unselectables:
self.unselectables.remove(sm)
# Functions for keyboard actions
def copy_selection(self):
names = []
shell = get_ipython()
for mob in self.selection:
name = str(id(mob))
if shell is None:
continue
for key, value in shell.user_ns.items():
if mob is value:
name = key
names.append(name)
pyperclip.copy(", ".join(names))
def paste_selection(self):
clipboard_str = pyperclip.paste()
# Try pasting a mobject
try:
ids = map(int, clipboard_str.split(","))
mobs = map(self.id_to_mobject, ids)
mob_copies = [m.copy() for m in mobs if m is not None]
self.clear_selection()
self.play(*(
FadeIn(mc, run_time=0.5, scale=1.5)
for mc in mob_copies
))
self.add_to_selection(*mob_copies)
return
except ValueError:
pass
# Otherwise, treat as tex or text
if set("\\^=+").intersection(clipboard_str): # Proxy to text for LaTeX
try:
new_mob = Tex(clipboard_str)
except LatexError:
return
else:
new_mob = Text(clipboard_str)
self.clear_selection()
self.add(new_mob)
self.add_to_selection(new_mob)
def delete_selection(self):
self.remove(*self.selection)
self.clear_selection()
def enable_selection(self):
self.is_selecting = True
self.add(self.selection_rectangle)
self.selection_rectangle.fixed_corner = self.frame.to_fixed_frame_point(
self.mouse_point.get_center()
)
def gather_new_selection(self):
self.is_selecting = False
if self.selection_rectangle in self.mobjects:
self.remove(self.selection_rectangle)
additions = []
for mob in reversed(self.get_selection_search_set()):
if self.selection_rectangle.is_touching(mob):
additions.append(mob)
if self.selection_rectangle.get_arc_length() < 1e-2:
break
self.toggle_from_selection(*additions)
def prepare_grab(self):
mp = self.mouse_point.get_center()
self.mouse_to_selection = mp - self.selection.get_center()
self.is_grabbing = True
def prepare_resizing(self, about_corner=False):
center = self.selection.get_center()
mp = self.mouse_point.get_center()
if about_corner:
self.scale_about_point = self.selection.get_corner(center - mp)
else:
self.scale_about_point = center
self.scale_ref_vect = mp - self.scale_about_point
self.scale_ref_width = self.selection.get_width()
self.scale_ref_height = self.selection.get_height()
def toggle_color_palette(self):
if len(self.selection) == 0:
return
if self.color_palette not in self.mobjects:
self.save_state()
self.add(self.color_palette)
else:
self.remove(self.color_palette)
def display_information(self, show=True):
if show:
self.add(self.information_label)
else:
self.remove(self.information_label)
def group_selection(self):
group = self.get_group(*self.selection)
self.add(group)
self.clear_selection()
self.add_to_selection(group)
def ungroup_selection(self):
pieces = []
for mob in list(self.selection):
self.remove(mob)
pieces.extend(list(mob))
self.clear_selection()
self.add(*pieces)
self.add_to_selection(*pieces)
def nudge_selection(self, vect: np.ndarray, large: bool = False):
nudge = self.selection_nudge_size
if large:
nudge *= 10
self.selection.shift(nudge * vect)
# Key actions
def on_key_press(self, symbol: int, modifiers: int) -> None:
super().on_key_press(symbol, modifiers)
char = chr(symbol)
if char == SELECT_KEY and (modifiers & ALL_MODIFIERS) == 0:
self.enable_selection()
if char == UNSELECT_KEY:
self.clear_selection()
elif char in GRAB_KEYS and (modifiers & ALL_MODIFIERS) == 0:
self.prepare_grab()
elif char == RESIZE_KEY and (modifiers & PygletWindowKeys.MOD_SHIFT):
self.prepare_resizing(about_corner=((modifiers & PygletWindowKeys.MOD_SHIFT) > 0))
elif symbol == PygletWindowKeys.LSHIFT:
if self.window.is_key_pressed(ord("t")):
self.prepare_resizing(about_corner=True)
elif char == COLOR_KEY and (modifiers & ALL_MODIFIERS) == 0:
self.toggle_color_palette()
elif char == INFORMATION_KEY and (modifiers & ALL_MODIFIERS) == 0:
self.display_information()
elif char == "c" and (modifiers & (PygletWindowKeys.MOD_COMMAND | PygletWindowKeys.MOD_CTRL)):
self.copy_selection()
elif char == "v" and (modifiers & (PygletWindowKeys.MOD_COMMAND | PygletWindowKeys.MOD_CTRL)):
self.paste_selection()
elif char == "x" and (modifiers & (PygletWindowKeys.MOD_COMMAND | PygletWindowKeys.MOD_CTRL)):
self.copy_selection()
self.delete_selection()
elif symbol == PygletWindowKeys.BACKSPACE:
self.delete_selection()
elif char == "a" and (modifiers & (PygletWindowKeys.MOD_COMMAND | PygletWindowKeys.MOD_CTRL)):
self.clear_selection()
self.add_to_selection(*self.mobjects)
elif char == "g" and (modifiers & (PygletWindowKeys.MOD_COMMAND | PygletWindowKeys.MOD_CTRL)):
self.group_selection()
elif char == "g" and (modifiers & (PygletWindowKeys.MOD_COMMAND | PygletWindowKeys.MOD_CTRL | PygletWindowKeys.MOD_SHIFT)):
self.ungroup_selection()
elif char == "t" and (modifiers & (PygletWindowKeys.MOD_COMMAND | PygletWindowKeys.MOD_CTRL)):
self.toggle_selection_mode()
elif char == "d" and (modifiers & PygletWindowKeys.MOD_SHIFT):
self.copy_frame_positioning()
elif char == "c" and (modifiers & PygletWindowKeys.MOD_SHIFT):
self.copy_cursor_position()
elif symbol in ARROW_SYMBOLS:
self.nudge_selection(
vect=[LEFT, UP, RIGHT, DOWN][ARROW_SYMBOLS.index(symbol)],
large=(modifiers & PygletWindowKeys.MOD_SHIFT),
)
# Adding crosshair
if char == CURSOR_KEY:
if self.crosshair in self.mobjects:
self.remove(self.crosshair)
else:
self.add(self.crosshair)
if char == SELECT_KEY:
self.add(self.crosshair)
# Conditions for saving state
if char in [GRAB_KEY, X_GRAB_KEY, Y_GRAB_KEY, RESIZE_KEY]:
self.save_state()
def on_key_release(self, symbol: int, modifiers: int) -> None:
super().on_key_release(symbol, modifiers)
if chr(symbol) == SELECT_KEY:
self.gather_new_selection()
if chr(symbol) in GRAB_KEYS:
self.is_grabbing = False
elif chr(symbol) == INFORMATION_KEY:
self.display_information(False)
elif symbol == PygletWindowKeys.LSHIFT and self.window.is_key_pressed(ord(RESIZE_KEY)):
self.prepare_resizing(about_corner=False)
# Mouse actions
def handle_grabbing(self, point: Vect3):
diff = point - self.mouse_to_selection
if self.window.is_key_pressed(ord(GRAB_KEY)):
self.selection.move_to(diff)
elif self.window.is_key_pressed(ord(X_GRAB_KEY)):
self.selection.set_x(diff[0])
elif self.window.is_key_pressed(ord(Y_GRAB_KEY)):
self.selection.set_y(diff[1])
def handle_resizing(self, point: Vect3):
if not hasattr(self, "scale_about_point"):
return
vect = point - self.scale_about_point
if self.window.is_key_pressed(PygletWindowKeys.LCTRL):
for i in (0, 1):
scalar = vect[i] / self.scale_ref_vect[i]
self.selection.rescale_to_fit(
scalar * [self.scale_ref_width, self.scale_ref_height][i],
dim=i,
about_point=self.scale_about_point,
stretch=True,
)
else:
scalar = get_norm(vect) / get_norm(self.scale_ref_vect)
self.selection.set_width(
scalar * self.scale_ref_width,
about_point=self.scale_about_point
)
def handle_sweeping_selection(self, point: Vect3):
mob = self.point_to_mobject(
point,
search_set=self.get_selection_search_set(),
buff=SMALL_BUFF
)
if mob is not None:
self.add_to_selection(mob)
def choose_color(self, point: Vect3):
# Search through all mobject on the screen, not just the palette
to_search = [
sm
for mobject in self.mobjects
for sm in mobject.family_members_with_points()
if mobject not in self.unselectables
]
mob = self.point_to_mobject(point, to_search)
if mob is not None:
self.selection.set_color(mob.get_color())
self.remove(self.color_palette)
def on_mouse_motion(self, point: Vect3, d_point: Vect3) -> None:
super().on_mouse_motion(point, d_point)
self.crosshair.move_to(self.frame.to_fixed_frame_point(point))
if self.is_grabbing:
self.handle_grabbing(point)
elif self.window.is_key_pressed(ord(RESIZE_KEY)):
self.handle_resizing(point)
elif self.window.is_key_pressed(ord(SELECT_KEY)) and self.window.is_key_pressed(PygletWindowKeys.LSHIFT):
self.handle_sweeping_selection(point)
def on_mouse_drag(
self,
point: Vect3,
d_point: Vect3,
buttons: int,
modifiers: int
) -> None:
super().on_mouse_drag(point, d_point, buttons, modifiers)
self.crosshair.move_to(self.frame.to_fixed_frame_point(point))
def on_mouse_release(self, point: Vect3, button: int, mods: int) -> None:
super().on_mouse_release(point, button, mods)
if self.color_palette in self.mobjects:
self.choose_color(point)
else:
self.clear_selection()
# Copying code to recreate state
def copy_frame_positioning(self):
frame = self.frame
center = frame.get_center()
height = frame.get_height()
angles = frame.get_euler_angles()
call = f"reorient("
theta, phi, gamma = (angles / DEG).astype(int)
call += f"{theta}, {phi}, {gamma}"
if any(center != 0):
call += f", {tuple(np.round(center, 2))}"
if height != FRAME_HEIGHT:
call += ", {:.2f}".format(height)
call += ")"
pyperclip.copy(call)
def copy_cursor_position(self):
pyperclip.copy(str(tuple(self.mouse_point.get_center().round(2))))