Merge pull request #1794 from 3b1b/video-work

InteractiveScene, etc.
This commit is contained in:
Grant Sanderson 2022-04-20 22:22:09 -07:00 committed by GitHub
commit 485a4ca33a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 688 additions and 159 deletions

View file

@ -579,7 +579,7 @@ class SurfaceExample(Scene):
self.play(light.animate.move_to(3 * IN), run_time=5)
self.play(light.animate.shift(10 * OUT), run_time=5)
drag_text = Text("Try moving the mouse while pressing d or s")
drag_text = Text("Try moving the mouse while pressing d or f")
drag_text.move_to(light_text)
drag_text.fix_in_frame()

View file

@ -53,6 +53,7 @@ from manimlib.mobject.value_tracker import *
from manimlib.mobject.vector_field import *
from manimlib.scene.scene import *
from manimlib.scene.interactive_scene import *
from manimlib.scene.three_d_scene import *
from manimlib.utils.bezier import *

View file

@ -52,6 +52,7 @@ class Animation(object):
# played. As much initialization as possible,
# especially any mobject copying, should live in
# this method
self.mobject.set_animating_status(True)
self.starting_mobject = self.create_starting_mobject()
if self.suspend_mobject_updating:
# All calls to self.mobject's internal updaters
@ -66,6 +67,7 @@ class Animation(object):
def finish(self) -> None:
self.interpolate(self.final_alpha_value)
self.mobject.set_animating_status(False)
if self.suspend_mobject_updating:
self.mobject.resume_updating()

View file

@ -50,11 +50,13 @@ class AnimationGroup(Animation):
return self.group
def begin(self) -> None:
self.group.set_animating_status(True)
for anim in self.animations:
anim.begin()
# self.init_run_time()
def finish(self) -> None:
self.group.set_animating_status(False)
for anim in self.animations:
anim.finish()

View file

