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

Improved embed, fixes to Mobject.copy
This commit is contained in:
Grant Sanderson 2022-04-23 09:16:46 -07:00 committed by GitHub
commit 753ef3b74a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 588 additions and 310 deletions

View file

@ -117,16 +117,19 @@ def parse_cli():
)
parser.add_argument(
"-n", "--start_at_animation_number",
help="Start rendering not from the first animation, but"
"from another, specified by its index. If you pass"
"in two comma separated values, e.g. \"3,6\", it will end"
help="Start rendering not from the first animation, but "
"from another, specified by its index. If you pass "
"in two comma separated values, e.g. \"3,6\", it will end "
"the rendering at the second value",
)
parser.add_argument(
"-e", "--embed", metavar="LINENO",
help="Takes a line number as an argument, and results"
"in the scene being called as if the line `self.embed()`"
"was inserted into the scene code at that line number."
"-e", "--embed",
nargs="?",
const="",
help="Creates a new file where the line `self.embed` is inserted "
"into the Scenes construct method. "
"If a string is passed in, the line will be inserted below the "
"last line of code including that string."
)
parser.add_argument(
"-r", "--resolution",
@ -185,14 +188,62 @@ def get_module(file_name):
return module
def get_indent(line: str):
return len(line) - len(line.lstrip())
@contextmanager
def insert_embed_line(file_name, lineno):
def insert_embed_line(file_name: str, scene_name: str, line_marker: str):
"""
This is hacky, but convenient. When user includes the argument "-e", it will try
to recreate a file that inserts the line `self.embed()` into the end of the scene's
construct method. If there is an argument passed in, it will insert the line after
the last line in the sourcefile which includes that string.
"""
with open(file_name, 'r') as fp:
lines = fp.readlines()
line = lines[lineno - 1]
n_spaces = len(line) - len(line.lstrip())
lines.insert(lineno, " " * n_spaces + "self.embed()\n")
try:
scene_line_number = next(
i for i, line in enumerate(lines)
if line.startswith(f"class {scene_name}")
)
except StopIteration:
log.error(f"No scene {scene_name}")
prev_line_num = None
n_spaces = None
if len(line_marker) == 0:
# Find the end of the construct method
in_construct = False
for index in range(scene_line_number, len(lines) - 1):
line = lines[index]
if line.lstrip().startswith("def construct"):
in_construct = True
n_spaces = get_indent(line) + 4
elif in_construct:
if len(line.strip()) > 0 and get_indent(line) < n_spaces:
prev_line_num = index - 2
break
elif line_marker.isdigit():
# Treat the argument as a line number
prev_line_num = int(line_marker) - 1
elif len(line_marker) > 0:
# Treat the argument as a string
try:
prev_line_num = next(
i
for i in range(len(lines) - 1, scene_line_number, -1)
if line_marker in lines[i]
)
except StopIteration:
log.error(f"No lines matching {line_marker}")
sys.exit(2)
# Insert and write new file
if n_spaces is None:
n_spaces = get_indent(lines[prev_line_num])
lines.insert(prev_line_num + 1, " " * n_spaces + "self.embed()\n")
alt_file = file_name.replace(".py", "_inserted_embed.py")
with open(alt_file, 'w') as fp:
fp.writelines(lines)
@ -296,10 +347,10 @@ def get_configuration(args):
"quiet": args.quiet,
}
if args.embed is None:
module = get_module(args.file)
else:
with insert_embed_line(args.file, int(args.embed)) as alt_file:
module = get_module(args.file)
if args.embed is not None:
with insert_embed_line(args.file, args.scene_names[0], args.embed) as alt_file:
module = get_module(alt_file)
config = {

View file

@ -75,6 +75,7 @@ DEFAULT_STROKE_WIDTH = 4
# For keyboard interactions
CTRL_SYMBOL = 65508
SHIFT_SYMBOL = 65505
COMMAND_SYMBOL = 65517
DELETE_SYMBOL = 65288
ARROW_SYMBOLS = list(range(65361, 65365))

View file

@ -4,10 +4,11 @@ import sys
from manimlib.config import get_custom_config
from manimlib.logger import log
from manimlib.scene.interactive_scene import InteractiveScene
from manimlib.scene.scene import Scene
class BlankScene(Scene):
class BlankScene(InteractiveScene):
def construct(self):
exec(get_custom_config()["universal_import_line"])
self.embed()

View file

@ -33,7 +33,6 @@ from manimlib.utils.config_ops import digest_config
from manimlib.utils.iterables import batch_by_property
from manimlib.utils.iterables import list_update
from manimlib.utils.iterables import listify
from manimlib.utils.iterables import make_even
from manimlib.utils.iterables import resize_array
from manimlib.utils.iterables import resize_preserving_order
from manimlib.utils.iterables import resize_with_interpolation
@ -96,7 +95,8 @@ class Mobject(object):
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.saved_state = None
self.target = None
self.init_data()
self.init_uniforms()
@ -148,8 +148,10 @@ class Mobject(object):
return self
def set_uniforms(self, uniforms: dict):
for key in uniforms:
self.uniforms[key] = uniforms[key] # Copy?
for key, value in uniforms.items():
if isinstance(value, np.ndarray):
value = value.copy()
self.uniforms[key] = value
return self
@property
@ -472,66 +474,91 @@ class Mobject(object):
self.assemble_family()
return self
# Creating new Mobjects from this one
# Copying and serialization
def replicate(self, n: int) -> Group:
return self.get_group_class()(
*(self.copy() for x in range(n))
)
def stash_mobject_pointers(func):
@wraps(func)
def wrapper(self, *args, **kwargs):
uncopied_attrs = ["parents", "target", "saved_state"]
stash = dict()
for attr in uncopied_attrs:
if hasattr(self, attr):
value = getattr(self, attr)
stash[attr] = value
null_value = [] if isinstance(value, list) else None
setattr(self, attr, null_value)
result = func(self, *args, **kwargs)
self.__dict__.update(stash)
return result
return wrapper
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
@stash_mobject_pointers
def serialize(self):
return pickle.dumps(self)
# Copying
def deserialize(self, data: bytes):
self.become(pickle.loads(data))
return self
def copy(self):
self.parents = []
try:
return pickle.loads(pickle.dumps(self))
except AttributeError:
return copy.deepcopy(self)
@stash_mobject_pointers
def copy(self, deep: bool = False):
if deep:
try:
# Often faster than deepcopy
return pickle.loads(pickle.dumps(self))
except AttributeError:
return copy.deepcopy(self)
result = copy.copy(self)
# The line above is only a shallow copy, so the internal
# data which are numpyu arrays or other mobjects still
# need to be further copied.
result.data = dict(self.data)
for key in result.data:
result.data[key] = result.data[key].copy()
result.uniforms = dict(self.uniforms)
for key in result.uniforms:
if isinstance(result.uniforms[key], np.ndarray):
result.uniforms[key] = result.uniforms[key].copy()
result.submobjects = []
result.add(*(sm.copy() for sm in self.submobjects))
result.match_updaters(self)
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(result, attr, result.family[self.family.index(value)])
if isinstance(value, np.ndarray):
setattr(result, attr, value.copy())
if isinstance(value, ShaderWrapper):
setattr(result, attr, value.copy())
return result
def deepcopy(self):
# This used to be different from copy, so is now just here for backward compatibility
return self.copy()
return self.copy(deep=True)
def generate_target(self, use_deepcopy: bool = False):
# TODO, remove now pointless use_deepcopy arg
self.target = None # Prevent exponential explosion
self.target = self.copy()
self.target = self.copy(deep=use_deepcopy)
self.target.saved_state = self.saved_state
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
self.saved_state = self.copy()
self.saved_state = self.copy(deep=use_deepcopy)
self.saved_state.target = self.target
return self
def restore(self):
if not hasattr(self, "saved_state") or self.save_state is None:
if not hasattr(self, "saved_state") or self.saved_state is None:
raise Exception("Trying to restore without having saved")
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
def save_to_file(self, file_path: str, supress_overwrite_warning: bool = False):
with open(file_path, "wb") as fp:
pickle.dump(self, fp)
fp.write(self.serialize())
log.info(f"Saved mobject to {file_path}")
return self
@ -544,6 +571,39 @@ class Mobject(object):
mobject = pickle.load(fp)
return mobject
def become(self, mobject: Mobject):
"""
Edit all data and submobjects to be idential
to another mobject
"""
self.align_family(mobject)
for sm1, sm2 in zip(self.get_family(), mobject.get_family()):
sm1.set_data(sm2.data)
sm1.set_uniforms(sm2.uniforms)
sm1.shader_folder = sm2.shader_folder
sm1.texture_paths = sm2.texture_paths
sm1.depth_test = sm2.depth_test
sm1.render_primitive = sm2.render_primitive
self.refresh_bounding_box(recurse_down=True)
return self
# Creating new Mobjects from this one
def replicate(self, n: int) -> Group:
group_class = self.get_group_class()
return group_class(*(self.copy() for _ in range(n)))
def get_grid(self, n_rows: int, n_cols: int, height: float | None = None, **kwargs) -> Group:
"""
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
# Updating
def init_updaters(self):
@ -646,21 +706,13 @@ class Mobject(object):
# 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
return self._is_animating or self.has_updaters
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):
@ -1540,18 +1592,6 @@ class Mobject(object):
"""
pass # To implement in subclass
def become(self, mobject: Mobject):
"""
Edit all data and submobjects to be idential
to another mobject
"""
self.align_family(mobject)
for sm1, sm2 in zip(self.get_family(), mobject.get_family()):
sm1.set_data(sm2.data)
sm1.set_uniforms(sm2.uniforms)
self.refresh_bounding_box(recurse_down=True)
return self
# Locking data
def lock_data(self, keys: Iterable[str]):

View file

@ -8,6 +8,8 @@ import operator as op
import moderngl
import numpy as np
from manimlib.constants import GREY_C
from manimlib.constants import GREY_E
from manimlib.constants import BLACK, WHITE
from manimlib.constants import DEFAULT_STROKE_WIDTH
from manimlib.constants import DEGREES
@ -23,6 +25,7 @@ from manimlib.utils.bezier import integer_interpolate
from manimlib.utils.bezier import interpolate
from manimlib.utils.bezier import inverse_interpolate
from manimlib.utils.bezier import partial_quadratic_bezier_points
from manimlib.utils.color import color_gradient
from manimlib.utils.color import rgb_to_hex
from manimlib.utils.iterables import listify
from manimlib.utils.iterables import make_even
@ -1193,3 +1196,24 @@ class DashedVMobject(VMobject):
# Family is already taken care of by get_subcurve
# implementation
self.match_style(vmobject, recurse=False)
class VHighlight(VGroup):
def __init__(
self,
vmobject: VMobject,
n_layers: int = 3,
color_bounds: tuple[ManimColor] = (GREY_C, GREY_E),
max_stroke_width: float = 10.0,
):
outline = vmobject.replicate(n_layers)
outline.set_fill(opacity=0)
added_widths = np.linspace(0, max_stroke_width, n_layers + 1)[1:]
colors = color_gradient(color_bounds, n_layers)
for part, added_width, color in zip(reversed(outline), added_widths, colors):
for sm in part.family_members_with_points():
part.set_stroke(
width=sm.get_stroke_width() + added_width,
color=color,
)
super().__init__(*outline)

View file

@ -1,24 +1,23 @@
import itertools as it
import numpy as np
import os
import platform
import pyperclip
from manimlib.animation.fading import FadeIn
from manimlib.constants import ARROW_SYMBOLS, DELETE_SYMBOL, SHIFT_SYMBOL
from manimlib.constants import ARROW_SYMBOLS, CTRL_SYMBOL, DELETE_SYMBOL, SHIFT_SYMBOL
from manimlib.constants import COMMAND_MODIFIER, SHIFT_MODIFIER
from manimlib.constants import DL, DOWN, DR, LEFT, ORIGIN, RIGHT, UL, UP, UR
from manimlib.constants import FRAME_WIDTH, SMALL_BUFF
from manimlib.constants import MANIM_COLORS, WHITE, YELLOW
from manimlib.logger import log
from manimlib.constants import MANIM_COLORS, WHITE, GREY_C
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.utils.family_ops import extract_mobject_family_members
@ -28,14 +27,17 @@ from manimlib.utils.tex_file_writing import LatexError
SELECT_KEY = 's'
GRAB_KEY = 'g'
HORIZONTAL_GRAB_KEY = 'h'
VERTICAL_GRAB_KEY = 'v'
X_GRAB_KEY = 'h'
Y_GRAB_KEY = 'v'
GRAB_KEYS = [GRAB_KEY, X_GRAB_KEY, Y_GRAB_KEY]
RESIZE_KEY = 't'
COLOR_KEY = 'c'
CURSOR_LOCATION_KEY = 'l'
# 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,
@ -66,36 +68,102 @@ class InteractiveScene(Scene):
selection_rectangle_stroke_width = 1.0
colors = MANIM_COLORS
selection_nudge_size = 0.05
cursor_location_config = dict(
font_size=14,
fill_color=GREY_C,
num_decimal_places=3,
)
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.cursor_location_label = self.get_cursor_location_label()
self.unselectables = [
self.selection,
self.selection_highlight,
self.selection_rectangle,
self.cursor_location_label,
self.camera.frame
]
self.saved_selection_state = []
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):
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 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_cursor_location_label(self):
decimals = VGroup(*(
DecimalNumber(**self.cursor_location_config)
for n in range(3)
))
def update_coords(decimals):
for mob, coord in zip(decimals, self.mouse_point.get_location()):
mob.set_value(coord)
decimals.arrange(RIGHT, buff=decimals.get_height())
decimals.to_corner(DR, buff=SMALL_BUFF)
decimals.fix_in_frame()
return decimals
decimals.add_updater(update_coords)
return decimals
# 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):
mobs = [m for m in self.mobjects if m not in self.unselectables]
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:
return mobs
self.selection_search_set = selectable
else:
return [
self.selection_search_set = [
submob
for mob in mobs
for mob in selectable
for submob in mob.family_members_with_points()
]
@ -116,37 +184,7 @@ class InteractiveScene(Scene):
)
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):
def get_corner_dots(self, mobject: Mobject) -> Mobject:
dots = DotCloud(**self.corner_dot_config)
radius = self.corner_dot_config["radius"]
if mobject.get_depth() < 1e-2:
@ -159,9 +197,11 @@ class InteractiveScene(Scene):
]))
return dots
def get_highlight(self, mobject):
if isinstance(mobject, VMobject) and mobject.has_points():
return self.get_stroke_highlight(mobject)
def get_highlight(self, mobject: Mobject) -> Mobject:
if isinstance(mobject, VMobject) and mobject.has_points() and not self.select_top_level_mobs:
result = VHighlight(mobject)
result.add_updater(lambda m: m.replace(mobject))
return result
else:
return self.get_corner_dots(mobject)
@ -171,40 +211,54 @@ class InteractiveScene(Scene):
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):
mobs = list(filter(lambda m: m not in self.unselectables, mobjects))
self.selection.add(*mobjects)
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)
self.selection_highlight.add(*map(self.get_highlight, mobs))
self.saved_selection_state = [(mob, mob.copy()) for mob in self.selection]
for mob in mobs:
mob.set_animating_status(True)
self.refresh_static_mobjects()
def toggle_from_selection(self, *mobjects):
for mob in mobjects:
if mob in self.selection:
self.selection.remove(mob)
mob.set_animating_status(False)
else:
self.add_to_selection(mob)
self.refresh_selection_highlight()
def clear_selection(self):
for mob in self.selection:
mob.set_animating_status(False)
self.selection.set_submobjects([])
self.selection_highlight.set_submobjects([])
self.refresh_static_mobjects()
def add(self, *new_mobjects: Mobject):
for mob in new_mobjects:
mob.make_movable()
super().add(*new_mobjects)
self.regenerate_selection_search_set()
# Selection operations
def remove(self, *mobjects: Mobject):
super().remove(*mobjects)
self.regenerate_selection_search_set()
def disable_interaction(self, *mobjects: Mobject):
for mob in mobjects:
self.unselectables.append(mob)
self.regenerate_selection_search_set()
def enable_interaction(self, *mobjects: Mobject):
for mob in mobjects:
if mob in self.unselectables:
self.unselectables.remove(mob)
# Functions for keyboard actions
def copy_selection(self):
ids = map(id, self.selection)
@ -218,11 +272,11 @@ class InteractiveScene(Scene):
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
))
self.add_to_selection(*mob_copies)
return
except ValueError:
pass
@ -242,41 +296,27 @@ class InteractiveScene(Scene):
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
if platform.system() == 'Darwin':
user_name = os.popen(f"""
osascript -e '
set chosenfile to (choose file name default name "{file_name}" default location "{directory}")
POSIX path of chosenfile'
""").read()
user_name = user_name.replace("\n", "")
else:
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)
def restore_state(self, mobject_states: list[tuple[Mobject, Mobject]]):
super().restore_state(mobject_states)
self.refresh_selection_highlight()
def enable_selection(self):
self.is_selecting = True
self.add(self.selection_rectangle)
self.selection_rectangle.fixed_corner = self.mouse_point.get_center().copy()
def gather_new_selection(self):
self.is_selecting = False
self.remove(self.selection_rectangle)
for mob in reversed(self.get_selection_search_set()):
if self.selection_rectangle.is_touching(mob):
self.add_to_selection(mob)
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()
@ -286,136 +326,177 @@ class InteractiveScene(Scene):
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()
# Event handlers
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 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)
def save_selection_to_file(self):
if len(self.selection) == 1:
self.save_mobject_to_file(self.selection[0])
else:
self.save_mobject_to_file(self.selection)
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
self.enable_selection()
elif char in GRAB_KEYS and modifiers == 0:
self.prepare_grab()
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
self.toggle_color_palette()
elif char == CURSOR_LOCATION_KEY and modifiers == 0:
self.add(self.cursor_location_label)
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
self.group_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
self.ungroup_selection()
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 == "z" and modifiers == COMMAND_MODIFIER | SHIFT_MODIFIER:
self.redo()
elif char == "s" and modifiers == COMMAND_MODIFIER:
self.saved_selection_to_file()
# Keyboard movements
self.save_selection_to_file()
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)
self.nudge_selection(
vect=[LEFT, UP, RIGHT, DOWN][ARROW_SYMBOLS.index(symbol)],
large=(modifiers & SHIFT_MODIFIER),
)
# 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.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)
self.gather_new_selection()
if chr(symbol) in GRAB_KEYS:
self.is_grabbing = False
elif chr(symbol) == CURSOR_LOCATION_KEY:
self.remove(self.cursor_location_label)
elif symbol == SHIFT_SYMBOL and self.window.is_key_pressed(ord(RESIZE_KEY)):
self.prepare_resizing(about_corner=False)
elif symbol == SHIFT_SYMBOL:
if self.window.is_key_pressed(ord(RESIZE_KEY)):
self.prepare_resizing(about_corner=False)
# Mouse actions
def handle_grabbing(self, point: np.ndarray):
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 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
def handle_resizing(self, point: np.ndarray):
vect = point - self.scale_about_point
if self.window.is_key_pressed(CTRL_SYMBOL):
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: np.ndarray):
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: np.ndarray):
# 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 toggle_clicked_mobject_from_selection(self, point: np.ndarray):
mob = self.point_to_mobject(
point,
search_set=self.get_selection_search_set(),
buff=SMALL_BUFF
)
if mob is not None:
self.toggle_from_selection(mob)
def on_mouse_motion(self, point: np.ndarray, d_point: np.ndarray) -> None:
super().on_mouse_motion(point, d_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(SHIFT_SYMBOL):
self.handle_sweeping_selection(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)
self.choose_color(point)
elif self.window.is_key_pressed(SHIFT_SYMBOL):
mob = self.point_to_mobject(point)
if mob is not None:
self.toggle_from_selection(mob)
self.toggle_clicked_mobject_from_selection(point)
else:
self.clear_selection()

View file

@ -14,10 +14,9 @@ from tqdm import tqdm as ProgressDisplay
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 ARROW_SYMBOLS
from manimlib.constants import DEFAULT_WAIT_TIME
from manimlib.constants import COMMAND_MODIFIER, CTRL_MODIFIER, SHIFT_MODIFIER
from manimlib.constants import COMMAND_MODIFIER
from manimlib.event_handler import EVENT_DISPATCHER
from manimlib.event_handler.event_type import EventType
from manimlib.logger import log
@ -46,7 +45,6 @@ FRAME_SHIFT_KEY = 'f'
ZOOM_KEY = 'z'
RESET_FRAME_KEY = 'r'
QUIT_KEY = 'q'
EMBED_KEY = 'e'
class Scene(object):
@ -65,6 +63,7 @@ class Scene(object):
"presenter_mode": False,
"linger_after_completion": True,
"pan_sensitivity": 3,
"max_num_saved_states": 20,
}
def __init__(self, **kwargs):
@ -74,12 +73,15 @@ class Scene(object):
self.window = Window(scene=self, **self.window_config)
self.camera_config["ctx"] = self.window.ctx
self.camera_config["frame_rate"] = 30 # Where's that 30 from?
self.undo_stack = []
self.redo_stack = []
else:
self.window = None
self.camera: Camera = self.camera_class(**self.camera_config)
self.file_writer = SceneFileWriter(self, **self.file_writer_config)
self.mobjects: list[Mobject] = [self.camera.frame]
self.id_to_mobject_map: dict[int, Mobject] = dict()
self.num_plays: int = 0
self.time: float = 0
self.skip_time: float = 0
@ -91,12 +93,16 @@ class Scene(object):
self.mouse_point = Point()
self.mouse_drag_point = Point()
self.hold_on_wait = self.presenter_mode
self.inside_embed = False
# Much nicer to work with deterministic scenes
if self.random_seed is not None:
random.seed(self.random_seed)
np.random.seed(self.random_seed)
def __str__(self) -> str:
return self.__class__.__name__
def run(self) -> None:
self.virtual_animation_start_time: float = 0
self.real_animation_start_time: float = time.time()
@ -146,40 +152,57 @@ class Scene(object):
def embed(self, close_scene_on_exit: bool = True) -> None:
if not self.preview:
# If the scene is just being
# written, ignore embed calls
# Ignore embed calls when there is no preview
return
self.inside_embed = True
self.stop_skipping()
self.linger_after_completion = False
self.update_frame()
# Save scene state at the point of embedding
self.save_state()
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
# Configure and launch embedded IPython terminal
from IPython.terminal import embed, pt_inputhooks
shell = embed.InteractiveShellEmbed.instance()
# Use the locals namespace of the caller
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"])
# Add a few custom shortcuts
local_ns.update({
name: getattr(self, name)
for name in [
"play", "wait", "add", "remove", "clear",
"save_state", "undo", "redo", "i2g", "i2m"
]
})
# Enables gui interactions during the embed
def inputhook(context):
while not context.input_is_ready():
if self.window.is_closing:
pass
# self.window.destroy()
else:
self.update_frame(dt=0)
pt_inputhooks.register("manim", inputhook)
shell.enable_gui("manim")
# Operation to run after each ipython command
def post_cell_func(*args, **kwargs):
self.refresh_static_mobjects()
shell.events.register("post_run_cell", post_cell_func)
# Launch shell, with stack_depth=2 indicating we should use caller globals/locals
shell(local_ns=local_ns, stack_depth=2)
self.inside_embed = False
# End scene when exiting an embed
if close_scene_on_exit:
raise EndSceneEarlyException()
def __str__(self) -> str:
return self.__class__.__name__
# Only these methods should touch the camera
def get_image(self) -> Image:
return self.camera.get_image()
@ -210,6 +233,7 @@ class Scene(object):
self.file_writer.write_frame(self.camera)
# Related to updating
def update_mobjects(self, dt: float) -> None:
for mobject in self.mobjects:
mobject.update(dt)
@ -228,6 +252,7 @@ class Scene(object):
])
# Related to time
def get_time(self) -> float:
return self.time
@ -235,6 +260,7 @@ class Scene(object):
self.time += dt
# Related to internal mobject organization
def get_top_level_mobjects(self) -> list[Mobject]:
# Return only those which are not in the family
# of another mobject from the scene
@ -259,6 +285,11 @@ class Scene(object):
"""
self.remove(*new_mobjects)
self.mobjects += new_mobjects
self.id_to_mobject_map.update({
id(sm): sm
for m in new_mobjects
for sm in m.get_family()
})
return self
def add_mobjects_among(self, values: Iterable):
@ -322,11 +353,7 @@ class Scene(object):
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
return self.id_to_mobject_map[id_value]
def ids_to_group(self, *id_values):
return self.get_group(*filter(
@ -334,7 +361,14 @@ class Scene(object):
map(self.id_to_mobject, id_values)
))
def i2g(self, *id_values):
return self.ids_to_group(*id_values)
def i2m(self, id_value):
return self.id_to_mobject(id_value)
# Related to skipping
def update_skipping_status(self) -> None:
if self.start_at_animation_number is not None:
if self.num_plays == self.start_at_animation_number:
@ -350,6 +384,7 @@ class Scene(object):
self.skip_animations = False
# Methods associated with running animations
def get_time_progression(
self,
run_time: float,
@ -473,6 +508,8 @@ class Scene(object):
def handle_play_like_call(func):
@wraps(func)
def wrapper(self, *args, **kwargs):
if self.inside_embed:
self.save_state()
self.update_skipping_status()
should_write = not self.skip_animations
if should_write:
@ -594,24 +631,39 @@ class Scene(object):
self.file_writer.add_sound(sound_file, time, gain, gain_to_background)
# Helpers for interactive development
def get_state(self) -> list[tuple[Mobject, Mobject]]:
return [(mob, mob.copy()) for mob in self.mobjects]
def restore_state(self, mobject_states: list[tuple[Mobject, Mobject]]):
self.mobjects = [mob.become(mob_copy) for mob, mob_copy in mobject_states]
def save_state(self) -> None:
self.saved_state = [
(mob, mob.copy())
for mob in self.mobjects
]
if not self.preview:
return
self.redo_stack = []
self.undo_stack.append(self.get_state())
if len(self.undo_stack) > self.max_num_saved_states:
self.undo_stack.pop(0)
def restore(self) -> None:
if not hasattr(self, "saved_state"):
raise Exception("Trying to restore scene without having saved")
self.mobjects = []
for mob, mob_state in self.saved_state:
mob.become(mob_state)
self.mobjects.append(mob)
def undo(self):
if self.undo_stack:
self.redo_stack.append(self.get_state())
self.restore_state(self.undo_stack.pop())
self.refresh_static_mobjects()
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 redo(self):
if self.redo_stack:
self.undo_stack.append(self.get_state())
self.restore_state(self.redo_stack.pop())
self.refresh_static_mobjects()
def save_mobject_to_file(self, mobject: Mobject, file_path: str | None = None) -> None:
if file_path is None:
file_path = self.file_writer.get_saved_mobject_path(mobject)
if file_path is None:
return
mobject.save_to_file(file_path)
def load_mobject(self, file_name):
if os.path.exists(file_name):
@ -739,9 +791,6 @@ class Scene(object):
# Space or right arrow
elif char == " " or symbol == ARROW_SYMBOLS[2]:
self.hold_on_wait = False
# 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:
self.camera.reset_pixel_shape(width, height)

View file

@ -12,6 +12,7 @@ from tqdm import tqdm as ProgressDisplay
from manimlib.constants import FFMPEG_BIN
from manimlib.logger import log
from manimlib.mobject.mobject import Mobject
from manimlib.utils.config_ops import digest_config
from manimlib.utils.file_ops import add_extension_if_not_present
from manimlib.utils.file_ops import get_sorted_integer_files
@ -61,7 +62,7 @@ class SceneFileWriter(object):
# Output directories and files
def init_output_directories(self) -> None:
out_dir = self.output_directory
out_dir = self.output_directory or ""
if self.mirror_module_path:
module_dir = self.get_default_module_directory()
out_dir = os.path.join(out_dir, module_dir)
@ -128,6 +129,36 @@ class SceneFileWriter(object):
str(self.scene),
))
def get_saved_mobject_path(self, mobject: Mobject) -> str | None:
directory = self.get_saved_mobject_directory()
files = os.listdir(directory)
default_name = str(mobject) + "_0.mob"
index = 0
while default_name in files:
default_name = default_name.replace(str(index), str(index + 1))
index += 1
if platform.system() == 'Darwin':
cmds = [
"osascript", "-e",
f"""
set chosenfile to (choose file name default name "{default_name}" default location "{directory}")
POSIX path of chosenfile
""",
]
process = sp.Popen(cmds, stdout=sp.PIPE)
file_path = process.stdout.read().decode("utf-8").split("\n")[0]
if not file_path:
return
else:
user_name = input(f"Enter mobject file name (default is {default_name}): ")
file_path = os.path.join(directory, user_name or default_name)
if os.path.exists(file_path) or os.path.exists(file_path + ".mob"):
if input(f"{file_path} already exists. Overwrite (y/n)? ") != "y":
return
if not file_path.endswith(".mob"):
file_path = file_path + ".mob"
return file_path
# Sound
def init_audio(self) -> None:
self.includes_sound: bool = False