Merge pull request #1794 from 3b1b/video-work

InteractiveScene, etc.
This commit is contained in:
Grant Sanderson 2022-04-20 22:22:09 -07:00 committed by GitHub
commit 485a4ca33a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 688 additions and 159 deletions

View file

@ -579,7 +579,7 @@ class SurfaceExample(Scene):
self.play(light.animate.move_to(3 * IN), run_time=5) self.play(light.animate.move_to(3 * IN), run_time=5)
self.play(light.animate.shift(10 * OUT), 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.move_to(light_text)
drag_text.fix_in_frame() drag_text.fix_in_frame()

View file

@ -53,6 +53,7 @@ from manimlib.mobject.value_tracker import *
from manimlib.mobject.vector_field import * from manimlib.mobject.vector_field import *
from manimlib.scene.scene import * from manimlib.scene.scene import *
from manimlib.scene.interactive_scene import *
from manimlib.scene.three_d_scene import * from manimlib.scene.three_d_scene import *
from manimlib.utils.bezier import * from manimlib.utils.bezier import *

View file

@ -52,6 +52,7 @@ class Animation(object):
# played. As much initialization as possible, # played. As much initialization as possible,
# especially any mobject copying, should live in # especially any mobject copying, should live in
# this method # this method
self.mobject.set_animating_status(True)
self.starting_mobject = self.create_starting_mobject() self.starting_mobject = self.create_starting_mobject()
if self.suspend_mobject_updating: if self.suspend_mobject_updating:
# All calls to self.mobject's internal updaters # All calls to self.mobject's internal updaters
@ -66,6 +67,7 @@ class Animation(object):
def finish(self) -> None: def finish(self) -> None:
self.interpolate(self.final_alpha_value) self.interpolate(self.final_alpha_value)
self.mobject.set_animating_status(False)
if self.suspend_mobject_updating: if self.suspend_mobject_updating:
self.mobject.resume_updating() self.mobject.resume_updating()

View file

@ -50,11 +50,13 @@ class AnimationGroup(Animation):
return self.group return self.group
def begin(self) -> None: def begin(self) -> None:
self.group.set_animating_status(True)
for anim in self.animations: for anim in self.animations:
anim.begin() anim.begin()
# self.init_run_time() # self.init_run_time()
def finish(self) -> None: def finish(self) -> None:
self.group.set_animating_status(False)
for anim in self.animations: for anim in self.animations:
anim.finish() anim.finish()

View file

@ -60,6 +60,15 @@ class CameraFrame(Mobject):
def get_euler_angles(self): def get_euler_angles(self):
return self.get_orientation().as_euler("zxz")[::-1] 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): def get_inverse_camera_rotation_matrix(self):
return self.get_orientation().as_matrix().T return self.get_orientation().as_matrix().T
@ -190,7 +199,10 @@ class Camera(object):
self.init_textures() self.init_textures()
self.init_light_source() self.init_light_source()
self.refresh_perspective_uniforms() 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: def init_frame(self) -> None:
self.frame = CameraFrame(**self.frame_config) self.frame = CameraFrame(**self.frame_config)
@ -356,11 +368,21 @@ class Camera(object):
if render_group["single_use"]: if render_group["single_use"]:
self.release_render_group(render_group) self.release_render_group(render_group)
def get_render_group_list(self, mobject: Mobject) -> list[dict[str]] | map[dict[str]]: def get_render_group_list(self, mobject: Mobject) -> Iterable[dict[str]]:
try: if mobject.is_changing():
return self.static_mobject_to_render_group_list[id(mobject)] return self.generate_render_group_list(mobject)
except KeyError:
return map(self.get_render_group, mobject.get_shader_wrapper_list()) # 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( def get_render_group(
self, self,
@ -399,19 +421,10 @@ class Camera(object):
if render_group[key] is not None: if render_group[key] is not None:
render_group[key].release() render_group[key].release()
def set_mobjects_as_static(self, *mobjects: Mobject) -> None: def refresh_static_mobjects(self) -> None:
# Creates buffer and array objects holding each mobjects shader data for render_group in it.chain(*self.mob_to_render_groups.values()):
for mob in mobjects: self.release_render_group(render_group)
self.static_mobject_to_render_group_list[id(mob)] = [ self.mob_to_render_groups = {}
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 = {}
# Shaders # Shaders
def init_shaders(self) -> None: def init_shaders(self) -> None:

View file

@ -71,6 +71,16 @@ BOLD = "BOLD"
DEFAULT_STROKE_WIDTH = 4 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 # Colors
BLUE_E = "#1C758A" BLUE_E = "#1C758A"
BLUE_D = "#29ABCA" BLUE_D = "#29ABCA"
@ -127,6 +137,20 @@ LIGHT_PINK = "#DC75CD"
GREEN_SCREEN = "#00FF00" GREEN_SCREEN = "#00FF00"
ORANGE = "#FF862F" 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 # Abbreviated names for the "median" colors
BLUE = BLUE_C BLUE = BLUE_C
TEAL = TEAL_C TEAL = TEAL_C

View file

@ -320,8 +320,8 @@ class Circle(Arc):
"anchors_span_full_range": False "anchors_span_full_range": False
} }
def __init__(self, **kwargs): def __init__(self, start_angle: float = 0, **kwargs):
Arc.__init__(self, 0, TAU, **kwargs) Arc.__init__(self, start_angle, TAU, **kwargs)
def surround( def surround(
self, self,

View file

@ -1,11 +1,12 @@
from __future__ import annotations from __future__ import annotations
import sys import sys
import copy
import random import random
import itertools as it import itertools as it
from functools import wraps from functools import wraps
from typing import Iterable, Callable, Union, Sequence from typing import Iterable, Callable, Union, Sequence
import pickle
import os
import colour import colour
import moderngl 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_array
from manimlib.utils.iterables import resize_preserving_order from manimlib.utils.iterables import resize_preserving_order
from manimlib.utils.iterables import resize_with_interpolation from manimlib.utils.iterables import resize_with_interpolation
from manimlib.utils.iterables import make_even
from manimlib.utils.iterables import listify from manimlib.utils.iterables import listify
from manimlib.utils.bezier import interpolate from manimlib.utils.bezier import interpolate
from manimlib.utils.bezier import integer_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 import EVENT_DISPATCHER
from manimlib.event_handler.event_listner import EventListner from manimlib.event_handler.event_listner import EventListner
from manimlib.event_handler.event_type import EventType from manimlib.event_handler.event_type import EventType
from manimlib.logger import log
TimeBasedUpdater = Callable[["Mobject", float], None] TimeBasedUpdater = Callable[["Mobject", float], None]
@ -71,7 +72,7 @@ class Mobject(object):
# Must match in attributes of vert shader # Must match in attributes of vert shader
"shader_dtype": [ "shader_dtype": [
('point', np.float32, (3,)), ('point', np.float32, (3,)),
] ],
} }
def __init__(self, **kwargs): def __init__(self, **kwargs):
@ -81,6 +82,8 @@ class Mobject(object):
self.family: list[Mobject] = [self] self.family: list[Mobject] = [self]
self.locked_data_keys: set[str] = set() self.locked_data_keys: set[str] = set()
self.needs_new_bounding_box: bool = True self.needs_new_bounding_box: bool = True
self._is_animating: bool = False
self._is_movable: bool = False
self.init_data() self.init_data()
self.init_uniforms() self.init_uniforms()
@ -265,15 +268,30 @@ class Mobject(object):
parent.refresh_bounding_box() parent.refresh_bounding_box()
return self return self
def is_point_touching( def are_points_touching(
self, self,
point: np.ndarray, points: np.ndarray,
buff: float = MED_SMALL_BUFF buff: float = MED_SMALL_BUFF
) -> bool: ) -> bool:
bb = self.get_bounding_box() bb = self.get_bounding_box()
mins = (bb[0] - buff) mins = (bb[0] - buff)
maxs = (bb[2] + 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 # Family matters
@ -421,22 +439,6 @@ class Mobject(object):
self.center() self.center()
return self 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( def sort(
self, self,
point_to_num_func: Callable[[np.ndarray], float] = lambda p: p[0], point_to_num_func: Callable[[np.ndarray], float] = lambda p: p[0],
@ -457,67 +459,46 @@ class Mobject(object):
self.assemble_family() self.assemble_family()
return self 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 # Copying
def copy(self): def copy(self):
# TODO, either justify reason for shallow copy, or
# remove this redundancy everywhere
# return self.deepcopy()
parents = self.parents
self.parents = [] self.parents = []
copy_mobject = copy.copy(self) return pickle.loads(pickle.dumps(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
def deepcopy(self): def deepcopy(self):
parents = self.parents # This used to be different from copy, so is now just here for backward compatibility
self.parents = [] return self.copy()
result = copy.deepcopy(self)
self.parents = parents
return result
def generate_target(self, use_deepcopy: bool = False): def generate_target(self, use_deepcopy: bool = False):
# TODO, remove now pointless use_deepcopy arg
self.target = None # Prevent exponential explosion self.target = None # Prevent exponential explosion
if use_deepcopy: self.target = self.copy()
self.target = self.deepcopy()
else:
self.target = self.copy()
return self.target return self.target
def save_state(self, use_deepcopy: bool = False): def save_state(self, use_deepcopy: bool = False):
# TODO, remove now pointless use_deepcopy arg
if hasattr(self, "saved_state"): if hasattr(self, "saved_state"):
# Prevent exponential growth of data # Prevent exponential growth of data
self.saved_state = None self.saved_state = None
if use_deepcopy: self.saved_state = self.copy()
self.saved_state = self.deepcopy()
else:
self.saved_state = self.copy()
return self return self
def restore(self): def restore(self):
@ -526,6 +507,27 @@ class Mobject(object):
self.become(self.saved_state) self.become(self.saved_state)
return self 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 # Updating
def init_updaters(self): def init_updaters(self):
@ -575,6 +577,8 @@ class Mobject(object):
updater_list.insert(index, update_function) updater_list.insert(index, update_function)
self.refresh_has_updater_status() self.refresh_has_updater_status()
for parent in self.parents:
parent.has_updaters = True
if call_updater: if call_updater:
self.update(dt=0) self.update(dt=0)
return self return self
@ -589,10 +593,10 @@ class Mobject(object):
def clear_updaters(self, recurse: bool = True): def clear_updaters(self, recurse: bool = True):
self.time_based_updaters = [] self.time_based_updaters = []
self.non_time_updaters = [] self.non_time_updaters = []
self.refresh_has_updater_status()
if recurse: if recurse:
for submob in self.submobjects: for submob in self.submobjects:
submob.clear_updaters() submob.clear_updaters()
self.refresh_has_updater_status()
return self return self
def match_updaters(self, mobject: Mobject): 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()) self.has_updaters = any(mob.get_updaters() for mob in self.get_family())
return self 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 # Transforming operations
def shift(self, vector: np.ndarray): def shift(self, vector: np.ndarray):
@ -1173,6 +1195,13 @@ class Mobject(object):
def get_corner(self, direction: np.ndarray) -> np.ndarray: def get_corner(self, direction: np.ndarray) -> np.ndarray:
return self.get_bounding_box_point(direction) 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: def get_center(self) -> np.ndarray:
return self.get_bounding_box()[1] return self.get_bounding_box()[1]
@ -1403,7 +1432,7 @@ class Mobject(object):
return self return self
def push_self_into_submobjects(self): def push_self_into_submobjects(self):
copy = self.deepcopy() copy = self.copy()
copy.set_submobjects([]) copy.set_submobjects([])
self.resize_points(0) self.resize_points(0)
self.add(copy) self.add(copy)

View file

@ -274,6 +274,3 @@ class BarChart(VGroup):
(value / self.max_value) * self.height (value / self.max_value) * self.height
) )
bar.move_to(bar_bottom, DOWN) bar.move_to(bar_bottom, DOWN)
def copy(self):
return self.deepcopy()

View file

@ -123,9 +123,6 @@ class LabelledString(_StringSVG, ABC):
self.group_substrs = self.get_group_substrs() self.group_substrs = self.get_group_substrs()
self.submob_groups = self.get_submob_groups() self.submob_groups = self.get_submob_groups()
def copy(self):
return self.deepcopy()
# Toolkits # Toolkits
def get_substr(self, span: Span) -> str: def get_substr(self, span: Span) -> str:

View file

@ -681,7 +681,7 @@ class VMobject(Mobject):
self.get_end_anchors(), 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 nppc = self.n_points_per_curve
points = self.get_points() points = self.get_points()
distinct_curves = reduce(op.or_, [ distinct_curves = reduce(op.or_, [

View file

@ -326,7 +326,7 @@ class ShowPassingFlashWithThinningStrokeWidth(AnimationGroup):
max_time_width = kwargs.pop("time_width", self.time_width) max_time_width = kwargs.pop("time_width", self.time_width)
AnimationGroup.__init__(self, *[ AnimationGroup.__init__(self, *[
VShowPassingFlash( VShowPassingFlash(
vmobject.deepcopy().set_stroke(width=stroke_width), vmobject.copy().set_stroke(width=stroke_width),
time_width=time_width, time_width=time_width,
**kwargs **kwargs
) )

View file

@ -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()

View file

@ -4,20 +4,25 @@ import time
import random import random
import inspect import inspect
import platform import platform
import itertools as it
from functools import wraps from functools import wraps
from typing import Iterable, Callable from typing import Iterable, Callable
import os
from tqdm import tqdm as ProgressDisplay from tqdm import tqdm as ProgressDisplay
import numpy as np import numpy as np
import numpy.typing as npt
from manimlib.animation.animation import prepare_animation from manimlib.animation.animation import prepare_animation
from manimlib.animation.transform import MoveToTarget from manimlib.animation.transform import MoveToTarget
from manimlib.camera.camera import Camera from manimlib.camera.camera import Camera
from manimlib.config import get_custom_config
from manimlib.constants import DEFAULT_WAIT_TIME 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 Mobject
from manimlib.mobject.mobject import Point 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.scene.scene_file_writer import SceneFileWriter
from manimlib.utils.config_ops import digest_config from manimlib.utils.config_ops import digest_config
from manimlib.utils.family_ops import extract_mobject_family_members from manimlib.utils.family_ops import extract_mobject_family_members
@ -33,6 +38,14 @@ if TYPE_CHECKING:
from manimlib.animation.animation import Animation 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): class Scene(object):
CONFIG = { CONFIG = {
"window_config": {}, "window_config": {},
@ -116,16 +129,17 @@ class Scene(object):
# If there is a window, enter a loop # If there is a window, enter a loop
# which updates the frame while under # which updates the frame while under
# the hood calling the pyglet event loop # the hood calling the pyglet event loop
log.info("Tips: You are now in the interactive mode. Now you can use the keyboard" log.info(
" and the mouse to interact with the scene. Just press `q` if you want to quit.") "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.quit_interaction = False
self.lock_static_mobject_data() self.refresh_static_mobjects()
while not (self.window.is_closing or self.quit_interaction): while not (self.window.is_closing or self.quit_interaction):
self.update_frame(1 / self.camera.frame_rate) self.update_frame(1 / self.camera.frame_rate)
if self.window.is_closing: if self.window.is_closing:
self.window.destroy() self.window.destroy()
if self.quit_interaction:
self.unlock_mobject_data()
def embed(self, close_scene_on_exit: bool = True) -> None: def embed(self, close_scene_on_exit: bool = True) -> None:
if not self.preview: if not self.preview:
@ -142,15 +156,18 @@ class Scene(object):
from IPython.terminal.embed import InteractiveShellEmbed from IPython.terminal.embed import InteractiveShellEmbed
shell = InteractiveShellEmbed() shell = InteractiveShellEmbed()
# Have the frame update after each command # 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()) shell.events.register('post_run_cell', lambda *a, **kw: self.update_frame())
# Use the locals of the caller as the local namespace # Use the locals of the caller as the local namespace
# once embedded, and add a few custom shortcuts # once embedded, and add a few custom shortcuts
local_ns = inspect.currentframe().f_back.f_locals local_ns = inspect.currentframe().f_back.f_locals
local_ns["touch"] = self.interact local_ns["touch"] = self.interact
local_ns["i2g"] = self.ids_to_group
for term in ("play", "wait", "add", "remove", "clear", "save_state", "restore"): for term in ("play", "wait", "add", "remove", "clear", "save_state", "restore"):
local_ns[term] = getattr(self, term) local_ns[term] = getattr(self, term)
log.info("Tips: Now the embed iPython terminal is open. But you can't interact with" 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()`") " 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) shell(local_ns=local_ns, stack_depth=2)
# End scene when exiting an embed # End scene when exiting an embed
if close_scene_on_exit: if close_scene_on_exit:
@ -295,6 +312,25 @@ class Scene(object):
return mobject return mobject
return None 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 # Related to skipping
def update_skipping_status(self) -> None: def update_skipping_status(self) -> None:
if self.start_at_animation_number is not None: if self.start_at_animation_number is not None:
@ -443,6 +479,7 @@ class Scene(object):
self.real_animation_start_time = time.time() self.real_animation_start_time = time.time()
self.virtual_animation_start_time = self.time self.virtual_animation_start_time = self.time
self.refresh_static_mobjects()
func(self, *args, **kwargs) func(self, *args, **kwargs)
if should_write: if should_write:
@ -451,23 +488,8 @@ class Scene(object):
self.num_plays += 1 self.num_plays += 1
return wrapper return wrapper
def lock_static_mobject_data(self, *animations: Animation) -> None: def refresh_static_mobjects(self) -> None:
movers = list(it.chain(*[ self.camera.refresh_static_mobjects()
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 begin_animations(self, animations: Iterable[Animation]) -> None: def begin_animations(self, animations: Iterable[Animation]) -> None:
for animation in animations: for animation in animations:
@ -507,11 +529,9 @@ class Scene(object):
log.warning("Called Scene.play with no animations") log.warning("Called Scene.play with no animations")
return return
animations = self.anims_from_play_args(*args, **kwargs) animations = self.anims_from_play_args(*args, **kwargs)
self.lock_static_mobject_data(*animations)
self.begin_animations(animations) self.begin_animations(animations)
self.progress_through_animations(animations) self.progress_through_animations(animations)
self.finish_animations(animations) self.finish_animations(animations)
self.unlock_mobject_data()
@handle_play_like_call @handle_play_like_call
def wait( def wait(
@ -524,7 +544,6 @@ class Scene(object):
if note: if note:
log.info(note) log.info(note)
self.update_mobjects(dt=0) # Any problems with this? 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: if self.presenter_mode and not self.skip_animations and not ignore_presenter_mode:
while self.hold_on_wait: while self.hold_on_wait:
self.update_frame(dt=1 / self.camera.frame_rate) self.update_frame(dt=1 / self.camera.frame_rate)
@ -538,9 +557,8 @@ class Scene(object):
self.update_frame(dt) self.update_frame(dt)
self.emit_frame() self.emit_frame()
if stop_condition is not None and stop_condition(): if stop_condition is not None and stop_condition():
time_progression.close()
break break
self.unlock_mobject_data() self.refresh_static_mobjects()
return self return self
def wait_until( def wait_until(
@ -574,22 +592,31 @@ class Scene(object):
# Helpers for interactive development # Helpers for interactive development
def save_state(self) -> None: def save_state(self) -> None:
self.saved_state = { self.saved_state = [
"mobjects": self.mobjects, (mob, mob.copy())
"mobject_states": [ for mob in self.mobjects
mob.copy() ]
for mob in self.mobjects
],
}
def restore(self) -> None: def restore(self) -> None:
if not hasattr(self, "saved_state"): if not hasattr(self, "saved_state"):
raise Exception("Trying to restore scene without having saved") raise Exception("Trying to restore scene without having saved")
mobjects = self.saved_state["mobjects"] self.mobjects = []
states = self.saved_state["mobject_states"] for mob, mob_state in self.saved_state:
for mob, state in zip(mobjects, states): mob.become(mob_state)
mob.become(state) self.mobjects.append(mob)
self.mobjects = mobjects
def save_mobect(self, mobject: Mobject, file_name: str):
directory = self.file_writer.get_saved_mobject_directory()
path = os.path.join(directory, file_name)
mobject.save_to_file(path)
def 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 # Event handling
@ -606,10 +633,12 @@ class Scene(object):
return return
frame = self.camera.frame 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_theta(-self.pan_sensitivity * d_point[0])
frame.increment_phi(self.pan_sensitivity * d_point[1]) 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 = -d_point
shift[0] *= frame.get_width() / 2 shift[0] *= frame.get_width() / 2
shift[1] *= frame.get_height() / 2 shift[1] *= frame.get_height() / 2
@ -637,6 +666,7 @@ class Scene(object):
button: int, button: int,
mods: int mods: int
) -> None: ) -> None:
self.mouse_drag_point.move_to(point)
event_data = {"point": point, "button": button, "mods": mods} event_data = {"point": point, "button": button, "mods": mods}
propagate_event = EVENT_DISPATCHER.dispatch(EventType.MousePressEvent, **event_data) propagate_event = EVENT_DISPATCHER.dispatch(EventType.MousePressEvent, **event_data)
if propagate_event is not None and propagate_event is False: if propagate_event is not None and propagate_event is False:
@ -664,9 +694,9 @@ class Scene(object):
return return
frame = self.camera.frame 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]) factor = 1 + np.arctan(10 * offset[1])
frame.scale(1/factor, about_point=point) frame.scale(1 / factor, about_point=point)
else: else:
transform = frame.get_inverse_camera_rotation_matrix() transform = frame.get_inverse_camera_rotation_matrix()
shift = np.dot(np.transpose(transform), offset) 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: if propagate_event is not None and propagate_event is False:
return return
if char == "r": if char == RESET_FRAME_KEY:
self.camera.frame.to_default_state() self.camera.frame.to_default_state()
elif char == "q": # command + q
elif char == QUIT_KEY and modifiers == COMMAND_MODIFIER:
self.quit_interaction = True 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 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) self.embed(close_scene_on_exit=False)
def on_resize(self, width: int, height: int) -> None: def on_resize(self, width: int, height: int) -> None:

View file

@ -78,6 +78,8 @@ class SceneFileWriter(object):
self.partial_movie_directory = guarantee_existence(os.path.join( self.partial_movie_directory = guarantee_existence(os.path.join(
movie_dir, "partial_movie_files", scene_name, 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: def get_default_module_directory(self) -> str:
path, _ = os.path.splitext(self.input_file_path) path, _ = os.path.splitext(self.input_file_path)
@ -119,6 +121,12 @@ class SceneFileWriter(object):
def get_movie_file_path(self) -> str: def get_movie_file_path(self) -> str:
return self.movie_file_path 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 # Sound
def init_audio(self) -> None: def init_audio(self) -> None:
self.includes_sound: bool = False self.includes_sound: bool = False
@ -220,7 +228,7 @@ class SceneFileWriter(object):
# This is if the background of the exported # This is if the background of the exported
# video should be transparent. # video should be transparent.
command += [ command += [
'-vcodec', 'qtrle', '-vcodec', 'prores_ks',
] ]
elif self.movie_file_extension == ".gif": elif self.movie_file_extension == ".gif":
command += [] command += []

View file

@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import itertools as it
from typing import Iterable from typing import Iterable
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@ -11,15 +10,14 @@ if TYPE_CHECKING:
def extract_mobject_family_members( def extract_mobject_family_members(
mobject_list: Iterable[Mobject], mobject_list: Iterable[Mobject],
only_those_with_points: bool = False exclude_pointless: bool = False
) -> list[Mobject]: ) -> list[Mobject]:
result = list(it.chain(*[ return [
mob.get_family() sm
for mob in mobject_list for mob in mobject_list
])) for sm in mob.get_family()
if only_those_with_points: if (not exclude_pointless) or sm.has_points()
result = [mob for mob in result if mob.has_points()] ]
return result
def restructure_list_to_exclude_certain_family_members( def restructure_list_to_exclude_certain_family_members(

View file

@ -152,6 +152,8 @@ def angle_between_vectors(v1: np.ndarray, v2: np.ndarray) -> float:
""" """
n1 = get_norm(v1) n1 = get_norm(v1)
n2 = get_norm(v2) n2 = get_norm(v2)
if n1 == 0 or n2 == 0:
return 0
cos_angle = np.dot(v1, v2) / np.float64(n1 * n2) cos_angle = np.dot(v1, v2) / np.float64(n1 * n2)
return math.acos(clip(cos_angle, -1, 1)) return math.acos(clip(cos_angle, -1, 1))

View file

@ -92,7 +92,7 @@ def tex_to_dvi(tex_file):
for line in file.readlines(): for line in file.readlines():
if line.startswith("!"): if line.startswith("!"):
log.debug(f"The error could be: `{line[2:-1]}`") log.debug(f"The error could be: `{line[2:-1]}`")
sys.exit(2) raise LatexError()
return result return result
@ -134,3 +134,8 @@ def display_during_execution(message):
yield yield
finally: finally:
print(" " * len(to_print), end="\r") print(" " * len(to_print), end="\r")
class LatexError(Exception):
pass

View file

@ -11,6 +11,7 @@ moderngl_window
skia-pathops skia-pathops
pydub pydub
pygments pygments
pyperclip
pyyaml pyyaml
rich rich
screeninfo screeninfo