Merge branch 'main' of https://github.com/ManimCommunity/manim into experimental

This commit is contained in:
JasonGrace2282 2024-11-30 21:35:36 -05:00
commit 497debad8e
No known key found for this signature in database
GPG key ID: 8D61FE3F93FB15FA
57 changed files with 2603 additions and 601 deletions

View file

@ -13,7 +13,7 @@ repos:
- id: check-toml
name: Validate Poetry
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.7.0
rev: v0.8.0
hooks:
- id: ruff
name: ruff lint
@ -22,7 +22,7 @@ repos:
- id: ruff-format
types: [python]
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.12.1
rev: v1.13.0
hooks:
- id: mypy
additional_dependencies:

View file

@ -90,7 +90,7 @@ Basic Concepts
[[i * 256 / n for i in range(0, n)] for _ in range(0, n)]
)
image = ImageMobject(imageArray).scale(2)
image.background_rectangle = SurroundingRectangle(image, GREEN)
image.background_rectangle = SurroundingRectangle(image, color=GREEN)
self.add(image, image.background_rectangle)
.. manim:: BooleanOperations

View file

@ -10,6 +10,7 @@ Module Index
:toctree: ../reference
~utils.bezier
cli
~utils.color
~utils.commands
~utils.config_ops

View file

@ -3,22 +3,49 @@ from __future__ import annotations
import click
import cloup
from . import __version__, cli_ctx_settings, console
from .cli.cfg.group import cfg
from .cli.checkhealth.commands import checkhealth
from .cli.default_group import DefaultGroup
from .cli.init.commands import init
from .cli.plugins.commands import plugins
from .cli.render.commands import render
from .constants import EPILOG
from manim import __version__
from manim._config import cli_ctx_settings, console
from manim.cli.cfg.group import cfg
from manim.cli.checkhealth.commands import checkhealth
from manim.cli.default_group import DefaultGroup
from manim.cli.init.commands import init
from manim.cli.plugins.commands import plugins
from manim.cli.render.commands import render
from manim.constants import EPILOG
def show_splash(ctx, param, value):
def show_splash(ctx: click.Context, param: click.Option, value: str | None) -> None:
"""When giving a value by console, show an initial message with the Manim
version before executing any other command: ``Manim Community vA.B.C``.
Parameters
----------
ctx
The Click context.
param
A Click option.
value
A string value given by console, or None.
"""
if value:
console.print(f"Manim Community [green]v{__version__}[/green]\n")
def print_version_and_exit(ctx, param, value):
def print_version_and_exit(
ctx: click.Context, param: click.Option, value: str | None
) -> None:
"""Same as :func:`show_splash`, but also exit when giving a value by
console.
Parameters
----------
ctx
The Click context.
param
A Click option.
value
A string value given by console, or None.
"""
show_splash(ctx, param, value)
if value:
ctx.exit()
@ -53,8 +80,14 @@ def print_version_and_exit(ctx, param, value):
expose_value=False,
)
@cloup.pass_context
def main(ctx):
"""The entry point for manim."""
def main(ctx: click.Context) -> None:
"""The entry point for Manim.
Parameters
----------
ctx
The Click context.
"""
pass

View file

@ -1,13 +1,14 @@
from __future__ import annotations
import configparser
from typing import Any
from cloup import Context, HelpFormatter, HelpTheme, Style
__all__ = ["parse_cli_ctx"]
def parse_cli_ctx(parser: configparser.SectionProxy) -> Context:
def parse_cli_ctx(parser: configparser.SectionProxy) -> dict[str, Any]:
formatter_settings: dict[str, str | int] = {
"indent_increment": int(parser["indent_increment"]),
"width": int(parser["width"]),

View file

@ -5,7 +5,7 @@ from __future__ import annotations
from collections.abc import Iterable, Sequence
from copy import deepcopy
from functools import partialmethod
from typing import TYPE_CHECKING, Callable, cast, overload
from typing import TYPE_CHECKING, Any, Callable, cast, overload
import numpy as np
from typing_extensions import Self, TypeVar, assert_never
@ -14,8 +14,9 @@ from manim.mobject.opengl.opengl_mobject import OpenGLMobject
from .. import config, logger
from ..mobject import mobject
from ..mobject.mobject import Mobject
from ..mobject.mobject import Group, Mobject
from ..mobject.opengl import opengl_mobject
from ..scene.scene import Scene
from ..utils.rate_functions import linear, smooth
from .protocol import AnimationProtocol, MobjectAnimation
from .scene_buffer import SceneBuffer, SceneOperation
@ -176,7 +177,20 @@ class Animation(AnimationProtocol):
),
)
def _typecheck_input(self, mobject: Mobject | OpenGLMobject | None) -> None:
@property
def run_time(self) -> float:
return self._run_time
@run_time.setter
def run_time(self, value: float) -> None:
if value < 0:
raise ValueError(
f"The run_time of {self.__class__.__name__} cannot be "
f"negative. The given value was {value}."
)
self._run_time = value
def _typecheck_input(self, mobject: Mobject | None) -> None:
if mobject is None:
logger.debug("Animation with empty mobject")
elif not isinstance(mobject, (Mobject, OpenGLMobject)):
@ -630,6 +644,90 @@ class Wait(Animation):
pass
class Add(Animation):
"""Add Mobjects to a scene, without animating them in any other way. This
is similar to the :meth:`.Scene.add` method, but :class:`Add` is an
animation which can be grouped into other animations.
Parameters
----------
mobjects
One :class:`~.Mobject` or more to add to a scene.
run_time
The duration of the animation after adding the ``mobjects``. Defaults
to 0, which means this is an instant animation without extra wait time
after adding them.
**kwargs
Additional arguments to pass to the parent :class:`Animation` class.
Examples
--------
.. manim:: DefaultAddScene
class DefaultAddScene(Scene):
def construct(self):
text_1 = Text("I was added with Add!")
text_2 = Text("Me too!")
text_3 = Text("And me!")
texts = VGroup(text_1, text_2, text_3).arrange(DOWN)
rect = SurroundingRectangle(texts, buff=0.5)
self.play(
Create(rect, run_time=3.0),
Succession(
Wait(1.0),
# You can Add a Mobject in the middle of an animation...
Add(text_1),
Wait(1.0),
# ...or multiple Mobjects at once!
Add(text_2, text_3),
),
)
self.wait()
.. manim:: AddWithRunTimeScene
class AddWithRunTimeScene(Scene):
def construct(self):
# A 5x5 grid of circles
circles = VGroup(
*[Circle(radius=0.5) for _ in range(25)]
).arrange_in_grid(5, 5)
self.play(
Succession(
# Add a run_time of 0.2 to wait for 0.2 seconds after
# adding the circle, instead of using Wait(0.2) after Add!
*[Add(circle, run_time=0.2) for circle in circles],
rate_func=smooth,
)
)
self.wait()
"""
def __init__(
self, *mobjects: Mobject, run_time: float = 0.0, **kwargs: Any
) -> None:
mobject = mobjects[0] if len(mobjects) == 1 else Group(*mobjects)
super().__init__(mobject, run_time=run_time, introducer=True, **kwargs)
def begin(self) -> None:
pass
def finish(self) -> None:
pass
def clean_up_from_scene(self, scene: Scene) -> None:
pass
def update_mobjects(self, dt: float) -> None:
pass
def interpolate(self, alpha: float) -> None:
pass
def override_animation(
animation_class: type[Animation],
) -> Callable[[Callable], Callable]:

View file

@ -175,11 +175,13 @@ class AnimationGroup(Animation):
]
run_times = to_update["end"] - to_update["start"]
with_zero_run_time = run_times == 0
run_times[with_zero_run_time] = 1
sub_alphas = (anim_group_time - to_update["start"]) / run_times
if time_goes_back:
sub_alphas[sub_alphas < 0] = 0
sub_alphas[(sub_alphas < 0) | with_zero_run_time] = 0
else:
sub_alphas[sub_alphas > 1] = 1
sub_alphas[(sub_alphas > 1) | with_zero_run_time] = 1
for anim_to_update, sub_alpha in zip(to_update["anim"], sub_alphas):
anim_to_update.interpolate(sub_alpha)

View file

@ -611,8 +611,8 @@ class Circumscribe(Succession):
if shape is Rectangle:
frame = SurroundingRectangle(
mobject,
color,
buff,
color=color,
buff=buff,
stroke_width=stroke_width,
)
elif shape is Circle:

View file

@ -0,0 +1,17 @@
"""The Manim CLI, and the available commands for ``manim``.
This page is a work in progress. Please run ``manim`` or ``manim --help`` in
your terminal to find more information on the following commands.
Available commands
------------------
.. autosummary::
:toctree: ../reference
cfg
checkhealth
init
plugins
render
"""

View file

@ -11,22 +11,23 @@ from __future__ import annotations
import contextlib
from ast import literal_eval
from pathlib import Path
from typing import Any, cast
import cloup
from rich.errors import StyleSyntaxError
from rich.style import Style
from ... import cli_ctx_settings, console
from ..._config.utils import config_file_paths, make_config_parser
from ...constants import EPILOG
from ...utils.file_ops import guarantee_existence, open_file
from manim._config import cli_ctx_settings, console
from manim._config.utils import config_file_paths, make_config_parser
from manim.constants import EPILOG
from manim.utils.file_ops import guarantee_existence, open_file
RICH_COLOUR_INSTRUCTIONS: str = """
[red]The default colour is used by the input statement.
If left empty, the default colour will be used.[/red]
[magenta] For a full list of styles, visit[/magenta] [green]https://rich.readthedocs.io/en/latest/style.html[/green]
"""
RICH_NON_STYLE_ENTRIES: str = ["log.width", "log.height", "log.timestamps"]
RICH_NON_STYLE_ENTRIES: list[str] = ["log.width", "log.height", "log.timestamps"]
__all__ = [
"value_from_string",
@ -41,7 +42,8 @@ __all__ = [
def value_from_string(value: str) -> str | int | bool:
"""Extracts the literal of proper datatype from a string.
"""Extract the literal of proper datatype from a ``value`` string.
Parameters
----------
value
@ -49,49 +51,60 @@ def value_from_string(value: str) -> str | int | bool:
Returns
-------
Union[:class:`str`, :class:`int`, :class:`bool`]
Returns the literal of appropriate datatype.
:class:`str` | :class:`int` | :class:`bool`
The literal of appropriate datatype.
"""
with contextlib.suppress(SyntaxError, ValueError):
value = literal_eval(value)
return value
def _is_expected_datatype(value: str, expected: str, style: bool = False) -> bool:
"""Checks whether `value` is the same datatype as `expected`,
and checks if it is a valid `style` if `style` is true.
def _is_expected_datatype(
value: str, expected: str, validate_style: bool = False
) -> bool:
"""Check whether the literal from ``value`` is the same datatype as the
literal from ``expected``. If ``validate_style`` is ``True``, also check if
the style given by ``value`` is valid, according to ``rich``.
Parameters
----------
value
The string of the value to check (obtained from reading the user input).
The string of the value to check, obtained from reading the user input.
expected
The string of the literal datatype must be matched by `value`. Obtained from
reading the cfg file.
style
Whether or not to confirm if `value` is a style, by default False
The string of the literal datatype which must be matched by ``value``.
This is obtained from reading the ``cfg`` file.
validate_style
Whether or not to confirm if ``value`` is a valid style, according to
``rich``. Default is ``False``.
Returns
-------
:class:`bool`
Whether or not `value` matches the datatype of `expected`.
Whether or not the literal from ``value`` matches the datatype of the
literal from ``expected``.
"""
value = value_from_string(value)
expected = type(value_from_string(expected))
value_literal = value_from_string(value)
ExpectedLiteralType = type(value_from_string(expected))
return isinstance(value, expected) and (is_valid_style(value) if style else True)
return isinstance(value_literal, ExpectedLiteralType) and (
(isinstance(value_literal, str) and is_valid_style(value_literal))
if validate_style
else True
)
def is_valid_style(style: str) -> bool:
"""Checks whether the entered color is a valid color according to rich
"""Checks whether the entered color style is valid, according to ``rich``.
Parameters
----------
style
The style to check whether it is valid.
Returns
-------
Boolean
Returns whether it is valid style or not according to rich.
:class:`bool`
Whether the color style is valid or not, according to ``rich``.
"""
try:
Style.parse(style)
@ -100,16 +113,20 @@ def is_valid_style(style: str) -> bool:
return False
def replace_keys(default: dict) -> dict:
"""Replaces _ to . and vice versa in a dictionary for rich
def replace_keys(default: dict[str, Any]) -> dict[str, Any]:
"""Replace ``_`` with ``.`` and vice versa in a dictionary's keys for
``rich``.
Parameters
----------
default
The dictionary to check and replace
The dictionary whose keys will be checked and replaced.
Returns
-------
:class:`dict`
The dictionary which is modified by replacing _ with . and vice versa
The dictionary whose keys are modified by replacing ``_`` with ``.``
and vice versa.
"""
for key in default:
if "_" in key:
@ -133,7 +150,7 @@ def replace_keys(default: dict) -> dict:
help="Manages Manim configuration files.",
)
@cloup.pass_context
def cfg(ctx):
def cfg(ctx: cloup.Context) -> None:
"""Responsible for the cfg subcommand."""
pass
@ -147,7 +164,7 @@ def cfg(ctx):
help="Specify if this config is for user or the working directory.",
)
@cloup.option("-o", "--open", "openfile", is_flag=True)
def write(level: str = None, openfile: bool = False) -> None:
def write(level: str | None = None, openfile: bool = False) -> None:
config_paths = config_file_paths()
console.print(
"[yellow bold]Manim Configuration File Writer[/yellow bold]",
@ -166,7 +183,7 @@ To save your config please save that file and place it in your current working d
action = "save this as"
for category in parser:
console.print(f"{category}", style="bold green underline")
default = parser[category]
default = cast(dict[str, Any], parser[category])
if category == "logger":
console.print(RICH_COLOUR_INSTRUCTIONS)
default = replace_keys(default)
@ -249,7 +266,7 @@ modify write_cfg_subcmd_input to account for it.""",
@cfg.command(context_settings=cli_ctx_settings)
def show():
def show() -> None:
parser = make_config_parser()
rich_non_style_entries = [a.replace(".", "_") for a in RICH_NON_STYLE_ENTRIES]
for category in parser:
@ -269,7 +286,7 @@ def show():
@cfg.command(context_settings=cli_ctx_settings)
@cloup.option("-d", "--directory", default=Path.cwd())
@cloup.pass_context
def export(ctx, directory):
def export(ctx: cloup.Context, directory: str) -> None:
directory_path = Path(directory)
if directory_path.absolute == Path.cwd().absolute:
console.print(

View file

@ -6,58 +6,74 @@ from __future__ import annotations
import os
import shutil
from typing import Callable
from typing import Callable, Protocol, cast
__all__ = ["HEALTH_CHECKS"]
HEALTH_CHECKS = []
class HealthCheckFunction(Protocol):
description: str
recommendation: str
skip_on_failed: list[str]
post_fail_fix_hook: Callable[..., object] | None
__name__: str
def __call__(self) -> bool: ...
HEALTH_CHECKS: list[HealthCheckFunction] = []
def healthcheck(
description: str,
recommendation: str,
skip_on_failed: list[Callable | str] | None = None,
post_fail_fix_hook: Callable | None = None,
):
skip_on_failed: list[HealthCheckFunction | str] | None = None,
post_fail_fix_hook: Callable[..., object] | None = None,
) -> Callable[[Callable[[], bool]], HealthCheckFunction]:
"""Decorator used for declaring health checks.
This decorator attaches some data to a function,
which is then added to a list containing all checks.
This decorator attaches some data to a function, which is then added to a
a list containing all checks.
Parameters
----------
description
A brief description of this check, displayed when
the checkhealth subcommand is run.
A brief description of this check, displayed when the ``checkhealth``
subcommand is run.
recommendation
Help text which is displayed in case the check fails.
skip_on_failed
A list of check functions which, if they fail, cause
the current check to be skipped.
A list of check functions which, if they fail, cause the current check
to be skipped.
post_fail_fix_hook
A function that is supposed to (interactively) help
to fix the detected problem, if possible. This is
only called upon explicit confirmation of the user.
A function that is meant to (interactively) help to fix the detected
problem, if possible. This is only called upon explicit confirmation of
the user.
Returns
-------
A check function, as required by the checkhealth subcommand.
Callable[Callable[[], bool], :class:`HealthCheckFunction`]
A decorator which converts a function into a health check function, as
required by the ``checkhealth`` subcommand.
"""
new_skip_on_failed: list[str]
if skip_on_failed is None:
skip_on_failed = []
skip_on_failed = [
skip.__name__ if callable(skip) else skip for skip in skip_on_failed
]
new_skip_on_failed = []
else:
new_skip_on_failed = [
skip.__name__ if callable(skip) else skip for skip in skip_on_failed
]
def decorator(func):
func.description = description
func.recommendation = recommendation
func.skip_on_failed = skip_on_failed
func.post_fail_fix_hook = post_fail_fix_hook
HEALTH_CHECKS.append(func)
return func
def wrapper(func: Callable[[], bool]) -> HealthCheckFunction:
health_func = cast(HealthCheckFunction, func)
health_func.description = description
health_func.recommendation = recommendation
health_func.skip_on_failed = new_skip_on_failed
health_func.post_fail_fix_hook = post_fail_fix_hook
HEALTH_CHECKS.append(health_func)
return health_func
return decorator
return wrapper
@healthcheck(
@ -75,7 +91,14 @@ def healthcheck(
"PATH variable."
),
)
def is_manim_on_path():
def is_manim_on_path() -> bool:
"""Check whether ``manim`` is in ``PATH``.
Returns
-------
:class:`bool`
Whether ``manim`` is in ``PATH`` or not.
"""
path_to_manim = shutil.which("manim")
return path_to_manim is not None
@ -91,10 +114,30 @@ def is_manim_on_path():
),
skip_on_failed=[is_manim_on_path],
)
def is_manim_executable_associated_to_this_library():
def is_manim_executable_associated_to_this_library() -> bool:
"""Check whether the ``manim`` executable in ``PATH`` is associated to this
library. To verify this, the executable should look like this:
.. code-block:: python
#!<MANIM_PATH>/.../python
import sys
from manim.__main__ import main
if __name__ == "__main__":
sys.exit(main())
Returns
-------
:class:`bool`
Whether the ``manim`` executable in ``PATH`` is associated to this
library or not.
"""
path_to_manim = shutil.which("manim")
with open(path_to_manim, "rb") as f:
manim_exec = f.read()
assert path_to_manim is not None
with open(path_to_manim, "rb") as manim_binary:
manim_exec = manim_binary.read()
# first condition below corresponds to the executable being
# some sort of python script. second condition happens when
@ -114,7 +157,14 @@ def is_manim_executable_associated_to_this_library():
"LaTeX distribution on your operating system."
),
)
def is_latex_available():
def is_latex_available() -> bool:
"""Check whether ``latex`` is in ``PATH`` and can be executed.
Returns
-------
:class:`bool`
Whether ``latex`` is in ``PATH`` and can be executed or not.
"""
path_to_latex = shutil.which("latex")
return path_to_latex is not None and os.access(path_to_latex, os.X_OK)
@ -129,6 +179,13 @@ def is_latex_available():
),
skip_on_failed=[is_latex_available],
)
def is_dvisvgm_available():
def is_dvisvgm_available() -> bool:
"""Check whether ``dvisvgm`` is in ``PATH`` and can be executed.
Returns
-------
:class:`bool`
Whether ``dvisvgm`` is in ``PATH`` and can be executed or not.
"""
path_to_dvisvgm = shutil.which("dvisvgm")
return path_to_dvisvgm is not None and os.access(path_to_dvisvgm, os.X_OK)

View file

@ -11,7 +11,7 @@ import timeit
import click
import cloup
from manim.cli.checkhealth.checks import HEALTH_CHECKS
from manim.cli.checkhealth.checks import HEALTH_CHECKS, HealthCheckFunction
__all__ = ["checkhealth"]
@ -19,13 +19,13 @@ __all__ = ["checkhealth"]
@cloup.command(
context_settings=None,
)
def checkhealth():
def checkhealth() -> None:
"""This subcommand checks whether Manim is installed correctly
and has access to its required (and optional) system dependencies.
"""
click.echo(f"Python executable: {sys.executable}\n")
click.echo("Checking whether your installation of Manim Community is healthy...")
failed_checks = []
failed_checks: list[HealthCheckFunction] = []
for check in HEALTH_CHECKS:
click.echo(f"- {check.description} ... ", nl=False)
@ -63,7 +63,7 @@ def checkhealth():
import manim as mn
class CheckHealthDemo(mn.Scene):
def _inner_construct(self):
def _inner_construct(self) -> None:
banner = mn.ManimBanner().shift(mn.UP * 0.5)
self.play(banner.create())
self.wait(0.5)
@ -80,7 +80,7 @@ def checkhealth():
mn.FadeOut(text_tex_group, shift=mn.DOWN),
)
def construct(self):
def construct(self) -> None:
self.execution_time = timeit.timeit(self._inner_construct, number=1)
with mn.tempconfig({"preview": True, "disable_caching": True}):

View file

@ -6,62 +6,183 @@ In particular, this class is what allows ``manim`` to act as ``manim render``.
This is a vendored version of https://github.com/click-contrib/click-default-group/
under the BSD 3-Clause "New" or "Revised" License.
This library isn't used as a dependency as we need to inherit from ``cloup.Group`` instead
of ``click.Group``.
This library isn't used as a dependency, as we need to inherit from
:class:`cloup.Group` instead of :class:`click.Group`.
"""
from __future__ import annotations
import warnings
from typing import TYPE_CHECKING, Any, Callable
import cloup
from manim.utils.deprecation import deprecated
__all__ = ["DefaultGroup"]
if TYPE_CHECKING:
from click import Command, Context
class DefaultGroup(cloup.Group):
"""Invokes a subcommand marked with ``default=True`` if any subcommand not
"""Invokes a subcommand marked with ``default=True`` if any subcommand is not
chosen.
Parameters
----------
*args
Positional arguments to forward to :class:`cloup.Group`.
**kwargs
Keyword arguments to forward to :class:`cloup.Group`. The keyword
``ignore_unknown_options`` must be set to ``False``.
Attributes
----------
default_cmd_name : str | None
The name of the default command, if specified through the ``default``
keyword argument. Otherwise, this is set to ``None``.
default_if_no_args : bool
Whether to include or not the default command, if no command arguments
are supplied. This can be specified through the ``default_if_no_args``
keyword argument. Default is ``False``.
"""
def __init__(self, *args, **kwargs):
def __init__(self, *args: Any, **kwargs: Any):
# To resolve as the default command.
if not kwargs.get("ignore_unknown_options", True):
raise ValueError("Default group accepts unknown options")
self.ignore_unknown_options = True
self.default_cmd_name = kwargs.pop("default", None)
self.default_if_no_args = kwargs.pop("default_if_no_args", False)
self.default_cmd_name: str | None = kwargs.pop("default", None)
self.default_if_no_args: bool = kwargs.pop("default_if_no_args", False)
super().__init__(*args, **kwargs)
def set_default_command(self, command):
"""Sets a command function as the default command."""
def set_default_command(self, command: Command) -> None:
"""Sets a command function as the default command.
Parameters
----------
command
The command to set as default.
"""
cmd_name = command.name
self.add_command(command)
self.default_cmd_name = cmd_name
def parse_args(self, ctx, args):
if not args and self.default_if_no_args:
args.insert(0, self.default_cmd_name)
return super().parse_args(ctx, args)
def parse_args(self, ctx: Context, args: list[str]) -> list[str]:
"""Parses the list of ``args`` by forwarding it to
:meth:`cloup.Group.parse_args`. Before doing so, if
:attr:`default_if_no_args` is set to ``True`` and ``args`` is empty,
this function appends to it the name of the default command specified
by :attr:`default_cmd_name`.
def get_command(self, ctx, cmd_name):
if cmd_name not in self.commands:
Parameters
----------
ctx
The Click context.
args
A list of arguments. If it's empty and :attr:`default_if_no_args`
is ``True``, append the name of the default command to it.
Returns
-------
list[str]
The parsed arguments.
"""
if not args and self.default_if_no_args and self.default_cmd_name:
args.insert(0, self.default_cmd_name)
parsed_args: list[str] = super().parse_args(ctx, args)
return parsed_args
def get_command(self, ctx: Context, cmd_name: str) -> Command | None:
"""Get a command function by its name, by forwarding the arguments to
:meth:`cloup.Group.get_command`. If ``cmd_name`` does not match any of
the command names in :attr:`commands`, attempt to get the default command
instead.
Parameters
----------
ctx
The Click context.
cmd_name
The name of the command to get.
Returns
-------
:class:`click.Command` | None
The command, if found. Otherwise, ``None``.
"""
if cmd_name not in self.commands and self.default_cmd_name:
# No command name matched.
ctx.arg0 = cmd_name
ctx.meta["arg0"] = cmd_name
cmd_name = self.default_cmd_name
return super().get_command(ctx, cmd_name)
def resolve_command(self, ctx, args):
base = super()
cmd_name, cmd, args = base.resolve_command(ctx, args)
if hasattr(ctx, "arg0"):
args.insert(0, ctx.arg0)
cmd_name = cmd.name
def resolve_command(
self, ctx: Context, args: list[str]
) -> tuple[str | None, Command | None, list[str]]:
"""Given a list of ``args`` given by a CLI, find a command which
matches the first element, and return its name (``cmd_name``), the
command function itself (``cmd``) and the rest of the arguments which
shall be passed to the function (``cmd_args``). If not found, return
``None``, ``None`` and the rest of the arguments.
After resolving the command, if the Click context given by ``ctx``
contains an ``arg0`` attribute in its :attr:`click.Context.meta`
dictionary, insert it as the first element of the returned
``cmd_args``.
Parameters
----------
ctx
The Click context.
cmd_name
The name of the command to get.
Returns
-------
cmd_name : str | None
The command name, if found. Otherwise, ``None``.
cmd : :class:`click.Command` | None
The command, if found. Otherwise, ``None``.
cmd_args : list[str]
The rest of the arguments to be passed to ``cmd``.
"""
cmd_name, cmd, args = super().resolve_command(ctx, args)
if "arg0" in ctx.meta:
args.insert(0, ctx.meta["arg0"])
if cmd is not None:
cmd_name = cmd.name
return cmd_name, cmd, args
def command(self, *args, **kwargs):
@deprecated
def command(
self, *args: Any, **kwargs: Any
) -> Callable[[Callable[..., object]], Command]:
"""Return a decorator which converts any function into the default
subcommand for this :class:`DefaultGroup`.
.. warning::
This method is deprecated. Use the ``default`` parameter of
:class:`DefaultGroup` or :meth:`set_default_command` instead.
Parameters
----------
*args
Positional arguments to pass to :meth:`cloup.Group.command`.
**kwargs
Keyword arguments to pass to :meth:`cloup.Group.command`.
Returns
-------
Callable[[Callable[..., object]], click.Command]
A decorator which transforms its input into this
:class:`DefaultGroup`'s default subcommand.
"""
default = kwargs.pop("default", False)
decorator = super().command(*args, **kwargs)
decorator: Callable[[Callable[..., object]], Command] = super().command(
*args, **kwargs
)
if not default:
return decorator
warnings.warn(
@ -70,7 +191,7 @@ class DefaultGroup(cloup.Group):
stacklevel=1,
)
def _decorator(f):
def _decorator(f: Callable) -> Command:
cmd = decorator(f)
self.set_default_command(cmd)
return cmd

View file

@ -10,13 +10,14 @@ from __future__ import annotations
import configparser
from pathlib import Path
from typing import Any
import click
import cloup
from ... import console
from ...constants import CONTEXT_SETTINGS, EPILOG, QUALITIES
from ...utils.file_ops import (
from manim._config import console
from manim.constants import CONTEXT_SETTINGS, EPILOG, QUALITIES
from manim.utils.file_ops import (
add_import_statement,
copy_template_files,
get_template_names,
@ -34,15 +35,15 @@ CFG_DEFAULTS = {
__all__ = ["select_resolution", "update_cfg", "project", "scene"]
def select_resolution():
def select_resolution() -> tuple[int, int]:
"""Prompts input of type click.Choice from user. Presents options from QUALITIES constant.
Returns
-------
:class:`tuple`
Tuple containing height and width.
tuple[int, int]
Tuple containing height and width.
"""
resolution_options = []
resolution_options: list[tuple[int, int]] = []
for quality in QUALITIES.items():
resolution_options.append(
(quality[1]["pixel_height"], quality[1]["pixel_width"]),
@ -54,18 +55,21 @@ def select_resolution():
show_default=False,
default="480p",
)
return [res for res in resolution_options if f"{res[0]}p" == choice][0]
matches = [res for res in resolution_options if f"{res[0]}p" == choice]
return matches[0]
def update_cfg(cfg_dict: dict, project_cfg_path: Path):
"""Updates the manim.cfg file after reading it from the project_cfg_path.
def update_cfg(cfg_dict: dict[str, Any], project_cfg_path: Path) -> None:
"""Update the ``manim.cfg`` file after reading it from the specified
``project_cfg_path``.
Parameters
----------
cfg_dict
values used to update manim.cfg found project_cfg_path.
Values used to update ``manim.cfg`` which is found in
``project_cfg_path``.
project_cfg_path
Path of manim.cfg file.
Path of the ``manim.cfg`` file.
"""
config = configparser.ConfigParser()
config.read(project_cfg_path)
@ -85,7 +89,7 @@ def update_cfg(cfg_dict: dict, project_cfg_path: Path):
context_settings=CONTEXT_SETTINGS,
epilog=EPILOG,
)
@cloup.argument("project_name", type=Path, required=False)
@cloup.argument("project_name", type=cloup.Path(path_type=Path), required=False)
@cloup.option(
"-d",
"--default",
@ -94,13 +98,14 @@ def update_cfg(cfg_dict: dict, project_cfg_path: Path):
help="Default settings for project creation.",
nargs=1,
)
def project(default_settings, **args):
def project(default_settings: bool, **kwargs: Any) -> None:
"""Creates a new project.
PROJECT_NAME is the name of the folder in which the new project will be initialized.
"""
if args["project_name"]:
project_name = args["project_name"]
project_name: Path
if kwargs["project_name"]:
project_name = kwargs["project_name"]
else:
project_name = click.prompt("Project Name", type=Path)
@ -117,7 +122,7 @@ def project(default_settings, **args):
)
else:
project_name.mkdir()
new_cfg = {}
new_cfg: dict[str, Any] = {}
new_cfg_path = Path.resolve(project_name / "manim.cfg")
if not default_settings:
@ -145,23 +150,23 @@ def project(default_settings, **args):
)
@cloup.argument("scene_name", type=str, required=True)
@cloup.argument("file_name", type=str, required=False)
def scene(**args):
def scene(**kwargs: Any) -> None:
"""Inserts a SCENE to an existing FILE or creates a new FILE.
SCENE is the name of the scene that will be inserted.
FILE is the name of file in which the SCENE will be inserted.
"""
template_name = click.prompt(
template_name: str = click.prompt(
"template",
type=click.Choice(get_template_names(), False),
default="Default",
)
scene = (get_template_path() / f"{template_name}.mtp").resolve().read_text()
scene = scene.replace(template_name + "Template", args["scene_name"], 1)
scene = scene.replace(template_name + "Template", kwargs["scene_name"], 1)
if args["file_name"]:
file_name = Path(args["file_name"])
if kwargs["file_name"]:
file_name = Path(kwargs["file_name"])
if file_name.suffix != ".py":
file_name = file_name.with_suffix(file_name.suffix + ".py")
@ -190,7 +195,7 @@ def scene(**args):
help="Create a new project or insert a new scene.",
)
@cloup.pass_context
def init(ctx):
def init(ctx: cloup.Context) -> None:
pass

