Merge branch 'main' into patch-1

This commit is contained in:
Benjamin Hackl 2026-02-24 00:35:28 +01:00 committed by GitHub
commit 0555a69012
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
103 changed files with 3132 additions and 1411 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

@ -1,10 +1,6 @@
<!-- Thank you for contributing to Manim! Learn more about the process in our contributing guidelines: https://docs.manim.community/en/latest/contributing.html -->
## Overview: What does this pull request change?
<!-- If there is more information than the PR title that should be added to our release changelog, add it in the following changelog section. This is optional, but recommended for larger pull requests. -->
<!--changelog-start-->
<!--changelog-end-->
## Motivation and Explanation: Why and how do your changes improve the library?
<!-- Optional for bugfixes, small enhancements, and documentation-related PRs. Otherwise, please give a short reasoning for your changes. -->

View file

@ -11,12 +11,13 @@ jobs:
environment: release
permissions:
id-token: write
contents: write
steps:
- uses: actions/checkout@v6
- name: Install dependencies
run: apt-get update && apt-get install -y build-essential python3-dev libcairo2-dev libpango1.0-dev
run: sudo apt-get update && sudo apt-get install -y build-essential python3-dev libcairo2-dev libpango1.0-dev
- name: Set up Python 3.13
uses: actions/setup-python@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-01-17
date-released: 2026-02-20
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.19.2"
version: "v0.20.0"
...

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

@ -2,14 +2,17 @@
Changelog
#########
This page contains a list of changes made between releases. Changes
from versions that are not listed below (in particular patch-level
releases since v0.18.0) are documented on our
`GitHub release page <https://github.com/ManimCommunity/manim/releases/>`__.
This page contains a list of changes made between releases.
.. toctree::
:maxdepth: 1
changelog/0.20.0-changelog
changelog/0.19.2-changelog
changelog/0.19.1-changelog
changelog/0.19.0-changelog
changelog/0.18.1-changelog
changelog/0.18.0.post0-changelog
changelog/0.18.0-changelog
changelog/0.17.3-changelog
changelog/0.17.2-changelog

View file

@ -0,0 +1,9 @@
*************
v0.18.0.post0
*************
:Date: April 08, 2024
This release is a post-release fixing `#3676
<https://github.com/ManimCommunity/manim/issues/3676>`_, a bug caused by a recent
change introduced to the way how SVG files of text are generated by Pango.

View file

