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( parser.add_argument(
"-n", "--start_at_animation_number", "-n", "--start_at_animation_number",
help="Start rendering not from the first animation, but" help="Start rendering not from the first animation, but "
"from another, specified by its index. If you pass" "from another, specified by its index. If you pass "
"in two comma separated values, e.g. \"3,6\", it will end" "in two comma separated values, e.g. \"3,6\", it will end "
"the rendering at the second value", "the rendering at the second value",
) )
parser.add_argument( parser.add_argument(
"-e", "--embed", metavar="LINENO", "-e", "--embed",
help="Takes a line number as an argument, and results" nargs="?",
"in the scene being called as if the line `self.embed()`" const="",
"was inserted into the scene code at that line number." 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( parser.add_argument(
"-r", "--resolution", "-r", "--resolution",
@ -185,14 +188,62 @@ def get_module(file_name):
return module return module
def get_indent(line: str):
return len(line) - len(line.lstrip())
@contextmanager @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: with open(file_name, 'r') as fp:
lines = fp.readlines() 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") alt_file = file_name.replace(".py", "_inserted_embed.py")
with open(alt_file, 'w') as fp: with open(alt_file, 'w') as fp:
fp.writelines(lines) fp.writelines(lines)
@ -296,10 +347,10 @@ def get_configuration(args):
"quiet": args.quiet, "quiet": args.quiet,
} }
if args.embed is None: module = get_module(args.file)
module = get_module(args.file)
else: if args.embed is not None:
with insert_embed_line(args.file, int(args.embed)) as alt_file: with insert_embed_line(args.file, args.scene_names[0], args.embed) as alt_file:
module = get_module(alt_file) module = get_module(alt_file)
config = { config = {

View file

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

View file

@ -4,10 +4,11 @@ import sys
from manimlib.config import get_custom_config from manimlib.config import get_custom_config
from manimlib.logger import log from manimlib.logger import log
from manimlib.scene.interactive_scene import InteractiveScene
from manimlib.scene.scene import Scene from manimlib.scene.scene import Scene
class BlankScene(Scene): class BlankScene(InteractiveScene):
def construct(self): def construct(self):
exec(get_custom_config()["universal_import_line"]) exec(get_custom_config()["universal_import_line"])
self.embed() 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 batch_by_property
from manimlib.utils.iterables import list_update from manimlib.utils.iterables import list_update
from manimlib.utils.iterables import listify 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_array
from manimlib.utils.iterables import resize_preserving_order from manimlib.utils.iterables import resize_preserving_order
from manimlib.utils.iterables import resize_with_interpolation from manimlib.utils.iterables import resize_with_interpolation
@ -96,7 +95,8 @@ class Mobject(object):
self.locked_data_keys: set[str] = set() self.locked_data_keys: set[str] = set()
self.needs_new_bounding_box: bool = True self.needs_new_bounding_box: bool = True
self._is_animating: bool = False self._is_animating: bool = False
self._is_movable: bool = False self.saved_state = None
self.target = None
self.init_data() self.init_data()
self.init_uniforms() self.init_uniforms()
@ -148,8 +148,10 @@ class Mobject(object):
return self return self
def set_uniforms(self, uniforms: dict): def set_uniforms(self, uniforms: dict):
for key in uniforms: for key, value in uniforms.items():
self.uniforms[key] = uniforms[key] # Copy? if isinstance(value, np.ndarray):
value = value.copy()
self.uniforms[key] = value
return self return self
@property @property
@ -472,66 +474,91 @@ class Mobject(object):
self.assemble_family() self.assemble_family()
return self return self
# Creating new Mobjects from this one # Copying and serialization
def replicate(self, n: int) -> Group: def stash_mobject_pointers(func):
return self.get_group_class()( @wraps(func)
*(self.copy() for x in range(n)) 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): @stash_mobject_pointers
""" def serialize(self):
Returns a new mobject containing multiple copies of this one return pickle.dumps(self)
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 deserialize(self, data: bytes):
self.become(pickle.loads(data))
return self
def copy(self): @stash_mobject_pointers
self.parents = [] def copy(self, deep: bool = False):
try: if deep:
return pickle.loads(pickle.dumps(self)) try:
except AttributeError: # Often faster than deepcopy
return copy.deepcopy(self) 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): def deepcopy(self):
# This used to be different from copy, so is now just here for backward compatibility return self.copy(deep=True)
return self.copy()
def generate_target(self, use_deepcopy: bool = False): def generate_target(self, use_deepcopy: bool = False):
# TODO, remove now pointless use_deepcopy arg self.target = self.copy(deep=use_deepcopy)
self.target = None # Prevent exponential explosion self.target.saved_state = self.saved_state
self.target = self.copy()
return self.target return self.target
def save_state(self, use_deepcopy: bool = False): def save_state(self, use_deepcopy: bool = False):
# TODO, remove now pointless use_deepcopy arg self.saved_state = self.copy(deep=use_deepcopy)
if hasattr(self, "saved_state"): self.saved_state.target = self.target
# Prevent exponential growth of data
self.saved_state = None
self.saved_state = self.copy()
return self return self
def restore(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") raise Exception("Trying to restore without having saved")
self.become(self.saved_state) self.become(self.saved_state)
return self return self
def save_to_file(self, file_path): def save_to_file(self, file_path: str, supress_overwrite_warning: bool = False):
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: with open(file_path, "wb") as fp:
pickle.dump(self, fp) fp.write(self.serialize())
log.info(f"Saved mobject to {file_path}") log.info(f"Saved mobject to {file_path}")
return self return self
@ -544,6 +571,39 @@ class Mobject(object):
mobject = pickle.load(fp) mobject = pickle.load(fp)
return mobject 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 # Updating
def init_updaters(self): def init_updaters(self):
@ -646,21 +706,13 @@ class Mobject(object):
# Check if mark as static or not for camera # Check if mark as static or not for camera
def is_changing(self) -> bool: 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: def set_animating_status(self, is_animating: bool, recurse: bool = True) -> None:
for mob in self.get_family(recurse): for mob in self.get_family(recurse):
mob._is_animating = is_animating mob._is_animating = is_animating
return self 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 # Transforming operations
def shift(self, vector: np.ndarray): def shift(self, vector: np.ndarray):
@ -1540,18 +1592,6 @@ class Mobject(object):
""" """
pass # To implement in subclass 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 # Locking data
def lock_data(self, keys: Iterable[str]): def lock_data(self, keys: Iterable[str]):

View file

@ -8,6 +8,8 @@ import operator as op
import moderngl import moderngl
import numpy as np 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 BLACK, WHITE
from manimlib.constants import DEFAULT_STROKE_WIDTH from manimlib.constants import DEFAULT_STROKE_WIDTH
from manimlib.constants import DEGREES 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 interpolate
from manimlib.utils.bezier import inverse_interpolate from manimlib.utils.bezier import inverse_interpolate
from manimlib.utils.bezier import partial_quadratic_bezier_points 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.color import rgb_to_hex
from manimlib.utils.iterables import listify from manimlib.utils.iterables import listify
from manimlib.utils.iterables import make_even from manimlib.utils.iterables import make_even
@ -1193,3 +1196,24 @@ class DashedVMobject(VMobject):
# Family is already taken care of by get_subcurve # Family is already taken care of by get_subcurve
# implementation # implementation
self.match_style(vmobject, recurse=False) 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 itertools as it
import numpy as np import numpy as np
import os
import platform
import pyperclip import pyperclip
from manimlib.animation.fading import FadeIn 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 COMMAND_MODIFIER, SHIFT_MODIFIER
from manimlib.constants import DL, DOWN, DR, LEFT, ORIGIN, RIGHT, UL, UP, UR 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 FRAME_WIDTH, SMALL_BUFF
from manimlib.constants import MANIM_COLORS, WHITE, YELLOW from manimlib.constants import MANIM_COLORS, WHITE, GREY_C
from manimlib.logger import log
from manimlib.mobject.geometry import Rectangle from manimlib.mobject.geometry import Rectangle
from manimlib.mobject.geometry import Square from manimlib.mobject.geometry import Square
from manimlib.mobject.mobject import Group from manimlib.mobject.mobject import Group
from manimlib.mobject.mobject import Mobject from manimlib.mobject.mobject import Mobject
from manimlib.mobject.numbers import DecimalNumber
from manimlib.mobject.svg.tex_mobject import Tex from manimlib.mobject.svg.tex_mobject import Tex
from manimlib.mobject.svg.text_mobject import Text from manimlib.mobject.svg.text_mobject import Text
from manimlib.mobject.types.dot_cloud import DotCloud from manimlib.mobject.types.dot_cloud import DotCloud
from manimlib.mobject.types.vectorized_mobject import VGroup 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.mobject.types.vectorized_mobject import VMobject
from manimlib.scene.scene import Scene from manimlib.scene.scene import Scene
from manimlib.utils.family_ops import extract_mobject_family_members 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' SELECT_KEY = 's'
GRAB_KEY = 'g' GRAB_KEY = 'g'
HORIZONTAL_GRAB_KEY = 'h' X_GRAB_KEY = 'h'
VERTICAL_GRAB_KEY = 'v' Y_GRAB_KEY = 'v'
GRAB_KEYS = [GRAB_KEY, X_GRAB_KEY, Y_GRAB_KEY]
RESIZE_KEY = 't' RESIZE_KEY = 't'
COLOR_KEY = 'c' COLOR_KEY = 'c'
CURSOR_LOCATION_KEY = 'l'
# Note, a lot of the functionality here is still buggy and very much a work in progress. # Note, a lot of the functionality here is still buggy and very much a work in progress.
class InteractiveScene(Scene): class InteractiveScene(Scene):
""" """
To select mobjects on screen, hold ctrl and move the mouse to highlight a region, 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 selection_rectangle_stroke_width = 1.0
colors = MANIM_COLORS colors = MANIM_COLORS
selection_nudge_size = 0.05 selection_nudge_size = 0.05
cursor_location_config = dict(
font_size=14,
fill_color=GREY_C,
num_decimal_places=3,
)
def setup(self): def setup(self):
self.selection = Group() self.selection = Group()
self.selection_highlight = Group() self.selection_highlight = Group()
self.selection_rectangle = self.get_selection_rectangle() self.selection_rectangle = self.get_selection_rectangle()
self.color_palette = self.get_color_palette() self.color_palette = self.get_color_palette()
self.cursor_location_label = self.get_cursor_location_label()
self.unselectables = [ self.unselectables = [
self.selection, self.selection,
self.selection_highlight, self.selection_highlight,
self.selection_rectangle, self.selection_rectangle,
self.cursor_location_label,
self.camera.frame self.camera.frame
] ]
self.saved_selection_state = []
self.select_top_level_mobs = True self.select_top_level_mobs = True
self.regenerate_selection_search_set()
self.is_selecting = False self.is_selecting = False
self.is_grabbing = False
self.add(self.selection_highlight) 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): def toggle_selection_mode(self):
self.select_top_level_mobs = not self.select_top_level_mobs self.select_top_level_mobs = not self.select_top_level_mobs
self.refresh_selection_scope() self.refresh_selection_scope()
self.regenerate_selection_search_set()
def get_selection_search_set(self): def get_selection_search_set(self) -> list[Mobject]:
mobs = [m for m in self.mobjects if m not in self.unselectables] 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: if self.select_top_level_mobs:
return mobs self.selection_search_set = selectable
else: else:
return [ self.selection_search_set = [
submob submob
for mob in mobs for mob in selectable
for submob in mob.family_members_with_points() for submob in mob.family_members_with_points()
] ]
@ -116,37 +184,7 @@ class InteractiveScene(Scene):
) )
self.refresh_selection_highlight() self.refresh_selection_highlight()
def get_selection_rectangle(self): def get_corner_dots(self, mobject: Mobject) -> Mobject:
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 = DotCloud(**self.corner_dot_config)
radius = self.corner_dot_config["radius"] radius = self.corner_dot_config["radius"]
if mobject.get_depth() < 1e-2: if mobject.get_depth() < 1e-2:
@ -159,9 +197,11 @@ class InteractiveScene(Scene):
])) ]))
return dots return dots
def get_highlight(self, mobject): def get_highlight(self, mobject: Mobject) -> Mobject:
if isinstance(mobject, VMobject) and mobject.has_points(): if isinstance(mobject, VMobject) and mobject.has_points() and not self.select_top_level_mobs:
return self.get_stroke_highlight(mobject) result = VHighlight(mobject)
result.add_updater(lambda m: m.replace(mobject))
return result
else: else:
return self.get_corner_dots(mobject) return self.get_corner_dots(mobject)
@ -171,40 +211,54 @@ class InteractiveScene(Scene):
for mob in self.selection 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): def add_to_selection(self, *mobjects):
mobs = list(filter(lambda m: m not in self.unselectables, mobjects)) mobs = list(filter(
self.selection.add(*mobjects) 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.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): def toggle_from_selection(self, *mobjects):
for mob in mobjects: for mob in mobjects:
if mob in self.selection: if mob in self.selection:
self.selection.remove(mob) self.selection.remove(mob)
mob.set_animating_status(False)
else: else:
self.add_to_selection(mob) self.add_to_selection(mob)
self.refresh_selection_highlight() self.refresh_selection_highlight()
def clear_selection(self): def clear_selection(self):
for mob in self.selection:
mob.set_animating_status(False)
self.selection.set_submobjects([]) self.selection.set_submobjects([])
self.selection_highlight.set_submobjects([]) self.selection_highlight.set_submobjects([])
self.refresh_static_mobjects()
def add(self, *new_mobjects: Mobject): def add(self, *new_mobjects: Mobject):
for mob in new_mobjects:
mob.make_movable()
super().add(*new_mobjects) 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): def copy_selection(self):
ids = map(id, self.selection) ids = map(id, self.selection)
@ -218,11 +272,11 @@ class InteractiveScene(Scene):
mobs = map(self.id_to_mobject, ids) mobs = map(self.id_to_mobject, ids)
mob_copies = [m.copy() for m in mobs if m is not None] mob_copies = [m.copy() for m in mobs if m is not None]
self.clear_selection() self.clear_selection()
self.add_to_selection(*mob_copies)
self.play(*( self.play(*(
FadeIn(mc, run_time=0.5, scale=1.5) FadeIn(mc, run_time=0.5, scale=1.5)
for mc in mob_copies for mc in mob_copies
)) ))
self.add_to_selection(*mob_copies)
return return
except ValueError: except ValueError:
pass pass
@ -242,41 +296,27 @@ class InteractiveScene(Scene):
self.remove(*self.selection) self.remove(*self.selection)
self.clear_selection() self.clear_selection()
def saved_selection_to_file(self): def restore_state(self, mobject_states: list[tuple[Mobject, Mobject]]):
directory = self.file_writer.get_saved_mobject_directory() super().restore_state(mobject_states)
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)
self.refresh_selection_highlight() 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): def prepare_resizing(self, about_corner=False):
center = self.selection.get_center() center = self.selection.get_center()
mp = self.mouse_point.get_center() mp = self.mouse_point.get_center()
@ -286,136 +326,177 @@ class InteractiveScene(Scene):
self.scale_about_point = center self.scale_about_point = center
self.scale_ref_vect = mp - self.scale_about_point self.scale_ref_vect = mp - self.scale_about_point
self.scale_ref_width = self.selection.get_width() 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: def on_key_press(self, symbol: int, modifiers: int) -> None:
super().on_key_press(symbol, modifiers) super().on_key_press(symbol, modifiers)
char = chr(symbol) char = chr(symbol)
# Enable selection
if char == SELECT_KEY and modifiers == 0: if char == SELECT_KEY and modifiers == 0:
self.is_selecting = True self.enable_selection()
self.add(self.selection_rectangle) elif char in GRAB_KEYS and modifiers == 0:
self.selection_rectangle.fixed_corner = self.mouse_point.get_center().copy() self.prepare_grab()
# 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]: elif char == RESIZE_KEY and modifiers in [0, SHIFT_MODIFIER]:
self.prepare_resizing(about_corner=(modifiers == SHIFT_MODIFIER)) self.prepare_resizing(about_corner=(modifiers == SHIFT_MODIFIER))
elif symbol == SHIFT_SYMBOL: elif symbol == SHIFT_SYMBOL:
if self.window.is_key_pressed(ord("t")): if self.window.is_key_pressed(ord("t")):
self.prepare_resizing(about_corner=True) self.prepare_resizing(about_corner=True)
# Show color palette
elif char == COLOR_KEY and modifiers == 0: elif char == COLOR_KEY and modifiers == 0:
if len(self.selection) == 0: self.toggle_color_palette()
return elif char == CURSOR_LOCATION_KEY and modifiers == 0:
if self.color_palette not in self.mobjects: self.add(self.cursor_location_label)
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: elif char == "c" and modifiers == COMMAND_MODIFIER:
self.copy_selection() self.copy_selection()
# Command + v -> Paste
elif char == "v" and modifiers == COMMAND_MODIFIER: elif char == "v" and modifiers == COMMAND_MODIFIER:
self.paste_selection() self.paste_selection()
# Command + x -> Cut
elif char == "x" and modifiers == COMMAND_MODIFIER: 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.copy_selection()
self.delete_selection() self.delete_selection()
# Delete
elif symbol == DELETE_SYMBOL: elif symbol == DELETE_SYMBOL:
self.delete_selection() self.delete_selection()
# Command + a -> Select all
elif char == "a" and modifiers == COMMAND_MODIFIER: elif char == "a" and modifiers == COMMAND_MODIFIER:
self.clear_selection() self.clear_selection()
self.add_to_selection(*self.mobjects) self.add_to_selection(*self.mobjects)
# Command + g -> Group selection
elif char == "g" and modifiers == COMMAND_MODIFIER: elif char == "g" and modifiers == COMMAND_MODIFIER:
group = self.get_group(*self.selection) self.group_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: elif char == "g" and modifiers == COMMAND_MODIFIER | SHIFT_MODIFIER:
pieces = [] self.ungroup_selection()
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: elif char == "t" and modifiers == COMMAND_MODIFIER:
self.toggle_selection_mode() self.toggle_selection_mode()
# Command + z -> Restore selection to original state
elif char == "z" and modifiers == COMMAND_MODIFIER: elif char == "z" and modifiers == COMMAND_MODIFIER:
self.undo() 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: elif char == "s" and modifiers == COMMAND_MODIFIER:
self.saved_selection_to_file() self.save_selection_to_file()
# Keyboard movements
elif symbol in ARROW_SYMBOLS: elif symbol in ARROW_SYMBOLS:
nudge = self.selection_nudge_size self.nudge_selection(
if (modifiers & SHIFT_MODIFIER): vect=[LEFT, UP, RIGHT, DOWN][ARROW_SYMBOLS.index(symbol)],
nudge *= 10 large=(modifiers & SHIFT_MODIFIER),
vect = [LEFT, UP, RIGHT, DOWN][ARROW_SYMBOLS.index(symbol)] )
self.selection.shift(nudge * vect)
# 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: def on_key_release(self, symbol: int, modifiers: int) -> None:
super().on_key_release(symbol, modifiers) super().on_key_release(symbol, modifiers)
if chr(symbol) == SELECT_KEY: if chr(symbol) == SELECT_KEY:
self.is_selecting = False self.gather_new_selection()
self.remove(self.selection_rectangle) if chr(symbol) in GRAB_KEYS:
for mob in reversed(self.get_selection_search_set()): self.is_grabbing = False
if mob.is_movable() and self.selection_rectangle.is_touching(mob): elif chr(symbol) == CURSOR_LOCATION_KEY:
self.add_to_selection(mob) 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: # Mouse actions
if self.window.is_key_pressed(ord(RESIZE_KEY)): def handle_grabbing(self, point: np.ndarray):
self.prepare_resizing(about_corner=False) 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: def handle_resizing(self, point: np.ndarray):
super().on_mouse_motion(point, d_point) vect = point - self.scale_about_point
# Move selection if self.window.is_key_pressed(CTRL_SYMBOL):
if self.window.is_key_pressed(ord("g")): for i in (0, 1):
self.selection.move_to(point - self.mouse_to_selection) scalar = vect[i] / self.scale_ref_vect[i]
# Move selection restricted to horizontal self.selection.rescale_to_fit(
elif self.window.is_key_pressed(ord("h")): scalar * [self.scale_ref_width, self.scale_ref_height][i],
self.selection.set_x((point - self.mouse_to_selection)[0]) dim=i,
# Move selection restricted to vertical about_point=self.scale_about_point,
elif self.window.is_key_pressed(ord("v")): stretch=True,
self.selection.set_y((point - self.mouse_to_selection)[1]) )
# Scale selection else:
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) scalar = get_norm(vect) / get_norm(self.scale_ref_vect)
self.selection.set_width( self.selection.set_width(
scalar * self.scale_ref_width, scalar * self.scale_ref_width,
about_point=self.scale_about_point 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: def on_mouse_release(self, point: np.ndarray, button: int, mods: int) -> None:
super().on_mouse_release(point, button, mods) super().on_mouse_release(point, button, mods)
if self.color_palette in self.mobjects: if self.color_palette in self.mobjects:
# Search through all mobject on the screne, not just the palette self.choose_color(point)
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): elif self.window.is_key_pressed(SHIFT_SYMBOL):
mob = self.point_to_mobject(point) self.toggle_clicked_mobject_from_selection(point)
if mob is not None:
self.toggle_from_selection(mob)
else: else:
self.clear_selection() 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.animation import prepare_animation
from manimlib.animation.transform import MoveToTarget from manimlib.animation.transform import MoveToTarget
from manimlib.camera.camera import Camera from manimlib.camera.camera import Camera
from manimlib.config import get_custom_config
from manimlib.constants import ARROW_SYMBOLS from manimlib.constants import ARROW_SYMBOLS
from manimlib.constants import DEFAULT_WAIT_TIME 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 import EVENT_DISPATCHER
from manimlib.event_handler.event_type import EventType from manimlib.event_handler.event_type import EventType
from manimlib.logger import log from manimlib.logger import log
@ -46,7 +45,6 @@ FRAME_SHIFT_KEY = 'f'
ZOOM_KEY = 'z' ZOOM_KEY = 'z'
RESET_FRAME_KEY = 'r' RESET_FRAME_KEY = 'r'
QUIT_KEY = 'q' QUIT_KEY = 'q'
EMBED_KEY = 'e'
class Scene(object): class Scene(object):
@ -65,6 +63,7 @@ class Scene(object):
"presenter_mode": False, "presenter_mode": False,
"linger_after_completion": True, "linger_after_completion": True,
"pan_sensitivity": 3, "pan_sensitivity": 3,
"max_num_saved_states": 20,
} }
def __init__(self, **kwargs): def __init__(self, **kwargs):
@ -74,12 +73,15 @@ class Scene(object):
self.window = Window(scene=self, **self.window_config) self.window = Window(scene=self, **self.window_config)
self.camera_config["ctx"] = self.window.ctx self.camera_config["ctx"] = self.window.ctx
self.camera_config["frame_rate"] = 30 # Where's that 30 from? self.camera_config["frame_rate"] = 30 # Where's that 30 from?
self.undo_stack = []
self.redo_stack = []
else: else:
self.window = None self.window = None
self.camera: Camera = self.camera_class(**self.camera_config) self.camera: Camera = self.camera_class(**self.camera_config)
self.file_writer = SceneFileWriter(self, **self.file_writer_config) self.file_writer = SceneFileWriter(self, **self.file_writer_config)
self.mobjects: list[Mobject] = [self.camera.frame] self.mobjects: list[Mobject] = [self.camera.frame]
self.id_to_mobject_map: dict[int, Mobject] = dict()
self.num_plays: int = 0 self.num_plays: int = 0
self.time: float = 0 self.time: float = 0
self.skip_time: float = 0 self.skip_time: float = 0
@ -91,12 +93,16 @@ class Scene(object):
self.mouse_point = Point() self.mouse_point = Point()
self.mouse_drag_point = Point() self.mouse_drag_point = Point()
self.hold_on_wait = self.presenter_mode self.hold_on_wait = self.presenter_mode
self.inside_embed = False
# Much nicer to work with deterministic scenes # Much nicer to work with deterministic scenes
if self.random_seed is not None: if self.random_seed is not None:
random.seed(self.random_seed) random.seed(self.random_seed)
np.random.seed(self.random_seed) np.random.seed(self.random_seed)
def __str__(self) -> str:
return self.__class__.__name__
def run(self) -> None: def run(self) -> None:
self.virtual_animation_start_time: float = 0 self.virtual_animation_start_time: float = 0
self.real_animation_start_time: float = time.time() 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: def embed(self, close_scene_on_exit: bool = True) -> None:
if not self.preview: if not self.preview:
# If the scene is just being # Ignore embed calls when there is no preview
# written, ignore embed calls
return return
self.inside_embed = True
self.stop_skipping() self.stop_skipping()
self.linger_after_completion = False self.linger_after_completion = False
self.update_frame() self.update_frame()
# Save scene state at the point of embedding
self.save_state() self.save_state()
from IPython.terminal.embed import InteractiveShellEmbed # Configure and launch embedded IPython terminal
shell = InteractiveShellEmbed() from IPython.terminal import embed, pt_inputhooks
# Have the frame update after each command shell = embed.InteractiveShellEmbed.instance()
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 namespace of the caller
# 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 = inspect.currentframe().f_back.f_locals
local_ns["touch"] = self.interact # Add a few custom shortcuts
local_ns["i2g"] = self.ids_to_group local_ns.update({
for term in ("play", "wait", "add", "remove", "clear", "save_state", "restore"): name: getattr(self, name)
local_ns[term] = getattr(self, term) for name in [
log.info("Tips: Now the embed iPython terminal is open. But you can't interact with" "play", "wait", "add", "remove", "clear",
" the window directly. To do so, you need to type `touch()` or `self.interact()`") "save_state", "undo", "redo", "i2g", "i2m"
exec(get_custom_config()["universal_import_line"]) ]
})
# 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) shell(local_ns=local_ns, stack_depth=2)
self.inside_embed = False
# End scene when exiting an embed # End scene when exiting an embed
if close_scene_on_exit: if close_scene_on_exit:
raise EndSceneEarlyException() raise EndSceneEarlyException()
def __str__(self) -> str:
return self.__class__.__name__
# Only these methods should touch the camera # Only these methods should touch the camera
def get_image(self) -> Image: def get_image(self) -> Image:
return self.camera.get_image() return self.camera.get_image()
@ -210,6 +233,7 @@ class Scene(object):
self.file_writer.write_frame(self.camera) self.file_writer.write_frame(self.camera)
# Related to updating # Related to updating
def update_mobjects(self, dt: float) -> None: def update_mobjects(self, dt: float) -> None:
for mobject in self.mobjects: for mobject in self.mobjects:
mobject.update(dt) mobject.update(dt)
@ -228,6 +252,7 @@ class Scene(object):
]) ])
# Related to time # Related to time
def get_time(self) -> float: def get_time(self) -> float:
return self.time return self.time
@ -235,6 +260,7 @@ class Scene(object):
self.time += dt self.time += dt
# Related to internal mobject organization # Related to internal mobject organization
def get_top_level_mobjects(self) -> list[Mobject]: def get_top_level_mobjects(self) -> list[Mobject]:
# Return only those which are not in the family # Return only those which are not in the family
# of another mobject from the scene # of another mobject from the scene
@ -259,6 +285,11 @@ class Scene(object):
""" """
self.remove(*new_mobjects) self.remove(*new_mobjects)
self.mobjects += 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 return self
def add_mobjects_among(self, values: Iterable): def add_mobjects_among(self, values: Iterable):
@ -322,11 +353,7 @@ class Scene(object):
return Group(*mobjects) return Group(*mobjects)
def id_to_mobject(self, id_value): def id_to_mobject(self, id_value):
for mob in self.mobjects: return self.id_to_mobject_map[id_value]
for sm in mob.get_family():
if id(sm) == id_value:
return sm
return None
def ids_to_group(self, *id_values): def ids_to_group(self, *id_values):
return self.get_group(*filter( return self.get_group(*filter(
@ -334,7 +361,14 @@ class Scene(object):
map(self.id_to_mobject, id_values) 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 # Related to skipping
def update_skipping_status(self) -> None: def update_skipping_status(self) -> None:
if self.start_at_animation_number is not None: if self.start_at_animation_number is not None:
if self.num_plays == self.start_at_animation_number: if self.num_plays == self.start_at_animation_number:
@ -350,6 +384,7 @@ class Scene(object):
self.skip_animations = False self.skip_animations = False
# Methods associated with running animations # Methods associated with running animations
def get_time_progression( def get_time_progression(
self, self,
run_time: float, run_time: float,
@ -473,6 +508,8 @@ class Scene(object):
def handle_play_like_call(func): def handle_play_like_call(func):
@wraps(func) @wraps(func)
def wrapper(self, *args, **kwargs): def wrapper(self, *args, **kwargs):
if self.inside_embed:
self.save_state()
self.update_skipping_status() self.update_skipping_status()
should_write = not self.skip_animations should_write = not self.skip_animations
if should_write: if should_write:
@ -594,24 +631,39 @@ class Scene(object):
self.file_writer.add_sound(sound_file, time, gain, gain_to_background) self.file_writer.add_sound(sound_file, time, gain, gain_to_background)
# Helpers for interactive development # 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: def save_state(self) -> None:
self.saved_state = [ if not self.preview:
(mob, mob.copy()) return
for mob in self.mobjects 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: def undo(self):
if not hasattr(self, "saved_state"): if self.undo_stack:
raise Exception("Trying to restore scene without having saved") self.redo_stack.append(self.get_state())
self.mobjects = [] self.restore_state(self.undo_stack.pop())
for mob, mob_state in self.saved_state: self.refresh_static_mobjects()
mob.become(mob_state)
self.mobjects.append(mob)
def save_mobect(self, mobject: Mobject, file_name: str): def redo(self):
directory = self.file_writer.get_saved_mobject_directory() if self.redo_stack:
path = os.path.join(directory, file_name) self.undo_stack.append(self.get_state())
mobject.save_to_file(path) 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): def load_mobject(self, file_name):
if os.path.exists(file_name): if os.path.exists(file_name):
@ -739,9 +791,6 @@ class Scene(object):
# Space or right arrow # Space or right arrow
elif char == " " or symbol == ARROW_SYMBOLS[2]: elif char == " " or symbol == ARROW_SYMBOLS[2]:
self.hold_on_wait = False 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: def on_resize(self, width: int, height: int) -> None:
self.camera.reset_pixel_shape(width, height) 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.constants import FFMPEG_BIN
from manimlib.logger import log from manimlib.logger import log
from manimlib.mobject.mobject import Mobject
from manimlib.utils.config_ops import digest_config 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 add_extension_if_not_present
from manimlib.utils.file_ops import get_sorted_integer_files from manimlib.utils.file_ops import get_sorted_integer_files
@ -61,7 +62,7 @@ class SceneFileWriter(object):
# Output directories and files # Output directories and files
def init_output_directories(self) -> None: def init_output_directories(self) -> None:
out_dir = self.output_directory out_dir = self.output_directory or ""
if self.mirror_module_path: if self.mirror_module_path:
module_dir = self.get_default_module_directory() module_dir = self.get_default_module_directory()
out_dir = os.path.join(out_dir, module_dir) out_dir = os.path.join(out_dir, module_dir)
@ -128,6 +129,36 @@ class SceneFileWriter(object):
str(self.scene), 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 # Sound
def init_audio(self) -> None: def init_audio(self) -> None:
self.includes_sound: bool = False self.includes_sound: bool = False