diff --git a/manimlib/config.py b/manimlib/config.py index a57c3535..2236e0d6 100644 --- a/manimlib/config.py +++ b/manimlib/config.py @@ -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 = { diff --git a/manimlib/constants.py b/manimlib/constants.py index 0404527b..f0bc3269 100644 --- a/manimlib/constants.py +++ b/manimlib/constants.py @@ -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)) diff --git a/manimlib/extract_scene.py b/manimlib/extract_scene.py index 55a73f63..a3686ebc 100644 --- a/manimlib/extract_scene.py +++ b/manimlib/extract_scene.py @@ -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() diff --git a/manimlib/mobject/mobject.py b/manimlib/mobject/mobject.py index 3b628970..164cc9d7 100644 --- a/manimlib/mobject/mobject.py +++ b/manimlib/mobject/mobject.py @@ -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]): diff --git a/manimlib/mobject/types/vectorized_mobject.py b/manimlib/mobject/types/vectorized_mobject.py index 630bcabd..92363ae0 100644 --- a/manimlib/mobject/types/vectorized_mobject.py +++ b/manimlib/mobject/types/vectorized_mobject.py @@ -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) diff --git a/manimlib/scene/interactive_scene.py b/manimlib/scene/interactive_scene.py index 82b06c82..a59adeea 100644 --- a/manimlib/scene/interactive_scene.py +++ b/manimlib/scene/interactive_scene.py @@ -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() diff --git a/manimlib/scene/scene.py b/manimlib/scene/scene.py index 6b86de14..ca15eec3 100644 --- a/manimlib/scene/scene.py +++ b/manimlib/scene/scene.py @@ -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) diff --git a/manimlib/scene/scene_file_writer.py b/manimlib/scene/scene_file_writer.py index a7d1a908..3a647fe9 100644 --- a/manimlib/scene/scene_file_writer.py +++ b/manimlib/scene/scene_file_writer.py @@ -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