Implement sections for scenes (#3883)

Reimplement the ManimCE sections API, as well as generic fixes for `Tex`.
This commit is contained in:
Aarush Deshpande 2024-08-02 20:08:44 -04:00 committed by GitHub
commit 7844c848f0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
82 changed files with 1512 additions and 770 deletions

View file

@ -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:

View file

@ -26,7 +26,7 @@ sys.path.insert(0, os.path.abspath("."))
# -- 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 @@ autodoc_type_aliases = {
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"

View file

@ -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']

View file

@ -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::

View file

@ -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 <https://cairographics.org/pycairo/>`__,
- and `Pango <https://pango.gnome.org>`__ headers.

View file

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

View file

@ -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
********

View file

@ -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.

View file

@ -2,20 +2,26 @@ from manim import *
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 @@ class Test(Scene):
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__":

View file

@ -72,6 +72,7 @@ from manim.mobject.types.vectorized_mobject import *
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 *

View file

@ -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.

View file

@ -291,10 +291,8 @@ class ManimConfig(MutableMapping):
"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 ManimConfig(MutableMapping):
"renderer",
"enable_gui",
"gui_location",
"use_projection_fill_shaders",
"use_projection_stroke_shaders",
"verbosity",
"video_dir",
"sections_dir",
@ -337,6 +333,10 @@ class ManimConfig(MutableMapping):
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 @@ class ManimConfig(MutableMapping):
"write_to_movie",
"save_last_frame",
"write_all",
"save_pngs",
"save_as_gif",
"save_sections",
"preview",
"show_in_file_browser",
@ -600,8 +598,6 @@ class ManimConfig(MutableMapping):
"custom_folders",
"enable_gui",
"fullscreen",
"use_projection_fill_shaders",
"use_projection_stroke_shaders",
"enable_wireframe",
"force_window",
"no_latex_cleanup",
@ -656,21 +652,18 @@ class ManimConfig(MutableMapping):
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 @@ class ManimConfig(MutableMapping):
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 @@ class ManimConfig(MutableMapping):
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 @@ class ManimConfig(MutableMapping):
"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 @@ class ManimConfig(MutableMapping):
"background_color",
"enable_gui",
"fullscreen",
"use_projection_fill_shaders",
"use_projection_stroke_shaders",
"zero_pad",
"enable_wireframe",
"force_window",
@ -852,15 +841,12 @@ class ManimConfig(MutableMapping):
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 @@ class ManimConfig(MutableMapping):
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 @@ class ManimConfig(MutableMapping):
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)."""

View file

@ -18,6 +18,7 @@ __all__ = ["Animation", "Wait", "override_animation"]
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 @@ class Animation(AnimationProtocol):
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

View file

@ -97,7 +97,7 @@ class AnimationGroup(Animation):
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)

View file

@ -96,7 +96,8 @@ from manim.utils.color import ManimColor
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 @@ class ShowPartial(Animation):
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 @@ class DrawBorderThenFill(Animation):
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 @@ class DrawBorderThenFill(Animation):
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 @@ class DrawBorderThenFill(Animation):
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 @@ class Write(DrawBorderThenFill):
) -> 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 @@ class SpiralIn(Animation):
def __init__(
self,
shapes: Mobject,
shapes: OpenGLMobject,
scale_factor: float = 8,
fade_in_fraction=0.3,
**kwargs,
@ -512,7 +514,7 @@ class ShowIncreasingSubsets(Animation):
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 @@ class TypeWithCursor(AddTextLetterByLetter):
def __init__(
self,
text: Text,
cursor: Mobject,
cursor: OpenGLMobject,
buff: float = 0.1,
keep_cursor_y: bool = True,
leave_cursor_on: bool = True,

View file

@ -61,10 +61,7 @@ class _Fade(Transform):
) -> 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:

View file

@ -6,15 +6,13 @@ __all__ = ["Rotating", "Rotate"]
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 @@ class Rotating(Animation):
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 @@ class Rotate(Rotating):
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__(

View file

@ -70,10 +70,7 @@ class Broadcast(LaggedStart):
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()

View file

@ -206,10 +206,7 @@ class Transform(Animation):
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 [

View file

@ -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 @@ __all__ = [
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
def assert_is_mobject_method(method: Callable) -> None:
assert inspect.ismethod(method)
mobject = method.__self__
assert isinstance(mobject, (Mobject, OpenGLMobject))
P = ParamSpec("P")
def always_redraw(func: Callable[[], Mobject]) -> Mobject:
M = TypeVar("M", bound=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(
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 always_redraw(func: Callable[[], Mobject]) -> Mobject:
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 turn_animation_into_updater(
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 turn_animation_into_updater(
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)

View file

@ -11,7 +11,7 @@ import typing
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 @@ class UpdateFromAlphaFunc(UpdateFromFunc):
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(),

View file

@ -8,6 +8,7 @@ group.
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 @@ To save your config please save that file and place it in your current working d
"""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,