View file

@ -10,8 +10,8 @@ from __future__ import annotations
import cloup
from ...constants import CONTEXT_SETTINGS, EPILOG
from ...plugins.plugins_flags import list_plugins
from manim.constants import CONTEXT_SETTINGS, EPILOG
from manim.plugins.plugins_flags import list_plugins
__all__ = ["plugins"]
@ -29,6 +29,16 @@ __all__ = ["plugins"]
is_flag=True,
help="List available plugins.",
)
def plugins(list_available):
def plugins(list_available: bool) -> None:
"""Print a list of all available plugins when calling ``manim plugins -l``
or ``manim plugins --list``.
Parameters
----------
list_available
If the ``-l`` or ``-list`` option is passed to ``manim plugins``, this
parameter will be set to ``True``, which will print a list of all
available plugins.
"""
if list_available:
list_plugins()

View file

@ -13,13 +13,20 @@ import json
import sys
import urllib.error
import urllib.request
from argparse import Namespace
from pathlib import Path
from typing import cast
from typing import Any, cast
import cloup
from manim import __version__, config, console, error_console, logger
from manim._config import tempconfig
from manim import __version__
from manim._config import (
config,
console,
error_console,
logger,
tempconfig,
)
from manim.cli.render.ease_of_access_options import ease_of_access_options
from manim.cli.render.global_options import global_options
from manim.cli.render.output_options import output_options
@ -30,7 +37,25 @@ from manim.utils.module_ops import scene_classes_from_file
__all__ = ["render"]
__all__ = ["render"]
class ClickArgs(Namespace):
def __init__(self, args: dict[str, Any]) -> None:
for name in args:
setattr(self, name, args[name])
def _get_kwargs(self) -> list[tuple[str, Any]]:
return list(self.__dict__.items())
def __eq__(self, other: object) -> bool:
if not isinstance(other, ClickArgs):
return NotImplemented
return vars(self) == vars(other)
def __contains__(self, key: str) -> bool:
return key in self.__dict__
def __repr__(self) -> str:
return str(self.__dict__)
@cloup.command(
@ -38,47 +63,26 @@ __all__ = ["render"]
no_args_is_help=True,
epilog=EPILOG,
)
@cloup.argument("file", type=Path, required=True)
@cloup.argument("file", type=cloup.Path(path_type=Path), required=True)
@cloup.argument("scene_names", required=False, nargs=-1)
@global_options
@output_options
@render_options
@ease_of_access_options
def render(
**args,
):
def render(**kwargs: Any) -> ClickArgs | dict[str, Any]:
"""Render SCENE(S) from the input FILE.
FILE is the file path of the script or a config file.
SCENES is an optional list of scenes in the file.
"""
if args["show_in_file_browser"]:
if kwargs["show_in_file_browser"]:
logger.warning(
"The short form of show_in_file_browser is deprecated and will be moved to support --format.",
)
class ClickArgs:
def __init__(self, args):
for name in args:
setattr(self, name, args[name])
def _get_kwargs(self):
return list(self.__dict__.items())
def __eq__(self, other):
if not isinstance(other, ClickArgs):
return NotImplemented
return vars(self) == vars(other)
def __contains__(self, key):
return key in self.__dict__
def __repr__(self):
return str(self.__dict__)
click_args = ClickArgs(args)
if args["jupyter"]:
click_args = ClickArgs(kwargs)
if kwargs["jupyter"]:
return click_args
config.digest_args(click_args)
@ -123,4 +127,4 @@ def render(
"You should consider upgrading via [yellow]pip install -U manim[/yellow]",
)
return args
return kwargs

View file

