fix: add type annotations to space_out_submobjects

This commit is contained in:
GoThrones 2026-03-24 11:55:34 +05:30
commit ff34ecb0ef
44 changed files with 942 additions and 371 deletions

View file

@ -1 +1,20 @@
.git
# Development / test artifacts
__pycache__
**/__pycache__
*.pyc
*.pyo
*.pyd
*.egg-info
dist/
build/
coverage.xml
# Not needed to install the package
docs/
tests/
example_scenes/
media/
logo/
scripts/

View file

@ -11,6 +11,7 @@ jobs:
environment: release
permissions:
id-token: write
contents: write
steps:
- uses: actions/checkout@v6
@ -28,7 +29,6 @@ jobs:
- name: Build and push release to PyPI
run: |
uv sync
uv build
uv publish
@ -37,37 +37,11 @@ jobs:
with:
path: dist/*.tar.gz
name: manim.tar.gz
- name: Install Dependency
run: pip install requests
- name: Get Upload URL
id: create_release
shell: python
env:
access_token: ${{ secrets.GITHUB_TOKEN }}
tag_act: ${{ github.ref }}
run: |
import requests
import os
ref_tag = os.getenv('tag_act').split('/')[-1]
access_token = os.getenv('access_token')
headers = {
"Accept":"application/vnd.github.v3+json",
"Authorization": f"token {access_token}"
}
url = f"https://api.github.com/repos/ManimCommunity/manim/releases/tags/{ref_tag}"
c = requests.get(url,headers=headers)
upload_url=c.json()['upload_url']
with open(os.getenv('GITHUB_OUTPUT'), 'w') as f:
print(f"upload_url={upload_url}", file=f)
print(f"tag_name={ref_tag[1:]}", file=f)
- name: Upload Release Asset
id: upload-release
uses: actions/upload-release-asset@v1
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: dist/manim-${{ steps.create_release.outputs.tag_name }}.tar.gz
asset_name: manim-${{ steps.create_release.outputs.tag_name }}.tar.gz
asset_content_type: application/gzip
run: |
TAG=${{ github.event.release.tag_name }}
gh release upload "$TAG" "dist/manim-${TAG#v}.tar.gz"

View file

@ -4,10 +4,10 @@ authors:
-
name: "The Manim Community Developers"
cff-version: "1.2.0"
date-released: 2026-02-20
date-released: 2026-02-27
license: MIT
message: "We acknowledge the importance of good software to support research, and we note that research becomes more valuable when it is communicated effectively. To demonstrate the value of Manim, we ask that you cite Manim in your work."
title: Manim Mathematical Animation Framework
url: "https://www.manim.community/"
version: "v0.20.0"
version: "v0.20.1"
...

View file

@ -1,10 +1,10 @@
<p align="center">
<a href="https://www.manim.community/"><img src="https://raw.githubusercontent.com/ManimCommunity/manim/main/logo/cropped.png"></a>
<a href="https://www.manim.community/"><img src="https://raw.githubusercontent.com/ManimCommunity/manim/main/logo/cropped.png" alt="Manim Community logo"></a>
<br />
<br />
<a href="https://pypi.org/project/manim/"><img src="https://img.shields.io/pypi/v/manim.svg?style=flat&logo=pypi" alt="PyPI Latest Release"></a>
<a href="https://hub.docker.com/r/manimcommunity/manim"><img src="https://img.shields.io/docker/v/manimcommunity/manim?color=%23099cec&label=docker%20image&logo=docker" alt="Docker image"> </a>
<a href="https://mybinder.org/v2/gh/ManimCommunity/jupyter_examples/HEAD?filepath=basic_example_scenes.ipynb"><img src="https://mybinder.org/badge_logo.svg"></a>
<a href="https://mybinder.org/v2/gh/ManimCommunity/jupyter_examples/HEAD?filepath=basic_example_scenes.ipynb"><img src="https://mybinder.org/badge_logo.svg" alt="Launch Binder"></a>
<a href="http://choosealicense.com/licenses/mit/"><img src="https://img.shields.io/badge/license-MIT-red.svg?style=flat" alt="MIT License"></a>
<a href="https://www.reddit.com/r/manim/"><img src="https://img.shields.io/reddit/subreddit-subscribers/manim.svg?color=orange&label=reddit&logo=reddit" alt="Reddit" href=></a>
<a href="https://twitter.com/manimcommunity/"><img src="https://img.shields.io/twitter/url/https/twitter.com/cloudposse.svg?style=social&label=Follow%20%40manimcommunity" alt="Twitter">

View file

@ -1,42 +1,74 @@
FROM python:3.11-slim
# ── Stage 1: builder ─────────────────────────────────────────────────────────
FROM python:3.14-slim AS builder
RUN apt-get update -qq \
&& apt-get install --no-install-recommends -y \
build-essential \
gcc \
cmake \
make \
pkg-config \
wget \
libcairo2-dev \
libffi-dev \
libpango1.0-dev \
freeglut3-dev \
ffmpeg \
pkg-config \
make \
wget \
libegl-dev \
&& rm -rf /var/lib/apt/lists/*
# Setup a minimal TeX Live installation (no ctex: drops ~100 MB of CJK fonts/packages)
COPY docker/texlive-profile.txt /tmp/
ENV PATH=/usr/local/texlive/bin/armhf-linux:/usr/local/texlive/bin/aarch64-linux:/usr/local/texlive/bin/x86_64-linux:$PATH
RUN wget -O /tmp/install-tl-unx.tar.gz http://mirror.ctan.org/systems/texlive/tlnet/install-tl-unx.tar.gz \
&& mkdir /tmp/install-tl \
&& tar -xzf /tmp/install-tl-unx.tar.gz -C /tmp/install-tl --strip-components=1 \
&& /tmp/install-tl/install-tl --profile=/tmp/texlive-profile.txt \
&& tlmgr install \
amsmath babel-english cbfonts-fd cm-super count1to doublestroke dvisvgm everysel \
fontspec frcursive fundus-calligra gnu-freefont jknapltx latex-bin \
mathastext microtype multitoc physics prelim2e preview ragged2e relsize rsfs \
setspace standalone tipa wasy wasysym xcolor xetex xkeyval \
&& rm -rf /tmp/install-tl /tmp/install-tl-unx.tar.gz
# Install manim into an isolated virtualenv
ENV VIRTUAL_ENV=/opt/venv
RUN python -m venv $VIRTUAL_ENV
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
COPY . /opt/manim
WORKDIR /opt/manim
RUN pip install --no-cache-dir .[jupyterlab]
# ── Stage 2: runtime ─────────────────────────────────────────────────────────
FROM python:3.14-slim
# Runtime libs only:
# - no ffmpeg: PyAV (av package) bundles its own ffmpeg libraries in av.libs/
# - OpenGL: keep EGL for headless rendering and libGL as required by moderngl/glcontext
# - fonts-noto-core instead of fonts-noto (drops CJK noto fonts)
RUN apt-get update -qq \
&& apt-get install --no-install-recommends -y \
libcairo2 \
libpango-1.0-0 \
libpangocairo-1.0-0 \
libpangoft2-1.0-0 \
libffi8 \
libegl1 \
libgl1 \
ghostscript \
fonts-noto
fonts-noto-core \
fontconfig \
&& rm -rf /var/lib/apt/lists/*
RUN fc-cache -fv
# setup a minimal texlive installation
COPY docker/texlive-profile.txt /tmp/
# Copy TeX Live from builder
ENV PATH=/usr/local/texlive/bin/armhf-linux:/usr/local/texlive/bin/aarch64-linux:/usr/local/texlive/bin/x86_64-linux:$PATH
RUN wget -O /tmp/install-tl-unx.tar.gz http://mirror.ctan.org/systems/texlive/tlnet/install-tl-unx.tar.gz && \
mkdir /tmp/install-tl && \
tar -xzf /tmp/install-tl-unx.tar.gz -C /tmp/install-tl --strip-components=1 && \
/tmp/install-tl/install-tl --profile=/tmp/texlive-profile.txt \
&& tlmgr install \
amsmath babel-english cbfonts-fd cm-super count1to ctex doublestroke dvisvgm everysel \
fontspec frcursive fundus-calligra gnu-freefont jknapltx latex-bin \
mathastext microtype multitoc physics prelim2e preview ragged2e relsize rsfs \
setspace standalone tipa wasy wasysym xcolor xetex xkeyval
COPY --from=builder /usr/local/texlive /usr/local/texlive
# clone and build manim
COPY . /opt/manim
WORKDIR /opt/manim
RUN pip install --no-cache .[jupyterlab]
RUN pip install -r docs/requirements.txt
# Copy the pre-built virtualenv from builder
ENV VIRTUAL_ENV=/opt/venv
COPY --from=builder /opt/venv /opt/venv
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
ARG NB_USER=manimuser
ARG NB_UID=1000
@ -49,11 +81,8 @@ RUN adduser --disabled-password \
--uid ${NB_UID} \
${NB_USER}
# create working directory for user to mount local directory into
WORKDIR ${HOME}
USER root
RUN chown -R ${NB_USER}:${NB_USER} ${HOME}
RUN chmod 777 ${HOME}
RUN chown -R ${NB_USER}:${NB_USER} ${HOME} && chmod 777 ${HOME}
USER ${NB_USER}
CMD [ "/bin/bash" ]
CMD ["/bin/bash"]

View file

@ -13,3 +13,11 @@ Multi-platform builds are possible by running
docker buildx build --push --platform linux/arm64/v8,linux/amd64 --tag manimcommunity/manim:TAG -f docker/Dockerfile .
```
from the root directory of the repository.
# Runtime notes
- The image is built via a multi-stage Dockerfile (build dependencies are not
carried into the runtime stage).
- The image does not include the `ffmpeg` CLI binary.
- The default TeX installation is minimal and does not include `ctex`.
- Headless OpenGL rendering relies on EGL/GL runtime libraries available in the
image.

View file

@ -7,6 +7,7 @@ This page contains a list of changes made between releases.
.. toctree::
:maxdepth: 1
changelog/0.20.1-changelog
changelog/0.20.0-changelog
changelog/0.19.2-changelog
changelog/0.19.1-changelog

View file

@ -0,0 +1,41 @@
---
short-title: v0.20.1
description: Changelog for v0.20.1
---
# v0.20.1
Date
: February 27, 2026
## What's Changed
### Enhancements 🚀
* Cleanup `TipableVMobject`: avoid mutable default and fix `assign_tip_attr` typo by {user}`josiest` in {pr}`4503`
* enhancement: optimize Docker image build and runtime footprint by {user}`behackl` in {pr}`4604`
### Bug Fixes 🐛
* fix: MathTex double-brace splitting no longer fires on natural LaTeX `}}` by {user}`behackl` in {pr}`4602`
* Fix creation or animation of a zero-length `DashedLine` by {user}`SORVER` in {pr}`4606`
* Fix moving-object detection for nested AnimationGroups with z-indexed mobjects by {user}`Merzlikin-Matvey` in {pr}`4389`
* Fix unintended propagation of `kwargs` in `LaggedStartMap` by {user}`irvanalhaq9` in {pr}`4613`
### Documentation 📚
* Documentation: manual installation of manim as a local package by {user}`u7920349` in {pr}`4456`
* Add alt text to all images in `README.md` by {user}`VerisimilitudeX` in {pr}`4064`
### Code Quality & Refactoring 🧹
* Fix publish release workflow by {user}`behackl` in {pr}`4600`
* Silence pydub ffmpeg/avconv import warning when ffmpeg CLI is absent by {user}`behackl` in {pr}`4603`
### Type Hints 📝
* Add type annotations to `manim/_config/utils.py` by {user}`henrikmidtiby` in {pr}`4230`
## New Contributors
* {user}`SORVER` made their first contribution in {pr}`4606`
* {user}`josiest` made their first contribution in {pr}`4503`
* {user}`u7920349` made their first contribution in {pr}`4456`
* {user}`Merzlikin-Matvey` made their first contribution in {pr}`4389`
* {user}`VerisimilitudeX` made their first contribution in {pr}`4064`
**Full Changelog**: [Compare view](https://github.com/ManimCommunity/manim/compare/v0.20.0...v0.20.1)

View file

@ -389,8 +389,9 @@ Substrings and parts
The TeX mobject can accept multiple strings as arguments. Afterwards you can
refer to the individual parts either by their index (like ``tex[1]``), or by
selecting parts of the tex code. In this example, we set the color
of the ``\bigstar`` using :func:`~.set_color_by_tex`:
using :func:`~.set_color_by_tex`, which matches the argument exactly against
the strings passed to the constructor. In this example, we color the
``\bigstar`` part:
.. manim:: LaTeXSubstrings
:save_last_frame:
@ -398,25 +399,13 @@ of the ``\bigstar`` using :func:`~.set_color_by_tex`:
class LaTeXSubstrings(Scene):
def construct(self):
tex = Tex('Hello', r'$\bigstar$', r'\LaTeX', font_size=144)
tex.set_color_by_tex('igsta', RED)
tex.set_color_by_tex(r'$\bigstar$', RED)
self.add(tex)
Note that :func:`~.set_color_by_tex` colors the entire substring containing
the Tex, not just the specific symbol or Tex expression. Consider the following example:
.. manim:: IncorrectLaTeXSubstringColoring
:save_last_frame:
class IncorrectLaTeXSubstringColoring(Scene):
def construct(self):
equation = MathTex(
r"e^x = x^0 + x^1 + \frac{1}{2} x^2 + \frac{1}{6} x^3 + \cdots + \frac{1}{n!} x^n + \cdots"
)
equation.set_color_by_tex("x", YELLOW)
self.add(equation)
As you can see, this colors the entire equation yellow, contrary to what
may be expected. To color only ``x`` yellow, we have to do the following:
Because :func:`~.set_color_by_tex` requires an exact match, it cannot directly
target a token inside a string that was passed as a single argument. To color
every ``x`` in a formula, use ``substrings_to_isolate`` to split the string at
each occurrence first:
.. manim:: CorrectLaTeXSubstringColoring
:save_last_frame:
@ -430,21 +419,27 @@ may be expected. To color only ``x`` yellow, we have to do the following:
equation.set_color_by_tex("x", YELLOW)
self.add(equation)
By setting ``substrings_to_isolate`` to ``x``, we split up the
:class:`~.MathTex` into substrings automatically and isolate the ``x`` components
into individual substrings. Only then can :meth:`~.set_color_by_tex` be used
to achieve the desired result.
Each isolated occurrence of ``x`` becomes its own sub-mobject that
:meth:`~.set_color_by_tex` can match exactly.
If one of the ``substrings_to_isolate`` is in a sub or superscript, it needs
to be enclosed by curly brackets.
Note that Manim also supports a custom syntax that allows splitting
a TeX string into substrings easily: simply enclose parts of your formula
that you want to isolate with double braces. In the string
``MathTex(r"{{ a^2 }} + {{ b^2 }} = {{ c^2 }}")``, the rendered mobject
``MathTex(r"{{ a^2 }} + {{ b^2 }} = {{ c^2 }}")``, the rendered mobject
will consist of the substrings ``a^2``, ``+``, ``b^2``, ``=``, and ``c^2``.
This makes transformations between similar text fragments easy
to write using :class:`~.TransformMatchingTex`.
For Manim to recognise a ``{{`` as a group opener, it must appear either
at the very start of the string or be immediately preceded by a whitespace
character. This means that ``{{`` embedded directly after non-whitespace
LaTeX — such as ``\frac{{{n}}}{k}`` or ``a^{{2}}`` — is left untouched,
which prevents accidental splitting of ordinary nested-brace expressions.
To stop a leading ``{{`` from being treated as a group opener, insert a
space between the two braces: ``{{ ... }}````{ { ... } }``.
Using ``index_labels`` to work with complicated strings
=======================================================

View file

@ -18,6 +18,13 @@ For our image ``manimcommunity/manim``, there are the following tags:
``-p`` (preview file) and ``-f`` (show output file in the file browser)
are not supported.
.. note::
The Docker image ships with a minimal TeX Live installation. In particular,
``ctex`` is not installed by default. If your scenes rely on
``TexTemplateLibrary.ctex``, install it in the container via
``tlmgr install ctex``.
Basic usage of the Docker container
-----------------------------------

View file

@ -329,3 +329,13 @@ version satisfies the requirement. Change the line to, for example
to pin the python version to `3.12`. Finally, run `uv sync`, and your
environment is updated!
:::
:::{dropdown} Installing the latest development version
If you want to install the latest (potentially unstable!)
development version of Manim from our source repository
[on GitHub](https://github.com/ManimCommunity/manim), then
simply run
```bash
uv add git+https://github.com/ManimCommunity/manim.git@main
```
:::

View file

@ -195,7 +195,7 @@ Positioning ``Mobject``\s
Next, let's go over some basic techniques for positioning ``Mobject``\s.
1. Open ``scene.py``, and add the following code snippet below the ``SquareToCircle`` method:
1. Open ``scene.py``, and add the following code snippet below the ``SquareToCircle`` class:
.. code-block:: python

View file

@ -20,7 +20,7 @@ import logging
import os
import re
import sys
from collections.abc import Iterable, Iterator, Mapping, MutableMapping
from collections.abc import Iterator, Mapping, MutableMapping
from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar, NoReturn
@ -134,7 +134,7 @@ def make_config_parser(
return parser
def _determine_quality(qual: str) -> str:
def _determine_quality(qual: str | None) -> str:
for quality, values in constants.QUALITIES.items():
if values["flag"] is not None and values["flag"] == qual:
return quality
@ -338,6 +338,7 @@ class ManimConfig(MutableMapping):
def __contains__(self, key: object) -> bool:
try:
assert isinstance(key, str)
self.__getitem__(key)
return True
except AttributeError:
@ -428,7 +429,7 @@ class ManimConfig(MutableMapping):
# Deepcopying the underlying dict is enough because all properties
# either read directly from it or compute their value on the fly from
# values read directly from it.
c._d = copy.deepcopy(self._d, memo)
c._d = copy.deepcopy(self._d, memo) # type: ignore[arg-type]
return c
# helper type-checking methods
@ -655,13 +656,15 @@ class ManimConfig(MutableMapping):
"window_size"
] # if not "default", get a tuple of the position
if window_size != "default":
window_size = tuple(map(int, re.split(r"[;,\-]", window_size)))
self.window_size = window_size
window_size_numbers = tuple(map(int, re.split(r"[;,\-]", window_size)))
self.window_size = window_size_numbers
else:
self.window_size = window_size
# plugins
plugins = parser["CLI"].get("plugins", fallback="", raw=True)
plugins = [] if plugins == "" else plugins.split(",")
self.plugins = plugins
plugin_list = [] if plugins is None or plugins == "" else plugins.split(",")
self.plugins = plugin_list
# the next two must be set AFTER digesting pixel_width and pixel_height
self["frame_height"] = parser["CLI"].getfloat("frame_height", 8.0)
width = parser["CLI"].getfloat("frame_width", None)
@ -671,31 +674,31 @@ class ManimConfig(MutableMapping):
self["frame_width"] = width
# other logic
val = parser["CLI"].get("tex_template_file")
if val:
self.tex_template_file = val
tex_template_file = parser["CLI"].get("tex_template_file")
if tex_template_file:
self.tex_template_file = Path(tex_template_file)
val = parser["CLI"].get("progress_bar")
if val:
self.progress_bar = val
progress_bar = parser["CLI"].get("progress_bar")
if progress_bar:
self.progress_bar = progress_bar
val = parser["ffmpeg"].get("loglevel")
if val:
self.ffmpeg_loglevel = val
ffmpeg_loglevel = parser["ffmpeg"].get("loglevel")
if ffmpeg_loglevel:
self.ffmpeg_loglevel = ffmpeg_loglevel
try:
val = parser["jupyter"].getboolean("media_embed")
media_embed = parser["jupyter"].getboolean("media_embed")
except ValueError:
val = None
self.media_embed = val
media_embed = None
self.media_embed = media_embed
val = parser["jupyter"].get("media_width")
if val:
self.media_width = val
media_width = parser["jupyter"].get("media_width")
if media_width:
self.media_width = media_width
val = parser["CLI"].get("quality", fallback="", raw=True)
if val:
self.quality = _determine_quality(val)
quality = parser["CLI"].get("quality", fallback="", raw=True)
if quality:
self.quality = _determine_quality(quality)
return self
@ -1044,7 +1047,7 @@ class ManimConfig(MutableMapping):
logger.setLevel(val)
@property
def format(self) -> str:
def format(self) -> str | None:
"""File format; "png", "gif", "mp4", "webm" or "mov"."""
return self._d["format"]
@ -1076,7 +1079,7 @@ class ManimConfig(MutableMapping):
logging.getLogger("libav").setLevel(self.ffmpeg_loglevel)
@property
def media_embed(self) -> bool:
def media_embed(self) -> bool | None:
"""Whether to embed videos in Jupyter notebook."""
return self._d["media_embed"]
@ -1112,8 +1115,10 @@ class ManimConfig(MutableMapping):
self._set_pos_number("pixel_height", value, False)
@property
def aspect_ratio(self) -> int:
def aspect_ratio(self) -> float:
"""Aspect ratio (width / height) in pixels (--resolution, -r)."""
assert isinstance(self._d["pixel_width"], int)
assert isinstance(self._d["pixel_height"], int)
return self._d["pixel_width"] / self._d["pixel_height"]
@property
@ -1137,22 +1142,22 @@ class ManimConfig(MutableMapping):
@property
def frame_y_radius(self) -> float:
"""Half the frame height (no flag)."""
return self._d["frame_height"] / 2
return self._d["frame_height"] / 2 # type: ignore[operator]
@frame_y_radius.setter
def frame_y_radius(self, value: float) -> None:
self._d.__setitem__("frame_y_radius", value) or self._d.__setitem__(
self._d.__setitem__("frame_y_radius", value) or self._d.__setitem__( # type: ignore[func-returns-value]
"frame_height", 2 * value
)
@property
def frame_x_radius(self) -> float:
"""Half the frame width (no flag)."""
return self._d["frame_width"] / 2
return self._d["frame_width"] / 2 # type: ignore[operator]
@frame_x_radius.setter
def frame_x_radius(self, value: float) -> None:
self._d.__setitem__("frame_x_radius", value) or self._d.__setitem__(
self._d.__setitem__("frame_x_radius", value) or self._d.__setitem__( # type: ignore[func-returns-value]
"frame_width", 2 * value
)
@ -1285,7 +1290,7 @@ class ManimConfig(MutableMapping):
@frame_size.setter
def frame_size(self, value: tuple[int, int]) -> None:
self._d.__setitem__("pixel_width", value[0]) or self._d.__setitem__(
self._d.__setitem__("pixel_width", value[0]) or self._d.__setitem__( # type: ignore[func-returns-value]
"pixel_height", value[1]
)
@ -1295,7 +1300,7 @@ class ManimConfig(MutableMapping):
keys = ["pixel_width", "pixel_height", "frame_rate"]
q = {k: self[k] for k in keys}
for qual in constants.QUALITIES:
if all(q[k] == constants.QUALITIES[qual][k] for k in keys):
if all(q[k] == constants.QUALITIES[qual][k] for k in keys): # type: ignore[literal-required]
return qual
return None
@ -1312,6 +1317,7 @@ class ManimConfig(MutableMapping):
@property
def transparent(self) -> bool:
"""Whether the background opacity is less than 1.0 (-t)."""
assert isinstance(self._d["background_opacity"], float)
return self._d["background_opacity"] < 1.0
@transparent.setter
@ -1421,12 +1427,12 @@ class ManimConfig(MutableMapping):
self._d.__setitem__("window_position", value)
@property
def window_size(self) -> str:
"""The size of the opengl window as 'width,height' or 'default' to automatically scale the window based on the display monitor."""
def window_size(self) -> str | tuple[int, ...]:
"""The size of the opengl window. 'default' to automatically scale the window based on the display monitor."""
return self._d["window_size"]
@window_size.setter
def window_size(self, value: str) -> None:
def window_size(self, value: str | tuple[int, ...]) -> None:
self._d.__setitem__("window_size", value)
def resolve_movie_file_extension(self, is_transparent: bool) -> None:
@ -1455,7 +1461,7 @@ class ManimConfig(MutableMapping):
self._set_boolean("enable_gui", value)
@property
def gui_location(self) -> tuple[Any]:
def gui_location(self) -> tuple[int, ...]:
"""Location parameters for the GUI window (e.g., screen coordinates or layout settings)."""
return self._d["gui_location"]
@ -1639,6 +1645,7 @@ class ManimConfig(MutableMapping):
all_args["quality"] = f"{self.pixel_height}p{self.frame_rate:g}"
path = self._d[key]
assert isinstance(path, str)
while "{" in path:
try:
path = path.format(**all_args)
@ -1738,7 +1745,7 @@ class ManimConfig(MutableMapping):
self._set_dir("custom_folders", value)
@property
def input_file(self) -> str:
def input_file(self) -> str | Path:
"""Input file name."""
return self._d["input_file"]
@ -1767,7 +1774,7 @@ class ManimConfig(MutableMapping):
@property
def tex_template(self) -> TexTemplate:
"""Template used when rendering Tex. See :class:`.TexTemplate`."""
if not hasattr(self, "_tex_template") or not self._tex_template:
if not hasattr(self, "_tex_template") or not self._tex_template: # type: ignore[has-type]
fn = self._d["tex_template_file"]
if fn:
self._tex_template = TexTemplate.from_file(fn)
@ -1803,7 +1810,7 @@ class ManimConfig(MutableMapping):
return self._d["plugins"]
@plugins.setter
def plugins(self, value: list[str]):
def plugins(self, value: list[str]) -> None:
self._d["plugins"] = value
@property
@ -1861,7 +1868,7 @@ class ManimFrame(Mapping):
self.__dict__["_c"] = c
# there are required by parent class Mapping to behave like a dict
def __getitem__(self, key: str | int) -> Any:
def __getitem__(self, key: str) -> Any:
if key in self._OPTS:
return self._c[key]
elif key in self._CONSTANTS:
@ -1869,7 +1876,7 @@ class ManimFrame(Mapping):
else:
raise KeyError(key)
def __iter__(self) -> Iterable[str]:
def __iter__(self) -> Iterator[Any]:
return iter(list(self._OPTS) + list(self._CONSTANTS))
def __len__(self) -> int:
@ -1887,4 +1894,4 @@ class ManimFrame(Mapping):
for opt in list(ManimFrame._OPTS) + list(ManimFrame._CONSTANTS):
setattr(ManimFrame, opt, property(lambda self, o=opt: self[o]))
setattr(ManimFrame, opt, property(lambda self, o=opt: self[o])) # type: ignore[misc]

View file

@ -353,7 +353,7 @@ class LaggedStartMap(LaggedStart):
Parameters
----------
AnimationClass
animation_class
:class:`~.Animation` to apply to mobject.
mobject
:class:`~.Mobject` whose submobjects the animation, and optionally the function,
@ -362,6 +362,17 @@ class LaggedStartMap(LaggedStart):
Function which will be applied to :class:`~.Mobject`.
run_time
The duration of the animation in seconds.
lag_ratio
Defines the delay after which the animation is applied to submobjects. A lag_ratio of
``n.nn`` means the next animation will play when ``nnn%`` of the current animation has played.
Defaults to 0.05, meaning that the next animation will begin when 5% of the current
animation has played.
This does not influence the total runtime of the animation. Instead the runtime
of individual animations is adjusted so that the complete animation has the defined
run time.
kwargs
Further keyword arguments that are passed to `animation_class`.
Examples
--------
@ -392,6 +403,7 @@ class LaggedStartMap(LaggedStart):
mobject: Mobject,
arg_creator: Callable[[Mobject], Iterable[Any]] | None = None,
run_time: float = 2,
lag_ratio: float = DEFAULT_LAGGED_START_LAG_RATIO,
**kwargs: Any,
):
if arg_creator is None:
@ -406,4 +418,4 @@ class LaggedStartMap(LaggedStart):
if "lag_ratio" in anim_kwargs:
anim_kwargs.pop("lag_ratio")
animations = [animation_class(*args, **anim_kwargs) for args in args_list]
super().__init__(*animations, run_time=run_time, **kwargs)
super().__init__(*animations, run_time=run_time, lag_ratio=lag_ratio)

View file

@ -10,7 +10,7 @@ from __future__ import annotations
__all__ = ["MovingCamera"]
from collections.abc import Iterable
from typing import Any
from typing import Any, Literal, overload
from cairo import Context
@ -20,7 +20,7 @@ from .. import config
from ..camera.camera import Camera
from ..constants import DOWN, LEFT, RIGHT, UP
from ..mobject.frame import ScreenRectangle
from ..mobject.mobject import Mobject
from ..mobject.mobject import Mobject, _AnimationBuilder
from ..utils.color import WHITE, ManimColor
@ -166,13 +166,31 @@ class MovingCamera(Camera):
"""
return [self.frame]
@overload
def auto_zoom(
self,
mobjects: Iterable[Mobject],
margin: float,
only_mobjects_in_frame: bool,
animate: Literal[False],
) -> Mobject: ...
@overload
def auto_zoom(
self,
mobjects: Iterable[Mobject],
margin: float,
only_mobjects_in_frame: bool,
animate: Literal[True],
) -> _AnimationBuilder: ...
def auto_zoom(
self,
mobjects: Iterable[Mobject],
margin: float = 0,
only_mobjects_in_frame: bool = False,
animate: bool = True,
) -> Mobject:
) -> _AnimationBuilder | Mobject:
"""Zooms on to a given array of mobjects (or a singular mobject)
and automatically resizes to frame all the mobjects.

View file

@ -101,12 +101,12 @@ class TipableVMobject(VMobject, metaclass=ConvertToOpenGL):
self,
tip_length: float = DEFAULT_ARROW_TIP_LENGTH,
normal_vector: Vector3DLike = OUT,
tip_style: dict = {},
tip_style: dict | None = None,
**kwargs: Any,
) -> None:
self.tip_length: float = tip_length
self.normal_vector = normal_vector
self.tip_style: dict = tip_style
self.tip_style: dict = tip_style if tip_style is not None else {}
super().__init__(**kwargs)
# Adding, Creating, Modifying tips
@ -128,7 +128,7 @@ class TipableVMobject(VMobject, metaclass=ConvertToOpenGL):
else:
self.position_tip(tip, at_start)
self.reset_endpoints_based_on_tip(tip, at_start)
self.asign_tip_attr(tip, at_start)
self.assign_tip_attr(tip, at_start)
self.add(tip)
return self
@ -201,6 +201,7 @@ class TipableVMobject(VMobject, metaclass=ConvertToOpenGL):
axis=axis,
) # Rotates the tip along the vertical wrt the axis
self._init_positioning_axis = axis
tip.shift(anchor - tip.tip_point)
return tip
@ -215,7 +216,7 @@ class TipableVMobject(VMobject, metaclass=ConvertToOpenGL):
self.put_start_and_end_on(self.get_start(), tip.base)
return self
def asign_tip_attr(self, tip: tips.ArrowTip, at_start: bool) -> Self:
def assign_tip_attr(self, tip: tips.ArrowTip, at_start: bool) -> Self:
if at_start:
self.start_tip = tip
else:
@ -241,7 +242,8 @@ class TipableVMobject(VMobject, metaclass=ConvertToOpenGL):
if self.has_start_tip():
result.add(self.start_tip)
self.remove(self.start_tip)
self.put_start_and_end_on(start, end)
if result.submobjects:
self.put_start_and_end_on(start, end)
return result
def get_tips(self) -> VGroup:

View file

@ -14,14 +14,14 @@ __all__ = [
"RightAngle",
]
from typing import TYPE_CHECKING, Any, Literal
from typing import TYPE_CHECKING, Any, Literal, cast
import numpy as np
from manim import config
from manim.constants import *
from manim.mobject.geometry.arc import Arc, ArcBetweenPoints, Dot, TipableVMobject
from manim.mobject.geometry.tips import ArrowTriangleFilledTip
from manim.mobject.geometry.tips import ArrowTip, ArrowTriangleFilledTip
from manim.mobject.mobject import Mobject
from manim.mobject.opengl.opengl_compatibility import ConvertToOpenGL
from manim.mobject.opengl.opengl_mobject import OpenGLMobject
@ -648,9 +648,11 @@ class Arrow(Line):
self._set_stroke_width_from_length()
if has_tip:
self.add_tip(tip=old_tips[0])
# error: Argument "tip" to "add_tip" of "TipableVMobject" has incompatible type "VMobject"; expected "ArrowTip | None" [arg-type]
self.add_tip(tip=cast(ArrowTip, old_tips[0]))
if has_start_tip:
self.add_tip(tip=old_tips[1], at_start=True)
# error: Argument "tip" to "add_tip" of "TipableVMobject" has incompatible type "VMobject"; expected "ArrowTip | None" [arg-type]
self.add_tip(tip=cast(ArrowTip, old_tips[1]), at_start=True)
return self
def get_normal_vector(self) -> Vector3D:

View file

@ -207,13 +207,11 @@ class SampleSpace(Rectangle):
if hasattr(parts, subattr):
self.add(getattr(parts, subattr))
def __getitem__(self, index: int) -> SampleSpace:
def __getitem__(self, index: int) -> VMobject:
if hasattr(self, "horizontal_parts"):
val: SampleSpace = self.horizontal_parts[index]
return val
return self.horizontal_parts[index]
elif hasattr(self, "vertical_parts"):
val = self.vertical_parts[index]
return val
return self.vertical_parts[index]
return self.split()[index]

View file

@ -40,15 +40,15 @@ __all__ = [
import itertools as it
from collections.abc import Callable, Iterable, Sequence
from collections.abc import Callable, Iterable
from typing import Any, Self
import numpy as np
from manim.mobject.mobject import Mobject
from manim.mobject.opengl.opengl_compatibility import ConvertToOpenGL
from manim.mobject.text.numbers import DecimalNumber, Integer
from manim.mobject.text.tex_mobject import MathTex, Tex
from manim.typing import Vector2DLike, Vector3DLike
from ..constants import *
from ..mobject.types.vectorized_mobject import VGroup, VMobject
@ -164,16 +164,16 @@ class Matrix(VMobject, metaclass=ConvertToOpenGL):
def __init__(
self,
matrix: Iterable,
matrix: Iterable[Iterable[Any] | Vector2DLike],
v_buff: float = 0.8,
h_buff: float = 1.3,
bracket_h_buff: float = MED_SMALL_BUFF,
bracket_v_buff: float = MED_SMALL_BUFF,
add_background_rectangles_to_entries: bool = False,
include_background_rectangle: bool = False,
element_to_mobject: type[Mobject] | Callable[..., Mobject] = MathTex,
element_to_mobject_config: dict = {},
element_alignment_corner: Sequence[float] = DR,
element_to_mobject: type[VMobject] | Callable[..., VMobject] = MathTex,
element_to_mobject_config: dict[str, Any] = {},
element_alignment_corner: Vector3DLike = DR,
left_bracket: str = "[",
right_bracket: str = "]",
stretch_brackets: bool = True,
@ -206,7 +206,9 @@ class Matrix(VMobject, metaclass=ConvertToOpenGL):
if self.include_background_rectangle:
self.add_background_rectangle()
def _matrix_to_mob_matrix(self, matrix: np.ndarray) -> list[list[Mobject]]:
def _matrix_to_mob_matrix(
self, matrix: Iterable[Iterable[Any]]
) -> list[list[VMobject]]:
return [
[
self.element_to_mobject(item, **self.element_to_mobject_config)
@ -215,7 +217,7 @@ class Matrix(VMobject, metaclass=ConvertToOpenGL):
for row in matrix
]
def _organize_mob_matrix(self, matrix: list[list[Mobject]]) -> Self:
def _organize_mob_matrix(self, matrix: list[list[VMobject]]) -> Self:
for i, row in enumerate(matrix):
for j, _ in enumerate(row):
mob = matrix[i][j]
@ -401,7 +403,7 @@ class Matrix(VMobject, metaclass=ConvertToOpenGL):
mob.add_background_rectangle()
return self
def get_mob_matrix(self) -> list[list[Mobject]]:
def get_mob_matrix(self) -> list[list[VMobject]]:
"""Return the underlying mob matrix mobjects.
Returns
@ -483,8 +485,8 @@ class DecimalMatrix(Matrix):
def __init__(
self,
matrix: Iterable,
element_to_mobject: type[Mobject] = DecimalNumber,
matrix: Iterable[Iterable[Any]],
element_to_mobject: type[VMobject] | Callable[..., VMobject] = DecimalNumber,
element_to_mobject_config: dict[str, Any] = {"num_decimal_places": 1},
**kwargs: Any,
):
@ -528,8 +530,8 @@ class IntegerMatrix(Matrix):
def __init__(
self,
matrix: Iterable,
element_to_mobject: type[Mobject] = Integer,
matrix: Iterable[Iterable[Any]],
element_to_mobject: type[VMobject] | Callable[..., VMobject] = Integer,
**kwargs: Any,
):
"""
@ -566,8 +568,8 @@ class MobjectMatrix(Matrix):
def __init__(
self,
matrix: Iterable,
element_to_mobject: type[Mobject] | Callable[..., Mobject] = lambda m: m,
matrix: Iterable[Iterable[Any]],
element_to_mobject: type[VMobject] | Callable[..., VMobject] = lambda m: m,
**kwargs: Any,
):
super().__init__(matrix, element_to_mobject=element_to_mobject, **kwargs)

View file

@ -14,10 +14,10 @@ import random
import sys
import types
import warnings
from collections.abc import Callable, Iterable
from collections.abc import Callable, Iterable, Iterator, Sequence
from functools import partialmethod, reduce
from pathlib import Path
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, cast
import numpy as np
@ -43,24 +43,30 @@ from ..utils.space_ops import angle_between_vectors, normalize, rotation_matrix
if TYPE_CHECKING:
from typing import Self, TypeAlias
from PIL import Image
from manim.mobject.types.point_cloud_mobject import Point
from manim.typing import (
FunctionOverride,
MappingFunction,
MatrixMN,
MultiMappingFunction,
PathFuncType,
PixelArray,
Point3D,
Point3D_Array,
Point3DLike,
Point3DLike_Array,
Vector3D,
Vector3DLike,
)
from ..animation.animation import Animation
from ..camera.camera import Camera
TimeBasedUpdater: TypeAlias = Callable[["Mobject", float], object]
NonTimeBasedUpdater: TypeAlias = Callable[["Mobject"], object]
Updater: TypeAlias = NonTimeBasedUpdater | TimeBasedUpdater
_TimeBasedUpdater: TypeAlias = Callable[["Mobject", float], object]
_NonTimeBasedUpdater: TypeAlias = Callable[["Mobject"], object]
_Updater: TypeAlias = _NonTimeBasedUpdater | _TimeBasedUpdater
class Mobject:
@ -83,16 +89,18 @@ class Mobject:
"""
animation_overrides = {}
original_id: str
_original__init__: Callable[..., None]
animation_overrides: dict[
type[Animation],
FunctionOverride,
] = {}
@classmethod
def __init_subclass__(cls, **kwargs) -> None:
def __init_subclass__(cls, **kwargs: Any) -> None:
super().__init_subclass__(**kwargs)
cls.animation_overrides: dict[
type[Animation],
FunctionOverride,
] = {}
cls.animation_overrides = {}
cls._add_intrinsic_animation_overrides()
cls._original__init__ = cls.__init__
@ -101,16 +109,16 @@ class Mobject:
color: ParsableManimColor | list[ParsableManimColor] = WHITE,
name: str | None = None,
dim: int = 3,
target=None,
target: Mobject | None = None,
z_index: float = 0,
) -> None:
):
self.name = self.__class__.__name__ if name is None else name
self.dim = dim
self.target = target
self.z_index = z_index
self.point_hash = None
self.submobjects = []
self.updaters: list[Updater] = []
self.submobjects: list[Mobject] = []
self.updaters: list[_Updater] = []
self.updating_suspended = False
self.color = ManimColor.parse(color)
@ -151,7 +159,7 @@ class Mobject:
return self._assert_valid_submobjects_internal(submobjects, Mobject)
def _assert_valid_submobjects_internal(
self, submobjects: list[Mobject], mob_class: type[Mobject]
self, submobjects: Iterable[Mobject], mob_class: type[Mobject]
) -> Self:
for i, submob in enumerate(submobjects):
if not isinstance(submob, mob_class):
@ -247,7 +255,7 @@ class Mobject:
)
@classmethod
def set_default(cls, **kwargs) -> None:
def set_default(cls, **kwargs: Any) -> None:
"""Sets the default values of keyword arguments.
If this method is called without any additional keyword
@ -290,8 +298,11 @@ class Mobject:
"""
if kwargs:
cls.__init__ = partialmethod(cls.__init__, **kwargs)
# Apparently mypy does not correctly understand `partialmethod`:
# see https://github.com/python/mypy/issues/8619
cls.__init__ = partialmethod(cls.__init__, **kwargs) # type: ignore[assignment]
else:
# error: Cannot assign to a method [method-assign]
cls.__init__ = cls._original__init__
@property
@ -430,7 +441,7 @@ class Mobject:
# can't use typing.cast because Self is under TYPE_CHECKING
return _UpdaterBuilder(self) # type: ignore[return-value]
def __deepcopy__(self, clone_from_id) -> Self:
def __deepcopy__(self, clone_from_id: dict[int, Mobject]) -> Self:
cls = self.__class__
result = cls.__new__(cls)
clone_from_id[id(self)] = result
@ -447,7 +458,7 @@ class Mobject:
self.points = np.zeros((0, self.dim))
return self
def init_colors(self) -> object:
def init_colors(self, propagate_colors: bool = True) -> object:
"""Initializes the colors.
Gets called upon creation. This is an empty method that can be implemented by
@ -567,10 +578,10 @@ class Mobject:
self._assert_valid_submobjects([mobject])
self.submobjects.insert(index, mobject)
def __add__(self, mobject: Mobject):
def __add__(self, mobject: Mobject) -> Self:
raise NotImplementedError
def __iadd__(self, mobject: Mobject):
def __iadd__(self, mobject: Mobject) -> Self:
raise NotImplementedError
def add_to_back(self, *mobjects: Mobject) -> Self:
@ -650,13 +661,13 @@ class Mobject:
self.submobjects.remove(mobject)
return self
def __sub__(self, other):
def __sub__(self, other: Mobject) -> Self:
raise NotImplementedError
def __isub__(self, other):
def __isub__(self, other: Mobject) -> Self:
raise NotImplementedError
def set(self, **kwargs) -> Self:
def set(self, **kwargs: Any) -> Self:
"""Sets attributes.
I.e. ``my_mobject.set(foo=1)`` applies ``my_mobject.foo = 1``.
@ -724,7 +735,7 @@ class Mobject:
# Remove the "get_" prefix
to_get = attr[4:]
def getter(self):
def getter(self: Mobject) -> Any:
warnings.warn(
"This method is not guaranteed to stay around. Please prefer "
"getting the attribute normally.",
@ -741,7 +752,7 @@ class Mobject:
# Remove the "set_" prefix
to_set = attr[4:]
def setter(self, value):
def setter(self: Mobject, value: Any) -> Mobject:
warnings.warn(
"This method is not guaranteed to stay around. Please prefer "
"setting the attribute normally or with Mobject.set().",
@ -792,7 +803,7 @@ class Mobject:
return self.length_over_dim(0)
@width.setter
def width(self, value: float):
def width(self, value: float) -> None:
self.scale_to_fit_width(value)
@property
@ -828,7 +839,7 @@ class Mobject:
return self.length_over_dim(1)
@height.setter
def height(self, value: float):
def height(self, value: float) -> None:
self.scale_to_fit_height(value)
@property
@ -848,7 +859,7 @@ class Mobject:
return self.length_over_dim(2)
@depth.setter
def depth(self, value: float):
def depth(self, value: float) -> None:
self.scale_to_fit_depth(value)
# Can't be staticmethod because of point_cloud_mobject.py
@ -861,16 +872,13 @@ class Mobject:
return self
# Displaying
def get_image(self, camera=None) -> PixelArray:
def get_image(self, camera: Camera | None = None) -> Image.Image:
if camera is None:
from ..camera.camera import Camera
camera = Camera()
camera.capture_mobject(self)
return camera.get_image()
def show(self, camera=None) -> None:
def show(self, camera: Camera | None = None) -> None:
self.get_image(camera=camera).show()
def save_image(self, name: str | None = None) -> None:
@ -930,18 +938,21 @@ class Mobject:
:meth:`get_updaters`
"""
if not self.updating_suspended:
for updater in self.updaters:
if "dt" in inspect.signature(updater).parameters:
updater(self, dt)
else:
updater(self)
if self.updating_suspended:
return self
for updater in self.updaters:
if "dt" in inspect.signature(updater).parameters:
time_based_updater = cast(_TimeBasedUpdater, updater)
time_based_updater(self, dt)
else:
non_time_based_updater = cast(_NonTimeBasedUpdater, updater)
non_time_based_updater(self)
if recursive:
for submob in self.submobjects:
submob.update(dt, recursive=recursive)
return self
def get_time_based_updaters(self) -> list[TimeBasedUpdater]:
def get_time_based_updaters(self) -> list[_TimeBasedUpdater]:
"""Return all updaters using the ``dt`` parameter.
The updaters use this parameter as the input for difference in time.
@ -957,11 +968,12 @@ class Mobject:
:meth:`has_time_based_updater`
"""
return [
updater
for updater in self.updaters
if "dt" in inspect.signature(updater).parameters
]
rv: list[_TimeBasedUpdater] = []
for updater in self.updaters:
if "dt" in inspect.signature(updater).parameters:
time_based_updater = cast(_TimeBasedUpdater, updater)
rv.append(time_based_updater)
return rv
def has_time_based_updater(self) -> bool:
"""Test if ``self`` has a time based updater.
@ -981,7 +993,7 @@ class Mobject:
"dt" in inspect.signature(updater).parameters for updater in self.updaters
)
def get_updaters(self) -> list[Updater]:
def get_updaters(self) -> list[_Updater]:
"""Return all updaters.
Returns
@ -997,12 +1009,12 @@ class Mobject:
"""
return self.updaters
def get_family_updaters(self) -> list[Updater]:
def get_family_updaters(self) -> list[_Updater]:
return list(it.chain(*(sm.get_updaters() for sm in self.get_family())))
def add_updater(
self,
update_function: Updater,
update_function: _Updater,
index: int | None = None,
call_updater: bool = False,
) -> Self:
@ -1076,12 +1088,15 @@ class Mobject:
if call_updater:
parameters = inspect.signature(update_function).parameters
if "dt" in parameters:
update_function(self, 0)
time_based_updater = cast(_TimeBasedUpdater, update_function)
time_based_updater(self, 0)
else:
update_function(self)
non_time_based_updater = cast(_NonTimeBasedUpdater, update_function)
non_time_based_updater(self)
return self
def remove_updater(self, update_function: Updater) -> Self:
def remove_updater(self, update_function: _Updater) -> Self:
"""Remove an updater.
If the same updater is applied multiple times, every instance gets removed.
@ -1328,6 +1343,7 @@ class Mobject:
*,
about_point: Point3DLike | None = None,
about_edge: Vector3DLike | None = None,
**kwargs: Any,
) -> Self:
"""Rotates the :class:`~.Mobject` around a specified axis and point.
@ -1516,10 +1532,10 @@ class Mobject:
self.play(t.animate.set_value(TAU), run_time=3)
"""
def R3_func(point):
def R3_func(point: Point3D) -> Point3D:
x, y, z = point
xy_complex = function(complex(x, y))
return [xy_complex.real, xy_complex.imag, z]
return np.array([xy_complex.real, xy_complex.imag, z])
return self.apply_function(
R3_func, about_point=about_point, about_edge=about_edge
@ -1533,7 +1549,7 @@ class Mobject:
def repeat(self, count: int) -> Self:
"""This can make transition animations nicer"""
def repeat_array(array):
def repeat_array(array: Point3D_Array) -> Point3D_Array:
return reduce(lambda a1, a2: np.append(a1, a2, axis=0), [array] * count)
for mob in self.family_members_with_points():
@ -1563,7 +1579,7 @@ class Mobject:
mob.points += about_point
return self
def pose_at_angle(self, **kwargs):
def pose_at_angle(self, **kwargs: Any) -> Self:
self.rotate(TAU / 14, RIGHT + UP, **kwargs)
return self
@ -1709,7 +1725,7 @@ class Mobject:
self.shift((target_point - point_to_align + buff * np_direction) * coor_mask)
return self
def shift_onto_screen(self, **kwargs) -> Self:
def shift_onto_screen(self, **kwargs: Any) -> Self:
space_lengths = [config["frame_x_radius"], config["frame_y_radius"]]
for vect in UP, DOWN, LEFT, RIGHT:
dim = np.argmax(np.abs(vect))
@ -1720,20 +1736,21 @@ class Mobject:
self.to_edge(vect, **kwargs)
return self
def is_off_screen(self):
def is_off_screen(self) -> bool:
if self.get_left()[0] > config["frame_x_radius"]:
return True
if self.get_right()[0] < -config["frame_x_radius"]:
return True
if self.get_bottom()[1] > config["frame_y_radius"]:
return True
return self.get_top()[1] < -config["frame_y_radius"]
rv: bool = self.get_top()[1] < -config["frame_y_radius"]
return rv
def stretch_about_point(self, factor: float, dim: int, point: Point3DLike) -> Self:
return self.stretch(factor, dim, about_point=point)
def rescale_to_fit(
self, length: float, dim: int, stretch: bool = False, **kwargs
self, length: float, dim: int, stretch: bool = False, **kwargs: Any
) -> Self:
old_length = self.length_over_dim(dim)
if old_length == 0:
@ -1744,7 +1761,7 @@ class Mobject:
self.scale(length / old_length, **kwargs)
return self
def scale_to_fit_width(self, width: float, **kwargs) -> Self:
def scale_to_fit_width(self, width: float, **kwargs: Any) -> Self:
"""Scales the :class:`~.Mobject` to fit a width while keeping height/depth proportional.
Returns
@ -1769,7 +1786,7 @@ class Mobject:
"""
return self.rescale_to_fit(width, 0, stretch=False, **kwargs)
def stretch_to_fit_width(self, width: float, **kwargs) -> Self:
def stretch_to_fit_width(self, width: float, **kwargs: Any) -> Self:
"""Stretches the :class:`~.Mobject` to fit a width, not keeping height/depth proportional.
Returns
@ -1794,7 +1811,7 @@ class Mobject:
"""
return self.rescale_to_fit(width, 0, stretch=True, **kwargs)
def scale_to_fit_height(self, height: float, **kwargs) -> Self:
def scale_to_fit_height(self, height: float, **kwargs: Any) -> Self:
"""Scales the :class:`~.Mobject` to fit a height while keeping width/depth proportional.
Returns
@ -1819,7 +1836,7 @@ class Mobject:
"""
return self.rescale_to_fit(height, 1, stretch=False, **kwargs)
def stretch_to_fit_height(self, height: float, **kwargs) -> Self:
def stretch_to_fit_height(self, height: float, **kwargs: Any) -> Self:
"""Stretches the :class:`~.Mobject` to fit a height, not keeping width/depth proportional.
Returns
@ -1844,15 +1861,17 @@ class Mobject:
"""
return self.rescale_to_fit(height, 1, stretch=True, **kwargs)
def scale_to_fit_depth(self, depth: float, **kwargs) -> Self:
def scale_to_fit_depth(self, depth: float, **kwargs: Any) -> Self:
"""Scales the :class:`~.Mobject` to fit a depth while keeping width/height proportional."""
return self.rescale_to_fit(depth, 2, stretch=False, **kwargs)
def stretch_to_fit_depth(self, depth: float, **kwargs) -> Self:
def stretch_to_fit_depth(self, depth: float, **kwargs: Any) -> Self:
"""Stretches the :class:`~.Mobject` to fit a depth, not keeping width/height proportional."""
return self.rescale_to_fit(depth, 2, stretch=True, **kwargs)
def set_coord(self, value, dim: int, direction: Vector3DLike = ORIGIN) -> Self:
def set_coord(
self, value: float, dim: int, direction: Vector3DLike = ORIGIN
) -> Self:
curr = self.get_coord(dim, direction)
shift_vect = np.zeros(self.dim)
shift_vect[dim] = value - curr
@ -1874,18 +1893,22 @@ class Mobject:
def space_out_submobjects(self, factor: float = 2.5, direction: np.ndarray = RIGHT, **kwargs) -> Self:
mobject_centre = self.get_center() # store the coordinate of centre of the Mobject
actual_submobjects = [submobject for submobject in self.get_family() if len(submobject.submobjects)==0] # list of all the submobjects which do not have submobjectes of their own
actual_submobjects = [
submobject
for submobject in self.get_family()
if len(submobject.submobjects) == 0
] # list of all the submobjects which do not have submobjectes of their own
# store the coordinates of all the submobjects before they are spaced out.
submobject_center = [submobject.get_center() for submobject in actual_submobjects]
for i,submobject in enumerate(actual_submobjects):
# How far is this submobject from the group center
offset = submobject_center[i] - mobject_centre
offset = submobject_center[i] - mobject_centre
# Calculate how much to shift it from it's current position, but only in x direction, since we are multiplying offset by direction: RIGHT, i.e. by [1,0,0]
submobject.shift(direction * offset * (factor - 1))
return self
def move_to(
@ -1961,7 +1984,10 @@ class Mobject:
# Background rectangle
def add_background_rectangle(
self, color: ParsableManimColor | None = None, opacity: float = 0.75, **kwargs
self,
color: ParsableManimColor | None = None,
opacity: float = 0.75,
**kwargs: Any,
) -> Self:
"""Add a BackgroundRectangle as submobject.
@ -2000,12 +2026,14 @@ class Mobject:
self.add_to_back(self.background_rectangle)
return self
def add_background_rectangle_to_submobjects(self, **kwargs) -> Self:
def add_background_rectangle_to_submobjects(self, **kwargs: Any) -> Self:
for submobject in self.submobjects:
submobject.add_background_rectangle(**kwargs)
return self
def add_background_rectangle_to_family_members_with_points(self, **kwargs) -> Self:
def add_background_rectangle_to_family_members_with_points(
self, **kwargs: Any
) -> Self:
for mob in self.family_members_with_points():
mob.add_background_rectangle(**kwargs)
return self
@ -2013,7 +2041,10 @@ class Mobject:
# Color functions
def set_color(
self, color: ParsableManimColor = PURE_YELLOW, family: bool = True
self,
color: ParsableManimColor = PURE_YELLOW,
alpha: Any = None,
family: bool = True,
) -> Self:
"""Condition is function which takes in one arguments, (x, y, z).
Here it just recurses to submobjects, but in subclasses this
@ -2055,7 +2086,7 @@ class Mobject:
)
return self
def set_submobject_colors_by_gradient(self, *colors: Iterable[ParsableManimColor]):
def set_submobject_colors_by_gradient(self, *colors: ParsableManimColor) -> Self:
if len(colors) == 0:
raise ValueError("Need at least one color")
elif len(colors) == 1:
@ -2081,7 +2112,9 @@ class Mobject:
for mob in self.family_members_with_points():
t = np.linalg.norm(mob.get_center() - center) / radius
t = min(t, 1)
mob_color = interpolate_color(inner_color, outer_color, t)
mob_color = interpolate_color(
ManimColor(inner_color), ManimColor(outer_color), t
)
mob.set_color(mob_color, family=False)
return self
@ -2094,7 +2127,7 @@ class Mobject:
self, color: ParsableManimColor, alpha: float, family: bool = True
) -> Self:
if self.get_num_points() > 0:
new_color = interpolate_color(self.get_color(), color, alpha)
new_color = interpolate_color(self.get_color(), ManimColor(color), alpha)
self.set_color(new_color, family=False)
if family:
for submob in self.submobjects:
@ -2134,12 +2167,14 @@ class Mobject:
def restore(self) -> Self:
"""Restores the state that was previously saved with :meth:`~.Mobject.save_state`."""
if not hasattr(self, "saved_state") or self.save_state is None:
if not hasattr(self, "saved_state") or self.saved_state is None:
raise Exception("Trying to restore without having saved")
self.become(self.saved_state)
return self
def reduce_across_dimension(self, reduce_func: Callable, dim: int):
def reduce_across_dimension(
self, reduce_func: Callable[[Iterable[float]], float], dim: int
) -> float:
"""Find the min or max value from a dimension across all points in this and submobjects."""
assert dim >= 0
assert dim <= 2
@ -2159,9 +2194,10 @@ class Mobject:
for mobj in self.submobjects:
value = mobj.reduce_across_dimension(reduce_func, dim)
rv = value if rv is None else reduce_func([value, rv])
assert rv is not None
return rv
def nonempty_submobjects(self) -> list[Self]:
def nonempty_submobjects(self) -> Sequence[Mobject]:
return [
submob
for submob in self.submobjects
@ -2205,11 +2241,14 @@ class Mobject:
)
values = np_points[:, dim]
if key < 0:
return np.min(values)
rv: float = np.min(values)
return rv
elif key == 0:
return (np.min(values) + np.max(values)) / 2
rv = (np.min(values) + np.max(values)) / 2
return rv
else:
return np.max(values)
rv = np.max(values)
return rv
def get_critical_point(self, direction: Vector3DLike) -> Point3D:
"""Picture a box bounding the :class:`~.Mobject`. Such a box has
@ -2234,7 +2273,7 @@ class Mobject:
result[dim] = self.get_extremum_along_dim(
all_points,
dim=dim,
key=direction[dim],
key=np.array(direction[dim]),
)
return result
@ -2309,14 +2348,16 @@ class Mobject:
def length_over_dim(self, dim: int) -> float:
"""Measure the length of an :class:`~.Mobject` in a certain direction."""
return self.reduce_across_dimension(
max_coord: float = self.reduce_across_dimension(
max,
dim,
) - self.reduce_across_dimension(min, dim)
)
min_coord: float = self.reduce_across_dimension(min, dim)
return max_coord - min_coord
def get_coord(self, dim: int, direction: Vector3DLike = ORIGIN) -> float:
"""Meant to generalize ``get_x``, ``get_y`` and ``get_z``"""
return self.get_extremum_along_dim(dim=dim, key=direction[dim])
return self.get_extremum_along_dim(dim=dim, key=np.array(direction)[dim])
def get_x(self, direction: Vector3DLike = ORIGIN) -> float:
"""Returns x Point3D of the center of the :class:`~.Mobject` as ``float``"""
@ -2380,19 +2421,19 @@ class Mobject:
"""Match the color with the color of another :class:`~.Mobject`."""
return self.set_color(mobject.get_color())
def match_dim_size(self, mobject: Mobject, dim: int, **kwargs) -> Self:
def match_dim_size(self, mobject: Mobject, dim: int, **kwargs: Any) -> Self:
"""Match the specified dimension with the dimension of another :class:`~.Mobject`."""
return self.rescale_to_fit(mobject.length_over_dim(dim), dim, **kwargs)
def match_width(self, mobject: Mobject, **kwargs) -> Self:
def match_width(self, mobject: Mobject, **kwargs: Any) -> Self:
"""Match the width with the width of another :class:`~.Mobject`."""
return self.match_dim_size(mobject, 0, **kwargs)
def match_height(self, mobject: Mobject, **kwargs) -> Self:
def match_height(self, mobject: Mobject, **kwargs: Any) -> Self:
"""Match the height with the height of another :class:`~.Mobject`."""
return self.match_dim_size(mobject, 1, **kwargs)
def match_depth(self, mobject: Mobject, **kwargs) -> Self:
def match_depth(self, mobject: Mobject, **kwargs: Any) -> Self:
"""Match the depth with the depth of another :class:`~.Mobject`."""
return self.match_dim_size(mobject, 2, **kwargs)
@ -2406,15 +2447,15 @@ class Mobject:
direction=direction,
)
def match_x(self, mobject: Mobject, direction=ORIGIN) -> Self:
def match_x(self, mobject: Mobject, direction: Vector3DLike = ORIGIN) -> Self:
"""Match x coord. to the x coord. of another :class:`~.Mobject`."""
return self.match_coord(mobject, 0, direction)
def match_y(self, mobject: Mobject, direction=ORIGIN) -> Self:
def match_y(self, mobject: Mobject, direction: Vector3DLike = ORIGIN) -> Self:
"""Match y coord. to the x coord. of another :class:`~.Mobject`."""
return self.match_coord(mobject, 1, direction)
def match_z(self, mobject: Mobject, direction=ORIGIN) -> Self:
def match_z(self, mobject: Mobject, direction: Vector3DLike = ORIGIN) -> Self:
"""Match z coord. to the x coord. of another :class:`~.Mobject`."""
return self.match_coord(mobject, 2, direction)
@ -2441,14 +2482,15 @@ class Mobject:
# Family matters
def __getitem__(self, value):
def __getitem__(self, value: Any) -> Mobject | Group:
self_list = self.split()
if isinstance(value, slice):
GroupClass = self.get_group_class()
return GroupClass(*self_list.__getitem__(value))
return self_list.__getitem__(value)
rv: Mobject | Group = self_list.__getitem__(value)
return rv
def __iter__(self):
def __iter__(self) -> Iterator[Mobject]:
return iter(self.split())
def __len__(self) -> int:
@ -2462,11 +2504,11 @@ class Mobject:
"""Return the base class of this mobject type."""
return Mobject
def split(self) -> list[Self]:
result = [self] if len(self.points) > 0 else []
def split(self) -> list[Mobject]:
result: list[Mobject] = [self] if len(self.points) > 0 else []
return result + self.submobjects
def get_family(self, recurse: bool = True) -> list[Self]:
def get_family(self, recurse: bool = True) -> list[Mobject]:
"""Lists all mobjects in the hierarchy (family) of the given mobject,
including the mobject itself and all its submobjects recursively.
@ -2500,7 +2542,7 @@ class Mobject:
all_mobjects = [self] + list(it.chain(*sub_families))
return remove_list_redundancies(all_mobjects)
def family_members_with_points(self) -> list[Self]:
def family_members_with_points(self) -> list[Mobject]:
"""Filters the list of family members (generated by :meth:`.get_family`) to include only mobjects with points.
Returns
@ -2531,7 +2573,7 @@ class Mobject:
direction: Vector3DLike = RIGHT,
buff: float = DEFAULT_MOBJECT_TO_MOBJECT_BUFFER,
center: bool = True,
**kwargs,
**kwargs: Any,
) -> Self:
"""Sorts :class:`~.Mobject` next to each other on screen.
@ -2567,7 +2609,7 @@ class Mobject:
row_heights: Iterable[float | None] | None = None,
col_widths: Iterable[float | None] | None = None,
flow_order: str = "rd",
**kwargs,
**kwargs: Any,
) -> Self:
"""Arrange submobjects in a grid.
@ -2661,13 +2703,18 @@ class Mobject:
start_pos = self.get_center()
# get cols / rows values if given (implicitly)
def init_size(num, alignments, sizes):
def init_size(
num: int | None,
alignments: str | None,
sizes: Iterable[float | None] | None,
) -> int | None:
if num is not None:
return num
if alignments is not None:
return len(alignments)
if sizes is not None:
return len(sizes)
return len(list(sizes))
return None
cols = init_size(cols, col_alignments, col_widths)
rows = init_size(rows, row_alignments, row_heights)
@ -2678,8 +2725,9 @@ class Mobject:
# make the grid as close to quadratic as possible.
# choosing cols first can results in cols>rows.
# This is favored over rows>cols since in general
# the sceene is wider than high.
# the scene is wider than high.
if rows is None:
assert isinstance(cols, int)
rows = math.ceil(len(mobs) / cols)
if cols is None:
cols = math.ceil(len(mobs) / rows)
@ -2694,25 +2742,29 @@ class Mobject:
buff_x = buff_y = buff
# Initialize alignments correctly
def init_alignments(alignments, num, mapping, name, dir_):
def init_alignments(
alignments: str | None,
num: int,
char_to_direction: dict[str, Vector3D],
name: str,
dir_: Vector3D,
) -> list[Vector3D]:
if alignments is None:
# Use cell_alignment as fallback
return [cell_alignment * dir_] * num
if len(alignments) != num:
raise ValueError(f"{name}_alignments has a mismatching size.")
alignments = list(alignments)
for i in range(num):
alignments[i] = mapping[alignments[i]]
return alignments
alignment_directions = [char_to_direction[char] for char in alignments]
return alignment_directions
row_alignments = init_alignments(
row_alignment_directions = init_alignments(
row_alignments,
rows,
{"u": UP, "c": ORIGIN, "d": DOWN},
"row",
RIGHT,
)
col_alignments = init_alignments(
col_alignment_directions = init_alignments(
col_alignments,
cols,
{"l": LEFT, "c": ORIGIN, "r": RIGHT},
@ -2721,7 +2773,7 @@ class Mobject:
)
# Now row_alignment[r] + col_alignment[c] is the alignment in cell [r][c]
mapper = {
mapper: dict[str, Callable[[int, int], int]] = {
"dr": lambda r, c: (rows - r - 1) + c * rows,
"dl": lambda r, c: (rows - r - 1) + (cols - c - 1) * rows,
"ur": lambda r, c: r + c * rows,
@ -2735,18 +2787,14 @@ class Mobject:
raise ValueError(
'flow_order must be one of the following values: "dr", "rd", "ld" "dl", "ru", "ur", "lu", "ul".',
)
flow_order = mapper[flow_order]
get_mob_index_by_position = mapper[flow_order]
# Reverse row_alignments and row_heights. Necessary since the
# Reverse row_alignment_directions and row_heights. Necessary since the
# grid filling is handled bottom up for simplicity reasons.
def reverse(maybe_list):
if maybe_list is not None:
maybe_list = list(maybe_list)
maybe_list.reverse()
return maybe_list
row_alignments = reverse(row_alignments)
row_heights = reverse(row_heights)
row_alignment_directions.reverse()
row_heights_list = list(row_heights) if row_heights is not None else []
row_heights_list.reverse()
col_widths_list = list(col_widths) if col_widths is not None else []
placeholder = Mobject()
# Used to fill up the grid temporarily, doesn't get added to the scene.
@ -2754,7 +2802,10 @@ class Mobject:
# properties of 0.
mobs.extend([placeholder] * (rows * cols - len(mobs)))
grid = [[mobs[flow_order(r, c)] for c in range(cols)] for r in range(rows)]
grid = [
[mobs[get_mob_index_by_position(r, c)] for c in range(cols)]
for r in range(rows)
]
measured_heigths = [
max(grid[r][c].height for c in range(cols)) for r in range(rows)
@ -2764,24 +2815,29 @@ class Mobject:
]
# Initialize row_heights / col_widths correctly using measurements as fallback
def init_sizes(sizes, num, measures, name):
if sizes is None:
def init_sizes(
sizes: list[float | None] | None, num: int, measures: list[float], name: str
) -> list[float]:
if sizes is None or len(sizes) == 0:
sizes = [None] * num
if len(sizes) != num:
raise ValueError(f"{name} has a mismatching size.")
return [
sizes[i] if sizes[i] is not None else measures[i] for i in range(num)
size if size is not None else measure
for size, measure in zip(sizes, measures, strict=False)
]
heights = init_sizes(row_heights, rows, measured_heigths, "row_heights")
widths = init_sizes(col_widths, cols, measured_widths, "col_widths")
heights = init_sizes(row_heights_list, rows, measured_heigths, "row_heights")
widths = init_sizes(col_widths_list, cols, measured_widths, "col_widths")
x, y = 0, 0
x, y = 0.0, 0.0
for r in range(rows):
x = 0
for c in range(cols):
if grid[r][c] is not placeholder:
alignment = row_alignments[r] + col_alignments[c]
alignment = (
row_alignment_directions[r] + col_alignment_directions[c]
)
line = Line(
x * RIGHT + y * UP,
(x + widths[c]) * RIGHT + (y + heights[r]) * UP,
@ -2845,7 +2901,7 @@ class Mobject:
self.submobjects.reverse()
# Just here to keep from breaking old scenes.
def arrange_submobjects(self, *args, **kwargs) -> Self:
def arrange_submobjects(self, *args: Any, **kwargs: Any) -> Self:
"""Arrange the position of :attr:`submobjects` with a small buffer.
Examples
@ -2866,11 +2922,11 @@ class Mobject:
"""
return self.arrange(*args, **kwargs)
def sort_submobjects(self, *args, **kwargs) -> Self:
def sort_submobjects(self, *args: Any, **kwargs: Any) -> Self:
"""Sort the :attr:`submobjects`"""
return self.sort(*args, **kwargs)
def shuffle_submobjects(self, *args, **kwargs) -> None:
def shuffle_submobjects(self, *args: Any, **kwargs: Any) -> None:
"""Shuffles the order of :attr:`submobjects`
Examples
@ -2938,7 +2994,7 @@ class Mobject:
for m1, m2 in zip(self.submobjects, mobject.submobjects, strict=True):
m1.align_data(m2)
def get_point_mobject(self, center=None):
def get_point_mobject(self, center: Point3DLike | None = None) -> Point:
"""The simplest :class:`~.Mobject` to be transformed to or from self.
Should by a point of the appropriate type
"""
@ -2954,7 +3010,7 @@ class Mobject:
mobject.align_points_with_larger(self)
return self
def align_points_with_larger(self, larger_mobject: Mobject):
def align_points_with_larger(self, larger_mobject: Mobject) -> None:
raise NotImplementedError("Please override in a child class.")
def align_submobjects(self, mobject: Mobject) -> Self:
@ -2966,7 +3022,7 @@ class Mobject:
mob2.add_n_more_submobjects(max(0, n1 - n2))
return self
def null_point_align(self, mobject: Mobject):
def null_point_align(self, mobject: Mobject) -> Self:
"""If a :class:`~.Mobject` with points is being aligned to
one without, treat both as groups, and push
the one with points into its own submobjects
@ -3011,7 +3067,7 @@ class Mobject:
self.submobjects = new_submobs
return self
def repeat_submobject(self, submob: Mobject) -> Self:
def repeat_submobject(self, submob: Mobject) -> Mobject:
return submob.copy()
def interpolate(
@ -3087,7 +3143,9 @@ class Mobject:
self.interpolate_color(mobject1, mobject2, alpha)
return self
def interpolate_color(self, mobject1: Mobject, mobject2: Mobject, alpha: float):
def interpolate_color(
self, mobject1: Mobject, mobject2: Mobject, alpha: float
) -> None:
raise NotImplementedError("Please override in a child class.")
def become(
@ -3318,25 +3376,25 @@ class Group(Mobject, metaclass=ConvertToOpenGL):
be added to the group.
"""
def __init__(self, *mobjects, **kwargs) -> None:
def __init__(self, *mobjects: Any, **kwargs: Any) -> None:
super().__init__(**kwargs)
self.add(*mobjects)
class _AnimationBuilder:
def __init__(self, mobject) -> None:
def __init__(self, mobject: Mobject) -> None:
self.mobject = mobject
self.mobject.generate_target()
self.overridden_animation = None
self.overridden_animation: Animation | None = None
self.is_chaining = False
self.methods: list[MethodWithArgs] = []
# Whether animation args can be passed
self.cannot_pass_args = False
self.anim_args = {}
self.anim_args: dict[str, Any] = {}
def __call__(self, **kwargs) -> Self:
def __call__(self, **kwargs: Any) -> Self:
if self.cannot_pass_args:
raise ValueError(
"Animation arguments must be passed before accessing methods and can only be passed once",
@ -3347,7 +3405,7 @@ class _AnimationBuilder:
return self
def __getattr__(self, method_name) -> types.MethodType:
def __getattr__(self, method_name: str) -> Callable[..., _AnimationBuilder]:
method = getattr(self.mobject.target, method_name)
has_overridden_animation = hasattr(method, "_override_animate")
@ -3356,7 +3414,7 @@ class _AnimationBuilder:
"Method chaining is currently not supported for overridden animations",
)
def update_target(*method_args, **method_kwargs):
def update_target(*method_args: Any, **method_kwargs: Any) -> _AnimationBuilder:
if has_overridden_animation:
self.overridden_animation = method._override_animate(
self.mobject,
@ -3375,9 +3433,7 @@ class _AnimationBuilder:
return update_target
def build(self) -> Animation:
from ..animation.transform import ( # is this to prevent circular import?
_MethodAnimation,
)
from ..animation.transform import _MethodAnimation
anim = self.overridden_animation or _MethodAnimation(self.mobject, self.methods)
@ -3393,9 +3449,9 @@ class _UpdaterBuilder:
def __init__(self, mobject: Mobject):
self._mobject = mobject
def __getattr__(self, name: str, /) -> Callable[..., Self]:
def __getattr__(self, name: str, /) -> Callable[..., _UpdaterBuilder]:
# just return a function that will add the updater
def add_updater(*method_args, **method_kwargs) -> Self:
def add_updater(*method_args: Any, **method_kwargs: Any) -> _UpdaterBuilder:
self._mobject.add_updater(
lambda m: getattr(m, name)(*method_args, **method_kwargs),
call_updater=True,
@ -3405,7 +3461,9 @@ class _UpdaterBuilder:
return add_updater
def override_animate(method) -> types.FunctionType:
def override_animate(
method: types.MethodType,
) -> Callable[[types.MethodType], types.MethodType]:
r"""Decorator for overriding method animations.
This allows to specify a method (returning an :class:`~.Animation`)
@ -3456,9 +3514,11 @@ def override_animate(method) -> types.FunctionType:
self.wait()
"""
temp_method = cast(_AnimationBuilder, method)
def decorator(animation_method):
method._override_animate = animation_method
def decorator(animation_method: types.MethodType) -> types.MethodType:
# error: "Callable[..., Animation]" has no attribute "_override_animate" [attr-defined]
temp_method._override_animate = animation_method # type: ignore[attr-defined]
return animation_method
return decorator

View file

@ -107,7 +107,7 @@ class OpenGLTipableVMobject(OpenGLVMobject):
"""
tip = self.create_tip(at_start, **kwargs)
self.reset_endpoints_based_on_tip(tip, at_start)
self.asign_tip_attr(tip, at_start)
self.assign_tip_attr(tip, at_start)
self.add(tip)
return self
@ -160,7 +160,7 @@ class OpenGLTipableVMobject(OpenGLVMobject):
self.put_start_and_end_on(start, end)
return self
def asign_tip_attr(self, tip: OpenGLArrowTip, at_start: bool) -> Self:
def assign_tip_attr(self, tip: OpenGLArrowTip, at_start: bool) -> Self:
if at_start:
self.start_tip = tip
else:

View file

@ -1222,7 +1222,7 @@ class OpenGLMobject:
) -> Sequence[Vector3D]:
if str_alignments is None:
# Use cell_alignment as fallback
return [cast(Vector3D, cell_alignment * direction)] * num
return [cast("Vector3D", cell_alignment * direction)] * num
if len(str_alignments) != num:
raise ValueError(f"{name}_alignments has a mismatching size.")
return [mapping[letter] for letter in str_alignments]
@ -3037,7 +3037,7 @@ class OpenGLPoint(OpenGLMobject):
return self.artificial_height
def get_location(self) -> Point3D:
return cast(Point3D, self.points[0]).copy()
return cast("Point3D", self.points[0]).copy()
@override
def get_bounding_box_point(self, *args: object, **kwargs: Any) -> Point3D:

View file

@ -1227,7 +1227,9 @@ class OpenGLVMobject(OpenGLMobject):
def get_nth_subpath(path_list, n):
if n >= len(path_list):
# Create a null path at the very end
return [path_list[-1][-1]] * nppc
if len(path_list) == 0:
return np.tile(np.zeros(3), (nppc, 1))
return np.tile(path_list[-1][-1], (nppc, 1))
path = path_list[n]
# Check for useless points at the end of the path and remove them
# https://github.com/ManimCommunity/manim/issues/1959

View file

@ -237,6 +237,26 @@ class MathTex(SingleStringMathTex):
t = MathTex(r"\int_a^b f'(x) dx = f(b)- f(a)")
self.add(t)
Notes
-----
Double-brace notation ``{{ ... }}`` can be used to split a single
string argument into multiple submobjects without having to pass
separate strings::
MathTex(r"{{ a^2 }} + {{ b^2 }} = {{ c^2 }}")
Each ``{{ ... }}`` group and every piece of text between groups
becomes its own submobject, which is useful for
:class:`~.TransformMatchingTex` animations.
For ``{{`` to be recognised as a group opener it must appear either
at the very start of the string or be immediately preceded by a
whitespace character. ``{{`` that follows non-whitespace such as
in ``\frac{{{n}}}{k}`` or ``a^{{2}}`` is left untouched, so
ordinary nested-brace LaTeX is not accidentally split. To prevent
an unintentional split, insert a space between the two braces:
``{{ ... }}`` ``{ { ... } }``.
Tests
-----
Check that creating a :class:`~.MathTex` works::
@ -318,15 +338,94 @@ class MathTex(SingleStringMathTex):
tex_strings_validated = [
string if isinstance(string, str) else str(string) for string in tex_strings
]
# Locate double curly bracers
# Locate double curly bracers and split on them.
tex_strings_validated_two = []
for tex_string in tex_strings_validated:
split = re.split(r"{{|}}", tex_string)
split = self._split_double_braces(tex_string)
tex_strings_validated_two.extend(split)
if len(tex_strings_validated_two) > len(tex_strings_validated):
self.brace_notation_split_occurred = True
return [string for string in tex_strings_validated_two if len(string) > 0]
@staticmethod
def _split_double_braces(tex_string: str) -> list[str]:
r"""Split *tex_string* on Manim's ``{{ ... }}`` double-brace notation.
Rules that avoid false positives on ordinary LaTeX source:
* ``{{`` is only treated as a group opener when it appears at the very
start of the string or is immediately preceded by a whitespace
character. Naturally-occurring ``{{`` in LaTeX is usually preceded
by non-whitespace (e.g. ``\frac{{{n}}}{k}`` or ``a^{{2}}``), so
the whitespace guard eliminates the most common false positives
without any brace-depth bookkeeping on the outer string.
* Inside an open group the depth of *real* LaTeX braces is tracked.
``}}`` only closes the Manim group when the inner depth is zero,
so ``{{ a^{b^{c}} }}`` is handled correctly.
* Escape sequences are consumed as two-character units in priority
order: ``\\`` first (escaped backslash), then ``\{`` / ``\}``
(escaped braces). This ensures e.g. ``\\}}`` is read as an
escaped backslash followed by a real ``}}`` rather than as
``\`` + ``\}`` + lone ``}``.
"""
segments: list[str] = []
current = ""
i = 0
inside_manim = False
inner_depth = 0
while i < len(tex_string):
# --- consume escape sequences as atomic units ---
if tex_string[i] == "\\" and i + 1 < len(tex_string):
next_ch = tex_string[i + 1]
if next_ch == "\\" or next_ch in "{}":
# \\ (escaped backslash) checked before \{ / \} so that
# the second \ in \\ is never mistaken for an escape prefix.
current += tex_string[i : i + 2]
i += 2
continue
if not inside_manim:
# {{ opens a Manim group only at start-of-string or after whitespace.
if tex_string[i : i + 2] == "{{" and (
i == 0 or tex_string[i - 1].isspace()
):
segments.append(current)
current = ""
inside_manim = True
inner_depth = 0
i += 2
else:
current += tex_string[i]
i += 1
else:
if tex_string[i] == "{":
inner_depth += 1
current += tex_string[i]
i += 1
elif (
tex_string[i] == "}"
and inner_depth == 0
and tex_string[i : i + 2] == "}}"
):
# }} at inner depth 0 closes the Manim group.
segments.append(current)
current = ""
inside_manim = False
i += 2
elif tex_string[i] == "}":
inner_depth -= 1
current += tex_string[i]
i += 1
else:
current += tex_string[i]
i += 1
segments.append(current)
return segments
def _join_tex_strings_with_unique_deliminters(
self, tex_strings: list[str], substrings_to_isolate: Iterable[str]
) -> str:
@ -488,7 +587,7 @@ class MathTex(SingleStringMathTex):
self.id_to_vgroup_dict[match[1]].set_color(color)
return self
def index_of_part(self, part: MathTex) -> int:
def index_of_part(self, part: VMobject) -> int:
split_self = self.split()
if part not in split_self:
raise ValueError("Trying to get index of part not in MathTex")

View file

@ -166,9 +166,12 @@ class Paragraph(VGroup):
lines_str_list = lines_str.split("\n")
self.chars = self._gen_chars(lines_str_list)
self.lines = [list(self.chars), [self.alignment] * len(self.chars)]
self.lines_initial_positions = [line.get_center() for line in self.lines[0]]
self.add(*self.lines[0])
# TODO: If possible get rid of self.lines_chars, as it seems to be a
# listified duplicate of self.chars.
self.lines_chars = list(self.chars)
self.lines_alignments = [self.alignment] * len(self.chars)
self.lines_initial_positions = [line.get_center() for line in self.lines_chars]
self.add(*self.lines_chars)
self.move_to(np.array([0, 0, 0]))
if self.alignment:
self._set_all_lines_alignments(self.alignment)
@ -221,7 +224,7 @@ class Paragraph(VGroup):
alignment
Defines the alignment of paragraph. Possible values are "left", "right", "center".
"""
for line_no in range(len(self.lines[0])):
for line_no in range(len(self.lines_chars)):
self._change_alignment_for_a_line(alignment, line_no)
return self
@ -240,8 +243,8 @@ class Paragraph(VGroup):
def _set_all_lines_to_initial_positions(self) -> Paragraph:
"""Set all lines to their initial positions."""
self.lines[1] = [None] * len(self.lines[0])
for line_no in range(len(self.lines[0])):
self.lines_alignments = [None] * len(self.lines_chars)
for line_no in range(len(self.lines_chars)):
self[line_no].move_to(
self.get_center() + self.lines_initial_positions[line_no],
)
@ -255,7 +258,7 @@ class Paragraph(VGroup):
line_no
Defines the line number for which we want to set given alignment.
"""
self.lines[1][line_no] = None
self.lines_alignments[line_no] = None
self[line_no].move_to(self.get_center() + self.lines_initial_positions[line_no])
return self
@ -269,12 +272,12 @@ class Paragraph(VGroup):
line_no
Defines the line number for which we want to set given alignment.
"""
self.lines[1][line_no] = alignment
if self.lines[1][line_no] == "center":
self.lines_alignments[line_no] = alignment
if self.lines_alignments[line_no] == "center":
self[line_no].move_to(
np.array([self.get_center()[0], self[line_no].get_center()[1], 0]),
)
elif self.lines[1][line_no] == "right":
elif self.lines_alignments[line_no] == "right":
self[line_no].move_to(
np.array(
[
@ -284,7 +287,7 @@ class Paragraph(VGroup):
],
),
)
elif self.lines[1][line_no] == "left":
elif self.lines_alignments[line_no] == "left":
self[line_no].move_to(
np.array(
[

View file

@ -149,6 +149,7 @@ class Surface(VGroup, metaclass=ConvertToOpenGL):
self.pre_function_handle_to_anchor_scale_factor = (
pre_function_handle_to_anchor_scale_factor
)
self.list_of_faces: list[ThreeDVMobject] = []
self._func = func
self._setup_in_uv_space()
self.apply_function(lambda p: func(p[0], p[1]))
@ -172,6 +173,7 @@ class Surface(VGroup, metaclass=ConvertToOpenGL):
def _setup_in_uv_space(self) -> None:
u_values, v_values = self._get_u_values_and_v_values()
faces = VGroup()
self.list_of_faces = []
for i in range(len(u_values) - 1):
for j in range(len(v_values) - 1):
u1, u2 = u_values[i : i + 2]
@ -193,6 +195,7 @@ class Surface(VGroup, metaclass=ConvertToOpenGL):
face.u2 = u2
face.v1 = v1
face.v2 = v2
self.list_of_faces.append(face)
faces.set_fill(color=self.fill_color, opacity=self.fill_opacity)
faces.set_stroke(
color=self.stroke_color,
@ -223,7 +226,7 @@ class Surface(VGroup, metaclass=ConvertToOpenGL):
The parametric surface with an alternating pattern.
"""
n_colors = len(colors)
for face in self:
for face in self.list_of_faces:
c_index = (face.u_index + face.v_index) % n_colors
face.set_fill(colors[c_index], opacity=opacity)
return self
@ -376,13 +379,7 @@ class Sphere(Surface):
class ExampleSphere(ThreeDScene):
def construct(self):
self.set_camera_orientation(phi=PI / 6, theta=PI / 6)
sphere1 = Sphere(
center=(3, 0, 0),
radius=1,
resolution=(20, 20),
u_range=[0.001, PI - 0.001],
v_range=[0, TAU]
)
sphere1 = Sphere(center=(3, 0, 0), radius=1, resolution=(20, 20))
sphere1.set_color(RED)
self.add(sphere1)
sphere2 = Sphere(center=(-1, -3, 0), radius=2, resolution=(18, 18))
@ -391,6 +388,57 @@ class Sphere(Surface):
sphere3 = Sphere(center=(-1, 2, 0), radius=2, resolution=(16, 16))
sphere3.set_color(BLUE)
self.add(sphere3)
This example shows that overlapping spheres can intersect with rough transitions.
.. manim:: ExampleSphereOverlap
:save_last_frame:
class ExampleSphereOverlap(ThreeDScene):
def construct(self):
self.set_camera_orientation(phi=PI / 4, theta=PI / 4)
sphere1 = Sphere(center=(0, 0, 0), radius=1, resolution=(20, 20))
sphere1.set_color(RED)
self.add(sphere1)
sphere2 = Sphere(center=(-0.5, -1, 0.5), radius=1.2, resolution=(20, 20))
sphere2.set_color(GREEN)
self.add(sphere2)
sphere3 = Sphere(center=(1, -1, 0), radius=1.1, resolution=(20, 20))
sphere3.set_color(BLUE)
self.add(sphere3)
In this example, by modifying ``u_range`` (the range of the azimuthal angle) and
``v_range`` (the range of the polar angle), it is possible to obtain a portion of a
sphere:
.. manim:: ExamplePartialSpheres
:save_last_frame:
class ExamplePartialSpheres(ThreeDScene):
def construct(self):
self.set_camera_orientation(phi=PI / 4)
sphere1 = Sphere(
center=(-3, 0, 0),
resolution=(10, 20),
u_range=[TAU / 4, 3 * TAU / 4],
)
sphere1.set_color(RED)
self.add(sphere1)
sphere2 = Sphere(
center=(0, 0, 0),
resolution=(20, 10),
v_range=[0, TAU / 4],
)
sphere2.set_color(GREEN)
self.add(sphere2)
sphere3 = Sphere(
center=(3, 0, 0),
resolution=(5, 10),
u_range=[3 * TAU / 4, TAU],
v_range=[TAU / 4, TAU / 2],
)
sphere3.set_color(BLUE)
self.add(sphere3)
"""
def __init__(

View file

@ -68,7 +68,7 @@ class AbstractImageMobject(Mobject):
def get_pixel_array(self) -> PixelArray:
raise NotImplementedError()
def set_color( # type: ignore[override]
def set_color(
self,
color: ParsableManimColor = YELLOW_C,
alpha: Any = None,
@ -217,7 +217,7 @@ class ImageMobject(AbstractImageMobject):
"""A simple getter method."""
return self.pixel_array
def set_color( # type: ignore[override]
def set_color(
self,
color: ParsableManimColor = YELLOW_C,
alpha: Any = None,

View file

@ -47,6 +47,7 @@ from manim.utils.iterables import (
from manim.utils.space_ops import rotate_vector, shoelace_direction
if TYPE_CHECKING:
from collections.abc import Iterator
from typing import Self
import numpy.typing as npt
@ -103,6 +104,7 @@ class VMobject(Mobject):
"""
sheen_factor = 0.0
target: VMobject
def __init__(
self,
@ -172,6 +174,9 @@ class VMobject(Mobject):
def _assert_valid_submobjects(self, submobjects: Iterable[VMobject]) -> Self:
return self._assert_valid_submobjects_internal(submobjects, VMobject)
def __iter__(self) -> Iterator[VMobject]:
return iter(self.split())
# OpenGL compatibility
@property
def n_points_per_curve(self) -> int:
@ -630,6 +635,17 @@ class VMobject(Mobject):
color: ManimColor = property(get_color, set_color)
def nonempty_submobjects(self) -> Sequence[VMobject]:
return [
submob
for submob in self.submobjects
if len(submob.submobjects) != 0 or len(submob.points) != 0
]
def split(self) -> list[VMobject]:
result: list[VMobject] = [self] if len(self.points) > 0 else []
return result + self.submobjects
def set_sheen_direction(self, direction: Vector3DLike, family: bool = True) -> Self:
"""Sets the direction of the applied sheen.
@ -1769,7 +1785,9 @@ class VMobject(Mobject):
def get_nth_subpath(path_list, n):
if n >= len(path_list):
# Create a null path at the very end
return [path_list[-1][-1]] * nppcc
if len(path_list) == 0:
return np.tile(np.zeros(3), (nppcc, 1))
return np.tile(path_list[-1][-1], (nppcc, 1))
path = path_list[n]
# Check for useless points at the end of the path and remove them
# https://github.com/ManimCommunity/manim/issues/1959
@ -2303,6 +2321,11 @@ class VGroup(VMobject, metaclass=ConvertToOpenGL):
self._assert_valid_submobjects(tuplify(value))
self.submobjects[key] = value
def __getitem__(self, key: int | slice) -> VMobject:
if isinstance(key, slice):
return VGroup(self.submobjects[key])
return self.submobjects[key]
class VDict(VMobject, metaclass=ConvertToOpenGL):
"""A VGroup-like class, also offering submobject access by

View file

@ -25,14 +25,23 @@ class Window(PygletWindow):
def __init__(
self,
renderer: OpenGLRenderer,
window_size: str = config.window_size,
window_size: str | tuple[int, ...] = config.window_size,
**kwargs: Any,
) -> None:
monitors = get_monitors()
mon_index = config.window_monitor
monitor = monitors[min(mon_index, len(monitors) - 1)]
if window_size == "default":
invalid_window_size_error_message = (
"window_size must be specified either as 'default', a string of the form "
"'width,height', or a tuple of 2 ints of the form (width, height)."
)
if isinstance(window_size, tuple):
if len(window_size) != 2:
raise ValueError(invalid_window_size_error_message)
size = window_size
elif window_size == "default":
# make window_width half the width of the monitor
# but make it full screen if --fullscreen
window_width = monitor.width
@ -48,9 +57,7 @@ class Window(PygletWindow):
(window_width, window_height) = tuple(map(int, window_size.split(",")))
size = (window_width, window_height)
else:
raise ValueError(
"Window_size must be specified as 'width,height' or 'default'.",
)
raise ValueError(invalid_window_size_error_message)
super().__init__(size=size)

View file

@ -903,7 +903,24 @@ class Scene:
# as soon as there's one that needs updating of
# some kind per frame, return the list from that
# point forward.
animation_mobjects = [anim.mobject for anim in animations]
# Imported inside the method to avoid cyclic import.
from ..animation.composition import AnimationGroup
def _collect_animation_mobjects(
nested_animations: Iterable[Animation],
) -> list[Mobject | OpenGLMobject]:
animation_mobjects: list[Mobject | OpenGLMobject] = []
for anim in nested_animations:
if isinstance(anim, AnimationGroup):
animation_mobjects.extend(
_collect_animation_mobjects(anim.animations),
)
else:
animation_mobjects.extend(anim.mobject.get_family())
return animation_mobjects
animation_mobjects = _collect_animation_mobjects(animations)
mobjects = self.get_mobject_family_members()
for i, mob in enumerate(mobjects):
update_possibilities = [

View file

@ -6,6 +6,7 @@ __all__ = ["SceneFileWriter"]
import json
import shutil
import warnings
from fractions import Fraction
from pathlib import Path
from queue import Queue
@ -17,7 +18,17 @@ import av
import numpy as np
import srt
from PIL import Image
from pydub import AudioSegment
# Manim handles audio conversion through PyAV directly. Importing pydub emits a
# RuntimeWarning if ffmpeg/avconv is not on PATH, even when only WAV code paths
# are used (which do not need ffmpeg). Silence this specific warning.
with warnings.catch_warnings():
warnings.filterwarnings(
"ignore",
message=r".*ffmpeg or avconv.*",
category=RuntimeWarning,
)
from pydub import AudioSegment
from manim import __version__

View file

@ -281,15 +281,19 @@ class VectorScene(Scene):
color (str),
label_scale_factor=VECTOR_LABEL_SCALE_FACTOR (int, float),
"""
i_hat, j_hat = self.get_basis_vectors()
i_hat = self.get_basis_vectors().submobjects[0]
j_hat = self.get_basis_vectors().submobjects[1]
return VGroup(
*(
self.get_vector_label(
vect, label, color=color, label_scale_factor=1, **kwargs
)
for vect, label, color in [
(i_hat, "\\hat{\\imath}", X_COLOR),
(j_hat, "\\hat{\\jmath}", Y_COLOR),
# Casting i_hat and j_hat to Vector, as the VGroup from
# self.get_basis_vectors() contains two vectors, but the
# type checker is currently not aware of that.
(cast(Vector, i_hat), "\\hat{\\imath}", X_COLOR),
(cast(Vector, j_hat), "\\hat{\\jmath}", Y_COLOR),
]
)
)
@ -517,7 +521,9 @@ class VectorScene(Scene):
y_line = Line(x_line.get_end(), arrow.get_end())
x_line.set_color(X_COLOR)
y_line.set_color(Y_COLOR)
x_coord, y_coord = cast(VGroup, array.get_entries())
temp = array.get_entries()
x_coord = temp.submobjects[0]
y_coord = temp.submobjects[1]
x_coord_start = self.position_x_coordinate(x_coord.copy(), x_line, vector)
y_coord_start = self.position_y_coordinate(y_coord.copy(), y_line, vector)
brackets = array.get_brackets()

View file

@ -90,6 +90,7 @@ def bezier(
containing :math:`n` values to evaluate the Bézier curve at, returning instead
an :math:`(n, 3)`-shaped :class:`~.Point3D_Array` containing the points
resulting from evaluating the Bézier at each of the :math:`n` values.
.. warning::
If passing a vector of :math:`t`-values to ``bezier_func``, it **must**
be a column vector/matrix of shape :math:`(n, 1)`. Passing an 1D array of
@ -106,6 +107,7 @@ def bezier(
Bézier curve defined by ``points`` is evaluated at the corresponding :math:`i`-th
value in ``t``, returning again an :math:`(M, 3)`-shaped :class:`~.Point3D_Array`
containing those :math:`M` evaluations.
.. warning::
Unlike the previous case, if you pass a :class:`~.ColVector` to ``bezier_func``,
it **must** contain exactly :math:`M` values, each value for each of the :math:`M`

View file

@ -250,6 +250,9 @@ def deprecated(
if type(func).__name__ != "function":
deprecate_docs(func)
# The following line raises this mypy error:
# Accessing "__init__" on an instance is unsound, since instance.__init__
# could be from an incompatible subclass [misc]</pre>
func.__init__ = decorate(func.__init__, deprecate)
return func

View file

@ -85,9 +85,6 @@ ignore_errors = True
[mypy-manim.mobject.logo]
ignore_errors = True
[mypy-manim.mobject.mobject]
ignore_errors = True
[mypy-manim.mobject.opengl.opengl_point_cloud_mobject]
ignore_errors = True
@ -100,6 +97,9 @@ ignore_errors = True
[mypy-manim.mobject.table]
ignore_errors = True
[mypy-manim.mobject.types.point_cloud_mobject]
ignore_errors = True
[mypy-manim.mobject.types.vectorized_mobject]
ignore_errors = True

View file

@ -1,6 +1,6 @@
[project]
name = "manim"
version = "0.20.0"
version = "0.20.1"
description = "Animation engine for explanatory math videos."
authors = [
{name = "The Manim Community Developers", email = "contact@manim.community"},
@ -62,7 +62,7 @@ documentation = "https://docs.manim.community/"
homepage = "https://www.manim.community/"
"Bug Tracker" = "https://github.com/ManimCommunity/manim/issues"
"Changelog" = "https://docs.manim.community/en/stable/changelog.html"
"X / Twitter" = "https://x.com/manim_community"
"X / Twitter" = "https://x.com/manimcommunity"
"Bluesky" = "https://bsky.app/profile/manim.community"
"Discord" = "https://www.manim.community/discord/"

View file

@ -5,14 +5,16 @@ from unittest.mock import MagicMock
import pytest
from manim.animation.animation import Animation, Wait
from manim.animation.composition import AnimationGroup, Succession
from manim.animation.composition import AnimationGroup, LaggedStartMap, Succession
from manim.animation.creation import Create, Write
from manim.animation.fading import FadeIn, FadeOut
from manim.constants import DOWN, UP
from manim.mobject.geometry.arc import Circle
from manim.mobject.geometry.line import Line
from manim.mobject.geometry.polygram import RegularPolygon, Square
from manim.mobject.types.vectorized_mobject import VGroup
from manim.scene.scene import Scene
from manim.utils.rate_functions import linear, there_and_back
def test_succession_timing():
@ -189,6 +191,23 @@ def test_animationgroup_calls_finish():
assert circ_animation.finished
def test_laggedstartmap_only_passes_kwargs_to_subanimations():
mobject = VGroup(Square(), Circle())
animation = LaggedStartMap(
FadeIn,
mobject,
rate_func=there_and_back,
lag_ratio=0.3,
)
assert animation.rate_func is linear
assert animation.lag_ratio == 0.3
assert all(
subanimation.rate_func is there_and_back
for subanimation in animation.animations
)
def test_empty_animation_group_fails():
with pytest.raises(ValueError, match="Please add at least one subanimation."):
AnimationGroup().begin()

View file

@ -20,13 +20,122 @@ def test_SingleStringMathTex(config):
@pytest.mark.parametrize( # : PT006
("text_input", "length_sub"),
[("{{ a }} + {{ b }} = {{ c }}", 5), (r"\frac{1}{a+b\sqrt{2}}", 1)],
[
("{{ a }} + {{ b }} = {{ c }}", 5),
(r"\frac{1}{a+b\sqrt{2}}", 1),
# Regression test for https://github.com/ManimCommunity/manim/issues/4601:
# a string whose only }} comes from closing two nested LaTeX brace groups
# (not from the {{ }} notation) must not be split.
(r"\\+\int_{0}^{\frac{Mq}{M+m}}", 1),
],
)
def test_double_braces_testing(text_input, length_sub):
t1 = MathTex(text_input)
assert len(t1.submobjects) == length_sub
# ---------------------------------------------------------------------------
# Unit tests for MathTex._split_double_braces — no LaTeX compilation needed.
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
("tex_string", "expected_segments"),
[
# ---- intended notation ----
# Basic split: each {{ }} group and the text between become segments.
(
"{{ a }} + {{ b }}",
["", " a ", " + ", " b ", ""],
),
# {{ }} at the very start of the string (no preceding character).
(
"{{x}}",
["", "x", ""],
),
# Content with arbitrarily nested LaTeX braces: inner }} must NOT close
# the Manim group early.
(
r"{{ a^{b^{c}} }}",
["", r" a^{b^{c}} ", ""],
),
# \frac inside a Manim group — the }} from {1}{a} are at inner_depth > 0.
(
r"{{ \frac{1}{a} }}",
["", r" \frac{1}{a} ", ""],
),
# ---- false-positive guards: {{ not preceded by whitespace ----
# \text{{word}}: {{ preceded by {, must not split.
(
r"\text{{word}}",
[r"\text{{word}}"],
),
# ^{{\alpha}}: {{ preceded by {, must not split.
(
r"^{{\alpha}}",
[r"^{{\alpha}}"],
),
# +{{a}}: {{ preceded by non-whitespace, must not split.
(
r"+{{a}}",
[r"+{{a}}"],
),
# ---- bug case: }} without any {{ must not split ----
(
r"\\+\int_{0}^{\frac{Mq}{M+m}}",
[r"\\+\int_{0}^{\frac{Mq}{M+m}}"],
),
# ---- backslash escape handling ----
# \}} — \} consumed as unit, remaining } is a lone close, not }}.
(
r"\}}",
[r"\}}"],
),
# \\}} — \\ consumed as unit, leaving real }} which is not inside any
# Manim group so it passes through unchanged.
(
r"\\}}",
[r"\\}}"],
),
# \\\}} — \\ consumed, then \} consumed; lone } passes through.
(
r"\\\}}",
[r"\\\}}"],
),
# \\\\}} — two \\ consumed; lone }} passes through (no Manim group open).
(
r"\\\\}}",
[r"\\\\}}"],
),
# Same backslash cases *inside* a Manim group.
# The escape sequence is placed right before the Manim }} close.
#
# {{ a \}}} — \} consumed as escaped brace (content), }} closes the group.
(
r"{{ a \}}}",
["", r" a \}", ""],
),
# {{ a \\}} — \\ consumed as escaped backslash (content), }} closes.
(
r"{{ a \\}}",
["", r" a \\", ""],
),
# {{ a \\\}}} — \\ then \} consumed (content), }} closes.
(
r"{{ a \\\}}}",
["", r" a \\\}", ""],
),
# {{ a \\\\}} — \\ then \\ consumed (content), }} closes.
(
r"{{ a \\\\}}",
["", r" a \\\\", ""],
),
],
)
def test_split_double_braces(tex_string, expected_segments):
assert MathTex._split_double_braces(tex_string) == expected_segments
def test_tex(config):
Tex("The horse does not eat cucumber salad.")
assert Path(config.media_dir, "Tex", "5384b41741a246bd.svg").exists()

View file

@ -1,4 +1,4 @@
from manim import ORIGIN, UR, Arrow, DashedVMobject, VGroup
from manim import ORIGIN, UR, Arrow, DashedLine, DashedVMobject, VGroup
from manim.mobject.geometry.tips import ArrowTip, StealthTip
@ -41,3 +41,19 @@ def test_dashed_arrow_with_start_tip_has_two_tips():
tips = _collect_tips(dashed)
assert len(tips) == 2
def test_zero_length_dashed_line_submobjects_have_2d_points():
"""Submobjects of a zero-length DashedLine must have 2-D point arrays."""
line = DashedLine(ORIGIN, ORIGIN)
for sub in line.submobjects:
assert sub.points.ndim == 2, (
f"Expected 2-D points array, got shape {sub.points.shape}"
)
def test_become_nonzero_to_zero_dashed_line_does_not_crash():
"""become() from a normal DashedLine to a zero-length one should not crash."""
normal = DashedLine(ORIGIN, 2 * UR)
zero = DashedLine(ORIGIN, ORIGIN)
normal.become(zero)

View file

@ -174,6 +174,27 @@ def test_ZIndex(scene):
scene.play(ApplyMethod(triangle.shift, 2 * UP))
@frames_comparison(last_frame=False)
def test_negative_z_index_AnimationGroup(scene):
# https://github.com/ManimCommunity/manim/issues/3334
s = Square().set_z_index(-1)
scene.play(AnimationGroup(GrowFromCenter(s)))
@frames_comparison(last_frame=False)
def test_negative_z_index_LaggedStart(scene):
# https://github.com/ManimCommunity/manim/issues/3914
line_1 = Line(LEFT, RIGHT, color=BLUE)
line_2 = Line(UP + LEFT, UP + RIGHT, color=RED).set_z_index(-1)
scene.play(LaggedStart(FadeIn(line_1), FadeIn(line_2), lag_ratio=0.5))
@frames_comparison(last_frame=False)
def test_nested_animation_groups_with_negative_z_index(scene):
line = Line(LEFT, RIGHT, color=BLUE).set_z_index(-1)
scene.play(AnimationGroup(AnimationGroup(AnimationGroup(FadeIn(line)))))
@frames_comparison
def test_Angle(scene):
l1 = Line(ORIGIN, RIGHT)

2
uv.lock generated
View file

@ -1396,7 +1396,7 @@ wheels = [
[[package]]
name = "manim"
version = "0.20.0"
version = "0.20.1"
source = { editable = "." }
dependencies = [
{ name = "audioop-lts", marker = "python_full_version >= '3.13'" },