From c04615c4e97cb6c962ffd8b673d27608adbd26fb Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Thu, 21 Apr 2022 14:30:39 -0700 Subject: [PATCH 01/27] In Mobject.set_uniforms, copy uniforms that are numpy arrays --- manimlib/mobject/mobject.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/manimlib/mobject/mobject.py b/manimlib/mobject/mobject.py index d09773ed..170f34a2 100644 --- a/manimlib/mobject/mobject.py +++ b/manimlib/mobject/mobject.py @@ -136,8 +136,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 From fe3e10acd29a3dd6f8b485c0e36ead819f2d937b Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Thu, 21 Apr 2022 14:32:27 -0700 Subject: [PATCH 02/27] Updates to copying based on pickle serializing --- manimlib/mobject/mobject.py | 80 +++++++++++++++++++++---------------- 1 file changed, 46 insertions(+), 34 deletions(-) diff --git a/manimlib/mobject/mobject.py b/manimlib/mobject/mobject.py index 170f34a2..a14a3ad6 100644 --- a/manimlib/mobject/mobject.py +++ b/manimlib/mobject/mobject.py @@ -462,32 +462,21 @@ 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 get_grid(self, n_rows: int, n_cols: int, height: float | None = None, **kwargs): - """ - Returns a new mobject containing multiple copies of this one - arranged in a grid - """ - grid = self.replicate(n_rows * n_cols) - grid.arrange_in_grid(n_rows, n_cols, **kwargs) - if height is not None: - grid.set_height(height) - return grid - - # Copying + def serialize(self): + pre, self.parents = self.parents, [] + result = pickle.dumps(self) + self.parents = pre + return result def copy(self): - self.parents = [] try: - return pickle.loads(pickle.dumps(self)) + serial = self.serialize() + return pickle.loads(serial) except AttributeError: return copy.deepcopy(self) + return result def deepcopy(self): # This used to be different from copy, so is now just here for backward compatibility @@ -513,7 +502,7 @@ class Mobject(object): self.become(self.saved_state) return self - def save_to_file(self, file_path): + def save_to_file(self, file_path: str): if not file_path.endswith(".mob"): file_path += ".mob" if os.path.exists(file_path): @@ -521,7 +510,7 @@ class Mobject(object): if cont != "y": return 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 @@ -534,6 +523,41 @@ 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_shader_wrapper_id() + self.refresh_bounding_box(recurse_down=True) + return self + + # Creating new Mobjects from this one + + def replicate(self, n: int) -> Group: + serial = self.serialize() + group_class = self.get_group_class() + return group_class(*(pickle.loads(serial) 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): @@ -1521,18 +1545,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]): From 9d5e2b32fa9215219d11a601829126cea40410d1 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Thu, 21 Apr 2022 14:32:39 -0700 Subject: [PATCH 03/27] Add VHighlight --- manimlib/mobject/types/vectorized_mobject.py | 24 ++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/manimlib/mobject/types/vectorized_mobject.py b/manimlib/mobject/types/vectorized_mobject.py index 6615f715..538ea655 100644 --- a/manimlib/mobject/types/vectorized_mobject.py +++ b/manimlib/mobject/types/vectorized_mobject.py @@ -10,6 +10,8 @@ import moderngl import numpy.typing as npt from manimlib.constants import * +from manimlib.constants import GREY_C +from manimlib.constants import GREY_E from manimlib.mobject.mobject import Mobject from manimlib.mobject.mobject import Point from manimlib.utils.bezier import bezier @@ -20,6 +22,7 @@ from manimlib.utils.bezier import interpolate from manimlib.utils.bezier import inverse_interpolate from manimlib.utils.bezier import integer_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 make_even from manimlib.utils.iterables import resize_array @@ -1174,3 +1177,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) From f53f202dcd250111a90604329a4630fe62919375 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Thu, 21 Apr 2022 15:00:58 -0700 Subject: [PATCH 04/27] A few small cleanups --- manimlib/mobject/mobject.py | 8 +----- manimlib/scene/interactive_scene.py | 42 +++++++++++++++-------------- 2 files changed, 23 insertions(+), 27 deletions(-) diff --git a/manimlib/mobject/mobject.py b/manimlib/mobject/mobject.py index a14a3ad6..10ebc35b 100644 --- a/manimlib/mobject/mobject.py +++ b/manimlib/mobject/mobject.py @@ -502,13 +502,7 @@ class Mobject(object): self.become(self.saved_state) return self - def save_to_file(self, file_path: str): - 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: fp.write(self.serialize()) log.info(f"Saved mobject to {file_path}") diff --git a/manimlib/scene/interactive_scene.py b/manimlib/scene/interactive_scene.py index d1cc6d1f..3d76dd1e 100644 --- a/manimlib/scene/interactive_scene.py +++ b/manimlib/scene/interactive_scene.py @@ -138,14 +138,6 @@ class InteractiveScene(Scene): 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) radius = self.corner_dot_config["radius"] @@ -160,8 +152,10 @@ class InteractiveScene(Scene): return dots def get_highlight(self, mobject): - if isinstance(mobject, VMobject) and mobject.has_points(): - return self.get_stroke_highlight(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) @@ -182,10 +176,14 @@ class InteractiveScene(Scene): return rect def add_to_selection(self, *mobjects): - mobs = list(filter(lambda m: m not in self.unselectables, mobjects)) - self.selection.add(*mobjects) - self.selection_highlight.add(*map(self.get_highlight, mobs)) - self.saved_selection_state = [(mob, mob.copy()) for mob in self.selection] + mobs = list(filter( + lambda m: m not in self.unselectables and m not in self.selection, + mobjects + )) + if mobs: + 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] def toggle_from_selection(self, *mobjects): for mob in mobjects: @@ -382,16 +380,16 @@ class InteractiveScene(Scene): 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")): + if self.window.is_key_pressed(ord(GRAB_KEY)): self.selection.move_to(point - self.mouse_to_selection) # Move selection restricted to horizontal - elif self.window.is_key_pressed(ord("h")): + elif self.window.is_key_pressed(ord(HORIZONTAL_GRAB_KEY)): self.selection.set_x((point - self.mouse_to_selection)[0]) # Move selection restricted to vertical - elif self.window.is_key_pressed(ord("v")): + elif self.window.is_key_pressed(ord(VERTICAL_GRAB_KEY)): self.selection.set_y((point - self.mouse_to_selection)[1]) # Scale selection - elif self.window.is_key_pressed(ord("t")): + elif self.window.is_key_pressed(ord(RESIZE_KEY)): # TODO, allow for scaling about the opposite corner vect = point - self.scale_about_point scalar = get_norm(vect) / get_norm(self.scale_ref_vect) @@ -411,10 +409,14 @@ class InteractiveScene(Scene): ))) mob = self.point_to_mobject(point, to_search) if mob is not None: - self.selection.set_color(mob.get_fill_color()) + self.selection.set_color(mob.get_color()) self.remove(self.color_palette) elif self.window.is_key_pressed(SHIFT_SYMBOL): - mob = self.point_to_mobject(point) + 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) else: From 3a60ab144bd3c7b5f13851797fa0f7c507dc33c8 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Thu, 21 Apr 2022 15:01:30 -0700 Subject: [PATCH 05/27] Remove saved mobject directory logic from InteractiveScene --- manimlib/scene/interactive_scene.py | 30 ++++------------------------- 1 file changed, 4 insertions(+), 26 deletions(-) diff --git a/manimlib/scene/interactive_scene.py b/manimlib/scene/interactive_scene.py index 3d76dd1e..8304c453 100644 --- a/manimlib/scene/interactive_scene.py +++ b/manimlib/scene/interactive_scene.py @@ -240,31 +240,6 @@ 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: @@ -355,7 +330,10 @@ class InteractiveScene(Scene): self.undo() # Command + s -> Save selections to file elif char == "s" and modifiers == COMMAND_MODIFIER: - self.saved_selection_to_file() + to_save = self.selection + if len(to_save) == 1: + to_save = to_save[0] + self.save_mobect(to_save) # Keyboard movements elif symbol in ARROW_SYMBOLS: nudge = self.selection_nudge_size From 4caa03332367631d2fff15afd7e56b15fe8701ee Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Thu, 21 Apr 2022 15:01:54 -0700 Subject: [PATCH 06/27] Allow for sweeping selection --- manimlib/scene/interactive_scene.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/manimlib/scene/interactive_scene.py b/manimlib/scene/interactive_scene.py index 8304c453..4c479030 100644 --- a/manimlib/scene/interactive_scene.py +++ b/manimlib/scene/interactive_scene.py @@ -1,11 +1,9 @@ import numpy as np import itertools as it import pyperclip -import os -import platform from manimlib.animation.fading import FadeIn -from manimlib.constants import MANIM_COLORS, WHITE, YELLOW +from manimlib.constants import MANIM_COLORS, WHITE from manimlib.constants import ORIGIN, UP, DOWN, LEFT, RIGHT, DL, UL, UR, DR from manimlib.constants import FRAME_WIDTH, SMALL_BUFF from manimlib.constants import SHIFT_SYMBOL, DELETE_SYMBOL, ARROW_SYMBOLS @@ -18,6 +16,7 @@ from manimlib.mobject.svg.tex_mobject import Tex from manimlib.mobject.svg.text_mobject import Text from manimlib.mobject.types.vectorized_mobject import VMobject from manimlib.mobject.types.vectorized_mobject import VGroup +from manimlib.mobject.types.vectorized_mobject import VHighlight from manimlib.mobject.types.dot_cloud import DotCloud from manimlib.scene.scene import Scene from manimlib.utils.tex_file_writing import LatexError @@ -375,6 +374,14 @@ class InteractiveScene(Scene): scalar * self.scale_ref_width, about_point=self.scale_about_point ) + # Add to selection + elif self.window.is_key_pressed(ord(SELECT_KEY)) and self.window.is_key_pressed(SHIFT_SYMBOL): + 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 on_mouse_release(self, point: np.ndarray, button: int, mods: int) -> None: super().on_mouse_release(point, button, mods) From 78a707877212d0df5383cc26e82a62209bc13bee Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Thu, 21 Apr 2022 15:02:11 -0700 Subject: [PATCH 07/27] Move saved mobject directory logic to scene_file_writer.py --- manimlib/scene/scene.py | 10 ++++++---- manimlib/scene/scene_file_writer.py | 31 +++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/manimlib/scene/scene.py b/manimlib/scene/scene.py index 01037f62..5f91bb37 100644 --- a/manimlib/scene/scene.py +++ b/manimlib/scene/scene.py @@ -605,10 +605,12 @@ class Scene(object): mob.become(mob_state) self.mobjects.append(mob) - def save_mobect(self, mobject: Mobject, file_name: str): - directory = self.file_writer.get_saved_mobject_directory() - path = os.path.join(directory, file_name) - mobject.save_to_file(path) + def save_mobect(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): diff --git a/manimlib/scene/scene_file_writer.py b/manimlib/scene/scene_file_writer.py index 869db27b..a46ec01d 100644 --- a/manimlib/scene/scene_file_writer.py +++ b/manimlib/scene/scene_file_writer.py @@ -11,6 +11,7 @@ from pydub import AudioSegment from tqdm import tqdm as ProgressDisplay from manimlib.constants import FFMPEG_BIN +from manimlib.mobject.mobject import Mobject from manimlib.utils.config_ops import digest_config from manimlib.utils.file_ops import guarantee_existence from manimlib.utils.file_ops import add_extension_if_not_present @@ -127,6 +128,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 From b4b72d1b68d0993b96a6af76c4bb6816f77f0f12 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Thu, 21 Apr 2022 15:31:46 -0700 Subject: [PATCH 08/27] Allow stretched-resizing --- manimlib/constants.py | 1 + manimlib/scene/interactive_scene.py | 24 +++++++++++++++++------- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/manimlib/constants.py b/manimlib/constants.py index cc73c0ac..88859df7 100644 --- a/manimlib/constants.py +++ b/manimlib/constants.py @@ -74,6 +74,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/scene/interactive_scene.py b/manimlib/scene/interactive_scene.py index 4c479030..7ce23a89 100644 --- a/manimlib/scene/interactive_scene.py +++ b/manimlib/scene/interactive_scene.py @@ -6,7 +6,7 @@ from manimlib.animation.fading import FadeIn from manimlib.constants import MANIM_COLORS, WHITE from manimlib.constants import ORIGIN, UP, DOWN, LEFT, RIGHT, DL, UL, UR, DR from manimlib.constants import FRAME_WIDTH, SMALL_BUFF -from manimlib.constants import SHIFT_SYMBOL, DELETE_SYMBOL, ARROW_SYMBOLS +from manimlib.constants import SHIFT_SYMBOL, CTRL_SYMBOL, DELETE_SYMBOL, ARROW_SYMBOLS from manimlib.constants import SHIFT_MODIFIER, COMMAND_MODIFIER from manimlib.mobject.mobject import Mobject from manimlib.mobject.geometry import Rectangle @@ -258,6 +258,7 @@ 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 @@ -367,13 +368,22 @@ class InteractiveScene(Scene): self.selection.set_y((point - self.mouse_to_selection)[1]) # Scale selection elif self.window.is_key_pressed(ord(RESIZE_KEY)): - # TODO, allow for scaling about the opposite corner vect = point - self.scale_about_point - scalar = get_norm(vect) / get_norm(self.scale_ref_vect) - self.selection.set_width( - scalar * self.scale_ref_width, - about_point=self.scale_about_point - ) + 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 + ) # Add to selection elif self.window.is_key_pressed(ord(SELECT_KEY)) and self.window.is_key_pressed(SHIFT_SYMBOL): mob = self.point_to_mobject( From 1b2460f02a694314897437b9b8755443ed290cc1 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Fri, 22 Apr 2022 08:14:05 -0700 Subject: [PATCH 09/27] Remove refresh_shader_wrapper_id from Mobject.become --- manimlib/mobject/mobject.py | 1 - 1 file changed, 1 deletion(-) diff --git a/manimlib/mobject/mobject.py b/manimlib/mobject/mobject.py index 10ebc35b..2e174a84 100644 --- a/manimlib/mobject/mobject.py +++ b/manimlib/mobject/mobject.py @@ -530,7 +530,6 @@ class Mobject(object): sm1.texture_paths = sm2.texture_paths sm1.depth_test = sm2.depth_test sm1.render_primitive = sm2.render_primitive - self.refresh_shader_wrapper_id() self.refresh_bounding_box(recurse_down=True) return self From 5927f6a1cd8a3be10391132e23df58dbcf216502 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Fri, 22 Apr 2022 08:14:29 -0700 Subject: [PATCH 10/27] Default to "" for scene_file_writer output dir --- manimlib/scene/scene_file_writer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manimlib/scene/scene_file_writer.py b/manimlib/scene/scene_file_writer.py index a46ec01d..4b6b70c4 100644 --- a/manimlib/scene/scene_file_writer.py +++ b/manimlib/scene/scene_file_writer.py @@ -61,7 +61,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) From c96bdc243e57c17bb75bf12d73ab5bf119cf1464 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Fri, 22 Apr 2022 08:16:17 -0700 Subject: [PATCH 11/27] Update Scene.embed to play nicely with gui interactions --- manimlib/scene/scene.py | 49 ++++++++++++++++++++++++++--------------- 1 file changed, 31 insertions(+), 18 deletions(-) diff --git a/manimlib/scene/scene.py b/manimlib/scene/scene.py index 5f91bb37..4303d737 100644 --- a/manimlib/scene/scene.py +++ b/manimlib/scene/scene.py @@ -14,7 +14,6 @@ import numpy as np from manimlib.animation.animation import prepare_animation from manimlib.animation.transform import MoveToTarget from manimlib.camera.camera import Camera -from manimlib.config import get_custom_config from manimlib.constants import DEFAULT_WAIT_TIME from manimlib.constants import ARROW_SYMBOLS from manimlib.constants import SHIFT_MODIFIER, CTRL_MODIFIER, COMMAND_MODIFIER @@ -143,32 +142,40 @@ 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 + # If the scene is just being written, ignore embed calls return 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 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"): + # Add a few custom shortcuts + for term in ("play", "wait", "add", "remove", "clear", "save_state", "restore", "i2g", "i2m"): 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"]) + + # Enables gui interactions during the embed + def inputhook(context): + while not context.input_is_ready(): + self.update_frame() + + pt_inputhooks.register("manim", inputhook) + shell.enable_gui("manim") + + # Have the frame update after each 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) + # End scene when exiting an embed if close_scene_on_exit: raise EndSceneEarlyException() @@ -331,6 +338,12 @@ 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: From 2737d9a736885a594dd101ffe07bb82e00069333 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Fri, 22 Apr 2022 08:33:18 -0700 Subject: [PATCH 12/27] Have BlankScene inherit from InteractiveScene --- manimlib/extract_scene.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/manimlib/extract_scene.py b/manimlib/extract_scene.py index abec96ec..b13afe5b 100644 --- a/manimlib/extract_scene.py +++ b/manimlib/extract_scene.py @@ -3,11 +3,12 @@ import sys import copy from manimlib.scene.scene import Scene +from manimlib.scene.interactive_scene import InteractiveScene from manimlib.config import get_custom_config from manimlib.logger import log -class BlankScene(Scene): +class BlankScene(InteractiveScene): def construct(self): exec(get_custom_config()["universal_import_line"]) self.embed() From 581228b08f8bbd31fb0386e46860d76b79a6af98 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Fri, 22 Apr 2022 08:33:57 -0700 Subject: [PATCH 13/27] Have scene keep track of a map from mobject ids to mobjects for all it's ever seen --- manimlib/scene/scene.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/manimlib/scene/scene.py b/manimlib/scene/scene.py index 4303d737..a540cd09 100644 --- a/manimlib/scene/scene.py +++ b/manimlib/scene/scene.py @@ -76,6 +76,7 @@ class Scene(object): 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 @@ -263,6 +264,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): @@ -326,11 +332,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( From e0f5686d667152582f052021cd62bd2ef8c6b470 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Fri, 22 Apr 2022 10:16:43 -0700 Subject: [PATCH 14/27] Fix bug with trying to close window during embed --- manimlib/scene/scene.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/manimlib/scene/scene.py b/manimlib/scene/scene.py index a540cd09..d3617f5b 100644 --- a/manimlib/scene/scene.py +++ b/manimlib/scene/scene.py @@ -163,7 +163,11 @@ class Scene(object): # Enables gui interactions during the embed def inputhook(context): while not context.input_is_ready(): - self.update_frame() + if self.window.is_closing: + pass + # self.window.destroy() + else: + self.update_frame(dt=0) pt_inputhooks.register("manim", inputhook) shell.enable_gui("manim") From bb7fa2c8aa68d7c7992517cfde3c7d0e804e13e8 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Fri, 22 Apr 2022 10:17:15 -0700 Subject: [PATCH 15/27] Update behavior of -e flag to take in (optional) strings as inputs --- manimlib/config.py | 63 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 48 insertions(+), 15 deletions(-) diff --git a/manimlib/config.py b/manimlib/config.py index a2e68c51..479797d8 100644 --- a/manimlib/config.py +++ b/manimlib/config.py @@ -117,16 +117,18 @@ 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="*", + 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", @@ -186,12 +188,43 @@ def get_module(file_name): @contextmanager -def insert_embed_line(file_name, lineno): +def insert_embed_line(file_name: str, scene_names: list[str], strings_to_match: 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") + + line = None + if strings_to_match: + matching_lines = [ + line for line in lines + if any(s in line for s in strings_to_match) + ] + if matching_lines: + line = matching_lines[-1] + n_spaces = len(line) - len(line.lstrip()) + lines.insert(lines.index(line), " " * n_spaces + "self.embed()\n") + if line is None: + lineno = 0 + in_scene = False + in_construct = False + n_spaces = 8 + # Search for scene definition + for lineno, line in enumerate(lines): + indent = len(line) - len(line.lstrip()) + if line.startswith(f"class {scene_names[0]}"): + in_scene = True + elif in_scene and "def construct" in line: + in_construct = True + n_spaces = indent + 4 + elif in_construct: + if len(line.strip()) > 0 and indent < n_spaces: + break + lines.insert(lineno, " " * n_spaces + "self.embed()\n") alt_file = file_name.replace(".py", "_inserted_embed.py") with open(alt_file, 'w') as fp: @@ -296,10 +329,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, args.embed) as alt_file: module = get_module(alt_file) config = { From b9751e9d06068f27a327b419c52fd3c9d68db2e6 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Fri, 22 Apr 2022 10:17:29 -0700 Subject: [PATCH 16/27] Add cursor location label --- manimlib/scene/interactive_scene.py | 103 ++++++++++++++++++---------- 1 file changed, 67 insertions(+), 36 deletions(-) diff --git a/manimlib/scene/interactive_scene.py b/manimlib/scene/interactive_scene.py index 7ce23a89..b805134e 100644 --- a/manimlib/scene/interactive_scene.py +++ b/manimlib/scene/interactive_scene.py @@ -3,15 +3,16 @@ import itertools as it import pyperclip from manimlib.animation.fading import FadeIn -from manimlib.constants import MANIM_COLORS, WHITE +from manimlib.constants import MANIM_COLORS, WHITE, GREY_C from manimlib.constants import ORIGIN, UP, DOWN, LEFT, RIGHT, DL, UL, UR, DR from manimlib.constants import FRAME_WIDTH, SMALL_BUFF from manimlib.constants import SHIFT_SYMBOL, CTRL_SYMBOL, DELETE_SYMBOL, ARROW_SYMBOLS from manimlib.constants import SHIFT_MODIFIER, COMMAND_MODIFIER -from manimlib.mobject.mobject import Mobject from manimlib.mobject.geometry import Rectangle from manimlib.mobject.geometry import Square from manimlib.mobject.mobject import Group +from manimlib.mobject.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.vectorized_mobject import VMobject @@ -31,6 +32,7 @@ HORIZONTAL_GRAB_KEY = 'h' VERTICAL_GRAB_KEY = 'v' 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. @@ -65,16 +67,23 @@ 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 = [] @@ -83,6 +92,57 @@ class InteractiveScene(Scene): self.is_selecting = 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() @@ -115,28 +175,6 @@ 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_corner_dots(self, mobject): dots = DotCloud(**self.corner_dot_config) radius = self.corner_dot_config["radius"] @@ -164,16 +202,6 @@ 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 and m not in self.selection, @@ -201,7 +229,7 @@ class InteractiveScene(Scene): mob.make_movable() super().add(*new_mobjects) - # Selection operations + # Functions for keyboard actions def copy_selection(self): ids = map(id, self.selection) @@ -288,6 +316,8 @@ class InteractiveScene(Scene): self.add(self.color_palette) else: self.remove(self.color_palette) + elif char == CURSOR_LOCATION_KEY and modifiers == 0: + self.add(self.cursor_location_label) # Command + c -> Copy mobject ids to clipboard elif char == "c" and modifiers == COMMAND_MODIFIER: self.copy_selection() @@ -350,7 +380,8 @@ class InteractiveScene(Scene): for mob in reversed(self.get_selection_search_set()): if mob.is_movable() and self.selection_rectangle.is_touching(mob): self.add_to_selection(mob) - + elif chr(symbol) == CURSOR_LOCATION_KEY: + self.remove(self.cursor_location_label) elif symbol == SHIFT_SYMBOL: if self.window.is_key_pressed(ord(RESIZE_KEY)): self.prepare_resizing(about_corner=False) From 4d8698a0e88333f6481c08d1b84b6e44f9dc4543 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Fri, 22 Apr 2022 11:42:26 -0700 Subject: [PATCH 17/27] Add Mobject.deserialize --- manimlib/mobject/mobject.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/manimlib/mobject/mobject.py b/manimlib/mobject/mobject.py index 2e174a84..3caeda69 100644 --- a/manimlib/mobject/mobject.py +++ b/manimlib/mobject/mobject.py @@ -470,6 +470,10 @@ class Mobject(object): self.parents = pre return result + def deserialize(self, data: bytes): + self.become(pickle.loads(data)) + return self + def copy(self): try: serial = self.serialize() From cf466006faa00fc12dc22f5732dc21ccedaa5a63 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Fri, 22 Apr 2022 11:44:28 -0700 Subject: [PATCH 18/27] Add undo and redo stacks for scene, together with Command + Z functionality --- manimlib/scene/interactive_scene.py | 25 ++++++----- manimlib/scene/scene.py | 70 ++++++++++++++++++++--------- 2 files changed, 63 insertions(+), 32 deletions(-) diff --git a/manimlib/scene/interactive_scene.py b/manimlib/scene/interactive_scene.py index b805134e..3c75334a 100644 --- a/manimlib/scene/interactive_scene.py +++ b/manimlib/scene/interactive_scene.py @@ -86,7 +86,6 @@ class InteractiveScene(Scene): self.cursor_location_label, self.camera.frame ] - self.saved_selection_state = [] self.select_top_level_mobs = True self.is_selecting = False @@ -210,7 +209,6 @@ class InteractiveScene(Scene): if mobs: 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] def toggle_from_selection(self, *mobjects): for mob in mobjects: @@ -267,14 +265,8 @@ class InteractiveScene(Scene): self.remove(*self.selection) self.clear_selection() - 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 prepare_resizing(self, about_corner=False): @@ -313,9 +305,11 @@ class InteractiveScene(Scene): 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) + # Show coordiantes of cursor location elif char == CURSOR_LOCATION_KEY and modifiers == 0: self.add(self.cursor_location_label) # Command + c -> Copy mobject ids to clipboard @@ -355,15 +349,18 @@ class InteractiveScene(Scene): # Command + t -> Toggle selection mode elif char == "t" and modifiers == COMMAND_MODIFIER: self.toggle_selection_mode() - # Command + z -> Restore selection to original state + # Command + z -> Undo elif char == "z" and modifiers == COMMAND_MODIFIER: self.undo() + # Command + shift + z -> Redo + elif char == "z" and modifiers == COMMAND_MODIFIER | SHIFT_MODIFIER: + self.redo() # Command + s -> Save selections to file elif char == "s" and modifiers == COMMAND_MODIFIER: to_save = self.selection if len(to_save) == 1: to_save = to_save[0] - self.save_mobect(to_save) + self.save_mobject_to_file(to_save) # Keyboard movements elif symbol in ARROW_SYMBOLS: nudge = self.selection_nudge_size @@ -372,6 +369,10 @@ class InteractiveScene(Scene): vect = [LEFT, UP, RIGHT, DOWN][ARROW_SYMBOLS.index(symbol)] self.selection.shift(nudge * vect) + # Conditions for saving state + if char in [GRAB_KEY, HORIZONTAL_GRAB_KEY, VERTICAL_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: diff --git a/manimlib/scene/scene.py b/manimlib/scene/scene.py index d3617f5b..6ba65367 100644 --- a/manimlib/scene/scene.py +++ b/manimlib/scene/scene.py @@ -61,6 +61,7 @@ class Scene(object): "presenter_mode": False, "linger_after_completion": True, "pan_sensitivity": 3, + "max_num_saved_states": 20, } def __init__(self, **kwargs): @@ -70,6 +71,8 @@ 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 @@ -88,12 +91,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() @@ -143,22 +150,28 @@ 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() self.save_state() - # Configure and launch embedded terminal + # 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 # Add a few custom shortcuts - for term in ("play", "wait", "add", "remove", "clear", "save_state", "restore", "i2g", "i2m"): - local_ns[term] = getattr(self, term) + 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): @@ -172,7 +185,7 @@ class Scene(object): pt_inputhooks.register("manim", inputhook) shell.enable_gui("manim") - # Have the frame update after each command + # Operation to run after each ipython command def post_cell_func(*args, **kwargs): self.refresh_static_mobjects() @@ -181,14 +194,13 @@ class Scene(object): # 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() @@ -219,6 +231,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) @@ -237,6 +250,7 @@ class Scene(object): ]) # Related to time + def get_time(self) -> float: return self.time @@ -244,6 +258,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 @@ -351,6 +366,7 @@ class Scene(object): 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: @@ -366,6 +382,7 @@ class Scene(object): self.skip_animations = False # Methods associated with running animations + def get_time_progression( self, run_time: float, @@ -489,6 +506,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: @@ -610,21 +629,32 @@ 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()) - def save_mobect(self, mobject: Mobject, file_path: str | None = None) -> None: + def redo(self): + if self.redo_stack: + self.undo_stack.append(self.get_state()) + self.restore_state(self.redo_stack.pop()) + + 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: From b2e0aee93e9e7f6777c57aa69be56c03fd208447 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Fri, 22 Apr 2022 11:46:18 -0700 Subject: [PATCH 19/27] Get rid of ctrl + shift + e embed option --- manimlib/scene/scene.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/manimlib/scene/scene.py b/manimlib/scene/scene.py index 6ba65367..3f295ab5 100644 --- a/manimlib/scene/scene.py +++ b/manimlib/scene/scene.py @@ -16,7 +16,7 @@ from manimlib.animation.transform import MoveToTarget from manimlib.camera.camera import Camera from manimlib.constants import DEFAULT_WAIT_TIME from manimlib.constants import ARROW_SYMBOLS -from manimlib.constants import SHIFT_MODIFIER, CTRL_MODIFIER, COMMAND_MODIFIER +from manimlib.constants import COMMAND_MODIFIER from manimlib.mobject.mobject import Mobject from manimlib.mobject.mobject import Point from manimlib.mobject.mobject import Group @@ -42,7 +42,6 @@ FRAME_SHIFT_KEY = 'f' ZOOM_KEY = 'z' RESET_FRAME_KEY = 'r' QUIT_KEY = 'q' -EMBED_KEY = 'e' class Scene(object): @@ -787,9 +786,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) From 71c14969dffc8762a43f9646a0c3dc024a51b8df Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Fri, 22 Apr 2022 15:41:23 -0700 Subject: [PATCH 20/27] Refactor -e flag hackiness --- manimlib/config.py | 78 ++++++++++++++++++++++++++++------------------ 1 file changed, 48 insertions(+), 30 deletions(-) diff --git a/manimlib/config.py b/manimlib/config.py index 479797d8..59b4f567 100644 --- a/manimlib/config.py +++ b/manimlib/config.py @@ -124,7 +124,8 @@ def parse_cli(): ) parser.add_argument( "-e", "--embed", - nargs="*", + 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 " @@ -187,8 +188,12 @@ 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: str, scene_names: list[str], strings_to_match: str): +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 @@ -198,34 +203,47 @@ def insert_embed_line(file_name: str, scene_names: list[str], strings_to_match: with open(file_name, 'r') as fp: lines = fp.readlines() - line = None - if strings_to_match: - matching_lines = [ - line for line in lines - if any(s in line for s in strings_to_match) - ] - if matching_lines: - line = matching_lines[-1] - n_spaces = len(line) - len(line.lstrip()) - lines.insert(lines.index(line), " " * n_spaces + "self.embed()\n") - if line is None: - lineno = 0 - in_scene = False - in_construct = False - n_spaces = 8 - # Search for scene definition - for lineno, line in enumerate(lines): - indent = len(line) - len(line.lstrip()) - if line.startswith(f"class {scene_names[0]}"): - in_scene = True - elif in_scene and "def construct" in line: - in_construct = True - n_spaces = indent + 4 - elif in_construct: - if len(line.strip()) > 0 and indent < n_spaces: - break - 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) @@ -332,7 +350,7 @@ def get_configuration(args): module = get_module(args.file) if args.embed is not None: - with insert_embed_line(args.file, args.scene_names, 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) config = { From 59506b89cc73fff3b3736245dd72e61dcebf9a2c Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Fri, 22 Apr 2022 19:02:44 -0700 Subject: [PATCH 21/27] Revert to original copying scheme --- manimlib/mobject/mobject.py | 79 ++++++++++++++++++++++++++----------- 1 file changed, 57 insertions(+), 22 deletions(-) diff --git a/manimlib/mobject/mobject.py b/manimlib/mobject/mobject.py index 3caeda69..5649e616 100644 --- a/manimlib/mobject/mobject.py +++ b/manimlib/mobject/mobject.py @@ -464,40 +464,76 @@ class Mobject(object): # Copying and serialization + 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, Iterable) else None + setattr(self, attr, null_value) + result = func(self, *args, **kwargs) + self.__dict__.update(stash) + return result + return wrapper + + @stash_mobject_pointers def serialize(self): - pre, self.parents = self.parents, [] - result = pickle.dumps(self) - self.parents = pre - return result + return pickle.dumps(self) def deserialize(self, data: bytes): self.become(pickle.loads(data)) return self - def copy(self): - try: - serial = self.serialize() - return pickle.loads(serial) - 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(self.serialize()) + 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) 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) return self def restore(self): @@ -540,9 +576,8 @@ class Mobject(object): # Creating new Mobjects from this one def replicate(self, n: int) -> Group: - serial = self.serialize() group_class = self.get_group_class() - return group_class(*(pickle.loads(serial) for _ in range(n))) + 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: """ From 7b342a27591a07298fb2d61d717324fabfa01f9b Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Fri, 22 Apr 2022 19:03:00 -0700 Subject: [PATCH 22/27] Remove unnecessary lines --- manimlib/scene/interactive_scene.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/manimlib/scene/interactive_scene.py b/manimlib/scene/interactive_scene.py index 3c75334a..0e7bc1fb 100644 --- a/manimlib/scene/interactive_scene.py +++ b/manimlib/scene/interactive_scene.py @@ -23,7 +23,6 @@ from manimlib.scene.scene import Scene from manimlib.utils.tex_file_writing import LatexError from manimlib.utils.family_ops import extract_mobject_family_members from manimlib.utils.space_ops import get_norm -from manimlib.logger import log SELECT_KEY = 's' @@ -320,8 +319,6 @@ class InteractiveScene(Scene): 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 From 3961005fd708333a3e77856d10e78451faa04075 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Fri, 22 Apr 2022 19:17:39 -0700 Subject: [PATCH 23/27] Rename is_movable to interaction_allowed --- manimlib/mobject/mobject.py | 12 ++++++------ manimlib/scene/interactive_scene.py | 19 +++++++++++++------ 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/manimlib/mobject/mobject.py b/manimlib/mobject/mobject.py index 5649e616..bf6b7465 100644 --- a/manimlib/mobject/mobject.py +++ b/manimlib/mobject/mobject.py @@ -84,7 +84,7 @@ 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.interaction_allowed: bool = False self.init_data() self.init_uniforms() @@ -692,20 +692,20 @@ 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 or self.interaction_allowed 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: + def allow_interaction(self, value: bool = True, recurse: bool = True) -> None: for mob in self.get_family(recurse): - mob._is_movable = value + mob.interaction_allowed = value return self - def is_movable(self) -> bool: - return self._is_movable + def is_interaction_allowed(self) -> bool: + return self.interaction_allowed # Transforming operations diff --git a/manimlib/scene/interactive_scene.py b/manimlib/scene/interactive_scene.py index 0e7bc1fb..86fd36b7 100644 --- a/manimlib/scene/interactive_scene.py +++ b/manimlib/scene/interactive_scene.py @@ -145,8 +145,11 @@ class InteractiveScene(Scene): self.select_top_level_mobs = not self.select_top_level_mobs self.refresh_selection_scope() - def get_selection_search_set(self): - mobs = [m for m in self.mobjects if m not in self.unselectables] + def get_selection_search_set(self) -> list[Mobject]: + mobs = [ + m for m in self.mobjects + if m not in self.unselectables and m.is_interaction_allowed() + ] if self.select_top_level_mobs: return mobs else: @@ -173,7 +176,7 @@ class InteractiveScene(Scene): ) self.refresh_selection_highlight() - 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: @@ -186,7 +189,7 @@ class InteractiveScene(Scene): ])) return dots - def get_highlight(self, 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)) @@ -223,9 +226,13 @@ class InteractiveScene(Scene): def add(self, *new_mobjects: Mobject): for mob in new_mobjects: - mob.make_movable() + mob.allow_interaction() super().add(*new_mobjects) + def disable_interaction(self, *mobjects: Mobject): + for mob in mobjects: + mob.allow_interaction(False) + # Functions for keyboard actions def copy_selection(self): @@ -376,7 +383,7 @@ class InteractiveScene(Scene): 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): + if self.selection_rectangle.is_touching(mob): self.add_to_selection(mob) elif chr(symbol) == CURSOR_LOCATION_KEY: self.remove(self.cursor_location_label) From 62289045cc8e102121cfe4d7739f3c89102046fb Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Fri, 22 Apr 2022 19:42:47 -0700 Subject: [PATCH 24/27] Fix animating Mobject.restore bug --- manimlib/mobject/mobject.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/manimlib/mobject/mobject.py b/manimlib/mobject/mobject.py index bf6b7465..277f75dc 100644 --- a/manimlib/mobject/mobject.py +++ b/manimlib/mobject/mobject.py @@ -85,6 +85,8 @@ class Mobject(object): self.needs_new_bounding_box: bool = True self._is_animating: bool = False self.interaction_allowed: bool = False + self.saved_state = None + self.target = None self.init_data() self.init_uniforms() @@ -473,7 +475,7 @@ class Mobject(object): if hasattr(self, attr): value = getattr(self, attr) stash[attr] = value - null_value = [] if isinstance(value, Iterable) else None + null_value = [] if isinstance(value, list) else None setattr(self, attr, null_value) result = func(self, *args, **kwargs) self.__dict__.update(stash) @@ -530,14 +532,16 @@ class Mobject(object): def generate_target(self, use_deepcopy: bool = False): 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): 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 From 04bca6cafbb1482b8f25cfb34ce83316d8a095c9 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Fri, 22 Apr 2022 23:14:00 -0700 Subject: [PATCH 25/27] Refresh static mobjects on undo's and redo's --- manimlib/scene/scene.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/manimlib/scene/scene.py b/manimlib/scene/scene.py index 3f295ab5..1af974b4 100644 --- a/manimlib/scene/scene.py +++ b/manimlib/scene/scene.py @@ -647,11 +647,13 @@ class Scene(object): if self.undo_stack: self.redo_stack.append(self.get_state()) self.restore_state(self.undo_stack.pop()) + self.refresh_static_mobjects() 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: From 754316bf586be5a59839f8bac6fb9fcc47da0efb Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Fri, 22 Apr 2022 23:14:19 -0700 Subject: [PATCH 26/27] Factor out event handling --- manimlib/scene/interactive_scene.py | 300 ++++++++++++++++------------ 1 file changed, 174 insertions(+), 126 deletions(-) diff --git a/manimlib/scene/interactive_scene.py b/manimlib/scene/interactive_scene.py index 86fd36b7..eb42665c 100644 --- a/manimlib/scene/interactive_scene.py +++ b/manimlib/scene/interactive_scene.py @@ -27,8 +27,9 @@ from manimlib.utils.space_ops import get_norm 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' @@ -36,6 +37,7 @@ 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, @@ -86,8 +88,10 @@ class InteractiveScene(Scene): self.camera.frame ] self.select_top_level_mobs = True + self.regenerate_selection_search_set() self.is_selecting = False + self.is_grabbing = False self.add(self.selection_highlight) def get_selection_rectangle(self): @@ -144,18 +148,22 @@ class InteractiveScene(Scene): def toggle_selection_mode(self): self.select_top_level_mobs = not self.select_top_level_mobs self.refresh_selection_scope() + self.regenerate_selection_search_set() def get_selection_search_set(self) -> list[Mobject]: - mobs = [ - m for m in self.mobjects - if m not in self.unselectables and m.is_interaction_allowed() - ] + 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() ] @@ -208,30 +216,47 @@ class InteractiveScene(Scene): lambda m: m not in self.unselectables and m not in self.selection, mobjects )) - if mobs: - self.selection.add(*mobs) - self.selection_highlight.add(*map(self.get_highlight, mobs)) + if len(mobs) == 0: + return + self.selection.add(*mobs) + self.selection_highlight.add(*map(self.get_highlight, mobs)) + 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.allow_interaction() super().add(*new_mobjects) + self.regenerate_selection_search_set() + + def remove(self, *mobjects: Mobject): + super().remove(*mobjects) + self.regenerate_selection_search_set() def disable_interaction(self, *mobjects: Mobject): for mob in mobjects: - mob.allow_interaction(False) + 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 @@ -247,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 @@ -275,6 +300,23 @@ class InteractiveScene(Scene): 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,169 +328,175 @@ class InteractiveScene(Scene): 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.save_state() - self.add(self.color_palette) - else: - self.remove(self.color_palette) - # Show coordiantes of cursor location + self.toggle_color_palette() elif char == CURSOR_LOCATION_KEY and modifiers == 0: self.add(self.cursor_location_label) - # Command + c -> Copy mobject ids to clipboard elif char == "c" and modifiers == COMMAND_MODIFIER: self.copy_selection() - # Command + v -> Paste elif char == "v" and modifiers == COMMAND_MODIFIER: self.paste_selection() - # Command + x -> Cut elif char == "x" and modifiers == COMMAND_MODIFIER: 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 -> Undo elif char == "z" and modifiers == COMMAND_MODIFIER: self.undo() - # Command + shift + z -> Redo elif char == "z" and modifiers == COMMAND_MODIFIER | SHIFT_MODIFIER: self.redo() - # Command + s -> Save selections to file elif char == "s" and modifiers == COMMAND_MODIFIER: - to_save = self.selection - if len(to_save) == 1: - to_save = to_save[0] - self.save_mobject_to_file(to_save) - # 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, HORIZONTAL_GRAB_KEY, VERTICAL_GRAB_KEY, RESIZE_KEY]: + 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 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: - if self.window.is_key_pressed(ord(RESIZE_KEY)): - self.prepare_resizing(about_corner=False) + elif symbol == SHIFT_SYMBOL and 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 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) - # Move selection - if self.window.is_key_pressed(ord(GRAB_KEY)): - self.selection.move_to(point - self.mouse_to_selection) - # Move selection restricted to horizontal - elif self.window.is_key_pressed(ord(HORIZONTAL_GRAB_KEY)): - self.selection.set_x((point - self.mouse_to_selection)[0]) - # Move selection restricted to vertical - elif self.window.is_key_pressed(ord(VERTICAL_GRAB_KEY)): - self.selection.set_y((point - self.mouse_to_selection)[1]) - # Scale selection + if self.is_grabbing: + self.handle_grabbing(point) elif self.window.is_key_pressed(ord(RESIZE_KEY)): - 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 - ) - # Add to selection + self.handle_resizing(point) elif self.window.is_key_pressed(ord(SELECT_KEY)) and self.window.is_key_pressed(SHIFT_SYMBOL): - 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) + 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_color()) - self.remove(self.color_palette) + self.choose_color(point) elif self.window.is_key_pressed(SHIFT_SYMBOL): - 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) + self.toggle_clicked_mobject_from_selection(point) else: self.clear_selection() From f70e91348c8241bcb96470e7881dd92d9d3386d3 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Fri, 22 Apr 2022 23:14:57 -0700 Subject: [PATCH 27/27] Remove Mobject.interaction_allowed, in favor of using _is_animating for multiple purposes --- manimlib/mobject/mobject.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/manimlib/mobject/mobject.py b/manimlib/mobject/mobject.py index 277f75dc..9a70c4b9 100644 --- a/manimlib/mobject/mobject.py +++ b/manimlib/mobject/mobject.py @@ -84,7 +84,6 @@ class Mobject(object): self.locked_data_keys: set[str] = set() self.needs_new_bounding_box: bool = True self._is_animating: bool = False - self.interaction_allowed: bool = False self.saved_state = None self.target = None @@ -495,7 +494,7 @@ class Mobject(object): if deep: try: # Often faster than deepcopy - return pickle.loads(self.serialize()) + return pickle.loads(pickle.dumps(self)) except AttributeError: return copy.deepcopy(self) @@ -696,21 +695,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.interaction_allowed + 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 allow_interaction(self, value: bool = True, recurse: bool = True) -> None: - for mob in self.get_family(recurse): - mob.interaction_allowed = value - return self - - def is_interaction_allowed(self) -> bool: - return self.interaction_allowed - # Transforming operations def shift(self, vector: np.ndarray):