@ -2,23 +2,56 @@ from __future__ import annotations
import logging
import re
import sys
from typing import TYPE_CHECKING
from cloup import Choice, option, option_group
if TYPE_CHECKING:
from click import Context, Option
__all__ = ["global_options"]
logger = logging.getLogger("manim")
def validate_gui_location(ctx, param, value):
if value:
try:
x_offset, y_offset = map(int, re.split(r"[;,\-]", value))
return (x_offset, y_offset)
except Exception:
logger.error("GUI location option is invalid.")
exit()
def validate_gui_location(
ctx: Context, param: Option, value: str | None
) -> tuple[int, int] | None:
"""If the ``value`` string is given, extract from it the GUI location,
which should be in any of these formats: 'x;y', 'x,y' or 'x-y'.
Parameters
----------
ctx
The Click context.
param
A Click option.
value
The optional string which will be parsed.
Returns
-------
tuple[int, int] | None
If ``value`` is ``None``, the return value is ``None``. Otherwise, it's
the ``(x, y)`` location for the GUI.
Raises
------
ValueError
If ``value`` has an invalid format.
"""
if value is None:
return None
try:
x_offset, y_offset = map(int, re.split(r"[;,\-]", value))
except Exception:
logger.error("GUI location option is invalid.")
sys.exit()
return (x_offset, y_offset)
global_options = option_group(

View file

@ -2,11 +2,16 @@ from __future__ import annotations
import logging
import re
import sys
from typing import TYPE_CHECKING
from cloup import Choice, option, option_group
from manim.constants import QUALITIES
if TYPE_CHECKING:
from click import Context, Option
__all__ = ["render_options"]
logger = logging.getLogger("manim")
@ -14,30 +19,89 @@ logger = logging.getLogger("manim")
__all__ = ["render_options"]
def validate_scene_range(ctx, param, value):
def validate_scene_range(
ctx: Context, param: Option, value: str | None
) -> tuple[int] | tuple[int, int] | None:
"""If the ``value`` string is given, extract from it the scene range, which
should be in any of these formats: 'start', 'start;end', 'start,end' or
'start-end'. Otherwise, return ``None``.
Parameters
----------
ctx
The Click context.
param
A Click option.
value
The optional string which will be parsed.
Returns
-------
tuple[int] | tuple[int, int] | None
If ``value`` is ``None``, the return value is ``None``. Otherwise, it's
the scene range, given by a tuple which may contain a single value
``start`` or two values ``start`` and ``end``.
Raises
------
ValueError
If ``value`` has an invalid format.
"""
if value is None:
return None
try:
start = int(value)
return (start,)
except Exception:
pass
if value:
try:
start, end = map(int, re.split(r"[;,\-]", value))
return start, end
except Exception:
logger.error("Couldn't determine a range for -n option.")
exit()
try:
start, end = map(int, re.split(r"[;,\-]", value))
except Exception:
logger.error("Couldn't determine a range for -n option.")
sys.exit()
return start, end
def validate_resolution(ctx, param, value):
if value:
try:
start, end = map(int, re.split(r"[;,\-]", value))
return (start, end)
except Exception:
logger.error("Resolution option is invalid.")
exit()
def validate_resolution(
ctx: Context, param: Option, value: str | None
) -> tuple[int, int] | None:
"""If the ``value`` string is given, extract from it the resolution, which
should be in any of these formats: 'W;H', 'W,H' or 'W-H'. Otherwise, return
``None``.
Parameters
----------
ctx
The Click context.
param
A Click option.
value
The optional string which will be parsed.
Returns
-------
tuple[int, int] | None
If ``value`` is ``None``, the return value is ``None``. Otherwise, it's
the resolution as a ``(W, H)`` tuple.
Raises
------
ValueError
If ``value`` has an invalid format.
"""
if value is None:
return None
try:
width, height = map(int, re.split(r"[;,\-]", value))
except Exception:
logger.error("Resolution option is invalid.")
sys.exit()
return width, height
render_options = option_group(
@ -81,7 +145,7 @@ render_options = option_group(
"--quality",
default=None,
type=Choice(
reversed([q["flag"] for q in QUALITIES.values() if q["flag"]]), # type: ignore[arg-type]
list(reversed([q["flag"] for q in QUALITIES.values() if q["flag"]])),
case_sensitive=False,
),
help="Render quality at the follow resolution framerates, respectively: "

View file

@ -3,6 +3,7 @@
from __future__ import annotations
from enum import Enum
from typing import TypedDict
import numpy as np
from cloup import Context
@ -199,8 +200,16 @@ DEGREES = TAU / 360
RADIANS: float = 1.0
"""Just a default to select for camera."""
class QualityDict(TypedDict):
flag: str | None
pixel_height: int
pixel_width: int
frame_rate: int
# Video qualities
QUALITIES: dict[str, dict[str, str | int | None]] = {
QUALITIES: dict[str, QualityDict] = {
"fourk_quality": {
"flag": "k",
"pixel_height": 2160,

View file

@ -44,7 +44,7 @@ __all__ = [
import itertools
import warnings
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, cast
import numpy as np
from typing_extensions import Self
@ -64,11 +64,20 @@ from manim.utils.space_ops import (
)
if TYPE_CHECKING:
from collections.abc import Iterable
from typing import Any
import manim.mobject.geometry.tips as tips
from manim.mobject.mobject import Mobject
from manim.mobject.text.tex_mobject import SingleStringMathTex, Tex
from manim.mobject.text.text_mobject import Text
from manim.typing import CubicBezierPoints, Point3D, QuadraticBezierPoints, Vector3D
from manim.typing import (
CubicBezierPoints,
InternalPoint3D,
Point3D,
QuadraticBezierPoints,
Vector3D,
)
class TipableVMobject(VMobject, metaclass=ConvertToOpenGL):
@ -94,7 +103,7 @@ class TipableVMobject(VMobject, metaclass=ConvertToOpenGL):
tip_length: float = DEFAULT_ARROW_TIP_LENGTH,
normal_vector: Vector3D = OUT,
tip_style: dict = {},
**kwargs,
**kwargs: Any,
) -> None:
self.tip_length: float = tip_length
self.normal_vector: Vector3D = normal_vector
@ -127,10 +136,10 @@ class TipableVMobject(VMobject, metaclass=ConvertToOpenGL):
def create_tip(
self,
tip_shape: type[tips.ArrowTip] | None = None,
tip_length: float = None,
tip_width: float = None,
tip_length: float | None = None,
tip_width: float | None = None,
at_start: bool = False,
):
) -> tips.ArrowTip:
"""Stylises the tip, positions it spatially, and returns
the newly instantiated tip to the caller.
"""
@ -143,13 +152,13 @@ class TipableVMobject(VMobject, metaclass=ConvertToOpenGL):
tip_shape: type[tips.ArrowTip] | None = None,
tip_length: float | None = None,
tip_width: float | None = None,
):
) -> tips.ArrowTip | tips.ArrowTriangleFilledTip:
"""Returns a tip that has been stylistically configured,
but has not yet been given a position in space.
"""
from manim.mobject.geometry.tips import ArrowTriangleFilledTip
style = {}
style: dict[str, Any] = {}
if tip_shape is None:
tip_shape = ArrowTriangleFilledTip
@ -167,7 +176,7 @@ class TipableVMobject(VMobject, metaclass=ConvertToOpenGL):
tip = tip_shape(length=tip_length, **style)
return tip
def position_tip(self, tip: tips.ArrowTip, at_start: bool = False):
def position_tip(self, tip: tips.ArrowTip, at_start: bool = False) -> tips.ArrowTip:
# Last two control points, defining both
# the end, and the tangency direction
if at_start:
@ -176,16 +185,18 @@ class TipableVMobject(VMobject, metaclass=ConvertToOpenGL):
else:
handle = self.get_last_handle()
anchor = self.get_end()
angles = cartesian_to_spherical(handle - anchor)
angles = cartesian_to_spherical((handle - anchor).tolist())
tip.rotate(
angles[1] - PI - tip.tip_angle,
) # Rotates the tip along the azimuthal
if not hasattr(self, "_init_positioning_axis"):
axis = [
np.sin(angles[1]),
-np.cos(angles[1]),
0,
] # Obtains the perpendicular of the tip
axis = np.array(
[
np.sin(angles[1]),
-np.cos(angles[1]),
0,
]
) # Obtains the perpendicular of the tip
tip.rotate(
-angles[2] + PI / 2,
axis=axis,
@ -245,7 +256,7 @@ class TipableVMobject(VMobject, metaclass=ConvertToOpenGL):
result.add(self.start_tip)
return result
def get_tip(self):
def get_tip(self) -> VMobject:
"""Returns the TipableVMobject instance's (first) tip,
otherwise throws an exception.
"""
@ -253,24 +264,28 @@ class TipableVMobject(VMobject, metaclass=ConvertToOpenGL):
if len(tips) == 0:
raise Exception("tip not found")
else:
return tips[0]
tip: VMobject = tips[0]
return tip
def get_default_tip_length(self) -> float:
return self.tip_length
def get_first_handle(self) -> Point3D:
def get_first_handle(self) -> InternalPoint3D:
# Type inference of extracting an element from a list, is not
# supported by numpy, see this numpy issue
# https://github.com/numpy/numpy/issues/16544
return self.points[1]
def get_last_handle(self) -> Point3D:
def get_last_handle(self) -> InternalPoint3D:
return self.points[-2]
def get_end(self) -> Point3D:
def get_end(self) -> InternalPoint3D:
if self.has_tip():
return self.tip.get_start()
else:
return super().get_end()
def get_start(self) -> Point3D:
def get_start(self) -> InternalPoint3D:
if self.has_start_tip():
return self.start_tip.get_start()
else:
@ -278,7 +293,7 @@ class TipableVMobject(VMobject, metaclass=ConvertToOpenGL):
def get_length(self) -> float:
start, end = self.get_start_and_end()
return np.linalg.norm(start - end)
return float(np.linalg.norm(start - end))
class Arc(TipableVMobject):
@ -298,20 +313,20 @@ class Arc(TipableVMobject):
def __init__(
self,
radius: float = 1.0,
radius: float | None = 1.0,
start_angle: float = 0,
angle: float = TAU / 4,
num_components: int = 9,
arc_center: Point3D = ORIGIN,
**kwargs,
arc_center: InternalPoint3D = ORIGIN,
**kwargs: Any,
):
if radius is None: # apparently None is passed by ArcBetweenPoints
radius = 1.0
self.radius = radius
self.num_components: int = num_components
self.arc_center: Point3D = arc_center
self.start_angle: float = start_angle
self.angle: float = angle
self.num_components = num_components
self.arc_center = arc_center
self.start_angle = start_angle
self.angle = angle
self._failed_to_get_center: bool = False
super().__init__(**kwargs)
@ -380,7 +395,7 @@ class Arc(TipableVMobject):
handles2 = anchors[1:] - (d_theta / 3) * tangent_vectors[1:]
self.set_anchors_and_handles(anchors[:-1], handles1, handles2, anchors[1:])
def get_arc_center(self, warning: bool = True) -> Point3D:
def get_arc_center(self, warning: bool = True) -> InternalPoint3D:
"""Looks at the normals to the first two
anchors, and finds their intersection points
"""
@ -408,12 +423,15 @@ class Arc(TipableVMobject):
self._failed_to_get_center = True
return np.array(ORIGIN)
def move_arc_center_to(self, point: Point3D) -> Self:
def move_arc_center_to(self, point: InternalPoint3D) -> Self:
self.shift(point - self.get_arc_center())
return self
def stop_angle(self) -> float:
return angle_of_vector(self.points[-1] - self.get_arc_center()) % TAU
return cast(
float,
angle_of_vector(self.points[-1] - self.get_arc_center()) % TAU,
)
class ArcBetweenPoints(Arc):
@ -440,8 +458,8 @@ class ArcBetweenPoints(Arc):
start: Point3D,
end: Point3D,
angle: float = TAU / 4,
radius: float = None,
**kwargs,
radius: float | None = None,
**kwargs: Any,
) -> None:
if radius is not None:
self.radius = radius
@ -461,19 +479,20 @@ class ArcBetweenPoints(Arc):
super().__init__(radius=radius, angle=angle, **kwargs)
if angle == 0:
self.set_points_as_corners([LEFT, RIGHT])
self.set_points_as_corners(np.array([LEFT, RIGHT]))
self.put_start_and_end_on(start, end)
if radius is None:
center = self.get_arc_center(warning=False)
if not self._failed_to_get_center:
self.radius = np.linalg.norm(np.array(start) - np.array(center))
temp_radius: float = np.linalg.norm(np.array(start) - np.array(center))
self.radius = temp_radius
else:
self.radius = np.inf
class CurvedArrow(ArcBetweenPoints):
def __init__(self, start_point: Point3D, end_point: Point3D, **kwargs) -> None:
def __init__(self, start_point: Point3D, end_point: Point3D, **kwargs: Any) -> None:
from manim.mobject.geometry.tips import ArrowTriangleFilledTip
tip_shape = kwargs.pop("tip_shape", ArrowTriangleFilledTip)
@ -482,7 +501,7 @@ class CurvedArrow(ArcBetweenPoints):
class CurvedDoubleArrow(CurvedArrow):
def __init__(self, start_point: Point3D, end_point: Point3D, **kwargs) -> None:
def __init__(self, start_point: Point3D, end_point: Point3D, **kwargs: Any) -> None:
if "tip_shape_end" in kwargs:
kwargs["tip_shape"] = kwargs.pop("tip_shape_end")
from manim.mobject.geometry.tips import ArrowTriangleFilledTip
@ -521,7 +540,7 @@ class Circle(Arc):
self,
radius: float | None = None,
color: ParsableManimColor = RED,
**kwargs,
**kwargs: Any,
) -> None:
super().__init__(
radius=radius,
@ -618,7 +637,9 @@ class Circle(Arc):
return self.point_from_proportion(proportion)
@staticmethod
def from_three_points(p1: Point3D, p2: Point3D, p3: Point3D, **kwargs) -> Self:
def from_three_points(
p1: Point3D, p2: Point3D, p3: Point3D, **kwargs: Any
) -> Circle:
"""Returns a circle passing through the specified
three points.
@ -638,10 +659,10 @@ class Circle(Arc):
self.add(NumberPlane(), circle, dots)
"""
center = line_intersection(
perpendicular_bisector([p1, p2]),
perpendicular_bisector([p2, p3]),
perpendicular_bisector([np.asarray(p1), np.asarray(p2)]),
perpendicular_bisector([np.asarray(p2), np.asarray(p3)]),
)
radius = np.linalg.norm(p1 - center)
radius: float = np.linalg.norm(p1 - center)
return Circle(radius=radius, **kwargs).shift(center)
@ -683,7 +704,7 @@ class Dot(Circle):
stroke_width: float = 0,
fill_opacity: float = 1.0,
color: ParsableManimColor = WHITE,
**kwargs,
**kwargs: Any,
) -> None:
super().__init__(
arc_center=point,
@ -704,7 +725,7 @@ class AnnotationDot(Dot):
stroke_width: float = 5,
stroke_color: ParsableManimColor = WHITE,
fill_color: ParsableManimColor = BLUE,
**kwargs,
**kwargs: Any,
) -> None:
super().__init__(
radius=radius,
@ -753,12 +774,12 @@ class LabeledDot(Dot):
self,
label: str | SingleStringMathTex | Text | Tex,
radius: float | None = None,
**kwargs,
**kwargs: Any,
) -> None:
if isinstance(label, str):
from manim import MathTex
rendered_label = MathTex(label, color=BLACK)
rendered_label: VMobject = MathTex(label, color=BLACK)
else:
rendered_label = label
@ -794,7 +815,7 @@ class Ellipse(Circle):
self.add(ellipse_group)
"""
def __init__(self, width: float = 2, height: float = 1, **kwargs) -> None:
def __init__(self, width: float = 2, height: float = 1, **kwargs: Any) -> None:
super().__init__(**kwargs)
self.stretch_to_fit_width(width)
self.stretch_to_fit_height(height)
@ -855,7 +876,7 @@ class AnnularSector(Arc):
fill_opacity: float = 1,
stroke_width: float = 0,
color: ParsableManimColor = WHITE,
**kwargs,
**kwargs: Any,
) -> None:
self.inner_radius = inner_radius
self.outer_radius = outer_radius
@ -904,7 +925,7 @@ class Sector(AnnularSector):
self.add(sector, sector2)
"""
def __init__(self, radius: float = 1, **kwargs) -> None:
def __init__(self, radius: float = 1, **kwargs: Any) -> None:
super().__init__(inner_radius=0, outer_radius=radius, **kwargs)
@ -934,13 +955,13 @@ class Annulus(Circle):
def __init__(
self,
inner_radius: float | None = 1,
outer_radius: float | None = 2,
inner_radius: float = 1,
outer_radius: float = 2,
fill_opacity: float = 1,
stroke_width: float = 0,
color: ParsableManimColor = WHITE,
mark_paths_closed: bool = False,
**kwargs,
**kwargs: Any,
) -> None:
self.mark_paths_closed = mark_paths_closed # is this even used?
self.inner_radius = inner_radius
@ -990,7 +1011,7 @@ class CubicBezier(VMobject, metaclass=ConvertToOpenGL):
start_handle: CubicBezierPoints,
end_handle: CubicBezierPoints,
end_anchor: CubicBezierPoints,
**kwargs,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self.add_cubic_bezier_curve(start_anchor, start_handle, end_handle, end_anchor)
@ -1081,14 +1102,16 @@ class ArcPolygon(VMobject, metaclass=ConvertToOpenGL):
angle: float = PI / 4,
radius: float | None = None,
arc_config: list[dict] | None = None,
**kwargs,
**kwargs: Any,
) -> None:
n = len(vertices)
point_pairs = [(vertices[k], vertices[(k + 1) % n]) for k in range(n)]
if not arc_config:
if radius:
all_arc_configs = itertools.repeat({"radius": radius}, len(point_pairs))
all_arc_configs: Iterable[dict] = itertools.repeat(
{"radius": radius}, len(point_pairs)
)
else:
all_arc_configs = itertools.repeat({"angle": angle}, len(point_pairs))
elif isinstance(arc_config, dict):
@ -1220,7 +1243,7 @@ class ArcPolygonFromArcs(VMobject, metaclass=ConvertToOpenGL):
self.wait(2)
"""
def __init__(self, *arcs: Arc | ArcBetweenPoints, **kwargs) -> None:
def __init__(self, *arcs: Arc | ArcBetweenPoints, **kwargs: Any) -> None:
if not all(isinstance(m, (Arc, ArcBetweenPoints)) for m in arcs):
raise ValueError(
"All ArcPolygon submobjects must be of type Arc/ArcBetweenPoints",

View file

@ -12,7 +12,9 @@ from manim.mobject.opengl.opengl_compatibility import ConvertToOpenGL
from manim.mobject.types.vectorized_mobject import VMobject
if TYPE_CHECKING:
from manim.typing import Point2D_Array, Point3D_Array
from typing import Any
from manim.typing import InternalPoint3D_Array, Point2D_Array
__all__ = ["Union", "Intersection", "Difference", "Exclusion"]
@ -28,7 +30,7 @@ class _BooleanOps(VMobject, metaclass=ConvertToOpenGL):
self,
points: Point2D_Array,
z_dim: float = 0.0,
) -> Point3D_Array:
) -> InternalPoint3D_Array:
"""Converts an iterable with coordinates in 2D to 3D by adding
:attr:`z_dim` as the Z coordinate.
@ -49,13 +51,14 @@ class _BooleanOps(VMobject, metaclass=ConvertToOpenGL):
>>> a = _BooleanOps()
>>> p = [(1, 2), (3, 4)]
>>> a._convert_2d_to_3d_array(p)
[array([1., 2., 0.]), array([3., 4., 0.])]
array([[1., 2., 0.],
[3., 4., 0.]])
"""
points = list(points)
for i, point in enumerate(points):
list_of_points = list(points)
for i, point in enumerate(list_of_points):
if len(point) == 2:
points[i] = np.array(list(point) + [z_dim])
return points
list_of_points[i] = np.array(list(point) + [z_dim])
return np.asarray(list_of_points)
def _convert_vmobject_to_skia_path(self, vmobject: VMobject) -> SkiaPath:
"""Converts a :class:`~.VMobject` to SkiaPath. This method only works for
@ -81,7 +84,6 @@ class _BooleanOps(VMobject, metaclass=ConvertToOpenGL):
if len(points) == 0: # what? No points so return empty path
return path
# In OpenGL it's quadratic beizer curves while on Cairo it's cubic...
subpaths = vmobject.get_subpaths_from_points(points)
for subpath in subpaths:
quads = vmobject.get_bezier_tuples_from_points(subpath)
@ -162,7 +164,7 @@ class Union(_BooleanOps):
"""
def __init__(self, *vmobjects: VMobject, **kwargs) -> None:
def __init__(self, *vmobjects: VMobject, **kwargs: Any) -> None:
if len(vmobjects) < 2:
raise ValueError("At least 2 mobjects needed for Union.")
super().__init__(**kwargs)
@ -201,7 +203,7 @@ class Difference(_BooleanOps):
"""
def __init__(self, subject: VMobject, clip: VMobject, **kwargs) -> None:
def __init__(self, subject: VMobject, clip: VMobject, **kwargs: Any) -> None:
super().__init__(**kwargs)
outpen = SkiaPath()
difference(
@ -243,7 +245,7 @@ class Intersection(_BooleanOps):
"""
def __init__(self, *vmobjects: VMobject, **kwargs) -> None:
def __init__(self, *vmobjects: VMobject, **kwargs: Any) -> None:
if len(vmobjects) < 2:
raise ValueError("At least 2 mobjects needed for Intersection.")
@ -296,7 +298,7 @@ class Exclusion(_BooleanOps):
"""
def __init__(self, subject: VMobject, clip: VMobject, **kwargs) -> None:
def __init__(self, subject: VMobject, clip: VMobject, **kwargs: Any) -> None:
super().__init__(**kwargs)
outpen = SkiaPath()
xor(

View file

@ -2,17 +2,116 @@ r"""Mobjects that inherit from lines and contain a label along the length."""
from __future__ import annotations
__all__ = ["LabeledLine", "LabeledArrow"]
__all__ = ["Label", "LabeledLine", "LabeledArrow", "LabeledPolygram"]
from typing import TYPE_CHECKING
import numpy as np
from manim.constants import *
from manim.mobject.geometry.line import Arrow, Line
from manim.mobject.geometry.polygram import Polygram
from manim.mobject.geometry.shape_matchers import (
BackgroundRectangle,
SurroundingRectangle,
)
from manim.mobject.text.tex_mobject import MathTex, Tex
from manim.mobject.text.text_mobject import Text
from manim.utils.color import WHITE, ManimColor, ParsableManimColor
from manim.mobject.types.vectorized_mobject import VGroup
from manim.utils.color import WHITE
from manim.utils.polylabel import polylabel
if TYPE_CHECKING:
from typing import Any
from manim.typing import Point3D_Array
class Label(VGroup):
"""A Label consisting of text surrounded by a frame.
Parameters
----------
label
Label that will be displayed.
label_config
A dictionary containing the configuration for the label.
This is only applied if ``label`` is of type ``str``.
box_config
A dictionary containing the configuration for the background box.
frame_config
A dictionary containing the configuration for the frame.
Examples
--------
.. manim:: LabelExample
:save_last_frame:
:quality: high
class LabelExample(Scene):
def construct(self):
label = Label(
label=Text('Label Text', font='sans-serif'),
box_config = {
"color" : BLUE,
"fill_opacity" : 0.75
}
)
label.scale(3)
self.add(label)
"""
def __init__(
self,
label: str | Tex | MathTex | Text,
label_config: dict[str, Any] | None = None,
box_config: dict[str, Any] | None = None,
frame_config: dict[str, Any] | None = None,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
# Setup Defaults
default_label_config: dict[str, Any] = {
"color": WHITE,
"font_size": DEFAULT_FONT_SIZE,
}
default_box_config: dict[str, Any] = {
"color": None,
"buff": 0.05,
"fill_opacity": 1,
"stroke_width": 0.5,
}
default_frame_config: dict[str, Any] = {
"color": WHITE,
"buff": 0.05,
"stroke_width": 0.5,
}
# Merge Defaults
label_config = default_label_config | (label_config or {})
box_config = default_box_config | (box_config or {})
frame_config = default_frame_config | (frame_config or {})
# Determine the type of label and instantiate the appropriate object
self.rendered_label: MathTex | Tex | Text
if isinstance(label, str):
self.rendered_label = MathTex(label, **label_config)
elif isinstance(label, (MathTex, Tex, Text)):
self.rendered_label = label
else:
raise TypeError("Unsupported label type. Must be MathTex, Tex, or Text.")
# Add a background box
self.background_rect = BackgroundRectangle(self.rendered_label, **box_config)
# Add a frame around the label
self.frame = SurroundingRectangle(self.rendered_label, **frame_config)
# Add components to the VGroup
self.add(self.background_rect, self.rendered_label, self.frame)
class LabeledLine(Line):
@ -20,42 +119,38 @@ class LabeledLine(Line):
Parameters
----------
label : str | Tex | MathTex | Text
label
Label that will be displayed on the line.
label_position : float | optional
label_position
A ratio in the range [0-1] to indicate the position of the label with respect to the length of the line. Default value is 0.5.
font_size : float | optional
Control font size for the label. This parameter is only used when `label` is of type `str`.
label_color: ParsableManimColor | optional
The color of the label's text. This parameter is only used when `label` is of type `str`.
label_frame : Bool | optional
Add a `SurroundingRectangle` frame to the label box.
frame_fill_color : ParsableManimColor | optional
Background color to fill the label box. If no value is provided, the background color of the canvas will be used.
frame_fill_opacity : float | optional
Determine the opacity of the label box by passing a value in the range [0-1], where 0 indicates complete transparency and 1 means full opacity.
label_config
A dictionary containing the configuration for the label.
This is only applied if ``label`` is of type ``str``.
box_config
A dictionary containing the configuration for the background box.
frame_config
A dictionary containing the configuration for the frame.
.. seealso::
:class:`LabeledArrow`
.. seealso::
:class:`LabeledArrow`
Examples
--------
.. manim:: LabeledLineExample
:save_last_frame:
:quality: high
class LabeledLineExample(Scene):
def construct(self):
line = LabeledLine(
label = '0.5',
label_position = 0.8,
font_size = 20,
label_color = WHITE,
label_frame = True,
label_config = {
"font_size" : 20
},
start=LEFT+DOWN,
end=RIGHT+UP)
line.set_length(line.get_length() * 2)
self.add(line)
"""
@ -64,50 +159,29 @@ class LabeledLine(Line):
self,
label: str | Tex | MathTex | Text,
label_position: float = 0.5,
font_size: float = DEFAULT_FONT_SIZE,
label_color: ParsableManimColor = WHITE,
label_frame: bool = True,
frame_fill_color: ParsableManimColor = None,
frame_fill_opacity: float = 1,
*args,
**kwargs,
label_config: dict[str, Any] | None = None,
box_config: dict[str, Any] | None = None,
frame_config: dict[str, Any] | None = None,
*args: Any,
**kwargs: Any,
) -> None:
label_color = ManimColor(label_color)
frame_fill_color = ManimColor(frame_fill_color)
if isinstance(label, str):
from manim import MathTex
rendered_label = MathTex(label, color=label_color, font_size=font_size)
else:
rendered_label = label
super().__init__(*args, **kwargs)
# calculating the vector for the label position
# Create Label
self.label = Label(
label=label,
label_config=label_config,
box_config=box_config,
frame_config=frame_config,
)
# Compute Label Position
line_start, line_end = self.get_start_and_end()
new_vec = (line_end - line_start) * label_position
label_coords = line_start + new_vec
# rendered_label.move_to(self.get_vector() * label_position)
rendered_label.move_to(label_coords)
box = BackgroundRectangle(
rendered_label,
buff=0.05,
color=frame_fill_color,
fill_opacity=frame_fill_opacity,
stroke_width=0.5,
)
self.add(box)
if label_frame:
box_frame = SurroundingRectangle(
rendered_label, buff=0.05, color=label_color, stroke_width=0.5
)
self.add(box_frame)
self.add(rendered_label)
self.label.move_to(label_coords)
self.add(self.label)
class LabeledArrow(LabeledLine, Arrow):
@ -116,29 +190,26 @@ class LabeledArrow(LabeledLine, Arrow):
Parameters
----------
label : str | Tex | MathTex | Text
Label that will be displayed on the line.
label_position : float | optional
label
Label that will be displayed on the Arrow.
label_position
A ratio in the range [0-1] to indicate the position of the label with respect to the length of the line. Default value is 0.5.
font_size : float | optional
Control font size for the label. This parameter is only used when `label` is of type `str`.
label_color: ParsableManimColor | optional
The color of the label's text. This parameter is only used when `label` is of type `str`.
label_frame : Bool | optional
Add a `SurroundingRectangle` frame to the label box.
frame_fill_color : ParsableManimColor | optional
Background color to fill the label box. If no value is provided, the background color of the canvas will be used.
frame_fill_opacity : float | optional
Determine the opacity of the label box by passing a value in the range [0-1], where 0 indicates complete transparency and 1 means full opacity.
label_config
A dictionary containing the configuration for the label.
This is only applied if ``label`` is of type ``str``.
box_config
A dictionary containing the configuration for the background box.
frame_config
A dictionary containing the configuration for the frame.
.. seealso::
:class:`LabeledLine`
.. seealso::
:class:`LabeledLine`
Examples
--------
.. manim:: LabeledArrowExample
:save_last_frame:
:quality: high
class LabeledArrowExample(Scene):
def construct(self):
@ -149,7 +220,159 @@ class LabeledArrow(LabeledLine, Arrow):
def __init__(
self,
*args,
**kwargs,
*args: Any,
**kwargs: Any,
) -> None:
super().__init__(*args, **kwargs)
class LabeledPolygram(Polygram):
"""Constructs a polygram containing a label box at its pole of inaccessibility.
Parameters
----------
vertex_groups
Vertices passed to the :class:`~.Polygram` constructor.
label
Label that will be displayed on the Polygram.
precision
The precision used by the PolyLabel algorithm.
label_config
A dictionary containing the configuration for the label.
This is only applied if ``label`` is of type ``str``.
box_config
A dictionary containing the configuration for the background box.
frame_config
A dictionary containing the configuration for the frame.
.. note::
The PolyLabel Algorithm expects each vertex group to form a closed ring.
If the input is open, :class:`LabeledPolygram` will attempt to close it.
This may cause the polygon to intersect itself leading to unexpected results.
.. tip::
Make sure the precision corresponds to the scale of your inputs!
For instance, if the bounding box of your polygon stretches from 0 to 10,000, a precision of 1.0 or 10.0 should be sufficient.
Examples
--------
.. manim:: LabeledPolygramExample
:save_last_frame:
:quality: high
class LabeledPolygramExample(Scene):
def construct(self):
# Define Rings
ring1 = [
[-3.8, -2.4, 0], [-2.4, -2.5, 0], [-1.3, -1.6, 0], [-0.2, -1.7, 0],
[1.7, -2.5, 0], [2.9, -2.6, 0], [3.5, -1.5, 0], [4.9, -1.4, 0],
[4.5, 0.2, 0], [4.7, 1.6, 0], [3.5, 2.4, 0], [1.1, 2.5, 0],
[-0.1, 0.9, 0], [-1.2, 0.5, 0], [-1.6, 0.7, 0], [-1.4, 1.9, 0],
[-2.6, 2.6, 0], [-4.4, 1.2, 0], [-4.9, -0.8, 0], [-3.8, -2.4, 0]
]
ring2 = [
[0.2, -1.2, 0], [0.9, -1.2, 0], [1.4, -2.0, 0], [2.1, -1.6, 0],
[2.2, -0.5, 0], [1.4, 0.0, 0], [0.4, -0.2, 0], [0.2, -1.2, 0]
]
ring3 = [[-2.7, 1.4, 0], [-2.3, 1.7, 0], [-2.8, 1.9, 0], [-2.7, 1.4, 0]]
# Create Polygons (for reference)
p1 = Polygon(*ring1, fill_opacity=0.75)
p2 = Polygon(*ring2, fill_color=BLACK, fill_opacity=1)
p3 = Polygon(*ring3, fill_color=BLACK, fill_opacity=1)
# Create Labeled Polygram
polygram = LabeledPolygram(
*[ring1, ring2, ring3],
label=Text('Pole', font='sans-serif'),
precision=0.01,
)
# Display Circle (for reference)
circle = Circle(radius=polygram.radius, color=WHITE).move_to(polygram.pole)
self.add(p1, p2, p3)
self.add(polygram)
self.add(circle)
.. manim:: LabeledCountryExample
:save_last_frame:
:quality: high
import requests
import json
class LabeledCountryExample(Scene):
def construct(self):
# Fetch JSON data and process arcs
data = requests.get('https://cdn.jsdelivr.net/npm/us-atlas@3/nation-10m.json').json()
arcs, transform = data['arcs'], data['transform']
sarcs = [np.cumsum(arc, axis=0) * transform['scale'] + transform['translate'] for arc in arcs]
ssarcs = sorted(sarcs, key=len, reverse=True)[:1]
# Compute Bounding Box
points = np.concatenate(ssarcs)
mins, maxs = np.min(points, axis=0), np.max(points, axis=0)
# Build Axes
ax = Axes(
x_range=[mins[0], maxs[0], maxs[0] - mins[0]], x_length=10,
y_range=[mins[1], maxs[1], maxs[1] - mins[1]], y_length=7,
tips=False
)
# Adjust Coordinates
array = [[ax.c2p(*point) for point in sarc] for sarc in ssarcs]
# Add Polygram
polygram = LabeledPolygram(
*array,
label=Text('USA', font='sans-serif'),
precision=0.01,
fill_color=BLUE,
stroke_width=0,
fill_opacity=0.75
)
# Display Circle (for reference)
circle = Circle(radius=polygram.radius, color=WHITE).move_to(polygram.pole)
self.add(ax)
self.add(polygram)
self.add(circle)
"""
def __init__(
self,
*vertex_groups: Point3D_Array,
label: str | Tex | MathTex | Text,
precision: float = 0.01,
label_config: dict[str, Any] | None = None,
box_config: dict[str, Any] | None = None,
frame_config: dict[str, Any] | None = None,
**kwargs: Any,
) -> None:
# Initialize the Polygram with the vertex groups
super().__init__(*vertex_groups, **kwargs)
# Create Label
self.label = Label(
label=label,
label_config=label_config,
box_config=box_config,
frame_config=frame_config,
)
# Close Vertex Groups
rings = [
group if np.array_equal(group[0], group[-1]) else list(group) + [group[0]]
for group in vertex_groups
]
# Compute the Pole of Inaccessibility
cell = polylabel(rings, precision=precision)
self.pole, self.radius = np.pad(cell.c, (0, 1), "constant"), cell.d
# Position the label at the pole
self.label.move_to(self.pole)
self.add(self.label)

View file

@ -31,9 +31,11 @@ from manim.utils.color import WHITE
from manim.utils.space_ops import angle_of_vector, line_intersection, normalize
if TYPE_CHECKING:
from typing import Any
from typing_extensions import Self
from manim.typing import Point2D, Point3D, Vector3D
from manim.typing import InternalPoint3D, Point2D, Point3D, Vector3D
from manim.utils.color import ParsableManimColor
from ..matrix import Matrix # Avoid circular import
@ -46,20 +48,21 @@ class Line(TipableVMobject):
end: Point3D | Mobject = RIGHT,
buff: float = 0,
path_arc: float | None = None,
**kwargs,
**kwargs: Any,
) -> None:
self.dim = 3
self.buff = buff
self.path_arc = path_arc
self._set_start_and_end_attrs(start, end)
super().__init__(**kwargs)
# TODO: Deal with the situation where path_arc is None
def generate_points(self) -> None:
self.set_points_by_ends(
start=self.start,
end=self.end,
buff=self.buff,
path_arc=self.path_arc,
path_arc=self.path_arc, # type: ignore[arg-type]
)
def set_points_by_ends(
@ -86,16 +89,19 @@ class Line(TipableVMobject):
"""
self._set_start_and_end_attrs(start, end)
if path_arc:
# self.path_arc could potentially be None, which is not accepted
# as parameter.
assert self.path_arc is not None
arc = ArcBetweenPoints(self.start, self.end, angle=self.path_arc)
self.set_points(arc.points)
else:
self.set_points_as_corners([self.start, self.end])
self.set_points_as_corners(np.asarray([self.start, self.end]))
self._account_for_buff(buff)
init_points = generate_points
def _account_for_buff(self, buff: float) -> Self | None:
def _account_for_buff(self, buff: float) -> None:
if buff == 0:
return
#
@ -105,7 +111,7 @@ class Line(TipableVMobject):
return
buff_proportion = buff / length
self.pointwise_become_partial(self, buff_proportion, 1 - buff_proportion)
return self
return
def _set_start_and_end_attrs(
self, start: Point3D | Mobject, end: Point3D | Mobject
@ -125,7 +131,7 @@ class Line(TipableVMobject):
self,
mob_or_point: Mobject | Point3D,
direction: Vector3D | None = None,
) -> Point3D:
) -> InternalPoint3D:
"""Transforms a mobject into its corresponding point. Does nothing if a point is passed.
``direction`` determines the location of the point along its bounding box in that direction.
@ -149,7 +155,11 @@ class Line(TipableVMobject):
self.path_arc = new_value
self.init_points()
def put_start_and_end_on(self, start: Point3D, end: Point3D) -> Self:
def put_start_and_end_on(
self,
start: InternalPoint3D,
end: InternalPoint3D,
) -> Self:
"""Sets starts and end coordinates of a line.
Examples
@ -189,7 +199,7 @@ class Line(TipableVMobject):
def get_angle(self) -> float:
return angle_of_vector(self.get_vector())
def get_projection(self, point: Point3D) -> Vector3D:
def get_projection(self, point: InternalPoint3D) -> Vector3D:
"""Returns the projection of a point onto a line.
Parameters
@ -200,10 +210,10 @@ class Line(TipableVMobject):
start = self.get_start()
end = self.get_end()
unit_vect = normalize(end - start)
return start + np.dot(point - start, unit_vect) * unit_vect
return start + float(np.dot(point - start, unit_vect)) * unit_vect
def get_slope(self) -> float:
return np.tan(self.get_angle())
return float(np.tan(self.get_angle()))
def set_angle(self, angle: float, about_point: Point3D | None = None) -> Self:
if about_point is None:
@ -217,7 +227,8 @@ class Line(TipableVMobject):
return self
def set_length(self, length: float) -> Self:
return self.scale(length / self.get_length())
scale_factor: float = length / self.get_length()
return self.scale(scale_factor)
class DashedLine(Line):
@ -256,10 +267,10 @@ class DashedLine(Line):
def __init__(
self,
*args,
*args: Any,
dash_length: float = DEFAULT_DASH_LENGTH,
dashed_ratio: float = 0.5,
**kwargs,
**kwargs: Any,
) -> None:
self.dash_length = dash_length
self.dashed_ratio = dashed_ratio
@ -288,7 +299,7 @@ class DashedLine(Line):
int(np.ceil((self.get_length() / self.dash_length) * self.dashed_ratio)),
)
def get_start(self) -> Point3D:
def get_start(self) -> InternalPoint3D:
"""Returns the start point of the line.
Examples
@ -303,7 +314,7 @@ class DashedLine(Line):
else:
return super().get_start()
def get_end(self) -> Point3D:
def get_end(self) -> InternalPoint3D:
"""Returns the end point of the line.
Examples
@ -318,7 +329,7 @@ class DashedLine(Line):
else:
return super().get_end()
def get_first_handle(self) -> Point3D:
def get_first_handle(self) -> InternalPoint3D:
"""Returns the point of the first handle.
Examples
@ -328,9 +339,12 @@ class DashedLine(Line):
>>> DashedLine().get_first_handle()
array([-0.98333333, 0. , 0. ])
"""
# Type inference of extracting an element from a list, is not
# supported by numpy, see this numpy issue
# https://github.com/numpy/numpy/issues/16544
return self.submobjects[0].points[1]
def get_last_handle(self) -> Point3D:
def get_last_handle(self) -> InternalPoint3D:
"""Returns the point of the last handle.
Examples
@ -340,6 +354,9 @@ class DashedLine(Line):
>>> DashedLine().get_last_handle()
array([0.98333333, 0. , 0. ])
"""
# Type inference of extracting an element from a list, is not
# supported by numpy, see this numpy issue
# https://github.com/numpy/numpy/issues/16544
return self.submobjects[-1].points[-2]
@ -382,7 +399,7 @@ class TangentLine(Line):
alpha: float,
length: float = 1,
d_alpha: float = 1e-6,
**kwargs,
**kwargs: Any,
) -> None:
self.length = length
self.d_alpha = d_alpha
@ -425,10 +442,10 @@ class Elbow(VMobject, metaclass=ConvertToOpenGL):
self.add(elbow_group)
"""
def __init__(self, width: float = 0.2, angle: float = 0, **kwargs) -> None:
def __init__(self, width: float = 0.2, angle: float = 0, **kwargs: Any) -> None:
self.angle = angle
super().__init__(**kwargs)
self.set_points_as_corners([UP, UP + RIGHT, RIGHT])
self.set_points_as_corners(np.array([UP, UP + RIGHT, RIGHT]))
self.scale_to_fit_width(width, about_point=ORIGIN)
self.rotate(self.angle, about_point=ORIGIN)
@ -523,24 +540,24 @@ class Arrow(Line):
def __init__(
self,
*args,
*args: Any,
stroke_width: float = 6,
buff: float = MED_SMALL_BUFF,
max_tip_length_to_length_ratio: float = 0.25,
max_stroke_width_to_length_ratio: float = 5,
**kwargs,
**kwargs: Any,
) -> None:
self.max_tip_length_to_length_ratio = max_tip_length_to_length_ratio
self.max_stroke_width_to_length_ratio = max_stroke_width_to_length_ratio
tip_shape = kwargs.pop("tip_shape", ArrowTriangleFilledTip)
super().__init__(*args, buff=buff, stroke_width=stroke_width, **kwargs)
super().__init__(*args, buff=buff, stroke_width=stroke_width, **kwargs) # type: ignore[misc]
# TODO, should this be affected when
# Arrow.set_stroke is called?
self.initial_stroke_width = self.stroke_width
self.add_tip(tip_shape=tip_shape)
self._set_stroke_width_from_length()
def scale(self, factor: float, scale_tips: bool = False, **kwargs) -> Self:
def scale(self, factor: float, scale_tips: bool = False, **kwargs: Any) -> Self: # type: ignore[override]
r"""Scale an arrow, but keep stroke width and arrow tip size fixed.
@ -663,7 +680,10 @@ class Vector(Arrow):
"""
def __init__(
self, direction: Point2D | Point3D = RIGHT, buff: float = 0, **kwargs
self,
direction: Point2D | Point3D = RIGHT,
buff: float = 0,
**kwargs: Any,
) -> None:
self.buff = buff
if len(direction) == 2:
@ -676,7 +696,7 @@ class Vector(Arrow):
integer_labels: bool = True,
n_dim: int = 2,
color: ParsableManimColor | None = None,
**kwargs,
**kwargs: Any,
) -> Matrix:
"""Creates a label based on the coordinates of the vector.
@ -779,7 +799,7 @@ class DoubleArrow(Arrow):
self.add(box, d1, d2, d3)
"""
def __init__(self, *args, **kwargs) -> None:
def __init__(self, *args: Any, **kwargs: Any) -> None:
if "tip_shape_end" in kwargs:
kwargs["tip_shape"] = kwargs.pop("tip_shape_end")
tip_shape_start = kwargs.pop("tip_shape_start", ArrowTriangleFilledTip)
@ -908,7 +928,7 @@ class Angle(VMobject, metaclass=ConvertToOpenGL):
dot_distance: float = 0.55,
dot_color: ParsableManimColor = WHITE,
elbow: bool = False,
**kwargs,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self.lines = (line1, line2)
@ -945,9 +965,9 @@ class Angle(VMobject, metaclass=ConvertToOpenGL):
+ quadrant[0] * radius * line1.get_unit_vector()
+ quadrant[1] * radius * line2.get_unit_vector()
)
angle_mobject = Elbow(**kwargs)
angle_mobject: VMobject = Elbow(**kwargs)
angle_mobject.set_points_as_corners(
[anchor_angle_1, anchor_middle, anchor_angle_2],
np.array([anchor_angle_1, anchor_middle, anchor_angle_2]),
)
else:
angle_1 = angle_of_vector(anchor_angle_1 - inter)
@ -1047,7 +1067,7 @@ class Angle(VMobject, metaclass=ConvertToOpenGL):
return self.angle_value / DEGREES if degrees else self.angle_value
@staticmethod
def from_three_points(A: Point3D, B: Point3D, C: Point3D, **kwargs) -> Angle:
def from_three_points(A: Point3D, B: Point3D, C: Point3D, **kwargs: Any) -> Angle:
r"""The angle between the lines AB and BC.
This constructs the angle :math:`\\angle ABC`.
@ -1123,6 +1143,10 @@ class RightAngle(Angle):
"""
def __init__(
self, line1: Line, line2: Line, length: float | None = None, **kwargs
self,
line1: Line,
line2: Line,
length: float | None = None,
**kwargs: Any,
) -> None:
super().__init__(line1, line2, radius=length, elbow=True, **kwargs)

View file

@ -13,6 +13,7 @@ __all__ = [
"Square",
"RoundedRectangle",
"Cutout",
"ConvexHull",
]
@ -27,12 +28,21 @@ from manim.mobject.opengl.opengl_vectorized_mobject import OpenGLVMobject
from manim.mobject.types.vectorized_mobject import VGroup
from manim.utils.color import BLUE, WHITE, ParsableManimColor
from manim.utils.iterables import adjacent_n_tuples, adjacent_pairs
from manim.utils.qhull import QuickHull
from manim.utils.space_ops import angle_between_vectors, normalize, regular_vertices
if TYPE_CHECKING:
from typing import Any, Literal
import numpy.typing as npt
from typing_extensions import Self
from manim.typing import Point3D, Point3D_Array
from manim.typing import (
InternalPoint3D,
InternalPoint3D_Array,
Point3D,
Point3D_Array,
)
from manim.utils.color import ParsableManimColor
@ -72,11 +82,16 @@ class Polygram(OpenGLVMobject):
"""
def __init__(
self, *vertex_groups: Point3D, color: ParsableManimColor = BLUE, **kwargs
self,
*vertex_groups: Point3D_Array,
color: ParsableManimColor = BLUE,
**kwargs: Any,
):
super().__init__(color=color, **kwargs)
for vertices in vertex_groups:
# The inferred type for *vertices is Any, but it should be
# InternalPoint3D_Array
first_vertex, *vertices = vertices
first_vertex = np.array(first_vertex)
@ -85,7 +100,7 @@ class Polygram(OpenGLVMobject):
[*(np.array(vertex) for vertex in vertices), first_vertex],
)
def get_vertices(self) -> Point3D_Array:
def get_vertices(self) -> InternalPoint3D_Array:
"""Gets the vertices of the :class:`Polygram`.
Returns
@ -106,7 +121,7 @@ class Polygram(OpenGLVMobject):
"""
return self.get_start_anchors()
def get_vertex_groups(self) -> np.ndarray[Point3D_Array]:
def get_vertex_groups(self) -> InternalPoint3D_Array:
"""Gets the vertex groups of the :class:`Polygram`.
Returns
@ -205,7 +220,7 @@ class Polygram(OpenGLVMobject):
if radius == 0:
return self
new_points = []
new_points: list[InternalPoint3D] = []
for vertices in self.get_vertex_groups():
arcs = []
@ -274,7 +289,7 @@ class Polygram(OpenGLVMobject):
new_points.extend(line.points)
self.set_points(new_points)
self.set_points(np.array(new_points))
return self
@ -309,7 +324,7 @@ class Polygon(Polygram):
self.add(isosceles, square_and_triangles)
"""
def __init__(self, *vertices: Point3D, **kwargs) -> None:
def __init__(self, *vertices: InternalPoint3D, **kwargs: Any) -> None:
super().__init__(vertices, **kwargs)
@ -352,7 +367,7 @@ class RegularPolygram(Polygram):
density: int = 2,
radius: float = 1,
start_angle: float | None = None,
**kwargs,
**kwargs: Any,
) -> None:
# Regular polygrams can be expressed by the number of their vertices
# and their density. This relation can be expressed as its Schläfli
@ -373,7 +388,7 @@ class RegularPolygram(Polygram):
# Utility function for generating the individual
# polygon vertices.
def gen_polygon_vertices(start_angle):
def gen_polygon_vertices(start_angle: float | None) -> tuple[list[Any], float]:
reg_vertices, start_angle = regular_vertices(
num_vertices,
radius=radius,
@ -429,7 +444,7 @@ class RegularPolygon(RegularPolygram):
self.add(poly_group)
"""
def __init__(self, n: int = 6, **kwargs) -> None:
def __init__(self, n: int = 6, **kwargs: Any) -> None:
super().__init__(n, density=1, **kwargs)
@ -499,7 +514,7 @@ class Star(Polygon):
inner_radius: float | None = None,
density: int = 2,
start_angle: float | None = TAU / 4,
**kwargs,
**kwargs: Any,
) -> None:
inner_angle = TAU / (2 * n)
@ -531,7 +546,7 @@ class Star(Polygon):
start_angle=self.start_angle + inner_angle,
)
vertices = []
vertices: list[npt.NDArray] = []
for pair in zip(outer_vertices, inner_vertices):
vertices.extend(pair)
@ -559,7 +574,7 @@ class Triangle(RegularPolygon):
self.add(tri_group)
"""
def __init__(self, **kwargs) -> None:
def __init__(self, **kwargs: Any) -> None:
super().__init__(n=3, **kwargs)
@ -610,7 +625,7 @@ class Rectangle(Polygon):
grid_ystep: float | None = None,
mark_paths_closed: bool = True,
close_new_points: bool = True,
**kwargs,
**kwargs: Any,
):
super().__init__(UR, UL, DL, DR, color=color, **kwargs)
self.stretch_to_fit_width(width)
@ -681,15 +696,15 @@ class Square(Rectangle):
self.add(square_1, square_2, square_3)
"""
def __init__(self, side_length: float = 2.0, **kwargs) -> None:
def __init__(self, side_length: float = 2.0, **kwargs: Any) -> None:
super().__init__(height=side_length, width=side_length, **kwargs)
@property
def side_length(self):
return np.linalg.norm(self.get_vertices()[0] - self.get_vertices()[1])
def side_length(self) -> float:
return float(np.linalg.norm(self.get_vertices()[0] - self.get_vertices()[1]))
@side_length.setter
def side_length(self, value):
def side_length(self, value: float) -> None:
self.scale(value / self.side_length)
@ -717,7 +732,7 @@ class RoundedRectangle(Rectangle):
self.add(rect_group)
"""
def __init__(self, corner_radius: float | list[float] = 0.5, **kwargs):
def __init__(self, corner_radius: float | list[float] = 0.5, **kwargs: Any):
super().__init__(**kwargs)
self.corner_radius = corner_radius
self.round_corners(self.corner_radius)
@ -758,9 +773,77 @@ class Cutout(OpenGLVMobject):
self.wait()
"""
def __init__(self, main_shape: VMobject, *mobjects: VMobject, **kwargs) -> None:
def __init__(
self, main_shape: VMobject, *mobjects: VMobject, **kwargs: Any
) -> None:
super().__init__(**kwargs)
self.append_points(main_shape.points)
sub_direction = "CCW" if main_shape.get_direction() == "CW" else "CW"
sub_direction: Literal["CCW", "CW"] = (
"CCW" if main_shape.get_direction() == "CW" else "CW"
)
for mobject in mobjects:
self.append_points(mobject.force_direction(sub_direction).points)
class ConvexHull(Polygram):
"""Constructs a convex hull for a set of points in no particular order.
Parameters
----------
points
The points to consider.
tolerance
The tolerance used by quickhull.
kwargs
Forwarded to the parent constructor.
Examples
--------
.. manim:: ConvexHullExample
:save_last_frame:
:quality: high
class ConvexHullExample(Scene):
def construct(self):
points = [
[-2.35, -2.25, 0],
[1.65, -2.25, 0],
[2.65, -0.25, 0],
[1.65, 1.75, 0],
[-0.35, 2.75, 0],
[-2.35, 0.75, 0],
[-0.35, -1.25, 0],
[0.65, -0.25, 0],
[-1.35, 0.25, 0],
[0.15, 0.75, 0]
]
hull = ConvexHull(*points, color=BLUE)
dots = VGroup(*[Dot(point) for point in points])
self.add(hull)
self.add(dots)
"""
def __init__(
self, *points: Point3D, tolerance: float = 1e-5, **kwargs: Any
) -> None:
# Build Convex Hull
array = np.array(points)[:, :2]
hull = QuickHull(tolerance)
hull.build(array)
# Extract Vertices
facets = set(hull.facets) - hull.removed
facet = facets.pop()
subfacets = list(facet.subfacets)
while len(subfacets) <= len(facets):
sf = subfacets[-1]
(facet,) = hull.neighbors[sf] - {facet}
(sf,) = facet.subfacets - {sf}
subfacets.append(sf)
# Setup Vertices as Point3D
coordinates = np.vstack([sf.coordinates for sf in subfacets])
vertices = np.hstack((coordinates, np.zeros((len(coordinates), 1))))
# Call Polygram
super().__init__(vertices, **kwargs)