View file

@ -67,6 +67,7 @@ class DefaultGroup(cloup.Group):
warnings.warn(
"Use default param of DefaultGroup or set_default_command() instead",
DeprecationWarning,
stacklevel=1,
)
def _decorator(f):

View file

@ -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.",

View file

@ -5,7 +5,7 @@ import re
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 @@ render_options = option_group(
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 @@ render_options = option_group(
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,
),
)

View file

@ -1,5 +1,6 @@
from __future__ import annotations
import contextlib
from typing import TYPE_CHECKING, Callable
if TYPE_CHECKING:
@ -23,12 +24,10 @@ class EventListener:
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

View file

@ -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: ...

View file

@ -77,7 +77,7 @@ class FileWriter(FileWriterProtocol):
# 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 @@ class FileWriter(FileWriterProtocol):
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 @@ class FileWriter(FileWriterProtocol):
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 @@ class FileWriter(FileWriterProtocol):
self.sections.append(
Section(
type,
type_,
section_video,
name,
skip_animations,

View file

@ -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: ...

View file

@ -34,23 +34,23 @@ class DefaultSectionType(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</tutorials/output_and_config>` 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 @@ class Section:
return dict(
{
"name": self.name,
"type": self.type,
"type": self.type_,
"video": self.video,
},
**video_metadata,

View file

@ -5,19 +5,26 @@ __all__ = ["Manager"]
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 @@ class Manager(Generic[Scene_co]):
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 @@ class Manager(Generic[Scene_co]):
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 @@ class Manager(Generic[Scene_co]):
-------
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 @@ class Manager(Generic[Scene_co]):
-------
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 @@ class Manager(Generic[Scene_co]):
-------
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 @@ class Manager(Generic[Scene_co]):
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 @@ class Manager(Generic[Scene_co]):
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 @@ class Manager(Generic[Scene_co]):
# 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 @@ class Manager(Generic[Scene_co]):
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 @@ class Manager(Generic[Scene_co]):
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 @@ class Manager(Generic[Scene_co]):
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 @@ class Manager(Generic[Scene_co]):
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 @@ class Manager(Generic[Scene_co]):
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 @@ class Manager(Generic[Scene_co]):
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 @@ class Manager(Generic[Scene_co]):
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 @@ class Manager(Generic[Scene_co]):
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 @@ class Manager(Generic[Scene_co]):
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 @@ class Manager(Generic[Scene_co]):
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 @@ class Manager(Generic[Scene_co]):
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"""
def update(self, _: Any) -> None: ...
return np.arange(0, run_time, 1 / config.frame_rate)
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)

View file

@ -401,7 +401,9 @@ class Arc(TipableVMobject):
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)

View file

@ -87,11 +87,10 @@ class _BooleanOps(VMobject, metaclass=ConvertToOpenGL):
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:

View file

@ -99,10 +99,7 @@ class Line(TipableVMobject):
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

View file

@ -757,9 +757,6 @@ class Cutout(OpenGLVMobject):
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)

View file

@ -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 _tree_layout(
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 @@ class GenericGraph(VMobject, metaclass=ConvertToOpenGL):
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 @@ class GenericGraph(VMobject, metaclass=ConvertToOpenGL):
::
>>> 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

View file

@ -48,6 +48,7 @@ from manim.utils.color import (
ManimColor,
ParsableManimColor,
color_gradient,
interpolate_color,
invert_color,
)
from manim.utils.config_ops import merge_dicts_recursively, update_dict_recursively
@ -628,6 +629,8 @@ class CoordinateSystem:
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 @@ class CoordinateSystem:
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 @@ class CoordinateSystem:
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(

View file

@ -339,10 +339,7 @@ class BarChart(Axes):
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

View file

@ -265,10 +265,12 @@ class Mobject:
>>> 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 @@ class Mobject:
# 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 @@ class Mobject:
# 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 @@ class Mobject:
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 @@ class Mobject:
# 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 @@ class Mobject:
# 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 @@ class Mobject:
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)

View file

@ -463,10 +463,7 @@ class OpenGLLine(OpenGLTipableVMobject):
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

View file

@ -14,9 +14,8 @@ from functools import partialmethod, wraps
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 @@ from manim.utils.space_ops import (
rotation_matrix_transpose,
)
__all__ = ["OpenGLMobject", "MobjectKwargs"]
if TYPE_CHECKING:
from collections.abc import Iterable, Sequence
from typing import Callable
@ -92,17 +93,6 @@ def stash_mobject_pointers(
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 @@ class OpenGLMobject(Generic[R]):
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 @@ class OpenGLMobject(Generic[R]):
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 @@ class OpenGLMobject(Generic[R]):
>>> 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 @@ class OpenGLMobject(Generic[R]):
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 @@ class OpenGLMobject(Generic[R]):
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 @@ class OpenGLMobject(Generic[R]):
for submob in self.submobjects:
submob.reverse_submobjects(recursive=True)
self.note_changed_family()
return self
# Copying
@ -1360,7 +1357,6 @@ class OpenGLMobject(Generic[R]):
result.parents = []
result.target = None
result.saved_state = None
#
result.points = np.array(self.points)
#
@ -1372,13 +1368,16 @@ class OpenGLMobject(Generic[R]):
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 @@ class OpenGLMobject(Generic[R]):
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 @@ class OpenGLMobject(Generic[R]):
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 @@ class OpenGLMobject(Generic[R]):
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 @@ class OpenGLMobject(Generic[R]):
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 @@ class OpenGLMobject(Generic[R]):
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 @@ class OpenGLMobject(Generic[R]):
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 @@ class OpenGLMobject(Generic[R]):
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 @@ class OpenGLMobject(Generic[R]):
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:

View file

@ -71,7 +71,7 @@ class OpenGLPMobject(OpenGLMobject):
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):

View file

@ -388,7 +388,8 @@ class OpenGLTexturedSurface(OpenGLSurface):
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 @@ class OpenGLTexturedSurface(OpenGLSurface):
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,

View file

@ -6,9 +6,11 @@ from functools import reduce
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 @@ from manim.utils.bezier import (
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_STROKE_COLOR = GREY_A
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 @@ class OpenGLVMobject(OpenGLMobject):
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 @@ class OpenGLVMobject(OpenGLMobject):
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 @@ class OpenGLVMobject(OpenGLMobject):
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 @@ class OpenGLVMobject(OpenGLMobject):
# 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 @@ class OpenGLVMobject(OpenGLMobject):
"""
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 @@ class OpenGLVMobject(OpenGLMobject):
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 @@ class OpenGLDashedVMobject(OpenGLVMobject):
**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 @@ class VHighlight(OpenGLVGroup):
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)

View file

@ -1,5 +1,6 @@
from __future__ import annotations
import contextlib
import inspect
import re
import textwrap
@ -382,10 +383,8 @@ class Shader:
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):

View file

@ -249,7 +249,7 @@ class BraceLabel(VMobject, metaclass=ConvertToOpenGL):
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)

View file

@ -98,27 +98,13 @@ class SVGMobject(VMobject, metaclass=ConvertToOpenGL):
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 @@ class SVGMobject(VMobject, metaclass=ConvertToOpenGL):
}
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 @@ class SVGMobject(VMobject, metaclass=ConvertToOpenGL):
"""
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)

View file

@ -210,10 +210,7 @@ class DecimalNumber(VMobject, metaclass=ConvertToOpenGL):
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

View file

@ -69,8 +69,9 @@ from manimpango import MarkupUtils, PangoUtils, TextSetting
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 @@ class Text(SVGMobject):
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 @@ class MarkupText(SVGMobject):
"end_offset": end_offset,
},
)
self.text = re.sub("<gradient[^>]+>(.+?)</gradient>", r"\1", self.text, 0, re.S)
self.text = re.sub(
"<gradient[^>]+>(.+?)</gradient>", r"\1", self.text, count=0, flags=re.S
)
return gradientmap
def _parse_color(self, col):
@ -1494,7 +1497,9 @@ class MarkupText(SVGMobject):
"end_offset": end_offset,
},
)
self.text = re.sub("<color[^>]+>(.+?)</color>", r"\1", self.text, 0, re.S)
self.text = re.sub(
"<color[^>]+>(.+?)</color>", r"\1", self.text, count=0, flags=re.S
)
return colormap
def __repr__(self):

View file

@ -664,10 +664,7 @@ class Cone(Surface):
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 @@ class Cylinder(Surface):
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

View file

@ -148,7 +148,7 @@ class PMobject(Mobject, metaclass=ConvertToOpenGL):
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 @@ class PMobject(Mobject, metaclass=ConvertToOpenGL):
"""
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):

View file

@ -198,13 +198,11 @@ class VMobject(Mobject):
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 @@ class VMobject(Mobject):
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 @@ class DashedVMobject(VMobject, metaclass=ConvertToOpenGL):
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:

View file

@ -2,12 +2,9 @@ from __future__ import annotations
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",
]

View file

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

View file

@ -295,7 +295,7 @@ class OpenGLRenderer(Renderer, RendererProtocol):
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 @@ class OpenGLRenderer(Renderer, RendererProtocol):
)
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)

View file

@ -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
if TYPE_CHECKING:
from typing_extensions import TypeGuard
T = TypeVar("T")
__all__ = ["Window"]
__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 @@ class Window(PygletWindow, WindowABC):
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 @@ class Window(PygletWindow, WindowABC):
-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

View file

@ -4,14 +4,12 @@ from abc import ABC, abstractmethod
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 @@ class Renderer(ABC):
(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 @@ class Renderer(ABC):
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"""
...

View file

@ -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.event_handler.event_type import EventType
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 @@ class Scene:
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 @@ class Scene:
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 @@ class Scene:
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()

135
manim/scene/sections.py Normal file
View file

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

View file

@ -135,8 +135,8 @@ class ThreeDScene(Scene):
}
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 @@ class ThreeDScene(Scene):
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,

View file

@ -41,6 +41,10 @@ __all__ = [
"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 @@ Its components describe, in order, the Hue, Saturation and Value (or
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,)``

View file

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

View file

@ -18,6 +18,27 @@ Note this way uses the name of the colors in UPPERCASE.
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 @@ from typing import Any, TypeVar, Union, overload
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 @@ class ManimColor:
# 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 @@ class ManimColor:
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 @@ class ManimColor:
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 @@ class ManimColor:
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 @@ class ManimColor:
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 @@ class ManimColor:
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 @@ class ManimColor:
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 @@ class ManimColor:
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 @@ class ManimColor:
"""
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 @@ class ManimColor:
.. 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 @@ class ManimColor:
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 invert(self, with_alpha=False) -> ManimColor:
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) -> Self:
"""Returns an linearly inverted version of the color (no inplace changes)
Parameters
@ -517,9 +577,15 @@ class 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 @@ class 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
Raises
------
ValueError
Raises an exception if the opacity value is not in range 0 to 1
The new ManimColor with the same color value but the new opacity
"""
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)
if opacity is None:
return self._internal_space[-1]
tmp = self._internal_space.copy()
tmp[-1] = opacity
return self._construct_from_space(tmp)
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.
"""
return cls(value)
@classmethod
def from_rgb(
@ -587,7 +684,7 @@ class ManimColor:
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 @@ class ManimColor:
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 @@ class ManimColor:
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 @@ class ManimColor:
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 @@ class ManimColor:
@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 @@ class ManimColor:
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 @@ class ManimColor:
)
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 __sub__(self, other: ManimColor) -> ManimColor:
return ManimColor(self._internal_value - other._internal_value)
def __radd__(self, other: int | float | Self) -> Self:
return self + other
def __mul__(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 __truediv__(self, other: ManimColor) -> ManimColor:
return ManimColor(self._internal_value / other._internal_value)
def __rsub__(self, other: int | float | Self) -> Self:
return self - other
def __floordiv__(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 __mod__(self, other: ManimColor) -> ManimColor:
return ManimColor(self._internal_value % other._internal_value)
def __rmul__(self, other: int | float | Self) -> Self:
return self * other
def __pow__(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 __and__(self, other: ManimColor) -> ManimColor:
return ManimColor(self.to_integer() & other.to_integer())
def __rtruediv__(self, other: int | float | Self) -> Self:
return self / other
def __or__(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 __xor__(self, other: ManimColor) -> ManimColor:
return ManimColor(self.to_integer() ^ other.to_integer())
def __rfloordiv__(self, other: int | float | Self) -> Self:
return self // other
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 @@ __all__ = [
"random_bright_color",
"random_color",
"get_shaded_rgb",
"HSV",
"RGBA",
]

View file

@ -16,7 +16,7 @@ from decorator import decorate, decorator
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":

View file

@ -21,7 +21,7 @@ ALIAS_LIST = [
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
]

View file

@ -69,10 +69,7 @@ class ManimColorModuleDocumenter(Directive):
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))

View file

@ -227,11 +227,7 @@ class ManimDirective(Directive):
+ 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'

View file

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

View file

@ -224,7 +224,7 @@ class _CustomEncoder(json.JSONEncoder):
# 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):

View file

@ -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:

View file

@ -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"""

View file

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

View file

@ -68,7 +68,8 @@ class _FramesTester:
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

View file

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

View file

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

View file

@ -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:

View file

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

View file

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

View file

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

View file

@ -135,7 +135,7 @@ class SceneWithSections(Scene):
)
self.wait(2)
self.next_section(type=PresentationSectionType.SKIP)
self.next_section(section_type=PresentationSectionType.SKIP)
self.wait()
self.next_section(