From df2d465140e25fee265f602608aebbbaa2898c7e Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Wed, 30 Mar 2022 13:14:09 -0700 Subject: [PATCH 01/34] Add specific euler angle getters --- manimlib/camera/camera.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/manimlib/camera/camera.py b/manimlib/camera/camera.py index 40037a3d..6466ffc9 100644 --- a/manimlib/camera/camera.py +++ b/manimlib/camera/camera.py @@ -60,6 +60,15 @@ class CameraFrame(Mobject): def get_euler_angles(self): return self.get_orientation().as_euler("zxz")[::-1] + def get_theta(self): + return self.get_euler_angles()[0] + + def get_phi(self): + return self.get_euler_angles()[1] + + def get_gamma(self): + return self.get_euler_angles()[2] + def get_inverse_camera_rotation_matrix(self): return self.get_orientation().as_matrix().T From e74cb851824ef10d55064ad0ded481a356fb2c3a Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Wed, 30 Mar 2022 13:14:29 -0700 Subject: [PATCH 02/34] Remove unnecessary close of ProgressDisplay --- manimlib/scene/scene.py | 1 - 1 file changed, 1 deletion(-) diff --git a/manimlib/scene/scene.py b/manimlib/scene/scene.py index 3b649c96..987e465b 100644 --- a/manimlib/scene/scene.py +++ b/manimlib/scene/scene.py @@ -538,7 +538,6 @@ class Scene(object): self.update_frame(dt) self.emit_frame() if stop_condition is not None and stop_condition(): - time_progression.close() break self.unlock_mobject_data() return self From 217c1d7bb02f23a61722bf7275c40802be808563 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Wed, 6 Apr 2022 13:03:36 -0700 Subject: [PATCH 03/34] Add start angle option to Circle --- manimlib/mobject/geometry.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/manimlib/mobject/geometry.py b/manimlib/mobject/geometry.py index f555ee52..81670fad 100644 --- a/manimlib/mobject/geometry.py +++ b/manimlib/mobject/geometry.py @@ -320,8 +320,8 @@ class Circle(Arc): "anchors_span_full_range": False } - def __init__(self, **kwargs): - Arc.__init__(self, 0, TAU, **kwargs) + def __init__(self, start_angle: float = 0, **kwargs): + Arc.__init__(self, start_angle, TAU, **kwargs) def surround( self, From 3b847da9eaad7391e779c5dbce63ad9257d8c773 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Wed, 6 Apr 2022 13:04:05 -0700 Subject: [PATCH 04/34] Update parent updater status when adding updaters --- manimlib/mobject/mobject.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/manimlib/mobject/mobject.py b/manimlib/mobject/mobject.py index 17fe9ad0..c9fd6467 100644 --- a/manimlib/mobject/mobject.py +++ b/manimlib/mobject/mobject.py @@ -575,6 +575,8 @@ class Mobject(object): updater_list.insert(index, update_function) self.refresh_has_updater_status() + for parent in self.parents: + parent.has_updaters = True if call_updater: self.update(dt=0) return self From d7bdcab161fcd31f6b4d5c447a267559e6a08b36 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Wed, 6 Apr 2022 13:04:44 -0700 Subject: [PATCH 05/34] Tiny formatting change --- manimlib/mobject/svg/svg_mobject.py | 1 + 1 file changed, 1 insertion(+) diff --git a/manimlib/mobject/svg/svg_mobject.py b/manimlib/mobject/svg/svg_mobject.py index 1ba0923b..c057c1b2 100644 --- a/manimlib/mobject/svg/svg_mobject.py +++ b/manimlib/mobject/svg/svg_mobject.py @@ -58,6 +58,7 @@ class SVGMobject(VMobject): }, "path_string_config": {}, } + def __init__(self, file_name: str | None = None, **kwargs): super().__init__(**kwargs) self.file_name = file_name or self.file_name From e8ac25903e19cbb2b2c2037c988baafce4ddcbbc Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Mon, 11 Apr 2022 09:59:24 -0700 Subject: [PATCH 06/34] Add case for zero vectors on angle_between_vectors --- manimlib/utils/space_ops.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/manimlib/utils/space_ops.py b/manimlib/utils/space_ops.py index d570d04d..a959b8a5 100644 --- a/manimlib/utils/space_ops.py +++ b/manimlib/utils/space_ops.py @@ -152,6 +152,8 @@ def angle_between_vectors(v1: np.ndarray, v2: np.ndarray) -> float: """ n1 = get_norm(v1) n2 = get_norm(v2) + if n1 == 0 or n2 == 0: + return 0 cos_angle = np.dot(v1, v2) / (n1 * n2) return math.acos(clip(cos_angle, -1, 1)) From 6a01e36b36b5517ca46c635bd97a1b22e3ee3720 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Thu, 14 Apr 2022 14:36:17 -0700 Subject: [PATCH 07/34] Minor cleanup --- manimlib/scene/scene.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/manimlib/scene/scene.py b/manimlib/scene/scene.py index 987e465b..69d79db5 100644 --- a/manimlib/scene/scene.py +++ b/manimlib/scene/scene.py @@ -10,7 +10,6 @@ from typing import Iterable, Callable from tqdm import tqdm as ProgressDisplay import numpy as np -import numpy.typing as npt from manimlib.animation.animation import prepare_animation from manimlib.animation.transform import MoveToTarget @@ -605,9 +604,11 @@ class Scene(object): return frame = self.camera.frame + # Handle perspective changes if self.window.is_key_pressed(ord("d")): frame.increment_theta(-self.pan_sensitivity * d_point[0]) frame.increment_phi(self.pan_sensitivity * d_point[1]) + # Handle frame movements elif self.window.is_key_pressed(ord("s")): shift = -d_point shift[0] *= frame.get_width() / 2 From 95f56f5e80106443d705c68fa220850ec38daee0 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Thu, 14 Apr 2022 14:37:12 -0700 Subject: [PATCH 08/34] Be sure has_updater_status is properly updated after clear --- manimlib/mobject/mobject.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manimlib/mobject/mobject.py b/manimlib/mobject/mobject.py index c9fd6467..bcf3151c 100644 --- a/manimlib/mobject/mobject.py +++ b/manimlib/mobject/mobject.py @@ -591,10 +591,10 @@ class Mobject(object): def clear_updaters(self, recurse: bool = True): self.time_based_updaters = [] self.non_time_updaters = [] - self.refresh_has_updater_status() if recurse: for submob in self.submobjects: submob.clear_updaters() + self.refresh_has_updater_status() return self def match_updaters(self, mobject: Mobject): From 29816fa74c7aa6ca060b63ab4165c89987e58d8b Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Thu, 14 Apr 2022 14:37:38 -0700 Subject: [PATCH 09/34] Add get_highlight --- manimlib/mobject/mobject.py | 42 ++++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/manimlib/mobject/mobject.py b/manimlib/mobject/mobject.py index bcf3151c..4d95b170 100644 --- a/manimlib/mobject/mobject.py +++ b/manimlib/mobject/mobject.py @@ -421,21 +421,6 @@ class Mobject(object): self.center() return self - def replicate(self, n: int) -> Group: - return self.get_group_class()( - *(self.copy() for x in range(n)) - ) - - def get_grid(self, n_rows: int, n_cols: int, height: float | None = None, **kwargs): - """ - Returns a new mobject containing multiple copies of this one - arranged in a grid - """ - grid = self.replicate(n_rows * n_cols) - grid.arrange_in_grid(n_rows, n_cols, **kwargs) - if height is not None: - grid.set_height(height) - return grid def sort( self, @@ -457,6 +442,33 @@ class Mobject(object): self.assemble_family() return self + # Creating new Mobjects from this one + + def replicate(self, n: int) -> Group: + return self.get_group_class()( + *(self.copy() for x in range(n)) + ) + + def get_grid(self, n_rows: int, n_cols: int, height: float | None = None, **kwargs): + """ + Returns a new mobject containing multiple copies of this one + arranged in a grid + """ + grid = self.replicate(n_rows * n_cols) + grid.arrange_in_grid(n_rows, n_cols, **kwargs) + if height is not None: + grid.set_height(height) + return grid + + def get_highlight(self): + from manimlib.mobject.types.dot_cloud import GlowDot + highlight = Group(*( + GlowDot(self.get_corner(v), color=WHITE) + for v in [UR, UL, DL, DR] + )) + highlight.add_updater(lambda m: m.move_to(self)) + return highlight + # Copying def copy(self): From 5e49f20294bb9cb178cd1a37cf74c6c53eb45ef4 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Thu, 14 Apr 2022 14:37:50 -0700 Subject: [PATCH 10/34] Add VMobject.get_highlight --- manimlib/mobject/types/vectorized_mobject.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/manimlib/mobject/types/vectorized_mobject.py b/manimlib/mobject/types/vectorized_mobject.py index f5b47859..bc815fbb 100644 --- a/manimlib/mobject/types/vectorized_mobject.py +++ b/manimlib/mobject/types/vectorized_mobject.py @@ -349,6 +349,22 @@ class VMobject(Mobject): def get_joint_type(self) -> str: return self.joint_type + def get_highlight( + self, + stroke_color: Color = WHITE, + added_stroke: float = 3.0, + opacity: float = 0.75, + ) -> VMobject: + highlight = self.copy() + highlight.set_fill(opacity=0) + highlight.set_stroke( + color=stroke_color, + width=self.get_stroke_width() + added_stroke, + opacity=opacity + ) + highlight.add_updater(lambda m: m.move_to(self)) + return highlight + # Points def set_anchors_and_handles( self, @@ -681,7 +697,7 @@ class VMobject(Mobject): self.get_end_anchors(), )))) - def get_points_without_null_curves(self, atol: float=1e-9) -> np.ndarray: + def get_points_without_null_curves(self, atol: float = 1e-9) -> np.ndarray: nppc = self.n_points_per_curve points = self.get_points() distinct_curves = reduce(op.or_, [ From 50565fcd7a43ed13dc532f17515208edf97f64d0 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Thu, 14 Apr 2022 16:27:58 -0700 Subject: [PATCH 11/34] Change the way changing-vs-static mobjects are tracked Previously, Camera would keep track of which mobjects are supposed to be "static", so that it could generated their render groups once and not repeat unnecessarily. This had an awkward dependence where Scene would then need to keep track of which mobjects should and should not be considered static. This update pushes that logic to the Mobject level, where it keeps track internally of whether it's being animated, has an updater, or can be moved around by the mouse. --- manimlib/animation/animation.py | 2 ++ manimlib/camera/camera.py | 42 ++++++++++++++++++--------------- manimlib/mobject/mobject.py | 22 +++++++++++++++-- manimlib/scene/scene.py | 36 ++++++++-------------------- 4 files changed, 55 insertions(+), 47 deletions(-) diff --git a/manimlib/animation/animation.py b/manimlib/animation/animation.py index 8ec26de2..e587f813 100644 --- a/manimlib/animation/animation.py +++ b/manimlib/animation/animation.py @@ -52,6 +52,7 @@ class Animation(object): # played. As much initialization as possible, # especially any mobject copying, should live in # this method + self.mobject.set_animating_status(True) self.starting_mobject = self.create_starting_mobject() if self.suspend_mobject_updating: # All calls to self.mobject's internal updaters @@ -66,6 +67,7 @@ class Animation(object): def finish(self) -> None: self.interpolate(self.final_alpha_value) + self.mobject.set_animating_status(False) if self.suspend_mobject_updating: self.mobject.resume_updating() diff --git a/manimlib/camera/camera.py b/manimlib/camera/camera.py index 6466ffc9..2884216c 100644 --- a/manimlib/camera/camera.py +++ b/manimlib/camera/camera.py @@ -199,7 +199,10 @@ class Camera(object): self.init_textures() self.init_light_source() self.refresh_perspective_uniforms() - self.static_mobject_to_render_group_list = {} + # A cached map from mobjects to their associated list of render groups + # so that these render groups are not regenerated unnecessarily for static + # mobjects + self.mob_to_render_groups = {} def init_frame(self) -> None: self.frame = CameraFrame(**self.frame_config) @@ -365,11 +368,21 @@ class Camera(object): if render_group["single_use"]: self.release_render_group(render_group) - def get_render_group_list(self, mobject: Mobject) -> list[dict[str]] | map[dict[str]]: - try: - return self.static_mobject_to_render_group_list[id(mobject)] - except KeyError: - return map(self.get_render_group, mobject.get_shader_wrapper_list()) + def get_render_group_list(self, mobject: Mobject) -> Iterable[dict[str]]: + if mobject.is_changing(): + return self.generate_render_group_list(mobject) + + # Otherwise, cache result for later use + key = id(mobject) + if key not in self.mob_to_render_groups: + self.mob_to_render_groups[key] = list(self.generate_render_group_list(mobject)) + return self.mob_to_render_groups[key] + + def generate_render_group_list(self, mobject: Mobject) -> Iterable[dict[str]]: + return ( + self.get_render_group(sw, single_use=mobject.is_changing()) + for sw in mobject.get_shader_wrapper_list() + ) def get_render_group( self, @@ -408,19 +421,10 @@ class Camera(object): if render_group[key] is not None: render_group[key].release() - def set_mobjects_as_static(self, *mobjects: Mobject) -> None: - # Creates buffer and array objects holding each mobjects shader data - for mob in mobjects: - self.static_mobject_to_render_group_list[id(mob)] = [ - self.get_render_group(sw, single_use=False) - for sw in mob.get_shader_wrapper_list() - ] - - def release_static_mobjects(self) -> None: - for rg_list in self.static_mobject_to_render_group_list.values(): - for render_group in rg_list: - self.release_render_group(render_group) - self.static_mobject_to_render_group_list = {} + def refresh_static_mobjects(self) -> None: + for render_group in it.chain(*self.mob_to_render_groups.values()): + self.release_render_group(render_group) + self.mob_to_render_groups = {} # Shaders def init_shaders(self) -> None: diff --git a/manimlib/mobject/mobject.py b/manimlib/mobject/mobject.py index 4d95b170..39454f6d 100644 --- a/manimlib/mobject/mobject.py +++ b/manimlib/mobject/mobject.py @@ -71,7 +71,7 @@ class Mobject(object): # Must match in attributes of vert shader "shader_dtype": [ ('point', np.float32, (3,)), - ] + ], } def __init__(self, **kwargs): @@ -81,6 +81,8 @@ class Mobject(object): self.family: list[Mobject] = [self] self.locked_data_keys: set[str] = set() self.needs_new_bounding_box: bool = True + self._is_animating: bool = False + self._is_movable: bool = False self.init_data() self.init_uniforms() @@ -421,7 +423,6 @@ class Mobject(object): self.center() return self - def sort( self, point_to_num_func: Callable[[np.ndarray], float] = lambda p: p[0], @@ -637,6 +638,23 @@ class Mobject(object): self.has_updaters = any(mob.get_updaters() for mob in self.get_family()) return self + # Check if mark as static or not for camera + + def is_changing(self) -> bool: + return self._is_animating or self.has_updaters or self._is_movable + + def set_animating_status(self, is_animating: bool) -> None: + self._is_animating = is_animating + + def set_movable_status(self, is_movable: bool) -> None: + self._is_movable = is_movable + + def is_movable(self) -> bool: + return self._is_movable + + def make_movable(self) -> None: + self._is_movable = True + # Transforming operations def shift(self, vector: np.ndarray): diff --git a/manimlib/scene/scene.py b/manimlib/scene/scene.py index 69d79db5..bb40ebc1 100644 --- a/manimlib/scene/scene.py +++ b/manimlib/scene/scene.py @@ -115,16 +115,16 @@ class Scene(object): # If there is a window, enter a loop # which updates the frame while under # the hood calling the pyglet event loop - log.info("Tips: You are now in the interactive mode. Now you can use the keyboard" - " and the mouse to interact with the scene. Just press `q` if you want to quit.") + log.info( + "Tips: You are now in the interactive mode. Now you can use the keyboard" + " and the mouse to interact with the scene. Just press `q` if you want to quit." + ) self.quit_interaction = False - self.lock_static_mobject_data() + self.refresh_static_mobjects() while not (self.window.is_closing or self.quit_interaction): self.update_frame(1 / self.camera.frame_rate) if self.window.is_closing: self.window.destroy() - if self.quit_interaction: - self.unlock_mobject_data() def embed(self, close_scene_on_exit: bool = True) -> None: if not self.preview: @@ -141,6 +141,7 @@ class Scene(object): from IPython.terminal.embed import InteractiveShellEmbed shell = InteractiveShellEmbed() # Have the frame update after each command + shell.events.register('post_run_cell', lambda *a, **kw: self.refresh_static_mobjects()) shell.events.register('post_run_cell', lambda *a, **kw: self.update_frame()) # Use the locals of the caller as the local namespace # once embedded, and add a few custom shortcuts @@ -442,6 +443,7 @@ class Scene(object): self.real_animation_start_time = time.time() self.virtual_animation_start_time = self.time + self.refresh_static_mobjects() func(self, *args, **kwargs) if should_write: @@ -450,23 +452,8 @@ class Scene(object): self.num_plays += 1 return wrapper - def lock_static_mobject_data(self, *animations: Animation) -> None: - movers = list(it.chain(*[ - anim.mobject.get_family() - for anim in animations - ])) - for mobject in self.mobjects: - if mobject in movers or mobject.get_family_updaters(): - continue - self.camera.set_mobjects_as_static(mobject) - - def unlock_mobject_data(self) -> None: - self.camera.release_static_mobjects() - - def refresh_locked_data(self): - self.unlock_mobject_data() - self.lock_static_mobject_data() - return self + def refresh_static_mobjects(self) -> None: + self.camera.refresh_static_mobjects() def begin_animations(self, animations: Iterable[Animation]) -> None: for animation in animations: @@ -506,11 +493,9 @@ class Scene(object): log.warning("Called Scene.play with no animations") return animations = self.anims_from_play_args(*args, **kwargs) - self.lock_static_mobject_data(*animations) self.begin_animations(animations) self.progress_through_animations(animations) self.finish_animations(animations) - self.unlock_mobject_data() @handle_play_like_call def wait( @@ -523,7 +508,6 @@ class Scene(object): if note: log.info(note) self.update_mobjects(dt=0) # Any problems with this? - self.lock_static_mobject_data() if self.presenter_mode and not self.skip_animations and not ignore_presenter_mode: while self.hold_on_wait: self.update_frame(dt=1 / self.camera.frame_rate) @@ -538,7 +522,7 @@ class Scene(object): self.emit_frame() if stop_condition is not None and stop_condition(): break - self.unlock_mobject_data() + self.refresh_static_mobjects() return self def wait_until( From 135f68de35712be266a1a85261d6d44234fc0056 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Wed, 20 Apr 2022 21:41:47 -0700 Subject: [PATCH 12/34] Update Mobject.is_point_touching --- manimlib/mobject/mobject.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/manimlib/mobject/mobject.py b/manimlib/mobject/mobject.py index 39454f6d..de618e38 100644 --- a/manimlib/mobject/mobject.py +++ b/manimlib/mobject/mobject.py @@ -267,15 +267,22 @@ class Mobject(object): parent.refresh_bounding_box() return self - def is_point_touching( + def are_points_touching( self, - point: np.ndarray, + points: np.ndarray, buff: float = MED_SMALL_BUFF ) -> bool: bb = self.get_bounding_box() mins = (bb[0] - buff) maxs = (bb[2] + buff) - return (point >= mins).all() and (point <= maxs).all() + return ((points >= mins) * (points <= maxs)).all(1) + + def is_point_touching( + self, + point: np.ndarray, + buff: float = MED_SMALL_BUFF + ) -> bool: + return self.are_points_touching(np.array(point, ndmin=2), buff)[0] # Family matters From c1716895c0d9f36e23487322a18963991100bb95 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Wed, 20 Apr 2022 21:42:07 -0700 Subject: [PATCH 13/34] Add Mobject.is_touching --- manimlib/mobject/mobject.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manimlib/mobject/mobject.py b/manimlib/mobject/mobject.py index de618e38..9e71ace8 100644 --- a/manimlib/mobject/mobject.py +++ b/manimlib/mobject/mobject.py @@ -284,6 +284,14 @@ class Mobject(object): ) -> bool: return self.are_points_touching(np.array(point, ndmin=2), buff)[0] + def is_touching(self, mobject: Mobject, buff: float = 1e-2) -> bool: + bb1 = self.get_bounding_box() + bb2 = mobject.get_bounding_box() + return not any(( + (bb2[2] < bb1[0] - buff).any(), # E.g. Right of mobject is left of self's left + (bb2[0] > bb1[2] + buff).any(), # E.g. Left of mobject is right of self's right + )) + # Family matters def __getitem__(self, value): From 2dd2fb500ef7d502432b5cbce5bde14df4494437 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Wed, 20 Apr 2022 21:42:22 -0700 Subject: [PATCH 14/34] Remove Mobject.get_highlight --- manimlib/mobject/mobject.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/manimlib/mobject/mobject.py b/manimlib/mobject/mobject.py index 9e71ace8..ec023a9c 100644 --- a/manimlib/mobject/mobject.py +++ b/manimlib/mobject/mobject.py @@ -476,15 +476,6 @@ class Mobject(object): grid.set_height(height) return grid - def get_highlight(self): - from manimlib.mobject.types.dot_cloud import GlowDot - highlight = Group(*( - GlowDot(self.get_corner(v), color=WHITE) - for v in [UR, UL, DL, DR] - )) - highlight.add_updater(lambda m: m.move_to(self)) - return highlight - # Copying def copy(self): From 50f5d20cc379947d7253d841c060dd7c55fa7787 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Wed, 20 Apr 2022 21:42:59 -0700 Subject: [PATCH 15/34] Allow for saving and loading mobjects from file --- manimlib/mobject/mobject.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/manimlib/mobject/mobject.py b/manimlib/mobject/mobject.py index ec023a9c..eedb5ff6 100644 --- a/manimlib/mobject/mobject.py +++ b/manimlib/mobject/mobject.py @@ -6,6 +6,8 @@ import random import itertools as it from functools import wraps from typing import Iterable, Callable, Union, Sequence +import pickle +import os import colour import moderngl @@ -37,6 +39,7 @@ from manimlib.shader_wrapper import get_colormap_code from manimlib.event_handler import EVENT_DISPATCHER from manimlib.event_handler.event_listner import EventListner from manimlib.event_handler.event_type import EventType +from manimlib.logger import log TimeBasedUpdater = Callable[["Mobject", float], None] @@ -545,6 +548,27 @@ class Mobject(object): self.become(self.saved_state) return self + def save_to_file(self, file_path): + if not file_path.endswith(".mob"): + file_path += ".mob" + if os.path.exists(file_path): + cont = input(f"{file_path} already exists. Overwrite (y/n)? ") + if cont != "y": + return + with open(file_path, "wb") as fp: + pickle.dump(self, fp) + log.info(f"Saved mobject to {file_path}") + return self + + @staticmethod + def load(file_path): + if not os.path.exists(file_path): + log.error(f"No file found at {file_path}") + sys.exit(2) + with open(file_path, "rb") as fp: + mobject = pickle.load(fp) + return mobject + # Updating def init_updaters(self): From f636199d9a5d1e87ab861bcb6aebae6c9d96a133 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Wed, 20 Apr 2022 21:43:16 -0700 Subject: [PATCH 16/34] Add Mobject.get_all_corners --- manimlib/mobject/mobject.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/manimlib/mobject/mobject.py b/manimlib/mobject/mobject.py index eedb5ff6..3bb37b6b 100644 --- a/manimlib/mobject/mobject.py +++ b/manimlib/mobject/mobject.py @@ -1235,6 +1235,13 @@ class Mobject(object): def get_corner(self, direction: np.ndarray) -> np.ndarray: return self.get_bounding_box_point(direction) + def get_all_corners(self): + bb = self.get_bounding_box() + return np.array([ + [bb[indices[-i + 1]][i] for i in range(3)] + for indices in it.product(*3 * [[0, 2]]) + ]) + def get_center(self) -> np.ndarray: return self.get_bounding_box()[1] From 48390375037f745c9cb82b03d1cb3a1de6c530f3 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Wed, 20 Apr 2022 21:44:42 -0700 Subject: [PATCH 17/34] Update Mobject.make_movable to recurse over family --- 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 3bb37b6b..661f292c 100644 --- a/manimlib/mobject/mobject.py +++ b/manimlib/mobject/mobject.py @@ -682,8 +682,10 @@ class Mobject(object): def is_movable(self) -> bool: return self._is_movable - def make_movable(self) -> None: - self._is_movable = True + def make_movable(self, value: bool = True, recurse: bool = True) -> None: + for mob in self.get_family(recurse): + mob._is_movable = value + return self # Transforming operations From a0c46ef3bfbeb5cb0eb46f11431cdb02e133cf49 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Wed, 20 Apr 2022 21:46:43 -0700 Subject: [PATCH 18/34] Have set_animating_status recurse over family --- manimlib/mobject/mobject.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/manimlib/mobject/mobject.py b/manimlib/mobject/mobject.py index 661f292c..58673e0b 100644 --- a/manimlib/mobject/mobject.py +++ b/manimlib/mobject/mobject.py @@ -673,20 +673,19 @@ class Mobject(object): def is_changing(self) -> bool: return self._is_animating or self.has_updaters or self._is_movable - def set_animating_status(self, is_animating: bool) -> None: - self._is_animating = is_animating - - def set_movable_status(self, is_movable: bool) -> None: - self._is_movable = is_movable - - def is_movable(self) -> bool: - return self._is_movable + def set_animating_status(self, is_animating: bool, recurse: bool = True) -> None: + for mob in self.get_family(recurse): + mob._is_animating = is_animating + return self def make_movable(self, value: bool = True, recurse: bool = True) -> None: for mob in self.get_family(recurse): mob._is_movable = value return self + def is_movable(self) -> bool: + return self._is_movable + # Transforming operations def shift(self, vector: np.ndarray): From b09e6916dc0b8ec2d9fb56fe12ea829c090f81fe Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Wed, 20 Apr 2022 21:47:12 -0700 Subject: [PATCH 19/34] Remove VMobject.get_highlight --- manimlib/mobject/types/vectorized_mobject.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/manimlib/mobject/types/vectorized_mobject.py b/manimlib/mobject/types/vectorized_mobject.py index bc815fbb..6615f715 100644 --- a/manimlib/mobject/types/vectorized_mobject.py +++ b/manimlib/mobject/types/vectorized_mobject.py @@ -349,22 +349,6 @@ class VMobject(Mobject): def get_joint_type(self) -> str: return self.joint_type - def get_highlight( - self, - stroke_color: Color = WHITE, - added_stroke: float = 3.0, - opacity: float = 0.75, - ) -> VMobject: - highlight = self.copy() - highlight.set_fill(opacity=0) - highlight.set_stroke( - color=stroke_color, - width=self.get_stroke_width() + added_stroke, - opacity=opacity - ) - highlight.add_updater(lambda m: m.move_to(self)) - return highlight - # Points def set_anchors_and_handles( self, From fdeab8ca953b46a902b531febcf132739ca194d4 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Wed, 20 Apr 2022 21:47:47 -0700 Subject: [PATCH 20/34] Make sure AnimationGroup plays nicely with setting mobject animation status --- manimlib/animation/composition.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/manimlib/animation/composition.py b/manimlib/animation/composition.py index f282bc9c..0771ad31 100644 --- a/manimlib/animation/composition.py +++ b/manimlib/animation/composition.py @@ -50,11 +50,13 @@ class AnimationGroup(Animation): return self.group def begin(self) -> None: + self.group.set_animating_status(True) for anim in self.animations: anim.begin() # self.init_run_time() def finish(self) -> None: + self.group.set_animating_status(False) for anim in self.animations: anim.finish() From cb768c26a0bc63e02c3035b4af31ba5cbc2e9dda Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Wed, 20 Apr 2022 21:48:58 -0700 Subject: [PATCH 21/34] Add functionality for recovering mobjects from their ids (to enable copying and pasting) --- manimlib/scene/scene.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/manimlib/scene/scene.py b/manimlib/scene/scene.py index bb40ebc1..284a03c9 100644 --- a/manimlib/scene/scene.py +++ b/manimlib/scene/scene.py @@ -295,6 +295,25 @@ class Scene(object): return mobject return None + def get_group(self, *mobjects): + if all(isinstance(m, VMobject) for m in mobjects): + return VGroup(*mobjects) + else: + return Group(*mobjects) + + def id_to_mobject(self, id_value): + for mob in self.mobjects: + for sm in mob.get_family(): + if id(sm) == id_value: + return sm + return None + + def ids_to_group(self, *id_values): + return self.get_group(*filter( + lambda x: x is not None, + map(self.id_to_mobject, id_values) + )) + # Related to skipping def update_skipping_status(self) -> None: if self.start_at_animation_number is not None: From 97400a5cf26f33ed507ddeeb9b9a7f1a558d4f17 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Wed, 20 Apr 2022 21:49:38 -0700 Subject: [PATCH 22/34] Update Scene.save_state and Scene.restore --- manimlib/scene/scene.py | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/manimlib/scene/scene.py b/manimlib/scene/scene.py index 284a03c9..df5c304b 100644 --- a/manimlib/scene/scene.py +++ b/manimlib/scene/scene.py @@ -575,22 +575,18 @@ class Scene(object): # Helpers for interactive development def save_state(self) -> None: - self.saved_state = { - "mobjects": self.mobjects, - "mobject_states": [ - mob.copy() - for mob in self.mobjects - ], - } + self.saved_state = [ + (mob, mob.copy()) + for mob in self.mobjects + ] def restore(self) -> None: if not hasattr(self, "saved_state"): raise Exception("Trying to restore scene without having saved") - mobjects = self.saved_state["mobjects"] - states = self.saved_state["mobject_states"] - for mob, state in zip(mobjects, states): - mob.become(state) - self.mobjects = mobjects + self.mobjects = [] + for mob, mob_state in self.saved_state: + mob.become(mob_state) + self.mobjects.append(mob) # Event handling From 777b6d37783f8592df8a8abc3d62af972bc5a0c6 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Wed, 20 Apr 2022 21:49:57 -0700 Subject: [PATCH 23/34] Allow for saving and loading mobjects from file at the Scene level --- manimlib/scene/scene.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/manimlib/scene/scene.py b/manimlib/scene/scene.py index df5c304b..c19fd056 100644 --- a/manimlib/scene/scene.py +++ b/manimlib/scene/scene.py @@ -588,6 +588,19 @@ 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 load_mobject(self, file_name): + if os.path.exists(file_name): + path = file_name + else: + directory = self.file_writer.get_saved_mobject_directory() + path = os.path.join(directory, file_name) + return Mobject.load(path) + # Event handling def on_mouse_motion( From 68e2909af16a541b179930766205f99e84ebfecc Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Wed, 20 Apr 2022 21:50:37 -0700 Subject: [PATCH 24/34] Mild cleanup to Scene interactivity --- manimlib/constants.py | 10 ++++++++++ manimlib/scene/scene.py | 41 +++++++++++++++++++++++++++++++---------- 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/manimlib/constants.py b/manimlib/constants.py index 590a9cda..0ace8909 100644 --- a/manimlib/constants.py +++ b/manimlib/constants.py @@ -71,6 +71,16 @@ BOLD = "BOLD" DEFAULT_STROKE_WIDTH = 4 +# For keyboard interactions +CTRL_SYMBOL = 65508 +SHIFT_SYMBOL = 65505 +DELETE_SYMBOL = 65288 +ARROW_SYMBOLS = list(range(65361, 65365)) + +SHIFT_MODIFIER = 1 +CTRL_MODIFIER = 2 +COMMAND_MODIFIER = 64 + # Colors BLUE_E = "#1C758A" BLUE_D = "#29ABCA" diff --git a/manimlib/scene/scene.py b/manimlib/scene/scene.py index c19fd056..01037f62 100644 --- a/manimlib/scene/scene.py +++ b/manimlib/scene/scene.py @@ -4,9 +4,9 @@ import time import random import inspect import platform -import itertools as it from functools import wraps from typing import Iterable, Callable +import os from tqdm import tqdm as ProgressDisplay import numpy as np @@ -14,9 +14,15 @@ 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 from manimlib.mobject.mobject import Mobject from manimlib.mobject.mobject import Point +from manimlib.mobject.mobject import Group +from manimlib.mobject.types.vectorized_mobject import VMobject +from manimlib.mobject.types.vectorized_mobject import VGroup from manimlib.scene.scene_file_writer import SceneFileWriter from manimlib.utils.config_ops import digest_config from manimlib.utils.family_ops import extract_mobject_family_members @@ -32,6 +38,14 @@ if TYPE_CHECKING: from manimlib.animation.animation import Animation +PAN_3D_KEY = 'd' +FRAME_SHIFT_KEY = 'f' +ZOOM_KEY = 'z' +RESET_FRAME_KEY = 'r' +QUIT_KEY = 'q' +EMBED_KEY = 'e' + + class Scene(object): CONFIG = { "window_config": {}, @@ -117,7 +131,8 @@ class Scene(object): # the hood calling the pyglet event loop log.info( "Tips: You are now in the interactive mode. Now you can use the keyboard" - " and the mouse to interact with the scene. Just press `q` if you want to quit." + " and the mouse to interact with the scene. Just press `command + q` or `esc`" + " if you want to quit." ) self.quit_interaction = False self.refresh_static_mobjects() @@ -147,10 +162,12 @@ class Scene(object): # once embedded, and add a few custom shortcuts local_ns = inspect.currentframe().f_back.f_locals local_ns["touch"] = self.interact + local_ns["i2g"] = self.ids_to_group for term in ("play", "wait", "add", "remove", "clear", "save_state", "restore"): local_ns[term] = getattr(self, term) log.info("Tips: Now the embed iPython terminal is open. But you can't interact with" " the window directly. To do so, you need to type `touch()` or `self.interact()`") + exec(get_custom_config()["universal_import_line"]) shell(local_ns=local_ns, stack_depth=2) # End scene when exiting an embed if close_scene_on_exit: @@ -617,11 +634,11 @@ class Scene(object): frame = self.camera.frame # Handle perspective changes - if self.window.is_key_pressed(ord("d")): + if self.window.is_key_pressed(ord(PAN_3D_KEY)): frame.increment_theta(-self.pan_sensitivity * d_point[0]) frame.increment_phi(self.pan_sensitivity * d_point[1]) # Handle frame movements - elif self.window.is_key_pressed(ord("s")): + elif self.window.is_key_pressed(ord(FRAME_SHIFT_KEY)): shift = -d_point shift[0] *= frame.get_width() / 2 shift[1] *= frame.get_height() / 2 @@ -649,6 +666,7 @@ class Scene(object): button: int, mods: int ) -> None: + self.mouse_drag_point.move_to(point) event_data = {"point": point, "button": button, "mods": mods} propagate_event = EVENT_DISPATCHER.dispatch(EventType.MousePressEvent, **event_data) if propagate_event is not None and propagate_event is False: @@ -676,9 +694,9 @@ class Scene(object): return frame = self.camera.frame - if self.window.is_key_pressed(ord("z")): + if self.window.is_key_pressed(ord(ZOOM_KEY)): factor = 1 + np.arctan(10 * offset[1]) - frame.scale(1/factor, about_point=point) + frame.scale(1 / factor, about_point=point) else: transform = frame.get_inverse_camera_rotation_matrix() shift = np.dot(np.transpose(transform), offset) @@ -710,13 +728,16 @@ class Scene(object): if propagate_event is not None and propagate_event is False: return - if char == "r": + if char == RESET_FRAME_KEY: self.camera.frame.to_default_state() - elif char == "q": + # command + q + elif char == QUIT_KEY and modifiers == COMMAND_MODIFIER: self.quit_interaction = True - elif char == " " or symbol == 65363: # Space or right arrow + # Space or right arrow + elif char == " " or symbol == ARROW_SYMBOLS[2]: self.hold_on_wait = False - elif char == "e" and modifiers == 3: # ctrl + shift + e + # ctrl + shift + e + elif char == EMBED_KEY and modifiers == CTRL_MODIFIER | SHIFT_MODIFIER: self.embed(close_scene_on_exit=False) def on_resize(self, width: int, height: int) -> None: From 5a34ca1fba8b4724eda0caa11b271d74e49f468c Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Wed, 20 Apr 2022 21:50:44 -0700 Subject: [PATCH 25/34] Add MANIM_COLORS --- manimlib/constants.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/manimlib/constants.py b/manimlib/constants.py index 0ace8909..cc73c0ac 100644 --- a/manimlib/constants.py +++ b/manimlib/constants.py @@ -137,6 +137,20 @@ LIGHT_PINK = "#DC75CD" GREEN_SCREEN = "#00FF00" ORANGE = "#FF862F" +MANIM_COLORS = [ + BLACK, GREY_E, GREY_D, GREY_C, GREY_B, GREY_A, WHITE, + BLUE_E, BLUE_D, BLUE_C, BLUE_B, BLUE_A, + TEAL_E, TEAL_D, TEAL_C, TEAL_B, TEAL_A, + GREEN_E, GREEN_D, GREEN_C, GREEN_B, GREEN_A, + YELLOW_E, YELLOW_D, YELLOW_C, YELLOW_B, YELLOW_A, + GOLD_E, GOLD_D, GOLD_C, GOLD_B, GOLD_A, + RED_E, RED_D, RED_C, RED_B, RED_A, + MAROON_E, MAROON_D, MAROON_C, MAROON_B, MAROON_A, + PURPLE_E, PURPLE_D, PURPLE_C, PURPLE_B, PURPLE_A, + GREY_BROWN, DARK_BROWN, LIGHT_BROWN, + PINK, LIGHT_PINK, +] + # Abbreviated names for the "median" colors BLUE = BLUE_C TEAL = TEAL_C From a3579eab419563d1d212e681305de9da6c9f74ba Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Wed, 20 Apr 2022 21:51:18 -0700 Subject: [PATCH 26/34] Have SceneFileWriter handle a location for saved mobjects --- manimlib/scene/scene_file_writer.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manimlib/scene/scene_file_writer.py b/manimlib/scene/scene_file_writer.py index cb948ab5..9be88487 100644 --- a/manimlib/scene/scene_file_writer.py +++ b/manimlib/scene/scene_file_writer.py @@ -78,6 +78,8 @@ class SceneFileWriter(object): self.partial_movie_directory = guarantee_existence(os.path.join( movie_dir, "partial_movie_files", scene_name, )) + # A place to save mobjects + self.saved_mobject_directory = os.path.join(out_dir, "mobjects") def get_default_module_directory(self) -> str: path, _ = os.path.splitext(self.input_file_path) @@ -119,6 +121,12 @@ class SceneFileWriter(object): def get_movie_file_path(self) -> str: return self.movie_file_path + def get_saved_mobject_directory(self) -> str: + return guarantee_existence(os.path.join( + self.saved_mobject_directory, + str(self.scene), + )) + # Sound def init_audio(self) -> None: self.includes_sound: bool = False From eae7dbbe6eaf4344374713052aae694e69b62c28 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Wed, 20 Apr 2022 21:51:36 -0700 Subject: [PATCH 27/34] Change default transparent background codec to be prores --- 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 9be88487..869db27b 100644 --- a/manimlib/scene/scene_file_writer.py +++ b/manimlib/scene/scene_file_writer.py @@ -228,7 +228,7 @@ class SceneFileWriter(object): # This is if the background of the exported # video should be transparent. command += [ - '-vcodec', 'qtrle', + '-vcodec', 'prores_ks', ] elif self.movie_file_extension == ".gif": command += [] From 47636686cb0dd2a1d7addbf94e560c2525e972e2 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Wed, 20 Apr 2022 21:51:56 -0700 Subject: [PATCH 28/34] Cleanup extract_mobject_family_members --- manimlib/utils/family_ops.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/manimlib/utils/family_ops.py b/manimlib/utils/family_ops.py index fc1a8b93..8468e74a 100644 --- a/manimlib/utils/family_ops.py +++ b/manimlib/utils/family_ops.py @@ -1,6 +1,5 @@ from __future__ import annotations -import itertools as it from typing import Iterable from typing import TYPE_CHECKING @@ -11,15 +10,14 @@ if TYPE_CHECKING: def extract_mobject_family_members( mobject_list: Iterable[Mobject], - only_those_with_points: bool = False + exclude_pointless: bool = False ) -> list[Mobject]: - result = list(it.chain(*[ - mob.get_family() + return [ + sm for mob in mobject_list - ])) - if only_those_with_points: - result = [mob for mob in result if mob.has_points()] - return result + for sm in mob.get_family() + if (not exclude_pointless) or sm.has_points() + ] def restructure_list_to_exclude_certain_family_members( From 4f2e3456e2cb987de8f5206d1b3b3041fe47d6be Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Wed, 20 Apr 2022 21:53:05 -0700 Subject: [PATCH 29/34] Raise Specific exception type when running into latex errors --- manimlib/utils/tex_file_writing.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/manimlib/utils/tex_file_writing.py b/manimlib/utils/tex_file_writing.py index 1ca7ed39..b41c2cf1 100644 --- a/manimlib/utils/tex_file_writing.py +++ b/manimlib/utils/tex_file_writing.py @@ -92,7 +92,7 @@ def tex_to_dvi(tex_file): for line in file.readlines(): if line.startswith("!"): log.debug(f"The error could be: `{line[2:-1]}`") - sys.exit(2) + raise LatexError() return result @@ -134,3 +134,8 @@ def display_during_execution(message): yield finally: print(" " * len(to_print), end="\r") + + + +class LatexError(Exception): + pass From e579f4c955844fba415b976c313f64d1bb0376d0 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Wed, 20 Apr 2022 21:53:25 -0700 Subject: [PATCH 30/34] Add pickle and pyperclip to requirements --- requirements.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/requirements.txt b/requirements.txt index ade806c9..cb60eccb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,8 +9,10 @@ matplotlib moderngl moderngl_window skia-pathops +pickle pydub pygments +pyperclip pyyaml rich screeninfo From c3afc84bfeb3a76ea8ede4ec4d9f36df0d4d9a28 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Wed, 20 Apr 2022 21:54:16 -0700 Subject: [PATCH 31/34] Add a rudimentary InteractiveScene to allow for Mobject editing in a GUI fashion --- manimlib/__init__.py | 1 + manimlib/scene/interactive_scene.py | 417 ++++++++++++++++++++++++++++ 2 files changed, 418 insertions(+) create mode 100644 manimlib/scene/interactive_scene.py diff --git a/manimlib/__init__.py b/manimlib/__init__.py index a0147cf7..ecee0ec4 100644 --- a/manimlib/__init__.py +++ b/manimlib/__init__.py @@ -53,6 +53,7 @@ from manimlib.mobject.value_tracker import * from manimlib.mobject.vector_field import * from manimlib.scene.scene import * +from manimlib.scene.interactive_scene import * from manimlib.scene.three_d_scene import * from manimlib.utils.bezier import * diff --git a/manimlib/scene/interactive_scene.py b/manimlib/scene/interactive_scene.py new file mode 100644 index 00000000..0939cfb7 --- /dev/null +++ b/manimlib/scene/interactive_scene.py @@ -0,0 +1,417 @@ +import numpy as np +import itertools as it +import pyperclip +import os + +from manimlib.animation.fading import FadeIn +from manimlib.constants import MANIM_COLORS, WHITE, YELLOW +from manimlib.constants import ORIGIN, UP, DOWN, LEFT, RIGHT +from manimlib.constants import FRAME_WIDTH, SMALL_BUFF +from manimlib.constants import CTRL_SYMBOL, SHIFT_SYMBOL, DELETE_SYMBOL, ARROW_SYMBOLS +from manimlib.constants import SHIFT_MODIFIER, COMMAND_MODIFIER +from manimlib.mobject.mobject import Mobject +from manimlib.mobject.geometry import Rectangle +from manimlib.mobject.geometry import Square +from manimlib.mobject.mobject import Group +from manimlib.mobject.svg.tex_mobject import Tex +from manimlib.mobject.svg.text_mobject import Text +from manimlib.mobject.types.vectorized_mobject import VMobject +from manimlib.mobject.types.vectorized_mobject import VGroup +from manimlib.mobject.types.dot_cloud import DotCloud +from manimlib.scene.scene import Scene +from manimlib.utils.tex_file_writing import LatexError +from manimlib.utils.family_ops import extract_mobject_family_members +from manimlib.utils.space_ops import get_norm +from manimlib.logger import log + + +SELECT_KEY = 's' +GRAB_KEY = 'g' +HORIZONTAL_GRAB_KEY = 'h' +VERTICAL_GRAB_KEY = 'v' +RESIZE_KEY = 't' +COLOR_KEY = 'c' + + +# Note, a lot of the functionality here is still buggy and very much a work in progress. + +class InteractiveScene(Scene): + """ + TODO, Document + + To select mobjects on screen, hold ctrl and move the mouse to highlight a region, + or just tap ctrl to select the mobject under the cursor. + + Pressing command + t will toggle between modes where you either select top level + mobjects part of the scene, or low level pieces. + + Hold 'g' to grab the selection and move it around + Hold 'h' to drag it constrained in the horizontal direction + Hold 'v' to drag it constrained in the vertical direction + Hold 't' to resize selection, adding 'shift' to resize with respect to a corner + + Command + 'c' copies the ids of selections to clipboard + Command + 'v' will paste either: + - The copied mobject + - A Tex mobject based on copied LaTeX + - A Text mobject based on copied Text + Command + 'z' restores selection back to its original state + Command + 's' saves the selected mobjects to file + """ + corner_dot_config = dict( + color=WHITE, + radius=0.1, + glow_factor=1.0, + ) + selection_rectangle_stroke_color = WHITE + selection_rectangle_stroke_width = 1.0 + colors = MANIM_COLORS + selection_nudge_size = 0.05 + + def setup(self): + self.selection = Group() + self.selection_highlight = Group() + self.selection_rectangle = self.get_selection_rectangle() + self.color_palette = self.get_color_palette() + self.unselectables = [ + self.selection, + self.selection_highlight, + self.selection_rectangle, + self.camera.frame + ] + self.saved_selection_state = [] + self.select_top_level_mobs = True + + self.is_selecting = False + self.add(self.selection_highlight) + + def toggle_selection_mode(self): + self.select_top_level_mobs = not self.select_top_level_mobs + self.refresh_selection_scope() + + def get_selection_search_set(self): + mobs = [m for m in self.mobjects if m not in self.unselectables] + if self.select_top_level_mobs: + return mobs + else: + return [ + submob + for mob in mobs + for submob in mob.family_members_with_points() + ] + + def refresh_selection_scope(self): + curr = list(self.selection) + if self.select_top_level_mobs: + self.selection.set_submobjects([ + mob + for mob in self.mobjects + if any(sm in mob.get_family() for sm in curr) + ]) + self.selection.refresh_bounding_box(recurse_down=True) + else: + self.selection.set_submobjects( + extract_mobject_family_members( + curr, exclude_pointless=True, + ) + ) + self.refresh_selection_highlight() + + def get_selection_rectangle(self): + rect = Rectangle( + stroke_color=self.selection_rectangle_stroke_color, + stroke_width=self.selection_rectangle_stroke_width, + ) + rect.fix_in_frame() + rect.fixed_corner = ORIGIN + rect.add_updater(self.update_selection_rectangle) + return rect + + def get_color_palette(self): + palette = VGroup(*( + Square(fill_color=color, fill_opacity=1, side_length=1) + for color in self.colors + )) + palette.set_stroke(width=0) + palette.arrange(RIGHT, buff=0.5) + palette.set_width(FRAME_WIDTH - 0.5) + palette.to_edge(DOWN, buff=SMALL_BUFF) + palette.fix_in_frame() + return palette + + def get_stroke_highlight(self, vmobject): + outline = vmobject.copy() + for sm, osm in zip(vmobject.get_family(), outline.get_family()): + osm.set_fill(opacity=0) + osm.set_stroke(YELLOW, width=sm.get_stroke_width() + 1.5) + outline.add_updater(lambda o: o.replace(vmobject)) + return outline + + def get_corner_dots(self, mobject): + dots = DotCloud(**self.corner_dot_config) + dots.add_updater(lambda d: d.set_points(mobject.get_all_corners())) + dots.scale((dots.get_width() + dots.get_radius()) / dots.get_width()) + # Since for flat object, all 8 corners really appear as four, dim the dots + if mobject.get_depth() < 1e-2: + dots.set_opacity(0.5) + return dots + + def get_highlight(self, mobject): + if isinstance(mobject, VMobject) and mobject.has_points(): + return self.get_stroke_highlight(mobject) + else: + return self.get_corner_dots(mobject) + + def refresh_selection_highlight(self): + self.selection_highlight.set_submobjects([ + self.get_highlight(mob) + for mob in self.selection + ]) + + def update_selection_rectangle(self, rect): + p1 = rect.fixed_corner + p2 = self.mouse_point.get_center() + rect.set_points_as_corners([ + p1, [p2[0], p1[1], 0], + p2, [p1[0], p2[1], 0], + p1, + ]) + return rect + + def add_to_selection(self, *mobjects): + for mob in mobjects: + if mob in self.unselectables: + continue + if mob not in self.selection: + self.selection.add(mob) + self.selection_highlight.add(self.get_highlight(mob)) + self.saved_selection_state = [ + (mob, mob.copy()) + for mob in self.selection + ] + + def toggle_from_selection(self, *mobjects): + for mob in mobjects: + if mob in self.selection: + self.selection.remove(mob) + else: + self.add_to_selection(mob) + self.refresh_selection_highlight() + + def clear_selection(self): + self.selection.set_submobjects([]) + self.selection_highlight.set_submobjects([]) + + def add(self, *new_mobjects: Mobject): + for mob in new_mobjects: + mob.make_movable() + super().add(*new_mobjects) + + # Selection operations + + def copy_selection(self): + ids = map(id, self.selection) + pyperclip.copy(",".join(map(str, ids))) + + def paste_selection(self): + clipboard_str = pyperclip.paste() + # Try pasting a mobject + try: + ids = map(int, clipboard_str.split(",")) + mobs = map(self.id_to_mobject, ids) + mob_copies = [m.copy() for m in mobs if m is not None] + self.clear_selection() + self.add_to_selection(*mob_copies) + self.play(*( + FadeIn(mc, run_time=0.5, scale=1.5) + for mc in mob_copies + )) + return + except ValueError: + pass + # Otherwise, treat as tex or text + if "\\" in clipboard_str: # Proxy to text for LaTeX + try: + new_mob = Tex(clipboard_str) + except LatexError: + return + else: + new_mob = Text(clipboard_str) + self.clear_selection() + self.add(new_mob) + self.add_to_selection(new_mob) + new_mob.move_to(self.mouse_point) + + def delete_selection(self): + self.remove(*self.selection) + self.clear_selection() + + def saved_selection_to_file(self): + directory = self.file_writer.get_saved_mobject_directory() + files = os.listdir(directory) + for mob in self.selection: + file_name = str(mob) + "_0.mob" + index = 0 + while file_name in files: + file_name = file_name.replace(str(index), str(index + 1)) + index += 1 + user_name = input( + f"Enter mobject file name (default is {file_name}): " + ) + if user_name: + file_name = user_name + files.append(file_name) + self.save_mobect(mob, file_name) + + def undo(self): + mobs = [] + for mob, state in self.saved_selection_state: + mob.become(state) + mobs.append(mob) + if mob not in self.mobjects: + self.add(mob) + self.selection.set_submobjects(mobs) + self.refresh_selection_highlight() + + def prepare_resizing(self, about_corner=False): + center = self.selection.get_center() + mp = self.mouse_point.get_center() + if about_corner: + self.scale_about_point = self.selection.get_corner(center - mp) + else: + self.scale_about_point = center + self.scale_ref_vect = mp - self.scale_about_point + self.scale_ref_width = self.selection.get_width() + + # Event handlers + + def on_key_press(self, symbol: int, modifiers: int) -> None: + super().on_key_press(symbol, modifiers) + char = chr(symbol) + # Enable selection + if char == SELECT_KEY and modifiers == 0: + self.is_selecting = True + self.add(self.selection_rectangle) + self.selection_rectangle.fixed_corner = self.mouse_point.get_center().copy() + # Prepare for move + elif char in [GRAB_KEY, HORIZONTAL_GRAB_KEY, VERTICAL_GRAB_KEY] and modifiers == 0: + mp = self.mouse_point.get_center() + self.mouse_to_selection = mp - self.selection.get_center() + # Prepare for resizing + elif char == RESIZE_KEY and modifiers in [0, SHIFT_MODIFIER]: + self.prepare_resizing(about_corner=(modifiers == SHIFT_MODIFIER)) + elif symbol == SHIFT_SYMBOL: + if self.window.is_key_pressed(ord("t")): + self.prepare_resizing(about_corner=True) + # Show color palette + elif char == COLOR_KEY and modifiers == 0: + if len(self.selection) == 0: + return + if self.color_palette not in self.mobjects: + self.add(self.color_palette) + else: + self.remove(self.color_palette) + # Command + c -> Copy mobject ids to clipboard + elif char == "c" and modifiers == COMMAND_MODIFIER: + self.copy_selection() + # Command + v -> Paste + elif char == "v" and modifiers == COMMAND_MODIFIER: + self.paste_selection() + # Command + x -> Cut + elif char == "x" and modifiers == COMMAND_MODIFIER: + # TODO, this copy won't work, because once the objects are removed, + # they're not searched for in the pasting. + self.copy_selection() + self.delete_selection() + # Delete + elif symbol == DELETE_SYMBOL: + self.delete_selection() + # Command + a -> Select all + elif char == "a" and modifiers == COMMAND_MODIFIER: + self.clear_selection() + self.add_to_selection(*self.mobjects) + # Command + g -> Group selection + elif char == "g" and modifiers == COMMAND_MODIFIER: + group = self.get_group(*self.selection) + self.add(group) + self.clear_selection() + self.add_to_selection(group) + # Command + shift + g -> Ungroup the selection + elif char == "g" and modifiers == COMMAND_MODIFIER | SHIFT_MODIFIER: + pieces = [] + for mob in list(self.selection): + self.remove(mob) + pieces.extend(list(mob)) + self.clear_selection() + self.add(*pieces) + self.add_to_selection(*pieces) + # Command + t -> Toggle selection mode + elif char == "t" and modifiers == COMMAND_MODIFIER: + self.toggle_selection_mode() + # Command + z -> Restore selection to original state + elif char == "z" and modifiers == COMMAND_MODIFIER: + self.undo() + # Command + s -> Save selections to file + elif char == "s" and modifiers == COMMAND_MODIFIER: + self.saved_selection_to_file() + # Keyboard movements + elif symbol in ARROW_SYMBOLS: + nudge = self.selection_nudge_size + if (modifiers & SHIFT_MODIFIER): + nudge *= 10 + vect = [LEFT, UP, RIGHT, DOWN][ARROW_SYMBOLS.index(symbol)] + self.selection.shift(nudge * vect) + + def on_key_release(self, symbol: int, modifiers: int) -> None: + super().on_key_release(symbol, modifiers) + if chr(symbol) == SELECT_KEY: + self.is_selecting = False + self.remove(self.selection_rectangle) + for mob in reversed(self.get_selection_search_set()): + if mob.is_movable() and self.selection_rectangle.is_touching(mob): + self.add_to_selection(mob) + + elif symbol == SHIFT_SYMBOL: + if self.window.is_key_pressed(ord(RESIZE_KEY)): + self.prepare_resizing(about_corner=False) + + def on_mouse_motion(self, point: np.ndarray, d_point: np.ndarray) -> None: + super().on_mouse_motion(point, d_point) + # Move selection + if self.window.is_key_pressed(ord("g")): + self.selection.move_to(point - self.mouse_to_selection) + # Move selection restricted to horizontal + elif self.window.is_key_pressed(ord("h")): + self.selection.set_x((point - self.mouse_to_selection)[0]) + # Move selection restricted to vertical + elif self.window.is_key_pressed(ord("v")): + self.selection.set_y((point - self.mouse_to_selection)[1]) + # Scale selection + elif self.window.is_key_pressed(ord("t")): + # TODO, allow for scaling about the opposite corner + vect = point - self.scale_about_point + scalar = get_norm(vect) / get_norm(self.scale_ref_vect) + self.selection.set_width( + scalar * self.scale_ref_width, + about_point=self.scale_about_point + ) + + def on_mouse_release(self, point: np.ndarray, button: int, mods: int) -> None: + super().on_mouse_release(point, button, mods) + if self.color_palette in self.mobjects: + # Search through all mobject on the screne, not just the palette + to_search = list(it.chain(*( + mobject.family_members_with_points() + for mobject in self.mobjects + if mobject not in self.unselectables + ))) + mob = self.point_to_mobject(point, to_search) + if mob is not None: + self.selection.set_color(mob.get_fill_color()) + self.remove(self.color_palette) + elif self.window.is_key_pressed(SHIFT_SYMBOL): + mob = self.point_to_mobject(point) + if mob is not None: + self.toggle_from_selection(mob) + else: + self.clear_selection() From 1b009a4b035244bd6a0b48bc4dc945fd3b4236ef Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Wed, 20 Apr 2022 22:07:10 -0700 Subject: [PATCH 32/34] Simplify Mobject.copy to just use pickle serialization --- manimlib/mobject/mobject.py | 57 ++++--------------------- manimlib/mobject/probability.py | 3 -- manimlib/mobject/svg/labelled_string.py | 3 -- manimlib/mobject/vector_field.py | 2 +- 4 files changed, 9 insertions(+), 56 deletions(-) diff --git a/manimlib/mobject/mobject.py b/manimlib/mobject/mobject.py index 58673e0b..9d6cfacf 100644 --- a/manimlib/mobject/mobject.py +++ b/manimlib/mobject/mobject.py @@ -1,7 +1,6 @@ from __future__ import annotations import sys -import copy import random import itertools as it from functools import wraps @@ -25,7 +24,6 @@ from manimlib.utils.iterables import list_update from manimlib.utils.iterables import resize_array from manimlib.utils.iterables import resize_preserving_order from manimlib.utils.iterables import resize_with_interpolation -from manimlib.utils.iterables import make_even from manimlib.utils.iterables import listify from manimlib.utils.bezier import interpolate from manimlib.utils.bezier import integer_interpolate @@ -482,64 +480,25 @@ class Mobject(object): # Copying def copy(self): - # TODO, either justify reason for shallow copy, or - # remove this redundancy everywhere - # return self.deepcopy() - - parents = self.parents self.parents = [] - copy_mobject = copy.copy(self) - self.parents = parents - - copy_mobject.data = dict(self.data) - for key in self.data: - copy_mobject.data[key] = self.data[key].copy() - - copy_mobject.uniforms = dict(self.uniforms) - for key in self.uniforms: - if isinstance(self.uniforms[key], np.ndarray): - copy_mobject.uniforms[key] = self.uniforms[key].copy() - - copy_mobject.submobjects = [] - copy_mobject.add(*[sm.copy() for sm in self.submobjects]) - copy_mobject.match_updaters(self) - - copy_mobject.needs_new_bounding_box = self.needs_new_bounding_box - - # Make sure any mobject or numpy array attributes are copied - family = self.get_family() - for attr, value in list(self.__dict__.items()): - if isinstance(value, Mobject) and value in family and value is not self: - setattr(copy_mobject, attr, value.copy()) - if isinstance(value, np.ndarray): - setattr(copy_mobject, attr, value.copy()) - if isinstance(value, ShaderWrapper): - setattr(copy_mobject, attr, value.copy()) - return copy_mobject + return pickle.loads(pickle.dumps(self)) def deepcopy(self): - parents = self.parents - self.parents = [] - result = copy.deepcopy(self) - self.parents = parents - return result + # This used to be different from copy, so is now just here for backward compatibility + return self.copy() def generate_target(self, use_deepcopy: bool = False): + # TODO, remove now pointless use_deepcopy arg self.target = None # Prevent exponential explosion - if use_deepcopy: - self.target = self.deepcopy() - else: - self.target = self.copy() + self.target = self.copy() return self.target def save_state(self, use_deepcopy: bool = False): + # TODO, remove now pointless use_deepcopy arg if hasattr(self, "saved_state"): # Prevent exponential growth of data self.saved_state = None - if use_deepcopy: - self.saved_state = self.deepcopy() - else: - self.saved_state = self.copy() + self.saved_state = self.copy() return self def restore(self): @@ -1473,7 +1432,7 @@ class Mobject(object): return self def push_self_into_submobjects(self): - copy = self.deepcopy() + copy = self.copy() copy.set_submobjects([]) self.resize_points(0) self.add(copy) diff --git a/manimlib/mobject/probability.py b/manimlib/mobject/probability.py index 9f4bdeab..3edb068b 100644 --- a/manimlib/mobject/probability.py +++ b/manimlib/mobject/probability.py @@ -274,6 +274,3 @@ class BarChart(VGroup): (value / self.max_value) * self.height ) bar.move_to(bar_bottom, DOWN) - - def copy(self): - return self.deepcopy() diff --git a/manimlib/mobject/svg/labelled_string.py b/manimlib/mobject/svg/labelled_string.py index 58c47094..f1354f0c 100644 --- a/manimlib/mobject/svg/labelled_string.py +++ b/manimlib/mobject/svg/labelled_string.py @@ -123,9 +123,6 @@ class LabelledString(_StringSVG, ABC): self.group_substrs = self.get_group_substrs() self.submob_groups = self.get_submob_groups() - def copy(self): - return self.deepcopy() - # Toolkits def get_substr(self, span: Span) -> str: diff --git a/manimlib/mobject/vector_field.py b/manimlib/mobject/vector_field.py index b17b55de..e657e84b 100644 --- a/manimlib/mobject/vector_field.py +++ b/manimlib/mobject/vector_field.py @@ -326,7 +326,7 @@ class ShowPassingFlashWithThinningStrokeWidth(AnimationGroup): max_time_width = kwargs.pop("time_width", self.time_width) AnimationGroup.__init__(self, *[ VShowPassingFlash( - vmobject.deepcopy().set_stroke(width=stroke_width), + vmobject.copy().set_stroke(width=stroke_width), time_width=time_width, **kwargs ) From a0507c5277d5e345864254bc0ea035dae484ac1c Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Wed, 20 Apr 2022 22:07:28 -0700 Subject: [PATCH 33/34] Update to use new keybinding --- example_scenes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example_scenes.py b/example_scenes.py index 5f61b874..6806147f 100644 --- a/example_scenes.py +++ b/example_scenes.py @@ -579,7 +579,7 @@ class SurfaceExample(Scene): self.play(light.animate.move_to(3 * IN), run_time=5) self.play(light.animate.shift(10 * OUT), run_time=5) - drag_text = Text("Try moving the mouse while pressing d or s") + drag_text = Text("Try moving the mouse while pressing d or f") drag_text.move_to(light_text) drag_text.fix_in_frame() From 19881f3e2da322c86857a197d5c783458fc12bdf Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Wed, 20 Apr 2022 22:17:25 -0700 Subject: [PATCH 34/34] Remove pickle from requirements (as it's in standard library) --- requirements.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index cb60eccb..a5225a8b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,6 @@ matplotlib moderngl moderngl_window skia-pathops -pickle pydub pygments pyperclip