View file

@ -8,8 +8,15 @@ from typing import Any
from typing_extensions import Self
from manim import config, logger
from manim.constants import *
from manim import logger
from manim._config import config
from manim.constants import (
DOWN,
LEFT,
RIGHT,
SMALL_BUFF,
UP,
)
from manim.mobject.geometry.line import Line
from manim.mobject.geometry.polygram import RoundedRectangle
from manim.mobject.mobject import Mobject
@ -43,21 +50,29 @@ class SurroundingRectangle(RoundedRectangle):
def __init__(
self,
mobject: Mobject,
*mobjects: Mobject,
color: ParsableManimColor = YELLOW,
buff: float = SMALL_BUFF,
corner_radius: float = 0.0,
**kwargs,
**kwargs: Any,
) -> None:
from manim.mobject.mobject import Group
if not all(isinstance(mob, Mobject) for mob in mobjects):
raise TypeError(
"Expected all inputs for parameter mobjects to be a Mobjects"
)
group = Group(*mobjects)
super().__init__(
color=color,
width=mobject.width + 2 * buff,
height=mobject.height + 2 * buff,
width=group.width + 2 * buff,
height=group.height + 2 * buff,
corner_radius=corner_radius,
**kwargs,
)
self.buff = buff
self.move_to(mobject)
self.move_to(group)
class BackgroundRectangle(SurroundingRectangle):
@ -87,19 +102,19 @@ class BackgroundRectangle(SurroundingRectangle):
def __init__(
self,
mobject: Mobject,
*mobjects: Mobject,
color: ParsableManimColor | None = None,
stroke_width: float = 0,
stroke_opacity: float = 0,
fill_opacity: float = 0.75,
buff: float = 0,
**kwargs,
):
**kwargs: Any,
) -> None:
if color is None:
color = config.background_color
super().__init__(
mobject,
*mobjects,
color=color,
stroke_width=stroke_width,
stroke_opacity=stroke_opacity,
@ -113,7 +128,7 @@ class BackgroundRectangle(SurroundingRectangle):
self.set_fill(opacity=b * self.original_fill_opacity)
return self
def set_style(self, fill_opacity: float, **kwargs) -> Self:
def set_style(self, fill_opacity: float, **kwargs: Any) -> Self: # type: ignore[override]
# Unchangeable style, except for fill_opacity
# All other style arguments are ignored
super().set_style(
@ -130,7 +145,10 @@ class BackgroundRectangle(SurroundingRectangle):
return self
def get_fill_color(self) -> ManimColor:
return self.color
# The type of the color property is set to Any using the property decorator
# vectorized_mobject.py#L571
temp_color: ManimColor = self.color
return temp_color
class Cross(VGroup):
@ -164,7 +182,7 @@ class Cross(VGroup):
stroke_color: ParsableManimColor = RED,
stroke_width: float = 6.0,
scale_factor: float = 1.0,
**kwargs,
**kwargs: Any,
) -> None:
super().__init__(
Line(UP + LEFT, DOWN + RIGHT), Line(UP + RIGHT, DOWN + LEFT), **kwargs
@ -190,7 +208,9 @@ class Underline(Line):
self.add(man, ul)
"""
def __init__(self, mobject: Mobject, buff: float = SMALL_BUFF, **kwargs) -> None:
def __init__(
self, mobject: Mobject, buff: float = SMALL_BUFF, **kwargs: Any
) -> None:
super().__init__(LEFT, RIGHT, buff=buff, **kwargs)
self.match_width(mobject)
self.next_to(mobject, DOWN, buff=self.buff)

View file

@ -25,7 +25,9 @@ from manim.mobject.opengl.opengl_vectorized_mobject import OpenGLVMobject as VMo
from manim.utils.space_ops import angle_of_vector
if TYPE_CHECKING:
from manim.typing import Point3D, Vector3D
from typing import Any
from manim.typing import InternalPoint3D, Point3D, Vector3D
class ArrowTip(VMobject, metaclass=ConvertToOpenGL):
@ -112,7 +114,7 @@ class ArrowTip(VMobject, metaclass=ConvertToOpenGL):
self.add(*big_arrows, *small_arrows, *labels)
"""
def __init__(self, *args, **kwargs) -> None:
def __init__(self, *args: Any, **kwargs: Any) -> None:
raise NotImplementedError("Has to be implemented in inheriting subclasses.")
@property
@ -134,7 +136,7 @@ class ArrowTip(VMobject, metaclass=ConvertToOpenGL):
return self.point_from_proportion(0.5)
@property
def tip_point(self) -> Point3D:
def tip_point(self) -> InternalPoint3D:
r"""The tip point of the arrow tip.
Examples
@ -147,6 +149,9 @@ class ArrowTip(VMobject, metaclass=ConvertToOpenGL):
array([2., 0., 0.])
"""
# Type inference of extracting an element from a list, is not
# supported by numpy, see this numpy issue
# https://github.com/numpy/numpy/issues/16544
return self.points[0]
@property
@ -182,7 +187,7 @@ class ArrowTip(VMobject, metaclass=ConvertToOpenGL):
return angle_of_vector(self.vector)
@property
def length(self) -> np.floating:
def length(self) -> float:
r"""The length of the arrow tip.
Examples
@ -195,7 +200,7 @@ class ArrowTip(VMobject, metaclass=ConvertToOpenGL):
0.35
"""
return np.linalg.norm(self.vector)
return float(np.linalg.norm(self.vector))
class StealthTip(ArrowTip):
@ -207,36 +212,38 @@ class StealthTip(ArrowTip):
def __init__(
self,
fill_opacity=1,
stroke_width=3,
length=DEFAULT_ARROW_TIP_LENGTH / 2,
start_angle=PI,
**kwargs,
fill_opacity: float = 1,
stroke_width: float = 3,
length: float = DEFAULT_ARROW_TIP_LENGTH / 2,
start_angle: float = PI,
**kwargs: Any,
):
self.start_angle = start_angle
VMobject.__init__(
self, fill_opacity=fill_opacity, stroke_width=stroke_width, **kwargs
)
self.set_points_as_corners(
[
[2, 0, 0], # tip
[-1.2, 1.6, 0],
[0, 0, 0], # base
[-1.2, -1.6, 0],
[2, 0, 0], # close path, back to tip
]
np.array(
[
[2, 0, 0], # tip
[-1.2, 1.6, 0],
[0, 0, 0], # base
[-1.2, -1.6, 0],
[2, 0, 0], # close path, back to tip
]
)
)
self.scale(length / self.length)
@property
def length(self):
def length(self) -> float:
"""The length of the arrow tip.
In this case, the length is computed as the height of
the triangle encompassing the stealth tip (otherwise,
the tip is scaled too large).
"""
return np.linalg.norm(self.vector) * 1.6
return float(np.linalg.norm(self.vector) * 1.6)
class ArrowTriangleTip(ArrowTip, Triangle):
@ -249,7 +256,7 @@ class ArrowTriangleTip(ArrowTip, Triangle):
length: float = DEFAULT_ARROW_TIP_LENGTH,
width: float = DEFAULT_ARROW_TIP_LENGTH,
start_angle: float = PI,
**kwargs,
**kwargs: Any,
) -> None:
Triangle.__init__(
self,
@ -271,7 +278,7 @@ class ArrowTriangleFilledTip(ArrowTriangleTip):
"""
def __init__(
self, fill_opacity: float = 1, stroke_width: float = 0, **kwargs
self, fill_opacity: float = 1, stroke_width: float = 0, **kwargs: Any
) -> None:
super().__init__(fill_opacity=fill_opacity, stroke_width=stroke_width, **kwargs)
@ -285,7 +292,7 @@ class ArrowCircleTip(ArrowTip, Circle):
stroke_width: float = 3,
length: float = DEFAULT_ARROW_TIP_LENGTH,
start_angle: float = PI,
**kwargs,
**kwargs: Any,
) -> None:
self.start_angle = start_angle
Circle.__init__(
@ -299,7 +306,7 @@ class ArrowCircleFilledTip(ArrowCircleTip):
r"""Circular arrow tip with filled tip."""
def __init__(
self, fill_opacity: float = 1, stroke_width: float = 0, **kwargs
self, fill_opacity: float = 1, stroke_width: float = 0, **kwargs: Any
) -> None:
super().__init__(fill_opacity=fill_opacity, stroke_width=stroke_width, **kwargs)
@ -313,7 +320,7 @@ class ArrowSquareTip(ArrowTip, Square):
stroke_width: float = 3,
length: float = DEFAULT_ARROW_TIP_LENGTH,
start_angle: float = PI,
**kwargs,
**kwargs: Any,
) -> None:
self.start_angle = start_angle
Square.__init__(
@ -331,6 +338,6 @@ class ArrowSquareFilledTip(ArrowSquareTip):
r"""Square arrow tip with filled tip."""
def __init__(
self, fill_opacity: float = 1, stroke_width: float = 0, **kwargs
self, fill_opacity: float = 1, stroke_width: float = 0, **kwargs: Any
) -> None:
super().__init__(fill_opacity=fill_opacity, stroke_width=stroke_width, **kwargs)

View file

@ -44,6 +44,7 @@ if TYPE_CHECKING:
from manim.animation.animation import Animation
from manim.typing import (
FunctionOverride,
InternalPoint3D,
ManimFloat,
ManimInt,
MappingFunction,
@ -2156,17 +2157,17 @@ class Mobject:
"""Returns z Point3D of the center of the :class:`~.Mobject` as ``float``"""
return self.get_coord(2, direction)
def get_start(self) -> Point3D:
def get_start(self) -> InternalPoint3D:
"""Returns the point, where the stroke that surrounds the :class:`~.Mobject` starts."""
self.throw_error_if_no_points()
return np.array(self.points[0])
def get_end(self) -> Point3D:
def get_end(self) -> InternalPoint3D:
"""Returns the point, where the stroke that surrounds the :class:`~.Mobject` ends."""
self.throw_error_if_no_points()
return np.array(self.points[-1])
def get_start_and_end(self) -> tuple[Point3D, Point3D]:
def get_start_and_end(self) -> tuple[InternalPoint3D, InternalPoint3D]:
"""Returns starting and ending point of a stroke as a ``tuple``."""
return self.get_start(), self.get_end()

View file

@ -10,11 +10,20 @@ from manim.mobject.geometry.polygram import Polygon
from manim.mobject.graph import Graph
from manim.mobject.three_d.three_dimensions import Dot3D
from manim.mobject.types.vectorized_mobject import VGroup
from manim.utils.qhull import QuickHull
if TYPE_CHECKING:
from manim.mobject.mobject import Mobject
from manim.typing import Point3D
__all__ = ["Polyhedron", "Tetrahedron", "Octahedron", "Icosahedron", "Dodecahedron"]
__all__ = [
"Polyhedron",
"Tetrahedron",
"Octahedron",
"Icosahedron",
"Dodecahedron",
"ConvexHull3D",
]
class Polyhedron(VGroup):
@ -361,3 +370,91 @@ class Dodecahedron(Polyhedron):
],
**kwargs,
)
class ConvexHull3D(Polyhedron):
"""A convex hull for a set of points
Parameters
----------
points
The points to consider.
tolerance
The tolerance used for quickhull.
kwargs
Forwarded to the parent constructor.
Examples
--------
.. manim:: ConvexHull3DExample
:save_last_frame:
:quality: high
class ConvexHull3DExample(ThreeDScene):
def construct(self):
self.set_camera_orientation(phi=75 * DEGREES, theta=30 * DEGREES)
points = [
[ 1.93192757, 0.44134585, -1.52407061],
[-0.93302521, 1.23206983, 0.64117067],
[-0.44350918, -0.61043677, 0.21723705],
[-0.42640268, -1.05260843, 1.61266094],
[-1.84449637, 0.91238739, -1.85172623],
[ 1.72068132, -0.11880457, 0.51881751],
[ 0.41904805, 0.44938012, -1.86440686],
[ 0.83864666, 1.66653337, 1.88960123],
[ 0.22240514, -0.80986286, 1.34249326],
[-1.29585759, 1.01516189, 0.46187522],
[ 1.7776499, -1.59550796, -1.70240747],
[ 0.80065226, -0.12530398, 1.70063977],
[ 1.28960948, -1.44158255, 1.39938582],
[-0.93538943, 1.33617705, -0.24852643],
[-1.54868271, 1.7444399, -0.46170734]
]
hull = ConvexHull3D(
*points,
faces_config = {"stroke_opacity": 0},
graph_config = {
"vertex_type": Dot3D,
"edge_config": {
"stroke_color": BLUE,
"stroke_width": 2,
"stroke_opacity": 0.05,
}
}
)
dots = VGroup(*[Dot3D(point) for point in points])
self.add(hull)
self.add(dots)
"""
def __init__(self, *points: Point3D, tolerance: float = 1e-5, **kwargs):
# Build Convex Hull
array = np.array(points)
hull = QuickHull(tolerance)
hull.build(array)
# Setup Lists
vertices = []
faces = []
# Extract Faces
c = 0
d = {}
facets = set(hull.facets) - hull.removed
for facet in facets:
tmp = set()
for subfacet in facet.subfacets:
for point in subfacet.points:
if point not in d:
vertices.append(point.coordinates)
d[point] = c
c += 1
tmp.add(point)
faces.append([d[point] for point in tmp])
# Call Polyhedron
super().__init__(
vertex_coords=vertices,
faces_list=faces,
**kwargs,
)

View file

@ -5,6 +5,7 @@ from __future__ import annotations
__all__ = ["AbstractImageMobject", "ImageMobject", "ImageMobjectFromCamera"]
import pathlib
from typing import TYPE_CHECKING
import numpy as np
from PIL import Image
@ -21,6 +22,14 @@ from ...utils.images import change_to_rgba_array, get_full_raster_image_path
__all__ = ["ImageMobject", "ImageMobjectFromCamera"]
if TYPE_CHECKING:
from typing import Any
import numpy.typing as npt
from typing_extensions import Self
from manim.typing import StrPath
class AbstractImageMobject(Mobject):
"""
@ -39,23 +48,23 @@ class AbstractImageMobject(Mobject):
def __init__(
self,
scale_to_resolution: int,
pixel_array_dtype="uint8",
resampling_algorithm=Resampling.BICUBIC,
**kwargs,
):
pixel_array_dtype: str = "uint8",
resampling_algorithm: Resampling = Resampling.BICUBIC,
**kwargs: Any,
) -> None:
self.pixel_array_dtype = pixel_array_dtype
self.scale_to_resolution = scale_to_resolution
self.set_resampling_algorithm(resampling_algorithm)
super().__init__(**kwargs)
def get_pixel_array(self):
def get_pixel_array(self) -> None:
raise NotImplementedError()
def set_color(self, color, alpha=None, family=True):
# Likely to be implemented in subclasses, but no obligation
pass
def set_resampling_algorithm(self, resampling_algorithm: int):
def set_resampling_algorithm(self, resampling_algorithm: int) -> Self:
"""
Sets the interpolation method for upscaling the image. By default the image is
interpolated using bicubic algorithm. This method lets you change it.
@ -87,7 +96,7 @@ class AbstractImageMobject(Mobject):
)
return self
def reset_points(self):
def reset_points(self) -> None:
"""Sets :attr:`points` to be the four image corners."""
self.points = np.array(
[
@ -171,15 +180,15 @@ class ImageMobject(AbstractImageMobject):
def __init__(
self,
filename_or_array,
filename_or_array: StrPath | npt.NDArray,
scale_to_resolution: int = QUALITIES[DEFAULT_QUALITY]["pixel_height"],
invert=False,
image_mode="RGBA",
**kwargs,
):
self.fill_opacity = 1
self.stroke_opacity = 1
self.invert = invert
invert: bool = False,
image_mode: str = "RGBA",
**kwargs: Any,
) -> None:
self.fill_opacity: float = 1
self.stroke_opacity: float = 1
self.invert_image = invert
self.image_mode = image_mode
if isinstance(filename_or_array, (str, pathlib.PurePath)):
path = get_full_raster_image_path(filename_or_array)
@ -192,7 +201,7 @@ class ImageMobject(AbstractImageMobject):
self.pixel_array = change_to_rgba_array(
self.pixel_array, self.pixel_array_dtype
)
if self.invert:
if self.invert_image:
self.pixel_array[:, :, :3] = (
np.iinfo(self.pixel_array_dtype).max - self.pixel_array[:, :, :3]
)
@ -212,7 +221,7 @@ class ImageMobject(AbstractImageMobject):
self.color = color
return self
def set_opacity(self, alpha: float):
def set_opacity(self, alpha: float) -> Self:
"""Sets the image's opacity.
Parameters
@ -226,7 +235,7 @@ class ImageMobject(AbstractImageMobject):
self.stroke_opacity = alpha
return self
def fade(self, darkness: float = 0.5, family: bool = True):
def fade(self, darkness: float = 0.5, family: bool = True) -> Self:
"""Sets the image's opacity using a 1 - alpha relationship.
Parameters
@ -243,7 +252,7 @@ class ImageMobject(AbstractImageMobject):
def interpolate_color(
self, mobject1: ImageMobject, mobject2: ImageMobject, alpha: float
):
) -> None:
"""Interpolates the array of pixel color values from one ImageMobject
into an array of equal size in the target ImageMobject.
@ -279,7 +288,7 @@ class ImageMobject(AbstractImageMobject):
alpha,
).astype(self.pixel_array_dtype)
def get_style(self):
def get_style(self) -> dict[str, Any]:
return {
"fill_color": ManimColor(self.color.get_rgb()).to_hex(),
"fill_opacity": self.fill_opacity,
@ -292,7 +301,12 @@ class ImageMobject(AbstractImageMobject):
class ImageMobjectFromCamera(AbstractImageMobject):
def __init__(self, camera, default_display_frame_config=None, **kwargs):
def __init__(
self,
camera,
default_display_frame_config: dict[str, Any] | None = None,
**kwargs: Any,
) -> None:
self.camera = camera
if default_display_frame_config is None:
default_display_frame_config = {
@ -309,14 +323,14 @@ class ImageMobjectFromCamera(AbstractImageMobject):
self.pixel_array = self.camera.pixel_array
return self.pixel_array
def add_display_frame(self, **kwargs):
def add_display_frame(self, **kwargs: Any) -> Self:
config = dict(self.default_display_frame_config)
config.update(kwargs)
self.display_frame = SurroundingRectangle(self, **config)
self.add(self.display_frame)
return self
def interpolate_color(self, mobject1, mobject2, alpha):
def interpolate_color(self, mobject1, mobject2, alpha) -> None:
assert mobject1.pixel_array.shape == mobject2.pixel_array.shape, (
f"Mobject pixel array shapes incompatible for interpolation.\n"
f"Mobject 1 ({mobject1}) : {mobject1.pixel_array.shape}\n"

View file

@ -4,6 +4,8 @@ from __future__ import annotations
__all__ = ["PMobject", "Mobject1D", "Mobject2D", "PGroup", "PointCloudDot", "Point"]
from typing import TYPE_CHECKING
import numpy as np
from manim.mobject.opengl.opengl_compatibility import ConvertToOpenGL
@ -17,6 +19,7 @@ from ...utils.color import (
WHITE,
YELLOW,
ManimColor,
ParsableManimColor,
color_gradient,
color_to_rgba,
rgba_to_color,
@ -25,6 +28,15 @@ from ...utils.iterables import stretch_array_to_length
__all__ = ["PMobject", "Mobject1D", "Mobject2D", "PGroup", "PointCloudDot"]
if TYPE_CHECKING:
from collections.abc import Callable
from typing import Any
import numpy.typing as npt
from typing_extensions import Self
from manim.typing import ManimFloat, Point3D, Vector3D
class PMobject(Mobject, metaclass=ConvertToOpenGL):
"""A disc made of a cloud of Dots
@ -55,19 +67,25 @@ class PMobject(Mobject, metaclass=ConvertToOpenGL):
"""
def __init__(self, stroke_width=DEFAULT_STROKE_WIDTH, **kwargs):
def __init__(self, stroke_width: int = DEFAULT_STROKE_WIDTH, **kwargs: Any) -> None:
self.stroke_width = stroke_width
super().__init__(**kwargs)
def reset_points(self):
def reset_points(self) -> Self:
self.rgbas = np.zeros((0, 4))
self.points = np.zeros((0, 3))
return self
def get_array_attrs(self):
def get_array_attrs(self) -> list[str]:
return super().get_array_attrs() + ["rgbas"]
def add_points(self, points, rgbas=None, color=None, alpha=1):
def add_points(
self,
points: npt.NDArray,
rgbas: npt.NDArray | None = None,
color: ParsableManimColor | None = None,
alpha: float = 1,
) -> Self:
"""Add points.
Points must be a Nx3 numpy array.
@ -85,24 +103,26 @@ class PMobject(Mobject, metaclass=ConvertToOpenGL):
self.rgbas = np.append(self.rgbas, rgbas, axis=0)
return self
def set_color(self, color=YELLOW, family=True):
def set_color(
self, color: ParsableManimColor = YELLOW, family: bool = True
) -> Self:
rgba = color_to_rgba(color)
mobs = self.family_members_with_points() if family else [self]
for mob in mobs:
mob.rgbas[:, :] = rgba
self.color = color
self.color = ManimColor.parse(color)
return self
def get_stroke_width(self):
def get_stroke_width(self) -> int:
return self.stroke_width
def set_stroke_width(self, width, family=True):
def set_stroke_width(self, width: int, family: bool = True) -> Self:
mobs = self.family_members_with_points() if family else [self]
for mob in mobs:
mob.stroke_width = width
return self
def set_color_by_gradient(self, *colors):
def set_color_by_gradient(self, *colors: ParsableManimColor) -> Self:
self.rgbas = np.array(
list(map(color_to_rgba, color_gradient(*colors, len(self.points)))),
)
@ -110,11 +130,11 @@ class PMobject(Mobject, metaclass=ConvertToOpenGL):
def set_colors_by_radial_gradient(
self,
center=None,
radius=1,
inner_color=WHITE,
outer_color=BLACK,
):
center: Point3D | None = None,
radius: float = 1,
inner_color: ParsableManimColor = WHITE,
outer_color: ParsableManimColor = BLACK,
) -> Self:
start_rgba, end_rgba = list(map(color_to_rgba, [inner_color, outer_color]))
if center is None:
center = self.get_center()
@ -129,19 +149,19 @@ class PMobject(Mobject, metaclass=ConvertToOpenGL):
)
return self
def match_colors(self, mobject):
def match_colors(self, mobject: Mobject) -> Self:
Mobject.align_data(self, mobject)
self.rgbas = np.array(mobject.rgbas)
return self
def filter_out(self, condition):
def filter_out(self, condition: npt.NDArray) -> Self:
for mob in self.family_members_with_points():
to_eliminate = ~np.apply_along_axis(condition, 1, mob.points)
mob.points = mob.points[to_eliminate]
mob.rgbas = mob.rgbas[to_eliminate]
return self
def thin_out(self, factor=5):
def thin_out(self, factor: int = 5) -> Self:
"""Removes all but every nth point for n = factor"""
for mob in self.family_members_with_points():
num_points = self.get_num_points()
@ -150,23 +170,27 @@ class PMobject(Mobject, metaclass=ConvertToOpenGL):
)
return self
def sort_points(self, function=lambda p: p[0]):
def sort_points(
self, function: Callable[[npt.NDArray[ManimFloat]], float] = lambda p: p[0]
) -> Self:
"""Function is any map from R^3 to R"""
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, idx=indices: arr[idx])
return self
def fade_to(self, color, alpha, family=True):
def fade_to(
self, color: ParsableManimColor, alpha: float, family: bool = True
) -> Self:
self.rgbas = interpolate(self.rgbas, color_to_rgba(color), alpha)
for mob in self.submobjects:
mob.fade_to(color, alpha, family)
return self
def get_all_rgbas(self):
def get_all_rgbas(self) -> npt.NDArray:
return self.get_merged_array("rgbas")
def ingest_submobjects(self):
def ingest_submobjects(self) -> Self:
attrs = self.get_array_attrs()
arrays = list(map(self.get_merged_array, attrs))
for attr, array in zip(attrs, arrays):
@ -175,30 +199,32 @@ class PMobject(Mobject, metaclass=ConvertToOpenGL):
self.note_changed_family()
return self
def get_color(self):
def get_color(self) -> ManimColor:
return rgba_to_color(self.rgbas[0, :])
def point_from_proportion(self, alpha):
def point_from_proportion(self, alpha: float) -> Any:
index = alpha * (self.get_num_points() - 1)
return self.points[index]
return self.points[np.floor(index)]
@staticmethod
def get_mobject_type_class():
def get_mobject_type_class() -> type[PMobject]:
return PMobject
# Alignment
def align_points_with_larger(self, larger_mobject):
def align_points_with_larger(self, larger_mobject: Mobject) -> None:
assert isinstance(larger_mobject, PMobject)
self.apply_over_attr_arrays(
lambda a: stretch_array_to_length(a, larger_mobject.get_num_points()),
)
def get_point_mobject(self, center=None):
def get_point_mobject(self, center: Point3D | None = None) -> Point:
if center is None:
center = self.get_center()
return PMobject().set_points([center])
def interpolate_color(self, mobject1, mobject2, alpha):
def interpolate_color(
self, mobject1: Mobject, mobject2: Mobject, alpha: float
) -> Self:
self.rgbas = interpolate(mobject1.rgbas, mobject2.rgbas, alpha)
self.set_stroke_width(
interpolate(
@ -209,7 +235,7 @@ class PMobject(Mobject, metaclass=ConvertToOpenGL):
)
return self
def pointwise_become_partial(self, mobject, a, b):
def pointwise_become_partial(self, mobject: Mobject, a: float, b: float) -> None:
lower_index, upper_index = (int(x * mobject.get_num_points()) for x in (a, b))
for attr in self.get_array_attrs():
full_array = getattr(mobject, attr)
@ -219,24 +245,31 @@ class PMobject(Mobject, metaclass=ConvertToOpenGL):
# TODO, Make the two implementations below non-redundant
class Mobject1D(PMobject, metaclass=ConvertToOpenGL):
def __init__(self, density=DEFAULT_POINT_DENSITY_1D, **kwargs):
def __init__(self, density: int = DEFAULT_POINT_DENSITY_1D, **kwargs: Any) -> None:
self.density = density
self.epsilon = 1.0 / self.density
super().__init__(**kwargs)
def add_line(self, start, end, color=None):
def add_line(
self,
start: npt.NDArray,
end: npt.NDArray,
color: ParsableManimColor | None = None,
) -> None:
start, end = list(map(np.array, [start, end]))
length = np.linalg.norm(end - start)
if length == 0:
points = [start]
points = np.array([start])
else:
epsilon = self.epsilon / length
points = [interpolate(start, end, t) for t in np.arange(0, 1, epsilon)]
points = np.array(
[interpolate(start, end, t) for t in np.arange(0, 1, epsilon)]
)
self.add_points(points, color=color)
class Mobject2D(PMobject, metaclass=ConvertToOpenGL):
def __init__(self, density=DEFAULT_POINT_DENSITY_2D, **kwargs):
def __init__(self, density: int = DEFAULT_POINT_DENSITY_2D, **kwargs: Any) -> None:
self.density = density
self.epsilon = 1.0 / self.density
super().__init__(**kwargs)
@ -265,7 +298,7 @@ class PGroup(PMobject):
"""
def __init__(self, *pmobs, **kwargs):
def __init__(self, *pmobs: Any, **kwargs: Any) -> None:
if not all(isinstance(m, (PMobject, OpenGLPMobject)) for m in pmobs):
raise ValueError(
"All submobjects must be of type PMobject or OpenGLPMObject"
@ -274,10 +307,13 @@ class PGroup(PMobject):
super().__init__(**kwargs)
self.add(*pmobs)
def fade_to(self, color, alpha, family=True):
def fade_to(
self, color: ParsableManimColor, alpha: float, family: bool = True
) -> Self:
if family:
for mob in self.submobjects:
mob.fade_to(color, alpha, family)
return self
class PointCloudDot(Mobject1D):
@ -314,13 +350,13 @@ class PointCloudDot(Mobject1D):
def __init__(
self,
center=ORIGIN,
radius=2.0,
stroke_width=2,
density=DEFAULT_POINT_DENSITY_1D,
color=YELLOW,
**kwargs,
):
center: Vector3D = ORIGIN,
radius: float = 2.0,
stroke_width: int = 2,
density: int = DEFAULT_POINT_DENSITY_1D,
color: ManimColor = YELLOW,
**kwargs: Any,
) -> None:
self.radius = radius
self.epsilon = 1.0 / density
super().__init__(
@ -328,20 +364,22 @@ class PointCloudDot(Mobject1D):
)
self.shift(center)
def init_points(self):
def init_points(self) -> None:
self.reset_points()
self.generate_points()
def generate_points(self):
def generate_points(self) -> None:
self.add_points(
[
r * (np.cos(theta) * RIGHT + np.sin(theta) * UP)
for r in np.arange(self.epsilon, self.radius, self.epsilon)
# Num is equal to int(stop - start)/ (step + 1) reformulated.
for theta in np.linspace(
0,
2 * np.pi,
num=int(2 * np.pi * (r + self.epsilon) / self.epsilon),
)
],
np.array(
[
r * (np.cos(theta) * RIGHT + np.sin(theta) * UP)
for r in np.arange(self.epsilon, self.radius, self.epsilon)
# Num is equal to int(stop - start)/ (step + 1) reformulated.
for theta in np.linspace(
0,
2 * np.pi,
num=int(2 * np.pi * (r + self.epsilon) / self.epsilon),
)
]
),
)

View file

@ -49,12 +49,15 @@ from manim.utils.iterables import (
from manim.utils.space_ops import rotate_vector, shoelace_direction
if TYPE_CHECKING:
from typing import Any
import numpy.typing as npt
from typing_extensions import Self
from manim.typing import (
BezierPoints,
CubicBezierPoints,
InternalPoint3D_Array,
ManimFloat,
MappingFunction,
Point2D,
@ -134,7 +137,7 @@ class VMobject(Mobject):
tolerance_for_point_equality: float = 1e-6,
n_points_per_cubic_curve: int = 4,
cap_style: CapStyleType = CapStyleType.AUTO,
**kwargs,
**kwargs: Any,
):
self.stroke_width = stroke_width
if background_stroke_color is not None:
@ -481,6 +484,64 @@ class VMobject(Mobject):
self.set_stroke(opacity=opacity, family=family, background=True)
return self
def scale(self, scale_factor: float, scale_stroke: bool = False, **kwargs) -> Self:
r"""Scale the size by a factor.
Default behavior is to scale about the center of the vmobject.
Parameters
----------
scale_factor
The scaling factor :math:`\alpha`. If :math:`0 < |\alpha| < 1`, the mobject
will shrink, and for :math:`|\alpha| > 1` it will grow. Furthermore,
if :math:`\alpha < 0`, the mobject is also flipped.
scale_stroke
Boolean determining if the object's outline is scaled when the object is scaled.
If enabled, and object with 2px outline is scaled by a factor of .5, it will have an outline of 1px.
kwargs
Additional keyword arguments passed to
:meth:`~.Mobject.scale`.
Returns
-------
:class:`VMobject`
``self``
Examples
--------
.. manim:: MobjectScaleExample
:save_last_frame:
class MobjectScaleExample(Scene):
def construct(self):
c1 = Circle(1, RED).set_x(-1)
c2 = Circle(1, GREEN).set_x(1)
vg = VGroup(c1, c2)
vg.set_stroke(width=50)
self.add(vg)
self.play(
c1.animate.scale(.25),
c2.animate.scale(.25,
scale_stroke=True)
)
See also
--------
:meth:`move_to`
"""
if scale_stroke:
self.set_stroke(width=abs(scale_factor) * self.get_stroke_width())
self.set_stroke(
width=abs(scale_factor) * self.get_stroke_width(background=True),
background=True,
)
super().scale(scale_factor, **kwargs)
return self
def fade(self, darkness: float = 0.5, family: bool = True) -> Self:
factor = 1.0 - darkness
self.set_fill(opacity=factor * self.get_fill_opacity(), family=False)
@ -711,7 +772,7 @@ class VMobject(Mobject):
return self
def set_points(self, points: Point3D_Array) -> Self:
self.points: Point3D_Array = np.array(points)
self.points: InternalPoint3D_Array = np.array(points)
return self
def set_z(self, z: float) -> Self:
@ -1601,7 +1662,7 @@ class VMobject(Mobject):
nppcc = self.n_points_per_cubic_curve
return [self.points[i::nppcc] for i in range(nppcc)]
def get_start_anchors(self) -> Point3D_Array:
def get_start_anchors(self) -> InternalPoint3D_Array:
"""Returns the start anchors of the bezier curves.
Returns
@ -2073,10 +2134,8 @@ class VGroup(VMobject, metaclass=ConvertToOpenGL):
"""
def __init__(
self,
*vmobjects: VMobject | Iterable[VMobject],
**kwargs,
):
self, *vmobjects: VMobject | Iterable[VMobject], **kwargs: Any
) -> None:
super().__init__(**kwargs)
self.add(*vmobjects)

View file

@ -1,8 +1,7 @@
from __future__ import annotations
from manim import config, logger
from .plugins_flags import get_plugins, list_plugins
from manim._config import config, logger
from manim.plugins.plugins_flags import get_plugins, list_plugins
__all__ = [
"list_plugins",

View file

@ -5,7 +5,7 @@ from __future__ import annotations
from importlib.metadata import entry_points
from typing import Any
from manim import console
from manim._config import console
__all__ = ["list_plugins"]
@ -22,5 +22,5 @@ def list_plugins() -> None:
console.print("[green bold]Plugins:[/green bold]", justify="left")
plugins = get_plugins()
for plugin in plugins:
console.print(f"{plugin}")
for plugin_name in plugins:
console.print(f"{plugin_name}")

View file

@ -250,13 +250,13 @@ class CairoRenderer:
if config["save_last_frame"]:
self.skip_animations = True
if (
config["from_animation_number"]
and self.num_plays < config["from_animation_number"]
config.from_animation_number > 0
and self.num_plays < config.from_animation_number
):
self.skip_animations = True
if (
config["upto_animation_number"]
and self.num_plays > config["upto_animation_number"]
config.upto_animation_number >= 0
and self.num_plays > config.upto_animation_number
):
self.skip_animations = True
raise EndSceneEarlyException()

View file

@ -55,6 +55,10 @@ __all__ = [
"Point3D",
"InternalPoint3D_Array",
"Point3D_Array",
"InternalPointND",
"PointND",
"InternalPointND_Array",
"PointND_Array",
"Vector2D",
"Vector2D_Array",
"Vector3D",
@ -301,7 +305,7 @@ parameter can handle being passed a `Point3D` instead.
"""
InternalPoint2D_Array: TypeAlias = npt.NDArray[PointDType]
"""``shape: (N, 2)``
"""``shape: (M, 2)``
An array of `InternalPoint2D` objects: ``[[float, float], ...]``.
@ -311,7 +315,7 @@ An array of `InternalPoint2D` objects: ``[[float, float], ...]``.
"""
Point2D_Array: TypeAlias = Union[InternalPoint2D_Array, tuple[Point2D, ...]]
"""``shape: (N, 2)``
"""``shape: (M, 2)``
An array of `Point2D` objects: ``[[float, float], ...]``.
@ -339,7 +343,7 @@ A 3-dimensional point: ``[float, float, float]``.
"""
InternalPoint3D_Array: TypeAlias = npt.NDArray[PointDType]
"""``shape: (N, 3)``
"""``shape: (M, 3)``
An array of `Point3D` objects: ``[[float, float, float], ...]``.
@ -349,7 +353,7 @@ An array of `Point3D` objects: ``[[float, float, float], ...]``.
"""
Point3D_Array: TypeAlias = Union[InternalPoint3D_Array, tuple[Point3D, ...]]
"""``shape: (N, 3)``
"""``shape: (M, 3)``
An array of `Point3D` objects: ``[[float, float, float], ...]``.
@ -357,6 +361,41 @@ Please refer to the documentation of the function you are using for
further type information.
"""
InternalPointND: TypeAlias = npt.NDArray[PointDType]
"""``shape: (N,)``
An N-dimensional point: ``[float, ...]``.
.. note::
This type alias is mostly made available for internal use, and
only includes the NumPy type.
"""
PointND: TypeAlias = Union[InternalPointND, tuple[float, ...]]
"""``shape: (N,)``
An N-dimensional point: ``[float, ...]``.
"""
InternalPointND_Array: TypeAlias = npt.NDArray[PointDType]
"""``shape: (M, N)``
An array of `PointND` objects: ``[[float, ...], ...]``.
.. note::
This type alias is mostly made available for internal use, and
only includes the NumPy type.
"""
PointND_Array: TypeAlias = Union[InternalPointND_Array, tuple[PointND, ...]]
"""``shape: (M, N)``
An array of `PointND` objects: ``[[float, ...], ...]``.
Please refer to the documentation of the function you are using for
further type information.
"""
"""
[CATEGORY]

View file

@ -0,0 +1,96 @@
r"""dvips Colors
This module contains the colors defined in the dvips driver, which are commonly accessed
as named colors in LaTeX via the ``\usepackage[dvipsnames]{xcolor}`` package.
To use the colors from this list, access them directly from the module (which
is exposed to Manim's global name space):
.. code:: pycon
>>> from manim import DVIPSNAMES
>>> DVIPSNAMES.DARKORCHID
ManimColor('#A4538A')
List of Color Constants
-----------------------
These hex values are derived from those specified in the ``xcolor`` package
documentation (see https://ctan.org/pkg/xcolor):
.. automanimcolormodule:: manim.utils.color.DVIPSNAMES
"""
from __future__ import annotations
from .core import ManimColor
AQUAMARINE = ManimColor("#00B5BE")
BITTERSWEET = ManimColor("#C04F17")
APRICOT = ManimColor("#FBB982")
BLACK = ManimColor("#221E1F")
BLUE = ManimColor("#2D2F92")
BLUEGREEN = ManimColor("#00B3B8")
BLUEVIOLET = ManimColor("#473992")
BRICKRED = ManimColor("#B6321C")
BROWN = ManimColor("#792500")
BURNTORANGE = ManimColor("#F7921D")
CADETBLUE = ManimColor("#74729A")
CARNATIONPINK = ManimColor("#F282B4")
CERULEAN = ManimColor("#00A2E3")
CORNFLOWERBLUE = ManimColor("#41B0E4")
CYAN = ManimColor("#00AEEF")
DANDELION = ManimColor("#FDBC42")
DARKORCHID = ManimColor("#A4538A")
EMERALD = ManimColor("#00A99D")
FORESTGREEN = ManimColor("#009B55")
FUCHSIA = ManimColor("#8C368C")
GOLDENROD = ManimColor("#FFDF42")
GRAY = ManimColor("#949698")
GREEN = ManimColor("#00A64F")
GREENYELLOW = ManimColor("#DFE674")
JUNGLEGREEN = ManimColor("#00A99A")
LAVENDER = ManimColor("#F49EC4")
LIMEGREEN = ManimColor("#8DC73E")
MAGENTA = ManimColor("#EC008C")
MAHOGANY = ManimColor("#A9341F")
MAROON = ManimColor("#AF3235")
MELON = ManimColor("#F89E7B")
MIDNIGHTBLUE = ManimColor("#006795")
MULBERRY = ManimColor("#A93C93")
NAVYBLUE = ManimColor("#006EB8")
OLIVEGREEN = ManimColor("#3C8031")
ORANGE = ManimColor("#F58137")
ORANGERED = ManimColor("#ED135A")
ORCHID = ManimColor("#AF72B0")
PEACH = ManimColor("#F7965A")
PERIWINKLE = ManimColor("#7977B8")
PINEGREEN = ManimColor("#008B72")
PLUM = ManimColor("#92268F")
PROCESSBLUE = ManimColor("#00B0F0")
PURPLE = ManimColor("#99479B")
RAWSIENNA = ManimColor("#974006")
RED = ManimColor("#ED1B23")
REDORANGE = ManimColor("#F26035")
REDVIOLET = ManimColor("#A1246B")
RHODAMINE = ManimColor("#EF559F")
ROYALBLUE = ManimColor("#0071BC")
ROYALPURPLE = ManimColor("#613F99")
RUBINERED = ManimColor("#ED017D")
SALMON = ManimColor("#F69289")
SEAGREEN = ManimColor("#3FBC9D")
SEPIA = ManimColor("#671800")
SKYBLUE = ManimColor("#46C5DD")
SPRINGGREEN = ManimColor("#C6DC67")
TAN = ManimColor("#DA9D76")
TEALBLUE = ManimColor("#00AEB3")
THISTLE = ManimColor("#D883B7")
TURQUOISE = ManimColor("#00B4CE")
VIOLET = ManimColor("#58429B")
VIOLETRED = ManimColor("#EF58A0")
WHITE = ManimColor("#FFFFFF")
WILDSTRAWBERRY = ManimColor("#EE2967")
YELLOW = ManimColor("#FFF200")
YELLOWGREEN = ManimColor("#98CC70")
YELLOWORANGE = ManimColor("#FAA21A")

View file

@ -0,0 +1,179 @@
r"""SVG 1.1 Colors
This module contains the colors defined in the SVG 1.1 specification, which are commonly
accessed as named colors in LaTeX via the ``\usepackage[svgnames]{xcolor}`` package.
To use the colors from this list, access them directly from the module (which
is exposed to Manim's global name space):
.. code:: pycon
>>> from manim import SVGNAMES
>>> SVGNAMES.LIGHTCORAL
ManimColor('#EF7F7F')
List of Color Constants
-----------------------
These hex values are derived from those specified in the ``xcolor`` package
documentation (see https://ctan.org/pkg/xcolor):
.. automanimcolormodule:: manim.utils.color.SVGNAMES
"""
from __future__ import annotations
from .core import ManimColor
ALICEBLUE = ManimColor("#EFF7FF")
ANTIQUEWHITE = ManimColor("#F9EAD7")
AQUA = ManimColor("#00FFFF")
AQUAMARINE = ManimColor("#7EFFD3")
AZURE = ManimColor("#EFFFFF")
BEIGE = ManimColor("#F4F4DC")
BISQUE = ManimColor("#FFE3C4")
BLACK = ManimColor("#000000")
BLANCHEDALMOND = ManimColor("#FFEACD")
BLUE = ManimColor("#0000FF")
BLUEVIOLET = ManimColor("#892BE2")
BROWN = ManimColor("#A52A2A")
BURLYWOOD = ManimColor("#DDB787")
CADETBLUE = ManimColor("#5E9EA0")
CHARTREUSE = ManimColor("#7EFF00")
CHOCOLATE = ManimColor("#D2681D")
CORAL = ManimColor("#FF7E4F")
CORNFLOWERBLUE = ManimColor("#6395ED")
CORNSILK = ManimColor("#FFF7DC")
CRIMSON = ManimColor("#DC143B")
CYAN = ManimColor("#00FFFF")
DARKBLUE = ManimColor("#00008A")
DARKCYAN = ManimColor("#008A8A")
DARKGOLDENROD = ManimColor("#B7850B")
DARKGRAY = ManimColor("#A9A9A9")
DARKGREEN = ManimColor("#006300")
DARKGREY = ManimColor("#A9A9A9")
DARKKHAKI = ManimColor("#BCB66B")
DARKMAGENTA = ManimColor("#8A008A")
DARKOLIVEGREEN = ManimColor("#546B2F")
DARKORANGE = ManimColor("#FF8C00")
DARKORCHID = ManimColor("#9931CC")
DARKRED = ManimColor("#8A0000")
DARKSALMON = ManimColor("#E8967A")
DARKSEAGREEN = ManimColor("#8EBB8E")
DARKSLATEBLUE = ManimColor("#483D8A")
DARKSLATEGRAY = ManimColor("#2F4F4F")
DARKSLATEGREY = ManimColor("#2F4F4F")
DARKTURQUOISE = ManimColor("#00CED1")
DARKVIOLET = ManimColor("#9300D3")
DEEPPINK = ManimColor("#FF1492")
DEEPSKYBLUE = ManimColor("#00BFFF")
DIMGRAY = ManimColor("#686868")
DIMGREY = ManimColor("#686868")
DODGERBLUE = ManimColor("#1D90FF")
FIREBRICK = ManimColor("#B12121")
FLORALWHITE = ManimColor("#FFF9EF")
FORESTGREEN = ManimColor("#218A21")
FUCHSIA = ManimColor("#FF00FF")
GAINSBORO = ManimColor("#DCDCDC")
GHOSTWHITE = ManimColor("#F7F7FF")
GOLD = ManimColor("#FFD700")
GOLDENROD = ManimColor("#DAA51F")
GRAY = ManimColor("#7F7F7F")
GREEN = ManimColor("#007F00")
GREENYELLOW = ManimColor("#ADFF2F")
GREY = ManimColor("#7F7F7F")
HONEYDEW = ManimColor("#EFFFEF")
HOTPINK = ManimColor("#FF68B3")
INDIANRED = ManimColor("#CD5B5B")
INDIGO = ManimColor("#4A0082")
IVORY = ManimColor("#FFFFEF")
KHAKI = ManimColor("#EFE58C")
LAVENDER = ManimColor("#E5E5F9")
LAVENDERBLUSH = ManimColor("#FFEFF4")
LAWNGREEN = ManimColor("#7CFC00")
LEMONCHIFFON = ManimColor("#FFF9CD")
LIGHTBLUE = ManimColor("#ADD8E5")
LIGHTCORAL = ManimColor("#EF7F7F")
LIGHTCYAN = ManimColor("#E0FFFF")
LIGHTGOLDENROD = ManimColor("#EDDD82")
LIGHTGOLDENRODYELLOW = ManimColor("#F9F9D2")
LIGHTGRAY = ManimColor("#D3D3D3")
LIGHTGREEN = ManimColor("#90ED90")
LIGHTGREY = ManimColor("#D3D3D3")
LIGHTPINK = ManimColor("#FFB5C0")
LIGHTSALMON = ManimColor("#FFA07A")
LIGHTSEAGREEN = ManimColor("#1FB1AA")
LIGHTSKYBLUE = ManimColor("#87CEF9")
LIGHTSLATEBLUE = ManimColor("#8470FF")
LIGHTSLATEGRAY = ManimColor("#778799")
LIGHTSLATEGREY = ManimColor("#778799")
LIGHTSTEELBLUE = ManimColor("#AFC4DD")
LIGHTYELLOW = ManimColor("#FFFFE0")
LIME = ManimColor("#00FF00")
LIMEGREEN = ManimColor("#31CD31")
LINEN = ManimColor("#F9EFE5")
MAGENTA = ManimColor("#FF00FF")
MAROON = ManimColor("#7F0000")
MEDIUMAQUAMARINE = ManimColor("#66CDAA")
MEDIUMBLUE = ManimColor("#0000CD")
MEDIUMORCHID = ManimColor("#BA54D3")
MEDIUMPURPLE = ManimColor("#9270DB")
MEDIUMSEAGREEN = ManimColor("#3BB271")
MEDIUMSLATEBLUE = ManimColor("#7B68ED")
MEDIUMSPRINGGREEN = ManimColor("#00F99A")
MEDIUMTURQUOISE = ManimColor("#48D1CC")
MEDIUMVIOLETRED = ManimColor("#C61584")
MIDNIGHTBLUE = ManimColor("#181870")
MINTCREAM = ManimColor("#F4FFF9")
MISTYROSE = ManimColor("#FFE3E1")
MOCCASIN = ManimColor("#FFE3B5")
NAVAJOWHITE = ManimColor("#FFDDAD")
NAVY = ManimColor("#00007F")
NAVYBLUE = ManimColor("#00007F")
OLDLACE = ManimColor("#FCF4E5")
OLIVE = ManimColor("#7F7F00")
OLIVEDRAB = ManimColor("#6B8D22")
ORANGE = ManimColor("#FFA500")
ORANGERED = ManimColor("#FF4400")
ORCHID = ManimColor("#DA70D6")
PALEGOLDENROD = ManimColor("#EDE8AA")
PALEGREEN = ManimColor("#97FB97")
PALETURQUOISE = ManimColor("#AFEDED")
PALEVIOLETRED = ManimColor("#DB7092")
PAPAYAWHIP = ManimColor("#FFEED4")
PEACHPUFF = ManimColor("#FFDAB8")
PERU = ManimColor("#CD843F")
PINK = ManimColor("#FFBFCA")
PLUM = ManimColor("#DDA0DD")
POWDERBLUE = ManimColor("#AFE0E5")
PURPLE = ManimColor("#7F007F")
RED = ManimColor("#FF0000")
ROSYBROWN = ManimColor("#BB8E8E")
ROYALBLUE = ManimColor("#4168E1")
SADDLEBROWN = ManimColor("#8A4413")
SALMON = ManimColor("#F97F72")
SANDYBROWN = ManimColor("#F3A45F")
SEAGREEN = ManimColor("#2D8A56")
SEASHELL = ManimColor("#FFF4ED")
SIENNA = ManimColor("#A0512C")
SILVER = ManimColor("#BFBFBF")
SKYBLUE = ManimColor("#87CEEA")
SLATEBLUE = ManimColor("#6959CD")
SLATEGRAY = ManimColor("#707F90")
SLATEGREY = ManimColor("#707F90")
SNOW = ManimColor("#FFF9F9")
SPRINGGREEN = ManimColor("#00FF7E")
STEELBLUE = ManimColor("#4682B3")
TAN = ManimColor("#D2B38C")
TEAL = ManimColor("#007F7F")
THISTLE = ManimColor("#D8BFD8")
TOMATO = ManimColor("#FF6347")
TURQUOISE = ManimColor("#3FE0CF")
VIOLET = ManimColor("#ED82ED")
VIOLETRED = ManimColor("#D01F90")
WHEAT = ManimColor("#F4DDB2")
WHITE = ManimColor("#FFFFFF")
WHITESMOKE = ManimColor("#F4F4F4")
YELLOW = ManimColor("#FFFF00")
YELLOWGREEN = ManimColor("#9ACD30")

View file

@ -16,8 +16,9 @@ There are several predefined colors available in Manim:
- The colors listed in :mod:`.color.manim_colors` are loaded into
Manim's global name space.
- The colors in :mod:`.color.AS2700`, :mod:`.color.BS381`, :mod:`.color.X11`,
and :mod:`.color.XKCD` need to be accessed via their module (which are available
- The colors in :mod:`.color.AS2700`, :mod:`.color.BS381`,
:mod:`.color.DVIPSNAMES`, :mod:`.color.SVGNAMES`, :mod:`.color.X11` and
:mod:`.color.XKCD` need to be accessed via their module (which are available
in Manim's global name space), or imported separately. For example:
.. code:: pycon
@ -42,6 +43,8 @@ The following modules contain the predefined color constants:
manim_colors
AS2700
BS381
DVIPSNAMES
SVGNAMES
XKCD
X11
@ -49,7 +52,7 @@ The following modules contain the predefined color constants:
from __future__ import annotations
from . import AS2700, BS381, X11, XKCD
from . import AS2700, BS381, DVIPSNAMES, SVGNAMES, X11, XKCD
from .core import *
from .manim_colors import *

View file

@ -585,7 +585,7 @@ class ManimColor:
new[-1] = alpha
return self._construct_from_space(new)
def interpolate(self, other: ManimColor, alpha: float) -> Self:
def interpolate(self, other: Self, alpha: float) -> Self:
"""Interpolates between the current and the given ManimColor an returns the interpolated color
Parameters
@ -606,6 +606,99 @@ class ManimColor:
self._internal_space * (1 - alpha) + other._internal_space * alpha
)
def darker(self, blend: float = 0.2) -> Self:
"""Returns a new color that is darker than the current color, i.e.
interpolated with black. The opacity is unchanged.
Parameters
----------
blend : float, optional
The blend ratio for the interpolation, from 0 (the current color
unchanged) to 1 (pure black). By default 0.2 which results in a
slightly darker color
Returns
-------
ManimColor
The darker ManimColor
See Also
--------
:meth:`lighter`
"""
from manim.utils.color.manim_colors import BLACK
alpha = self._internal_space[3]
black = self._from_internal(BLACK._internal_value)
return self.interpolate(black, blend).opacity(alpha)
def lighter(self, blend: float = 0.2) -> Self:
"""Returns a new color that is lighter than the current color, i.e.
interpolated with white. The opacity is unchanged.
Parameters
----------
blend : float, optional
The blend ratio for the interpolation, from 0 (the current color
unchanged) to 1 (pure white). By default 0.2 which results in a
slightly lighter color
Returns
-------
ManimColor
The lighter ManimColor
See Also
--------
:meth:`darker`
"""
from manim.utils.color.manim_colors import WHITE
alpha = self._internal_space[3]
white = self._from_internal(WHITE._internal_value)
return self.interpolate(white, blend).opacity(alpha)
def contrasting(
self,
threshold: float = 0.5,
light: Self | None = None,
dark: Self | None = None,
) -> Self:
"""Returns one of two colors, light or dark (by default white or black),
that contrasts with the current color (depending on its luminance).
This is typically used to set text in a contrasting color that ensures
it is readable against a background of the current color.
Parameters
----------
threshold : float, optional
The luminance threshold that dictates whether the current color is
considered light or dark (and thus whether to return the dark or
light color, respectively), by default 0.5
light : ManimColor, optional
The light color to return if the current color is considered dark,
by default pure white
dark : ManimColor, optional
The dark color to return if the current color is considered light,
by default pure black
Returns
-------
ManimColor
The contrasting ManimColor
"""
from manim.utils.color.manim_colors import BLACK, WHITE
luminance, _, _ = colorsys.rgb_to_yiq(*self.to_rgb())
if luminance < threshold:
if light is not None:
return light
return self._from_internal(WHITE._internal_value)
else:
if dark is not None:
return dark
return self._from_internal(BLACK._internal_value)
@overload
def opacity(self, opacity: float) -> Self: ...
@ -1293,7 +1386,7 @@ def color_gradient(
def interpolate_color(
color1: ManimColorT, color2: ManimColor, alpha: float
color1: ManimColorT, color2: ManimColorT, alpha: float
) -> ManimColorT:
"""Standalone function to interpolate two ManimColors and get the result refer to :meth:`interpolate` in :class:`ManimColor`

View file

@ -46,21 +46,22 @@ These colors form Manim's default color space.
for line, char in zip(color_groups[0], "abcde"):
color_groups.add(Text(char).scale(0.6).next_to(line, LEFT, buff=0.2))
def named_lines_group(length, colors, names, text_colors, align_to_block):
def named_lines_group(length, color_names, labels, align_to_block):
colors = [getattr(Colors, color.upper()) for color in color_names]
lines = VGroup(
*[
Line(
ORIGIN,
RIGHT * length,
stroke_width=55,
color=getattr(Colors, color.upper()),
color=color,
)
for color in colors
]
).arrange_submobjects(buff=0.6, direction=DOWN)
for line, name, color in zip(lines, names, text_colors):
line.add(Text(name, color=color).scale(0.6).move_to(line))
for line, name, color in zip(lines, labels, colors):
line.add(Text(name, color=color.contrasting()).scale(0.6).move_to(line))
lines.next_to(color_groups, DOWN, buff=0.5).align_to(
color_groups[align_to_block], LEFT
)
@ -79,7 +80,6 @@ These colors form Manim's default color space.
3.2,
other_colors,
other_colors,
[BLACK] * 4 + [WHITE] * 2,
0,
)
@ -95,7 +95,6 @@ These colors form Manim's default color space.
"darker_gray / gray_e",
"black",
],
[BLACK] * 3 + [WHITE] * 4,
2,
)
@ -109,7 +108,6 @@ These colors form Manim's default color space.
3.2,
pure_colors,
pure_colors,
[BLACK, BLACK, WHITE],
6,
)

View file

@ -30,7 +30,9 @@ from typing import TYPE_CHECKING
import numpy as np
if TYPE_CHECKING:
from manim.scene.scene_file_writer import SceneFileWriter
from manim.typing import StrPath
from ..file_writer import FileWriter
from manim import __version__, config, console, logger
@ -159,7 +161,7 @@ def guarantee_empty_existence(path: Path) -> Path:
def seek_full_path_from_defaults(
file_name: str, default_dir: Path, extensions: list[str]
file_name: StrPath, default_dir: Path, extensions: list[str]
) -> Path:
possible_paths = [Path(file_name).expanduser()]
possible_paths += [
@ -186,7 +188,7 @@ def modify_atime(file_path: str) -> None:
os.utime(file_path, times=(time.time(), Path(file_path).stat().st_mtime))
def open_file(file_path, in_browser=False):
def open_file(file_path: Path, in_browser: bool = False) -> None:
current_os = platform.system()
if current_os == "Windows":
os.startfile(file_path if not in_browser else file_path.parent)
@ -209,7 +211,7 @@ def open_file(file_path, in_browser=False):
sp.run(commands)
def open_media_file(file_writer: SceneFileWriter) -> None:
def open_media_file(file_writer: FileWriter) -> None:
file_paths = []
if config["save_last_frame"]:
@ -249,7 +251,7 @@ def get_template_path() -> Path:
return Path.resolve(Path(__file__).parent.parent / "templates")
def add_import_statement(file: Path):
def add_import_statement(file: Path) -> None:
"""Prepends an import statement in a file
Parameters

View file

@ -9,7 +9,8 @@ __all__ = [
"change_to_rgba_array",
]
from pathlib import Path
from pathlib import Path, PurePath
from typing import TYPE_CHECKING
import numpy as np
from PIL import Image
@ -17,8 +18,11 @@ from PIL import Image
from .. import config
from ..utils.file_ops import seek_full_path_from_defaults
if TYPE_CHECKING:
import numpy.typing as npt
def get_full_raster_image_path(image_file_name: str) -> Path:
def get_full_raster_image_path(image_file_name: str | PurePath) -> Path:
return seek_full_path_from_defaults(
image_file_name,
default_dir=config.get_dir("assets_dir"),
@ -26,7 +30,7 @@ def get_full_raster_image_path(image_file_name: str) -> Path:
)
def get_full_vector_image_path(image_file_name: str) -> Path:
def get_full_vector_image_path(image_file_name: str | PurePath) -> Path:
return seek_full_path_from_defaults(
image_file_name,
default_dir=config.get_dir("assets_dir"),
@ -49,7 +53,7 @@ def invert_image(image: np.array) -> Image:
return Image.fromarray(arr)
def change_to_rgba_array(image, dtype="uint8"):
def change_to_rgba_array(image: npt.NDArray, dtype="uint8") -> npt.NDArray:
"""Converts an RGB array into RGBA with the alpha value opacity maxed."""
pa = image
if len(pa.shape) == 2:

156
manim/utils/polylabel.py Normal file
View file

@ -0,0 +1,156 @@
#!/usr/bin/env python
from __future__ import annotations
from queue import PriorityQueue
from typing import TYPE_CHECKING
import numpy as np
if TYPE_CHECKING:
from collections.abc import Sequence
from manim.typing import Point3D, Point3D_Array
class Polygon:
"""
Initializes the Polygon with the given rings.
Parameters
----------
rings
A collection of closed polygonal ring.
"""
def __init__(self, rings: Sequence[Point3D_Array]) -> None:
# Flatten Array
csum = np.cumsum([ring.shape[0] for ring in rings])
self.array = np.concatenate(rings, axis=0)
# Compute Boundary
self.start = np.delete(self.array, csum - 1, axis=0)
self.stop = np.delete(self.array, csum % csum[-1], axis=0)
self.diff = np.delete(np.diff(self.array, axis=0), csum[:-1] - 1, axis=0)
self.norm = self.diff / np.einsum("ij,ij->i", self.diff, self.diff).reshape(
-1, 1
)
# Compute Centroid
x, y = self.start[:, 0], self.start[:, 1]
xr, yr = self.stop[:, 0], self.stop[:, 1]
self.area = 0.5 * (np.dot(x, yr) - np.dot(xr, y))
if self.area:
factor = x * yr - xr * y
cx = np.sum((x + xr) * factor) / (6.0 * self.area)
cy = np.sum((y + yr) * factor) / (6.0 * self.area)
self.centroid = np.array([cx, cy])
def compute_distance(self, point: Point3D) -> float:
"""Compute the minimum distance from a point to the polygon."""
scalars = np.einsum("ij,ij->i", self.norm, point - self.start)
clips = np.clip(scalars, 0, 1).reshape(-1, 1)
d = np.min(np.linalg.norm(self.start + self.diff * clips - point, axis=1))
return d if self.inside(point) else -d
def inside(self, point: Point3D) -> bool:
"""Check if a point is inside the polygon."""
# Views
px, py = point
x, y = self.start[:, 0], self.start[:, 1]
xr, yr = self.stop[:, 0], self.stop[:, 1]
# Count Crossings (enforce short-circuit)
c = (y > py) != (yr > py)
c = px < x[c] + (py - y[c]) * (xr[c] - x[c]) / (yr[c] - y[c])
return np.sum(c) % 2 == 1
class Cell:
"""
A square in a mesh covering the :class:`~.Polygon` passed as an argument.
Parameters
----------
c
Center coordinates of the Cell.
h
Half-Size of the Cell.
polygon
:class:`~.Polygon` object for which the distance is computed.
"""
def __init__(self, c: Point3D, h: float, polygon: Polygon) -> None:
self.c = c
self.h = h
self.d = polygon.compute_distance(self.c)
self.p = self.d + self.h * np.sqrt(2)
def __lt__(self, other: Cell) -> bool:
return self.d < other.d
def __gt__(self, other: Cell) -> bool:
return self.d > other.d
def __le__(self, other: Cell) -> bool:
return self.d <= other.d
def __ge__(self, other: Cell) -> bool:
return self.d >= other.d
def polylabel(rings: Sequence[Point3D_Array], precision: float = 0.01) -> Cell:
"""
Finds the pole of inaccessibility (the point that is farthest from the edges of the polygon)
using an iterative grid-based approach.
Parameters
----------
rings
A list of lists, where each list is a sequence of points representing the rings of the polygon.
Typically, multiple rings indicate holes in the polygon.
precision
The precision of the result (default is 0.01).
Returns
-------
Cell
A Cell containing the pole of inaccessibility to a given precision.
"""
# Precompute Polygon Data
array = [np.array(ring)[:, :2] for ring in rings]
polygon = Polygon(array)
# Bounding Box
mins = np.min(polygon.array, axis=0)
maxs = np.max(polygon.array, axis=0)
dims = maxs - mins
s = np.min(dims)
h = s / 2.0
# Initial Grid
queue = PriorityQueue()
xv, yv = np.meshgrid(np.arange(mins[0], maxs[0], s), np.arange(mins[1], maxs[1], s))
for corner in np.vstack([xv.ravel(), yv.ravel()]).T:
queue.put(Cell(corner + h, h, polygon))
# Initial Guess
best = Cell(polygon.centroid, 0, polygon)
bbox = Cell(mins + (dims / 2), 0, polygon)
if bbox.d > best.d:
best = bbox
# While there are cells to consider...
directions = np.array([[-1, -1], [1, -1], [-1, 1], [1, 1]])
while not queue.empty():
cell = queue.get()
if cell > best:
best = cell
# If a cell is promising, subdivide!
if cell.p - best.d > precision:
h = cell.h / 2.0
offsets = cell.c + directions * h
queue.put(Cell(offsets[0], h, polygon))
queue.put(Cell(offsets[1], h, polygon))
queue.put(Cell(offsets[2], h, polygon))
queue.put(Cell(offsets[3], h, polygon))
return best

196
manim/utils/qhull.py Normal file
View file

@ -0,0 +1,196 @@
#!/usr/bin/env python
from __future__ import annotations
from typing import TYPE_CHECKING
import numpy as np
if TYPE_CHECKING:
from manim.typing import PointND, PointND_Array
class QuickHullPoint:
def __init__(self, coordinates: PointND_Array) -> None:
self.coordinates = coordinates
def __hash__(self) -> int:
return hash(self.coordinates.tobytes())
def __eq__(self, other: QuickHullPoint) -> bool:
return np.array_equal(self.coordinates, other.coordinates)
class SubFacet:
def __init__(self, coordinates: PointND_Array) -> None:
self.coordinates = coordinates
self.points = frozenset(QuickHullPoint(c) for c in coordinates)
def __hash__(self) -> int:
return hash(self.points)
def __eq__(self, other: SubFacet) -> bool:
return self.points == other.points
class Facet:
def __init__(self, coordinates: PointND_Array, internal: PointND) -> None:
self.coordinates = coordinates
self.center = np.mean(coordinates, axis=0)
self.normal = self.compute_normal(internal)
self.subfacets = frozenset(
SubFacet(np.delete(self.coordinates, i, axis=0))
for i in range(self.coordinates.shape[0])
)
def compute_normal(self, internal: PointND) -> PointND:
centered = self.coordinates - self.center
_, _, vh = np.linalg.svd(centered)
normal = vh[-1, :]
normal /= np.linalg.norm(normal)
# If the normal points towards the internal point, flip it!
if np.dot(normal, self.center - internal) < 0:
normal *= -1
return normal
def __hash__(self) -> int:
return hash(self.subfacets)
def __eq__(self, other: Facet) -> bool:
return self.subfacets == other.subfacets
class Horizon:
def __init__(self) -> None:
self.facets: set[Facet] = set()
self.boundary: list[SubFacet] = []
class QuickHull:
"""
QuickHull algorithm for constructing a convex hull from a set of points.
Parameters
----------
tolerance
A tolerance threshold for determining when points lie on the convex hull (default is 1e-5).
Attributes
----------
facets
List of facets considered.
removed
Set of internal facets that have been removed from the hull during the construction process.
outside
Dictionary mapping each facet to its outside points and eye point.
neighbors
Mapping of subfacets to their neighboring facets. Each subfacet links precisely two neighbors.
unclaimed
Points that have not yet been classified as inside or outside the current hull.
internal
An internal point (i.e., the center of the initial simplex) used as a reference during hull construction.
tolerance
The tolerance used to determine if points are considered outside the current hull.
"""
def __init__(self, tolerance: float = 1e-5) -> None:
self.facets: list[Facet] = []
self.removed: set[Facet] = set()
self.outside: dict[Facet, tuple[PointND_Array, PointND | None]] = {}
self.neighbors: dict[SubFacet, set[Facet]] = {}
self.unclaimed: PointND_Array | None = None
self.internal: PointND | None = None
self.tolerance = tolerance
def initialize(self, points: PointND_Array) -> None:
# Sample Points
simplex = points[
np.random.choice(points.shape[0], points.shape[1] + 1, replace=False)
]
self.unclaimed = points
self.internal = np.mean(simplex, axis=0)
# Build Simplex
for c in range(simplex.shape[0]):
facet = Facet(np.delete(simplex, c, axis=0), internal=self.internal)
self.classify(facet)
self.facets.append(facet)
# Attach Neighbors
for f in self.facets:
for sf in f.subfacets:
self.neighbors.setdefault(sf, set()).add(f)
def classify(self, facet: Facet) -> None:
if not self.unclaimed.size:
self.outside[facet] = (None, None)
return
# Compute Projections
projections = (self.unclaimed - facet.center) @ facet.normal
arg = np.argmax(projections)
mask = projections > self.tolerance
# Identify Eye and Outside Set
eye = self.unclaimed[arg] if projections[arg] > self.tolerance else None
outside = self.unclaimed[mask]
self.outside[facet] = (outside, eye)
self.unclaimed = self.unclaimed[~mask]
def compute_horizon(self, eye: PointND, start_facet: Facet) -> Horizon:
horizon = Horizon()
self._recursive_horizon(eye, start_facet, horizon)
return horizon
def _recursive_horizon(self, eye: PointND, facet: Facet, horizon: Horizon) -> int:
visible = np.dot(facet.normal, eye - facet.center) > 0
if not visible:
return False
# If the eye is visible from the facet...
else:
# Label the facet as visible and cross each edge
horizon.facets.add(facet)
for subfacet in facet.subfacets:
neighbor = (self.neighbors[subfacet] - {facet}).pop()
# If the neighbor is not visible, then the edge shared must be on the boundary
if neighbor not in horizon.facets and not self._recursive_horizon(
eye, neighbor, horizon
):
horizon.boundary.append(subfacet)
return True
def build(self, points: PointND_Array):
num, dim = points.shape
if (dim == 0) or (num < dim + 1):
raise ValueError("Not enough points supplied to build Convex Hull!")
if dim == 1:
raise ValueError("The Convex Hull of 1D data is its min-max!")
self.initialize(points)
while True:
updated = False
for facet in self.facets:
if facet in self.removed:
continue
outside, eye = self.outside[facet]
if eye is not None:
updated = True
horizon = self.compute_horizon(eye, facet)
for f in horizon.facets:
self.unclaimed = np.vstack((self.unclaimed, self.outside[f][0]))
self.removed.add(f)
for sf in f.subfacets:
self.neighbors[sf].discard(f)
if self.neighbors[sf] == set():
del self.neighbors[sf]
for sf in horizon.boundary:
nf = Facet(
np.vstack((sf.coordinates, eye)), internal=self.internal
)
self.classify(nf)
self.facets.append(nf)
for nsf in nf.subfacets:
self.neighbors.setdefault(nsf, set()).add(nf)
if not updated:
break

View file

@ -59,10 +59,10 @@ ignore_errors = True
ignore_errors = True
[mypy-manim.cli.*]
ignore_errors = True
ignore_errors = False
[mypy-manim.cli.cfg.*]
ignore_errors = True
ignore_errors = False
[mypy-manim.gui.*]
ignore_errors = True
@ -70,8 +70,8 @@ ignore_errors = True
[mypy-manim.mobject.*]
ignore_errors = True
[mypy-manim.plugins.*]
ignore_errors = True
[mypy-manim.mobject.geometry.*]
ignore_errors = False
[mypy-manim.renderer.*]
ignore_errors = True
@ -86,9 +86,6 @@ ignore_errors = True
ignore_errors = False
warn_return_any = False
[mypy-manim.__main__]
ignore_errors = True
# ---------------- We can't properly type this ------------------------

26
poetry.lock generated
View file

@ -4188,22 +4188,22 @@ files = [
[[package]]
name = "tornado"
version = "6.4.1"
version = "6.4.2"
description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed."
optional = true
optional = false
python-versions = ">=3.8"
files = [
{file = "tornado-6.4.1-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:163b0aafc8e23d8cdc3c9dfb24c5368af84a81e3364745ccb4427669bf84aec8"},
{file = "tornado-6.4.1-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:6d5ce3437e18a2b66fbadb183c1d3364fb03f2be71299e7d10dbeeb69f4b2a14"},
{file = "tornado-6.4.1-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2e20b9113cd7293f164dc46fffb13535266e713cdb87bd2d15ddb336e96cfc4"},
{file = "tornado-6.4.1-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ae50a504a740365267b2a8d1a90c9fbc86b780a39170feca9bcc1787ff80842"},
{file = "tornado-6.4.1-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:613bf4ddf5c7a95509218b149b555621497a6cc0d46ac341b30bd9ec19eac7f3"},
{file = "tornado-6.4.1-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25486eb223babe3eed4b8aecbac33b37e3dd6d776bc730ca14e1bf93888b979f"},
{file = "tornado-6.4.1-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:454db8a7ecfcf2ff6042dde58404164d969b6f5d58b926da15e6b23817950fc4"},
{file = "tornado-6.4.1-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a02a08cc7a9314b006f653ce40483b9b3c12cda222d6a46d4ac63bb6c9057698"},
{file = "tornado-6.4.1-cp38-abi3-win32.whl", hash = "sha256:d9a566c40b89757c9aa8e6f032bcdb8ca8795d7c1a9762910c722b1635c9de4d"},
{file = "tornado-6.4.1-cp38-abi3-win_amd64.whl", hash = "sha256:b24b8982ed444378d7f21d563f4180a2de31ced9d8d84443907a0a64da2072e7"},
{file = "tornado-6.4.1.tar.gz", hash = "sha256:92d3ab53183d8c50f8204a51e6f91d18a15d5ef261e84d452800d4ff6fc504e9"},
{file = "tornado-6.4.2-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:e828cce1123e9e44ae2a50a9de3055497ab1d0aeb440c5ac23064d9e44880da1"},
{file = "tornado-6.4.2-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:072ce12ada169c5b00b7d92a99ba089447ccc993ea2143c9ede887e0937aa803"},
{file = "tornado-6.4.2-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a017d239bd1bb0919f72af256a970624241f070496635784d9bf0db640d3fec"},
{file = "tornado-6.4.2-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c36e62ce8f63409301537222faffcef7dfc5284f27eec227389f2ad11b09d946"},
{file = "tornado-6.4.2-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bca9eb02196e789c9cb5c3c7c0f04fb447dc2adffd95265b2c7223a8a615ccbf"},
{file = "tornado-6.4.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:304463bd0772442ff4d0f5149c6f1c2135a1fae045adf070821c6cdc76980634"},
{file = "tornado-6.4.2-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:c82c46813ba483a385ab2a99caeaedf92585a1f90defb5693351fa7e4ea0bf73"},
{file = "tornado-6.4.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:932d195ca9015956fa502c6b56af9eb06106140d844a335590c1ec7f5277d10c"},
{file = "tornado-6.4.2-cp38-abi3-win32.whl", hash = "sha256:2876cef82e6c5978fde1e0d5b1f919d756968d5b4282418f3146b79b58556482"},
{file = "tornado-6.4.2-cp38-abi3-win_amd64.whl", hash = "sha256:908b71bf3ff37d81073356a5fadcc660eb10c1476ee6e2725588626ce7e5ca38"},
{file = "tornado-6.4.2.tar.gz", hash = "sha256:92bad5b4746e9879fd7bf1eb21dce4e3fc5128d71601f80005afa39237ad620b"},
]
[[package]]

View file

@ -5,17 +5,24 @@ import pytest
from manim import FadeIn, Manager, Scene
@pytest.mark.parametrize(
"run_time",
[0, -1],
)
def test_animation_forbidden_run_time(run_time):
def test_animation_zero_total_run_time():
manager = Manager(Scene)
test_scene = manager.scene
with pytest.raises(
ValueError, match="Please set the run_time to a positive number."
ValueError, match="The total run_time must be a positive number."
):
test_scene.play(FadeIn(None, run_time=run_time))
test_scene.play(FadeIn(None, run_time=0))
def test_single_animation_zero_run_time_with_more_animations():
manager = Manager(Scene)
test_scene = manager.scene
test_scene.play(FadeIn(None, run_time=0), FadeIn(None, run_time=1))
def test_animation_negative_run_time():
with pytest.raises(ValueError, match="The run_time of FadeIn cannot be negative."):
FadeIn(None, run_time=-1)
def test_animation_run_time_shorter_than_frame_rate(manim_caplog, config):
@ -25,8 +32,32 @@ def test_animation_run_time_shorter_than_frame_rate(manim_caplog, config):
assert "too short for the current frame rate" in manim_caplog.text
def test_wait_run_time_shorter_than_frame_rate(manim_caplog):
@pytest.mark.parametrize("duration", [0, -1])
def test_wait_invalid_duration(duration):
manager = Manager(Scene)
test_scene = manager.scene
test_scene.wait(1e-9)
with pytest.raises(ValueError, match="The duration must be a positive number."):
test_scene.wait(duration)
@pytest.mark.parametrize("frozen_frame", [False, True])
def test_wait_duration_shorter_than_frame_rate(manim_caplog, frozen_frame):
manager = Manager(Scene)
test_scene = manager.scene
test_scene.wait(1e-9, frozen_frame=frozen_frame)
assert "too short for the current frame rate" in manim_caplog.text
@pytest.mark.parametrize("duration", [0, -1])
def test_pause_invalid_duration(duration):
manager = Manager(Scene)
test_scene = manager.scene
with pytest.raises(ValueError, match="The duration must be a positive number."):
test_scene.pause(duration)
@pytest.mark.parametrize("max_time", [0, -1])
def test_wait_until_invalid_max_time(max_time):
test_scene = Manager(Scene)
with pytest.raises(ValueError, match="The max_time must be a positive number."):
test_scene.wait_until(lambda: True, max_time)

View file

@ -4,7 +4,7 @@ import logging
import numpy as np
from manim import BackgroundRectangle, Circle, Sector, Square
from manim import BackgroundRectangle, Circle, Sector, Square, SurroundingRectangle
logger = logging.getLogger(__name__)
@ -15,9 +15,18 @@ def test_get_arc_center():
)
def test_SurroundingRectangle():
circle = Circle()
square = Square()
sr = SurroundingRectangle(circle, square)
sr.set_style(fill_opacity=0.42)
assert sr.get_fill_opacity() == 0.42
def test_BackgroundRectangle(manim_caplog):
c = Circle()
bg = BackgroundRectangle(c)
circle = Circle()
square = Square()
bg = BackgroundRectangle(circle, square)
bg.set_style(fill_opacity=0.42)
assert bg.get_fill_opacity() == 0.42
bg.set_style(fill_opacity=1, hello="world")

View file

@ -39,3 +39,25 @@ def test_streamline_attributes_for_single_color():
)
assert vector_field[0].stroke_width == 1.0
assert vector_field[0].stroke_opacity == 0.2
def test_stroke_scale():
a = VMobject()
b = VMobject()
a.set_stroke(width=50)
b.set_stroke(width=50)
a.scale(0.5)
b.scale(0.5, scale_stroke=True)
assert a.get_stroke_width() == 50
assert b.get_stroke_width() == 25
def test_background_stroke_scale():
a = VMobject()
b = VMobject()
a.set_stroke(width=50, background=True)
b.set_stroke(width=50, background=True)
a.scale(0.5)
b.scale(0.5, scale_stroke=True)
assert a.get_stroke_width(background=True) == 50
assert b.get_stroke_width(background=True) == 25

View file

@ -26,6 +26,24 @@ def test_scene_add_remove(dry_run):
# Check that Scene.add() returns the Scene (for chained calls)
assert scene.add(Mobject()) is scene
manager = Manager(Scene)
scene = manager.scene
to_remove = Mobject()
scene = Scene()
scene.add(to_remove)
scene.add(*(Mobject() for _ in range(10)))
assert len(scene.mobjects) == 11
scene.remove(to_remove)
assert len(scene.mobjects) == 10
scene.remove(to_remove)
assert len(scene.mobjects) == 10
# Check that Scene.remove() returns the instance (for chained calls)
assert scene.add(Mobject()) is scene
def test_scene_time(dry_run):
manager = Manager(Scene)
scene = manager.scene
assert scene.time == 0
@ -35,7 +53,7 @@ def test_scene_add_remove(dry_run):
assert pytest.approx(scene.time) == 2.5
scene._original_skipping_status = True
scene.play(FadeIn(Square()), run_time=5) # this animation gets skipped.
assert pytest.approx(scene.renderer.time) == 7.5
assert pytest.approx(scene.time) == 7.5
def test_subcaption(dry_run):

View file

@ -173,3 +173,32 @@ def test_hsv_init() -> None:
def test_into_HSV() -> None:
nt.assert_equal(RED.into(HSV).into(ManimColor), RED)
def test_contrasting() -> None:
nt.assert_equal(BLACK.contrasting(), WHITE)
nt.assert_equal(WHITE.contrasting(), BLACK)
nt.assert_equal(RED.contrasting(0.1), BLACK)
nt.assert_equal(RED.contrasting(0.9), WHITE)
nt.assert_equal(BLACK.contrasting(dark=GREEN, light=RED), RED)
nt.assert_equal(WHITE.contrasting(dark=GREEN, light=RED), GREEN)
def test_lighter() -> None:
c = RED.opacity(0.42)
cl = c.lighter(0.2)
nt.assert_array_equal(
cl._internal_value[:3],
0.8 * c._internal_value[:3] + 0.2 * WHITE._internal_value[:3],
)
nt.assert_equal(cl[-1], c[-1])
def test_darker() -> None:
c = RED.opacity(0.42)
cd = c.darker(0.2)
nt.assert_array_equal(
cd._internal_value[:3],
0.8 * c._internal_value[:3] + 0.2 * BLACK._internal_value[:3],
)
nt.assert_equal(cd[-1], c[-1])

View file

@ -244,3 +244,24 @@ def test_tex_template_file(tmp_path):
assert Path(custom_config.tex_template_file) == tex_file
assert custom_config.tex_template.body == "Hello World!"
def test_from_to_animations_only_first_animation(config):
config: ManimConfig
config.from_animation_number = 0
config.upto_animation_number = 0
class SceneWithTwoAnimations(Scene):
def construct(self):
self.after_first_animation = False
s = Square()
self.add(s)
self.play(s.animate.scale(2))
self.renderer.update_skipping_status()
self.after_first_animation = True
self.play(s.animate.scale(2))
scene = SceneWithTwoAnimations()
scene.render()
assert scene.after_first_animation is False

View file

@ -138,6 +138,20 @@ def test_RoundedRectangle(scene):
scene.add(a)
@frames_comparison
def test_ConvexHull(scene):
a = ConvexHull(
*[
[-2.7, -0.6, 0],
[0.2, -1.7, 0],
[1.9, 1.2, 0],
[-2.7, 0.9, 0],
[1.6, 2.2, 0],
]
)
scene.add(a)
@frames_comparison
def test_Arrange(scene):
s1 = Square()
@ -254,9 +268,7 @@ def test_LabeledLine(scene):
line = LabeledLine(
label="0.5",
label_position=0.8,
font_size=20,
label_color=WHITE,
label_frame=True,
label_config={"font_size": 20},
start=LEFT + DOWN,
end=RIGHT + UP,
)
@ -266,6 +278,27 @@ def test_LabeledLine(scene):
@frames_comparison
def test_LabeledArrow(scene):
l_arrow = LabeledArrow(
"0.5", start=LEFT * 3, end=RIGHT * 3 + UP * 2, label_position=0.5, font_size=15
label="0.5",
label_position=0.5,
label_config={"font_size": 15},
start=LEFT * 3,
end=RIGHT * 3 + UP * 2,
)
scene.add(l_arrow)
@frames_comparison
def test_LabeledPolygram(scene):
polygram = LabeledPolygram(
[
[-2.5, -2.5, 0],
[2.5, -2.5, 0],
[2.5, 2.5, 0],
[-2.5, 2.5, 0],
[-2.5, -2.5, 0],
],
[[-1, -1, 0], [0.5, -1, 0], [0.5, 0.5, 0], [-1, 0.5, 0], [-1, -1, 0]],
[[1, 1, 0], [2, 1, 0], [2, 2, 0], [1, 2, 0], [1, 1, 0]],
label="C",
)
scene.add(polygram)

View file

@ -24,3 +24,17 @@ def test_Icosahedron(scene):
@frames_comparison
def test_Dodecahedron(scene):
scene.add(Dodecahedron())
@frames_comparison
def test_ConvexHull3D(scene):
a = ConvexHull3D(
*[
[-2.7, -0.6, 3.5],
[0.2, -1.7, -2.8],
[1.9, 1.2, 0.7],
[-2.7, 0.9, 1.9],
[1.6, 2.2, -4.2],
]
)
scene.add(a)