@ -60,6 +60,15 @@ class CameraFrame(Mobject):
def get_euler_angles(self):
return self.get_orientation().as_euler("zxz")[::-1]
def get_theta(self):
return self.get_euler_angles()[0]
def get_phi(self):
return self.get_euler_angles()[1]
def get_gamma(self):
return self.get_euler_angles()[2]
def get_inverse_camera_rotation_matrix(self):
return self.get_orientation().as_matrix().T
@ -190,7 +199,10 @@ class Camera(object):
self.init_textures()
self.init_light_source()
self.refresh_perspective_uniforms()
self.static_mobject_to_render_group_list = {}
# A cached map from mobjects to their associated list of render groups
# so that these render groups are not regenerated unnecessarily for static
# mobjects
self.mob_to_render_groups = {}
def init_frame(self) -> None:
self.frame = CameraFrame(**self.frame_config)
@ -356,11 +368,21 @@ class Camera(object):
if render_group["single_use"]:
self.release_render_group(render_group)
def get_render_group_list(self, mobject: Mobject) -> list[dict[str]] | map[dict[str]]:
try:
return self.static_mobject_to_render_group_list[id(mobject)]
except KeyError:
return map(self.get_render_group, mobject.get_shader_wrapper_list())
def get_render_group_list(self, mobject: Mobject) -> Iterable[dict[str]]:
if mobject.is_changing():
return self.generate_render_group_list(mobject)
# Otherwise, cache result for later use
key = id(mobject)
if key not in self.mob_to_render_groups:
self.mob_to_render_groups[key] = list(self.generate_render_group_list(mobject))
return self.mob_to_render_groups[key]
def generate_render_group_list(self, mobject: Mobject) -> Iterable[dict[str]]:
return (
self.get_render_group(sw, single_use=mobject.is_changing())
for sw in mobject.get_shader_wrapper_list()
)
def get_render_group(
self,
@ -399,19 +421,10 @@ class Camera(object):
if render_group[key] is not None:
render_group[key].release()
def set_mobjects_as_static(self, *mobjects: Mobject) -> None:
# Creates buffer and array objects holding each mobjects shader data
for mob in mobjects:
self.static_mobject_to_render_group_list[id(mob)] = [
self.get_render_group(sw, single_use=False)
for sw in mob.get_shader_wrapper_list()
]
def release_static_mobjects(self) -> None:
for rg_list in self.static_mobject_to_render_group_list.values():
for render_group in rg_list:
self.release_render_group(render_group)
self.static_mobject_to_render_group_list = {}
def refresh_static_mobjects(self) -> None:
for render_group in it.chain(*self.mob_to_render_groups.values()):
self.release_render_group(render_group)
self.mob_to_render_groups = {}
# Shaders
def init_shaders(self) -> None:

View file

@ -71,6 +71,16 @@ BOLD = "BOLD"
DEFAULT_STROKE_WIDTH = 4
# For keyboard interactions
CTRL_SYMBOL = 65508
SHIFT_SYMBOL = 65505
DELETE_SYMBOL = 65288
ARROW_SYMBOLS = list(range(65361, 65365))
SHIFT_MODIFIER = 1
CTRL_MODIFIER = 2
COMMAND_MODIFIER = 64
# Colors
BLUE_E = "#1C758A"
BLUE_D = "#29ABCA"
@ -127,6 +137,20 @@ LIGHT_PINK = "#DC75CD"
GREEN_SCREEN = "#00FF00"
ORANGE = "#FF862F"
MANIM_COLORS = [
BLACK, GREY_E, GREY_D, GREY_C, GREY_B, GREY_A, WHITE,
BLUE_E, BLUE_D, BLUE_C, BLUE_B, BLUE_A,
TEAL_E, TEAL_D, TEAL_C, TEAL_B, TEAL_A,
GREEN_E, GREEN_D, GREEN_C, GREEN_B, GREEN_A,
YELLOW_E, YELLOW_D, YELLOW_C, YELLOW_B, YELLOW_A,
GOLD_E, GOLD_D, GOLD_C, GOLD_B, GOLD_A,
RED_E, RED_D, RED_C, RED_B, RED_A,
MAROON_E, MAROON_D, MAROON_C, MAROON_B, MAROON_A,
PURPLE_E, PURPLE_D, PURPLE_C, PURPLE_B, PURPLE_A,
GREY_BROWN, DARK_BROWN, LIGHT_BROWN,
PINK, LIGHT_PINK,
]
# Abbreviated names for the "median" colors
BLUE = BLUE_C
TEAL = TEAL_C

View file

@ -320,8 +320,8 @@ class Circle(Arc):
"anchors_span_full_range": False
}
def __init__(self, **kwargs):
Arc.__init__(self, 0, TAU, **kwargs)
def __init__(self, start_angle: float = 0, **kwargs):
Arc.__init__(self, start_angle, TAU, **kwargs)
def surround(
self,

View file

@ -1,11 +1,12 @@
from __future__ import annotations
import sys
import copy
import random
import itertools as it
from functools import wraps
from typing import Iterable, Callable, Union, Sequence
import pickle
import os
import colour
import moderngl
@ -23,7 +24,6 @@ from manimlib.utils.iterables import list_update
from manimlib.utils.iterables import resize_array
from manimlib.utils.iterables import resize_preserving_order
from manimlib.utils.iterables import resize_with_interpolation
from manimlib.utils.iterables import make_even
from manimlib.utils.iterables import listify
from manimlib.utils.bezier import interpolate
from manimlib.utils.bezier import integer_interpolate
@ -37,6 +37,7 @@ from manimlib.shader_wrapper import get_colormap_code
from manimlib.event_handler import EVENT_DISPATCHER
from manimlib.event_handler.event_listner import EventListner
from manimlib.event_handler.event_type import EventType
from manimlib.logger import log
TimeBasedUpdater = Callable[["Mobject", float], None]
@ -71,7 +72,7 @@ class Mobject(object):
# Must match in attributes of vert shader
"shader_dtype": [
('point', np.float32, (3,)),
]
],
}
def __init__(self, **kwargs):
@ -81,6 +82,8 @@ class Mobject(object):
self.family: list[Mobject] = [self]
self.locked_data_keys: set[str] = set()
self.needs_new_bounding_box: bool = True
self._is_animating: bool = False
self._is_movable: bool = False
self.init_data()
self.init_uniforms()
@ -265,15 +268,30 @@ class Mobject(object):
parent.refresh_bounding_box()
return self
def is_point_touching(
def are_points_touching(
self,
point: np.ndarray,
points: np.ndarray,
buff: float = MED_SMALL_BUFF
) -> bool:
bb = self.get_bounding_box()
mins = (bb[0] - buff)
maxs = (bb[2] + buff)
return (point >= mins).all() and (point <= maxs).all()
return ((points >= mins) * (points <= maxs)).all(1)
def is_point_touching(
self,
point: np.ndarray,
buff: float = MED_SMALL_BUFF
) -> bool:
return self.are_points_touching(np.array(point, ndmin=2), buff)[0]
def is_touching(self, mobject: Mobject, buff: float = 1e-2) -> bool:
bb1 = self.get_bounding_box()
bb2 = mobject.get_bounding_box()
return not any((
(bb2[2] < bb1[0] - buff).any(), # E.g. Right of mobject is left of self's left
(bb2[0] > bb1[2] + buff).any(), # E.g. Left of mobject is right of self's right
))
# Family matters
@ -421,22 +439,6 @@ class Mobject(object):
self.center()
return self
def replicate(self, n: int) -> Group:
return self.get_group_class()(
*(self.copy() for x in range(n))
)
def get_grid(self, n_rows: int, n_cols: int, height: float | None = None, **kwargs):
"""
Returns a new mobject containing multiple copies of this one
arranged in a grid
"""
grid = self.replicate(n_rows * n_cols)
grid.arrange_in_grid(n_rows, n_cols, **kwargs)
if height is not None:
grid.set_height(height)
return grid
def sort(
self,
point_to_num_func: Callable[[np.ndarray], float] = lambda p: p[0],
@ -457,67 +459,46 @@ class Mobject(object):
self.assemble_family()
return self
# Creating new Mobjects from this one
def replicate(self, n: int) -> Group:
return self.get_group_class()(
*(self.copy() for x in range(n))
)
def get_grid(self, n_rows: int, n_cols: int, height: float | None = None, **kwargs):
"""
Returns a new mobject containing multiple copies of this one
arranged in a grid
"""
grid = self.replicate(n_rows * n_cols)
grid.arrange_in_grid(n_rows, n_cols, **kwargs)
if height is not None:
grid.set_height(height)
return grid
# Copying
def copy(self):
# TODO, either justify reason for shallow copy, or
# remove this redundancy everywhere
# return self.deepcopy()
parents = self.parents
self.parents = []
copy_mobject = copy.copy(self)
self.parents = parents
copy_mobject.data = dict(self.data)
for key in self.data:
copy_mobject.data[key] = self.data[key].copy()
copy_mobject.uniforms = dict(self.uniforms)
for key in self.uniforms:
if isinstance(self.uniforms[key], np.ndarray):
copy_mobject.uniforms[key] = self.uniforms[key].copy()
copy_mobject.submobjects = []
copy_mobject.add(*[sm.copy() for sm in self.submobjects])
copy_mobject.match_updaters(self)
copy_mobject.needs_new_bounding_box = self.needs_new_bounding_box
# Make sure any mobject or numpy array attributes are copied
family = self.get_family()
for attr, value in list(self.__dict__.items()):
if isinstance(value, Mobject) and value in family and value is not self:
setattr(copy_mobject, attr, value.copy())
if isinstance(value, np.ndarray):
setattr(copy_mobject, attr, value.copy())
if isinstance(value, ShaderWrapper):
setattr(copy_mobject, attr, value.copy())
return copy_mobject
return pickle.loads(pickle.dumps(self))
def deepcopy(self):
parents = self.parents
self.parents = []
result = copy.deepcopy(self)
self.parents = parents
return result
# This used to be different from copy, so is now just here for backward compatibility
return self.copy()
def generate_target(self, use_deepcopy: bool = False):
# TODO, remove now pointless use_deepcopy arg
self.target = None # Prevent exponential explosion
if use_deepcopy:
self.target = self.deepcopy()
else:
self.target = self.copy()
self.target = self.copy()
return self.target
def save_state(self, use_deepcopy: bool = False):
# TODO, remove now pointless use_deepcopy arg
if hasattr(self, "saved_state"):
# Prevent exponential growth of data
self.saved_state = None
if use_deepcopy:
self.saved_state = self.deepcopy()
else:
self.saved_state = self.copy()
self.saved_state = self.copy()
return self
def restore(self):
@ -526,6 +507,27 @@ class Mobject(object):
self.become(self.saved_state)
return self
def save_to_file(self, file_path):
if not file_path.endswith(".mob"):
file_path += ".mob"
if os.path.exists(file_path):
cont = input(f"{file_path} already exists. Overwrite (y/n)? ")
if cont != "y":
return
with open(file_path, "wb") as fp:
pickle.dump(self, fp)
log.info(f"Saved mobject to {file_path}")
return self
@staticmethod
def load(file_path):
if not os.path.exists(file_path):
log.error(f"No file found at {file_path}")
sys.exit(2)
with open(file_path, "rb") as fp:
mobject = pickle.load(fp)
return mobject
# Updating
def init_updaters(self):
@ -575,6 +577,8 @@ class Mobject(object):
updater_list.insert(index, update_function)
self.refresh_has_updater_status()
for parent in self.parents:
parent.has_updaters = True
if call_updater:
self.update(dt=0)
return self
@ -589,10 +593,10 @@ class Mobject(object):
def clear_updaters(self, recurse: bool = True):
self.time_based_updaters = []
self.non_time_updaters = []
self.refresh_has_updater_status()
if recurse:
for submob in self.submobjects:
submob.clear_updaters()
self.refresh_has_updater_status()
return self
def match_updaters(self, mobject: Mobject):
@ -623,6 +627,24 @@ class Mobject(object):
self.has_updaters = any(mob.get_updaters() for mob in self.get_family())
return self
# Check if mark as static or not for camera
def is_changing(self) -> bool:
return self._is_animating or self.has_updaters or self._is_movable
def set_animating_status(self, is_animating: bool, recurse: bool = True) -> None:
for mob in self.get_family(recurse):
mob._is_animating = is_animating
return self
def make_movable(self, value: bool = True, recurse: bool = True) -> None:
for mob in self.get_family(recurse):
mob._is_movable = value
return self
def is_movable(self) -> bool:
return self._is_movable
# Transforming operations
def shift(self, vector: np.ndarray):
@ -1173,6 +1195,13 @@ class Mobject(object):
def get_corner(self, direction: np.ndarray) -> np.ndarray:
return self.get_bounding_box_point(direction)
def get_all_corners(self):
bb = self.get_bounding_box()
return np.array([
[bb[indices[-i + 1]][i] for i in range(3)]
for indices in it.product(*3 * [[0, 2]])
])
def get_center(self) -> np.ndarray:
return self.get_bounding_box()[1]
@ -1403,7 +1432,7 @@ class Mobject(object):
return self
def push_self_into_submobjects(self):
copy = self.deepcopy()
copy = self.copy()
copy.set_submobjects([])
self.resize_points(0)
self.add(copy)

View file

@ -274,6 +274,3 @@ class BarChart(VGroup):
(value / self.max_value) * self.height
)
bar.move_to(bar_bottom, DOWN)
def copy(self):
return self.deepcopy()

View file

@ -123,9 +123,6 @@ class LabelledString(_StringSVG, ABC):
self.group_substrs = self.get_group_substrs()
self.submob_groups = self.get_submob_groups()
def copy(self):
return self.deepcopy()
# Toolkits
def get_substr(self, span: Span) -> str:

View file

@ -681,7 +681,7 @@ class VMobject(Mobject):
self.get_end_anchors(),
))))
def get_points_without_null_curves(self, atol: float=1e-9) -> np.ndarray:
def get_points_without_null_curves(self, atol: float = 1e-9) -> np.ndarray:
nppc = self.n_points_per_curve
points = self.get_points()
distinct_curves = reduce(op.or_, [

View file

@ -326,7 +326,7 @@ class ShowPassingFlashWithThinningStrokeWidth(AnimationGroup):
max_time_width = kwargs.pop("time_width", self.time_width)
AnimationGroup.__init__(self, *[
VShowPassingFlash(
vmobject.deepcopy().set_stroke(width=stroke_width),
vmobject.copy().set_stroke(width=stroke_width),
time_width=time_width,
**kwargs
)

View file

@ -0,0 +1,417 @@
import numpy as np
import itertools as it
import pyperclip
import os
from manimlib.animation.fading import FadeIn
from manimlib.constants import MANIM_COLORS, WHITE, YELLOW
from manimlib.constants import ORIGIN, UP, DOWN, LEFT, RIGHT
from manimlib.constants import FRAME_WIDTH, SMALL_BUFF
from manimlib.constants import CTRL_SYMBOL, SHIFT_SYMBOL, DELETE_SYMBOL, ARROW_SYMBOLS
from manimlib.constants import SHIFT_MODIFIER, COMMAND_MODIFIER
from manimlib.mobject.mobject import Mobject
from manimlib.mobject.geometry import Rectangle
from manimlib.mobject.geometry import Square
from manimlib.mobject.mobject import Group
from manimlib.mobject.svg.tex_mobject import Tex
from manimlib.mobject.svg.text_mobject import Text
from manimlib.mobject.types.vectorized_mobject import VMobject
from manimlib.mobject.types.vectorized_mobject import VGroup
from manimlib.mobject.types.dot_cloud import DotCloud
from manimlib.scene.scene import Scene
from manimlib.utils.tex_file_writing import LatexError
from manimlib.utils.family_ops import extract_mobject_family_members
from manimlib.utils.space_ops import get_norm
from manimlib.logger import log
SELECT_KEY = 's'
GRAB_KEY = 'g'
HORIZONTAL_GRAB_KEY = 'h'
VERTICAL_GRAB_KEY = 'v'
RESIZE_KEY = 't'
COLOR_KEY = 'c'
# Note, a lot of the functionality here is still buggy and very much a work in progress.
class InteractiveScene(Scene):
"""
TODO, Document
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.1,
glow_factor=1.0,
)
selection_rectangle_stroke_color = WHITE
selection_rectangle_stroke_width = 1.0
colors = MANIM_COLORS
selection_nudge_size = 0.05
def setup(self):
self.selection = Group()
self.selection_highlight = Group()
self.selection_rectangle = self.get_selection_rectangle()
self.color_palette = self.get_color_palette()
self.unselectables = [
self.selection,
self.selection_highlight,
self.selection_rectangle,
self.camera.frame
]
self.saved_selection_state = []
self.select_top_level_mobs = True
self.is_selecting = False
self.add(self.selection_highlight)
def toggle_selection_mode(self):
self.select_top_level_mobs = not self.select_top_level_mobs
self.refresh_selection_scope()
def get_selection_search_set(self):
mobs = [m for m in self.mobjects if m not in self.unselectables]
if self.select_top_level_mobs:
return mobs
else:
return [
submob
for mob in mobs
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,
)
)
self.refresh_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 get_color_palette(self):
palette = VGroup(*(
Square(fill_color=color, fill_opacity=1, side_length=1)
for color in self.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_stroke_highlight(self, vmobject):
outline = vmobject.copy()
for sm, osm in zip(vmobject.get_family(), outline.get_family()):
osm.set_fill(opacity=0)
osm.set_stroke(YELLOW, width=sm.get_stroke_width() + 1.5)
outline.add_updater(lambda o: o.replace(vmobject))
return outline
def get_corner_dots(self, mobject):
dots = DotCloud(**self.corner_dot_config)
dots.add_updater(lambda d: d.set_points(mobject.get_all_corners()))
dots.scale((dots.get_width() + dots.get_radius()) / dots.get_width())
# Since for flat object, all 8 corners really appear as four, dim the dots
if mobject.get_depth() < 1e-2:
dots.set_opacity(0.5)
return dots
def get_highlight(self, mobject):
if isinstance(mobject, VMobject) and mobject.has_points():
return self.get_stroke_highlight(mobject)
else:
return self.get_corner_dots(mobject)
def refresh_selection_highlight(self):
self.selection_highlight.set_submobjects([
self.get_highlight(mob)
for mob in self.selection
])
def update_selection_rectangle(self, rect):
p1 = rect.fixed_corner
p2 = self.mouse_point.get_center()
rect.set_points_as_corners([
p1, [p2[0], p1[1], 0],
p2, [p1[0], p2[1], 0],
p1,
])
return rect
def add_to_selection(self, *mobjects):
for mob in mobjects:
if mob in self.unselectables:
continue
if mob not in self.selection:
self.selection.add(mob)
self.selection_highlight.add(self.get_highlight(mob))
self.saved_selection_state = [
(mob, mob.copy())
for mob in self.selection
]
def toggle_from_selection(self, *mobjects):
for mob in mobjects:
if mob in self.selection:
self.selection.remove(mob)
else:
self.add_to_selection(mob)
self.refresh_selection_highlight()
def clear_selection(self):
self.selection.set_submobjects([])
self.selection_highlight.set_submobjects([])
def add(self, *new_mobjects: Mobject):
for mob in new_mobjects:
mob.make_movable()
super().add(*new_mobjects)
# Selection operations
def copy_selection(self):
ids = map(id, self.selection)
pyperclip.copy(",".join(map(str, ids)))
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.add_to_selection(*mob_copies)
self.play(*(
FadeIn(mc, run_time=0.5, scale=1.5)
for mc in mob_copies
))
return
except ValueError:
pass
# Otherwise, treat as tex or text
if "\\" in 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)
new_mob.move_to(self.mouse_point)
def delete_selection(self):
self.remove(*self.selection)
self.clear_selection()
def saved_selection_to_file(self):
directory = self.file_writer.get_saved_mobject_directory()
files = os.listdir(directory)
for mob in self.selection:
file_name = str(mob) + "_0.mob"
index = 0
while file_name in files:
file_name = file_name.replace(str(index), str(index + 1))
index += 1
user_name = input(
f"Enter mobject file name (default is {file_name}): "
)
if user_name:
file_name = user_name
files.append(file_name)
self.save_mobect(mob, file_name)
def undo(self):
mobs = []
for mob, state in self.saved_selection_state:
mob.become(state)
mobs.append(mob)
if mob not in self.mobjects:
self.add(mob)
self.selection.set_submobjects(mobs)
self.refresh_selection_highlight()
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()
# Event handlers
def on_key_press(self, symbol: int, modifiers: int) -> None:
super().on_key_press(symbol, modifiers)
char = chr(symbol)
# Enable selection
if char == SELECT_KEY and modifiers == 0:
self.is_selecting = True
self.add(self.selection_rectangle)
self.selection_rectangle.fixed_corner = self.mouse_point.get_center().copy()
# Prepare for move
elif char in [GRAB_KEY, HORIZONTAL_GRAB_KEY, VERTICAL_GRAB_KEY] and modifiers == 0:
mp = self.mouse_point.get_center()
self.mouse_to_selection = mp - self.selection.get_center()
# Prepare for resizing
elif char == RESIZE_KEY and modifiers in [0, SHIFT_MODIFIER]:
self.prepare_resizing(about_corner=(modifiers == SHIFT_MODIFIER))
elif symbol == SHIFT_SYMBOL:
if self.window.is_key_pressed(ord("t")):
self.prepare_resizing(about_corner=True)
# Show color palette
elif char == COLOR_KEY and modifiers == 0:
if len(self.selection) == 0:
return
if self.color_palette not in self.mobjects:
self.add(self.color_palette)
else:
self.remove(self.color_palette)
# Command + c -> Copy mobject ids to clipboard
elif char == "c" and modifiers == COMMAND_MODIFIER:
self.copy_selection()
# Command + v -> Paste
elif char == "v" and modifiers == COMMAND_MODIFIER:
self.paste_selection()
# Command + x -> Cut
elif char == "x" and modifiers == COMMAND_MODIFIER:
# TODO, this copy won't work, because once the objects are removed,
# they're not searched for in the pasting.
self.copy_selection()
self.delete_selection()
# Delete
elif symbol == DELETE_SYMBOL:
self.delete_selection()
# Command + a -> Select all
elif char == "a" and modifiers == COMMAND_MODIFIER:
self.clear_selection()
self.add_to_selection(*self.mobjects)
# Command + g -> Group selection
elif char == "g" and modifiers == COMMAND_MODIFIER:
group = self.get_group(*self.selection)
self.add(group)
self.clear_selection()
self.add_to_selection(group)
# Command + shift + g -> Ungroup the selection
elif char == "g" and modifiers == COMMAND_MODIFIER | SHIFT_MODIFIER:
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)
# Command + t -> Toggle selection mode
elif char == "t" and modifiers == COMMAND_MODIFIER:
self.toggle_selection_mode()
# Command + z -> Restore selection to original state
elif char == "z" and modifiers == COMMAND_MODIFIER:
self.undo()
# Command + s -> Save selections to file
elif char == "s" and modifiers == COMMAND_MODIFIER:
self.saved_selection_to_file()
# Keyboard movements
elif symbol in ARROW_SYMBOLS:
nudge = self.selection_nudge_size
if (modifiers & SHIFT_MODIFIER):
nudge *= 10
vect = [LEFT, UP, RIGHT, DOWN][ARROW_SYMBOLS.index(symbol)]
self.selection.shift(nudge * vect)
def on_key_release(self, symbol: int, modifiers: int) -> None:
super().on_key_release(symbol, modifiers)
if chr(symbol) == SELECT_KEY:
self.is_selecting = False
self.remove(self.selection_rectangle)
for mob in reversed(self.get_selection_search_set()):
if mob.is_movable() and self.selection_rectangle.is_touching(mob):
self.add_to_selection(mob)
elif symbol == SHIFT_SYMBOL:
if self.window.is_key_pressed(ord(RESIZE_KEY)):
self.prepare_resizing(about_corner=False)
def on_mouse_motion(self, point: np.ndarray, d_point: np.ndarray) -> None:
super().on_mouse_motion(point, d_point)
# Move selection
if self.window.is_key_pressed(ord("g")):
self.selection.move_to(point - self.mouse_to_selection)
# Move selection restricted to horizontal
elif self.window.is_key_pressed(ord("h")):
self.selection.set_x((point - self.mouse_to_selection)[0])
# Move selection restricted to vertical
elif self.window.is_key_pressed(ord("v")):
self.selection.set_y((point - self.mouse_to_selection)[1])
# Scale selection
elif self.window.is_key_pressed(ord("t")):
# TODO, allow for scaling about the opposite corner
vect = point - self.scale_about_point
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 on_mouse_release(self, point: np.ndarray, button: int, mods: int) -> None:
super().on_mouse_release(point, button, mods)
if self.color_palette in self.mobjects:
# Search through all mobject on the screne, not just the palette
to_search = list(it.chain(*(
mobject.family_members_with_points()
for mobject in self.mobjects
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_fill_color())
self.remove(self.color_palette)
elif self.window.is_key_pressed(SHIFT_SYMBOL):
mob = self.point_to_mobject(point)
if mob is not None:
self.toggle_from_selection(mob)
else:
self.clear_selection()

View file

@ -4,20 +4,25 @@ import time
import random
import inspect
import platform
import itertools as it
from functools import wraps
from typing import Iterable, Callable
import os
from tqdm import tqdm as ProgressDisplay
import numpy as np
import numpy.typing as npt
from manimlib.animation.animation import prepare_animation
from manimlib.animation.transform import MoveToTarget
from manimlib.camera.camera import Camera
from manimlib.config import get_custom_config
from manimlib.constants import DEFAULT_WAIT_TIME
from manimlib.constants import ARROW_SYMBOLS
from manimlib.constants import SHIFT_MODIFIER, CTRL_MODIFIER, COMMAND_MODIFIER
from manimlib.mobject.mobject import Mobject
from manimlib.mobject.mobject import Point
from manimlib.mobject.mobject import Group
from manimlib.mobject.types.vectorized_mobject import VMobject
from manimlib.mobject.types.vectorized_mobject import VGroup
from manimlib.scene.scene_file_writer import SceneFileWriter
from manimlib.utils.config_ops import digest_config
from manimlib.utils.family_ops import extract_mobject_family_members
@ -33,6 +38,14 @@ if TYPE_CHECKING:
from manimlib.animation.animation import Animation
PAN_3D_KEY = 'd'
FRAME_SHIFT_KEY = 'f'
ZOOM_KEY = 'z'
RESET_FRAME_KEY = 'r'
QUIT_KEY = 'q'
EMBED_KEY = 'e'
class Scene(object):
CONFIG = {
"window_config": {},
@ -116,16 +129,17 @@ class Scene(object):
# If there is a window, enter a loop
# which updates the frame while under
# the hood calling the pyglet event loop
log.info("Tips: You are now in the interactive mode. Now you can use the keyboard"
" and the mouse to interact with the scene. Just press `q` if you want to quit.")
log.info(
"Tips: You are now in the interactive mode. Now you can use the keyboard"
" and the mouse to interact with the scene. Just press `command + q` or `esc`"
" if you want to quit."
)
self.quit_interaction = False
self.lock_static_mobject_data()
self.refresh_static_mobjects()
while not (self.window.is_closing or self.quit_interaction):
self.update_frame(1 / self.camera.frame_rate)
if self.window.is_closing:
self.window.destroy()
if self.quit_interaction:
self.unlock_mobject_data()
def embed(self, close_scene_on_exit: bool = True) -> None:
if not self.preview:
@ -142,15 +156,18 @@ class Scene(object):
from IPython.terminal.embed import InteractiveShellEmbed
shell = InteractiveShellEmbed()
# Have the frame update after each command
shell.events.register('post_run_cell', lambda *a, **kw: self.refresh_static_mobjects())
shell.events.register('post_run_cell', lambda *a, **kw: self.update_frame())
# Use the locals of the caller as the local namespace
# once embedded, and add a few custom shortcuts
local_ns = inspect.currentframe().f_back.f_locals
local_ns["touch"] = self.interact
local_ns["i2g"] = self.ids_to_group
for term in ("play", "wait", "add", "remove", "clear", "save_state", "restore"):
local_ns[term] = getattr(self, term)
log.info("Tips: Now the embed iPython terminal is open. But you can't interact with"
" the window directly. To do so, you need to type `touch()` or `self.interact()`")
exec(get_custom_config()["universal_import_line"])
shell(local_ns=local_ns, stack_depth=2)
# End scene when exiting an embed
if close_scene_on_exit:
@ -295,6 +312,25 @@ class Scene(object):
return mobject
return None
def get_group(self, *mobjects):
if all(isinstance(m, VMobject) for m in mobjects):
return VGroup(*mobjects)
else:
return Group(*mobjects)
def id_to_mobject(self, id_value):
for mob in self.mobjects:
for sm in mob.get_family():
if id(sm) == id_value:
return sm
return None
def ids_to_group(self, *id_values):
return self.get_group(*filter(
lambda x: x is not None,
map(self.id_to_mobject, id_values)
))
# Related to skipping
def update_skipping_status(self) -> None:
if self.start_at_animation_number is not None:
@ -443,6 +479,7 @@ class Scene(object):
self.real_animation_start_time = time.time()
self.virtual_animation_start_time = self.time
self.refresh_static_mobjects()
func(self, *args, **kwargs)
if should_write:
@ -451,23 +488,8 @@ class Scene(object):
self.num_plays += 1
return wrapper
def lock_static_mobject_data(self, *animations: Animation) -> None:
movers = list(it.chain(*[
anim.mobject.get_family()
for anim in animations
]))
for mobject in self.mobjects:
if mobject in movers or mobject.get_family_updaters():
continue
self.camera.set_mobjects_as_static(mobject)
def unlock_mobject_data(self) -> None:
self.camera.release_static_mobjects()
def refresh_locked_data(self):
self.unlock_mobject_data()
self.lock_static_mobject_data()
return self
def refresh_static_mobjects(self) -> None:
self.camera.refresh_static_mobjects()
def begin_animations(self, animations: Iterable[Animation]) -> None:
for animation in animations:
@ -507,11 +529,9 @@ class Scene(object):
log.warning("Called Scene.play with no animations")
return
animations = self.anims_from_play_args(*args, **kwargs)
self.lock_static_mobject_data(*animations)
self.begin_animations(animations)
self.progress_through_animations(animations)
self.finish_animations(animations)
self.unlock_mobject_data()
@handle_play_like_call
def wait(
@ -524,7 +544,6 @@ class Scene(object):
if note:
log.info(note)
self.update_mobjects(dt=0) # Any problems with this?
self.lock_static_mobject_data()
if self.presenter_mode and not self.skip_animations and not ignore_presenter_mode:
while self.hold_on_wait:
self.update_frame(dt=1 / self.camera.frame_rate)
@ -538,9 +557,8 @@ class Scene(object):
self.update_frame(dt)
self.emit_frame()
if stop_condition is not None and stop_condition():
time_progression.close()
break
self.unlock_mobject_data()
self.refresh_static_mobjects()
return self
def wait_until(
@ -574,22 +592,31 @@ class Scene(object):
# Helpers for interactive development
def save_state(self) -> None:
self.saved_state = {
"mobjects": self.mobjects,
"mobject_states": [
mob.copy()
for mob in self.mobjects
],
}
self.saved_state = [
(mob, mob.copy())
for mob in self.mobjects
]
def restore(self) -> None:
if not hasattr(self, "saved_state"):
raise Exception("Trying to restore scene without having saved")
mobjects = self.saved_state["mobjects"]
states = self.saved_state["mobject_states"]
for mob, state in zip(mobjects, states):
mob.become(state)
self.mobjects = mobjects
self.mobjects = []
for mob, mob_state in self.saved_state:
mob.become(mob_state)
self.mobjects.append(mob)
def save_mobect(self, mobject: Mobject, file_name: str):
directory = self.file_writer.get_saved_mobject_directory()
path = os.path.join(directory, file_name)
mobject.save_to_file(path)
def load_mobject(self, file_name):
if os.path.exists(file_name):
path = file_name
else:
directory = self.file_writer.get_saved_mobject_directory()
path = os.path.join(directory, file_name)
return Mobject.load(path)
# Event handling
@ -606,10 +633,12 @@ class Scene(object):
return
frame = self.camera.frame
if self.window.is_key_pressed(ord("d")):
# 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])
elif self.window.is_key_pressed(ord("s")):
# Handle frame movements
elif self.window.is_key_pressed(ord(FRAME_SHIFT_KEY)):
shift = -d_point
shift[0] *= frame.get_width() / 2
shift[1] *= frame.get_height() / 2
@ -637,6 +666,7 @@ class Scene(object):
button: int,
mods: int
) -> None:
self.mouse_drag_point.move_to(point)
event_data = {"point": point, "button": button, "mods": mods}
propagate_event = EVENT_DISPATCHER.dispatch(EventType.MousePressEvent, **event_data)
if propagate_event is not None and propagate_event is False:
@ -664,9 +694,9 @@ class Scene(object):
return
frame = self.camera.frame
if self.window.is_key_pressed(ord("z")):
if self.window.is_key_pressed(ord(ZOOM_KEY)):
factor = 1 + np.arctan(10 * offset[1])
frame.scale(1/factor, about_point=point)
frame.scale(1 / factor, about_point=point)
else:
transform = frame.get_inverse_camera_rotation_matrix()
shift = np.dot(np.transpose(transform), offset)
@ -698,13 +728,16 @@ class Scene(object):
if propagate_event is not None and propagate_event is False:
return
if char == "r":
if char == RESET_FRAME_KEY:
self.camera.frame.to_default_state()
elif char == "q":
# command + q
elif char == QUIT_KEY and modifiers == COMMAND_MODIFIER:
self.quit_interaction = True
elif char == " " or symbol == 65363: # Space or right arrow
# Space or right arrow
elif char == " " or symbol == ARROW_SYMBOLS[2]:
self.hold_on_wait = False
elif char == "e" and modifiers == 3: # ctrl + shift + e
# ctrl + shift + e
elif char == EMBED_KEY and modifiers == CTRL_MODIFIER | SHIFT_MODIFIER:
self.embed(close_scene_on_exit=False)
def on_resize(self, width: int, height: int) -> None:

View file

@ -78,6 +78,8 @@ class SceneFileWriter(object):
self.partial_movie_directory = guarantee_existence(os.path.join(
movie_dir, "partial_movie_files", scene_name,
))
# A place to save mobjects
self.saved_mobject_directory = os.path.join(out_dir, "mobjects")
def get_default_module_directory(self) -> str:
path, _ = os.path.splitext(self.input_file_path)
@ -119,6 +121,12 @@ class SceneFileWriter(object):
def get_movie_file_path(self) -> str:
return self.movie_file_path
def get_saved_mobject_directory(self) -> str:
return guarantee_existence(os.path.join(
self.saved_mobject_directory,
str(self.scene),
))
# Sound
def init_audio(self) -> None:
self.includes_sound: bool = False
@ -220,7 +228,7 @@ class SceneFileWriter(object):
# This is if the background of the exported
# video should be transparent.
command += [
'-vcodec', 'qtrle',
'-vcodec', 'prores_ks',
]
elif self.movie_file_extension == ".gif":
command += []

View file

@ -1,6 +1,5 @@
from __future__ import annotations
import itertools as it
from typing import Iterable
from typing import TYPE_CHECKING
@ -11,15 +10,14 @@ if TYPE_CHECKING:
def extract_mobject_family_members(
mobject_list: Iterable[Mobject],
only_those_with_points: bool = False
exclude_pointless: bool = False
) -> list[Mobject]:
result = list(it.chain(*[
mob.get_family()
return [
sm
for mob in mobject_list
]))
if only_those_with_points:
result = [mob for mob in result if mob.has_points()]
return result
for sm in mob.get_family()
if (not exclude_pointless) or sm.has_points()
]
def restructure_list_to_exclude_certain_family_members(

View file

@ -152,6 +152,8 @@ def angle_between_vectors(v1: np.ndarray, v2: np.ndarray) -> float:
"""
n1 = get_norm(v1)
n2 = get_norm(v2)
if n1 == 0 or n2 == 0:
return 0
cos_angle = np.dot(v1, v2) / np.float64(n1 * n2)
return math.acos(clip(cos_angle, -1, 1))

View file

@ -92,7 +92,7 @@ def tex_to_dvi(tex_file):
for line in file.readlines():
if line.startswith("!"):
log.debug(f"The error could be: `{line[2:-1]}`")
sys.exit(2)
raise LatexError()
return result
@ -134,3 +134,8 @@ def display_during_execution(message):
yield
finally:
print(" " * len(to_print), end="\r")
class LatexError(Exception):
pass

View file

@ -11,6 +11,7 @@ moderngl_window
skia-pathops
pydub
pygments
pyperclip
pyyaml
rich
screeninfo