diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 113ea16403..e22535881f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,7 +18,7 @@ repos: - id: python-check-blanket-noqa name: Precision flake ignores - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.5.2 + rev: v0.5.5 hooks: - id: ruff name: ruff lint @@ -32,16 +32,13 @@ repos: - id: flake8 additional_dependencies: [ - flake8-bugbear==21.4.3, - flake8-builtins==1.5.3, - flake8-comprehensions>=3.6.1, flake8-docstrings==1.6.0, flake8-pytest-style==1.5.0, flake8-rst-docstrings==0.2.3, flake8-simplify==0.14.1, ] - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.10.1 + rev: v1.11.0 hooks: - id: mypy additional_dependencies: diff --git a/docs/source/conf.py b/docs/source/conf.py index 3910fd7ed1..ed95eacfee 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -26,7 +26,7 @@ # -- Project information ----------------------------------------------------- project = "Manim" -copyright = f"2020-{datetime.now().year}, The Manim Community Dev Team" +copyright = f"2020-{datetime.now().year}, The Manim Community Dev Team" # noqa: A001 author = "The Manim Community Dev Team" @@ -63,7 +63,7 @@ alias_name: f"~manim.{module}.{alias_name}" for module, module_dict in ALIAS_DOCS_DICT.items() for category_dict in module_dict.values() - for alias_name in category_dict.keys() + for alias_name in category_dict } autoclass_content = "both" diff --git a/docs/source/guides/configuration.rst b/docs/source/guides/configuration.rst index 17cdfecc90..8af5d230d4 100644 --- a/docs/source/guides/configuration.rst +++ b/docs/source/guides/configuration.rst @@ -357,10 +357,10 @@ A list of all config options 'log_dir', 'log_to_file', 'max_files_cached', 'media_dir', 'media_width', 'movie_file_extension', 'notify_outdated_version', 'output_file', 'partial_movie_dir', 'pixel_height', 'pixel_width', 'plugins', 'preview', - 'progress_bar', 'quality', 'right_side', 'save_as_gif', 'save_last_frame', - 'save_pngs', 'scene_names', 'show_in_file_browser', 'sound', 'tex_dir', + 'progress_bar', 'quality', 'right_side', 'save_last_frame', + 'scene_names', 'show_in_file_browser', 'sound', 'tex_dir', 'tex_template', 'tex_template_file', 'text_dir', 'top', 'transparent', - 'upto_animation_number', 'use_opengl_renderer', 'verbosity', 'video_dir', + 'upto_animation_number', 'verbosity', 'video_dir', 'window_position', 'window_monitor', 'window_size', 'write_all', 'write_to_movie', 'enable_wireframe', 'force_window'] diff --git a/docs/source/installation.rst b/docs/source/installation.rst index fbc65a7cc6..c4c3c2273a 100644 --- a/docs/source/installation.rst +++ b/docs/source/installation.rst @@ -68,7 +68,7 @@ in order for Manim to work properly, some additional system dependencies need to be installed first. The following pages have operating system specific instructions for you to follow. -Manim requires Python version ``3.9`` or above to run. +Manim requires Python version ``3.10`` or above to run. .. hint:: diff --git a/docs/source/installation/linux.rst b/docs/source/installation/linux.rst index 6c154d8eed..b9f8bb2fdf 100644 --- a/docs/source/installation/linux.rst +++ b/docs/source/installation/linux.rst @@ -5,7 +5,7 @@ The installation instructions depend on your particular operating system and package manager. If you happen to know exactly what you are doing, you can also simply ensure that your system has: -- a reasonably recent version of Python 3 (3.9 or above), +- a reasonably recent version of Python 3 (3.10 or above), - with working Cairo bindings in the form of `pycairo `__, - and `Pango `__ headers. diff --git a/docs/source/installation/windows.rst b/docs/source/installation/windows.rst index 04fe2b735a..4cac87d3aa 100644 --- a/docs/source/installation/windows.rst +++ b/docs/source/installation/windows.rst @@ -16,7 +16,7 @@ to make one of them available on your system. Required Dependencies --------------------- -Manim requires a recent version of Python (3.9 or above) +Manim requires a recent version of Python (3.10 or above) in order to work. Chocolatey @@ -35,7 +35,7 @@ Pip *** As mentioned above, Manim needs a reasonably recent version of -Python 3 (3.9 or above). +Python 3 (3.10 or above). **Python:** Head over to https://www.python.org, download an installer for a recent version of Python, and follow its instructions to get Python diff --git a/docs/source/reference.rst b/docs/source/reference.rst index 2a662a288b..01844a5779 100644 --- a/docs/source/reference.rst +++ b/docs/source/reference.rst @@ -38,9 +38,9 @@ Cameras ******* .. inheritance-diagram:: - manim.camera.cairo_camera + manim.camera.camera :parts: 1 - :top-classes: manim.camera.camera.Camera, manim.mobject.mobject.Mobject + :top-classes: manim.camera.camera.Camera, manim.mobject.opengl.opengl_mobject.OpenGLMobject Mobjects ******** diff --git a/docs/source/tutorials/output_and_config.rst b/docs/source/tutorials/output_and_config.rst index af7961d873..d59da93b73 100644 --- a/docs/source/tutorials/output_and_config.rst +++ b/docs/source/tutorials/output_and_config.rst @@ -18,7 +18,7 @@ At this point, you have just executed the following command. Let's dissect what just happened step by step. First, this command executes manim on the file ``scene.py``, which contains our animation code. Further, -this command tells manim exactly which ``Scene`` is to be rendered, in this case, +this command tells manim exactly which :class:`.Scene` is to be rendered, in this case, it is ``SquareToCircle``. This is necessary because a single scene file may contain more than one scene. Next, the flag `-p` tells manim to play the scene once it's rendered, and the `-ql` flag tells manim to render the scene in low @@ -140,19 +140,30 @@ resolutions, e.g. ``-s -ql``, ``-s -qh`` Sections ******** -In addition to the movie output file one can use sections. Each section produces -its own output video. The cuts between two sections can be set like this: +In addition to the movie output file one can use sections. If :attr:`ManimConfig.save_sections` is ``True``, +each section produces its own output video. In order to use sections, set :attr:`~Scene.sections_api` to ``True``. .. code-block:: python - def construct(self): - # play the first animations... - # you don't need a section in the very beginning as it gets created automatically - self.next_section() - # play more animations... - self.next_section("this is an optional name that doesn't have to be unique") - # play even more animations... - self.next_section("this is a section without any animations, it will be removed") + class MyScene(Scene): + sections_api = True + + @section + def introduction(self): + # play the first animations... + # the default name of this section is the name of the method + ... + + @section(name="this is an optional name that doesn't have to be unique") + def second_section(self): + # play more animations... + ... + + @section(skip=True) + def finale(self): + # play even more animations... + # however, they won't be included in the final output video + ... All the animations between two of these cuts get concatenated into a single output video file. @@ -160,24 +171,42 @@ Be aware that you need at least one animation in each section. For example this .. code-block:: python - def construct(self): - self.next_section() - # this section doesn't have any animations and will be removed - # but no error will be thrown - # feel free to tend your flock of empty sections if you so desire - self.add(Circle()) - self.next_section() + class SectionsExampleWithNoAnimations(Scene): + sections_api = True + + @section + def first(self): + self.next_section() + # this section doesn't have any animations and will be removed + # but no error will be thrown + # feel free to tend your flock of empty sections if you so desire + self.add(Circle()) + + @section + def next(self): + # play some animations + ... One way of fixing this is to wait a little: .. code-block:: python - def construct(self): - self.next_section() - self.add(Circle()) - # now we wait 1sec and have an animation to satisfy the section - self.wait() - self.next_section() + class SectionsExampleWithNoAnimations(Scene): + sections_api = True + + @section + def first(self): + self.next_section() + # this section doesn't have any animations and will be removed + # but no error will be thrown + # feel free to tend your flock of empty sections if you so desire + self.add(Circle()) + self.wait() + + @section + def next(self): + # play some animations + ... For videos to be created for each section you have to add the ``--save_sections`` flag to the Manim call like this: @@ -258,13 +287,20 @@ You can also skip rendering all animations belonging to a section like this: .. code-block:: python - def construct(self): - self.next_section(skip_animations=True) - # play some animations that shall be skipped... - self.next_section() - # play some animations that won't get skipped... + class SkippingSections(Scene): + sections_api = True + @section(skip=True) + def first(self): + # play some animations + # things here will execute, but they + # won't be written to the output file + ... + @section + def next(self): + # play some animations + ... Some command line flags @@ -277,9 +313,9 @@ When executing the command manim -pql scene.py SquareToCircle it specifies the scene to render. This is not necessary now. When a single -file contains only one ``Scene`` class, it will just render the ``Scene`` -class. When a single file contains more than one ``Scene`` class, manim will -let you choose a ``Scene`` class. If your file contains multiple ``Scene`` +file contains only one :class:`.Scene` class, it will just render the :class:`.Scene` +class. When a single file contains more than one :class:`.Scene` class, manim will +let you choose a :class:`.Scene` class. If your file contains multiple :class:`.Scene` classes, and you want to render them all, you can use the ``-a`` flag. As discussed previously, the ``-ql`` specifies low render quality (854x480 @@ -294,7 +330,7 @@ the file browser at the location of the animation instead of playing it, you can use the ``-f`` flag. You can also omit these two flags. Finally, by default manim will output .mp4 files. If you want your animations -in .gif format instead, use the ``--format gif`` flag. The output files will +in .gif format instead, use the ``--format=gif`` flag. The output files will be in the same folder as the .mp4 files, and with the same name, but a different file extension. diff --git a/example_scenes/test_new_rendering.py b/example_scenes/test_new_rendering.py index 4d44b6f9cd..869db5fa15 100644 --- a/example_scenes/test_new_rendering.py +++ b/example_scenes/test_new_rendering.py @@ -2,20 +2,26 @@ class Test(Scene): - def construct(self) -> None: - self.play( - Create( - t := Tex( - "Hello, world!", stroke_color=RED, fill_color=BLUE, stroke_width=2 - ) - ) - ) - self.play(FadeOut(t)) + sections_api = True + + @section(name="spinning_math") + def first_section(self) -> None: + line = Line() + line.add_updater(lambda m, dt: m.rotate(PI * dt)) + t = Tex(r"Math! $\sum e^{i\theta}$").add_updater(lambda m: m.next_to(line, UP)) + line.to_edge(LEFT) + self.add(line, t) s = Square() - self.add(s) - self.play(Rotate(s, PI / 2)) - self.wait(7) + t = Tex( + "Hello, world!", stroke_color=RED, fill_color=BLUE, stroke_width=2 + ).to_edge(RIGHT) + self.add(t) + self.play(Create(t), Rotate(s, PI / 2)) + self.wait(1) self.play(FadeOut(s)) + + @section + def three_mobjects(self) -> None: sq = RegularPolygon(6) c = Circle() st = Star() @@ -27,7 +33,11 @@ def construct(self) -> None: Create(st), ) ) - self.play(FadeOut(VGroup(*self.mobjects))) + self.play(FadeOut(VGroup(sq, c, st))) + + @section(skip=True) + def never_run(self) -> None: + self.play(Write(Text("This should never be run"))) if __name__ == "__main__": diff --git a/manim/__init__.py b/manim/__init__.py index bd85300125..f9bded2b2f 100644 --- a/manim/__init__.py +++ b/manim/__init__.py @@ -72,6 +72,7 @@ from manim.mobject.value_tracker import * from manim.mobject.vector_field import * from manim.scene.scene import * +from manim.scene.sections import * from manim.scene.vector_space_scene import * from manim.utils import color, rate_functions, unit from manim.utils.bezier import * diff --git a/manim/_config/default.cfg b/manim/_config/default.cfg index 0c7d6ddbf7..7e60dd7476 100644 --- a/manim/_config/default.cfg +++ b/manim/_config/default.cfg @@ -29,15 +29,9 @@ save_last_frame = False # -a, --write_all write_all = False -# -g, --save_pngs -save_pngs = False - # -0, --zero_pad zero_pad = 4 -# -i, --save_as_gif -save_as_gif = False - # --save_sections save_sections = False @@ -121,12 +115,6 @@ window_monitor = 0 # --force_window force_window = False -# --use_projection_fill_shaders -use_projection_fill_shaders = False - -# --use_projection_stroke_shaders -use_projection_stroke_shaders = False - movie_file_extension = .mp4 # These now override the --quality option. diff --git a/manim/_config/utils.py b/manim/_config/utils.py index 0592def622..7c7c2184f2 100644 --- a/manim/_config/utils.py +++ b/manim/_config/utils.py @@ -291,10 +291,8 @@ class MyScene(Scene): ... "preview", "progress_bar", "quality", - "save_as_gif", "save_sections", "save_last_frame", - "save_pngs", "scene_names", "show_in_file_browser", "tex_dir", @@ -305,8 +303,6 @@ class MyScene(Scene): ... "renderer", "enable_gui", "gui_location", - "use_projection_fill_shaders", - "use_projection_stroke_shaders", "verbosity", "video_dir", "sections_dir", @@ -337,6 +333,10 @@ def _warn_about_config_options(self) -> None: logger.warning( "preview and write_to_movie disabled, this is a dry run. Try passing -p or -w." ) + elif self.preview and self.write_to_movie: + logger.warning( + "Both preview and write_to_movie enabled, this can be slower than just previewing." + ) # behave like a dict def __iter__(self) -> Iterator[str]: @@ -588,8 +588,6 @@ def digest_parser(self, parser: configparser.ConfigParser) -> Self: "write_to_movie", "save_last_frame", "write_all", - "save_pngs", - "save_as_gif", "save_sections", "preview", "show_in_file_browser", @@ -600,8 +598,6 @@ def digest_parser(self, parser: configparser.ConfigParser) -> Self: "custom_folders", "enable_gui", "fullscreen", - "use_projection_fill_shaders", - "use_projection_stroke_shaders", "enable_wireframe", "force_window", "no_latex_cleanup", @@ -656,21 +652,18 @@ def digest_parser(self, parser: configparser.ConfigParser) -> Self: gui_location = tuple( map(int, re.split(r"[;,\-]", parser["CLI"]["gui_location"])), ) - setattr(self, "gui_location", gui_location) + self.gui_location = gui_location window_size = parser["CLI"][ "window_size" ] # if not "default", get a tuple of the position if window_size != "default": window_size = tuple(map(int, re.split(r"[;,\-]", window_size))) - setattr(self, "window_size", window_size) + self.window_size = window_size # plugins plugins = parser["CLI"].get("plugins", fallback="", raw=True) - if plugins == "": - plugins = [] - else: - plugins = plugins.split(",") + plugins = [] if plugins == "" else plugins.split(",") self.plugins = plugins # the next two must be set AFTER digesting pixel_width and pixel_height self["frame_height"] = parser["CLI"].getfloat("frame_height", 8.0) @@ -687,7 +680,7 @@ def digest_parser(self, parser: configparser.ConfigParser) -> Self: val = parser["CLI"].get("progress_bar") if val: - setattr(self, "progress_bar", val) + self.progress_bar = val val = parser["ffmpeg"].get("loglevel") if val: @@ -697,11 +690,11 @@ def digest_parser(self, parser: configparser.ConfigParser) -> Self: val = parser["jupyter"].getboolean("media_embed") except ValueError: val = None - setattr(self, "media_embed", val) + self.media_embed = val val = parser["jupyter"].get("media_width") if val: - setattr(self, "media_width", val) + self.media_width = val val = parser["CLI"].get("quality", fallback="", raw=True) if val: @@ -761,8 +754,6 @@ def digest_args(self, args: argparse.Namespace) -> Self: "show_in_file_browser", "write_to_movie", "save_last_frame", - "save_pngs", - "save_as_gif", "save_sections", "write_all", "disable_caching", @@ -776,8 +767,6 @@ def digest_args(self, args: argparse.Namespace) -> Self: "background_color", "enable_gui", "fullscreen", - "use_projection_fill_shaders", - "use_projection_stroke_shaders", "zero_pad", "enable_wireframe", "force_window", @@ -852,15 +841,12 @@ def digest_args(self, args: argparse.Namespace) -> Self: if args.tex_template: self.tex_template = TexTemplate.from_file(args.tex_template) - if ( - self.renderer == RendererType.OPENGL - and getattr(args, "write_to_movie") is None - ): + if self.renderer == RendererType.OPENGL and args.write_to_movie is None: # --write_to_movie was not passed on the command line, so don't generate video. self["write_to_movie"] = False # Handle --gui_location flag. - if getattr(args, "gui_location") is not None: + if args.gui_location is not None: self.gui_location = args.gui_location return self @@ -980,24 +966,6 @@ def write_all(self) -> bool: def write_all(self, value: bool) -> None: self._set_boolean("write_all", value) - @property - def save_pngs(self) -> bool: - """Whether to save all frames in the scene as images files (-g).""" - return self._d["save_pngs"] - - @save_pngs.setter - def save_pngs(self, value: bool) -> None: - self._set_boolean("save_pngs", value) - - @property - def save_as_gif(self) -> bool: - """Whether to save the rendered scene in .gif format (-i).""" - return self._d["save_as_gif"] - - @save_as_gif.setter - def save_as_gif(self, value: bool) -> None: - self._set_boolean("save_as_gif", value) - @property def save_sections(self) -> bool: """Whether to save single videos for each section in addition to the movie file.""" @@ -1481,24 +1449,6 @@ def fullscreen(self) -> bool: def fullscreen(self, value: bool) -> None: self._set_boolean("fullscreen", value) - @property - def use_projection_fill_shaders(self) -> bool: - """Use shaders for OpenGLVMobject fill which are compatible with transformation matrices.""" - return self._d["use_projection_fill_shaders"] - - @use_projection_fill_shaders.setter - def use_projection_fill_shaders(self, value: bool) -> None: - self._set_boolean("use_projection_fill_shaders", value) - - @property - def use_projection_stroke_shaders(self) -> bool: - """Use shaders for OpenGLVMobject stroke which are compatible with transformation matrices.""" - return self._d["use_projection_stroke_shaders"] - - @use_projection_stroke_shaders.setter - def use_projection_stroke_shaders(self, value: bool) -> None: - self._set_boolean("use_projection_stroke_shaders", value) - @property def zero_pad(self) -> int: """PNG zero padding. A number between 0 (no zero padding) and 9 (9 columns minimum).""" diff --git a/manim/animation/animation.py b/manim/animation/animation.py index cea03507ed..d70575266d 100644 --- a/manim/animation/animation.py +++ b/manim/animation/animation.py @@ -18,6 +18,7 @@ from collections.abc import Iterable, Sequence from copy import deepcopy +from functools import partialmethod from typing import TYPE_CHECKING, Callable from typing_extensions import Self, TypeVar @@ -445,6 +446,52 @@ def set_name(self, name: str) -> Self: self.name = name return self + @classmethod + def __init_subclass__(cls, **kwargs) -> None: + super().__init_subclass__(**kwargs) + + cls._original__init__ = cls.__init__ + + @classmethod + def set_default(cls, **kwargs) -> None: + """Sets the default values of keyword arguments. + + If this method is called without any additional keyword + arguments, the original default values of the initialization + method of this class are restored. + + Parameters + ---------- + + kwargs + Passing any keyword argument will update the default + values of the keyword arguments of the initialization + function of this class. + + Examples + -------- + + .. manim:: ChangeDefaultAnimation + + class ChangeDefaultAnimation(Scene): + def construct(self): + Rotate.set_default(run_time=2, rate_func=rate_functions.linear) + Indicate.set_default(color=None) + + S = Square(color=BLUE, fill_color=BLUE, fill_opacity=0.25) + self.add(S) + self.play(Rotate(S, PI)) + self.play(Indicate(S)) + + Rotate.set_default() + Indicate.set_default() + + """ + if kwargs: + cls.__init__ = partialmethod(cls.__init__, **kwargs) + else: + cls.__init__ = cls._original__init__ + def prepare_animation( anim: AnimationProtocol diff --git a/manim/animation/composition.py b/manim/animation/composition.py index 963cafd755..cc3b936f34 100644 --- a/manim/animation/composition.py +++ b/manim/animation/composition.py @@ -97,7 +97,7 @@ def begin(self) -> None: def finish(self) -> None: self.interpolate(1) self.anims_begun[:] = True - self.anims_begun[:] = True + self.anims_finished[:] = True for anim in self.animations: self.process_subanimation_buffer(anim.buffer) diff --git a/manim/animation/creation.py b/manim/animation/creation.py index 1124575b99..232c4f361f 100644 --- a/manim/animation/creation.py +++ b/manim/animation/creation.py @@ -96,7 +96,8 @@ def construct(self): from .. import config from ..animation.animation import Animation from ..animation.composition import Succession -from ..mobject.mobject import Group, Mobject +from ..mobject.mobject import Group +from ..mobject.opengl.opengl_mobject import OpenGLMobject from ..utils.bezier import integer_interpolate from ..utils.rate_functions import double_smooth, linear @@ -127,8 +128,8 @@ def __init__( def interpolate_submobject( self, - submobject: Mobject, - starting_submobject: Mobject, + submobject: OpenGLMobject, + starting_submobject: OpenGLMobject, alpha: float, ) -> Self: submobject.pointwise_become_partial( @@ -231,7 +232,7 @@ def __init__( run_time: float = 2, rate_func: Callable[[float], float] = double_smooth, stroke_width: float = 2, - stroke_color: str = None, + stroke_color: ManimColor | None = None, draw_border_animation_config: dict = {}, # what does this dict accept? fill_animation_config: dict = {}, introducer: bool = True, @@ -251,15 +252,19 @@ def __init__( self.fill_animation_config = fill_animation_config self.outline = self.get_outline() - def _typecheck_input(self, vmobject: VMobject | OpenGLVMobject) -> None: - if not isinstance(vmobject, (VMobject, OpenGLVMobject)): - raise TypeError("DrawBorderThenFill only works for vectorized Mobjects") + def _typecheck_input(self, vmobject: OpenGLVMobject) -> None: + if not isinstance(vmobject, OpenGLVMobject): + raise TypeError( + f"{self.__class__.__name__} only works for vectorized Mobjects" + ) def begin(self) -> None: + # this self.get_outline() has to be called + # before super().begin(), for whatever reason self.outline = self.get_outline() super().begin() - def get_outline(self) -> Mobject: + def get_outline(self) -> OpenGLMobject: outline = self.mobject.copy() outline.set_fill(opacity=0) for sm in outline.family_members_with_points(): @@ -273,16 +278,16 @@ def get_stroke_color(self, vmobject: VMobject | OpenGLVMobject) -> ManimColor: return vmobject.get_stroke_color() return vmobject.get_color() - def get_all_mobjects(self) -> Sequence[Mobject]: + def get_all_mobjects(self) -> Sequence[OpenGLMobject]: return [*super().get_all_mobjects(), self.outline] def interpolate_submobject( self, - submobject: Mobject, - starting_submobject: Mobject, - outline, + submobject: OpenGLMobject, + starting_submobject: OpenGLMobject, + outline: OpenGLMobject, alpha: float, - ) -> None: # Fixme: not matching the parent class? What is outline doing here? + ) -> None: index: int subalpha: int index, subalpha = integer_interpolate(0, 2, alpha) @@ -354,10 +359,7 @@ def _set_default_config_from_length( ) -> tuple[float, float]: length = len(vmobject.family_members_with_points()) if run_time is None: - if length < 15: - run_time = 1 - else: - run_time = 2 + run_time = 1 if length < 15 else 2 if lag_ratio is None: lag_ratio = min(4.0 / max(1.0, length), 0.2) return run_time, lag_ratio @@ -455,7 +457,7 @@ def construct(self): def __init__( self, - shapes: Mobject, + shapes: OpenGLMobject, scale_factor: float = 8, fade_in_fraction=0.3, **kwargs, @@ -512,7 +514,7 @@ def construct(self): def __init__( self, - group: Mobject, + group: OpenGLMobject, suspend_mobject_updating: bool = False, int_func: Callable[[np.ndarray], np.ndarray] = np.floor, reverse_rate_function=False, @@ -640,7 +642,7 @@ class ShowSubmobjectsOneByOne(ShowIncreasingSubsets): def __init__( self, - group: Iterable[Mobject], + group: Iterable[OpenGLMobject], int_func: Callable[[np.ndarray], np.ndarray] = np.ceil, **kwargs, ) -> None: @@ -725,7 +727,7 @@ def construct(self): def __init__( self, text: Text, - cursor: Mobject, + cursor: OpenGLMobject, buff: float = 0.1, keep_cursor_y: bool = True, leave_cursor_on: bool = True, diff --git a/manim/animation/fading.py b/manim/animation/fading.py index f25cd44ade..7f4646e3a8 100644 --- a/manim/animation/fading.py +++ b/manim/animation/fading.py @@ -61,10 +61,7 @@ def __init__( ) -> None: if not mobjects: raise ValueError("At least one mobject must be passed.") - if len(mobjects) == 1: - mobject = mobjects[0] - else: - mobject = Group(*mobjects) + mobject = mobjects[0] if len(mobjects) == 1 else Group(*mobjects) self.point_target = False if shift is None: diff --git a/manim/animation/rotation.py b/manim/animation/rotation.py index 6ae3c4e51f..e4be408005 100644 --- a/manim/animation/rotation.py +++ b/manim/animation/rotation.py @@ -6,15 +6,13 @@ from typing import TYPE_CHECKING -import numpy as np - from manim.animation.animation import Animation from manim.constants import ORIGIN, OUT, PI, TAU from manim.utils.rate_functions import linear if TYPE_CHECKING: from manim.mobject.opengl.opengl_mobject import OpenGLMobject - from manim.typing import RateFunc + from manim.typing import Point3D, RateFunc, Vector3D class Rotating(Animation): @@ -22,17 +20,15 @@ def __init__( self, mobject: OpenGLMobject, angle: float = TAU, - axis: np.ndarray = OUT, - about_point: np.ndarray | None = None, - about_edge: np.ndarray | None = None, - run_time: float = 5.0, + axis: Vector3D = OUT, + about_point: Point3D | None = None, + about_edge: Vector3D | None = None, rate_func: RateFunc = linear, suspend_mobject_updating: bool = False, **kwargs, ): super().__init__( mobject, - run_time=run_time, rate_func=rate_func, suspend_mobject_updating=suspend_mobject_updating, **kwargs, @@ -79,6 +75,7 @@ class Rotate(Rotating): Examples -------- .. manim:: UsingRotate + class UsingRotate(Scene): def construct(self): self.play( @@ -89,16 +86,16 @@ def construct(self): rate_func=linear, ), Rotate(Square(side_length=0.5), angle=2*PI, rate_func=linear), - ) + ) """ def __init__( self, mobject: OpenGLMobject, angle: float = PI, - axis: np.ndarray = OUT, + axis: Vector3D = OUT, run_time: float = 1, - about_edge: np.ndarray = ORIGIN, + about_edge: Vector3D = ORIGIN, **kwargs, ): super().__init__( diff --git a/manim/animation/specialized.py b/manim/animation/specialized.py index 99320f36d9..e5f9e96d96 100644 --- a/manim/animation/specialized.py +++ b/manim/animation/specialized.py @@ -70,10 +70,7 @@ def __init__( anims = [] # Works by saving the mob that is passed into the animation, scaling it to 0 (or the initial_width) and then restoring the original mob. - if mobject.fill_opacity: - fill_o = True - else: - fill_o = False + fill_o = bool(mobject.fill_opacity) for _ in range(self.n_mobs): mob = mobject.copy() diff --git a/manim/animation/transform.py b/manim/animation/transform.py index d1ee529e7a..78e661910e 100644 --- a/manim/animation/transform.py +++ b/manim/animation/transform.py @@ -206,10 +206,7 @@ def create_target(self) -> OpenGLMobject: def finish(self) -> None: super().finish() if self.replace_mobject_with_target_in_scene: - # Ideally this should stay at the same z-level as - # the original mobject, but this is difficult to implement - self.buffer.remove(self.mobject) - self.buffer.add(self.target_mobject) + self.buffer.replace(self.mobject, self.target_mobject) def get_all_mobjects(self) -> Sequence[OpenGLMobject]: return [ diff --git a/manim/animation/updaters/mobject_update_utils.py b/manim/animation/updaters/mobject_update_utils.py index dcd041502e..2b394025bd 100644 --- a/manim/animation/updaters/mobject_update_utils.py +++ b/manim/animation/updaters/mobject_update_utils.py @@ -3,7 +3,8 @@ from __future__ import annotations __all__ = [ - "assert_is_mobject_method", + "always", + "f_always", "always_redraw", "turn_animation_into_updater", "cycle_animation", @@ -11,24 +12,66 @@ import inspect -from typing import TYPE_CHECKING, Callable +from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast import numpy as np -from manim.mobject.mobject import Mobject from manim.mobject.opengl.opengl_mobject import OpenGLMobject if TYPE_CHECKING: + import types + + from typing_extensions import Concatenate, ParamSpec, TypeIs + from manim.animation.protocol import AnimationProtocol + P = ParamSpec("P") + + +M = TypeVar("M", bound=OpenGLMobject) + -def assert_is_mobject_method(method: Callable) -> None: - assert inspect.ismethod(method) - mobject = method.__self__ - assert isinstance(mobject, (Mobject, OpenGLMobject)) +# TODO: figure out how to typehint as MethodType[OpenGLMobject] to avoid the cast +# madness in always/f_always +def is_mobject_method(method: Callable[..., Any]) -> TypeIs[types.MethodType]: + return inspect.ismethod(method) and isinstance(method.__self__, OpenGLMobject) -def always_redraw(func: Callable[[], Mobject]) -> Mobject: +def always( + method: Callable[Concatenate[M, P], object], *args: P.args, **kwargs: P.kwargs +) -> M: + if not is_mobject_method(method): + raise ValueError("always must take a method of a Mobject") + mobject = cast(M, method.__self__) + func = method.__func__ + mobject.add_updater(lambda m: func(m, *args, **kwargs)) + return mobject + + +def f_always( + method: Callable[Concatenate[M, ...], None], + *arg_generators: Callable[[], object], + **kwargs, +) -> M: + """ + More functional version of always, where instead + of taking in args, it takes in functions which output + the relevant arguments. + """ + if not is_mobject_method(method): + raise ValueError("f_always must take a method of a Mobject") + mobject = cast(M, method.__self__) + func = method.__func__ + + def updater(mob): + args = [arg_generator() for arg_generator in arg_generators] + func(mob, *args, **kwargs) + + mobject.add_updater(updater) + return mobject + + +def always_redraw(func: Callable[[], M]) -> M: """Redraw the mobject constructed by a function every frame. This function returns a mobject with an attached updater that @@ -41,7 +84,8 @@ def always_redraw(func: Callable[[], Mobject]) -> Mobject: A function without (required) input arguments that returns a mobject. - Examples -------- + Examples + -------- .. manim:: TangentAnimation class TangentAnimation(Scene): @@ -71,9 +115,10 @@ def construct(self): return mob +# TODO: create a new Protocol for AnimationWithMobject def turn_animation_into_updater( - animation: AnimationProtocol, cycle: bool = False, **kwargs -) -> Mobject: + animation: AnimationProtocol, cycle: bool = False +) -> OpenGLMobject: """ Add an updater to the animation's mobject which applies the interpolation and update functions of the animation @@ -97,14 +142,15 @@ def construct(self): self.wait(0.5) self.play(banner.expand(), run_time=0.5) """ - mobject = animation.mobject + mobject = cast(OpenGLMobject, animation.mobject) animation.suspend_mobject_updating = False animation.begin() - animation.total_time = 0 + total_time = 0 - def update(m: Mobject, dt: float): + def update(m: OpenGLMobject, dt: float): + nonlocal total_time run_time = animation.get_run_time() - time_ratio = animation.total_time / run_time + time_ratio = total_time / run_time if cycle: alpha = time_ratio % 1 else: @@ -115,11 +161,11 @@ def update(m: Mobject, dt: float): return animation.interpolate(alpha) animation.update_mobjects(dt) - animation.total_time += dt + total_time += dt mobject.add_updater(update) return mobject -def cycle_animation(animation: AnimationProtocol, **kwargs) -> Mobject: +def cycle_animation(animation: AnimationProtocol, **kwargs) -> OpenGLMobject: return turn_animation_into_updater(animation, cycle=True, **kwargs) diff --git a/manim/animation/updaters/update.py b/manim/animation/updaters/update.py index 4422b0277f..4393172d1c 100644 --- a/manim/animation/updaters/update.py +++ b/manim/animation/updaters/update.py @@ -11,7 +11,7 @@ from manim.animation.animation import Animation if typing.TYPE_CHECKING: - from manim.mobject.mobject import Mobject + from manim.mobject.opengl.opengl_mobject import OpenGLMobject class UpdateFromFunc(Animation): @@ -23,15 +23,15 @@ class UpdateFromFunc(Animation): def __init__( self, - mobject: Mobject, - update_function: typing.Callable[[Mobject], typing.Any], + mobject: OpenGLMobject, + update_function: typing.Callable[[OpenGLMobject], object], suspend_mobject_updating: bool = False, **kwargs, ) -> None: - self.update_function = update_function super().__init__( mobject, suspend_mobject_updating=suspend_mobject_updating, **kwargs ) + self.update_function = update_function def interpolate(self, alpha: float) -> None: self.update_function(self.mobject) @@ -43,7 +43,9 @@ def interpolate(self, alpha: float) -> None: class MaintainPositionRelativeTo(Animation): - def __init__(self, mobject: Mobject, tracked_mobject: Mobject, **kwargs) -> None: + def __init__( + self, mobject: OpenGLMobject, tracked_mobject: OpenGLMobject, **kwargs + ) -> None: self.tracked_mobject = tracked_mobject self.diff = op.sub( mobject.get_center(), diff --git a/manim/cli/cfg/group.py b/manim/cli/cfg/group.py index 346205d89f..113818e245 100644 --- a/manim/cli/cfg/group.py +++ b/manim/cli/cfg/group.py @@ -8,6 +8,7 @@ from __future__ import annotations +import contextlib from ast import literal_eval from pathlib import Path @@ -51,10 +52,8 @@ def value_from_string(value: str) -> str | int | bool: Union[:class:`str`, :class:`int`, :class:`bool`] Returns the literal of appropriate datatype. """ - try: + with contextlib.suppress(SyntaxError, ValueError): value = literal_eval(value) - except (SyntaxError, ValueError): - pass return value @@ -197,7 +196,7 @@ def write(level: str = None, openfile: bool = False) -> None: """Not enough values in input. You may have added a new entry to default.cfg, in which case you will have to modify write_cfg_subcmd_input to account for it.""", - ) + ) from None if temp: while temp and not _is_expected_datatype( temp, diff --git a/manim/cli/default_group.py b/manim/cli/default_group.py index f4c8c33dbb..03b2e5b2fb 100644 --- a/manim/cli/default_group.py +++ b/manim/cli/default_group.py @@ -67,6 +67,7 @@ def command(self, *args, **kwargs): warnings.warn( "Use default param of DefaultGroup or set_default_command() instead", DeprecationWarning, + stacklevel=1, ) def _decorator(f): diff --git a/manim/cli/render/commands.py b/manim/cli/render/commands.py index 8eb2a3bafc..418c28eaec 100644 --- a/manim/cli/render/commands.py +++ b/manim/cli/render/commands.py @@ -54,14 +54,6 @@ def render( SCENES is an optional list of scenes in the file. """ - if args["save_as_gif"]: - logger.warning("--save_as_gif is deprecated, please use --format=gif instead!") - args["format"] = "gif" - - if args["save_pngs"]: - logger.warning("--save_pngs is deprecated, please use --format=png instead!") - args["format"] = "png" - if args["show_in_file_browser"]: logger.warning( "The short form of show_in_file_browser is deprecated and will be moved to support --format.", diff --git a/manim/cli/render/render_options.py b/manim/cli/render/render_options.py index f31358c10b..fbdd9756a7 100644 --- a/manim/cli/render/render_options.py +++ b/manim/cli/render/render_options.py @@ -5,7 +5,7 @@ from cloup import Choice, option, option_group -from manim.constants import QUALITIES, RendererType +from manim.constants import QUALITIES __all__ = ["render_options"] @@ -103,29 +103,6 @@ def validate_resolution(ctx, param, value): default=None, help="Render at this frame rate.", ), - option( - "--renderer", - type=Choice( - [renderer_type.value for renderer_type in RendererType], - case_sensitive=False, - ), - help="Select a renderer for your Scene.", - default="opengl", - ), - option( - "-g", - "--save_pngs", - is_flag=True, - default=None, - help="Save each frame as png (Deprecated).", - ), - option( - "-i", - "--save_as_gif", - default=None, - is_flag=True, - help="Save as a gif (Deprecated).", - ), option( "--save_sections", default=None, @@ -138,16 +115,4 @@ def validate_resolution(ctx, param, value): is_flag=True, help="Render scenes with alpha channel.", ), - option( - "--use_projection_fill_shaders", - is_flag=True, - help="Use shaders for OpenGLVMobject fill which are compatible with transformation matrices.", - default=None, - ), - option( - "--use_projection_stroke_shaders", - is_flag=True, - help="Use shaders for OpenGLVMobject stroke which are compatible with transformation matrices.", - default=None, - ), ) diff --git a/manim/event_handler/event_listener.py b/manim/event_handler/event_listener.py index 22a9ea0767..33c0349d84 100644 --- a/manim/event_handler/event_listener.py +++ b/manim/event_handler/event_listener.py @@ -1,5 +1,6 @@ from __future__ import annotations +import contextlib from typing import TYPE_CHECKING, Callable if TYPE_CHECKING: @@ -23,12 +24,10 @@ def __init__( def __eq__(self, other: Any) -> bool: return_val = False if isinstance(other, EventListener): - try: + with contextlib.suppress(Exception): return_val = ( self.callback == other.callback and self.mobject == other.mobject and self.event_type == other.event_type ) - except Exception: - pass return return_val diff --git a/manim/event_handler/window.py b/manim/event_handler/window.py index 57846bd36e..cde15873fe 100644 --- a/manim/event_handler/window.py +++ b/manim/event_handler/window.py @@ -1,16 +1,14 @@ from __future__ import annotations -from abc import ABC, abstractmethod +from typing import Protocol -class WindowABC(ABC): - is_closing: bool +class WindowProtocol(Protocol): + @property + def is_closing(self) -> bool: ... - @abstractmethod - def swap_buffers(self) -> None: ... + def swap_buffers(self) -> object: ... - @abstractmethod - def close(self) -> None: ... + def close(self) -> object: ... - @abstractmethod - def clear(self) -> None: ... + def clear(self) -> object: ... diff --git a/manim/file_writer/file_writer.py b/manim/file_writer/file_writer.py index 74d766719e..2a0675c22f 100644 --- a/manim/file_writer/file_writer.py +++ b/manim/file_writer/file_writer.py @@ -77,7 +77,7 @@ def __init__(self, scene_name: str) -> None: # first section gets automatically created for convenience # if you need the first section to be skipped, add a first section by hand, it will replace this one self.next_section( - name="autocreated", type=DefaultSectionType.NORMAL, skip_animations=False + name="autocreated", type_=DefaultSectionType.NORMAL, skip_animations=False ) def init_output_directories(self, scene_name: str) -> None: @@ -93,10 +93,7 @@ def init_output_directories(self, scene_name: str) -> None: if config["dry_run"]: # in dry-run mode there is no output return - if config["input_file"]: - module_name = config.get_dir("input_file").stem - else: - module_name = "" + module_name = config.get_dir("input_file").stem if config["input_file"] else "" if self.force_output_as_scene_name: self.output_name = Path(scene_name) @@ -165,7 +162,7 @@ def finish_last_section(self) -> None: if len(self.sections) and self.sections[-1].is_empty(): self.sections.pop() - def next_section(self, name: str, type: str, skip_animations: bool) -> None: + def next_section(self, name: str, type_: str, skip_animations: bool) -> None: """Create segmentation cut here.""" self.finish_last_section() @@ -183,7 +180,7 @@ def next_section(self, name: str, type: str, skip_animations: bool) -> None: self.sections.append( Section( - type, + type_, section_video, name, skip_animations, diff --git a/manim/file_writer/protocols.py b/manim/file_writer/protocols.py index 5b08d0f38e..851569352e 100644 --- a/manim/file_writer/protocols.py +++ b/manim/file_writer/protocols.py @@ -16,16 +16,18 @@ class FileWriterProtocol(Protocol): def __init__(self, scene_name: str) -> None: ... - def begin_animation(self, allow_write: bool = False) -> None: ... + def begin_animation(self, allow_write: bool = False) -> object: ... - def end_animation(self, allow_write: bool = False) -> None: ... + def end_animation(self, allow_write: bool = False) -> object: ... def is_already_cached(self, hash_invocation: str) -> bool: ... - def add_partial_movie_file(self, hash_animation: str) -> None: ... + def add_partial_movie_file(self, hash_animation: str) -> object: ... - def write_frame(self, frame: PixelArray) -> None: ... + def write_frame(self, frame: PixelArray) -> object: ... + + def next_section(self, name: str, type_: str, skip_animations: bool) -> object: ... def finish(self) -> None: ... - def save_image(self, image: PixelArray) -> None: ... + def save_image(self, image: PixelArray) -> object: ... diff --git a/manim/file_writer/sections.py b/manim/file_writer/sections.py index 838d534277..728104f32e 100644 --- a/manim/file_writer/sections.py +++ b/manim/file_writer/sections.py @@ -34,23 +34,23 @@ class PresentationSectionType(str, Enum): class Section: - """A :class:`.Scene` can be segmented into multiple Sections. + r"""A :class:`.Scene` can be segmented into multiple Sections. Refer to :doc:`the documentation` for more info. It consists of multiple animations. Attributes ---------- - type - Can be used by a third party applications to classify different types of sections. - video - Path to video file with animations belonging to section relative to sections directory. - If ``None``, then the section will not be saved. - name - Human readable, non-unique name for this section. - skip_animations - Skip rendering the animations in this section when ``True``. - partial_movie_files - Animations belonging to this section. + type\_ + Can be used by a third party applications to classify different types of sections. + video + Path to video file with animations belonging to section relative to sections directory. + If ``None``, then the section will not be saved. + name + Human readable, non-unique name for this section. + skip_animations + Skip rendering the animations in this section when ``True``. + partial_movie_files + Animations belonging to this section. See Also -------- @@ -59,8 +59,8 @@ class Section: :meth:`.OpenGLRenderer.update_skipping_status` """ - def __init__(self, type: str, video: str | None, name: str, skip_animations: bool): - self.type = type + def __init__(self, type_: str, video: str | None, name: str, skip_animations: bool): + self.type_ = type_ # None when not to be saved -> still keeps section alive self.video: str | None = video self.name = name @@ -94,7 +94,7 @@ def get_dict(self, sections_dir: Path) -> dict[str, Any]: return dict( { "name": self.name, - "type": self.type, + "type": self.type_, "video": self.video, }, **video_metadata, diff --git a/manim/manager.py b/manim/manager.py index 0692360c9a..d68ad97696 100644 --- a/manim/manager.py +++ b/manim/manager.py @@ -5,19 +5,26 @@ import contextlib import platform import time +import warnings from collections.abc import Iterable, Sequence from typing import TYPE_CHECKING, Callable, Generic, TypeVar import numpy as np -from tqdm import tqdm from manim import config, logger -from manim.event_handler.window import WindowABC +from manim.event_handler.window import WindowProtocol from manim.file_writer import FileWriter -from manim.plugins import Hooks, plugins +from manim.renderer.opengl_renderer import OpenGLRenderer +from manim.renderer.opengl_renderer_window import Window from manim.scene.scene import Scene, SceneState from manim.utils.exceptions import EndSceneEarlyException from manim.utils.hashing import get_hash_from_play_call +from manim.utils.progressbar import ( + ExperimentalProgressBarWarning, + NullProgressBar, + ProgressBar, + ProgressBarProtocol, +) if TYPE_CHECKING: import numpy.typing as npt @@ -53,6 +60,10 @@ def construct(self): Manager(Manimation).render() """ + window_class: type[WindowProtocol] = Window # type: ignore[type-abstract] + file_writer_class: type[FileWriterProtocol] = FileWriter + renderer_class: type[RendererProtocol] = OpenGLRenderer + def __init__(self, scene_cls: type[Scene_co]) -> None: # scene self.scene: Scene_co = scene_cls(manager=self) @@ -69,10 +80,12 @@ def __init__(self, scene_cls: type[Scene_co]) -> None: self.renderer = self.create_renderer() self.renderer.use_window() - # file writer self.file_writer: FileWriterProtocol = self.create_file_writer() self._write_files = config.write_to_movie + # internal state + self._skipping = False + # keep these as instance methods so subclasses # have access to everything def create_renderer(self) -> RendererProtocol: @@ -85,12 +98,12 @@ def create_renderer(self) -> RendererProtocol: ------- An instance of a renderer """ - renderer = plugins.renderer() + renderer = self.renderer_class() if config.preview: renderer.use_window() return renderer - def create_window(self) -> WindowABC | None: + def create_window(self) -> WindowProtocol | None: """Create and return a window instance. This can be overridden in subclasses (plugins), if more @@ -100,7 +113,7 @@ def create_window(self) -> WindowABC | None: ------- A window if previewing, else None """ - return plugins.window() if config.preview else None + return self.window_class() if config.preview else None def create_file_writer(self) -> FileWriterProtocol: """Create and returna file writer instance. @@ -112,7 +125,7 @@ def create_file_writer(self) -> FileWriterProtocol: ------- A file writer satisfying :class:`.FileWriterProtocol` """ - return FileWriter(scene_name=self.scene.get_default_scene_name()) + return self.file_writer_class(scene_name=self.scene.get_default_scene_name()) def setup(self) -> None: """Set up processes and manager""" @@ -155,12 +168,27 @@ def _render_first_pass(self) -> None: self.setup() with contextlib.suppress(EndSceneEarlyException): - self.scene.construct() + self.construct() self.post_contruct() self._interact() self.tear_down() + def construct(self) -> None: + if not self.scene.sections_api: + self.scene.construct() + return + for section in self.scene.find_sections(): + self.file_writer.next_section( + section.name, + section.type_, + section.skip, + ) + if section.skip: + self._skipping = True + section() + self._skipping = False + def _render_second_pass(self) -> None: """ In the future, this method could be used @@ -170,9 +198,6 @@ def _render_second_pass(self) -> None: def post_contruct(self) -> None: """Run post-construct hooks, and clean up the file writer.""" - for hook in plugins.hooks[Hooks.POST_CONSTRUCT]: - hook(self) - if self.file_writer.num_plays: self.file_writer.finish() # otherwise no animations were played @@ -181,7 +206,6 @@ def post_contruct(self) -> None: # FIXME: for some reason the OpenGLRenderer does not give out the # correct frame values here frame = self.renderer.get_pixels() - # NOTE: add hooks for post-processing (e.g. gaussian blur)? self.file_writer.save_image(frame) self._write_files = False @@ -229,15 +253,16 @@ def _update_frame(self, dt: float, *, write_frame: bool | None = None) -> None: self.scene.time = self.time if self.window is not None: - self.window.clear() + if not self._skipping: + self.window.clear() # if it's closing, then any subsequent methods will # raise an error because the internal C window pointer is nullptr. if self.window.is_closing: raise EndSceneEarlyException() - self.render_state(write_frame=write_frame) - + if not self._skipping: + self.render_state(write_frame=write_frame) self._wait_for_animation_time() def _wait_for_animation_time(self) -> None: @@ -253,6 +278,9 @@ def _wait_for_animation_time(self) -> None: self.window.swap_buffers() + if self._skipping: + return + vt = self.time - self.virtual_animation_start_time rt = time.perf_counter() - self.real_animation_start_time # we can't sleep because we still need to poll for events, @@ -267,6 +295,8 @@ def _wait_for_animation_time(self) -> None: def _play(self, *animations: AnimationProtocol) -> None: """Play a bunch of animations""" + self.scene.pre_play() + if self.window is not None: self.real_animation_start_time = time.perf_counter() self.virtual_animation_start_time = self.time @@ -287,7 +317,7 @@ def _write_hashed_movie_file(self, animations: Sequence[AnimationProtocol]) -> N Essentially, a series of methods that need to be called to successfully render a frame. """ - if not config.write_to_movie: + if not config.write_to_movie or self._skipping: return if config.disable_caching: @@ -315,13 +345,18 @@ def _write_hashed_movie_file(self, animations: Sequence[AnimationProtocol]) -> N def _create_progressbar( self, total: float, description: str, **kwargs: Any - ) -> tqdm | contextlib.nullcontext[NullProgressBar]: + ) -> contextlib.AbstractContextManager[ProgressBarProtocol]: """Create a progressbar""" if not config.progress_bar: return contextlib.nullcontext(NullProgressBar()) - else: - return tqdm( + + with warnings.catch_warnings(): + if config.verbosity != "DEBUG": + # Note: update when rich/notebook tqdm is no longer experimental + warnings.simplefilter("ignore", category=ExperimentalProgressBarWarning) + + return ProgressBar( total=total, unit="frames", desc=description % {"num": self.file_writer.num_plays}, @@ -349,7 +384,7 @@ def _wait( update_mobjects = self.scene.should_update_mobjects() condition = stop_condition or (lambda: False) - progression = self._calc_time_progression(duration) + progression = _calc_time_progression(duration) state = self.scene.get_state() @@ -365,9 +400,7 @@ def _wait( break else: self.time += dt - # this fixes it, but at that point we might as well - # just not cache - self.renderer.render(self.scene.camera, state.mobjects) + self.renderer.render(state) if self.window is not None and self.window.is_closing: raise EndSceneEarlyException() self._wait_for_animation_time() @@ -380,8 +413,8 @@ def _progress_through_animations( self, animations: Sequence[AnimationProtocol] ) -> None: last_t = 0.0 - run_time = self._calc_runtime(animations) - progression = self._calc_time_progression(run_time) + run_time = _calc_runtime(animations) + progression = _calc_time_progression(run_time) with self._create_progressbar( progression.shape[0], f"Animation %(num)d: {animations[0]}{', etc.' if len(animations) > 1 else ''}", @@ -392,20 +425,6 @@ def _progress_through_animations( self._update_frame(dt) progress.update(1) - def _calc_time_progression(self, run_time: float) -> npt.NDArray[np.float64]: - """Compute the time values at which to evaluate the animation""" - - return np.arange(0, run_time, 1 / config.frame_rate) - - def _calc_runtime(self, animations: Iterable[AnimationProtocol]) -> float: - """Calculate the runtime of an iterable of animations. - - .. warning:: - - If animations is a generator, this will consume the generator. - """ - return max(animation.get_run_time() for animation in animations) - # -------------------------# # Rendering # # -------------------------# @@ -432,8 +451,7 @@ def _render_frame( use this to write a single frame! """ - # TODO: change self.scene.camera to state.camera - self.renderer.render(self.scene.camera, state.mobjects) + self.renderer.render(state) should_write = write_frame if write_frame is not None else self._write_files if should_write: @@ -446,7 +464,17 @@ def write_frame(self) -> None: self.file_writer.write_frame(frame) -class NullProgressBar: - """Fake progressbar.""" +def _calc_time_progression(run_time: float) -> npt.NDArray[np.float64]: + """Compute the time values at which to evaluate the animation""" + + return np.arange(0, run_time, 1 / config.frame_rate) + - def update(self, _: Any) -> None: ... +def _calc_runtime(animations: Iterable[AnimationProtocol]) -> float: + """Calculate the runtime of an iterable of animations. + + .. warning:: + + If animations is a generator, this will consume the generator. + """ + return max(animation.get_run_time() for animation in animations) diff --git a/manim/mobject/geometry/arc.py b/manim/mobject/geometry/arc.py index 09dbcd1c57..b09e71930d 100644 --- a/manim/mobject/geometry/arc.py +++ b/manim/mobject/geometry/arc.py @@ -401,7 +401,9 @@ def get_arc_center(self, warning: bool = True) -> Point3D: return line_intersection(line1=(a1, a1 + n1), line2=(a2, a2 + n2)) except Exception: if warning: - warnings.warn("Can't find Arc center, using ORIGIN instead") + warnings.warn( + "Can't find Arc center, using ORIGIN instead", stacklevel=1 + ) self._failed_to_get_center = True return np.array(ORIGIN) diff --git a/manim/mobject/geometry/boolean_ops.py b/manim/mobject/geometry/boolean_ops.py index 7387088a05..106394d8aa 100644 --- a/manim/mobject/geometry/boolean_ops.py +++ b/manim/mobject/geometry/boolean_ops.py @@ -87,11 +87,10 @@ def _convert_vmobject_to_skia_path(self, vmobject: VMobject) -> SkiaPath: quads = vmobject.get_bezier_tuples_from_points(subpath) start = subpath[0] path.moveTo(*start[:2]) - for p0, p1, p2 in quads: + for _p0, p1, p2 in quads: path.quadTo(*p1[:2], *p2[:2]) if vmobject.consider_points_equals(subpath[0], subpath[-1]): path.close() - return path def _convert_skia_path_to_vmobject(self, path: SkiaPath) -> VMobject: diff --git a/manim/mobject/geometry/line.py b/manim/mobject/geometry/line.py index d9253f1def..16eaaba0d6 100644 --- a/manim/mobject/geometry/line.py +++ b/manim/mobject/geometry/line.py @@ -99,10 +99,7 @@ def _account_for_buff(self, buff: float) -> Self | None: if buff == 0: return # - if self.path_arc == 0: - length = self.get_length() - else: - length = self.get_arc_length() + length = self.get_length() if self.path_arc == 0 else self.get_arc_length() # if length < 2 * buff: return diff --git a/manim/mobject/geometry/polygram.py b/manim/mobject/geometry/polygram.py index 4d1194eba4..f3ac477b0e 100644 --- a/manim/mobject/geometry/polygram.py +++ b/manim/mobject/geometry/polygram.py @@ -757,9 +757,6 @@ def construct(self): def __init__(self, main_shape: VMobject, *mobjects: VMobject, **kwargs) -> None: super().__init__(**kwargs) self.append_points(main_shape.points) - if main_shape.get_direction() == "CW": - sub_direction = "CCW" - else: - sub_direction = "CW" + sub_direction = "CCW" if main_shape.get_direction() == "CW" else "CW" for mobject in mobjects: self.append_points(mobject.force_direction(sub_direction).points) diff --git a/manim/mobject/graph.py b/manim/mobject/graph.py index 8afcf03012..17f25a3d6a 100644 --- a/manim/mobject/graph.py +++ b/manim/mobject/graph.py @@ -338,10 +338,7 @@ def _tree_layout( parent = {u: root_vertex for u in children[root_vertex]} pos = {} obstruction = [0.0] * len(T) - if orientation == "down": - o = -1 - else: - o = 1 + o = -1 if orientation == "down" else 1 def slide(v, dx): """ @@ -404,15 +401,9 @@ def slide(v, dx): if isinstance(scale, (float, int)) and (width > 0 or height > 0): sf = 2 * scale / max(width, height) elif isinstance(scale, tuple): - if scale[0] is not None and width > 0: - sw = 2 * scale[0] / width - else: - sw = 1 + sw = 2 * scale[0] / width if scale[0] is not None and width > 0 else 1 - if scale[1] is not None and height > 0: - sh = 2 * scale[1] / height - else: - sh = 1 + sh = 2 * scale[1] / height if scale[1] is not None and height > 0 else 1 sf = np.array([sw, sh, 0]) else: @@ -478,11 +469,11 @@ def _determine_graph_layout( return cast(LayoutFunction, layout)( nx_graph, scale=layout_scale, **layout_config ) - except TypeError: + except TypeError as e: raise ValueError( f"The layout '{layout}' is neither a recognized layout, a layout function," "nor a vertex placement dictionary.", - ) + ) from e class GenericGraph(VMobject, metaclass=ConvertToOpenGL): @@ -851,7 +842,7 @@ def _create_vertices( label_fill_color=label_fill_color, vertex_type=vertex_type, vertex_config=vertex_config[v], - vertex_mobject=vertex_mobjects[v] if v in vertex_mobjects else None, + vertex_mobject=vertex_mobjects.get(v), ) for v in vertices ] @@ -977,7 +968,8 @@ def remove_vertices(self, *vertices): :: >>> G = Graph([1, 2, 3], [(1, 2), (2, 3)]) - >>> removed = G.remove_vertices(2, 3); removed + >>> removed = G.remove_vertices(2, 3) + >>> removed VGroup(Line, Line, Dot, Dot) >>> G Undirected graph on 1 vertices and 0 edges diff --git a/manim/mobject/graphing/coordinate_systems.py b/manim/mobject/graphing/coordinate_systems.py index bc5dc3c686..facb96ff20 100644 --- a/manim/mobject/graphing/coordinate_systems.py +++ b/manim/mobject/graphing/coordinate_systems.py @@ -48,6 +48,7 @@ ManimColor, ParsableManimColor, color_gradient, + interpolate_color, invert_color, ) from manim.utils.config_ops import merge_dicts_recursively, update_dict_recursively @@ -628,6 +629,8 @@ def plot( function: Callable[[float], float], x_range: Sequence[float] | None = None, use_vectorized: bool = False, + colorscale: Union[Iterable[Color], Iterable[Color, float]] | None = None, + colorscale_axis: int = 1, **kwargs: Any, ) -> ParametricFunction: """Generates a curve based on a function. @@ -641,6 +644,12 @@ def plot( use_vectorized Whether to pass in the generated t value array to the function. Only use this if your function supports it. Output should be a numpy array of shape ``[y_0, y_1, ...]`` + colorscale + Colors of the function. Optional parameter used when coloring a function by values. Passing a list of colors + and a colorscale_axis will color the function by y-value. Passing a list of tuples in the form ``(color, pivot)`` + allows user-defined pivots where the color transitions. + colorscale_axis + Defines the axis on which the colorscale is applied (0 = x, 1 = y), default is y-axis (1). kwargs Additional parameters to be passed to :class:`~.ParametricFunction`. @@ -719,7 +728,57 @@ def log_func(x): use_vectorized=use_vectorized, **kwargs, ) + graph.underlying_function = function + + if colorscale: + if type(colorscale[0]) in (list, tuple): + new_colors, pivots = [ + [i for i, j in colorscale], + [j for i, j in colorscale], + ] + else: + new_colors = colorscale + + ranges = [self.x_range, self.y_range] + pivot_min = ranges[colorscale_axis][0] + pivot_max = ranges[colorscale_axis][1] + pivot_frequency = (pivot_max - pivot_min) / (len(new_colors) - 1) + pivots = np.arange( + start=pivot_min, + stop=pivot_max + pivot_frequency, + step=pivot_frequency, + ) + + resolution = 0.01 if len(x_range) == 2 else x_range[2] + sample_points = np.arange(x_range[0], x_range[1] + resolution, resolution) + color_list = [] + for samp_x in sample_points: + axis_value = (samp_x, function(samp_x))[colorscale_axis] + if axis_value <= pivots[0]: + color_list.append(new_colors[0]) + elif axis_value >= pivots[-1]: + color_list.append(new_colors[-1]) + else: + for i, pivot in enumerate(pivots): + if pivot > axis_value: + color_index = (axis_value - pivots[i - 1]) / ( + pivots[i] - pivots[i - 1] + ) + color_index = min(color_index, 1) + mob_color = interpolate_color( + new_colors[i - 1], + new_colors[i], + color_index, + ) + color_list.append(mob_color) + break + if config.renderer == RendererType.OPENGL: + graph.set_color(color_list) + else: + graph.set_stroke(color_list) + graph.set_sheen_direction(RIGHT) + return graph def plot_implicit_curve( diff --git a/manim/mobject/graphing/probability.py b/manim/mobject/graphing/probability.py index 0f98620dc3..42197b8ff0 100644 --- a/manim/mobject/graphing/probability.py +++ b/manim/mobject/graphing/probability.py @@ -339,10 +339,7 @@ def _add_x_axis_labels(self): for i, (value, bar_name) in enumerate(zip(val_range, self.bar_names)): # to accommodate negative bars, the label may need to be # below or above the x_axis depending on the value of the bar - if self.values[i] < 0: - direction = UP - else: - direction = DOWN + direction = UP if self.values[i] < 0 else DOWN bar_name_label = self.x_axis.label_constructor(bar_name) bar_name_label.font_size = self.x_axis.font_size diff --git a/manim/mobject/mobject.py b/manim/mobject/mobject.py index 9603335d04..ad8d5b3f9d 100644 --- a/manim/mobject/mobject.py +++ b/manim/mobject/mobject.py @@ -265,10 +265,12 @@ def set_default(cls, **kwargs) -> None: >>> from manim import Square, GREEN >>> Square.set_default(color=GREEN, fill_opacity=0.25) - >>> s = Square(); s.color, s.fill_opacity + >>> s = Square() + >>> s.color, s.fill_opacity (ManimColor('#83C167'), 0.25) >>> Square.set_default() - >>> s = Square(); s.color, s.fill_opacity + >>> s = Square() + >>> s.color, s.fill_opacity (ManimColor('#FFFFFF'), 0.0) .. manim:: ChangedDefaultTextcolor @@ -674,13 +676,10 @@ def __getattr__(self, attr: str) -> types.MethodType: # Add automatic compatibility layer # between properties and get_* and set_* # methods. - # - # In python 3.9+ we could change this - # logic to use str.remove_prefix instead. if attr.startswith("get_"): # Remove the "get_" prefix - to_get = attr[4:] + to_get = attr.removeprefix("get_") def getter(self): warnings.warn( @@ -1339,12 +1338,12 @@ def apply_matrix(self, matrix, **kwargs) -> Self: # Default to applying matrix about the origin, not mobjects center if ("about_point" not in kwargs) and ("about_edge" not in kwargs): kwargs["about_point"] = ORIGIN - full_matrix = np.identity(self.dim) - matrix = np.array(matrix) - full_matrix[: matrix.shape[0], : matrix.shape[1]] = matrix - self.apply_points_function_about_point( - lambda points: np.dot(points, full_matrix.T), **kwargs - ) + # full_matrix = np.identity(self.dim) + # matrix = np.array(matrix) + # full_matrix[: matrix.shape[0], : matrix.shape[1]] = matrix + # self.apply_points_function_about_point( + # lambda points: np.dot(points, full_matrix.T), **kwargs + # ) return self def apply_complex_function( @@ -1577,9 +1576,7 @@ def is_off_screen(self): return True if self.get_bottom()[1] > config["frame_y_radius"]: return True - if self.get_top()[1] < -config["frame_y_radius"]: - return True - return False + return self.get_top()[1] < -config["frame_y_radius"] def stretch_about_point(self, factor: float, dim: int, point: Point3D) -> Self: return self.stretch(factor, dim, about_point=point) @@ -1994,7 +1991,7 @@ def reduce_across_dimension(self, reduce_func: Callable, dim: int): # If we do not have points (but do have submobjects) # use only the points from those. - if len(self.points) == 0: + if len(self.points) == 0: # noqa: SIM108 rv = None else: # Otherwise, be sure to include our own points @@ -2003,10 +2000,7 @@ def reduce_across_dimension(self, reduce_func: Callable, dim: int): # smallest dimension they have and compare it to the return value. for mobj in self.submobjects: value = mobj.reduce_across_dimension(reduce_func, dim) - if rv is None: - rv = value - else: - rv = reduce_func([value, rv]) + rv = value if rv is None else reduce_func([value, rv]) return rv def nonempty_submobjects(self) -> list[Self]: @@ -2487,10 +2481,10 @@ def init_size(num, alignments, sizes): buff_x = buff_y = buff # Initialize alignments correctly - def init_alignments(alignments, num, mapping, name, dir): + def init_alignments(alignments, num, mapping, name, dir_): if alignments is None: # Use cell_alignment as fallback - return [cell_alignment * dir] * num + return [cell_alignment * dir_] * num if len(alignments) != num: raise ValueError(f"{name}_alignments has a mismatching size.") alignments = list(alignments) diff --git a/manim/mobject/opengl/opengl_geometry.py b/manim/mobject/opengl/opengl_geometry.py index 2ec0bbe4dd..646594a29c 100644 --- a/manim/mobject/opengl/opengl_geometry.py +++ b/manim/mobject/opengl/opengl_geometry.py @@ -463,10 +463,7 @@ def account_for_buff(self, buff): if buff == 0: return # - if self.path_arc == 0: - length = self.get_length() - else: - length = self.get_arc_length() + length = self.get_length() if self.path_arc == 0 else self.get_arc_length() # if length < 2 * buff: return diff --git a/manim/mobject/opengl/opengl_mobject.py b/manim/mobject/opengl/opengl_mobject.py index ad93db1303..20160c6192 100644 --- a/manim/mobject/opengl/opengl_mobject.py +++ b/manim/mobject/opengl/opengl_mobject.py @@ -14,9 +14,8 @@ from math import ceil from typing import TYPE_CHECKING, Generic -import moderngl import numpy as np -from typing_extensions import TypeVar +from typing_extensions import TypedDict, TypeVar from manim import config from manim.constants import * @@ -43,6 +42,8 @@ rotation_matrix_transpose, ) +__all__ = ["OpenGLMobject", "MobjectKwargs"] + if TYPE_CHECKING: from collections.abc import Iterable, Sequence from typing import Callable @@ -92,17 +93,6 @@ def wrapper(self: M, *args: P.args, **kwargs: P.kwargs): return wrapper -def affects_shader_info_id(func): - @wraps(func) - def wrapper(self): - for mob in self.get_family(): - func(mob) - mob.refresh_shader_wrapper_id() - return self - - return wrapper - - @dataclass class MobjectStatus: color_changed: bool = False @@ -112,9 +102,19 @@ class MobjectStatus: points_changed: bool = False -# it's generic in its renderer, which is a little bit cursed -# In the future, it should be replaced with a RendererData protocol -class OpenGLMobject(Generic[R]): +# TODO: add this to the **kwargs of all mobjects that use OpenGLMobject +class MobjectKwargs(TypedDict, total=False): + opacity: float + reflectiveness: float + shadow: float + gloss: float + is_fixed_in_frame: bool + is_fixed_orientation: bool + depth_test: bool + name: str + + +class OpenGLMobject: """Mathematical Object: base class for objects that can be displayed on screen. Attributes @@ -131,12 +131,9 @@ class OpenGLMobject(Generic[R]): """ dim: int = 3 - shader_folder: str = "" - render_primitive: int = moderngl.TRIANGLE_STRIP - shader_dtype: Sequence[tuple[str, type, tuple[int]]] = [ - ("point", np.float32, (3,)), - ] + # WARNING: when changing a parameter here, be sure to update the + # TypedDict above so that autocomplete works for users def __init__( self, color=WHITE, @@ -144,19 +141,16 @@ def __init__( reflectiveness: float = 0.0, shadow: float = 0.0, gloss: float = 0.0, - texture_paths: dict[str, str] | None = None, is_fixed_in_frame: bool = False, is_fixed_orientation: bool = False, depth_test: bool = True, name: str | None = None, - **kwargs, ): self.color = color self.opacity = opacity self.reflectiveness = reflectiveness self.shadow = shadow self.gloss = gloss - self.texture_paths = texture_paths self.is_fixed_in_frame = is_fixed_in_frame self.is_fixed_orientation = is_fixed_orientation self.depth_test = depth_test @@ -174,7 +168,7 @@ def __init__( self.target: OpenGLMobject | None = None # TODO replace with protocol - self.renderer_data: R | None = None + self.renderer_data: RendererData | None = None # currently does nothing self.status = MobjectStatus() @@ -237,10 +231,12 @@ def set_default(cls, **kwargs): >>> from manim import Square, GREEN >>> Square.set_default(color=GREEN, fill_opacity=0.25) - >>> s = Square(); s.color, s.fill_opacity + >>> s = Square() + >>> s.color, s.fill_opacity (ManimColor('#83C167'), 0.25) >>> Square.set_default() - >>> s = Square(); s.color, s.fill_opacity + >>> s = Square() + >>> s.color, s.fill_opacity (ManimColor('#FFFFFF'), 0.0) .. manim:: ChangedDefaultTextcolor @@ -1080,10 +1076,10 @@ def init_size(num, alignments, sizes): buff_x = buff_y = buff # Initialize alignments correctly - def init_alignments(alignments, num, mapping, name, dir): + def init_alignments(alignments, num, mapping, name, dir_): if alignments is None: # Use cell_alignment as fallback - return [cell_alignment * dir] * num + return [cell_alignment * dir_] * num if len(alignments) != num: raise ValueError(f"{name}_alignments has a mismatching size.") alignments = list(alignments) @@ -1220,8 +1216,8 @@ def arrange_in_grid( if v_buff is None: v_buff = v_buff_ratio * self[0].get_height() - x_unit = h_buff + max([sm.get_width() for sm in submobs]) - y_unit = v_buff + max([sm.get_height() for sm in submobs]) + x_unit = h_buff + max(sm.get_width() for sm in submobs) + y_unit = v_buff + max(sm.get_height() for sm in submobs) for index, sm in enumerate(submobs): if fill_rows_first: @@ -1315,6 +1311,7 @@ def construct(self): for submob in self.submobjects: submob.reverse_submobjects(recursive=True) self.note_changed_family() + return self # Copying @@ -1360,7 +1357,6 @@ def copy(self, deep: bool = False) -> Self: result.parents = [] result.target = None result.saved_state = None - # result.points = np.array(self.points) # @@ -1372,13 +1368,16 @@ def copy(self, deep: bool = False) -> Self: sm.parents = [result] result.note_changed_family() + for current, copy_ in zip(self.get_family(), result.get_family()): + copy_.points = np.array(current.points) + copy_.match_color(current) # Similarly, instead of calling match_updaters, since we know the status # won't have changed, just directly match with shallow copies. result.non_time_updaters = self.non_time_updaters.copy() result.time_based_updaters = self.time_based_updaters.copy() family = self.get_family() - for attr, value in list(self.__dict__.items()): + for attr, value in self.__dict__.items(): if ( isinstance(value, OpenGLMobject) and value is not self @@ -1404,7 +1403,7 @@ def save_state(self, use_deepcopy: bool = False): def restore(self): """Restores the state that was previously saved with :meth:`~.OpenGLMobject.save_state`.""" - if not hasattr(self, "saved_state") or self.save_state is None: + if self.saved_state is None: raise Exception("Trying to restore without having saved") self.become(self.saved_state) return self @@ -1918,9 +1917,7 @@ def is_off_screen(self): return True if self.get_bottom()[1] > config.frame_y_radius: return True - if self.get_top()[1] < -config.frame_y_radius: - return True - return False + return self.get_top()[1] < -config.frame_y_radius def stretch_about_point(self, factor, dim, point): return self.stretch(factor, dim, about_point=point) @@ -2158,7 +2155,7 @@ def set_color(self, color: ParsableManimColor | None, opacity=None, recurse=True if color is not None: self.color: ManimColor = ManimColor.parse(color) if opacity is not None: - self.color.set_opacity(opacity) + self.color.opacity(opacity) if recurse: for submob in self.submobjects: submob.set_color(color, recurse=True) @@ -2171,17 +2168,14 @@ def set_opacity(self, opacity, recurse=True): submob.set_opacity(opacity, recurse=True) return self - def get_color(self): - return rgb_to_hex(self.rgbas[0, :3]) + def get_color(self) -> ManimColor: + return self.color def get_opacity(self): - return self.color._internal_value[3] + return self.color.opacity() def set_color_by_gradient(self, *colors: ParsableManimColor): - if self.has_points(): - self.set_color(colors) - else: - self.set_submobject_colors_by_gradient(*colors) + self.set_submobject_colors_by_gradient(*colors) return self def set_submobject_colors_by_gradient(self, *colors): @@ -2293,7 +2287,7 @@ def get_boundary_point(self, direction): return all_points[index] def get_continuous_bounding_box_point(self, direction): - dl, center, ur = self.get_bounding_box() + _dl, center, ur = self.get_bounding_box() corner_vect = ur - center return center + direction / np.max( np.abs( @@ -2570,7 +2564,6 @@ def construct(self): self.add(dotL, dotR, dotMiddle) """ - # TODO: replace with list of attribute names with a locking system self.points = path_func(mobject1.points, mobject2.points, alpha) self.interpolate_color(mobject1, mobject2, alpha) return self @@ -2652,10 +2645,7 @@ def construct(self): family1 = self.get_family() family2 = mobject.get_family() for sm1, sm2 in zip(family1, family2): - sm1.shader_folder = sm2.shader_folder - sm1.texture_paths = sm2.texture_paths sm1.depth_test = sm2.depth_test - sm1.render_primitive = sm2.render_primitive # Make sure named family members carry over for attr, value in list(mobject.__dict__.items()): if isinstance(value, OpenGLMobject) and value in family2: @@ -2663,6 +2653,7 @@ def construct(self): self.refresh_bounding_box(recurse_down=True) if match_updaters: self.match_updaters(mobject) + self.note_changed_family() return self def looks_identical(self, mobject: OpenGLMobject) -> bool: diff --git a/manim/mobject/opengl/opengl_point_cloud_mobject.py b/manim/mobject/opengl/opengl_point_cloud_mobject.py index 0b5a940a7e..d8e374c5d1 100644 --- a/manim/mobject/opengl/opengl_point_cloud_mobject.py +++ b/manim/mobject/opengl/opengl_point_cloud_mobject.py @@ -71,7 +71,7 @@ def thin_out(self, factor=5): for mob in self.family_members_with_points(): num_points = mob.get_num_points() - def thin_func(): + def thin_func(num_points=num_points): return np.arange(0, num_points, factor) if len(mob.points) == len(mob.rgbas): diff --git a/manim/mobject/opengl/opengl_surface.py b/manim/mobject/opengl/opengl_surface.py index 565b8c71cf..c18c9ad317 100644 --- a/manim/mobject/opengl/opengl_surface.py +++ b/manim/mobject/opengl/opengl_surface.py @@ -388,7 +388,8 @@ def __init__( if isinstance(image_mode, (str, Path)): image_mode = [image_mode] * 2 image_mode_light, image_mode_dark = image_mode - texture_paths = { + # TODO: move to renderer + _texture_paths = { "LightTexture": self.get_image_from_file( image_file, image_mode_light, @@ -407,7 +408,7 @@ def __init__( self.v_range = uv_surface.v_range self.resolution = uv_surface.resolution self.gloss = self.uv_surface.gloss - super().__init__(texture_paths=texture_paths, **kwargs) + super().__init__(**kwargs) def get_image_from_file( self, diff --git a/manim/mobject/opengl/opengl_vectorized_mobject.py b/manim/mobject/opengl/opengl_vectorized_mobject.py index 0b4b0b783c..12c6b48bc1 100644 --- a/manim/mobject/opengl/opengl_vectorized_mobject.py +++ b/manim/mobject/opengl/opengl_vectorized_mobject.py @@ -6,9 +6,11 @@ from typing import TYPE_CHECKING, Literal import numpy as np +from typing_extensions import Unpack from manim.constants import * from manim.mobject.opengl.opengl_mobject import ( + MobjectKwargs, OpenGLMobject, OpenGLPoint, ) @@ -24,6 +26,7 @@ proportions_along_bezier_curve_for_point, ) from manim.utils.color import * +from manim.utils.color.core import ParsableManimColor from manim.utils.deprecation import deprecated from manim.utils.iterables import ( listify, @@ -55,6 +58,21 @@ DEFAULT_FILL_COLOR = GREY_C +# TODO: add this to the **kwargs of all mobjects that use OpenGLVMobject +class VMobjectKwargs(MobjectKwargs, total=False): + color: ParsableManimColor | list[ParsableManimColor] + fill_color: ParsableManimColor | list[ParsableManimColor] + fill_opacity: float + stroke_color: ParsableManimColor | list[ParsableManimColor] + stroke_opacity: float + stroke_width: float + draw_stroke_behind_fill: bool + background_image_file: str + long_lines: bool + joint_type: LineJointType + flat_stroke: bool + + class OpenGLVMobject(OpenGLMobject): """A vectorized mobject.""" @@ -63,6 +81,8 @@ class OpenGLVMobject(OpenGLMobject): make_smooth_after_applying_functions: bool = False tolerance_for_point_equality: float = 1e-8 + # WARNING: before updating the __init__ update the VMobjectKwargs TypedDict + # so users can get autocomplete def __init__( self, color: ParsableManimColor | list[ParsableManimColor] | None = None, @@ -76,7 +96,7 @@ def __init__( long_lines: bool = False, joint_type: LineJointType = LineJointType.AUTO, flat_stroke: bool = False, - **kwargs, + **kwargs: Unpack[MobjectKwargs], ): super().__init__(**kwargs) if fill_color is None: @@ -198,7 +218,7 @@ def construct(self): if color is not None: self.fill_color = listify(ManimColor.parse(color)) if opacity is not None: - self.fill_color = [c.set_opacity(opacity) for c in self.fill_color] + self.fill_color = [c.opacity(opacity) for c in self.fill_color] return self def set_stroke( @@ -213,7 +233,7 @@ def set_stroke( if color is not None: mob.stroke_color = listify(ManimColor.parse(color)) if opacity is not None: - mob.stroke_color = [c.set_opacity(opacity) for c in mob.stroke_color] + mob.stroke_color = [c.opacity(opacity) for c in mob.stroke_color] if width is not None: mob.stroke_width = listify(width) @@ -319,7 +339,7 @@ def get_stroke_widths(self) -> np.ndarray: # TODO, it's weird for these to return the first of various lists # rather than the full information - def get_fill_color(self) -> str: + def get_fill_color(self) -> ManimColor: """ If there are multiple colors (for gradient) this returns the first one @@ -333,7 +353,7 @@ def get_fill_opacity(self) -> float: """ return self.get_fill_opacities()[0] - def get_stroke_color(self) -> str: + def get_stroke_color(self) -> ManimColor: return self.get_stroke_colors()[0] def get_stroke_width(self) -> float | np.ndarray: @@ -342,7 +362,7 @@ def get_stroke_width(self) -> float | np.ndarray: def get_stroke_opacity(self) -> float: return self.get_stroke_opacities()[0] - def get_color(self) -> str: + def get_color(self) -> ManimColor: if self.has_fill(): return self.get_fill_color() return self.get_stroke_color() @@ -1655,25 +1675,26 @@ def __init__( **kwargs, ): super().__init__(**kwargs) + self.dashed_ratio = dashed_ratio + self.num_dashes = num_dashes + r = self.dashed_ratio + n = self.num_dashes if num_dashes > 0: - # End points of the unit interval for division - alphas = np.linspace(0, 1, num_dashes + 1) - - # This determines the length of each "dash" - full_d_alpha = 1.0 / num_dashes - partial_d_alpha = full_d_alpha * positive_space_ratio - - # Rescale so that the last point of vmobject will - # be the end of the last dash - alphas /= 1 - full_d_alpha + partial_d_alpha + # Assuming total length is 1 + dash_len = r / n + void_len = (1 - r) / n if vmobject.is_closed() else (1 - r) / (n - 1) self.add( - *[ - vmobject.get_subcurve(alpha, alpha + partial_d_alpha) - for alpha in alphas[:-1] - ] + *( + vmobject.get_subcurve( + i * (dash_len + void_len), + i * (dash_len + void_len) + dash_len, + ) + for i in range(n) + ) ) + # Family is already taken care of by get_subcurve # implementation self.match_style(vmobject, recurse=False) @@ -1684,7 +1705,7 @@ def __init__( self, vmobject: OpenGLVMobject, n_layers: int = 5, - color_bounds: tuple[Color, Color] = (GREY_C, GREY_E), + color_bounds: tuple[ManimColor, ManimColor] = (GREY_C, GREY_E), max_stroke_addition: float = 5.0, ): outline = vmobject.replicate(n_layers) diff --git a/manim/mobject/opengl/shader.py b/manim/mobject/opengl/shader.py index 85b9dad14a..a098ed30ca 100644 --- a/manim/mobject/opengl/shader.py +++ b/manim/mobject/opengl/shader.py @@ -1,5 +1,6 @@ from __future__ import annotations +import contextlib import inspect import re import textwrap @@ -382,10 +383,8 @@ def __init__( shader_program_cache[self.name] = self.shader_program def set_uniform(self, name, value): - try: + with contextlib.suppress(KeyError): self.shader_program[name] = value - except KeyError: - pass class FullScreenQuad(Mesh): diff --git a/manim/mobject/svg/brace.py b/manim/mobject/svg/brace.py index 600e841f65..ae7ee6a2ac 100644 --- a/manim/mobject/svg/brace.py +++ b/manim/mobject/svg/brace.py @@ -249,7 +249,7 @@ def __init__( self.brace = Brace(obj, brace_direction, buff, **brace_config) if isinstance(text, (tuple, list)): - self.label = self.label_constructor(font_size=font_size, *text, **kwargs) + self.label = self.label_constructor(*text, font_size=font_size, **kwargs) else: self.label = self.label_constructor(str(text), font_size=font_size) diff --git a/manim/mobject/svg/svg_mobject.py b/manim/mobject/svg/svg_mobject.py index 1d4e7bff61..116de30b1f 100644 --- a/manim/mobject/svg/svg_mobject.py +++ b/manim/mobject/svg/svg_mobject.py @@ -98,27 +98,13 @@ def __init__( should_center: bool = True, height: float | None = 2, width: float | None = None, - color: str | None = None, opacity: float | None = None, - fill_color: str | None = None, - fill_opacity: float | None = None, - stroke_color: str | None = None, - stroke_opacity: float | None = None, - stroke_width: float | None = None, svg_default: dict | None = None, path_string_config: dict | None = None, use_svg_cache: bool = True, **kwargs, ): - super().__init__( - color=color, - stroke_color=stroke_color, - stroke_opacity=stroke_opacity, - stroke_width=stroke_width, - fill_opacity=fill_opacity, - fill_color=fill_color, - **kwargs, - ) + super().__init__(**kwargs) # process keyword arguments self.file_name = Path(file_name) if file_name is not None else None @@ -139,9 +125,7 @@ def __init__( } self.svg_default = svg_default - if path_string_config is None: - path_string_config = {} - self.path_string_config = path_string_config + self.path_string_config = path_string_config or {} self.init_svg_mobject(use_svg_cache=use_svg_cache) @@ -263,7 +247,8 @@ def get_mobjects_from(self, svg: se.SVG) -> list[VMobject]: """ result = [] for shape in svg.elements(): - if isinstance(shape, se.Group): + # can we combine the two continue cases into one? + if isinstance(shape, se.Group): # noqa: SIM114 continue elif isinstance(shape, se.Path): mob = self.path_to_mobject(shape) diff --git a/manim/mobject/text/numbers.py b/manim/mobject/text/numbers.py index 6a0eb45a82..05854dfb43 100644 --- a/manim/mobject/text/numbers.py +++ b/manim/mobject/text/numbers.py @@ -210,10 +210,7 @@ def _get_num_string(self, number): rounded_num = np.round(number, self.num_decimal_places) if num_string.startswith("-") and rounded_num == 0: - if self.include_sign: - num_string = "+" + num_string[1:] - else: - num_string = num_string[1:] + num_string = "+" + num_string[1:] if self.include_sign else num_string[1:] return num_string diff --git a/manim/mobject/text/text_mobject.py b/manim/mobject/text/text_mobject.py index 456a081cc9..dcb6de392e 100644 --- a/manim/mobject/text/text_mobject.py +++ b/manim/mobject/text/text_mobject.py @@ -69,8 +69,9 @@ def construct(self): from manim import config, logger from manim.constants import * from manim.mobject.geometry.arc import Dot +from manim.mobject.opengl.opengl_vectorized_mobject import OpenGLVMobject from manim.mobject.svg.svg_mobject import SVGMobject -from manim.mobject.types.vectorized_mobject import VGroup, VMobject +from manim.mobject.types.vectorized_mobject import VGroup from manim.utils.color import ManimColor, ParsableManimColor, color_gradient from manim.utils.deprecation import deprecated @@ -508,7 +509,7 @@ def __init__( else: self.line_spacing = self._font_size + self._font_size * self.line_spacing - color: ManimColor = ManimColor(color) if color else VMobject().color + color: ManimColor = ManimColor(color) if color else OpenGLVMobject().color file_name = self._text2svg(color.to_hex()) PangoUtils.remove_last_M(file_name) super().__init__( @@ -1452,7 +1453,9 @@ def _extract_gradient_tags(self): "end_offset": end_offset, }, ) - self.text = re.sub("]+>(.+?)", r"\1", self.text, 0, re.S) + self.text = re.sub( + "]+>(.+?)", r"\1", self.text, count=0, flags=re.S + ) return gradientmap def _parse_color(self, col): @@ -1494,7 +1497,9 @@ def _extract_color_tags(self): "end_offset": end_offset, }, ) - self.text = re.sub("]+>(.+?)", r"\1", self.text, 0, re.S) + self.text = re.sub( + "]+>(.+?)", r"\1", self.text, count=0, flags=re.S + ) return colormap def __repr__(self): diff --git a/manim/mobject/three_d/three_dimensions.py b/manim/mobject/three_d/three_dimensions.py index bdd26e9ffb..e8fa3cac6e 100644 --- a/manim/mobject/three_d/three_dimensions.py +++ b/manim/mobject/three_d/three_dimensions.py @@ -664,10 +664,7 @@ def _rotate_to_direction(self) -> None: x, y, z = self.direction r = np.sqrt(x**2 + y**2 + z**2) - if r > 0: - theta = np.arccos(z / r) - else: - theta = 0 + theta = np.arccos(z / r) if r > 0 else 0 if x == 0: if y == 0: # along the z axis @@ -834,10 +831,7 @@ def _rotate_to_direction(self) -> None: x, y, z = self.direction r = np.sqrt(x**2 + y**2 + z**2) - if r > 0: - theta = np.arccos(z / r) - else: - theta = 0 + theta = np.arccos(z / r) if r > 0 else 0 if x == 0: if y == 0: # along the z axis diff --git a/manim/mobject/types/point_cloud_mobject.py b/manim/mobject/types/point_cloud_mobject.py index 04da9e75b6..66076e090e 100644 --- a/manim/mobject/types/point_cloud_mobject.py +++ b/manim/mobject/types/point_cloud_mobject.py @@ -148,7 +148,7 @@ def thin_out(self, factor=5): for mob in self.family_members_with_points(): num_points = self.get_num_points() mob.apply_over_attr_arrays( - lambda arr: arr[np.arange(0, num_points, factor)], + lambda arr, n=num_points: arr[np.arange(0, n, factor)], ) return self @@ -158,7 +158,7 @@ def sort_points(self, function=lambda p: p[0]): """ for mob in self.family_members_with_points(): indices = np.argsort(np.apply_along_axis(function, 1, mob.points)) - mob.apply_over_attr_arrays(lambda arr: arr[indices]) + mob.apply_over_attr_arrays(lambda arr, idx=indices: arr[idx]) return self def fade_to(self, color, alpha, family=True): diff --git a/manim/mobject/types/vectorized_mobject.py b/manim/mobject/types/vectorized_mobject.py index 4659059fb2..1fff3f143d 100644 --- a/manim/mobject/types/vectorized_mobject.py +++ b/manim/mobject/types/vectorized_mobject.py @@ -198,13 +198,11 @@ def get_mobject_type_class() -> type[VMobject]: def init_colors(self, propagate_colors: bool = True) -> Self: self.set_fill( color=self.fill_color, - opacity=self.fill_opacity, family=propagate_colors, ) self.set_stroke( color=self.stroke_color, width=self.stroke_width, - opacity=self.stroke_opacity, family=propagate_colors, ) self.set_background_stroke( @@ -1216,9 +1214,7 @@ def consider_points_equals_2d(self, p0: Point2D, p1: Point2D) -> bool: atol = self.tolerance_for_point_equality if abs(p0[0] - p1[0]) > atol + rtol * abs(p1[0]): return False - if abs(p0[1] - p1[1]) > atol + rtol * abs(p1[1]): - return False - return True + return abs(p0[1] - p1[1]) <= atol + rtol * abs(p1[1]) # Information about line def get_cubic_bezier_tuples_from_points( @@ -2726,15 +2722,12 @@ def __init__( if vmobject.is_closed(): void_len = (1 - r) / n else: - if n == 1: - void_len = 1 - r - else: - void_len = (1 - r) / (n - 1) + void_len = 1 - r if n == 1 else (1 - r) / (n - 1) period = dash_len + void_len phase_shift = (dash_offset % 1) * period - if vmobject.is_closed(): + if vmobject.is_closed(): # noqa: SIM108 # closed curves have equal amount of dashes and voids pattern_len = 1 else: diff --git a/manim/plugins/__init__.py b/manim/plugins/__init__.py index 50d2aec2b6..35f4b1a59e 100644 --- a/manim/plugins/__init__.py +++ b/manim/plugins/__init__.py @@ -2,12 +2,9 @@ from manim import config, logger -from .plugin_config import Hooks, plugins from .plugins_flags import get_plugins, list_plugins __all__ = [ - "plugins", - "Hooks", "list_plugins", "get_plugins", ] diff --git a/manim/plugins/plugin_config.py b/manim/plugins/plugin_config.py deleted file mode 100644 index 54a2807730..0000000000 --- a/manim/plugins/plugin_config.py +++ /dev/null @@ -1,98 +0,0 @@ -from __future__ import annotations - -from collections.abc import Callable -from enum import Enum -from typing import TYPE_CHECKING - -from pydantic import BaseModel - -from manim.event_handler.window import WindowABC -from manim.renderer.opengl_renderer import OpenGLRenderer -from manim.renderer.opengl_renderer_window import Window -from manim.renderer.renderer import RendererProtocol - -if TYPE_CHECKING: - from typing_extensions import TypeAlias - - from manim.manager import Manager - - HookFunction: TypeAlias = Callable[[Manager], object] - - -__all__ = ( - "plugins", - "Hooks", -) - - -class Hooks(Enum): - POST_CONSTRUCT = "post_construct" - - -class PluginConfig(BaseModel): - """Plugin abilities that should be customizable by the user. - - Parameters - ---------- - renderer : The renderer class to use for rendering scenes. - window: The window class to use for displaying the scene. - - Examples - -------- - - .. code-block:: pycon - - >>> from manim import plugins - >>> plugins.renderer.__name__ - 'OpenGLRenderer' - >>> class MyRenderer(OpenGLRenderer): - ... '''My custom renderer - ... - ... All this actually has to do is implement - ... the RendererProtocol. - ... ''' - >>> plugins.renderer = MyRenderer - >>> plugins.renderer.__name__ - 'MyRenderer' - >>> plugins.renderer = 3 - Traceback (most recent call last): - ... - pydantic_core._pydantic_core.ValidationError: 1 validation error for PluginConfig - renderer - Input should be a subclass of RendererProtocol [type=is_subclass_of, input_value=3, input_type=int] - For further information visit https://errors.pydantic.dev/2.8/v/is_subclass_of - """ - - class Config: - # runtime check Protocols (must be runtime_checkable Protocols) - allow_arbitrary_types = True - # validate setting attributes - validate_assignment = True - extra = "forbid" - - renderer: type[RendererProtocol] - window: type[WindowABC] - - # not included in pydantic because Manager is undefined - # due to circular imports and __future__.annotations - # instead we do validation manually via :meth:`.register` - _hooks: dict[Hooks, list[HookFunction]] = {hook: [] for hook in Hooks} - - @property - def hooks(self) -> dict[Hooks, list[HookFunction]]: - return self._hooks - - def register(self, hooks: dict[Hooks, list[HookFunction]]) -> None: - """Register hooks to run at specific points in the program.""" - - for hook, functions in hooks.items(): - if not all(callable(func) for func in functions): - raise ValueError("All hooks must be callables!") - if not isinstance(hook, Hooks): - raise ValueError( - f"Unknown hook type {hook}, must be an instance of enum {Hooks}" - ) - self._hooks[hook].extend(functions) - - -plugins = PluginConfig(renderer=OpenGLRenderer, window=Window) diff --git a/manim/renderer/opengl_renderer.py b/manim/renderer/opengl_renderer.py index 6ed5601f7b..4d256738a3 100644 --- a/manim/renderer/opengl_renderer.py +++ b/manim/renderer/opengl_renderer.py @@ -295,7 +295,7 @@ def __init__( self.ctx, "render_texture" ) - def use_window(self): + def use_window(self) -> None: self.output_fbo.release() self.output_fbo = self.ctx.detect_framebuffer() @@ -369,10 +369,10 @@ def post_render(self): ) frame_data["uv"] = np.array([[0, 0], [0, 1], [1, 0], [1, 0], [0, 1], [1, 1]]) vbo = self.ctx.buffer(frame_data.tobytes()) - format = gl.detect_format(self.render_texture_program, frame_data.dtype.names) + format_ = gl.detect_format(self.render_texture_program, frame_data.dtype.names) vao = self.ctx.vertex_array( program=self.render_texture_program, - content=[(vbo, format, *frame_data.dtype.names)], # type: ignore + content=[(vbo, format_, *frame_data.dtype.names)], # type: ignore ) self.ctx.copy_framebuffer(self.render_target_texture_fbo, self.color_buffer_fbo) self.render_target_texture.use(0) diff --git a/manim/renderer/opengl_renderer_window.py b/manim/renderer/opengl_renderer_window.py index 7a49ccf607..32cc0bdcf9 100644 --- a/manim/renderer/opengl_renderer_window.py +++ b/manim/renderer/opengl_renderer_window.py @@ -1,20 +1,24 @@ from __future__ import annotations +from typing import TYPE_CHECKING, TypeVar + import moderngl_window as mglw -import numpy as np from moderngl_window.context.pyglet.window import Window as PygletWindow from moderngl_window.timers.clock import Timer from screeninfo import get_monitors from manim import __version__, config -from manim.event_handler.window import WindowABC +from manim.event_handler.window import WindowProtocol -__all__ = ["Window"] +if TYPE_CHECKING: + from typing_extensions import TypeGuard + +T = TypeVar("T") __all__ = ["Window"] -class Window(PygletWindow, WindowABC): +class Window(PygletWindow, WindowProtocol): name = "Manim Community" fullscreen: bool = False resizable: bool = False @@ -22,7 +26,7 @@ class Window(PygletWindow, WindowABC): vsync: bool = True cursor: bool = True - def __init__(self, size=config.window_size): + def __init__(self, size=config.window_size) -> None: # TODO: remove size argument from window init, # move size computation below to config @@ -65,15 +69,20 @@ def __init__(self, size=config.window_size): self.position = initial_position def find_initial_position(self, size: tuple[int, int]) -> tuple[int, int]: - custom_position = config.window_position + custom_position = config.window_position.replace(" ", "").upper() monitors = get_monitors() mon_index = config.window_monitor monitor = monitors[min(mon_index, len(monitors) - 1)] window_width, window_height = size + # Position might be specified with a string of the form # x,y for integers x and y if "," in custom_position: - return tuple(map(int, custom_position.split(","))) + pos = tuple(int(p) for p in custom_position.split(",")) + if tuple_len_2(pos): + return pos + else: + raise ValueError("Expected position in the form x,y") # Alternatively, it might be specified with a string like # UR, OO, DL, etc. specifying what corner it should go to @@ -85,23 +94,6 @@ def find_initial_position(self, size: tuple[int, int]) -> tuple[int, int]: -monitor.y + char_to_n[custom_position[0]] * height_diff // 2, ) - # Delegate event handling to scene - def pixel_coords_to_space_coords( - self, px: int, py: int, relative: bool = False - ) -> np.ndarray: - pw, ph = self.size - # TODO - fw, fh = ( - config.frame_width, - config.frame_height, - ) or self.scene.camera.get_frame_shape() - fc = ( - config.frame_width, - config.frame_height, - ) or self.scene.camera.get_frame_center() - if relative: - return np.array([px / pw, py / ph, 0]) - else: - return np.array( - [fc[0] + px * fw / pw - fw / 2, fc[1] + py * fh / ph - fh / 2, 0] - ) + +def tuple_len_2(pos: tuple[T, ...]) -> TypeGuard[tuple[T, T]]: + return len(pos) == 2 diff --git a/manim/renderer/renderer.py b/manim/renderer/renderer.py index 537145c40d..f6d8667dcf 100644 --- a/manim/renderer/renderer.py +++ b/manim/renderer/renderer.py @@ -4,14 +4,12 @@ from typing import TYPE_CHECKING, Protocol, runtime_checkable from manim._config import logger -from manim.mobject.opengl.opengl_mobject import OpenGLMobject from manim.mobject.opengl.opengl_vectorized_mobject import OpenGLVMobject from manim.mobject.types.image_mobject import ImageMobject if TYPE_CHECKING: - from collections.abc import Iterable - from manim.camera.camera import Camera + from manim.scene.scene import SceneState from manim.typing import PixelArray @@ -32,9 +30,9 @@ def __init__(self): (ImageMobject, self.render_image), ] - def render(self, camera: Camera, renderables: Iterable[OpenGLMobject]) -> None: - self.pre_render(camera) - for mob in renderables: + def render(self, state: SceneState) -> None: + self.pre_render(state.camera) + for mob in state.mobjects: for type_, render_func in self.capabilities: if isinstance(mob, type_): render_func(mob) @@ -68,7 +66,7 @@ def render_image(self, mob: ImageMobject): class RendererProtocol(Protocol): """The Protocol a renderer must implement to be used in :class:`.Manager`.""" - def render(self, camera: Camera, renderables: Iterable[OpenGLMobject]) -> None: + def render(self, state: SceneState) -> None: """Render a group of Mobjects""" ... diff --git a/manim/scene/scene.py b/manim/scene/scene.py index cfba6b4727..756c7e120b 100644 --- a/manim/scene/scene.py +++ b/manim/scene/scene.py @@ -1,5 +1,6 @@ from __future__ import annotations +import inspect import random from collections import OrderedDict, deque from typing import TYPE_CHECKING @@ -17,11 +18,12 @@ from manim.mobject.mobject import Group, Point, _AnimationBuilder from manim.mobject.opengl.opengl_mobject import OpenGLMobject from manim.mobject.types.vectorized_mobject import VGroup, VMobject +from manim.scene.sections import SceneSection from manim.utils.iterables import list_difference_update if TYPE_CHECKING: from collections.abc import Iterable, Reversible, Sequence - from typing import Any, Callable + from typing import Any, Callable, Self from manim.animation.protocol import AnimationProtocol from manim.manager import Manager @@ -72,7 +74,9 @@ def construct(self): embed_exception_mode: str = "" embed_error_sound: bool = False - def __init__(self, manager: Manager): + sections_api: bool = False + + def __init__(self, manager: Manager[Self]): # Core state of the scene self.camera: Camera = Camera() self.manager = manager @@ -131,13 +135,32 @@ def construct(self) -> None: The entrypoint to animations in Manim. Should be overridden in the subclass to produce animations """ - raise RuntimeError("Could not find the construct method, did you misspell it?") + raise RuntimeError( + "Could not find the construct method, did you misspell the name?" + ) def tear_down(self) -> None: """ This method is used to clean up scenes """ + def find_sections(self) -> list[SceneSection]: + """Find all sections in a :class:`.Scene`""" + + sections: list[SceneSection] = [ + bound + for _, bound in inspect.getmembers( + self, predicate=lambda x: isinstance(x, SceneSection) + ) + ] + # we can't care about the actual value of the order + # because that would break files with multiple scenes that have sections + sections.sort(key=lambda x: x.order) + # turn them into bound methods + for section in sections: + section.func = section.func.__get__(self, type(self)) + return sections + # Only these methods should touch the camera # Related to updating @@ -544,7 +567,9 @@ def on_close(self) -> None: class SceneState: - def __init__(self, scene: Scene, ignore: list[OpenGLMobject] | None = None) -> None: + def __init__( + self, scene: Scene, ignore: Iterable[OpenGLMobject] | None = None + ) -> None: self.time = scene.time self.num_plays = scene.num_plays self.camera = scene.camera.copy() diff --git a/manim/scene/sections.py b/manim/scene/sections.py new file mode 100644 index 0000000000..14308eb36c --- /dev/null +++ b/manim/scene/sections.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import ClassVar, Generic, ParamSpec, TypeVar, final, overload + +from typing_extensions import TypedDict, Unpack + +from manim.file_writer.sections import DefaultSectionType + +__all__ = ["section"] + + +P = ParamSpec("P") +T = TypeVar("T") + + +class SceneSectionData(TypedDict, total=False): + """(Public) data for a :class:`.SceneSection` in a :class:`.Scene`.""" + + skip: bool + type_: str + name: str + order: int + + +# mark as final because _cls_instance_count doesn't +# work with inheritance +@final +class SceneSection(Generic[P, T]): + """A section in a :class:`.Scene`. + + It holds data about each subsection, and keeps track of the order + of the sections via :attr:`~SceneSection.order`. + + .. warning:: + + :attr:`~SceneSection.func` is effectively a function - it is not + bound to the scene, and thus must be called with the first argument + as an instance of :class:`.Scene`. + """ + + _cls_instance_count: ClassVar[int] = 0 + """How many times the class has been instantiated. + + This is also used for ordering sections, because of the order + decorators are called in a class. + """ + + def __init__( + self, func: Callable[P, T], **kwargs: Unpack[SceneSectionData] + ) -> None: + self.func = func + + self.skip = False + self.type_ = DefaultSectionType.NORMAL + self.name = func.__name__ + + # update the order for finding section orders + self.order = self._cls_instance_count + self.__class__._cls_instance_count += 1 + + # we assume that users have a typechecker on + # and aren't doing any weird stuff + self.__dict__.update(kwargs) + + def __str__(self) -> str: + name = self.name + skip = self.skip + section_type = self.type_ + order = self.order + return f"{self.__class__.__name__}({name=}, {order=}, {skip=}, {section_type=})" + + def __repr__(self) -> str: + # return a slightly more verbose repr + s = str(self).removesuffix(")") + func = self.func + return f"{s}, {func=})" + + def __call__(self, *args: P.args, **kwargs: P.kwargs) -> T: + return self.func(*args, **kwargs) + + +@overload +def section( + func: Callable[P, T], + **kwargs: Unpack[SceneSectionData], +) -> SceneSection[P, T]: ... + + +@overload +def section( + func: None = None, + **kwargs: Unpack[SceneSectionData], +) -> Callable[[Callable[P, T]], SceneSection[P, T]]: ... + + +def section( + func: Callable[P, T] | None = None, **kwargs: Unpack[SceneSectionData] +) -> SceneSection[P, T] | Callable[[Callable[P, T]], SceneSection[P, T]]: + r"""Decorator to create a section in the scene. + + Example + ------- + + .. code-block:: python + + class MyScene(Scene): + sections_api = True + + @section + def first_section(self): + pass + + @section(skip=True, name="Introduce Bob") + def second_section(self): + pass + + Parameters + ---------- + func : Callable + The subsection. + skip : bool, optional + Whether to skip the section, by default False + type\_ : str, optional + The type of the section, by default :attr:`.DefaultSectionType.NORMAL` + name : str, optional + The name of the section, by default the name of the method. + """ + if func is not None: + return SceneSection(func, **kwargs) + + def wrapper(func: Callable[P, T]) -> SceneSection[P, T]: + return SceneSection(func, **kwargs) + + return wrapper diff --git a/manim/scene/three_d_scene.py b/manim/scene/three_d_scene.py index 919f3cb2c6..8abbdb7cd6 100644 --- a/manim/scene/three_d_scene.py +++ b/manim/scene/three_d_scene.py @@ -135,8 +135,8 @@ def begin_ambient_camera_rotation(self, rate: float = 0.02, about: str = "theta" } cam.add_updater(lambda m, dt: methods[about](rate * dt)) self.add(self.camera) - except Exception: - raise ValueError("Invalid ambient rotation angle.") + except Exception as e: + raise ValueError("Invalid ambient rotation angle.") from e def stop_ambient_camera_rotation(self, about="theta"): """ @@ -155,8 +155,8 @@ def stop_ambient_camera_rotation(self, about="theta"): self.remove(x) elif config.renderer == RendererType.OPENGL: self.camera.clear_updaters() - except Exception: - raise ValueError("Invalid ambient rotation angle.") + except Exception as e: + raise ValueError("Invalid ambient rotation angle.") from e def begin_3dillusion_camera_rotation( self, diff --git a/manim/typing.py b/manim/typing.py index ac31d9d456..03da1bdaaa 100644 --- a/manim/typing.py +++ b/manim/typing.py @@ -41,6 +41,10 @@ "RGBA_Tuple_Int", "HSV_Array_Float", "HSV_Tuple_Float", + "HSL_Array_Float", + "HSL_Tuple_Float", + "HSVA_Array_Float", + "HSVA_Tuple_Float", "ManimColorInternal", "PointDType", "InternalPoint2D", @@ -215,6 +219,46 @@ Brightness) in the represented color. """ +HSVA_Array_Float: TypeAlias = RGBA_Array_Float +"""``shape: (4,)`` + +A :class:`numpy.ndarray` of 4 floats between 0 and 1, representing a +color in HSVA (or HSBA) format. + +Its components describe, in order, the Hue, Saturation and Value (or +Brightness) in the represented color. +""" + +HSVA_Tuple_Float: TypeAlias = RGBA_Tuple_Float +"""``shape: (4,)`` + +A tuple of 4 floats between 0 and 1, representing a color in HSVA (or +HSBA) format. + +Its components describe, in order, the Hue, Saturation and Value (or +Brightness) in the represented color. +""" + +HSL_Array_Float: TypeAlias = RGB_Array_Float +"""``shape: (3,)`` + +A :class:`numpy.ndarray` of 3 floats between 0 and 1, representing a +color in HSL format. + +Its components describe, in order, the Hue, Saturation and Lightness +in the represented color. +""" + +HSL_Tuple_Float: TypeAlias = RGB_Tuple_Float +"""``shape: (3,)`` + +A :class:`numpy.ndarray` of 3 floats between 0 and 1, representing a +color in HSL format. + +Its components describe, in order, the Hue, Saturation and Lightness +in the represented color. +""" + ManimColorInternal: TypeAlias = RGBA_Array_Float """``shape: (4,)`` diff --git a/manim/utils/bezier.py b/manim/utils/bezier.py index befaca9324..4552021c9d 100644 --- a/manim/utils/bezier.py +++ b/manim/utils/bezier.py @@ -1855,9 +1855,7 @@ def is_closed(points: Point3D_Array) -> bool: return False if abs(end[1] - start[1]) > tolerance[1]: return False - if abs(end[2] - start[2]) > tolerance[2]: - return False - return True + return abs(end[2] - start[2]) <= tolerance[2] def proportions_along_bezier_curve_for_point( diff --git a/manim/utils/color/core.py b/manim/utils/color/core.py index 013736e573..5611bbfb3c 100644 --- a/manim/utils/color/core.py +++ b/manim/utils/color/core.py @@ -18,6 +18,27 @@ The colors of type "C" have an alias equal to the colorname without a letter, e.g. GREEN = GREEN_C + +=================== +Custom Color Spaces +=================== + +Hello dear visitor, you seem to be interested in implementing a custom color class for a color space we don't currently support. + +The current system is using a few indirections for ensuring a consistent behavior with all other color types in manim. + +To implement a custom color space you must subclass :class:`ManimColor` and implement three important functions + +:attr:`~.ManimColor._internal_value` is an ``@property`` implemented on :class:`ManimColor` with the goal of keeping a consistent internal representation that can be referenced by other functions in :class:`ManimColor`. +The getter should always return a value in the format of ``[r,g,b,a]`` as a numpy array which is in accordance with the type :class:`.ManimColorInternal`. +The setter should always accept a value in the format ``[r,g,b,a]`` which can be converted to whatever attributes you need. +This property acts as a proxy to whatever representation you need in your class. + +:attr:`~ManimColor._internal_space` this is a readonly ``@property`` implemented on :class:`ManimColor` with the goal of a useful representation that can be used by operators and interpolation and color transform functions. +The only constraints on this value are that it needs to be a numpy array and the last value must be the opacity in a range ``0.0`` to ``1.0``. +Additionally your ``__init__`` must support this format as initialization value without additional parameters to ensure correct functionality of all other methods in :class:`ManimColor`. + +:func:`~ManimColor._from_internal` is a ``@classmethod`` that converts an ``[r,g,b,a]`` value into suitable parameters for your ``__init__`` method and calls the cls parameter. """ from __future__ import annotations @@ -32,13 +53,18 @@ import numpy as np import numpy.typing as npt -from typing_extensions import Self, TypeAlias +from typing_extensions import Self, TypeAlias, TypeGuard, override from manim.typing import ( + HSL_Array_Float, + HSL_Tuple_Float, HSV_Array_Float, HSV_Tuple_Float, + HSVA_Array_Float, + HSVA_Tuple_Float, ManimColorDType, ManimColorInternal, + ManimFloat, RGB_Array_Float, RGB_Array_Int, RGB_Tuple_Float, @@ -132,7 +158,7 @@ def __init__( # This is not expected to be called on module initialization time # It can be horribly slow to convert a string to a color because # it has to access the dictionary of colors and find the right color - self._internal_value = ManimColor._internal_from_string(value) + self._internal_value = ManimColor._internal_from_string(value, alpha) elif isinstance(value, (list, tuple, np.ndarray)): length = len(value) if all(isinstance(x, float) for x in value): @@ -147,8 +173,8 @@ def __init__( else: if length == 3: self._internal_value = ManimColor._internal_from_int_rgb( - value, - alpha, # type: ignore + value, # type: ignore + alpha, ) elif length == 4: self._internal_value = ManimColor._internal_from_int_rgba(value) # type: ignore @@ -160,7 +186,6 @@ def __init__( result = re_hex.search(value.get_hex()) if result is None: raise ValueError(f"Failed to parse a color from {value}") - self._internal_value = ManimColor._internal_from_hex_string( result.group(), alpha ) @@ -172,6 +197,14 @@ def __init__( f"list[float, float, float, float], not {type(value)}" ) + @property + def _internal_space(self) -> npt.NDArray[ManimFloat]: + """ + This is a readonly property which is a custom representation for color space operations. + It is used for operators and can be used when implementing a custom color space. + """ + return self._internal_value + @property def _internal_value(self) -> ManimColorInternal: """Returns the internal value of the current Manim color [r,g,b,a] float array @@ -203,6 +236,14 @@ def _internal_value(self, value: ManimColorInternal) -> None: raise TypeError("Array must have 4 values exactly") self.__value: ManimColorInternal = value + @classmethod + def _construct_from_space(cls, _space) -> Self: + """ + This function is used as a proxy for constructing a color with an internal value, + this can be used by subclasses to hook into the construction of new objects using the internal value format + """ + return cls(_space) + @staticmethod def _internal_from_integer(value: int, alpha: float) -> ManimColorInternal: return np.asarray( @@ -215,9 +256,8 @@ def _internal_from_integer(value: int, alpha: float) -> ManimColorInternal: dtype=ManimColorDType, ) - # TODO: Maybe make 8 nibble hex also convertible ? @staticmethod - def _internal_from_hex_string(hex: str, alpha: float) -> ManimColorInternal: + def _internal_from_hex_string(hex_: str, alpha: float) -> ManimColorInternal: """Internal function for converting a hex string into the internal representation of a ManimColor. .. warning:: @@ -231,16 +271,22 @@ def _internal_from_hex_string(hex: str, alpha: float) -> ManimColorInternal: hex : str hex string to be parsed alpha : float - alpha value used for the color + alpha value used for the color if the color is only 3 bytes long, if the color is 4 bytes long the parameter will not be used Returns ------- ManimColorInternal Internal color representation """ - if len(hex) == 6: - hex += "00" - tmp = int(hex, 16) + if len(hex_) == 6: + hex_ += "FF" + elif len(hex_) == 8: + alpha = (int(hex_, 16) & 0xFF) / 255 + else: + raise ValueError( + "Hex colors must be specified with either 0x or # as prefix and contain 6 or 8 hexadecimal numbers" + ) + tmp = int(hex_, 16) return np.asarray( ( ((tmp >> 24) & 0xFF) / 255, @@ -340,7 +386,7 @@ def _internal_from_rgba(rgba: RGBA_Tuple_Float) -> ManimColorInternal: return np.asarray(rgba, dtype=ManimColorDType) @staticmethod - def _internal_from_string(name: str) -> ManimColorInternal: + def _internal_from_string(name: str, alpha: float) -> ManimColorInternal: """Internal function for converting a string into the internal representation of a ManimColor. This is not used for hex strings, please refer to :meth:`_internal_from_hex` for this functionality. @@ -364,10 +410,9 @@ def _internal_from_string(name: str) -> ManimColorInternal: """ from . import _all_color_dict - upper_name = name.upper() - - if upper_name in _all_color_dict: - return _all_color_dict[upper_name]._internal_value + if tmp := _all_color_dict.get(name.upper()): + tmp._internal_value[3] = alpha + return tmp._internal_value.copy() else: raise ValueError(f"Color {name} not found") @@ -382,9 +427,8 @@ def to_integer(self) -> int: .. warning:: This will return only the rgb part of the color """ - return int.from_bytes( - (self._internal_value[:3] * 255).astype(int).tobytes(), "big" - ) + tmp = (self._internal_value[:3] * 255).astype(dtype=np.byte).tobytes() + return int.from_bytes(tmp, "big") def to_rgb(self) -> RGB_Array_Float: """Converts the current ManimColor into a rgb array of floats @@ -498,9 +542,25 @@ def to_hsv(self) -> HSV_Array_Float: HSV_Array_Float A hsv array containing 3 elements of type float ranging from 0 to 1 """ - return colorsys.rgb_to_hsv(*self.to_rgb()) + return np.array(colorsys.rgb_to_hsv(*self.to_rgb())) + + def to_hsl(self) -> HSL_Array_Float: + """Converts the Manim Color to HSL array. + + .. note:: + Be careful this returns an array in the form `[h, s, l]` where the elements are floats. + This might be confusing because rgb can also be an array of floats so you might want to annotate the usage + of this function in your code by typing the variables with :class:`HSL_Array_Float` in order to differentiate + between rgb arrays and hsl arrays + + Returns + ------- + HSL_Array_Float + A hsl array containing 3 elements of type float ranging from 0 to 1 + """ + return np.array(colorsys.rgb_to_hls(*self.to_rgb())) - def invert(self, with_alpha=False) -> ManimColor: + def invert(self, with_alpha=False) -> Self: """Returns an linearly inverted version of the color (no inplace changes) Parameters @@ -517,9 +577,15 @@ def invert(self, with_alpha=False) -> ManimColor: ManimColor The linearly inverted ManimColor """ - return ManimColor(1.0 - self._internal_value, with_alpha) + if with_alpha: + return self._construct_from_space(1.0 - self._internal_space) + else: + alpha = self._internal_space[3] + new = 1.0 - self._internal_space + new[-1] = alpha + return self._construct_from_space(new) - def interpolate(self, other: ManimColor, alpha: float) -> ManimColor: + def interpolate(self, other: ManimColor, alpha: float) -> Self: """Interpolates between the current and the given ManimColor an returns the interpolated color Parameters @@ -536,31 +602,62 @@ def interpolate(self, other: ManimColor, alpha: float) -> ManimColor: ManimColor The interpolated ManimColor """ - return ManimColor( - self._internal_value * (1 - alpha) + other._internal_value * alpha + return self._construct_from_space( + self._internal_space * (1 - alpha) + other._internal_space * alpha ) - def set_opacity(self, opacity: float) -> ManimColor: - """Sets the alpha value for the current ManimColor + @overload + def opacity(self, opacity: float) -> Self: ... + + @overload + def opacity(self, opacity: None = None) -> float: ... + + def opacity(self, opacity: float | None = None) -> float | Self: + """Creates a new ManimColor with the given opacity and the same color value as before, or returns opacity. + + If no opacity is passed it will return the current opacity value. Otherwise, it will set the opacity + to the given value, returning a new ManimColor object. Parameters ---------- opacity : float - + The new opacity value to be used Returns ------- ManimColor - The color with the changed opacity value + The new ManimColor with the same color value but the new opacity + """ + if opacity is None: + return self._internal_space[-1] + tmp = self._internal_space.copy() + tmp[-1] = opacity + return self._construct_from_space(tmp) - Raises - ------ - ValueError - Raises an exception if the opacity value is not in range 0 to 1 + def into(self, classtype: type[ManimColorT]) -> ManimColorT: + """Converts the current color into a different colorspace that is given without changing the _internal_value + + Parameters + ---------- + classtype : type[ManimColorT] + The class that is used for conversion, it must be a subclass of ManimColor which respects the specification + HSV, RGBA, ... + + Returns + ------- + ManimColorT + Color object of the type passed into classtype with the same internal value as previously + """ + return classtype._from_internal(self._internal_value) + + @classmethod + def _from_internal(cls, value: ManimColorInternal) -> Self: + """This function is intended to be overwritten by custom color space classes which are subtypes of ManimColor. + + The function constructs a new object of the given class by transforming the value in the internal format ``[r,g,b,a]`` + into a format which the constructor of the custom class can understand. Look at :class:`.HSV` for an example. """ - if opacity < 0 or opacity > 1: - raise ValueError(f"Alpha value is not in range 0-1 it is {opacity}") - return ManimColor(self._internal_value[:3], opacity) + return cls(value) @classmethod def from_rgb( @@ -587,7 +684,7 @@ def from_rgb( ManimColor Returns the ManimColor object """ - return cls(rgb, alpha) + return cls._from_internal(ManimColor(rgb, alpha)._internal_value) @classmethod def from_rgba( @@ -612,12 +709,12 @@ def from_rgba( return cls(rgba) @classmethod - def from_hex(cls, hex: str, alpha: float = 1.0) -> Self: + def from_hex(cls, hex_str: str, alpha: float = 1.0) -> Self: """Creates a Manim Color from a hex string, prefixes allowed # and 0x Parameters ---------- - hex : str + hex_str : str The hex string to be converted (currently only supports 6 nibbles) alpha : float, optional alpha value to be used for the hex string, by default 1.0 @@ -627,7 +724,7 @@ def from_hex(cls, hex: str, alpha: float = 1.0) -> Self: ManimColor The ManimColor represented by the hex string """ - return cls(hex, alpha) + return cls._from_internal(ManimColor(hex_str, alpha)._internal_value) @classmethod def from_hsv( @@ -648,7 +745,28 @@ def from_hsv( The ManimColor with the corresponding RGB values to the HSV """ rgb = colorsys.hsv_to_rgb(*hsv) - return cls(rgb, alpha) + return cls._from_internal(ManimColor(rgb, alpha)._internal_value) + + @classmethod + def from_hsl( + cls, hsl: HSL_Array_Float | HSL_Tuple_Float, alpha: float = 1.0 + ) -> Self: + """Creates a ManimColor from an HSL Array + + Parameters + ---------- + hsl : HSL_Array_Float | HSL_Tuple_Float + Any 3 Element Iterable containing floats from 0-1 + alpha : float, optional + the alpha value to be used, by default 1.0 + + Returns + ------- + ManimColor + The ManimColor with the corresponding RGB values to the HSL + """ + rgb = colorsys.hls_to_rgb(*hsl) + return cls._from_internal(ManimColor(rgb, alpha)._internal_value) @overload @classmethod @@ -669,7 +787,7 @@ def parse( @classmethod def parse( cls, - color: ParsableManimColor | list[ParsableManimColor] | None, + color: ParsableManimColor | Sequence[ParsableManimColor] | None, alpha: float = 1.0, ) -> Self | list[Self]: """ @@ -687,9 +805,19 @@ def parse( ManimColor Either a list of colors or a singular color depending on the input """ - if isinstance(color, (list, tuple)): - return [cls(c, alpha) for c in color] # type: ignore - return cls(color, alpha) # type: ignore + + def is_sequence(colors) -> TypeGuard[Sequence[ParsableManimColor]]: + return isinstance(colors, (list, tuple)) + + def is_parsable(color) -> TypeGuard[ParsableManimColor]: + return not isinstance(color, (list, tuple)) + + if is_sequence(color): + return [ + cls._from_internal(ManimColor(c, alpha)._internal_value) for c in color + ] + elif is_parsable(color): + return cls._from_internal(ManimColor(color, alpha)._internal_value) @staticmethod def gradient(colors: list[ManimColor], length: int): @@ -710,35 +838,225 @@ def __eq__(self, other: object) -> bool: ) return np.allclose(self._internal_value, other._internal_value) - def __add__(self, other: ManimColor) -> ManimColor: - return ManimColor(self._internal_value + other._internal_value) + def __add__(self, other: int | float | Self) -> Self: + if isinstance(other, (int, float)): + return self._construct_from_space(self._internal_space + other) + else: + return self._construct_from_space( + self._internal_space + other._internal_space + ) + + def __radd__(self, other: int | float | Self) -> Self: + return self + other - def __sub__(self, other: ManimColor) -> ManimColor: - return ManimColor(self._internal_value - other._internal_value) + def __sub__(self, other: int | float | Self) -> Self: + if isinstance(other, (int, float)): + return self._construct_from_space(self._internal_space - other) + else: + return self._construct_from_space( + self._internal_space - other._internal_space + ) - def __mul__(self, other: ManimColor) -> ManimColor: - return ManimColor(self._internal_value * other._internal_value) + def __rsub__(self, other: int | float | Self) -> Self: + return self - other - def __truediv__(self, other: ManimColor) -> ManimColor: - return ManimColor(self._internal_value / other._internal_value) + def __mul__(self, other: int | float | Self) -> Self: + if isinstance(other, (int, float)): + return self._construct_from_space(self._internal_space * other) + else: + return self._construct_from_space( + self._internal_space * other._internal_space + ) - def __floordiv__(self, other: ManimColor) -> ManimColor: - return ManimColor(self._internal_value // other._internal_value) + def __rmul__(self, other: int | float | Self) -> Self: + return self * other - def __mod__(self, other: ManimColor) -> ManimColor: - return ManimColor(self._internal_value % other._internal_value) + def __truediv__(self, other: int | float | Self) -> Self: + if isinstance(other, (int, float)): + return self._construct_from_space(self._internal_space / other) + else: + return self._construct_from_space( + self._internal_space / other._internal_space + ) - def __pow__(self, other: ManimColor) -> ManimColor: - return ManimColor(self._internal_value**other._internal_value) + def __rtruediv__(self, other: int | float | Self) -> Self: + return self / other - def __and__(self, other: ManimColor) -> ManimColor: - return ManimColor(self.to_integer() & other.to_integer()) + def __floordiv__(self, other: int | float | Self) -> Self: + if isinstance(other, (int, float)): + return self._construct_from_space(self._internal_space // other) + else: + return self._construct_from_space( + self._internal_space // other._internal_space + ) - def __or__(self, other: ManimColor) -> ManimColor: - return ManimColor(self.to_integer() | other.to_integer()) + def __rfloordiv__(self, other: int | float | Self) -> Self: + return self // other - def __xor__(self, other: ManimColor) -> ManimColor: - return ManimColor(self.to_integer() ^ other.to_integer()) + def __mod__(self, other: int | float | Self) -> Self: + if isinstance(other, (int, float)): + return self._construct_from_space(self._internal_space % other) + else: + return self._construct_from_space( + self._internal_space % other._internal_space + ) + + def __rmod__(self, other: int | float | Self) -> Self: + return self % other + + def __pow__(self, other: int | float | Self) -> Self: + if isinstance(other, (int, float)): + return self._construct_from_space(self._internal_space**other) + else: + return self._construct_from_space( + self._internal_space**other._internal_space + ) + + def __rpow__(self, other: int | float | Self) -> Self: + return self**other + + def __invert__(self) -> Self: + return self.invert() + + def __int__(self) -> int: + return self.to_integer() + + def __getitem__(self, index: int) -> float: + return self._internal_space[index] + + def __and__(self, other: Self) -> Self: + return self._construct_from_space( + self._internal_from_integer(self.to_integer() & int(other), 1.0) + ) + + def __or__(self, other: Self) -> Self: + return self._construct_from_space( + self._internal_from_integer(self.to_integer() | int(other), 1.0) + ) + + def __xor__(self, other: Self) -> Self: + return self._construct_from_space( + self._internal_from_integer(self.to_integer() ^ int(other), 1.0) + ) + + +RGBA = ManimColor +"""RGBA Color Space""" + + +class HSV(ManimColor): + """HSV Color Space""" + + def __init__( + self, + hsv: HSV_Array_Float | HSV_Tuple_Float | HSVA_Array_Float | HSVA_Tuple_Float, + alpha: float = 1.0, + ) -> None: + super().__init__(None) + if len(hsv) == 3: + self.__hsv: HSVA_Array_Float = np.asarray((*hsv, alpha)) + elif len(hsv) == 4: + self.__hsv: HSVA_Array_Float = np.asarray(hsv) + else: + raise ValueError("HSV Color must be an array of 3 values") + + @classmethod + @override + def _from_internal(cls, value: ManimColorInternal) -> Self: + hsv = colorsys.rgb_to_hsv(*value[:3]) + hsva = [*hsv, value[-1]] + return cls(np.array(hsva)) + + @property + def hue(self) -> float: + return self.__hsv[0] + + @property + def saturation(self) -> float: + return self.__hsv[1] + + @property + def value(self) -> float: + return self.__hsv[2] + + @hue.setter + def hue(self, value: float) -> None: + self.__hsv[0] = value + + @saturation.setter + def saturation(self, value: float) -> None: + self.__hsv[1] = value + + @value.setter + def value(self, value: float) -> None: + self.__hsv[2] = value + + @property + def h(self) -> float: + return self.__hsv[0] + + @property + def s(self) -> float: + return self.__hsv[1] + + @property + def v(self) -> float: + return self.__hsv[2] + + @h.setter + def h(self, value: float) -> None: + self.__hsv[0] = value + + @s.setter + def s(self, value: float) -> None: + self.__hsv[1] = value + + @v.setter + def v(self, value: float) -> None: + self.__hsv[2] = value + + @property + def _internal_space(self) -> npt.NDArray: + return self.__hsv + + @property + def _internal_value(self) -> ManimColorInternal: + """Returns the internal value of the current Manim color [r,g,b,a] float array + + Returns + ------- + ManimColorInternal + internal color representation + """ + return np.array( + [ + *colorsys.hsv_to_rgb(self.__hsv[0], self.__hsv[1], self.__hsv[2]), + self.__alpha, + ], + dtype=ManimColorDType, + ) + + @_internal_value.setter + def _internal_value(self, value: ManimColorInternal) -> None: + """Overwrites the internal color value of the ManimColor object + + Parameters + ---------- + value : ManimColorInternal + The value which will overwrite the current color + + Raises + ------ + TypeError + Raises a TypeError if an invalid array is passed + """ + if not isinstance(value, np.ndarray): + raise TypeError("value must be a numpy array") + if value.shape[0] != 4: + raise TypeError("Array must have 4 values exactly") + tmp = colorsys.rgb_to_hsv(value[0], value[1], value[2]) + self.__hsv = np.array(tmp) + self.__alpha = value[3] ParsableManimColor: TypeAlias = Union[ @@ -1075,4 +1393,6 @@ def get_shaded_rgb( "random_bright_color", "random_color", "get_shaded_rgb", + "HSV", + "RGBA", ] diff --git a/manim/utils/deprecation.py b/manim/utils/deprecation.py index 4fd76880b4..afa9cb41a2 100644 --- a/manim/utils/deprecation.py +++ b/manim/utils/deprecation.py @@ -16,7 +16,7 @@ logger = logging.getLogger("manim") -def _get_callable_info(callable: Callable) -> tuple[str, str]: +def _get_callable_info(callable_: Callable, /) -> tuple[str, str]: """Returns type and name of a callable. Parameters @@ -30,8 +30,8 @@ def _get_callable_info(callable: Callable) -> tuple[str, str]: The type and name of the callable. Type can can be one of "class", "method" (for functions defined in classes) or "function"). For methods, name is Class.method. """ - what = type(callable).__name__ - name = callable.__qualname__ + what = type(callable_).__name__ + name = callable_.__qualname__ if what == "function" and "." in name: what = "method" elif what != "function": diff --git a/manim/utils/docbuild/autoaliasattr_directive.py b/manim/utils/docbuild/autoaliasattr_directive.py index ba42bd1ec4..5735dc9798 100644 --- a/manim/utils/docbuild/autoaliasattr_directive.py +++ b/manim/utils/docbuild/autoaliasattr_directive.py @@ -21,7 +21,7 @@ alias_name for module_dict in ALIAS_DOCS_DICT.values() for category_dict in module_dict.values() - for alias_name in category_dict.keys() + for alias_name in category_dict ] diff --git a/manim/utils/docbuild/autocolor_directive.py b/manim/utils/docbuild/autocolor_directive.py index 574aaedf77..3a699f54d4 100644 --- a/manim/utils/docbuild/autocolor_directive.py +++ b/manim/utils/docbuild/autocolor_directive.py @@ -69,10 +69,7 @@ def run(self) -> list[nodes.Element]: luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b # Choose the font color based on the background luminance - if luminance > 0.5: - font_color = "black" - else: - font_color = "white" + font_color = "black" if luminance > 0.5 else "white" color_elements.append((member_name, member_obj.to_hex(), font_color)) diff --git a/manim/utils/docbuild/manim_directive.py b/manim/utils/docbuild/manim_directive.py index b6af8ee00d..7982075a8c 100644 --- a/manim/utils/docbuild/manim_directive.py +++ b/manim/utils/docbuild/manim_directive.py @@ -227,11 +227,7 @@ def run(self) -> list[nodes.Element]: + self.options.get("ref_functions", []) + self.options.get("ref_methods", []) ) - if ref_content: - ref_block = "References: " + " ".join(ref_content) - - else: - ref_block = "" + ref_block = "References: " + " ".join(ref_content) if ref_content else "" if "quality" in self.options: quality = f'{self.options["quality"]}_quality' diff --git a/manim/utils/file_ops.py b/manim/utils/file_ops.py index d8e28e1515..81733905ed 100644 --- a/manim/utils/file_ops.py +++ b/manim/utils/file_ops.py @@ -304,10 +304,7 @@ def get_sorted_integer_files( ) -> list[str]: indexed_files = [] for file in os.listdir(directory): - if "." in file: - index_str = file[: file.index(".")] - else: - index_str = file + index_str = file[: file.index(".")] if "." in file else file full_path = os.path.join(directory, file) if index_str.isdigit(): diff --git a/manim/utils/hashing.py b/manim/utils/hashing.py index 72bfa99532..24154d335f 100644 --- a/manim/utils/hashing.py +++ b/manim/utils/hashing.py @@ -224,7 +224,7 @@ def default(self, o: Any): # We return the repr and not a list to avoid the JsonEncoder to iterate over it. return repr(o) elif hasattr(o, "__dict__"): - temp = getattr(o, "__dict__") + temp = o.__dict__ # MappingProxy is scene-caching nightmare. It contains all of the object methods and attributes. We skip it as the mechanism will at some point process the object, but instantiated. # Indeed, there is certainly no case where scene-caching will receive only a non instancied object, as this is never used in the library or encouraged to be used user-side. if isinstance(temp, MappingProxyType): diff --git a/manim/utils/opengl.py b/manim/utils/opengl.py index f9844f1b33..d48817b76e 100644 --- a/manim/utils/opengl.py +++ b/manim/utils/opengl.py @@ -31,7 +31,7 @@ def orthographic_projection_matrix( height=None, near=1, far=depth + 1, - format=True, + format_=True, ): if width is None: width = config["frame_width"] @@ -45,13 +45,15 @@ def orthographic_projection_matrix( [0, 0, 0, 1], ], ) - if format: + if format_: return matrix_to_shader_input(projection_matrix) else: return projection_matrix -def perspective_projection_matrix(width=None, height=None, near=2, far=50, format=True): +def perspective_projection_matrix( + width=None, height=None, near=2, far=50, format_=True +): if width is None: width = config["frame_width"] / 6 if height is None: diff --git a/manim/utils/progressbar.py b/manim/utils/progressbar.py new file mode 100644 index 0000000000..a941460426 --- /dev/null +++ b/manim/utils/progressbar.py @@ -0,0 +1,68 @@ +"""Create an abstraction over the progress bar used.""" + +from __future__ import annotations + +import contextlib +from typing import Protocol, cast + +from tqdm.asyncio import tqdm as asyncio_tqdm +from tqdm.auto import tqdm as auto_tqdm +from tqdm.rich import tqdm as rich_tqdm +from tqdm.std import TqdmExperimentalWarning as ExperimentalProgressBarWarning + +__all__ = [ + "ProgressBar", + "ProgressBarProtocol", + "NullProgressBar", + "ExperimentalProgressBarWarning", +] + + +# let tqdm figure out whether we're in a notebook +# but replace the basic tqdm with tqdm.rich.tqdm +if auto_tqdm is asyncio_tqdm: # noqa: SIM108 + tqdm = rich_tqdm +else: + # we're in a notebook + # tell typecheckers to pretend like it's tqdm.rich.tqdm + tqdm = cast(type[rich_tqdm], auto_tqdm) + + +class ProgressBarProtocol(Protocol): + def update(self, n: int) -> object: ... + + +class ProgressBar(tqdm, contextlib.AbstractContextManager[ProgressBarProtocol]): + """A manim progress bar. + + This abstracts away whether a progress bar is used in a notebook, or via the terminal, + or something else. + + You may need to ignore warnings from ``tqdm``, due to the experimental nature of + ``tqdm.notebook.tqdm`` and ``tqdm.rich.tqdm``. This can be done with something like: + + .. code-block:: python + + import warnings + + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=ExperimentalProgressBarWarning) + return ProgressBar(...) + + + .. note:: + + This warning filtering could have been done in the constructor, but would + have caused the loss of autocomplete with the ``__init__`` of ``tqdm``, as + well as possibly hide issues with the progressbar. Therefore, the warning + filtering is left to the user. + """ + + pass + + +class NullProgressBar(ProgressBarProtocol): + """Fake progressbar.""" + + def update(self, n: int) -> None: + """Do nothing""" diff --git a/manim/utils/space_ops.py b/manim/utils/space_ops.py index 1981bd708e..bdd4884135 100644 --- a/manim/utils/space_ops.py +++ b/manim/utils/space_ops.py @@ -495,10 +495,7 @@ def regular_vertices( """ if start_angle is None: - if n % 2 == 0: - start_angle = 0 - else: - start_angle = TAU / 4 + start_angle = 0 if n % 2 == 0 else TAU / 4 start_vector = rotate_vector(RIGHT * radius, start_angle) vertices = compass_directions(n, start_vector) diff --git a/manim/utils/testing/_frames_testers.py b/manim/utils/testing/_frames_testers.py index 1849feb2a9..45cd34b7f9 100644 --- a/manim/utils/testing/_frames_testers.py +++ b/manim/utils/testing/_frames_testers.py @@ -68,7 +68,8 @@ def check_frame(self, frame_number: int, frame: PixelArray): warnings.warn( f"Mismatch of {number_of_mismatches} pixel values in frame {frame_number} " f"against control data in {self._file_path}. Below error threshold, " - "continuing..." + "continuing...", + stacklevel=1, ) return diff --git a/manim/utils/tex.py b/manim/utils/tex.py index f642abad72..2103f20344 100644 --- a/manim/utils/tex.py +++ b/manim/utils/tex.py @@ -191,7 +191,7 @@ def _texcode_for_environment(environment: str) -> tuple[str, str]: begin += "}" # While the \end command terminates at the first closing brace - split_at_brace = re.split("}", environment, 1) + split_at_brace = re.split("}", environment, maxsplit=1) end = r"\end{" + split_at_brace[0] + "}" return begin, end diff --git a/pyproject.toml b/pyproject.toml index 74434f0960..a57becec8e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,8 +14,6 @@ classifiers= [ "Topic :: Scientific/Engineering", "Topic :: Multimedia :: Video", "Topic :: Multimedia :: Graphics", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -27,7 +25,7 @@ packages = [ ] [tool.poetry.dependencies] -python = ">=3.9,<3.13" +python = ">=3.10,<3.13" av = ">=9.0.0" click = ">=8.0" cloup = ">=2.0.0" @@ -64,13 +62,8 @@ jupyterlab = ["jupyterlab", "notebook"] gui = ["dearpygui"] [tool.poetry.group.dev.dependencies] -black = ">=23.11,<25.0" flake8 = "^6.1.0" -flake8-bugbear = "^23.11.28" -flake8-builtins = "^2.2.0" -flake8-comprehensions = "^3.7.0" flake8-docstrings = "^1.7.0" -flake8-simplify = "^0.14.1" furo = "^2023.09.10" gitpython = "^3" isort = "^5.12.0" @@ -137,14 +130,23 @@ fix = true [tool.ruff.lint] select = [ + "A", + "B", + "C4", "E", "F", "I", "PT", + "SIM", "UP", ] ignore = [ + # mutable argument defaults (too many changes) + "B006", + # No function calls in defaults + # ignored because np.array() and straight_path() + "B008", # due to the import * used in manim "F403", "F405", @@ -164,6 +166,10 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" [tool.ruff.lint.per-file-ignores] "tests/*" = [ + # flake8-builtins + "A", + # unused expression + "B018", # unused variable "F841", # from __future__ import annotations diff --git a/scripts/dev_changelog.py b/scripts/dev_changelog.py index 35e77dd798..a4a18b934e 100755 --- a/scripts/dev_changelog.py +++ b/scripts/dev_changelog.py @@ -110,7 +110,7 @@ def process_pullrequests(lst, cur, github_repo, pr_nums): authors.add(pr.user) reviewers = reviewers.union(rev.user for rev in pr.get_reviews()) pr_labels = [label.name for label in pr.labels] - for label in PR_LABELS.keys(): + for label in PR_LABELS: if label in pr_labels: pr_by_labels[label].append(pr) break # ensure that PR is only added in one category @@ -291,7 +291,7 @@ def main(token, prior, tag, additional, outfile): ) pr_by_labels = contributions["PRs"] - for label in PR_LABELS.keys(): + for label in PR_LABELS: pr_of_label = pr_by_labels[label] if pr_of_label: diff --git a/tests/module/utils/test_manim_color.py b/tests/module/utils/test_manim_color.py index 1ae07c3b8b..68c3960412 100644 --- a/tests/module/utils/test_manim_color.py +++ b/tests/module/utils/test_manim_color.py @@ -1,9 +1,20 @@ from __future__ import annotations +import colorsys + import numpy as np import numpy.testing as nt -from manim.utils.color import BLACK, WHITE, ManimColor, ManimColorDType +from manim.utils.color import ( + BLACK, + HSV, + RED, + WHITE, + YELLOW, + ManimColor, + ManimColorDType, +) +from manim.utils.color.XKCD import GREEN def test_init_with_int() -> None: @@ -20,3 +31,145 @@ def test_init_with_int() -> None: nt.assert_array_equal( color._internal_value, np.array([1.0, 1.0, 1.0, 1.0], dtype=ManimColorDType) ) + + +def test_init_with_hex() -> None: + color = ManimColor("0xFF0000") + nt.assert_array_equal(color._internal_value, np.array([1, 0, 0, 1])) + color = ManimColor("0xFF000000") + nt.assert_array_equal(color._internal_value, np.array([1, 0, 0, 0])) + + color = ManimColor("#FF0000") + nt.assert_array_equal(color._internal_value, np.array([1, 0, 0, 1])) + color = ManimColor("#FF000000") + nt.assert_array_equal(color._internal_value, np.array([1, 0, 0, 0])) + + +def test_init_with_string() -> None: + color = ManimColor("BLACK") + nt.assert_array_equal(color._internal_value, BLACK._internal_value) + + +def test_init_with_tuple_int() -> None: + color = ManimColor((50, 10, 50)) + nt.assert_array_equal( + color._internal_value, np.array([50 / 255, 10 / 255, 50 / 255, 1.0]) + ) + + color = ManimColor((50, 10, 50, 50)) + nt.assert_array_equal( + color._internal_value, np.array([50 / 255, 10 / 255, 50 / 255, 50 / 255]) + ) + + +def test_init_with_tuple_float() -> None: + color = ManimColor((0.5, 0.6, 0.7)) + nt.assert_array_equal(color._internal_value, np.array([0.5, 0.6, 0.7, 1.0])) + + color = ManimColor((0.5, 0.6, 0.7, 0.1)) + nt.assert_array_equal(color._internal_value, np.array([0.5, 0.6, 0.7, 0.1])) + + +def test_to_integer() -> None: + color = ManimColor((0x1, 0x2, 0x3, 0x4)) + nt.assert_equal(color.to_integer(), 0x010203) + + +def test_to_rgb() -> None: + color = ManimColor((0x1, 0x2, 0x3, 0x4)) + nt.assert_array_equal(color.to_rgb(), (0x1 / 255, 0x2 / 255, 0x3 / 255)) + nt.assert_array_equal(color.to_int_rgb(), (0x1, 0x2, 0x3)) + nt.assert_array_equal(color.to_rgba(), (0x1 / 255, 0x2 / 255, 0x3 / 255, 0x4 / 255)) + nt.assert_array_equal(color.to_int_rgba(), (0x1, 0x2, 0x3, 0x4)) + nt.assert_array_equal( + color.to_rgba_with_alpha(0.5), (0x1 / 255, 0x2 / 255, 0x3 / 255, 0.5) + ) + nt.assert_array_equal( + color.to_int_rgba_with_alpha(0.5), (0x1, 0x2, 0x3, int(0.5 * 255)) + ) + + +def test_to_hex() -> None: + color = ManimColor((0x1, 0x2, 0x3, 0x4)) + nt.assert_equal(color.to_hex(), "#010203") + nt.assert_equal(color.to_hex(True), "#01020304") + + +def test_to_hsv() -> None: + color = ManimColor((0x1, 0x2, 0x3, 0x4)) + nt.assert_array_equal( + color.to_hsv(), colorsys.rgb_to_hsv(0x1 / 255, 0x2 / 255, 0x3 / 255) + ) + + +def test_to_hsl() -> None: + color = ManimColor((0x1, 0x2, 0x3, 0x4)) + nt.assert_array_equal( + color.to_hsl(), colorsys.rgb_to_hls(0x1 / 255, 0x2 / 255, 0x3 / 255) + ) + + +def test_invert() -> None: + color = ManimColor((0x1, 0x2, 0x3, 0x4)) + rgba = color._internal_value + inverted = color.invert() + nt.assert_array_equal( + inverted._internal_value, (1 - rgba[0], 1 - rgba[1], 1 - rgba[2], rgba[3]) + ) + + +def test_invert_with_alpha() -> None: + color = ManimColor((0x1, 0x2, 0x3, 0x4)) + rgba = color._internal_value + inverted = color.invert(True) + nt.assert_array_equal( + inverted._internal_value, (1 - rgba[0], 1 - rgba[1], 1 - rgba[2], 1 - rgba[3]) + ) + + +def test_interpolate() -> None: + r1 = RED._internal_value + r2 = YELLOW._internal_value + nt.assert_array_equal( + RED.interpolate(YELLOW, 0.5)._internal_value, 0.5 * r1 + 0.5 * r2 + ) + + +def test_opacity() -> None: + nt.assert_equal(RED.opacity(0.5)._internal_value[3], 0.5) + + +def test_parse() -> None: + nt.assert_equal(ManimColor.parse([RED, YELLOW]), [RED, YELLOW]) + + +def test_mc_operators() -> None: + c1 = RED + c2 = GREEN + halfway1 = 0.5 * c1 + 0.5 * c2 + halfway2 = c1.interpolate(c2, 0.5) + nt.assert_equal(halfway1, halfway2) + nt.assert_array_equal((WHITE / 2.0)._internal_value, np.array([0.5, 0.5, 0.5, 0.5])) + + +def test_mc_from_functions() -> None: + color = ManimColor.from_hex("#ff00a0") + nt.assert_equal(color.to_hex(), "#FF00A0") + + color = ManimColor.from_rgb((1.0, 1.0, 0.0)) + nt.assert_equal(color.to_hex(), "#FFFF00") + + color = ManimColor.from_rgba((1.0, 1.0, 0.0, 1.0)) + nt.assert_equal(color.to_hex(True), "#FFFF00FF") + + color = ManimColor.from_hsv((1.0, 1.0, 1.0), alpha=0.0) + nt.assert_equal(color.to_hex(True), "#FF000000") + + +def test_hsv_init() -> None: + color = HSV((0.25, 1, 1)) + nt.assert_array_equal(color._internal_value, np.array([0.5, 1.0, 0.0, 1.0])) + + +def test_into_HSV() -> None: + nt.assert_equal(RED.into(HSV).into(ManimColor), RED) diff --git a/tests/test_graphical_units/control_data/coordinate_system/gradient_line_graph_x_axis.npz b/tests/test_graphical_units/control_data/coordinate_system/gradient_line_graph_x_axis.npz new file mode 100644 index 0000000000..6efda8b2e0 Binary files /dev/null and b/tests/test_graphical_units/control_data/coordinate_system/gradient_line_graph_x_axis.npz differ diff --git a/tests/test_graphical_units/control_data/coordinate_system/gradient_line_graph_y_axis.npz b/tests/test_graphical_units/control_data/coordinate_system/gradient_line_graph_y_axis.npz new file mode 100644 index 0000000000..3c7afcda7d Binary files /dev/null and b/tests/test_graphical_units/control_data/coordinate_system/gradient_line_graph_y_axis.npz differ diff --git a/tests/test_graphical_units/test_animation.py b/tests/test_graphical_units/test_animation.py new file mode 100644 index 0000000000..3758e4985b --- /dev/null +++ b/tests/test_graphical_units/test_animation.py @@ -0,0 +1,16 @@ +from manim import * +from manim.animation.animation import DEFAULT_ANIMATION_RUN_TIME + +__module_test__ = "animation" + + +def test_animation_set_default(): + s = Square() + Rotate.set_default(run_time=100) + anim = Rotate(s) + assert anim.run_time == 100 + anim = Rotate(s, run_time=5) + assert anim.run_time == 5 + Rotate.set_default() + anim = Rotate(s) + assert anim.run_time == DEFAULT_ANIMATION_RUN_TIME diff --git a/tests/test_graphical_units/test_coordinate_systems.py b/tests/test_graphical_units/test_coordinate_systems.py index d7603f4d00..7d6dad67af 100644 --- a/tests/test_graphical_units/test_coordinate_systems.py +++ b/tests/test_graphical_units/test_coordinate_systems.py @@ -141,3 +141,33 @@ def test_number_plane_log(scene): ) scene.add(VGroup(plane1, plane2).arrange()) + + +@frames_comparison +def test_gradient_line_graph_x_axis(scene): + """Test that using `colorscale` generates a line whose gradient matches the y-axis""" + axes = Axes(x_range=[-3, 3], y_range=[-3, 3]) + + curve = axes.plot( + lambda x: 0.1 * x**3, + x_range=(-3, 3, 0.001), + colorscale=[BLUE, GREEN, YELLOW, ORANGE, RED], + colorscale_axis=0, + ) + + scene.add(axes, curve) + + +@frames_comparison +def test_gradient_line_graph_y_axis(scene): + """Test that using `colorscale` generates a line whose gradient matches the y-axis""" + axes = Axes(x_range=[-3, 3], y_range=[-3, 3]) + + curve = axes.plot( + lambda x: 0.1 * x**3, + x_range=(-3, 3, 0.001), + colorscale=[BLUE, GREEN, YELLOW, ORANGE, RED], + colorscale_axis=1, + ) + + scene.add(axes, curve) diff --git a/tests/test_scene_rendering/simple_scenes.py b/tests/test_scene_rendering/simple_scenes.py index 922a93ee6c..7f626ffbe2 100644 --- a/tests/test_scene_rendering/simple_scenes.py +++ b/tests/test_scene_rendering/simple_scenes.py @@ -135,7 +135,7 @@ class PresentationSectionType(str, Enum): ) self.wait(2) - self.next_section(type=PresentationSectionType.SKIP) + self.next_section(section_type=PresentationSectionType.SKIP) self.wait() self.next_section(