@ -0,0 +1,160 @@
---
short-title: v0.18.1
description: Changelog for Manim v0.18.1
---
# v0.18.1
Date
: April 28, 2024
## What's Changed
### Breaking Changes and Deprecations
* Removed deprecated `manim new` command by {user}`chopan050` in {pr}`3512`
* Removed support for dynamic plugin imports by {user}`Viicos` in {pr}`3524`
* Remove meth:``.Mobject.wag`` by {user}`JasonGrace2282` in {pr}`3539`
* Remove deprecated parameters and animations by {user}`JasonGrace2282` in {pr}`3688`
### New Features
* Added `cap_style` feature to `VMobject` by {user}`MathItYT` in {pr}`3516`
* Allow hiding version splash by {user}`jeertmans` in {pr}`3329`
* Added the ability to pass lists and generators to `Scene.play()` by {user}`MrDiver` in {pr}`3365`
* Added ``--preview_command`` cli flag by {user}`JasonGrace2282` in {pr}`3615`
### Fixed Bugs and Enhancements
* Allow accessing ghost vectors in :class:`.LinearTransformationScene` by {user}`JasonGrace2282` in {pr}`3435`
* Optimized `get_unit_normal()` and replaced `np.cross()` with custom `cross()` in `manim.utils.space_ops` by {user}`chopan050` in {pr}`3494`
* Implement caching of fonts list to improve runtime performance by {user}`MrDiver` in {pr}`3316`
* Reformatting the `--save_sections` output to have the format `<Scene>_<SecNum>_<SecName><extension>` by {user}`doaamuham` in {pr}`3499`
* Account for dtype in the pixel array so the maximum value stays correct in the invert function by {user}`jeertmans` in {pr}`3493`
* Added `grid_lines` attribute to `Rectangle` to add individual styling to the grid lines by {user}`RobinPH` in {pr}`3428`
* Fixed rectangle grid properties (#3082) by {user}`pauluhlenbruck` in {pr}`3513`
* Fixed animations with zero runtime length to give a useful error instead of a broken pipe by {user}`MrDiver` in {pr}`3491`
* Fixed stroke width being ignored by `StreamLines` with a single color by {user}`yashm277` in {pr}`3436`
* Fixed formatting of ``MoveAlongPath`` docs by {user}`JasonGrace2282` in {pr}`3541`
* Added helpful hints to `VGroup.add()` error message by {user}`vvolhejn` in {pr}`3561`
* Made `earclip_triangulation` more robust by {user}`hydromelvictor` in {pr}`3574`
* Refactored `TexTemplate` by {user}`Viicos` in {pr}`3520`
* Fixed `write_subcaption_file` error when using OpenGL renderer by {user}`yuan-xy` in {pr}`3546`
* Fixed `get_arc_center()` returning reference of point by {user}`sparshg` in {pr}`3599`
* Improved handling of specified font name by {user}`staghado` in {pr}`3429`
* Fixing the behavior of `.become` to not modify target mobject via side effects fix color linking by {user}`MrDiver` in {pr}`3508`
* Fixed bug in :class:`.VMobjectFromSVGPath` by {user}`abul4fia` in {pr}`3677`
* Fix for windows cp1252 encoding failure (fix test pipeline) by {user}`JasonGrace2282` in {pr}`3687`
* Fix NameError in try... except by {user}`JasonGrace2282` in {pr}`3694`
* Fix successive calls of :meth:`.LinearTransformationScene.apply_matrix` by {user}`SirJamesClarkMaxwell` in {pr}`3675`
* Fixed `Mobject.put_start_and_end_on` with same start and end point by {user}`MontroyJosh` in {pr}`3718`
* Fixed issue where `SpiralIn` doesn't show elements by {user}`Gixtox` in {pr}`3589`
* Cleaned `Graph` layouts and increase flexibility by {user}`Nikhil-42` in {pr}`3434`
* `AnimationGroup`: optimized `interpolate()` and fixed alpha bug on `finish()` by {user}`chopan050` in {pr}`3542`
* Fixed warning about missing plugin `""` by {user}`behackl` in {pr}`3734`
### Documentation
* Typo in `indication` documentation by {user}`jcep` in {pr}`3477`
* Fixed typo: 360° to 180° in quickstart tutorial by {user}`szchixy` in {pr}`3498`
* Fixed typo in mobject docstring: `line` -> `square` by {user}`yuan-xy` in {pr}`3509`
* Explain ``.Transform`` vs ``.ReplacementTransform`` in quickstart examples by {user}`JasonGrace2282` in {pr}`3500`
* Fixed formatting in building blocks tutorial by {user}`MrDiver` in {pr}`3515`
* Fixed `Indicate` docstring typo by {user}`Lawqup` in {pr}`3461`
* Added Documentation to `.to_edge` and `to_corner` by {user}`TheMathematicFanatic` in {pr}`3408`
* Added some words about Cairo 1.18 by {user}`jeertmans` in {pr}`3530`
* Fixed typo of `get_y_axis_label` parameter documentation by {user}`yuan-xy` in {pr}`3547`
* Added note in docstring of `ManimColor` about class constructors by {user}`JasonGrace2282` in {pr}`3554`
* Improve documentation section about contributing to docs by {user}`chopan050` in {pr}`3555`
* Removed duplicated documentation for -s / --save_last_frame CLI flag by {user}`Gixtox` in {pr}`3528`
* Updated Docker instructions to use bash from the PATH by {user}`NotWearingPants` in {pr}`3582`
* Fixed typo in `value_tracker.py` by {user}`yuan-xy` in {pr}`3594`
* Added `ref_class` for `BooleanOperations` in Example Gallery by {user}`JasonGrace2282` in {pr}`3598`
* Changed `Vector3` to `Vector3D` in contributing docs by {user}`JasonGrace2282` in {pr}`3639`
* Added some examples for `Mobject`/`VMobject` methods by {user}`JasonGrace2282` in {pr}`3641`
* Fixed broken link to Poetry's installation guide in the documentation by {user}`biinnnggggg` in {pr}`3692`
* Fixed minor grammatical errors found in the index page of the documentation by {user}`biinnnggggg` in {pr}`3690`
* Fixed typo on page about translations by {user}`biinnnggggg` in {pr}`3696`
* Fixed outdated description of CLI option in Manim's Output Settings by {user}`HairlessVillager` in {pr}`3674`
* Mention pixi in installation guide by {user}`pavelzw` in {pr}`3678`
* Updated typing guidelines by {user}`JasonGrace2282` in {pr}`3704`
* Updated documentation and typings for `ParametricFunction` by {user}`danielzsh` in {pr}`3703`
* Fixed docstring markup in `Rotate` by {user}`TheCrowned` in {pr}`3721`
* Improve consistency in axis label example by {user}`amrear` in {pr}`3730`
### Maintenance and Testing
* Fixed wrong path in action building downloadable docs by {user}`behackl` in {pr}`3450`
* Add type hints to `_config` by {user}`Viicos` in {pr}`3440`
* Update dependency constraints, fix deprecation warnings by {user}`Viicos` in {pr}`3376`
* Update Docker base image to python3.12-slim (#3458) by {user}`PikaBlue107` in {pr}`3459`
* Fixed `line_join` to `joint_type` in example_scenes/basic.py by {user}`szchixy` in {pr}`3510`
* Fixed :attr:`.Mobject.animate` type-hint to allow LSP autocomplete by {user}`JasonGrace2282` in {pr}`3543`
* Finish TODO's in ``contributing/typings.rst`` by {user}`JasonGrace2282` in {pr}`3545`
* Fixed use of `Mobject`'s deprecated `get_*()` and `set_*()` methods in Cairo tests by {user}`JasonGrace2282` in {pr}`3549`
* Added support for Manim type aliases in Sphinx docs and added new TypeAliases by {user}`chopan050` in {pr}`3484`
* Fixed typing of `Animation` by {user}`dandavison` in {pr}`3568`
* Added some TODOs for future use of `ManimFrame` by {user}`chopan050` in {pr}`3553`
* Fixed typehint of :attr:`InternalPoint2D_Array` by {user}`JasonGrace2282` in {pr}`3592`
* Fixed error in Windows CI pipeline by {user}`behackl` in {pr}`3611`
* Fixed type hint of indication.py by {user}`yuan-xy` in {pr}`3613`
* Revert vector type aliases to NumPy ndarrays by {user}`chopan050` in {pr}`3595`
* Run `poetry lock --no-update` by {user}`JasonGrace2282` in {pr}`3621`
* Code Cleanup: removing unused imports and global variables by {user}`JasonGrace2282` in {pr}`3620`
* Fixed type hint of `Vector` direction parameter by {user}`JasonGrace2282` in {pr}`3640`
* Flake8 rule C901 is about McCabe code complexity by {user}`cclauss` in {pr}`3673`
* Updated year in license by {user}`JasonGrace2282` in {pr}`3689`
* Automated copyright updating for docs by {user}`JasonGrace2282` in {pr}`3708`
* Fixed some typehints in `mobject.py` by {user}`JasonGrace2282` in {pr}`3668`
* Search for type aliases if TYPE_CHECKING by {user}`JasonGrace2282` in {pr}`3671`
* Follow-up to graph layout cleanup: improvements for tests and typing by {user}`behackl` in {pr}`3728`
* GH Actions: Changed from macos-latest to macos-13 by {user}`JasonGrace2282` in {pr}`3729`
* Fixed return type inconsistency for `get_anchors()` by {user}`JinchuLi2002` in {pr}`3214`
* Prepared new release: `v0.18.1` by {user}`behackl` in {pr}`3719`
#### Dependency Version Changes
* Bump jupyter-server from 2.9.1 to 2.11.2 by {user}`dependabot` in {pr}`3497`
* Bump github/codeql-action from 2 to 3 by {user}`dependabot` in {pr}`3567`
* Bump actions/upload-artifact from 3 to 4 by {user}`dependabot` in {pr}`3566`
* Bump actions/setup-python from 4 to 5 by {user}`dependabot` in {pr}`3565`
* updated several packages (pillow, jupyterlab, notebook, jupyterlab-lsp, jinja2, gitpython) by {user}`behackl` in {pr}`3593`
* Update jupyter.rst by {user}`abul4fia` in {pr}`3630`
* Bump black from 23.12.1 to 24.3.0 by {user}`dependabot` in {pr}`3649`
* Bump cryptography from 42.0.0 to 42.0.4 by {user}`dependabot` in {pr}`3629`
* Bump actions/cache from 3 to 4 by {user}`dependabot` in {pr}`3607`
* Bump FedericoCarboni/setup-ffmpeg from 2 to 3 by {user}`dependabot` in {pr}`3608`
* Bump ssciwr/setup-mesa-dist-win from 1 to 2 by {user}`dependabot` in {pr}`3609`
* Bump idna from 3.6 to 3.7 by {user}`dependabot` in {pr}`3693`
* Bump pillow from 10.2.0 to 10.3.0 by {user}`dependabot` in {pr}`3672`
* [pre-commit.ci] pre-commit autoupdate by {user}`pre-commit-ci` in {pr}`3332`
* Updated sphinx deps by {user}`JasonGrace2282` in {pr}`3720`
## New Contributors
* {user}`Lawqup` made their first contribution in {pr}`3461`
* {user}`jcep` made their first contribution in {pr}`3477`
* {user}`szchixy` made their first contribution in {pr}`3498`
* {user}`PikaBlue107` made their first contribution in {pr}`3459`
* {user}`yuan-xy` made their first contribution in {pr}`3509`
* {user}`MathItYT` made their first contribution in {pr}`3516`
* {user}`doaamuham` made their first contribution in {pr}`3499`
* {user}`RobinPH` made their first contribution in {pr}`3428`
* {user}`pauluhlenbruck` made their first contribution in {pr}`3513`
* {user}`yashm277` made their first contribution in {pr}`3436`
* {user}`TheMathematicFanatic` made their first contribution in {pr}`3408`
* {user}`vvolhejn` made their first contribution in {pr}`3561`
* {user}`hydromelvictor` made their first contribution in {pr}`3574`
* {user}`dandavison` made their first contribution in {pr}`3568`
* {user}`Gixtox` made their first contribution in {pr}`3528`
* {user}`staghado` made their first contribution in {pr}`3429`
* {user}`biinnnggggg` made their first contribution in {pr}`3692`
* {user}`HairlessVillager` made their first contribution in {pr}`3674`
* {user}`SirJamesClarkMaxwell` made their first contribution in {pr}`3675`
* {user}`danielzsh` made their first contribution in {pr}`3703`
* {user}`TheCrowned` made their first contribution in {pr}`3721`
* {user}`MontroyJosh` made their first contribution in {pr}`3718`
* {user}`amrear` made their first contribution in {pr}`3730`
**Full Changelog**: https://github.com/ManimCommunity/manim/compare/v0.18.0.post0...v0.18.1

View file

@ -0,0 +1,197 @@
---
short-title: v0.19.1
description: Changelog for Manim v0.19.1
---
# v0.19.1
Date
: December 01, 2025
## What's Changed
### New Features
* Introduce seed in `random_color` method to produce colors deterministically by {user}`ishu9bansal` in {pr}`4265`
* Add support for arithmetic operators `//`, `%`, `*`, `**` and `/` on `ValueTracker` by {user}`fmuenkel` in {pr}`4351`
* Add `TangentialArc` mobject by {user}`Brainsucker92` in {pr}`4469`
### Fixed Bugs and Enhancements
* Fix environment formatting for Tex() mobject by {user}`fmuenkel` in {pr}`4159`
* Improved consistency of rate_function implementations by {user}`BenKirkels` in {pr}`4144`
* Make new `Code` mobject compatible with OpenGL renderer by {user}`behackl` in {pr}`4164`
* Fix HSL color ordering in ManimColor by {user}`thehugwizard` in {pr}`4202`
* Fix return type of `Polygram.get_vertex_groups()` and rename variables in `.round_corners()` by {user}`chopan050` in {pr}`4063`
* Improve `Mobject.align_data` docstring by {user}`irvanalhaq9` in {pr}`4152`
* Fix :meth:`VMobject.pointwise_become_partial` failing when `vmobject` is `self` by {user}`irvanalhaq9` in {pr}`4193`
* Fix `add_points_as_corners` not connecting single point to existing path by {user}`irvanalhaq9` in {pr}`4219`
* Complete typing for logger_utils.py by {user}`fmuenkel` in {pr}`4134`
* Fix(graph): Allow any Line subclass as edge_type in Graph/DiGraph by {user}`Akshat-Mishra-py` in {pr}`4251`
* Replace exceptions, remove unused parameters, and fix type hints in `Animation`, `ShowPartial`, `Create`, `ShowPassingFlash`, and `DrawBorderThenFill` by {user}`irvanalhaq9` in {pr}`4214`
* Fix: `Axes` submobject colors are not being set properly by {user}`ishu9bansal` in {pr}`4291`
* Refactor `Rotating` and add docstrings to `Mobject.rotate()` and `Rotating` by {user}`irvanalhaq9` in {pr}`4147`
* Fix default config of `manim init project` to use correct `pixel_height` and `pixel_width` by {user}`StevenH34` in {pr}`4213`
* Handle opacity and transparent images by {user}`henrikmidtiby` in {pr}`4313`
* Gracefully fall back when version metadata is missing by {user}`mohiuddin-khan-shiam` in {pr}`4324`
* Fix for issue 4255 - Do not clear points when the number of curves is zero by {user}`henrikmidtiby` in {pr}`4320`
* Use utf-8 encoding to read generated .tex files. by {user}`OliverStrait` in {pr}`4334`
* Add zero to vmobject points to remove negative zeros in `get_mobject_key` by {user}`elshorbagyx` in {pr}`4332`
* Ensure `stroke_width` attribute of `SVGMobject` is not set to `None` by {user}`henrikmidtiby` in {pr}`4319`
* Fix `Prism` incorrectly rendering with `dimensions=[2, 2, 2]` in OpenGL by {user}`ra1u` in {pr}`4003`
* Fix `BraceLabel.change_label()` and document `BraceText` by {user}`henrikmidtiby` in {pr}`4347`
* Include `Text.gradient` in hash to properly regenerate `Text` when its gradient changes by {user}`AbhilashaTandon` in {pr}`4099`
* Fixed surface animations in OpenGL by {user}`nubDotDev` in {pr}`4286`
* Add type hints and support for arithmetic operators `+` and `-` on `ValueTracker` by {user}`fmuenkel` in {pr}`4129`
* Fix duplicate references in `Scene.mobjects` after `ReplacementTransform` with existing target mobject by {user}`irvanalhaq9` in {pr}`4242`
* Optimize `always_redraw()` by reducing `Mobject` copying in `Mobject.become()` by {user}`chopan050` in {pr}`4357`
* Enhance `manim cfg show` output and add info-level logging for config files read by {user}`xnov18` in {pr}`4375`
* Let `Cube` use Bevel type line joints by {user}`nubDotDev` in {pr}`4361`
* Properly define `init_points` methods for use in OpenGL instead of defining `init_points = generate_points` by {user}`chopan050` in {pr}`4360`
* Allow passing a tuple to `buff` in `SurroundingRectangle` to specify buffer in x and y direction independently by {user}`nubDotDev` in {pr}`4390`
* Rewrite `color_gradient` to always return a list of ManimColors by {user}`henrikmidtiby` in {pr}`4380`
* Ensure leading whitespace does not change line height for lines in CodeMobject by {user}`behackl` in {pr}`4392`
* Simplify the function `remove_invisible_chars` in `text_mobject.py` by {user}`henrikmidtiby` in {pr}`4394`
* Fix some config options specified via `--config_file` not being respected properly by {user}`behackl` in {pr}`4401`
* Fix: Correct resolution tuple order to (height, width) by {user}`Nikhil172913832` in {pr}`4440`
* Ensure that start and end points are stored as float values in Line3D by {user}`SirJamesClarkMaxwell` in {pr}`4080`
* OpenGL: Fix iterated nesting in `DecimalNumber.set_value` by {user}`henrikmidtiby` in {pr}`4373`
* Update default resolution in CLI to match Manims 1920x1080 default settings by {user}`SASHAKT1290` in {pr}`4452`
* Better parsing of color styles in CodeMobject by {user}`SirJamesClarkMaxwell` in {pr}`4454`
* Allow selection of all scenes to render using '*' by {user}`NightyStudios` in {pr}`4470`
* Prevent mutation of `about_point` in `apply_points_function_about_point` by {user}`Morkunas` in {pr}`4478`
* Fix behavior of `Mobject.suspend_updating`: when only suspending parent mobject, let children continue updating by {user}`behackl` in {pr}`4402`
* Allow passing a `buff` to `LabeledDot` by {user}`nubDotDev` in {pr}`4403`
* Pass ndarrays to `mapbox_earcut.triangulate_float32()` to fix `TypeError` in `mapbox_earcut==2.0.0` by {user}`GuiCT` in {pr}`4479`
* Fix duplicated arrow tips in DashedVMobject (issue #3220) by {user}`jakekinchen` in {pr}`4484`
### Documentation
* Add docstring to :meth:`.Mobject.get_family` by {user}`irvanalhaq9` in {pr}`4127`
* Fix link formatting and clarify the distinction between Manim versions in index.rst by {user}`irvanalhaq9` in {pr}`4131`
* Add instructions for installing system utilities `cairo` and `pkg-config` via Homebrew on MacOS by {user}`behackl` in {pr}`4146`
* Add missing line break in Code of Conduct's conflict of interest policy by {user}`Hasan-Mesbaul-Ali-Taher` in {pr}`4185`
* Fix links to Pango website by {user}`ragibson` in {pr}`4217`
* Replace poetry with uv in the README by {user}`xinoehp512` in {pr}`4226`
* Improve docstring for `interpolate` method in `Mobject` class by {user}`irvanalhaq9` in {pr}`4149`
* Add docstrings to `Line` and remove `None` handling for `path_arc` parameter by {user}`irvanalhaq9` in {pr}`4223`
* Add docstring to :meth:`Mobject.family_members_with_points` by {user}`irvanalhaq9` in {pr}`4128`
* Update incorrect docstring for :attr:`ManimConfig.gui_location` property by {user}`SAYAN02-DEV` in {pr}`4254`
* Fix formatting of color space documentation by {user}`behackl` in {pr}`4274`
* Enhance and Paraphrase Description of ManimCE in README.md by {user}`irvanalhaq9` in {pr}`4141`
* docs: add explanation about the rate_func in the custom animation by {user}`pedropxoto` in {pr}`4278`
* Fixed artifact in docstring of Animation by {user}`barollet` in {pr}`4283`
* Rename update function `dot_position` to `update_label` in `.add_updater` example by {user}`irvanalhaq9` in {pr}`4196`
* Fix Microsoft typo in `TexFontTemplateLibrary` scene in `example_scenes/advanced_tex_fonts.py` by {user}`alterdim` in {pr}`4305`
* Improved readability, grammar, as well as added docstrings for consistency by {user}`NASAnerd05` in {pr}`4267`
* Add docstrings for `ChangingDecimal` and `ChangeDecimalToValue` by {user}`haveheartt` in {pr}`4346`
* Fix Sphinx exceptions when trying to build documentation via latex / as pdf by {user}`behackl` in {pr}`4370`
* Added license information to documentation landing page by {user}`Nikil-D-Gr8` in {pr}`3986`
* Set the default Python version to 3.13 in the uv installation guide by {user}`henrikmidtiby` in {pr}`4480`
### Maintenance and Testing
* Change project management tool from poetry to uv by {user}`behackl` in {pr}`4138`
* Re-add ffmpeg as dependency within Docker image by {user}`behackl` in {pr}`4150`
* Add tests for Matrix, DecimalMatrix, IntegerMatrix by {user}`pdrzan` in {pr}`4279`
* Add tests for polylabel utility by {user}`giolucasd` in {pr}`4269`
* Add support for `pycodestyle W` rule in Ruff by {user}`KaiqueDultra` in {pr}`4276`
* Fix files with few MyPy typing errors by {user}`henrikmidtiby` in {pr}`4263`
* Explicitly mention all files that mypy should ignore in the `mypy.ini` configuration file by {user}`henrikmidtiby` in {pr}`4306`
* Remove dead code from `scene.py` and `vector_space_scene.py` by {user}`henrikmidtiby` in {pr}`4310`
* Add type annotations to `scene.py` and `vector_space_scene.py` by {user}`henrikmidtiby` in {pr}`4260`
* Replace setup-texlive-action in CI workflow by {user}`behackl` in {pr}`4326`
* Adding type annotations to polyhedra.py and matrix.py by {user}`henrikmidtiby` in {pr}`4322`
* Handling typing errors in text/numbers.py by {user}`henrikmidtiby` in {pr}`4317`
* Move `configure_pygui` into a `Scene` method and remove `manim.gui` by {user}`chopan050` in {pr}`4314`
* Add typing annotations to svg_mobject.py by {user}`henrikmidtiby` in {pr}`4318`
* Add type annotations to `mobject/svg/brace.py` and default to `label_constructor=Text` in `BraceText` by {user}`henrikmidtiby` in {pr}`4309`
* Add classes `MethodWithArgs`, `SceneInteractContinue` and `SceneInteractRerun` inside new module `manim.data_structures` by {user}`chopan050` in {pr}`4315`
* Fix typo in import of OpenGLCamera in `utils/hashing.py` by {user}`fmuenkel` in {pr}`4352`
* Add type annotations to `manim/renderer/shader.py` by {user}`henrikmidtiby` in {pr}`4350`
* Add type annotations to `tex_mobject.py` by {user}`henrikmidtiby` in {pr}`4355`
* Add type annotations to `three_d_camera.py` by {user}`henrikmidtiby` in {pr}`4356`
* Revert change of default value for tex_environment by {user}`henrikmidtiby` in {pr}`4358`
* Add type hints to `scene_file_writer.py`, `section.py`, and `zoomed_scene.py` by {user}`fmuenkel` in {pr}`4133`
* Add type annotations for most of `camera` and `mobject.graphing` by {user}`henrikmidtiby` in {pr}`4125`
* Add `VectorNDLike` type aliases by {user}`chopan050` in {pr}`4068`
* Add type annotations to `dot_cloud.py`, `vectorized_mobject_rendering.py` and `opengl_three_dimensions.py` by {user}`henrikmidtiby` in {pr}`4359`
* Add type annotations to `indication.py` by {user}`henrikmidtiby` in {pr}`4367`
* Add type annotations to `composition.py` by {user}`henrikmidtiby` in {pr}`4366`
* Add type annotations to `growing.py` by {user}`henrikmidtiby` in {pr}`4368`
* Add type annotations to `movement.py` by {user}`henrikmidtiby` in {pr}`4371`
* Exclude check for cyclic imports by CodeQL by {user}`behackl` in {pr}`4384`
* Refactor imports from `collections.abc`, `typing` and `typing_extensions` for Python 3.9 by {user}`chopan050` in {pr}`4353`
* Add type annotations to `opengl_renderer_window.py` by {user}`fmuenkel` in {pr}`4363`
* Rename `SceneFileWriter.save_final_image()` to `save_image()` by {user}`fmuenkel` in {pr}`4378`
* Add type annotations to `text_mobject.py` by {user}`henrikmidtiby` in {pr}`4381`
* Rename types like `RGBA_Array_Float` to `FloatRGBA` and add types like `FloatRGBA_Array` by {user}`chopan050` in {pr}`4386`
* Add type annotations to `opengl_geometry.py` by {user}`henrikmidtiby` in {pr}`4396`
* Add type annotations to `moving_camera.py` by {user}`henrikmidtiby` in {pr}`4397`
* Add type annotations to `opengl_mobject.py` by {user}`RBerga06` in {pr}`4398`
* Fix failing pre-commit tests by {user}`cclauss` in {pr}`4434`
* Add type annotations to `cairo_renderer.py` by {user}`fmuenkel` in {pr}`4393`
* Fix type errors and add typings for `Mobject.apply_function()`, its derivatives, and other utility functions by {user}`godalming123` in {pr}`4228`
* Bump macOS image from deprecated macos-13 to macos-15-intel by {user}`chopan050` in {pr}`4481`
* Prepare new release `v0.19.1` and bump minimum required Python version to 3.10 by {user}`behackl` in {pr}`4490`
### Dependency Version Changes
* Bump typing extensions minimum version by {user}`JasonGrace2282` in {pr}`4121`
* [pre-commit.ci] pre-commit autoupdate by {user}`pre-commit-ci`[bot] in {pr}`4122`
* [pre-commit.ci] pre-commit autoupdate by {user}`pre-commit-ci`[bot] in {pr}`4140`
* [pre-commit.ci] pre-commit autoupdate by {user}`pre-commit-ci`[bot] in {pr}`4148`
* [pre-commit.ci] pre-commit autoupdate by {user}`pre-commit-ci`[bot] in {pr}`4181`
* Bump astral-sh/setup-uv from 5 to 6 by {user}`dependabot`[bot] in {pr}`4234`
* [pre-commit.ci] pre-commit autoupdate by {user}`pre-commit-ci`[bot] in {pr}`4204`
* [pre-commit.ci] pre-commit autoupdate by {user}`pre-commit-ci`[bot] in {pr}`4391`
* [pre-commit.ci] pre-commit autoupdate by {user}`pre-commit-ci`[bot] in {pr}`4405`
* Bump actions/setup-python from 5 to 6 by {user}`dependabot`[bot] in {pr}`4433`
* Bump actions/checkout from 4 to 5 by {user}`dependabot`[bot] in {pr}`4418`
* [pre-commit.ci] pre-commit autoupdate by {user}`pre-commit-ci`[bot] in {pr}`4409`
* [pre-commit.ci] pre-commit autoupdate by {user}`pre-commit-ci`[bot] in {pr}`4460`
* [pre-commit.ci] pre-commit autoupdate by {user}`pre-commit-ci`[bot] in {pr}`4467`
* Bump github/codeql-action from 3 to 4 by {user}`dependabot`[bot] in {pr}`4466`
* Bump astral-sh/setup-uv from 6 to 7 by {user}`dependabot`[bot] in {pr}`4465`
* Bump actions/upload-artifact from 4 to 5 by {user}`dependabot`[bot] in {pr}`4464`
## New Contributors
* {user}`BenKirkels` made their first contribution in {pr}`4144`
* {user}`Hasan-Mesbaul-Ali-Taher` made their first contribution in {pr}`4185`
* {user}`ragibson` made their first contribution in {pr}`4217`
* {user}`thehugwizard` made their first contribution in {pr}`4202`
* {user}`xinoehp512` made their first contribution in {pr}`4226`
* {user}`SAYAN02-DEV` made their first contribution in {pr}`4254`
* {user}`Akshat-Mishra-py` made their first contribution in {pr}`4251`
* {user}`pdrzan` made their first contribution in {pr}`4279`
* {user}`pedropxoto` made their first contribution in {pr}`4278`
* {user}`giolucasd` made their first contribution in {pr}`4269`
* {user}`KaiqueDultra` made their first contribution in {pr}`4276`
* {user}`ishu9bansal` made their first contribution in {pr}`4291`
* {user}`StevenH34` made their first contribution in {pr}`4213`
* {user}`alterdim` made their first contribution in {pr}`4305`
* {user}`mohiuddin-khan-shiam` made their first contribution in {pr}`4324`
* {user}`elshorbagyx` made their first contribution in {pr}`4332`
* {user}`NASAnerd05` made their first contribution in {pr}`4267`
* {user}`ra1u` made their first contribution in {pr}`4003`
* {user}`AbhilashaTandon` made their first contribution in {pr}`4099`
* {user}`nubDotDev` made their first contribution in {pr}`4286`
* {user}`haveheartt` made their first contribution in {pr}`4346`
* {user}`xnov18` made their first contribution in {pr}`4375`
* {user}`Nikil-D-Gr8` made their first contribution in {pr}`3986`
* {user}`RBerga06` made their first contribution in {pr}`4398`
* {user}`Nikhil172913832` made their first contribution in {pr}`4440`
* {user}`SASHAKT1290` made their first contribution in {pr}`4452`
* {user}`Brainsucker92` made their first contribution in {pr}`4469`
* {user}`NightyStudios` made their first contribution in {pr}`4470`
* {user}`Morkunas` made their first contribution in {pr}`4478`
* {user}`GuiCT` made their first contribution in {pr}`4479`
* {user}`godalming123` made their first contribution in {pr}`4228`
* {user}`jakekinchen` made their first contribution in {pr}`4484`
**Full Changelog**: https://github.com/ManimCommunity/manim/compare/v0.19.0...v0.19.1

View file

@ -0,0 +1,41 @@
---
short-title: v0.19.2
description: Changelog for Manim v0.19.2
---
# v0.19.2
Date
: January 17, 2026
## What's Changed
### Highlights 🌟
* Add support for Python 3.14, bump minimum Python to 3.11 and av to 14.0.1 by {user}`behackl` in {pr}`4385`
### Bug Fixes 🐛
* Fix argument passed to `get_hash_from_play_call` in hashing by {user}`judenimo` in {pr}`4524`
* Fix incorrect `Circle.point_at_angle` calculation by {user}`Swarnlataaa` in {pr}`4438`
### Testing 🧪
* Test on Apple Silicon ARM64 by {user}`cclauss` in {pr}`4496`
### Code Quality & Refactoring 🧹
* Add ruff rules PERF for performance by {user}`cclauss` in {pr}`4492`
* Remove deprecation warning from pytest "np.trapz" -> "np.trapezoid" by {user}`henrikmidtiby` in {pr}`4513`
* Bump Python target versions of both mypy and ruff by {user}`behackl` in {pr}`4520`
* Replace legacy numpy usage -- ruff rule NPY002 by {user}`cclauss` in {pr}`4516`
* Add `.github/release.yml` for improved classifications in automatically generated changelogs by {user}`behackl` in {pr}`4526`
* Check and bump lower version requirements for dependencies by {user}`henrikmidtiby` in {pr}`4529`
### Type Hints 📝
* Add type annotations to `three_dimensions.py` by {user}`henrikmidtiby` in {pr}`4497`
### Other Changes
* Prepare new release `v0.19.2` by {user}`behackl` in {pr}`4528`
## New Contributors
* {user}`judenimo` made their first contribution in {pr}`4524`
* {user}`Swarnlataaa` made their first contribution in {pr}`4438`
**Full Changelog**: https://github.com/ManimCommunity/manim/compare/v0.19.1...v0.19.2

View file

@ -0,0 +1,86 @@
---
short-title: v0.20.0
description: Changelog for v0.20.0
---
# v0.20.0
Date
: February 20, 2026
## What's Changed
### Breaking Changes 🚨
* Fix `ImageMobject` 3D rotation/flipping and remove resampling algorithms `lanczos` (`antialias`), `box` and `hamming` by {user}`chopan050` in {pr}`4266`
* Fix `YELLOW_C` and add `PURE_CYAN`, `PURE_MAGENTA` and `PURE_YELLOW` by {user}`chopan050` in {pr}`4562`
### Highlights 🌟
* Rewrite MathTex to make it more robust regarding splitting by {user}`henrikmidtiby` in {pr}`4515`
The MathTex implementation has been updated to make it more robust and fix a number of issues.
A beneficial side effect is that named groups in svg files can now be accessed through SVGMobject.
* Add new Animation Builder `Mobject.always` by {user}`JasonGrace2282` in {pr}`4594`
This new feature is a convenience wrapper around `add_updater` that allows adding
updaters to a mobject in an intuitive and easy-to-read way. Example usage in a scene:
```python
d = Dot()
s = Square()
d.always.next_to(s, UP)
self.add(s, d)
self.play(s.animate.to_edge(LEFT))
```
### New Features ✨
* Add a `seed` config option + `--seed` CLI option for reproducible randomness in rendered scenes by {user}`arnaud-ma` in {pr}`4532`
### Enhancements 🚀
* Enable `strict=True` for `zip()` where safe by {user}`Oll-iver` in {pr}`4547`
### Bug Fixes 🐛
* using `color` instead of `fill_color` with MathTeX for node labels by {user}`Schefflera-Arboricola` in {pr}`4501`
* fix: infinite recursion caused by accessing color of a highlighted Ta… by {user}`BHearron` in {pr}`4435`
* Prevent potential `UnboundLocalError` in `PolarPlane` by {user}`RinZ27` in {pr}`4557`
* Fixed division by 0 in `turn_animation_into_updater` by {user}`SoldierSacha` in {pr}`4567`
* Fix TOCTOU Race Conditions when creating directories by {user}`SoldierSacha` in {pr}`4587`
* Resolve more race conditions potentially happening during directory creation by {user}`SoldierSacha` in {pr}`4589`
* Fix `c2p`/`coords_to_point` method call with single flat list or 1D array input by {user}`danielalanbates` in {pr}`4596`
### Documentation 📚
* Enable rendered documentation of `RandomColorGenerator` by {user}`arnaud-ma` in {pr}`4533`
* Remove pin to Python 3.13 in installation docs by {user}`chopan050` in {pr}`4534`
* Fix broken aquabeam OpenGL link using Wayback Machine by {user}`behackl` in {pr}`4545`
* Add type annotations and docstrings in `opengl_renderer.py` by {user}`arnaud-ma` in {pr}`4537`
* docs: improve `TransformFromCopy` docstring by {user}`GoThrones` in {pr}`4597`
### Infrastructure & Build 🔨
* Install missing dependencies in release pipeline by {user}`behackl` in {pr}`4531`
### Code Quality & Refactoring 🧹
* Rework and consolidate release changelog script, add previously skipped changelog entries by {user}`behackl` in {pr}`4568`
* Remove `__future__.annotations` from required imports by {user}`JasonGrace2282` in {pr}`4571`
* Cleaned up `mypy.ini` by {user}`henrikmidtiby` in {pr}`4584`
* Add `py.typed` to declare manim as having type hints by {user}`Timmmm` in {pr}`4553`
* Fix assertion in `ImageMobjectFromCamera.interpolate_color()` by {user}`chopan050` in {pr}`4593`
* Reduce dependency on scipy - replace `scipy.special.comb` with `math.comb` by {user}`fmuenkel` in {pr}`4598`
### Type Hints 📝
* Add type annotations to `rotation.py` by {user}`fmuenkel` in {pr}`4535`
* Add type annotations to `opengl_compatibility.py` by {user}`fmuenkel` in {pr}`4585`
* Add type annotations to `image_mobject.py` by {user}`henrikmidtiby` in {pr}`4458`
* Add type annotations to `opengl_image_mobject.py` by {user}`fmuenkel` in {pr}`4536`
* Add type annotations to `point_cloud_mobject.py` by {user}`fmuenkel` in {pr}`4586`
## New Contributors
* {user}`arnaud-ma` made their first contribution in {pr}`4533`
* {user}`Schefflera-Arboricola` made their first contribution in {pr}`4501`
* {user}`BHearron` made their first contribution in {pr}`4435`
* {user}`RinZ27` made their first contribution in {pr}`4557`
* {user}`SoldierSacha` made their first contribution in {pr}`4567`
* {user}`Oll-iver` made their first contribution in {pr}`4547`
* {user}`GoThrones` made their first contribution in {pr}`4597`
* {user}`danielalanbates` made their first contribution in {pr}`4596`
**Full Changelog**: [Compare view](https://github.com/ManimCommunity/manim/compare/v0.19.2...v0.20.0)

View file

@ -58,7 +58,7 @@ extensions = [
# Automatically generate stub pages when using the .. autosummary directive
autosummary_generate = True
myst_enable_extensions = ["colon_fence", "amsmath"]
myst_enable_extensions = ["colon_fence", "amsmath", "deflist"]
# redirects (for moved / deleted pages)
redirects = {
@ -161,7 +161,8 @@ latex_engine = "lualatex"
# external links
extlinks = {
"issue": ("https://github.com/ManimCommunity/manim/issues/%s", "#%s"),
"pr": ("https://github.com/ManimCommunity/manim/pull/%s", "#%s"),
"pr": ("https://github.com/ManimCommunity/manim/pull/%s", "PR #%s"),
"user": ("https://github.com/%s", "@%s"),
}
# opengraph settings

View file

@ -8,7 +8,7 @@ or specific OpenGL classes like `OpenGLSurface`, but documentation for some of
them is available in form of docstrings
[in the source code](https://github.com/ManimCommunity/manim/tree/main/manim/mobject/opengl).
Furthermore, [this user guide by *aquabeam*](https://www.aquabeam.me/manim/opengl_guide/)
Furthermore, [this user guide by *aquabeam*](https://web.archive.org/web/20250708135737/https://www.aquabeam.me/manim/opengl_guide/)
can be helpful to get started using the OpenGL renderer.
---

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:
@ -424,25 +413,33 @@ may be expected. To color only ``x`` yellow, we have to do the following:
class CorrectLaTeXSubstringColoring(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",
r"e^{x} = x^0 + x^1 + \frac{1}{2} x^2 + \frac{1}{6} x^3 + \cdots + \frac{1}{n!} x^n + \cdots",
substrings_to_isolate="x"
)
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

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

@ -186,7 +186,7 @@ class AnimationGroup(Animation):
sub_alphas[(sub_alphas > 1) | with_zero_run_time] = 1
for anim_to_update, sub_alpha in zip(
to_update["anim"], sub_alphas, strict=False
to_update["anim"], sub_alphas, strict=True
):
anim_to_update.interpolate(sub_alpha)

View file

@ -472,7 +472,7 @@ class SpiralIn(Animation):
def interpolate_mobject(self, alpha: float) -> None:
alpha = self.rate_func(alpha)
for original_shape, shape in zip(self.shapes, self.mobject, strict=False):
for original_shape, shape in zip(self.shapes, self.mobject, strict=True):
shape.restore()
fill_opacity = original_shape.get_fill_opacity()
stroke_opacity = original_shape.get_stroke_opacity()

View file

@ -64,7 +64,7 @@ from ..mobject.mobject import Mobject
from ..mobject.types.vectorized_mobject import VGroup, VMobject
from ..typing import Point3D, Point3DLike, Vector3DLike
from ..utils.bezier import interpolate, inverse_interpolate
from ..utils.color import GREY, YELLOW, ParsableManimColor
from ..utils.color import GREY, PURE_YELLOW, ParsableManimColor
from ..utils.rate_functions import RateFunction, smooth, there_and_back, wiggle
from ..utils.space_ops import normalize
@ -89,7 +89,7 @@ class FocusOn(Transform):
class UsingFocusOn(Scene):
def construct(self):
dot = Dot(color=YELLOW).shift(DOWN)
dot = Dot(color=PURE_YELLOW).shift(DOWN)
self.add(Tex("Focusing on the dot below:"), dot)
self.play(FocusOn(dot))
self.wait()
@ -153,7 +153,7 @@ class Indicate(Transform):
self,
mobject: Mobject,
scale_factor: float = 1.2,
color: ParsableManimColor = YELLOW,
color: ParsableManimColor = PURE_YELLOW,
rate_func: RateFunction = there_and_back,
**kwargs: Any,
):
@ -198,7 +198,7 @@ class Flash(AnimationGroup):
class UsingFlash(Scene):
def construct(self):
dot = Dot(color=YELLOW).shift(DOWN)
dot = Dot(color=PURE_YELLOW).shift(DOWN)
self.add(Tex("Flash the dot below:"), dot)
self.play(Flash(dot))
self.wait()
@ -226,7 +226,7 @@ class Flash(AnimationGroup):
num_lines: int = 12,
flash_radius: float = 0.1,
line_stroke_width: int = 3,
color: ParsableManimColor = YELLOW,
color: ParsableManimColor = PURE_YELLOW,
time_width: float = 1,
run_time: float = 1.0,
**kwargs: Any,
@ -349,7 +349,7 @@ class ShowPassingFlashWithThinningStrokeWidth(AnimationGroup):
for stroke_width, time_width in zip(
np.linspace(0, max_stroke_width, self.n_segments),
np.linspace(max_time_width, 0, self.n_segments),
strict=False,
strict=True,
)
),
)
@ -618,7 +618,7 @@ class Circumscribe(Succession):
fade_out: bool = False,
time_width: float = 0.3,
buff: float = SMALL_BUFF,
color: ParsableManimColor = YELLOW,
color: ParsableManimColor = PURE_YELLOW,
run_time: float = 1,
stroke_width: float = DEFAULT_STROKE_WIDTH,
**kwargs: Any,

View file

@ -235,8 +235,8 @@ class Transform(Animation):
self.target_copy,
]
if config.renderer == RendererType.OPENGL:
return zip(*(mob.get_family() for mob in mobs), strict=False)
return zip(*(mob.family_members_with_points() for mob in mobs), strict=False)
return zip(*(mob.get_family() for mob in mobs), strict=True)
return zip(*(mob.family_members_with_points() for mob in mobs), strict=True)
def interpolate_submobject(
self,
@ -304,7 +304,7 @@ class ReplacementTransform(Transform):
class TransformFromCopy(Transform):
"""Performs a reversed Transform"""
"""Preserves a copy of the original VMobject and transforms only it's copy to the target VMobject"""
def __init__(self, mobject: Mobject, target_mobject: Mobject, **kwargs) -> None:
super().__init__(target_mobject, mobject, **kwargs)
@ -741,7 +741,7 @@ class CyclicReplace(Transform):
def create_target(self) -> Group:
target = self.group.copy()
cycled_targets = [target[-1], *target[:-1]]
for m1, m2 in zip(cycled_targets, self.group, strict=False):
for m1, m2 in zip(cycled_targets, self.group, strict=True):
m1.move_to(m2)
return target
@ -929,5 +929,5 @@ class FadeTransformPieces(FadeTransform):
"""Replaces the source submobjects by the target submobjects and sets
the opacity to 0.
"""
for sm0, sm1 in zip(source.get_family(), target.get_family(), strict=False):
for sm0, sm1 in zip(source.get_family(), target.get_family(), strict=True):
super().ghost_to(sm0, sm1)

View file

@ -214,6 +214,16 @@ def turn_animation_into_updater(
def update(m: Mobject, dt: float):
if animation.total_time >= 0:
run_time = animation.get_run_time()
# handle zero/negative runtime safely
if run_time <= 0:
# instantly snap to final state once, then remove updater
animation.interpolate(1)
animation.update_mobjects(dt)
animation.finish()
m.remove_updater(update)
return
time_ratio = animation.total_time / run_time
if cycle:
alpha = time_ratio % 1

View file

@ -14,32 +14,32 @@ from typing import TYPE_CHECKING, Any, Self
import cairo
import numpy as np
import numpy.typing as npt
from PIL import Image
from scipy.spatial.distance import pdist
from manim.typing import (
FloatRGBA_Array,
FloatRGBALike_Array,
ManimInt,
PixelArray,
Point3D,
Point3D_Array,
)
from .. import config, logger
from ..constants import *
from ..mobject.mobject import Mobject
from ..mobject.types.point_cloud_mobject import PMobject
from ..mobject.types.vectorized_mobject import VMobject
from ..utils.color import ManimColor, ParsableManimColor, color_to_int_rgba
from ..utils.family import extract_mobject_family_members
from ..utils.images import get_full_raster_image_path
from ..utils.iterables import list_difference_update
from ..utils.space_ops import angle_of_vector
from manim._config import config, logger
from manim.constants import *
from manim.mobject.mobject import Mobject
from manim.mobject.types.point_cloud_mobject import PMobject
from manim.mobject.types.vectorized_mobject import VMobject
from manim.utils.color import ManimColor, ParsableManimColor, color_to_int_rgba
from manim.utils.family import extract_mobject_family_members
from manim.utils.images import get_full_raster_image_path
from manim.utils.iterables import list_difference_update
from manim.utils.space_ops import cross2d
if TYPE_CHECKING:
from ..mobject.types.image_mobject import AbstractImageMobject
import numpy.typing as npt
from manim.mobject.types.image_mobject import AbstractImageMobject
from manim.typing import (
FloatRGBA_Array,
FloatRGBALike_Array,
ManimFloat,
ManimInt,
PixelArray,
Point3D,
Point3D_Array,
)
LINE_JOIN_MAP = {
@ -756,9 +756,8 @@ class Camera:
points = vmobject.get_gradient_start_and_end_points()
points = self.transform_points_pre_display(vmobject, points)
pat = cairo.LinearGradient(*it.chain(*(point[:2] for point in points)))
step = 1.0 / (len(rgbas) - 1)
offsets = np.arange(0, 1 + step, step)
for rgba, offset in zip(rgbas, offsets, strict=False):
offsets = np.linspace(0, 1, len(rgbas))
for rgba, offset in zip(rgbas, offsets, strict=True):
pat.add_color_stop_rgba(offset, *rgba[2::-1], rgba[3])
ctx.set_source(pat)
return self
@ -998,60 +997,113 @@ class Camera:
def display_image_mobject(
self, image_mobject: AbstractImageMobject, pixel_array: np.ndarray
) -> None:
"""Displays an ImageMobject by changing the pixel_array suitably.
"""Display an :class:`~.ImageMobject` by changing the ``pixel_array`` suitably.
Parameters
----------
image_mobject
The imageMobject to display
The :class:`~.ImageMobject` to display.
pixel_array
The Pixel array to put the imagemobject in.
The pixel array to put the :class:`~.ImageMobject` in.
"""
corner_coords = self.points_to_pixel_coords(image_mobject, image_mobject.points)
ul_coords, ur_coords, dl_coords, _ = corner_coords
right_vect = ur_coords - ul_coords
down_vect = dl_coords - ul_coords
center_coords = ul_coords + (right_vect + down_vect) / 2
sub_image = Image.fromarray(image_mobject.get_pixel_array(), mode="RGBA")
original_coords = np.array(
[
[0, 0],
[sub_image.width, 0],
[0, sub_image.height],
[sub_image.width, sub_image.height],
]
)
target_coords = self.points_to_subpixel_coords(
image_mobject, image_mobject.points
)
int_target_coords = target_coords.astype(np.int64)
# Reshape
pixel_width = max(int(pdist([ul_coords, ur_coords]).item()), 1)
pixel_height = max(int(pdist([ul_coords, dl_coords]).item()), 1)
sub_image = sub_image.resize(
(pixel_width, pixel_height),
# Temporarily translate target coords to upper left corner to calculate the
# smallest possible size for the target image.
shift_vector = np.array(
[
min(*[x for x, y in int_target_coords]),
min(*[y for x, y in int_target_coords]),
]
)
target_coords -= shift_vector
int_target_coords -= shift_vector
target_size = (
max(*[x for x, y in int_target_coords]),
max(*[y for x, y in int_target_coords]),
)
# Check that the quadrilateral of the transformed image can actually contain any
# pixels by checking that its height from the longest side is longer than 0.5 pixels.
# If it's not, do not render the image. Otherwise, the perspective transform
# coefficients below might have broken values due to the extreme distortion (for
# example, when the image is perpendicular to the camera).
ordered_vertices = [target_coords[i] for i in (0, 1, 3, 2)]
sides = [ordered_vertices[(i + 1) % 4] - ordered_vertices[i] for i in range(4)]
side_lengths_in_pixels = np.linalg.norm(sides, axis=1)
longest_side_index = np.argmax(side_lengths_in_pixels)
longest_side = sides[longest_side_index]
longest_side_length_in_pixels = side_lengths_in_pixels[longest_side_index]
if longest_side_length_in_pixels == 0:
return
previous_side = sides[(longest_side_index - 1) % 4]
next_side = sides[(longest_side_index - 1) % 4]
# height = area / base
h1 = abs(cross2d(longest_side, previous_side)) / longest_side_length_in_pixels
h2 = abs(cross2d(longest_side, next_side)) / longest_side_length_in_pixels
height_from_longest_side_in_pixels = max(h1, h2)
if height_from_longest_side_in_pixels < 0.5:
return
# Use PIL.Image.Image.transform() to apply a perspective transform to the image.
# The transform coefficients must be calculated. The following is adapted from:
# https://pc-pillow.readthedocs.io/en/latest/Image_class/Image_transform.html#transform-perspective-coefficients
# https://stackoverflow.com/questions/14177744/how-does-perspective-transformation-work-in-pil
# The derivation can be found here:
# https://web.archive.org/web/20150222120106/xenia.media.mit.edu/~cwren/interpolator/
homography_matrix = []
for (x, y), (X, Y) in zip(target_coords, original_coords, strict=True):
homography_matrix.append([x, y, 1, 0, 0, 0, -X * x, -X * y])
homography_matrix.append([0, 0, 0, x, y, 1, -Y * x, -Y * y])
A = np.array(homography_matrix, dtype=np.float64)
b = original_coords.reshape(8).astype(np.float64)
try:
transform_coefficients = np.linalg.solve(A, b)
except np.linalg.LinAlgError:
# The matrix A might be singular if three points are collinear.
# In this case, do nothing and return.
return
sub_image = sub_image.transform(
size=target_size, # Use the smallest possible size for speed.
method=Image.Transform.PERSPECTIVE,
data=transform_coefficients,
resample=image_mobject.resampling_algorithm,
)
# Rotate
angle = angle_of_vector(right_vect)
adjusted_angle = -int(360 * angle / TAU)
if adjusted_angle != 0:
sub_image = sub_image.rotate(
adjusted_angle,
resample=image_mobject.resampling_algorithm,
expand=1,
)
# TODO, there is no accounting for a shear...
# Paste into an image as large as the camera's pixel array
# Paste into an image as large as the camera's pixel array.
full_image = Image.fromarray(
np.zeros((self.pixel_height, self.pixel_width)),
mode="RGBA",
)
new_ul_coords = center_coords - np.array(sub_image.size) / 2
new_ul_coords = new_ul_coords.astype(int)
full_image.paste(
sub_image,
box=(
new_ul_coords[0],
new_ul_coords[1],
new_ul_coords[0] + sub_image.size[0],
new_ul_coords[1] + sub_image.size[1],
shift_vector[0],
shift_vector[1],
shift_vector[0] + target_size[0],
shift_vector[1] + target_size[1],
),
)
# Paint on top of existing pixel array
# Paint on top of existing pixel array.
self.overlay_PIL_image(pixel_array, full_image)
def overlay_rgba_array(
@ -1127,11 +1179,13 @@ class Camera:
points = np.zeros((1, 3))
return points
def points_to_pixel_coords(
def points_to_subpixel_coords(
self,
mobject: Mobject,
points: Point3D_Array,
) -> npt.NDArray[ManimInt]: # TODO: Write more detailed docstrings for this method.
) -> npt.NDArray[
ManimFloat
]: # TODO: Write more detailed docstrings for this method.
points = self.transform_points_pre_display(mobject, points)
shifted_points = points - self.frame_center
@ -1149,7 +1203,14 @@ class Camera:
result[:, 0] = shifted_points[:, 0] * width_mult + width_add
result[:, 1] = shifted_points[:, 1] * height_mult + height_add
return result.astype("int")
return result
def points_to_pixel_coords(
self,
mobject: Mobject,
points: Point3D_Array,
) -> npt.NDArray[ManimInt]: # TODO: Write more detailed docstrings for this method.
return self.points_to_subpixel_coords(mobject, points).astype(np.int64)
def on_screen_pixels(self, pixel_coords: np.ndarray) -> PixelArray:
"""Returns array of pixels that are on the screen from a given

View file

@ -308,7 +308,7 @@ Are you sure you want to continue? (y/n)""",
if proceed:
if not directory_path.is_dir():
console.print(f"Creating folder: {directory}.", style="red bold")
directory_path.mkdir(parents=True)
directory_path.mkdir(parents=True, exist_ok=True)
ctx.invoke(write)
from_path = Path.cwd() / "manim.cfg"

View file

@ -111,14 +111,10 @@ ULTRAHEAVY = "ULTRAHEAVY"
RESAMPLING_ALGORITHMS = {
"nearest": Resampling.NEAREST,
"none": Resampling.NEAREST,
"lanczos": Resampling.LANCZOS,
"antialias": Resampling.LANCZOS,
"bilinear": Resampling.BILINEAR,
"linear": Resampling.BILINEAR,
"bicubic": Resampling.BICUBIC,
"cubic": Resampling.BICUBIC,
"box": Resampling.BOX,
"hamming": Resampling.HAMMING,
}
# Geometry: directions

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:
@ -1231,7 +1233,7 @@ class ArcPolygon(VMobject, metaclass=ConvertToOpenGL):
arcs = [
ArcBetweenPoints(*pair, **conf)
for (pair, conf) in zip(point_pairs, all_arc_configs, strict=False)
for (pair, conf) in zip(point_pairs, all_arc_configs, strict=True)
]
super().__init__(**kwargs)

View file

@ -152,7 +152,7 @@ class Polygram(VMobject, metaclass=ConvertToOpenGL):
# times, then .get_vertex_groups() splits it into N vertex groups.
group = []
for start, end in zip(
self.get_start_anchors(), self.get_end_anchors(), strict=False
self.get_start_anchors(), self.get_end_anchors(), strict=True
):
group.append(start)
@ -240,7 +240,7 @@ class Polygram(VMobject, metaclass=ConvertToOpenGL):
radius_list = radius * ceil(len(vertex_group) / len(radius))
for current_radius, (v1, v2, v3) in zip(
radius_list, adjacent_n_tuples(vertex_group, 3), strict=False
radius_list, adjacent_n_tuples(vertex_group, 3), strict=True
):
vect1 = v2 - v1
vect2 = v3 - v2
@ -552,7 +552,7 @@ class Star(Polygon):
)
vertices: list[npt.NDArray] = []
for pair in zip(outer_vertices, inner_vertices, strict=False):
for pair in zip(outer_vertices, inner_vertices, strict=True):
vertices.extend(pair)
super().__init__(*vertices, **kwargs)

View file

@ -20,7 +20,7 @@ from manim.mobject.geometry.polygram import RoundedRectangle
from manim.mobject.mobject import Mobject
from manim.mobject.opengl.opengl_mobject import OpenGLMobject
from manim.mobject.types.vectorized_mobject import VGroup
from manim.utils.color import BLACK, RED, YELLOW, ManimColor, ParsableManimColor
from manim.utils.color import BLACK, PURE_YELLOW, RED, ParsableManimColor
class SurroundingRectangle(RoundedRectangle):
@ -50,7 +50,7 @@ class SurroundingRectangle(RoundedRectangle):
def __init__(
self,
*mobjects: Mobject,
color: ParsableManimColor = YELLOW,
color: ParsableManimColor = PURE_YELLOW,
buff: float | tuple[float, float] = SMALL_BUFF,
corner_radius: float = 0.0,
**kwargs: Any,
@ -149,12 +149,6 @@ class BackgroundRectangle(SurroundingRectangle):
)
return self
def get_fill_color(self) -> ManimColor:
# The type of the color property is set to Any using the property decorator
# vectorized_mobject.py#L571
temp_color: ManimColor = self.color
return temp_color
class Cross(VGroup):
"""Creates a cross.

View file

@ -42,8 +42,8 @@ from manim.utils.color import (
BLUE,
BLUE_D,
GREEN,
PURE_YELLOW,
WHITE,
YELLOW,
ManimColor,
ParsableManimColor,
color_gradient,
@ -448,7 +448,7 @@ class CoordinateSystem:
zip(
tick_range,
axis.scaling.get_custom_labels(tick_range),
strict=False,
strict=True,
)
)
)
@ -1300,7 +1300,7 @@ class CoordinateSystem:
colors = color_gradient(color, len(x_range_array))
for x, color in zip(x_range_array, colors, strict=False):
for x, color in zip(x_range_array, colors, strict=True):
if input_sample_type == "left":
sample_input = x
elif input_sample_type == "right":
@ -1614,7 +1614,7 @@ class CoordinateSystem:
x: float,
graph: ParametricFunction,
dx: float | None = None,
dx_line_color: ParsableManimColor = YELLOW,
dx_line_color: ParsableManimColor = PURE_YELLOW,
dy_line_color: ParsableManimColor | None = None,
dx_label: float | str | None = None,
dy_label: float | str | None = None,
@ -1796,7 +1796,7 @@ class CoordinateSystem:
triangle_size: float = MED_SMALL_BUFF,
triangle_color: ParsableManimColor | None = WHITE,
line_func: type[Line] = Line,
line_color: ParsableManimColor = YELLOW,
line_color: ParsableManimColor = PURE_YELLOW,
) -> VGroup:
"""Creates a labelled triangle marker with a vertical line from the x-axis
to a curve at a given x-value.
@ -2089,6 +2089,10 @@ class Axes(VGroup, CoordinateSystem, metaclass=ConvertToOpenGL):
``ax.coords_to_point( [[x_0, y_0, z_0], [x_1, y_1, z_1]] )``
A single coordinate can also be passed as a flat list or 1D array:
``ax.coords_to_point( [x, y, z] )``
Returns
-------
np.ndarray
@ -2117,6 +2121,10 @@ class Axes(VGroup, CoordinateSystem, metaclass=ConvertToOpenGL):
array([[0. , 0.86, 0.86],
[0.75, 0.75, 0. ],
[0. , 0. , 0. ]])
>>> np.around(ax.coords_to_point([1, 0, 0]), 2)
array([0.86, 0. , 0. ])
>>> np.around(ax.coords_to_point(np.array([1, 0])), 2)
array([0.86, 0. , 0. ])
.. manim:: CoordsToPointExample
:save_last_frame:
@ -2159,6 +2167,10 @@ class Axes(VGroup, CoordinateSystem, metaclass=ConvertToOpenGL):
else:
coords = coords.T
are_coordinates_transposed = True
# If coords is in the format ([x, y, z]) -- a single flat list/array passed as one argument:
elif coords.ndim == 2 and coords.shape[0] == 1:
# Extract the single list so [x, y, z] is treated like c2p(x, y, z).
coords = coords[0]
# Otherwise, coords already looked like (x, y, z) or ([x1 x2 ...], [y1 y2 ...], [z1 z2 ...]),
# so no further processing is needed.
@ -2293,7 +2305,7 @@ class Axes(VGroup, CoordinateSystem, metaclass=ConvertToOpenGL):
x_values: Iterable[float],
y_values: Iterable[float],
z_values: Iterable[float] | None = None,
line_color: ParsableManimColor = YELLOW,
line_color: ParsableManimColor = PURE_YELLOW,
add_vertex_dots: bool = True,
vertex_dot_radius: float = DEFAULT_DOT_RADIUS,
vertex_dot_style: dict[str, Any] | None = None,
@ -2362,7 +2374,7 @@ class Axes(VGroup, CoordinateSystem, metaclass=ConvertToOpenGL):
vertices = [
self.coords_to_point(x, y, z)
for x, y, z in zip(x_values, y_values, z_values, strict=False)
for x, y, z in zip(x_values, y_values, z_values, strict=True)
]
graph.set_points_as_corners(vertices)
line_graph["line_graph"] = graph
@ -3245,6 +3257,7 @@ class PolarPlane(Axes):
}
for i in a_values
]
a_tex = []
if self.azimuth_units == "PI radians" or self.azimuth_units == "TAU radians":
a_tex = [
self.get_radian_label(

View file

@ -22,7 +22,7 @@ if TYPE_CHECKING:
from manim.typing import Point3D, Point3DLike
from manim.utils.color import ParsableManimColor
from manim.utils.color import YELLOW
from manim.utils.color import PURE_YELLOW
class ParametricFunction(VMobject, metaclass=ConvertToOpenGL):
@ -157,7 +157,7 @@ class ParametricFunction(VMobject, metaclass=ConvertToOpenGL):
else:
boundary_times = [self.t_min, self.t_max]
for t1, t2 in zip(boundary_times[0::2], boundary_times[1::2], strict=False):
for t1, t2 in zip(boundary_times[0::2], boundary_times[1::2], strict=True):
t_range = np.array(
[
*self.scaling.function(np.arange(t1, t2, self.t_step)),
@ -217,7 +217,7 @@ class FunctionGraph(ParametricFunction):
self,
function: Callable[[float], Any],
x_range: tuple[float, float] | tuple[float, float, float] | None = None,
color: ParsableManimColor = YELLOW,
color: ParsableManimColor = PURE_YELLOW,
**kwargs: Any,
) -> None:
if x_range is None:

View file

@ -262,7 +262,7 @@ class NumberLine(Line):
zip(
tick_range,
custom_labels,
strict=False,
strict=True,
)
),
)

View file

@ -107,7 +107,7 @@ class SampleSpace(Rectangle):
last_point = self.get_edge_center(-vect)
parts = VGroup()
for factor, color in zip(p_list_complete, colors_in_gradient, strict=False):
for factor, color in zip(p_list_complete, colors_in_gradient, strict=True):
part = SampleSpace()
part.set_fill(color, 1)
part.replace(self, stretch=True)
@ -368,7 +368,7 @@ class BarChart(Axes):
labels = VGroup()
for i, (value, bar_name) in enumerate(
zip(val_range, self.bar_names, strict=False)
zip(val_range, self.bar_names, strict=True)
):
# to accommodate negative bars, the label may need to be
# below or above the x_axis depending on the value of the bar

View file

@ -17,7 +17,7 @@ import warnings
from collections.abc import Callable, Iterable
from functools import partialmethod, reduce
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal
from typing import TYPE_CHECKING, Any
import numpy as np
@ -28,8 +28,8 @@ from .. import config, logger
from ..constants import *
from ..utils.color import (
BLACK,
PURE_YELLOW,
WHITE,
YELLOW_C,
ManimColor,
ParsableManimColor,
color_gradient,
@ -394,6 +394,42 @@ class Mobject:
"""
return _AnimationBuilder(self)
@property
def always(self) -> Self:
"""Call a method on a mobject every frame.
This is syntactic sugar for ``mob.add_updater(lambda m: m.method(*args, **kwargs), call_updater=True)``.
Note that this will call the method immediately. If this behavior is not
desired, you should use :meth:`add_updater` directly.
.. warning::
Chaining of methods is allowed, but each method will be added
as its own updater. If you are chaining methods, make sure they
do not interfere with each other or you may get unexpected results.
.. warning::
:attr:`always` is not compatible with :meth:`.ValueTracker.get_value`, because
the value will be computed once and then never updated again. Use :meth:`add_updater`
if you would like to use a :class:`~.ValueTracker` to update the value.
Example
-------
.. manim:: AlwaysExample
class AlwaysExample(Scene):
def construct(self):
sq = Square().to_edge(LEFT)
t = Text("Hello World!")
t.always.next_to(sq, UP)
self.add(sq, t)
self.play(sq.animate.to_edge(RIGHT))
"""
# 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:
cls = self.__class__
result = cls.__new__(cls)
@ -406,9 +442,10 @@ class Mobject:
def __repr__(self) -> str:
return str(self.name)
def reset_points(self) -> None:
def reset_points(self) -> Self:
"""Sets :attr:`points` to be an empty array."""
self.points = np.zeros((0, self.dim))
return self
def init_colors(self) -> object:
"""Initializes the colors.
@ -815,7 +852,7 @@ class Mobject:
self.scale_to_fit_depth(value)
# Can't be staticmethod because of point_cloud_mobject.py
def get_array_attrs(self) -> list[Literal["points"]]:
def get_array_attrs(self) -> list[str]:
return ["points"]
def apply_over_attr_arrays(self, func: MultiMappingFunction) -> Self:
@ -1965,7 +2002,7 @@ class Mobject:
# Color functions
def set_color(
self, color: ParsableManimColor = YELLOW_C, family: bool = True
self, color: ParsableManimColor = PURE_YELLOW, 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
@ -2016,7 +2053,7 @@ class Mobject:
mobs = self.family_members_with_points()
new_colors = color_gradient(colors, len(mobs))
for mob, color in zip(mobs, new_colors, strict=False):
for mob, color in zip(mobs, new_colors, strict=True):
mob.set_color(color, family=False)
return self
@ -2309,7 +2346,7 @@ class Mobject:
return Group(
*(
template.copy().pointwise_become_partial(self, a1, a2)
for a1, a2 in zip(alphas[:-1], alphas[1:], strict=False)
for a1, a2 in zip(alphas[:-1], alphas[1:], strict=True)
)
)
@ -2502,7 +2539,7 @@ class Mobject:
x = VGroup(s1, s2, s3, s4).set_x(0).arrange(buff=1.0)
self.add(x)
"""
for m1, m2 in zip(self.submobjects, self.submobjects[1:], strict=False):
for m1, m2 in zip(self.submobjects[:-1], self.submobjects[1:], strict=True):
m2.next_to(m1, direction, buff, **kwargs)
if center:
self.center()
@ -2887,7 +2924,7 @@ class Mobject:
if not skip_point_alignment:
self.align_points(mobject)
# Recurse
for m1, m2 in zip(self.submobjects, mobject.submobjects, strict=False):
for m1, m2 in zip(self.submobjects, mobject.submobjects, strict=True):
m1.align_data(m2)
def get_point_mobject(self, center=None):
@ -2957,7 +2994,7 @@ class Mobject:
repeat_indices = (np.arange(target) * curr) // target
split_factors = [sum(repeat_indices == i) for i in range(curr)]
new_submobs = []
for submob, sf in zip(self.submobjects, split_factors, strict=False):
for submob, sf in zip(self.submobjects, split_factors, strict=True):
new_submobs.append(submob)
new_submobs.extend(submob.copy().fade(1) for _ in range(1, sf))
self.submobjects = new_submobs
@ -3169,7 +3206,7 @@ class Mobject:
mobject.move_to(self.get_center())
self.align_data(mobject, skip_point_alignment=True)
for sm1, sm2 in zip(self.get_family(), mobject.get_family(), strict=False):
for sm1, sm2 in zip(self.get_family(), mobject.get_family(), strict=True):
sm1.points = np.array(sm2.points)
sm1.interpolate_color(sm1, sm2, 1)
return self
@ -3339,6 +3376,24 @@ class _AnimationBuilder:
return anim
class _UpdaterBuilder:
"""Syntactic sugar for adding updaters to mobjects."""
def __init__(self, mobject: Mobject):
self._mobject = mobject
def __getattr__(self, name: str, /) -> Callable[..., Self]:
# just return a function that will add the updater
def add_updater(*method_args, **method_kwargs) -> Self:
self._mobject.add_updater(
lambda m: getattr(m, name)(*method_args, **method_kwargs),
call_updater=True,
)
return self
return add_updater
def override_animate(method) -> types.FunctionType:
r"""Decorator for overriding method animations.

View file

@ -9,13 +9,13 @@ import numpy as np
from manim.constants import ORIGIN, RIGHT, UP
from manim.mobject.opengl.opengl_point_cloud_mobject import OpenGLPMobject
from manim.typing import Point3DLike
from manim.utils.color import YELLOW, ParsableManimColor
from manim.utils.color import PURE_YELLOW, ParsableManimColor
class DotCloud(OpenGLPMobject):
def __init__(
self,
color: ParsableManimColor = YELLOW,
color: ParsableManimColor = PURE_YELLOW,
stroke_width: float = 2.0,
radius: float = 2.0,
density: float = 10,

View file

@ -1,6 +1,7 @@
from __future__ import annotations
from abc import ABCMeta
from typing import Any
from manim import config
from manim.mobject.opengl.opengl_mobject import OpenGLMobject
@ -19,13 +20,15 @@ class ConvertToOpenGL(ABCMeta):
on the lowest order inheritance classes such as Mobject and VMobject.
"""
_converted_classes = []
_converted_classes: list[type] = []
def __new__(mcls, name, bases, namespace):
def __new__(
mcls, name: str, bases: tuple[type, ...], namespace: dict[str, Any]
) -> type:
if config.renderer == RendererType.OPENGL:
# Must check class names to prevent
# cyclic importing.
base_names_to_opengl = {
base_names_to_opengl: dict[str, type] = {
"Mobject": OpenGLMobject,
"VMobject": OpenGLVMobject,
"PMobject": OpenGLPMobject,
@ -40,6 +43,6 @@ class ConvertToOpenGL(ABCMeta):
return super().__new__(mcls, name, bases, namespace)
def __init__(cls, name, bases, namespace):
def __init__(cls, name: str, bases: tuple[type, ...], namespace: dict[str, Any]):
super().__init__(name, bases, namespace)
cls._converted_classes.append(cls)

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

@ -5,6 +5,7 @@ __all__ = [
]
from pathlib import Path
from typing import TYPE_CHECKING, Any
import numpy as np
from PIL import Image
@ -13,26 +14,29 @@ from PIL.Image import Resampling
from manim.mobject.opengl.opengl_surface import OpenGLSurface, OpenGLTexturedSurface
from manim.utils.images import get_full_raster_image_path
if TYPE_CHECKING:
import numpy.typing as npt
__all__ = ["OpenGLImageMobject"]
class OpenGLImageMobject(OpenGLTexturedSurface):
def __init__(
self,
filename_or_array: str | Path | np.ndarray,
width: float = None,
height: float = None,
filename_or_array: str | Path | npt.NDArray,
width: float | None = None,
height: float | None = None,
image_mode: str = "RGBA",
resampling_algorithm: int = Resampling.BICUBIC,
resampling_algorithm: Resampling = Resampling.BICUBIC,
opacity: float = 1,
gloss: float = 0,
shadow: float = 0,
**kwargs,
**kwargs: Any,
):
self.image = filename_or_array
self.resampling_algorithm = resampling_algorithm
if isinstance(filename_or_array, np.ndarray):
self.size = self.image.shape[1::-1]
self.size = filename_or_array.shape[1::-1]
elif isinstance(filename_or_array, (str, Path)):
path = get_full_raster_image_path(filename_or_array)
self.size = Image.open(path).size
@ -68,7 +72,7 @@ class OpenGLImageMobject(OpenGLTexturedSurface):
self,
image_file: str | Path | np.ndarray,
image_mode: str,
):
) -> Image.Image:
if isinstance(image_file, (str, Path)):
return super().get_image_from_file(image_file, image_mode)
else:
@ -76,7 +80,7 @@ class OpenGLImageMobject(OpenGLTexturedSurface):
Image.fromarray(image_file.astype("uint8"))
.convert(image_mode)
.resize(
np.array(image_file.shape[:2])
image_file.shape[:2]
* 200, # assumption of 200 ppmu (pixels per manim unit) would suffice
resample=self.resampling_algorithm,
)

View file

@ -1055,7 +1055,7 @@ class OpenGLMobject:
x = OpenGLVGroup(s1, s2, s3, s4).set_x(0).arrange(buff=1.0)
self.add(x)
"""
for m1, m2 in zip(self.submobjects, self.submobjects[1:], strict=False):
for m1, m2 in zip(self.submobjects[:-1], self.submobjects[1:], strict=True):
m2.next_to(m1, direction, **kwargs)
if center:
self.center()
@ -2190,7 +2190,7 @@ class OpenGLMobject:
# Color and opacity
if color is not None and opacity is not None:
rgbas: FloatRGBA_Array = np.array(
[[*rgb, o] for rgb, o in zip(*make_even(rgbs, opacities), strict=False)]
[[*rgb, o] for rgb, o in zip(*make_even(rgbs, opacities), strict=True)]
)
for mob in self.get_family(recurse):
mob.data[name] = rgbas.copy()
@ -2266,7 +2266,7 @@ class OpenGLMobject:
mobs = self.submobjects
new_colors = color_gradient(colors, len(mobs))
for mob, color in zip(mobs, new_colors, strict=False):
for mob, color in zip(mobs, new_colors, strict=True):
mob.set_color(color)
return self
@ -2481,7 +2481,7 @@ class OpenGLMobject:
return OpenGLGroup(
*(
template.copy().pointwise_become_partial(self, a1, a2)
for a1, a2 in zip(alphas[:-1], alphas[1:], strict=False)
for a1, a2 in zip(alphas[:-1], alphas[1:], strict=True)
)
)
@ -2612,7 +2612,7 @@ class OpenGLMobject:
mob1.add_n_more_submobjects(max(0, n2 - n1))
mob2.add_n_more_submobjects(max(0, n1 - n2))
# Recurse
for sm1, sm2 in zip(mob1.submobjects, mob2.submobjects, strict=False):
for sm1, sm2 in zip(mob1.submobjects, mob2.submobjects, strict=True):
sm1.align_family(sm2)
return self
@ -2638,7 +2638,7 @@ class OpenGLMobject:
repeat_indices = (np.arange(target) * curr) // target
split_factors = [(repeat_indices == i).sum() for i in range(curr)]
new_submobs = []
for submob, sf in zip(self.submobjects, split_factors, strict=False):
for submob, sf in zip(self.submobjects, split_factors, strict=True):
new_submobs.append(submob)
for _ in range(1, sf):
new_submob = submob.copy()
@ -2780,7 +2780,7 @@ class OpenGLMobject:
mobject.move_to(self.get_center())
self.align_family(mobject)
for sm1, sm2 in zip(self.get_family(), mobject.get_family(), strict=False):
for sm1, sm2 in zip(self.get_family(), mobject.get_family(), strict=True):
sm1.set_data(sm2.data)
sm1.set_uniforms(sm2.uniforms)
self.refresh_bounding_box(recurse_down=True)

View file

@ -12,8 +12,8 @@ from manim.mobject.opengl.opengl_mobject import OpenGLMobject
from manim.utils.bezier import interpolate
from manim.utils.color import (
BLACK,
PURE_YELLOW,
WHITE,
YELLOW,
ParsableManimColor,
color_gradient,
color_to_rgba,
@ -48,7 +48,7 @@ class OpenGLPMobject(OpenGLMobject):
def __init__(
self,
stroke_width: float = 2.0,
color: ParsableManimColor = YELLOW,
color: ParsableManimColor = PURE_YELLOW,
render_primitive: int = moderngl.POINTS,
**kwargs,
):
@ -79,7 +79,7 @@ class OpenGLPMobject(OpenGLMobject):
Rgbas must be a Nx4 numpy array if it is not None.
"""
if rgbas is None and color is None:
color = YELLOW
color = PURE_YELLOW
self.append_points(points)
# rgbas array will have been resized with points
if color is not None:

View file

@ -2,6 +2,7 @@ from __future__ import annotations
from collections.abc import Iterable
from pathlib import Path
from typing import TYPE_CHECKING
import moderngl
import numpy as np
@ -9,7 +10,6 @@ from PIL import Image
from manim.constants import *
from manim.mobject.opengl.opengl_mobject import OpenGLMobject
from manim.typing import Point3D_Array, Vector3D_Array
from manim.utils.bezier import integer_interpolate, interpolate
from manim.utils.color import *
from manim.utils.config_ops import _Data, _Uniforms
@ -17,6 +17,11 @@ from manim.utils.images import change_to_rgba_array, get_full_raster_image_path
from manim.utils.iterables import listify
from manim.utils.space_ops import normalize_along_axis
if TYPE_CHECKING:
import numpy.typing as npt
from manim.typing import Point3D_Array, Vector3D_Array
__all__ = ["OpenGLSurface", "OpenGLTexturedSurface"]
@ -83,7 +88,7 @@ class OpenGLSurface(OpenGLMobject):
render_primitive=moderngl.TRIANGLES,
depth_test=True,
shader_folder=None,
**kwargs,
**kwargs: Any,
):
self.passed_uv_func = uv_func
self.u_range = u_range if u_range is not None else (0, 1)
@ -374,7 +379,7 @@ class OpenGLTexturedSurface(OpenGLSurface):
def __init__(
self,
uv_surface: OpenGLSurface,
image_file: str | Path,
image_file: str | Path | npt.NDArray,
dark_image_file: str | Path = None,
image_mode: str | Iterable[str] = "RGBA",
shader_folder: str | Path = None,
@ -416,7 +421,7 @@ class OpenGLTexturedSurface(OpenGLSurface):
self,
image_file: str | Path,
image_mode: str,
):
) -> Image.Image:
image_file = get_full_raster_image_path(image_file)
return Image.open(image_file).convert(image_mode)

View file

@ -350,7 +350,7 @@ class OpenGLVMobject(OpenGLMobject):
return self
elif len(submobs2) == 0:
submobs2 = [vmobject]
for sm1, sm2 in zip(*make_even(submobs1, submobs2), strict=False):
for sm1, sm2 in zip(*make_even(submobs1, submobs2), strict=True):
sm1.match_style(sm2)
return self
@ -580,7 +580,7 @@ class OpenGLVMobject(OpenGLMobject):
new_points.extend(
[
partial_bezier_points(tup, a1, a2)
for a1, a2 in zip(alphas, alphas[1:], strict=False)
for a1, a2 in zip(alphas[:-1], alphas[1:], strict=True)
],
)
else:
@ -769,7 +769,7 @@ class OpenGLVMobject(OpenGLMobject):
split_indices = [0, *split_indices, len(points)]
return [
points[i1:i2]
for i1, i2 in zip(split_indices, split_indices[1:], strict=False)
for i1, i2 in zip(split_indices[:-1], split_indices[1:], strict=True)
if (i2 - i1) >= nppc
]
@ -1093,7 +1093,7 @@ class OpenGLVMobject(OpenGLMobject):
s = self.get_start_anchors()
e = self.get_end_anchors()
return list(it.chain.from_iterable(zip(s, e, strict=False)))
return list(it.chain.from_iterable(zip(s, e, strict=True)))
def get_points_without_null_curves(self, atol=1e-9):
nppc = self.n_points_per_curve

View file

@ -21,12 +21,12 @@ from ..geometry.arc import Circle
from ..geometry.line import Line
from ..geometry.polygram import Polygon, Rectangle, RoundedRectangle
from ..opengl.opengl_compatibility import ConvertToOpenGL
from ..types.vectorized_mobject import VMobject
from ..types.vectorized_mobject import VGroup, VMobject
__all__ = ["SVGMobject", "VMobjectFromSVGPath"]
SVG_HASH_TO_MOB_MAP: dict[int, VMobject] = {}
SVG_HASH_TO_MOB_MAP: dict[int, SVGMobject] = {}
def _convert_point_to_3d(x: float, y: float) -> np.ndarray:
@ -127,6 +127,7 @@ class SVGMobject(VMobject, metaclass=ConvertToOpenGL):
self.stroke_color = stroke_color
self.stroke_opacity = stroke_opacity # type: ignore[assignment]
self.stroke_width = stroke_width # type: ignore[assignment]
self.id_to_vgroup_dict: dict[str, VGroup] = {}
if self.stroke_width is None:
self.stroke_width = 0
@ -170,6 +171,7 @@ class SVGMobject(VMobject, metaclass=ConvertToOpenGL):
if hash_val in SVG_HASH_TO_MOB_MAP:
mob = SVG_HASH_TO_MOB_MAP[hash_val].copy()
self.add(*mob)
self.id_to_vgroup_dict = mob.id_to_vgroup_dict
return
self.generate_mobject()
@ -203,8 +205,9 @@ class SVGMobject(VMobject, metaclass=ConvertToOpenGL):
svg = se.SVG.parse(modified_file_path)
modified_file_path.unlink()
mobjects = self.get_mobjects_from(svg)
mobjects, mobject_dict = self.get_mobjects_from(svg)
self.add(*mobjects)
self.id_to_vgroup_dict = mobject_dict
self.flip(RIGHT) # Flip y
def get_file_path(self) -> Path:
@ -258,7 +261,9 @@ class SVGMobject(VMobject, metaclass=ConvertToOpenGL):
result[svg_key] = str(svg_default_dict[style_key])
return result
def get_mobjects_from(self, svg: se.SVG) -> list[VMobject]:
def get_mobjects_from(
self, svg: se.SVG
) -> tuple[list[VMobject], dict[str, VGroup]]:
"""Convert the elements of the SVG to a list of mobjects.
Parameters
@ -267,36 +272,77 @@ class SVGMobject(VMobject, metaclass=ConvertToOpenGL):
The parsed SVG file.
"""
result: list[VMobject] = []
for shape in svg.elements():
# can we combine the two continue cases into one?
if isinstance(shape, se.Group): # noqa: SIM114
continue
elif isinstance(shape, se.Path):
mob: VMobject = self.path_to_mobject(shape)
elif isinstance(shape, se.SimpleLine):
mob = self.line_to_mobject(shape)
elif isinstance(shape, se.Rect):
mob = self.rect_to_mobject(shape)
elif isinstance(shape, (se.Circle, se.Ellipse)):
mob = self.ellipse_to_mobject(shape)
elif isinstance(shape, se.Polygon):
mob = self.polygon_to_mobject(shape)
elif isinstance(shape, se.Polyline):
mob = self.polyline_to_mobject(shape)
elif isinstance(shape, se.Text):
mob = self.text_to_mobject(shape)
elif isinstance(shape, se.Use) or type(shape) is se.SVGElement:
continue
else:
logger.warning(f"Unsupported element type: {type(shape)}")
continue
if mob is None or not mob.has_points():
continue
self.apply_style_to_mobject(mob, shape)
if isinstance(shape, se.Transformable) and shape.apply:
self.handle_transform(mob, shape.transform)
result.append(mob)
return result
stack: list[tuple[se.SVGElement, int]] = []
stack.append((svg, 1))
group_id_number = 0
vgroup_stack: list[str] = ["root"]
vgroup_names: list[str] = ["root"]
vgroups: dict[str, VGroup] = {"root": VGroup()}
while len(stack) > 0:
element, depth = stack.pop()
# Reduce stack heights
vgroup_stack = vgroup_stack[0:(depth)]
try:
group_name = str(element.values["id"])
except Exception:
group_name = f"numbered_group_{group_id_number}"
group_id_number += 1
vg = VGroup()
vgroup_names.append(group_name)
vgroup_stack.append(group_name)
vgroups[group_name] = vg
if isinstance(element, (se.Group, se.Use)):
stack.extend((subelement, depth + 1) for subelement in element[::-1])
# Add element to the parent vgroup
try:
if isinstance(
element,
(
se.Path,
se.SimpleLine,
se.Rect,
se.Circle,
se.Ellipse,
se.Polygon,
se.Polyline,
se.Text,
),
):
mob = self.get_mob_from_shape_element(element)
if mob is not None:
result.append(mob)
for parent_name in vgroup_stack[:-1]:
vgroups[parent_name].add(mob)
except Exception as e:
logger.error(f"Exception occurred in 'get_mobjects_from'. Details: {e}")
return result, vgroups
def get_mob_from_shape_element(self, shape: se.SVGElement) -> VMobject | None:
if isinstance(shape, se.Path):
mob: VMobject | None = self.path_to_mobject(shape)
elif isinstance(shape, se.SimpleLine):
mob = self.line_to_mobject(shape)
elif isinstance(shape, se.Rect):
mob = self.rect_to_mobject(shape)
elif isinstance(shape, (se.Circle, se.Ellipse)):
mob = self.ellipse_to_mobject(shape)
elif isinstance(shape, se.Polygon):
mob = self.polygon_to_mobject(shape)
elif isinstance(shape, se.Polyline):
mob = self.polyline_to_mobject(shape)
elif isinstance(shape, se.Text):
mob = self.text_to_mobject(shape)
else:
logger.warning(f"Unsupported element type: {type(shape)}")
mob = None
if mob is None or not mob.has_points():
return mob
self.apply_style_to_mobject(mob, shape)
if isinstance(shape, se.Transformable) and shape.apply:
self.handle_transform(mob, shape.transform)
return mob
@staticmethod
def handle_transform(mob: VMobject, matrix: se.Matrix) -> VMobject:

View file

@ -79,7 +79,7 @@ from ..animation.composition import AnimationGroup
from ..animation.creation import Create, Write
from ..animation.fading import FadeIn
from ..mobject.types.vectorized_mobject import VGroup, VMobject
from ..utils.color import BLACK, YELLOW, ManimColor, ParsableManimColor
from ..utils.color import BLACK, PURE_YELLOW, ManimColor, ParsableManimColor
from .utils import get_vectorized_mobject_class
@ -811,7 +811,10 @@ class Table(VGroup):
return rec
def get_highlighted_cell(
self, pos: Sequence[int] = (1, 1), color: ParsableManimColor = YELLOW, **kwargs
self,
pos: Sequence[int] = (1, 1),
color: ParsableManimColor = PURE_YELLOW,
**kwargs,
) -> BackgroundRectangle:
"""Returns a :class:`~.BackgroundRectangle` of the cell at the given position.
@ -847,7 +850,10 @@ class Table(VGroup):
return bg_cell
def add_highlighted_cell(
self, pos: Sequence[int] = (1, 1), color: ParsableManimColor = YELLOW, **kwargs
self,
pos: Sequence[int] = (1, 1),
color: ParsableManimColor = PURE_YELLOW,
**kwargs,
) -> Table:
"""Highlights one cell at a specific position on the table by adding a :class:`~.BackgroundRectangle`.
@ -1078,11 +1084,11 @@ class IntegerTable(Table):
[[0,30,45,60,90],
[90,60,45,30,0]],
col_labels=[
MathTex(r"\frac{\sqrt{0}}{2}"),
MathTex(r"\frac{\sqrt{1}}{2}"),
MathTex(r"\frac{\sqrt{2}}{2}"),
MathTex(r"\frac{\sqrt{3}}{2}"),
MathTex(r"\frac{\sqrt{4}}{2}")],
MathTex(r"\frac{ \sqrt{0} }{2}"),
MathTex(r"\frac{ \sqrt{1} }{2}"),
MathTex(r"\frac{ \sqrt{2} }{2}"),
MathTex(r"\frac{ \sqrt{3} }{2}"),
MathTex(r"\frac{ \sqrt{4} }{2}")],
row_labels=[MathTex(r"\sin"), MathTex(r"\cos")],
h_buff=1,
element_to_mobject_config={"unit": r"^{\circ}"})

View file

@ -12,7 +12,7 @@ r"""Mobjects representing text rendered using LaTeX.
from __future__ import annotations
from manim.utils.color import BLACK, ManimColor, ParsableManimColor
from manim.utils.color import BLACK, ParsableManimColor
__all__ = [
"SingleStringMathTex",
@ -23,10 +23,9 @@ __all__ = [
]
import itertools as it
import operator as op
import re
from collections.abc import Iterable, Sequence
from collections.abc import Iterable
from functools import reduce
from textwrap import dedent
from typing import Any, Self
@ -39,6 +38,10 @@ from manim.mobject.types.vectorized_mobject import VGroup, VMobject
from manim.utils.tex import TexTemplate
from manim.utils.tex_file_writing import tex_to_svg_file
from ..opengl.opengl_compatibility import ConvertToOpenGL
MATHTEX_SUBSTRING = "substring"
class SingleStringMathTex(SVGMobject):
"""Elementary building block for rendering text with LaTeX.
@ -234,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::
@ -264,22 +287,30 @@ class MathTex(SingleStringMathTex):
self.tex_template = kwargs.pop("tex_template", config["tex_template"])
self.arg_separator = arg_separator
self.substrings_to_isolate = (
[] if substrings_to_isolate is None else substrings_to_isolate
[] if substrings_to_isolate is None else list(substrings_to_isolate)
)
if tex_to_color_map is None:
self.tex_to_color_map: dict[str, ParsableManimColor] = {}
else:
self.tex_to_color_map = tex_to_color_map
self.substrings_to_isolate.extend(self.tex_to_color_map.keys())
self.tex_environment = tex_environment
self.brace_notation_split_occurred = False
self.tex_strings = self._break_up_tex_strings(tex_strings)
self.tex_strings = self._prepare_tex_strings(tex_strings)
self.matched_strings_and_ids: list[tuple[str, str]] = []
try:
joined_string = self._join_tex_strings_with_unique_deliminters(
self.tex_strings, self.substrings_to_isolate
)
super().__init__(
self.arg_separator.join(self.tex_strings),
joined_string,
tex_environment=self.tex_environment,
tex_template=self.tex_template,
**kwargs,
)
# Save the original tex_string
self.tex_string = self.arg_separator.join(self.tex_strings)
self._break_up_by_substrings()
except ValueError as compilation_error:
if self.brace_notation_split_occurred:
@ -301,36 +332,188 @@ class MathTex(SingleStringMathTex):
if self.organize_left_to_right:
self._organize_submobjects_left_to_right()
def _break_up_tex_strings(self, tex_strings: Sequence[str]) -> list[str]:
# Separate out anything surrounded in double braces
pre_split_length = len(tex_strings)
tex_strings_brace_splitted = [
re.split("{{(.*?)}}", str(t)) for t in tex_strings
def _prepare_tex_strings(self, tex_strings: Iterable[str]) -> list[str]:
# Deal with the case where tex_strings contains integers instead
# of strings.
tex_strings_validated = [
string if isinstance(string, str) else str(string) for string in tex_strings
]
tex_strings_combined = sum(tex_strings_brace_splitted, [])
if len(tex_strings_combined) > pre_split_length:
# Locate double curly bracers and split on them.
tex_strings_validated_two = []
for tex_string in tex_strings_validated:
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]
# Separate out any strings specified in the isolate
# or tex_to_color_map lists.
patterns = []
patterns.extend(
[
f"({re.escape(ss)})"
for ss in it.chain(
self.substrings_to_isolate,
self.tex_to_color_map.keys(),
@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:
joined_string = ""
ssIdx = 0
for idx, tex_string in enumerate(tex_strings):
string_part = rf"\special{{dvisvgm:raw <g id='unique{idx:03d}'>}}"
self.matched_strings_and_ids.append((tex_string, f"unique{idx:03d}"))
# Try to match with all substrings_to_isolate and apply the first match
# then match again (on the rest of the string) and continue until no
# characters are left in the string
unprocessed_string = str(tex_string)
processed_string = ""
while len(unprocessed_string) > 0:
first_match = self._locate_first_match(
substrings_to_isolate, unprocessed_string
)
],
if first_match:
processed, unprocessed_string = self._handle_match(
ssIdx, first_match
)
processed_string = processed_string + processed
ssIdx += 1
else:
processed_string = processed_string + unprocessed_string
unprocessed_string = ""
string_part += processed_string
if idx < len(tex_strings) - 1:
string_part += self.arg_separator
string_part += r"\special{dvisvgm:raw </g>}"
joined_string = joined_string + string_part
return joined_string
def _locate_first_match(
self, substrings_to_isolate: Iterable[str], unprocessed_string: str
) -> re.Match | None:
first_match_start = len(unprocessed_string)
first_match_length = 0
first_match = None
for substring in substrings_to_isolate:
match = re.match(f"(.*?)({re.escape(substring)})(.*)", unprocessed_string)
if match and len(match.group(1)) < first_match_start:
first_match = match
first_match_start = len(match.group(1))
first_match_length = len(match.group(2))
elif match and len(match.group(1)) == first_match_start:
# Break ties by looking at length of matches.
if first_match_length < len(match.group(2)):
first_match = match
first_match_start = len(match.group(1))
first_match_length = len(match.group(2))
return first_match
def _handle_match(self, ssIdx: int, first_match: re.Match) -> tuple[str, str]:
pre_match = first_match.group(1)
matched_string = first_match.group(2)
post_match = first_match.group(3)
pre_string = (
rf"\special{{dvisvgm:raw <g id='unique{ssIdx:03d}{MATHTEX_SUBSTRING}'>}}"
)
pattern = "|".join(patterns)
if pattern:
pieces = []
for s in tex_strings_combined:
pieces.extend(re.split(pattern, s))
else:
pieces = tex_strings_combined
return [p for p in pieces if p]
post_string = r"\special{dvisvgm:raw </g>}"
self.matched_strings_and_ids.append(
(matched_string, f"unique{ssIdx:03d}{MATHTEX_SUBSTRING}")
)
processed_string = pre_match + pre_string + matched_string + post_string
unprocessed_string = post_match
return processed_string, unprocessed_string
@property
def _substring_matches(self) -> list[tuple[str, str]]:
"""Return only the 'ss' (substring_to_isolate) matches."""
return [
(tex, id_)
for tex, id_ in self.matched_strings_and_ids
if id_.endswith(MATHTEX_SUBSTRING)
]
@property
def _main_matches(self) -> list[tuple[str, str]]:
"""Return only the main tex_string matches."""
return [
(tex, id_)
for tex, id_ in self.matched_strings_and_ids
if not id_.endswith(MATHTEX_SUBSTRING)
]
def _break_up_by_substrings(self) -> Self:
"""
@ -339,51 +522,32 @@ class MathTex(SingleStringMathTex):
of tex_strings)
"""
new_submobjects: list[VMobject] = []
curr_index = 0
for tex_string in self.tex_strings:
sub_tex_mob = SingleStringMathTex(
tex_string,
tex_environment=self.tex_environment,
tex_template=self.tex_template,
try:
for tex_string, tex_string_id in self._main_matches:
mtp = MathTexPart()
mtp.tex_string = tex_string
mtp.add(*self.id_to_vgroup_dict[tex_string_id].submobjects)
new_submobjects.append(mtp)
except KeyError:
logger.error(
f"MathTex: Could not find SVG group for tex part '{tex_string}' (id: {tex_string_id}). Using fallback to root group."
)
num_submobs = len(sub_tex_mob.submobjects)
new_index = (
curr_index + num_submobs + len("".join(self.arg_separator.split()))
)
if num_submobs == 0:
last_submob_index = min(curr_index, len(self.submobjects) - 1)
sub_tex_mob.move_to(self.submobjects[last_submob_index], RIGHT)
else:
sub_tex_mob.submobjects = self.submobjects[curr_index:new_index]
new_submobjects.append(sub_tex_mob)
curr_index = new_index
new_submobjects.append(self.id_to_vgroup_dict["root"])
self.submobjects = new_submobjects
return self
def get_parts_by_tex(
self, tex: str, substring: bool = True, case_sensitive: bool = True
) -> VGroup:
def test(tex1: str, tex2: str) -> bool:
if not case_sensitive:
tex1 = tex1.lower()
tex2 = tex2.lower()
if substring:
return tex1 in tex2
else:
return tex1 == tex2
return VGroup(*(m for m in self.submobjects if test(tex, m.get_tex_string())))
def get_part_by_tex(self, tex: str, **kwargs: Any) -> MathTex | None:
all_parts = self.get_parts_by_tex(tex, **kwargs)
return all_parts[0] if all_parts else None
def get_part_by_tex(self, tex: str, **kwargs: Any) -> VGroup | None:
for tex_str, match_id in self.matched_strings_and_ids:
if tex_str == tex:
return self.id_to_vgroup_dict[match_id]
return None
def set_color_by_tex(
self, tex: str, color: ParsableManimColor, **kwargs: Any
) -> Self:
parts_to_color = self.get_parts_by_tex(tex, **kwargs)
for part in parts_to_color:
part.set_color(color)
for tex_str, match_id in self.matched_strings_and_ids:
if tex_str == tex:
self.id_to_vgroup_dict[match_id].set_color(color)
return self
def set_opacity_by_tex(
@ -409,22 +573,18 @@ class MathTex(SingleStringMathTex):
"""
if remaining_opacity is not None:
self.set_opacity(opacity=remaining_opacity)
for part in self.get_parts_by_tex(tex):
part.set_opacity(opacity)
for tex_str, match_id in self.matched_strings_and_ids:
if tex_str == tex:
self.id_to_vgroup_dict[match_id].set_opacity(opacity)
return self
def set_color_by_tex_to_color_map(
self, texs_to_color_map: dict[str, ParsableManimColor], **kwargs: Any
) -> Self:
for texs, color in list(texs_to_color_map.items()):
try:
# If the given key behaves like tex_strings
texs + ""
self.set_color_by_tex(texs, ManimColor(color), **kwargs)
except TypeError:
# If the given key is a tuple
for tex in texs:
self.set_color_by_tex(tex, ManimColor(color), **kwargs)
for match in self.matched_strings_and_ids:
if match[0] == texs:
self.id_to_vgroup_dict[match[1]].set_color(color)
return self
def index_of_part(self, part: MathTex) -> int:
@ -433,16 +593,17 @@ class MathTex(SingleStringMathTex):
raise ValueError("Trying to get index of part not in MathTex")
return split_self.index(part)
def index_of_part_by_tex(self, tex: str, **kwargs: Any) -> int:
part = self.get_part_by_tex(tex, **kwargs)
if part is None:
return -1
return self.index_of_part(part)
def sort_alphabetically(self) -> None:
self.submobjects.sort(key=lambda m: m.get_tex_string())
class MathTexPart(VMobject, metaclass=ConvertToOpenGL):
tex_string: str
def __repr__(self) -> str:
return f"{type(self).__name__}({repr(self.tex_string)})"
class Tex(MathTex):
r"""A string compiled with LaTeX in normal mode.

View file

@ -801,8 +801,7 @@ class Text(SVGMobject):
line_spacing /= TEXT2SVG_ADJUSTMENT_FACTOR
dir_name = config.get_dir("text_dir")
if not dir_name.is_dir():
dir_name.mkdir(parents=True)
dir_name.mkdir(parents=True, exist_ok=True)
hash_name = self._text2hash(color)
file_name = dir_name / (hash_name + ".svg")
@ -1349,8 +1348,7 @@ class MarkupText(SVGMobject):
line_spacing /= TEXT2SVG_ADJUSTMENT_FACTOR
dir_name = config.get_dir("text_dir")
if not dir_name.is_dir():
dir_name.mkdir(parents=True)
dir_name.mkdir(parents=True, exist_ok=True)
hash_name = self._text2hash(color)
file_name = dir_name / (hash_name + ".svg")

View file

@ -132,7 +132,7 @@ class Polyhedron(VGroup):
"""Creates list of cyclic pairwise tuples."""
edges: list[tuple[int, int]] = []
for face in faces_list:
edges += zip(face, face[1:] + face[:1], strict=False)
edges += zip(face, face[1:] + face[:1], strict=True)
return edges
def create_faces(

View file

@ -18,7 +18,13 @@ from ...camera.moving_camera import MovingCamera
from ...constants import *
from ...mobject.mobject import Mobject
from ...utils.bezier import interpolate
from ...utils.color import WHITE, ManimColor, color_to_int_rgb
from ...utils.color import (
WHITE,
YELLOW_C,
ManimColor,
ParsableManimColor,
color_to_int_rgb,
)
from ...utils.images import change_to_rgba_array, get_full_raster_image_path
__all__ = ["ImageMobject", "ImageMobjectFromCamera"]
@ -62,9 +68,14 @@ class AbstractImageMobject(Mobject):
def get_pixel_array(self) -> PixelArray:
raise NotImplementedError()
def set_color(self, color, alpha=None, family=True):
def set_color( # type: ignore[override]
self,
color: ParsableManimColor = YELLOW_C,
alpha: Any = None,
family: bool = True,
) -> AbstractImageMobject:
# Likely to be implemented in subclasses, but no obligation
pass
raise NotImplementedError()
def set_resampling_algorithm(self, resampling_algorithm: int) -> Self:
"""
@ -87,18 +98,18 @@ class AbstractImageMobject(Mobject):
* 'hamming'
* 'lanczos' or 'antialias'
"""
if isinstance(resampling_algorithm, int):
self.resampling_algorithm = resampling_algorithm
else:
if resampling_algorithm not in RESAMPLING_ALGORITHMS.values():
raise ValueError(
"resampling_algorithm has to be an int, one of the values defined in "
"RESAMPLING_ALGORITHMS or a Pillow resampling filter constant. "
"Available algorithms: 'bicubic', 'nearest', 'box', 'bilinear', "
"'hamming', 'lanczos'.",
"Available algorithms: 'bicubic' (or 'cubic'), 'nearest' (or 'none'), "
"'bilinear' (or 'linear').",
)
self.resampling_algorithm = resampling_algorithm
return self
def reset_points(self) -> None:
def reset_points(self) -> Self:
"""Sets :attr:`points` to be the four image corners."""
self.points = np.array(
[
@ -116,6 +127,7 @@ class AbstractImageMobject(Mobject):
height = 3 # this is the case for ImageMobjectFromCamera
self.stretch_to_fit_height(height)
self.stretch_to_fit_width(height * w / h)
return self
class ImageMobject(AbstractImageMobject):
@ -157,27 +169,18 @@ class ImageMobject(AbstractImageMobject):
[0, 0, 0, 255]
]))
img.height = 2
img1 = img.copy()
img2 = img.copy()
img3 = img.copy()
img4 = img.copy()
img5 = img.copy()
img.height = 3
img1.set_resampling_algorithm(RESAMPLING_ALGORITHMS["nearest"])
img2.set_resampling_algorithm(RESAMPLING_ALGORITHMS["lanczos"])
img3.set_resampling_algorithm(RESAMPLING_ALGORITHMS["linear"])
img4.set_resampling_algorithm(RESAMPLING_ALGORITHMS["cubic"])
img5.set_resampling_algorithm(RESAMPLING_ALGORITHMS["box"])
img1.add(Text("nearest").scale(0.5).next_to(img1,UP))
img2.add(Text("lanczos").scale(0.5).next_to(img2,UP))
img3.add(Text("linear").scale(0.5).next_to(img3,UP))
img4.add(Text("cubic").scale(0.5).next_to(img4,UP))
img5.add(Text("box").scale(0.5).next_to(img5,UP))
group = Group()
algorithm_texts = ["nearest", "linear", "cubic"]
for algorithm_text in algorithm_texts:
algorithm = RESAMPLING_ALGORITHMS[algorithm_text]
img_copy = img.copy().set_resampling_algorithm(algorithm)
img_copy.add(Text(algorithm_text).scale(0.5).next_to(img_copy, UP))
group.add(img_copy)
x= Group(img1,img2,img3,img4,img5)
x.arrange()
self.add(x)
group.arrange()
self.add(group)
"""
def __init__(
@ -210,18 +213,23 @@ class ImageMobject(AbstractImageMobject):
self.orig_alpha_pixel_array = self.pixel_array[:, :, 3].copy()
super().__init__(scale_to_resolution, **kwargs)
def get_pixel_array(self):
def get_pixel_array(self) -> PixelArray:
"""A simple getter method."""
return self.pixel_array
def set_color(self, color, alpha=None, family=True):
def set_color( # type: ignore[override]
self,
color: ParsableManimColor = YELLOW_C,
alpha: Any = None,
family: bool = True,
) -> Self:
rgb = color_to_int_rgb(color)
self.pixel_array[:, :, :3] = rgb
if alpha is not None:
self.pixel_array[:, :, 3] = int(255 * alpha)
for submob in self.submobjects:
submob.set_color(color, alpha, family)
self.color = color
self.color = ManimColor(color)
return self
def set_opacity(self, alpha: float) -> Self:
@ -253,7 +261,7 @@ class ImageMobject(AbstractImageMobject):
return self
def interpolate_color(
self, mobject1: ImageMobject, mobject2: ImageMobject, alpha: float
self, mobject1: Mobject, mobject2: Mobject, alpha: float
) -> None:
"""Interpolates the array of pixel color values from one ImageMobject
into an array of equal size in the target ImageMobject.
@ -269,6 +277,8 @@ class ImageMobject(AbstractImageMobject):
alpha
Used to track the lerp relationship. Not opacity related.
"""
assert isinstance(mobject1, ImageMobject)
assert isinstance(mobject2, ImageMobject)
assert mobject1.pixel_array.shape == mobject2.pixel_array.shape, (
f"Mobject pixel array shapes incompatible for interpolation.\n"
f"Mobject 1 ({mobject1}) : {mobject1.pixel_array.shape}\n"
@ -292,7 +302,7 @@ class ImageMobject(AbstractImageMobject):
def get_style(self) -> dict[str, Any]:
return {
"fill_color": ManimColor(self.color.get_rgb()).to_hex(),
"fill_color": ManimColor(self.color.to_rgb()).to_hex(),
"fill_opacity": self.fill_opacity,
}
@ -321,7 +331,7 @@ class ImageMobjectFromCamera(AbstractImageMobject):
super().__init__(scale_to_resolution=False, **kwargs)
# TODO: Get rid of this.
def get_pixel_array(self):
def get_pixel_array(self) -> PixelArray:
self.pixel_array = self.camera.pixel_array
return self.pixel_array
@ -332,7 +342,11 @@ class ImageMobjectFromCamera(AbstractImageMobject):
self.add(self.display_frame)
return self
def interpolate_color(self, mobject1, mobject2, alpha) -> None:
def interpolate_color(
self, mobject1: Mobject, mobject2: Mobject, alpha: float
) -> None:
assert isinstance(mobject1, ImageMobjectFromCamera)
assert isinstance(mobject2, ImageMobjectFromCamera)
assert mobject1.pixel_array.shape == mobject2.pixel_array.shape, (
f"Mobject pixel array shapes incompatible for interpolation.\n"
f"Mobject 1 ({mobject1}) : {mobject1.pixel_array.shape}\n"

View file

@ -17,8 +17,8 @@ from ...mobject.mobject import Mobject
from ...utils.bezier import interpolate
from ...utils.color import (
BLACK,
PURE_YELLOW,
WHITE,
YELLOW,
ManimColor,
ParsableManimColor,
color_gradient,
@ -110,7 +110,7 @@ class PMobject(Mobject, metaclass=ConvertToOpenGL):
return self
def set_color(
self, color: ParsableManimColor = YELLOW, family: bool = True
self, color: ParsableManimColor = PURE_YELLOW, family: bool = True
) -> Self:
rgba = color_to_rgba(color)
mobs = self.family_members_with_points() if family else [self]
@ -130,7 +130,7 @@ class PMobject(Mobject, metaclass=ConvertToOpenGL):
def set_color_by_gradient(self, *colors: ParsableManimColor) -> Self:
self.rgbas = np.array(
list(map(color_to_rgba, color_gradient(*colors, len(self.points)))),
list(map(color_to_rgba, color_gradient(colors, len(self.points)))),
)
return self
@ -172,7 +172,7 @@ class PMobject(Mobject, metaclass=ConvertToOpenGL):
for mob in self.family_members_with_points():
num_points = self.get_num_points()
mob.apply_over_attr_arrays(
lambda arr, n=num_points: arr[np.arange(0, n, factor)],
lambda arr, n=num_points: arr[np.arange(0, n, factor)], # type: ignore[misc]
)
return self
@ -182,7 +182,7 @@ class PMobject(Mobject, metaclass=ConvertToOpenGL):
"""Function is any map from R^3 to R"""
for mob in self.family_members_with_points():
indices = np.argsort(np.apply_along_axis(function, 1, mob.points))
mob.apply_over_attr_arrays(lambda arr, idx=indices: arr[idx])
mob.apply_over_attr_arrays(lambda arr, idx=indices: arr[idx]) # type: ignore[misc]
return self
def fade_to(
@ -199,7 +199,7 @@ class PMobject(Mobject, metaclass=ConvertToOpenGL):
def ingest_submobjects(self) -> Self:
attrs = self.get_array_attrs()
arrays = list(map(self.get_merged_array, attrs))
for attr, array in zip(attrs, arrays, strict=False):
for attr, array in zip(attrs, arrays, strict=True):
setattr(self, attr, array)
self.submobjects = []
return self
@ -359,7 +359,7 @@ class PointCloudDot(Mobject1D):
radius: float = 2.0,
stroke_width: int = 2,
density: int = DEFAULT_POINT_DENSITY_1D,
color: ManimColor = YELLOW,
color: ManimColor = PURE_YELLOW,
**kwargs: Any,
) -> None:
self.radius = radius

View file

@ -236,7 +236,7 @@ class VMobject(Mobject):
rgbas: FloatRGBA_Array = np.array(
[
c.to_rgba_with_alpha(o)
for c, o in zip(*make_even(colors, opacities), strict=False)
for c, o in zip(*make_even(colors, opacities), strict=True)
],
)
@ -461,7 +461,7 @@ class VMobject(Mobject):
return self
elif len(submobs2) == 0:
submobs2 = [vmobject]
for sm1, sm2 in zip(*make_even(submobs1, submobs2), strict=False):
for sm1, sm2 in zip(*make_even(submobs1, submobs2), strict=True):
sm1.match_style(sm2)
return self
@ -1337,7 +1337,7 @@ class VMobject(Mobject):
split_indices = [0] + list(filtered) + [len(points)]
return (
points[i1:i2]
for i1, i2 in zip(split_indices, split_indices[1:], strict=False)
for i1, i2 in zip(split_indices[:-1], split_indices[1:], strict=True)
if (i2 - i1) >= nppcc
)
@ -1691,7 +1691,7 @@ class VMobject(Mobject):
s = self.get_start_anchors()
e = self.get_end_anchors()
return list(it.chain.from_iterable(zip(s, e, strict=False)))
return list(it.chain.from_iterable(zip(s, e, strict=True)))
def get_points_defining_boundary(self) -> Point3D_Array:
# Probably returns all anchors, but this is weird regarding the name of the method.

0
manim/py.typed Normal file
View file

File diff suppressed because it is too large Load diff

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

@ -35,9 +35,9 @@ from ..utils.color import (
BLUE_D,
GREEN_C,
GREY,
PURE_YELLOW,
RED_C,
WHITE,
YELLOW,
ManimColor,
ParsableManimColor,
)
@ -172,7 +172,7 @@ class VectorScene(Scene):
def add_vector(
self,
vector: Arrow | Vector3DLike,
color: ParsableManimColor | Iterable[ParsableManimColor] = YELLOW,
color: ParsableManimColor | Iterable[ParsableManimColor] = PURE_YELLOW,
animate: bool = True,
**kwargs: Any,
) -> Arrow:
@ -808,7 +808,7 @@ class LinearTransformationScene(VectorScene):
def get_unit_square(
self,
color: ParsableManimColor | Iterable[ParsableManimColor] = YELLOW,
color: ParsableManimColor | Iterable[ParsableManimColor] = PURE_YELLOW,
opacity: float = 0.3,
stroke_width: float = 3,
) -> Rectangle:
@ -875,7 +875,7 @@ class LinearTransformationScene(VectorScene):
def add_vector(
self,
vector: Arrow | list | tuple | np.ndarray,
color: ParsableManimColor = YELLOW,
color: ParsableManimColor = PURE_YELLOW,
animate: bool = False,
**kwargs: Any,
) -> Arrow:

View file

@ -999,7 +999,7 @@ def bezier_remap(
new_tuples = np.empty((new_number_of_curves, nppc, dim))
index = 0
for curve, sf in zip(bezier_tuples, split_factors, strict=False):
for curve, sf in zip(bezier_tuples, split_factors, strict=True):
new_tuples[index : index + sf] = subdivide_bezier(curve, sf).reshape(
sf, nppc, dim
)

View file

@ -1418,7 +1418,7 @@ def color_gradient(
floors[-1] = num_colors - 2
return [
rgb_to_color((rgbs[i] * (1 - alpha)) + (rgbs[i + 1] * alpha))
for i, alpha in zip(floors, alphas_mod1, strict=False)
for i, alpha in zip(floors, alphas_mod1, strict=True)
]
@ -1520,7 +1520,7 @@ class RandomColorGenerator:
>>> rnd = RandomColorGenerator(42)
>>> rnd.next()
ManimColor('#ECE7E2')
ManimColor('#8B4513')
>>> rnd.next()
ManimColor('#BBBBBB')
>>> rnd.next()
@ -1530,7 +1530,7 @@ class RandomColorGenerator:
>>> rnd2 = RandomColorGenerator(42)
>>> rnd2.next()
ManimColor('#ECE7E2')
ManimColor('#8B4513')
>>> rnd2.next()
ManimColor('#BBBBBB')
>>> rnd2.next()

View file

@ -102,6 +102,9 @@ These colors form Manim's default color space.
"pure_red",
"pure_green",
"pure_blue",
"pure_cyan",
"pure_magenta",
"pure_yellow",
)
pure_lines = named_lines_group(
@ -145,12 +148,17 @@ DARK_GRAY = ManimColor("#444444")
DARK_GREY = ManimColor("#444444")
DARKER_GRAY = ManimColor("#222222")
DARKER_GREY = ManimColor("#222222")
PURE_RED = ManimColor("#FF0000")
PURE_GREEN = ManimColor("#00FF00")
PURE_BLUE = ManimColor("#0000FF")
PURE_CYAN = ManimColor("#00FFFF")
PURE_MAGENTA = ManimColor("#FF00FF")
PURE_YELLOW = ManimColor("#FFFF00")
BLUE_A = ManimColor("#C7E9F1")
BLUE_B = ManimColor("#9CDCEB")
BLUE_C = ManimColor("#58C4DD")
BLUE_D = ManimColor("#29ABCA")
BLUE_E = ManimColor("#236B8E")
PURE_BLUE = ManimColor("#0000FF")
BLUE = ManimColor("#58C4DD")
DARK_BLUE = ManimColor("#236B8E")
TEAL_A = ManimColor("#ACEAD7")
@ -164,14 +172,13 @@ GREEN_B = ManimColor("#A6CF8C")
GREEN_C = ManimColor("#83C167")
GREEN_D = ManimColor("#77B05D")
GREEN_E = ManimColor("#699C52")
PURE_GREEN = ManimColor("#00FF00")
GREEN = ManimColor("#83C167")
YELLOW_A = ManimColor("#FFF1B6")
YELLOW_B = ManimColor("#FFEA94")
YELLOW_C = ManimColor("#FFFF00")
YELLOW_C = ManimColor("#F7D96F")
YELLOW_D = ManimColor("#F4D345")
YELLOW_E = ManimColor("#E8C11C")
YELLOW = ManimColor("#FFFF00")
YELLOW = ManimColor("#F7D96F")
GOLD_A = ManimColor("#F7C797")
GOLD_B = ManimColor("#F9B775")
GOLD_C = ManimColor("#F0AC5F")
@ -183,7 +190,6 @@ RED_B = ManimColor("#FF8080")
RED_C = ManimColor("#FC6255")
RED_D = ManimColor("#E65A4C")
RED_E = ManimColor("#CF5044")
PURE_RED = ManimColor("#FF0000")
RED = ManimColor("#FC6255")
MAROON_A = ManimColor("#ECABC1")
MAROON_B = ManimColor("#EC92AB")

View file

@ -153,15 +153,14 @@ def add_version_before_extension(file_name: Path) -> Path:
def guarantee_existence(path: Path) -> Path:
if not path.exists():
path.mkdir(parents=True)
path.mkdir(parents=True, exist_ok=True)
return path.resolve(strict=True)
def guarantee_empty_existence(path: Path) -> Path:
if path.exists():
shutil.rmtree(str(path))
path.mkdir(parents=True)
path.mkdir(parents=True, exist_ok=True)
return path.resolve(strict=True)

View file

@ -58,7 +58,7 @@ def adjacent_n_tuples(objects: Sequence[T], n: int) -> zip[tuple[T, ...]]:
>>> list(adjacent_n_tuples([1, 2, 3, 4], 3))
[(1, 2, 3), (2, 3, 4), (3, 4, 1), (4, 1, 2)]
"""
return zip(*([*objects[k:], *objects[:k]] for k in range(n)), strict=False)
return zip(*([*objects[k:], *objects[:k]] for k in range(n)), strict=True)
def adjacent_pairs(objects: Sequence[T]) -> zip[tuple[T, ...]]:

View file

@ -9,13 +9,12 @@ __all__ = [
"sigmoid",
]
import math
from collections.abc import Callable
from functools import lru_cache
from typing import Any, Protocol, TypeVar
import numpy as np
from scipy import special
def binary_search(
@ -87,9 +86,9 @@ def choose(n: int, k: int) -> int:
References
----------
- https://en.wikipedia.org/wiki/Combination
- https://docs.scipy.org/doc/scipy/reference/generated/scipy.special.comb.html
- https://docs.python.org/3/library/math.html#math.comb
"""
value: int = special.comb(n, k, exact=True)
value: int = math.comb(n, k)
return value

View file

@ -609,7 +609,7 @@ def find_intersection(
# algorithm from https://en.wikipedia.org/wiki/Skew_lines#Nearest_points
result = []
for p0, v0, p1, v1 in zip(p0s, v0s, p1s, v1s, strict=False):
for p0, v0, p1, v1 in zip(p0s, v0s, p1s, v1s, strict=True):
normal = cross(v1, cross(v0, v1))
denom = max(np.dot(v0, normal), threshold)
result += [p0 + np.dot(p1 - p0, normal) / denom * v0]

View file

@ -103,8 +103,7 @@ def generate_tex_file(
output = tex_template.get_texcode_for_expression(expression)
tex_dir = config.get_dir("tex_dir")
if not tex_dir.exists():
tex_dir.mkdir()
tex_dir.mkdir(parents=True, exist_ok=True)
result = tex_dir / (tex_hash(output) + ".tex")
if not result.exists():

123
mypy.ini
View file

@ -52,27 +52,15 @@ warn_return_any = True
#
# disable_recursive_aliases = True
[mypy-manim.__main__]
ignore_errors = True
[mypy-manim._config.utils]
ignore_errors = True
[mypy-manim._config.cli_colors]
ignore_errors = True
[mypy-manim.animation.animation]
ignore_errors = True
[mypy-manim.animation.creation]
ignore_errors = True
[mypy-manim.animation.indication]
ignore_errors = True
[mypy-manim.animation.specialized]
ignore_errors = True
[mypy-manim.animation.speedmodifier]
ignore_errors = True
@ -85,21 +73,9 @@ ignore_errors = True
[mypy-manim.animation.updaters.mobject_update_utils]
ignore_errors = True
[mypy-manim.camera.camera]
ignore_errors = True
[mypy-manim.camera.mapping_camera]
ignore_errors = True
[mypy-manim.cli.default_group]
ignore_errors = True
[mypy-manim.mobject.geometry.boolean_ops]
ignore_errors = True
[mypy-manim.mobject.geometry.polygram]
ignore_errors = True
[mypy-manim.mobject.graphing.coordinate_systems]
ignore_errors = True
@ -112,12 +88,6 @@ ignore_errors = True
[mypy-manim.mobject.mobject]
ignore_errors = True
[mypy-manim.mobject.opengl.opengl_compatibility]
ignore_errors = True
[mypy-manim.mobject.opengl.opengl_image_mobject]
ignore_errors = True
[mypy-manim.mobject.opengl.opengl_point_cloud_mobject]
ignore_errors = True
@ -130,21 +100,12 @@ ignore_errors = True
[mypy-manim.mobject.table]
ignore_errors = True
[mypy-manim.mobject.types.image_mobject]
ignore_errors = True
[mypy-manim.mobject.types.point_cloud_mobject]
ignore_errors = True
[mypy-manim.mobject.types.vectorized_mobject]
ignore_errors = True
[mypy-manim.mobject.vector_field]
ignore_errors = True
[mypy-manim.renderer.opengl_renderer]
ignore_errors = True
[mypy-manim.renderer.shader_wrapper]
ignore_errors = True
@ -154,90 +115,6 @@ ignore_errors = True
[mypy-manim.utils.hashing]
ignore_errors = True
# Added temporarily due to current mypy failures
[mypy-manim.camera.three_d_camera]
ignore_errors = True
[mypy-manim.mobject.graphing.functions]
ignore_errors = True
[mypy-manim.mobject.graphing.number_line]
ignore_errors = True
[mypy-manim.mobject.graphing.probability]
ignore_errors = True
[mypy-manim.mobject.graphing.scale]
ignore_errors = True
[mypy-manim.mobject.matrix]
ignore_errors = True
[mypy-manim.mobject.opengl.opengl_geometry]
ignore_errors = True
[mypy-manim.mobject.opengl.opengl_mobject]
ignore_errors = True
[mypy-manim.mobject.svg.brace]
ignore_errors = True
[mypy-manim.mobject.svg.svg_mobject]
ignore_errors = True
[mypy-manim.mobject.text.code_mobject]
ignore_errors = True
[mypy-manim.mobject.three_d.polyhedra]
ignore_errors = True
[mypy-manim.mobject.three_d.three_d_utils]
ignore_errors = True
[mypy-manim.mobject.three_d.three_dimensions]
ignore_errors = True
[mypy-manim.renderer.shader]
ignore_errors = True
[mypy-manim.renderer.vectorized_mobject_rendering]
ignore_errors = True
[mypy-manim.scene.scene]
ignore_errors = True
[mypy-manim.scene.scene_file_writer]
ignore_errors = True
[mypy-manim.scene.vector_space_scene]
ignore_errors = True
[mypy-manim.utils.bezier]
ignore_errors = True
[mypy-manim.utils.color.core]
ignore_errors = True
[mypy-manim.utils.commands]
ignore_errors = True
[mypy-manim.utils.images]
ignore_errors = True
[mypy-manim.utils.iterables]
ignore_errors = True
[mypy-manim.utils.opengl]
ignore_errors = True
[mypy-manim.utils.paths]
ignore_errors = True
[mypy-manim.utils.space_ops]
ignore_errors = True
[mypy-manim.utils.testing._test_class_makers]
ignore_errors = True
# ---------------- Stubless imported Modules --------------------------

View file

@ -1,6 +1,6 @@
[project]
name = "manim"
version = "0.19.2"
version = "0.20.0"
description = "Animation engine for explanatory math videos."
authors = [
{name = "The Manim Community Developers", email = "contact@manim.community"},
@ -78,11 +78,9 @@ jupyterlab = [
[dependency-groups]
dev = [
"furo>=2024.8.6",
"gitpython>=3.1.44",
"matplotlib>=3.9.4",
"myst-parser>=3.0.1",
"pre-commit>=4.1.0",
"pygithub>=2.5.0",
"pytest>=8.3.4",
"pytest-cov>=6.0.0",
"pytest-xdist>=2.2,<3.0",
@ -212,9 +210,6 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
"F403",
]
[tool.ruff.lint.isort]
required-imports = ["from __future__ import annotations"]
[tool.ruff.lint.flake8-pytest-style]
fixture-parentheses = false
mark-parentheses = false

View file

@ -1,316 +0,0 @@
#!/usr/bin/env python
"""Script to generate contributor and pull request lists.
This script generates contributor and pull request lists for release
changelogs using Github v3 protocol. Use requires an authentication token in
order to have sufficient bandwidth, you can get one following the directions at
`<https://help.github.com/articles/creating-an-access-token-for-command-line-use/>_
Don't add any scope, as the default is read access to public information. The
token may be stored in an environment variable as you only get one chance to
see it.
Usage::
$ ./scripts/dev_changelog.py [OPTIONS] TOKEN PRIOR TAG [ADDITIONAL]...
The output is utf8 rst.
Dependencies
------------
- gitpython
- pygithub
Examples
--------
From a bash command line with $GITHUB environment variable as the GitHub token::
$ ./scripts/dev_changelog.py $GITHUB v0.3.0 v0.4.0
This would generate 0.4.0-changelog.rst file and place it automatically under
docs/source/changelog/.
As another example, you may also run include PRs that have been excluded by
providing a space separated list of ticket numbers after TAG::
$ ./scripts/dev_changelog.py $GITHUB v0.3.0 v0.4.0 1911 1234 1492 ...
Note
----
This script was taken from Numpy under the terms of BSD-3-Clause license.
"""
from __future__ import annotations
import concurrent.futures
import datetime
import re
from collections import defaultdict
from pathlib import Path
from textwrap import dedent, indent
import cloup
from git import Repo
from github import Github
from tqdm import tqdm
from manim.constants import CONTEXT_SETTINGS, EPILOG
this_repo = Repo(str(Path(__file__).resolve().parent.parent))
PR_LABELS = {
"breaking changes": "Breaking changes",
"highlight": "Highlights",
"pr:deprecation": "Deprecated classes and functions",
"new feature": "New features",
"enhancement": "Enhancements",
"pr:bugfix": "Fixed bugs",
"documentation": "Documentation-related changes",
"testing": "Changes concerning the testing system",
"infrastructure": "Changes to our development infrastructure",
"maintenance": "Code quality improvements and similar refactors",
"revert": "Changes that needed to be reverted again",
"release": "New releases",
"unlabeled": "Unclassified changes",
}
SILENT_CONTRIBUTORS = [
"dependabot[bot]",
]
def update_citation(version, date):
current_directory = Path(__file__).parent
parent_directory = current_directory.parent
contents = (current_directory / "TEMPLATE.cff").read_text()
contents = contents.replace("<version>", version)
contents = contents.replace("<date_released>", date)
with (parent_directory / "CITATION.cff").open("w", newline="\n") as f:
f.write(contents)
def process_pullrequests(lst, cur, github_repo, pr_nums):
lst_commit = github_repo.get_commit(sha=this_repo.git.rev_list("-1", lst))
lst_date = lst_commit.commit.author.date
authors = set()
reviewers = set()
pr_by_labels = defaultdict(list)
with concurrent.futures.ThreadPoolExecutor() as executor:
future_to_num = {
executor.submit(github_repo.get_pull, num): num for num in pr_nums
}
for future in tqdm(
concurrent.futures.as_completed(future_to_num), "Processing PRs"
):
pr = future.result()
authors.add(pr.user)
reviewers = reviewers.union(rev.user for rev in pr.get_reviews())
pr_labels = [label.name for label in pr.labels]
for label in PR_LABELS:
if label in pr_labels:
pr_by_labels[label].append(pr)
break # ensure that PR is only added in one category
else:
pr_by_labels["unlabeled"].append(pr)
# identify first-time contributors:
author_names = []
for author in authors:
name = author.name if author.name is not None else author.login
if name in SILENT_CONTRIBUTORS:
continue
if github_repo.get_commits(author=author, until=lst_date).totalCount == 0:
name += " +"
author_names.append(name)
reviewer_names = []
for reviewer in reviewers:
name = reviewer.name if reviewer.name is not None else reviewer.login
if name in SILENT_CONTRIBUTORS:
continue
reviewer_names.append(name)
# Sort items in pr_by_labels
for i in pr_by_labels:
pr_by_labels[i] = sorted(pr_by_labels[i], key=lambda pr: pr.number)
return {
"authors": sorted(author_names),
"reviewers": sorted(reviewer_names),
"PRs": pr_by_labels,
}
def get_pr_nums(lst, cur):
print("Getting PR Numbers:")
prnums = []
# From regular merges
merges = this_repo.git.log("--oneline", "--merges", f"{lst}..{cur}")
issues = re.findall(r".*\(\#(\d+)\)", merges)
prnums.extend(int(s) for s in issues)
# From fast forward squash-merges
commits = this_repo.git.log(
"--oneline",
"--no-merges",
"--first-parent",
f"{lst}..{cur}",
)
split_commits = list(
filter(
lambda x: not any(
["pre-commit autoupdate" in x, "New Crowdin updates" in x]
),
commits.split("\n"),
),
)
commits = "\n".join(split_commits)
issues = re.findall(r"^.*\(\#(\d+)\)$", commits, re.M)
prnums.extend(int(s) for s in issues)
print(prnums)
return prnums
def get_summary(body):
pattern = '<!--changelog-start-->([^"]*)<!--changelog-end-->'
try:
has_changelog_pattern = re.search(pattern, body)
if has_changelog_pattern:
return has_changelog_pattern.group()[22:-21].strip()
except Exception:
print(f"Error parsing body for changelog: {body}")
@cloup.command(
context_settings=CONTEXT_SETTINGS,
epilog=EPILOG,
)
@cloup.argument("token")
@cloup.argument("prior")
@cloup.argument("tag")
@cloup.argument(
"additional",
nargs=-1,
required=False,
type=int,
)
@cloup.option(
"-o",
"--outfile",
type=str,
help="Path and file name of the changelog output.",
)
def main(token, prior, tag, additional, outfile):
"""Generate Changelog/List of contributors/PRs for release.
TOKEN is your GitHub Personal Access Token.
PRIOR is the tag/commit SHA of the previous release.
TAG is the tag of the new release.
ADDITIONAL includes additional PR(s) that have not been recognized automatically.
"""
lst_release, cur_release = prior, tag
github = Github(token)
github_repo = github.get_repo("ManimCommunity/manim")
pr_nums = get_pr_nums(lst_release, cur_release)
if additional:
print(f"Adding {additional} to the mix!")
pr_nums = pr_nums + list(additional)
# document authors
contributions = process_pullrequests(lst_release, cur_release, github_repo, pr_nums)
authors = contributions["authors"]
reviewers = contributions["reviewers"]
# update citation file
today = datetime.date.today()
update_citation(tag, str(today))
if not outfile:
outfile = (
Path(__file__).resolve().parent.parent / "docs" / "source" / "changelog"
)
outfile = outfile / f"{tag[1:] if tag.startswith('v') else tag}-changelog.rst"
else:
outfile = Path(outfile).resolve()
with outfile.open("w", encoding="utf8", newline="\n") as f:
f.write("*" * len(tag) + "\n")
f.write(f"{tag}\n")
f.write("*" * len(tag) + "\n\n")
f.write(f":Date: {today.strftime('%B %d, %Y')}\n\n")
heading = "Contributors"
f.write(f"{heading}\n")
f.write("=" * len(heading) + "\n\n")
f.write(
dedent(
f"""\
A total of {len(set(authors).union(set(reviewers)))} people contributed to this
release. People with a '+' by their names authored a patch for the first
time.\n
""",
),
)
for author in authors:
f.write(f"* {author}\n")
f.write("\n")
f.write(
dedent(
"""
The patches included in this release have been reviewed by
the following contributors.\n
""",
),
)
for reviewer in reviewers:
f.write(f"* {reviewer}\n")
# document pull requests
heading = "Pull requests merged"
f.write("\n")
f.write(heading + "\n")
f.write("=" * len(heading) + "\n\n")
f.write(
f"A total of {len(pr_nums)} pull requests were merged for this release.\n\n",
)
pr_by_labels = contributions["PRs"]
for label in PR_LABELS:
pr_of_label = pr_by_labels[label]
if pr_of_label:
heading = PR_LABELS[label]
f.write(f"{heading}\n")
f.write("-" * len(heading) + "\n\n")
for PR in pr_by_labels[label]:
num = PR.number
title = PR.title
label = PR.labels
f.write(f"* :pr:`{num}`: {title}\n")
overview = get_summary(PR.body)
if overview:
f.write(indent(f"{overview}\n\n", " "))
else:
f.write("\n\n")
print(f"Wrote changelog to: {outfile}")
if __name__ == "__main__":
main()

View file

@ -13,8 +13,7 @@ def main():
sys.exit(1)
npz_file = sys.argv[1]
output_folder = pathlib.Path(sys.argv[2])
if not output_folder.exists():
output_folder.mkdir(parents=True)
output_folder.mkdir(parents=True, exist_ok=True)
data = np.load(npz_file)
if "frame_data" not in data:

545
scripts/release.py Normal file
View file

@ -0,0 +1,545 @@
#!/usr/bin/env python3
"""
Release management tools for Manim.
This script provides commands for preparing and managing Manim releases:
- Generate changelogs from GitHub's release notes API
- Update CITATION.cff with new version information
- Fetch existing release notes for documentation
Usage:
# Generate changelog for an upcoming release
uv run python scripts/release.py changelog --base v0.19.0 --version 0.20.0
# Also update CITATION.cff at the same time
uv run python scripts/release.py changelog --base v0.19.0 --version 0.20.0 --update-citation
# Update only CITATION.cff
uv run python scripts/release.py citation --version 0.20.0
# Fetch existing release changelogs for documentation
uv run python scripts/release.py fetch-releases
Requirements:
- gh CLI installed and authenticated
- Python 3.11+
"""
from __future__ import annotations
import re
import subprocess
import sys
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING
import click
if TYPE_CHECKING:
from collections.abc import Sequence
# =============================================================================
# Constants
# =============================================================================
REPO = "manimcommunity/manim"
SCRIPT_DIR = Path(__file__).resolve().parent
REPO_ROOT = SCRIPT_DIR.parent
CHANGELOG_DIR = REPO_ROOT / "docs" / "source" / "changelog"
CITATION_TEMPLATE = SCRIPT_DIR / "TEMPLATE.cff"
CITATION_FILE = REPO_ROOT / "CITATION.cff"
# Minimum version for fetching historical releases
DEFAULT_MIN_VERSION = "0.18.0"
# =============================================================================
# GitHub CLI Helpers
# =============================================================================
def run_gh(
args: Sequence[str],
*,
check: bool = True,
suppress_errors: bool = False,
) -> subprocess.CompletedProcess[str]:
"""
Run a gh CLI command.
Args:
args: Arguments to pass to gh
check: If True, raise on non-zero exit
suppress_errors: If True, don't print errors to stderr
Returns:
CompletedProcess with stdout/stderr
"""
result = subprocess.run(
["gh", *args],
capture_output=True,
text=True,
)
if (
result.returncode != 0
and not suppress_errors
and "not found" not in result.stderr.lower()
):
click.echo(f"gh error: {result.stderr}", err=True)
if check and result.returncode != 0:
raise click.ClickException(f"gh command failed: gh {' '.join(args)}")
return result
def get_release_tags() -> list[str]:
"""Get all published release tags from GitHub, newest first."""
result = run_gh(
["release", "list", "--repo", REPO, "--limit", "100", "--json", "tagName"],
check=False,
)
if result.returncode != 0 or not result.stdout.strip():
return []
import json
try:
data = json.loads(result.stdout)
return [item["tagName"] for item in data]
except (json.JSONDecodeError, KeyError):
return []
def get_release_body(tag: str) -> str | None:
"""Get the release body for a published release."""
result = run_gh(
["release", "view", tag, "--repo", REPO, "--json", "body", "--jq", ".body"],
check=False,
suppress_errors=True,
)
if result.returncode != 0:
return None
return result.stdout.strip() or None
def get_release_date(tag: str) -> str | None:
"""Get the release date formatted as 'Month DD, YYYY'."""
result = run_gh(
[
"release",
"view",
tag,
"--repo",
REPO,
"--json",
"createdAt",
"--jq",
".createdAt",
],
check=False,
)
if result.returncode != 0:
return None
date_str = result.stdout.strip().strip('"')
try:
dt = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
return dt.strftime("%B %d, %Y")
except ValueError:
return None
def generate_release_notes(head_tag: str, base_tag: str) -> str:
"""
Generate release notes using GitHub's API.
This respects .github/release.yml for PR categorization.
"""
result = run_gh(
[
"api",
f"repos/{REPO}/releases/generate-notes",
"--field",
f"tag_name={head_tag}",
"--field",
f"previous_tag_name={base_tag}",
"--jq",
".body",
]
)
body = result.stdout.strip()
if not body:
raise click.ClickException("GitHub API returned empty release notes")
return body
# =============================================================================
# Version Utilities
# =============================================================================
def normalize_tag(tag: str) -> str:
"""Ensure tag has 'v' prefix."""
return tag if tag.startswith("v") else f"v{tag}"
def version_from_tag(tag: str) -> str:
"""Extract version from tag (e.g., 'v0.18.0' -> '0.18.0')."""
return tag[1:] if tag.startswith("v") else tag
def parse_version(version: str) -> tuple[int, ...]:
"""Parse version string into comparable tuple."""
# Handle post-releases like '0.18.0.post0'
version = version.replace(".post", "-post")
parts = []
for part in version.replace("-", ".").split("."):
try:
parts.append(int(part))
except ValueError:
continue
# Pad to at least 3 components
while len(parts) < 3:
parts.append(0)
return tuple(parts)
def version_gte(version: str, min_version: str) -> bool:
"""Check if version >= min_version."""
return parse_version(version) >= parse_version(min_version)
# =============================================================================
# Markdown Conversion
# =============================================================================
def convert_to_myst(body: str) -> str:
"""
Convert GitHub markdown to MyST format.
- PR URLs -> {pr}`NUMBER`
- Issue URLs -> {issue}`NUMBER`
- @mentions -> {user}`USERNAME`
- Strips HTML comments
- Ensures proper list spacing
"""
lines = body.split("\n")
result = []
prev_bullet = False
for line in lines:
# Skip HTML comments
if line.strip().startswith("<!--") and line.strip().endswith("-->"):
continue
# Convert URLs to extlinks
line = re.sub(
r"https://github\.com/ManimCommunity/manim/pull/(\d+)",
r"{pr}`\1`",
line,
)
line = re.sub(
r"https://github\.com/ManimCommunity/manim/issues/(\d+)",
r"{issue}`\1`",
line,
)
line = re.sub(r"@([a-zA-Z0-9_-]+)", r"{user}`\1`", line)
if line.startswith("**Full Changelog**:"):
_, url = line.split(":", 1)
url = url.strip().replace("vmain", "main")
line = f"**Full Changelog**: [Compare view]({url})"
# Handle list spacing
is_bullet = line.strip().startswith("*") and not line.strip().startswith("**")
if prev_bullet and not is_bullet and line.strip():
result.append("")
result.append(line)
prev_bullet = is_bullet
return "\n".join(result)
def format_changelog(
version: str,
body: str,
date: str | None = None,
title: str | None = None,
) -> str:
"""Create changelog file content with MyST frontmatter."""
title = title or f"v{version}"
body = convert_to_myst(body)
date_section = f"Date\n: {date}\n" if date else ""
return f"""---
short-title: {title}
description: Changelog for {title}
---
# {title}
{date_section}
{body}
"""
# =============================================================================
# File Operations
# =============================================================================
def get_existing_versions() -> set[str]:
"""Get versions that already have changelog files."""
if not CHANGELOG_DIR.exists():
return set()
return {
f.stem.replace("-changelog", "") for f in CHANGELOG_DIR.glob("*-changelog.*")
}
def save_changelog(version: str, content: str) -> Path:
"""Save changelog and return filepath."""
filepath = CHANGELOG_DIR / f"{version}-changelog.md"
filepath.write_text(content)
return filepath
def update_citation(version: str, date: str | None = None) -> Path:
"""Update CITATION.cff from template."""
if not CITATION_TEMPLATE.exists():
raise click.ClickException(f"Citation template not found: {CITATION_TEMPLATE}")
date = date or datetime.now().strftime("%Y-%m-%d")
version_tag = normalize_tag(version)
content = CITATION_TEMPLATE.read_text()
content = content.replace("<version>", version_tag)
content = content.replace("<date_released>", date)
CITATION_FILE.write_text(content)
return CITATION_FILE
# =============================================================================
# CLI Commands
# =============================================================================
@click.group()
@click.option(
"--dry-run", is_flag=True, help="Show what would be done without making changes"
)
@click.pass_context
def cli(ctx: click.Context, dry_run: bool) -> None:
"""Release management tools for Manim."""
ctx.ensure_object(dict)
ctx.obj["dry_run"] = dry_run
@cli.command()
@click.option("--base", required=True, help="Base tag for comparison (e.g., v0.19.0)")
@click.option(
"--version", "version", required=True, help="New version number (e.g., 0.20.0)"
)
@click.option("--head", default="main", help="Head ref for comparison (default: main)")
@click.option("--title", help="Custom changelog title (default: vX.Y.Z)")
@click.option(
"--update-citation",
"also_update_citation",
is_flag=True,
help="Also update CITATION.cff",
)
@click.pass_context
def changelog(
ctx: click.Context,
base: str,
version: str,
head: str,
title: str | None,
also_update_citation: bool,
) -> None:
"""Generate changelog for an upcoming release.
Uses GitHub's release notes API with .github/release.yml categorization.
"""
dry_run = ctx.obj["dry_run"]
base_tag = normalize_tag(base)
head_tag = normalize_tag(head) if head != "main" else normalize_tag(version)
click.echo(f"Generating changelog for v{version}...")
click.echo(f" Comparing: {base_tag}{head}")
body = generate_release_notes(head_tag, base_tag)
date = datetime.now().strftime("%B %d, %Y")
content = format_changelog(version, body, date=date, title=title)
if dry_run:
click.echo()
click.secho("[DRY RUN]", fg="yellow", bold=True)
click.echo(f" Would save: {CHANGELOG_DIR / f'{version}-changelog.md'}")
if also_update_citation:
click.echo(f" Would update: {CITATION_FILE}")
click.echo()
click.echo("--- Generated changelog ---")
click.echo(content)
return
filepath = save_changelog(version, content)
click.secho(f" ✓ Saved: {filepath}", fg="green")
if also_update_citation:
citation_path = update_citation(version)
click.secho(f" ✓ Updated: {citation_path}", fg="green")
click.echo()
click.echo("Next steps:")
click.echo(" • Review and edit the changelog as needed")
click.echo(" • Update docs/source/changelog.rst to include the new file")
@cli.command()
@click.option(
"--version", "version", required=True, help="Version number (e.g., 0.20.0)"
)
@click.option("--date", help="Release date as YYYY-MM-DD (default: today)")
@click.pass_context
def citation(ctx: click.Context, version: str, date: str | None) -> None:
"""Update CITATION.cff for a release."""
dry_run = ctx.obj["dry_run"]
display_date = date or datetime.now().strftime("%Y-%m-%d")
if dry_run:
click.secho("[DRY RUN]", fg="yellow", bold=True)
click.echo(f" Would update: {CITATION_FILE}")
click.echo(f" Version: v{version}")
click.echo(f" Date: {display_date}")
return
filepath = update_citation(version, date)
click.secho(f"✓ Updated: {filepath}", fg="green")
click.echo(f" Version: v{version}")
click.echo(f" Date: {display_date}")
@cli.command("fetch-releases")
@click.option("--tag", help="Fetch a specific release tag")
@click.option(
"--min-version",
default=DEFAULT_MIN_VERSION,
help=f"Minimum version to fetch (default: {DEFAULT_MIN_VERSION})",
)
@click.option("--force", is_flag=True, help="Overwrite existing changelog files")
@click.pass_context
def fetch_releases(
ctx: click.Context,
tag: str | None,
min_version: str,
force: bool,
) -> None:
"""Fetch existing release changelogs from GitHub.
Converts GitHub release notes to documentation-ready MyST markdown.
"""
dry_run = ctx.obj["dry_run"]
existing = get_existing_versions()
# Single tag mode
if tag:
tag = normalize_tag(tag)
version = version_from_tag(tag)
if version in existing and not force:
click.echo(
f"Changelog for {version} already exists. Use --force to overwrite."
)
return
if dry_run:
click.secho("[DRY RUN]", fg="yellow", bold=True)
click.echo(f" Would fetch: {version}")
return
_fetch_single_release(tag, version)
return
# Batch mode
click.echo(f"Existing versions: {', '.join(sorted(existing)) or '(none)'}")
click.echo("Fetching release list...")
tags = get_release_tags()
click.echo(f"Found {len(tags)} releases")
fetched = 0
prev_tag = None
for tag in reversed(tags):
version = version_from_tag(tag)
if not version_gte(version, min_version):
prev_tag = tag
continue
if version in existing and not force:
click.echo(f" Skipping {version} (exists)")
prev_tag = tag
continue
if dry_run:
click.echo(f" [DRY RUN] Would fetch {version}")
fetched += 1
else:
if _fetch_single_release(tag, version, prev_tag):
existing.add(version)
fetched += 1
prev_tag = tag
click.echo()
click.echo(f"Processed {fetched} changelog(s)")
if fetched > 0 and not dry_run:
click.echo()
click.echo("Next steps:")
click.echo(" • Update docs/source/changelog.rst to include new files")
def _fetch_single_release(tag: str, version: str, prev_tag: str | None = None) -> bool:
"""Fetch and save a single release changelog."""
click.echo(f" Fetching {version}...")
body = get_release_body(tag)
if not body and prev_tag:
click.echo(f" No body, generating from {prev_tag}...")
try:
body = generate_release_notes(tag, prev_tag)
except click.ClickException:
body = None
if not body:
click.secho(f" ✗ Could not get release notes for {tag}", fg="red", err=True)
return False
date = get_release_date(tag)
content = format_changelog(version, body, date=date)
filepath = save_changelog(version, content)
click.secho(f" ✓ Saved: {filepath}", fg="green")
return True
# =============================================================================
# Entry Point
# =============================================================================
def main() -> None:
"""Entry point."""
cli()
if __name__ == "__main__":
sys.exit(main() or 0)

View file

@ -1,8 +1,8 @@
{"levelname": "INFO", "module": "logger_utils", "message": "Log file will be saved in <>"}
{"levelname": "INFO", "module": "tex_file_writing", "message": "Writing <> to <>"}
{"levelname": "ERROR", "module": "tex_file_writing", "message": "LaTeX compilation error: LaTeX Error: File `notapackage.sty' not found.\n"}
{"levelname": "ERROR", "module": "tex_file_writing", "message": "Context of error: \n\\documentclass[preview]{standalone}\n-> \\usepackage{notapackage}\n\\begin{document}\n\\begin{center}\n\\frac{1}{0}\n"}
{"levelname": "ERROR", "module": "tex_file_writing", "message": "Context of error: \n\\documentclass[preview]{standalone}\n-> \\usepackage{notapackage}\n\\begin{document}\n\\begin{center}\n\\special{dvisvgm:raw <g id='unique000'>}\\frac{1}{0}\\special{dvisvgm:raw </g>}\n"}
{"levelname": "INFO", "module": "tex_file_writing", "message": "You do not have package notapackage.sty installed."}
{"levelname": "INFO", "module": "tex_file_writing", "message": "Install notapackage.sty it using your LaTeX package manager, or check for typos."}
{"levelname": "ERROR", "module": "tex_file_writing", "message": "LaTeX compilation error: Emergency stop.\n"}
{"levelname": "ERROR", "module": "tex_file_writing", "message": "Context of error: \n\\documentclass[preview]{standalone}\n-> \\usepackage{notapackage}\n\\begin{document}\n\\begin{center}\n\\frac{1}{0}\n"}
{"levelname": "ERROR", "module": "tex_file_writing", "message": "Context of error: \n\\documentclass[preview]{standalone}\n-> \\usepackage{notapackage}\n\\begin{document}\n\\begin{center}\n\\special{dvisvgm:raw <g id='unique000'>}\\frac{1}{0}\\special{dvisvgm:raw </g>}\n"}

View file

@ -53,7 +53,6 @@ def set_test_scene(scene_object: type[Scene], module_name: str, config):
tests_directory = Path(__file__).absolute().parent.parent
path_control_data = Path(tests_directory) / "control_data" / "graphical_units_data"
path = Path(path_control_data) / module_name
if not path.is_dir():
path.mkdir(parents=True)
path.mkdir(parents=True, exist_ok=True)
np.savez_compressed(path / str(scene), frame_data=data)
logger.info(f"Test data for {str(scene)} saved in {path}\n")

View file

@ -0,0 +1,67 @@
from __future__ import annotations
from manim import UP, Circle, Dot, FadeIn
from manim.animation.updaters.mobject_update_utils import turn_animation_into_updater
def test_turn_animation_into_updater_zero_run_time():
"""Test that turn_animation_into_updater handles zero run_time correctly."""
# Create a simple mobject and animation
mobject = Circle()
animation = FadeIn(mobject, run_time=0)
# Track updater calls
update_calls = []
original_updaters = mobject.updaters.copy()
# Call turn_animation_into_updater
result = turn_animation_into_updater(animation)
# Verify mobject is returned
assert result is mobject
# Get the updater that was added
assert len(mobject.updaters) == len(original_updaters) + 1
updater = mobject.updaters[-1]
# Simulate calling the updater
updater(mobject, dt=0.1)
# The updater should have finished and removed itself
assert len(mobject.updaters) == len(original_updaters)
assert updater not in mobject.updaters
# Animation should be in finished state
assert animation.total_time >= 0
def test_turn_animation_into_updater_positive_run_time_persists():
"""Test that updater persists with positive run_time."""
mobject = Circle()
animation = FadeIn(mobject, run_time=1.0)
original_updaters = mobject.updaters.copy()
# Call turn_animation_into_updater
result = turn_animation_into_updater(animation)
# Get the updater that was added
updater = mobject.updaters[-1]
# Simulate calling the updater (partial progress)
updater(mobject, dt=0.1)
# The updater should still be present (not finished)
assert len(mobject.updaters) == len(original_updaters) + 1
assert updater in mobject.updaters
def test_always():
d = Dot()
circ = Circle()
d.always.next_to(circ, UP)
assert len(d.updaters) == 1
# we should be able to chain updaters
d2 = Dot()
d.always.next_to(d2, UP).next_to(circ, UP)
assert len(d.updaters) == 3

View file

@ -7,6 +7,7 @@ import numpy as np
from manim import (
DEGREES,
DOWN,
GREEN,
LEFT,
ORIGIN,
RIGHT,
@ -89,7 +90,7 @@ def test_Polygram_get_vertex_groups():
for vertex_groups in vertex_groups_arr:
polygram = Polygram(*vertex_groups)
poly_vertex_groups = polygram.get_vertex_groups()
for poly_group, group in zip(poly_vertex_groups, vertex_groups, strict=False):
for poly_group, group in zip(poly_vertex_groups, vertex_groups, strict=True):
np.testing.assert_array_equal(poly_group, group)
# If polygram is a Polygram of a vertex group containing the start vertex N times,
@ -152,6 +153,18 @@ def test_BackgroundRectangle(manim_caplog):
)
def test_BackgroundRectangle_color_access():
"""Test that BackgroundRectangle color access works correctly.
Regression test for https://github.com/ManimCommunity/manim/issues/4419
"""
square = Square()
bg_rect = BackgroundRectangle(square, color=GREEN)
# Should not cause infinite recursion
assert bg_rect.color == GREEN
def test_Square_side_length_reflets_correct_width_and_height():
sq = Square(side_length=1).scale(3)
assert sq.side_length == 3

View file

@ -62,7 +62,7 @@ def test_add_labels():
expected_label_length = 6
num_line = NumberLine(x_range=[-4, 4])
num_line.add_labels(
dict(zip(list(range(-3, 3)), [Integer(m) for m in range(-1, 5)], strict=False)),
dict(zip(list(range(-3, 3)), [Integer(m) for m in range(-1, 5)], strict=True)),
)
actual_label_length = len(num_line.labels)
assert actual_label_length == expected_label_length, (

View file

@ -27,7 +27,7 @@ def test_bracelabel_copy(tmp_path, config):
config["text_dir"] = str(mediadir.joinpath("Text"))
config["tex_dir"] = str(mediadir.joinpath("Tex"))
for el in ["text_dir", "tex_dir"]:
Path(config[el]).mkdir(parents=True)
Path(config[el]).mkdir(parents=True, exist_ok=True)
# Before the refactoring of Mobject.copy(), the class BraceLabel was the
# only one to have a non-trivial definition of copy. Here we test that it

View file

@ -0,0 +1,19 @@
"""Tests for Table and related mobjects."""
from __future__ import annotations
from manim import Table
from manim.utils.color import GREEN
def test_highlighted_cell_color_access():
"""Test that accessing the color of a highlighted cell doesn't cause infinite recursion.
Regression test for https://github.com/ManimCommunity/manim/issues/4419
"""
table = Table([["This", "is a"], ["simple", "table"]])
rect = table.get_highlighted_cell((1, 1), color=GREEN)
# Should not raise RecursionError
color = rect.color
assert color == GREEN

View file

@ -10,7 +10,7 @@ from manim import MathTex, SingleStringMathTex, Tex, TexTemplate, tempconfig
def test_MathTex(config):
MathTex("a^2 + b^2 = c^2")
assert Path(config.media_dir, "Tex", "e4be163a00cf424f.svg").exists()
assert Path(config.media_dir, "Tex", "05bb0a41ed575f00.svg").exists()
def test_SingleStringMathTex(config):
@ -20,16 +20,125 @@ 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", "c3945e23e546c95a.svg").exists()
assert Path(config.media_dir, "Tex", "5384b41741a246bd.svg").exists()
def test_tex_temp_directory(tmpdir, monkeypatch):
@ -38,16 +147,16 @@ def test_tex_temp_directory(tmpdir, monkeypatch):
# tempconfig to change media directory to temporary directory by default
# we partially, revert that change here.
monkeypatch.chdir(tmpdir)
Path(tmpdir, "media").mkdir()
Path(tmpdir, "media").mkdir(exist_ok=True)
with tempconfig({"media_dir": "media"}):
Tex("The horse does not eat cucumber salad.")
assert Path("media", "Tex").exists()
assert Path("media", "Tex", "c3945e23e546c95a.svg").exists()
assert Path("media", "Tex", "5384b41741a246bd.svg").exists()
def test_percent_char_rendering(config):
Tex(r"\%")
assert Path(config.media_dir, "Tex", "4a583af4d19a3adf.tex").exists()
assert Path(config.media_dir, "Tex", "32509dd0ea993961.tex").exists()
def test_tex_whitespace_arg():
@ -108,7 +217,7 @@ def test_multi_part_tex_with_empty_parts():
for one_part_glyph, multi_part_glyph in zip(
one_part_fomula.family_members_with_points(),
multi_part_formula.family_members_with_points(),
strict=False,
strict=True,
):
np.testing.assert_allclose(one_part_glyph.points, multi_part_glyph.points)
@ -215,14 +324,14 @@ def test_tempconfig_resetting_tex_template(config):
def test_tex_garbage_collection(tmpdir, monkeypatch, config):
monkeypatch.chdir(tmpdir)
Path(tmpdir, "media").mkdir()
Path(tmpdir, "media").mkdir(exist_ok=True)
config.media_dir = "media"
tex_without_log = Tex("Hello World!") # d771330b76d29ffb.tex
assert Path("media", "Tex", "d771330b76d29ffb.tex").exists()
assert not Path("media", "Tex", "d771330b76d29ffb.log").exists()
tex_without_log = Tex("Hello World!") # 058a4e242c57db6d.tex
assert Path("media", "Tex", "058a4e242c57db6d.tex").exists()
assert not Path("media", "Tex", "058a4e242c57db6d.log").exists()
config.no_latex_cleanup = True
tex_with_log = Tex("Hello World, again!") # da27670a37b08799.tex
assert Path("media", "Tex", "da27670a37b08799.log").exists()
tex_with_log = Tex("Hello World, again!") # 45b4e7819cc20cb1.tex
assert Path("media", "Tex", "45b4e7819cc20cb1.log").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

@ -369,7 +369,7 @@ def test_vdict_init():
# Test VDict made from a python dict
VDict({"a": VMobject(), "b": VMobject(), "c": VMobject()})
# Test VDict made using zip
VDict(zip(["a", "b", "c"], [VMobject(), VMobject(), VMobject()], strict=False))
VDict(zip(["a", "b", "c"], [VMobject(), VMobject(), VMobject()], strict=True))
# If the value is of type Mobject, must raise a TypeError
with pytest.raises(TypeError):
VDict({"a": Mobject()})

View file

@ -20,7 +20,7 @@ from manim import (
tempconfig,
)
from manim import CoordinateSystem as CS
from manim.utils.color import BLUE, GREEN, ORANGE, RED, YELLOW
from manim.utils.color import BLUE, GREEN, ORANGE, PURE_YELLOW, RED
from manim.utils.testing.frames_comparison import frames_comparison
__module_test__ = "coordinate_system_opengl"
@ -152,7 +152,7 @@ def test_gradient_line_graph_x_axis(scene, using_opengl_renderer):
curve = axes.plot(
lambda x: 0.1 * x**3,
x_range=(-3, 3, 0.001),
colorscale=[BLUE, GREEN, YELLOW, ORANGE, RED],
colorscale=[BLUE, GREEN, PURE_YELLOW, ORANGE, RED],
colorscale_axis=0,
)
@ -167,7 +167,7 @@ def test_gradient_line_graph_y_axis(scene, using_opengl_renderer):
curve = axes.plot(
lambda x: 0.1 * x**3,
x_range=(-3, 3, 0.001),
colorscale=[BLUE, GREEN, YELLOW, ORANGE, RED],
colorscale=[BLUE, GREEN, PURE_YELLOW, ORANGE, RED],
colorscale_axis=1,
)

View file

@ -28,7 +28,7 @@ def test_bracelabel_copy(config, using_opengl_renderer, tmp_path):
config["text_dir"] = str(mediadir.joinpath("Text"))
config["tex_dir"] = str(mediadir.joinpath("Tex"))
for el in ["text_dir", "tex_dir"]:
Path(config[el]).mkdir(parents=True)
Path(config[el]).mkdir(parents=True, exist_ok=True)
# Before the refactoring of OpenGLMobject.copy(), the class BraceLabel was the
# only one to have a non-trivial definition of copy. Here we test that it

View file

@ -62,7 +62,7 @@ def test_add_labels():
expected_label_length = 6
num_line = NumberLine(x_range=[-4, 4])
num_line.add_labels(
dict(zip(list(range(-3, 3)), [Integer(m) for m in range(-1, 5)], strict=False)),
dict(zip(list(range(-3, 3)), [Integer(m) for m in range(-1, 5)], strict=True)),
)
actual_label_length = len(num_line.labels)
assert actual_label_length == expected_label_length, (

View file

@ -312,7 +312,7 @@ def test_vdict_init(using_opengl_renderer):
zip(
["a", "b", "c"],
[OpenGLVMobject(), OpenGLVMobject(), OpenGLVMobject()],
strict=False,
strict=True,
)
)
# If the value is of type OpenGLMobject, must raise a TypeError

View file

@ -9,7 +9,7 @@ from manim import MathTex, SingleStringMathTex, Tex
def test_MathTex(config, using_opengl_renderer):
MathTex("a^2 + b^2 = c^2")
assert Path(config.media_dir, "Tex", "e4be163a00cf424f.svg").exists()
assert Path(config.media_dir, "Tex", "05bb0a41ed575f00.svg").exists()
def test_SingleStringMathTex(config, using_opengl_renderer):
@ -28,7 +28,7 @@ def test_double_braces_testing(using_opengl_renderer, text_input, length_sub):
def test_tex(config, using_opengl_renderer):
Tex("The horse does not eat cucumber salad.")
assert Path(config.media_dir, "Tex", "c3945e23e546c95a.svg").exists()
assert Path(config.media_dir, "Tex", "5384b41741a246bd.svg").exists()
def test_tex_whitespace_arg(using_opengl_renderer):

View file

@ -32,7 +32,7 @@ def test_custom_coordinates(scene):
ax = Axes(x_range=[0, 10])
ax.add_coordinates(
dict(zip(list(range(1, 10)), [Tex("str") for _ in range(1, 10)], strict=False)),
dict(zip(list(range(1, 10)), [Tex("str") for _ in range(1, 10)], strict=True)),
)
scene.add(ax)
@ -226,7 +226,7 @@ def test_get_area(scene):
area2 = ax.get_area(
curve1,
x_range=(-4.5, -2),
color=(RED, YELLOW),
color=(RED, PURE_YELLOW),
opacity=0.2,
bounded_graph=curve2,
)
@ -266,7 +266,7 @@ def test_get_riemann_rectangles(scene, use_vectorized):
quadratic,
x_range=[-1.5, 1.5],
dx=0.15,
color=YELLOW,
color=PURE_YELLOW,
)
bounding_line = ax.plot(lambda x: 1.5 * x, color=BLUE_B, x_range=[3.3, 6])

View file

@ -26,7 +26,7 @@ def test_line_graph(scene):
first_line = plane.plot_line_graph(
x_values=[-3, 1],
y_values=[-2, 2],
line_color=YELLOW,
line_color=PURE_YELLOW,
)
second_line = plane.plot_line_graph(
x_values=[0, 2, 2, 4],
@ -71,7 +71,7 @@ def test_plot_surface_colorscale(scene):
param_trig,
u_range=(-3, 3),
v_range=(-3, 3),
colorscale=[BLUE, GREEN, YELLOW, ORANGE, RED],
colorscale=[BLUE, GREEN, PURE_YELLOW, ORANGE, RED],
)
scene.add(axes, trig_plane)
@ -151,7 +151,7 @@ def test_gradient_line_graph_x_axis(scene):
curve = axes.plot(
lambda x: 0.1 * x**3,
x_range=(-3, 3, 0.001),
colorscale=[BLUE, GREEN, YELLOW, ORANGE, RED],
colorscale=[BLUE, GREEN, PURE_YELLOW, ORANGE, RED],
colorscale_axis=0,
)
@ -166,7 +166,7 @@ def test_gradient_line_graph_y_axis(scene):
curve = axes.plot(
lambda x: 0.1 * x**3,
x_range=(-3, 3, 0.001),
colorscale=[BLUE, GREEN, YELLOW, ORANGE, RED],
colorscale=[BLUE, GREEN, PURE_YELLOW, ORANGE, RED],
colorscale_axis=1,
)

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)

View file

@ -268,21 +268,16 @@ def test_ImageInterpolation(scene):
img = ImageMobject(
np.uint8([[63, 0, 0, 0], [0, 127, 0, 0], [0, 0, 191, 0], [0, 0, 0, 255]]),
)
img.height = 2
img1 = img.copy()
img2 = img.copy()
img3 = img.copy()
img4 = img.copy()
img5 = img.copy()
img.height = 3
img1.set_resampling_algorithm(RESAMPLING_ALGORITHMS["nearest"])
img2.set_resampling_algorithm(RESAMPLING_ALGORITHMS["lanczos"])
img3.set_resampling_algorithm(RESAMPLING_ALGORITHMS["linear"])
img4.set_resampling_algorithm(RESAMPLING_ALGORITHMS["cubic"])
img5.set_resampling_algorithm(RESAMPLING_ALGORITHMS["box"])
algorithm_texts = ["nearest", "linear", "cubic"]
for i, algorithm_text in enumerate(algorithm_texts):
algorithm = RESAMPLING_ALGORITHMS[algorithm_text]
img_copy = img.copy().set_resampling_algorithm(algorithm)
position = img.height * (i - (len(algorithm_texts) - 1) / 2) * RIGHT
img_copy.move_to(position)
scene.add(img_copy)
scene.add(img1, img2, img3, img4, img5)
[s.shift(4 * LEFT + pos * 2 * RIGHT) for pos, s in enumerate(scene.mobjects)]
scene.wait()

View file

@ -24,7 +24,7 @@ def test_become(scene):
.set_opacity(0.25)
.set_color(GREEN)
)
s3 = s.copy().become(d, stretch=True).set_opacity(0.25).set_color(YELLOW)
s3 = s.copy().become(d, stretch=True).set_opacity(0.25).set_color(PURE_YELLOW)
scene.add(s, d, s1, s2, s3)
@ -37,7 +37,7 @@ def test_become_no_color_linking(scene):
scene.add(b)
b.become(a)
b.shift(1 * RIGHT)
b.set_stroke(YELLOW, opacity=1)
b.set_stroke(PURE_YELLOW, opacity=1)
@frames_comparison
@ -59,7 +59,7 @@ def test_vmobject_joint_types(scene):
]
)
lines = VGroup(*[angled_line.copy() for _ in range(len(LineJointType))])
for line, joint_type in zip(lines, LineJointType, strict=False):
for line, joint_type in zip(lines, LineJointType, strict=True):
line.joint_type = joint_type
lines.arrange(RIGHT, buff=1)

View file

@ -8,11 +8,11 @@ __module_test__ = "modifier_methods"
@frames_comparison
def test_Gradient(scene):
c = Circle(fill_opacity=1).set_color(color=[YELLOW, GREEN])
c = Circle(fill_opacity=1).set_color(color=[PURE_YELLOW, GREEN])
scene.add(c)
@frames_comparison
def test_GradientRotation(scene):
c = Circle(fill_opacity=1).set_color(color=[YELLOW, GREEN]).rotate(PI)
c = Circle(fill_opacity=1).set_color(color=[PURE_YELLOW, GREEN]).rotate(PI)
scene.add(c)

View file

@ -1,7 +1,7 @@
from manim.constants import LEFT
from manim.mobject.graphing.probability import BarChart
from manim.mobject.text.tex_mobject import MathTex
from manim.utils.color import BLUE, GREEN, RED, WHITE, YELLOW
from manim.utils.color import BLUE, GREEN, PURE_YELLOW, RED, WHITE
from manim.utils.testing.frames_comparison import frames_comparison
__module_test__ = "probability"
@ -69,13 +69,13 @@ def test_advanced_customization(scene):
chart = BarChart(values=[10, 40, 10, 20], bar_names=["one", "two", "three", "four"])
c_x_lbls = chart.x_axis.labels
c_x_lbls.set_color_by_gradient(GREEN, RED, YELLOW)
c_x_lbls.set_color_by_gradient(GREEN, RED, PURE_YELLOW)
c_y_nums = chart.y_axis.numbers
c_y_nums.set_color_by_gradient(BLUE, WHITE).shift(LEFT)
c_y_axis = chart.y_axis
c_y_axis.ticks.set_color(YELLOW)
c_y_axis.ticks.set_color(PURE_YELLOW)
c_bar_lbls = chart.get_bar_labels()

View file

@ -133,7 +133,7 @@ def test_SurfaceColorscale(scene):
u_range=[-3, 3],
)
trig_plane.set_fill_by_value(
axes=axes, colorscale=[BLUE, GREEN, YELLOW, ORANGE, RED]
axes=axes, colorscale=[BLUE, GREEN, PURE_YELLOW, ORANGE, RED]
)
scene.add(axes, trig_plane)
@ -158,7 +158,7 @@ def test_Y_Direction(scene):
)
surface_plane.set_style(fill_opacity=1)
surface_plane.set_fill_by_value(
axes=axes, colorscale=[(RED, -0.4), (YELLOW, 0), (GREEN, 0.4)], axis=1
axes=axes, colorscale=[(RED, -0.4), (PURE_YELLOW, 0), (GREEN, 0.4)], axis=1
)
scene.add(axes, surface_plane)

View file

@ -159,7 +159,7 @@ def test_AnimationBuilder(scene):
@frames_comparison(last_frame=False)
def test_ReplacementTransform(scene):
yellow = Square(fill_opacity=1.0, fill_color=YELLOW)
yellow = Square(fill_opacity=1.0, fill_color=PURE_YELLOW)
yellow.move_to([0, 0.75, 0])
green = Square(fill_opacity=1.0, fill_color=GREEN)

View file

@ -105,7 +105,7 @@ def create_plugin(tmp_path, python_version, random_string):
def _create_plugin(entry_point, class_name, function_name, all_dec=""):
entry_point = entry_point.format(plugin_name=plugin_name)
module_dir = plugin_dir / plugin_name
module_dir.mkdir(parents=True)
module_dir.mkdir(parents=True, exist_ok=True)
(module_dir / "__init__.py").write_text(
plugin_init_template.format(
class_name=class_name,

Some files were not shown because too many files have changed in this diff Show more