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() 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/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/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() diff --git a/manimlib/camera/camera.py b/manimlib/camera/camera.py index 40037a3d..2884216c 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 @@ -190,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) @@ -356,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, @@ -399,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/constants.py b/manimlib/constants.py index 590a9cda..cc73c0ac 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" @@ -127,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 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, diff --git a/manimlib/mobject/mobject.py b/manimlib/mobject/mobject.py index 17fe9ad0..9d6cfacf 100644 --- a/manimlib/mobject/mobject.py +++ b/manimlib/mobject/mobject.py @@ -1,11 +1,12 @@ from __future__ import annotations import sys -import copy 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 @@ -23,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 @@ -37,6 +37,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] @@ -71,7 +72,7 @@ class Mobject(object): # Must match in attributes of vert shader "shader_dtype": [ ('point', np.float32, (3,)), - ] + ], } def __init__(self, **kwargs): @@ -81,6 +82,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() @@ -265,15 +268,30 @@ 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] + + 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 @@ -421,22 +439,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, point_to_num_func: Callable[[np.ndarray], float] = lambda p: p[0], @@ -457,67 +459,46 @@ 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 + # 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): @@ -526,6 +507,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): @@ -575,6 +577,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 @@ -589,10 +593,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): @@ -623,6 +627,24 @@ 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, 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): @@ -1173,6 +1195,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] @@ -1403,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/types/vectorized_mobject.py b/manimlib/mobject/types/vectorized_mobject.py index f5b47859..6615f715 100644 --- a/manimlib/mobject/types/vectorized_mobject.py +++ b/manimlib/mobject/types/vectorized_mobject.py @@ -681,7 +681,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_, [ 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 ) 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() diff --git a/manimlib/scene/scene.py b/manimlib/scene/scene.py index 3b649c96..01037f62 100644 --- a/manimlib/scene/scene.py +++ b/manimlib/scene/scene.py @@ -4,20 +4,25 @@ 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 -import numpy.typing as npt 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 @@ -33,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": {}, @@ -116,16 +129,17 @@ 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 `command + q` or `esc`" + " 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: @@ -142,15 +156,18 @@ 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 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: @@ -295,6 +312,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: @@ -443,6 +479,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: @@ -451,23 +488,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: @@ -507,11 +529,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( @@ -524,7 +544,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,9 +557,8 @@ 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() + self.refresh_static_mobjects() return self def wait_until( @@ -574,22 +592,31 @@ 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) + + 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 @@ -606,10 +633,12 @@ class Scene(object): return frame = self.camera.frame - if self.window.is_key_pressed(ord("d")): + # Handle perspective changes + 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]) - elif self.window.is_key_pressed(ord("s")): + # Handle frame movements + 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 @@ -637,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: @@ -664,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) @@ -698,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: diff --git a/manimlib/scene/scene_file_writer.py b/manimlib/scene/scene_file_writer.py index cb948ab5..869db27b 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 @@ -220,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 += [] 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( diff --git a/manimlib/utils/space_ops.py b/manimlib/utils/space_ops.py index 29c67ab4..e6ecfb13 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) / np.float64(n1 * n2) return math.acos(clip(cos_angle, -1, 1)) 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 diff --git a/requirements.txt b/requirements.txt index ade806c9..a5225a8b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,6 +11,7 @@ moderngl_window skia-pathops pydub pygments +pyperclip pyyaml rich screeninfo