Merge branch 'main' into main

This commit is contained in:
Francisco Manríquez Novoa 2026-06-12 00:32:43 -04:00 committed by GitHub
commit 2f10c9db66
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
469 changed files with 22362 additions and 12321 deletions

View file

@ -1,4 +1,4 @@
[codespell]
check-hidden = True
skip = .git,*.js,*.js.map,*.css,*.css.map,*.html,*.po,*.pot,poetry.lock,*.log,*.svg
skip = .git,*.js,*.js.map,*.css,*.css.map,*.html,*.po,*.pot,uv.lock,*.log,*.svg
ignore-words = .codespell_ignorewords

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

6
.github/codeql.yml vendored
View file

@ -9,6 +9,12 @@ query-filters:
id: py/multiple-calls-to-init
- exclude:
id: py/missing-call-to-init
- exclude:
id: py/method-first-arg-is-not-self
- exclude:
id: py/cyclic-import
- exclude:
id: py/unsafe-cyclic-import
paths:
- manim
paths-ignore:

67
.github/release.yml vendored Normal file
View file

@ -0,0 +1,67 @@
# https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes
changelog:
exclude:
labels:
- duplicate/wontfix
- invalid
- question
- release
authors:
- dependabot[bot]
- pre-commit-ci[bot]
categories:
# High Impact
- title: "Breaking Changes 🚨"
labels:
- breaking changes
# Highlights
- title: "Highlights 🌟"
labels:
- highlight
# User-facing
- title: "New Features ✨"
labels:
- new feature
- title: "Enhancements 🚀"
labels:
- enhancement
- title: "Bug Fixes 🐛"
labels:
- pr:bugfix
- title: "Deprecations & Removals ⚠️"
labels:
- pr:deprecation
# Developer-facing
- title: "Documentation 📚"
labels:
- documentation
- title: "Testing 🧪"
labels:
- testing
- title: "Infrastructure & Build 🔨"
labels:
- infrastructure
- title: "Code Quality & Refactoring 🧹"
labels:
- maintenance
- refactor
- title: "Type Hints 📝"
labels:
- typehints
# Catch-all (must be last)
- title: "Other Changes"
labels:
- "*"

View file

@ -14,8 +14,8 @@ import subprocess
import sys
import tarfile
import tempfile
import typing
import urllib.request
from collections.abc import Generator
from contextlib import contextmanager
from pathlib import Path
from sys import stdout
@ -67,7 +67,7 @@ def run_command(command, cwd=None, env=None):
@contextmanager
def gha_group(title: str) -> typing.Generator:
def gha_group(title: str) -> Generator:
if not is_ci():
yield
return
@ -144,10 +144,38 @@ def main():
]
)
env_vars = {
# add the venv bin directory to PATH so that meson can find ninja
"PATH": f"{os.path.join(tmpdir, VENV_NAME, 'bin')}{os.pathsep}{os.environ['PATH']}",
}
# Inherit the current environment so PKG_CONFIG_PATH, CFLAGS, LDFLAGS, etc. are preserved.
env_vars = os.environ.copy()
# Prepend the venv bin directory so meson/ninja from the venv are used.
env_vars["PATH"] = f"{os.path.join(tmpdir, VENV_NAME, 'bin')}{os.pathsep}{env_vars.get('PATH','')}"
# Ensure Homebrew-provided pkgconfig and include/lib paths are present on macOS ARM.
if sys.platform == "darwin":
try:
# Try to get specific prefix for lzo (safer for opt path), fall back to generic brew prefix.
brew_prefix = subprocess.check_output(["brew", "--prefix", "lzo"], text=True).strip()
except subprocess.CalledProcessError:
try:
brew_prefix = subprocess.check_output(["brew", "--prefix"], text=True).strip()
except Exception:
brew_prefix = None
if brew_prefix:
# pkg-config files can live in lib/pkgconfig or opt/<pkg>/lib/pkgconfig
pkgconfig_paths = [f"{brew_prefix}/lib/pkgconfig", f"{brew_prefix}/opt/lzo/lib/pkgconfig"]
# merge with any existing PKG_CONFIG_PATH
existing_pc = env_vars.get("PKG_CONFIG_PATH", "")
merged_pc = ":".join([p for p in pkgconfig_paths if p]) + (f":{existing_pc}" if existing_pc else "")
env_vars["PKG_CONFIG_PATH"] = merged_pc
# Ensure compiler & linker flags include brew include/lib
existing_cflags = env_vars.get("CFLAGS", "")
existing_ldflags = env_vars.get("LDFLAGS", "")
env_vars["CFLAGS"] = f"-I{brew_prefix}/include {existing_cflags}".strip()
env_vars["LDFLAGS"] = f"-L{brew_prefix}/lib {existing_ldflags}".strip()
# Debugging: log environment keys relevant to detection
# logger.info(f"env vars for meson: {env_vars}")
with gha_group("Building and Installing Cairo"):
logger.info("Running meson setup")

View file

@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out a copy of the repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Check whether the citation metadata from CITATION.cff is valid
uses: citation-file-format/cffconvert-github-action@2.0.0

View file

@ -22,28 +22,25 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [ubuntu-22.04, macos-13, windows-latest]
python: ["3.9", "3.10", "3.11", "3.12"]
os: [ubuntu-22.04, macos-latest, windows-latest]
python: ["3.11", "3.12", "3.13", "3.14"]
include:
- os: macos-15-intel
python: "3.13"
steps:
- name: Checkout the repository
uses: actions/checkout@v4
- name: Install Poetry
run: |
pipx install "poetry==1.7.*"
poetry config virtualenvs.prefer-active-python true
uses: actions/checkout@v6
- name: Setup Python ${{ matrix.python }}
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python }}
cache: "poetry"
- name: Setup macOS PATH
if: runner.os == 'macOS'
run: |
echo "$HOME/.local/bin" >> $GITHUB_PATH
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
- name: Setup cache variables
shell: bash
@ -60,10 +57,13 @@ jobs:
- name: Install Texlive (Linux)
if: runner.os == 'Linux'
uses: teatimeguest/setup-texlive-action@v3
uses: zauguin/install-texlive@v4
with:
cache: true
packages: scheme-basic fontspec inputenc fontenc tipa mathrsfs calligra xcolor standalone preview doublestroke ms everysel setspace rsfs relsize ragged2e fundus-calligra microtype wasysym physics dvisvgm jknapltx wasy cm-super babel-english gnu-freefont mathastext cbfonts-fd xetex
packages: >
scheme-basic latex fontspec tipa calligra xcolor
standalone preview doublestroke setspace rsfs relsize
ragged2e fundus-calligra microtype wasysym physics dvisvgm jknapltx
wasy cm-super babel-english gnu-freefont mathastext cbfonts-fd xetex
- name: Start virtual display (Linux)
if: runner.os == 'Linux'
@ -72,12 +72,12 @@ jobs:
sudo /usr/bin/Xvfb $DISPLAY -screen 0 1280x1024x24 &
- name: Setup Cairo Cache
uses: actions/cache@v4
uses: actions/cache@v5
id: cache-cairo
if: runner.os == 'Linux' || runner.os == 'macOS'
with:
path: ${{ github.workspace }}/third_party
key: ${{ runner.os }}-dependencies-cairo-${{ hashFiles('.github/scripts/ci_build_cairo.py') }}
key: ${{ runner.os }}-${{ runner.arch }}-dependencies-cairo-${{ hashFiles('.github/scripts/ci_build_cairo.py') }}
- name: Build and install Cairo (Linux and macOS)
if: (runner.os == 'Linux' || runner.os == 'macOS') && steps.cache-cairo.outputs.cache-hit != 'true'
@ -88,7 +88,7 @@ jobs:
run: python .github/scripts/ci_build_cairo.py --set-env-vars
- name: Setup macOS cache
uses: actions/cache@v4
uses: actions/cache@v5
id: cache-macos
if: runner.os == 'macOS'
with:
@ -104,8 +104,8 @@ jobs:
oriPath=$PATH
sudo mkdir -p $PWD/macos-cache
echo "Install TinyTeX"
sudo curl -L -o "/tmp/TinyTeX.tgz" "https://github.com/yihui/tinytex-releases/releases/download/daily/TinyTeX-1.tgz"
sudo tar zxf "/tmp/TinyTeX.tgz" -C "$PWD/macos-cache"
sudo curl -L -o "/tmp/TinyTeX.tar.xz" "https://github.com/rstudio/tinytex-releases/releases/download/daily/TinyTeX-1-darwin.tar.xz"
sudo tar xJf "/tmp/TinyTeX.tar.xz" -C "$PWD/macos-cache"
export PATH="$PWD/macos-cache/TinyTeX/bin/universal-darwin:$PATH"
sudo tlmgr update --self
for i in "${ttp[@]}"; do
@ -119,18 +119,17 @@ jobs:
shell: bash
run: |
echo "/Library/TeX/texbin" >> $GITHUB_PATH
echo "$HOME/.poetry/bin" >> $GITHUB_PATH
echo "$PWD/macos-cache/TinyTeX/bin/universal-darwin" >> $GITHUB_PATH
- name: Setup Windows cache
id: cache-windows
if: runner.os == 'Windows'
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ${{ github.workspace }}\ManimCache
key: ${{ runner.os }}-dependencies-tinytex-${{ hashFiles('.github/manimdependency.json') }}-${{ steps.cache-vars.outputs.date }}-1
- uses: ssciwr/setup-mesa-dist-win@v2
- uses: ssciwr/setup-mesa-dist-win@v3
- name: Install system dependencies (Windows)
if: runner.os == 'Windows' && steps.cache-windows.outputs.cache-hit != 'true'
@ -138,14 +137,11 @@ jobs:
$tinyTexPackages = $(python -c "import json;print(' '.join(json.load(open('.github/manimdependency.json'))['windows']['tinytex']))") -Split ' '
$OriPath = $env:PATH
echo "Install Tinytex"
Invoke-WebRequest "https://github.com/yihui/tinytex-releases/releases/download/daily/TinyTeX-1.zip" -OutFile "$($env:TMP)\TinyTex.zip"
Expand-Archive -LiteralPath "$($env:TMP)\TinyTex.zip" -DestinationPath "$($PWD)\ManimCache\LatexWindows"
Invoke-WebRequest "https://github.com/rstudio/tinytex-releases/releases/download/daily/TinyTeX-1-windows.exe" -OutFile "$($env:TMP)\TinyTex.exe"
.$env:TMP\TinyTex.exe -o"$($PWD)\ManimCache\LatexWindows"
$env:Path = "$($PWD)\ManimCache\LatexWindows\TinyTeX\bin\windows;$($env:PATH)"
tlmgr update --self
foreach ($c in $tinyTexPackages){
$c=$c.Trim()
tlmgr install $c
}
tlmgr install $tinyTexPackages
$env:PATH=$OriPath
echo "Completed Latex"
@ -153,22 +149,20 @@ jobs:
if: runner.os == 'Windows'
run: |
$env:Path += ";" + "$($PWD)\ManimCache\LatexWindows\TinyTeX\bin\windows"
$env:Path = "$env:USERPROFILE\.poetry\bin;$($env:PATH)"
echo "$env:Path" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
- name: Install manim
- name: Install dependencies and manim
run: |
poetry config installer.modern-installation false
poetry install
uv sync --all-extras --locked
- name: Run tests
run: |
poetry run python -m pytest
uv run python -m pytest
- name: Run module doctests
run: |
poetry run python -m pytest -v --cov-append --ignore-glob="*opengl*" --doctest-modules manim
uv run python -m pytest -v --cov-append --ignore-glob="*opengl*" --doctest-modules manim
- name: Run doctests in rst files
run: |
cd docs && poetry run make doctest O=-tskip-manim
cd docs && uv run make doctest O=-tskip-manim

View file

@ -24,19 +24,19 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
config-file: ./.github/codeql.yml
queries: +security-and-quality
- name: Autobuild
uses: github/codeql-action/autobuild@v3
uses: github/codeql-action/autobuild@v4
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
uses: github/codeql-action/analyze@v4
with:
category: "/language:${{ matrix.language }}"

View file

@ -13,19 +13,19 @@ jobs:
if: github.event_name != 'release'
steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
platforms: linux/arm64,linux/amd64
push: true
@ -38,13 +38,13 @@ jobs:
if: github.event_name == 'release'
steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@ -61,7 +61,7 @@ jobs:
print(f"tag_name={ref_tag}", file=f)
- name: Build and push
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
platforms: linux/arm64,linux/amd64
push: true

View file

@ -6,65 +6,42 @@ on:
jobs:
release:
name: "Publish release"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
environment: release
permissions:
id-token: write
contents: write
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: 3.11
steps:
- uses: actions/checkout@v6
- name: Install dependencies
run: python -m pip install --upgrade poetry
run: sudo apt-get update && sudo apt-get install -y build-essential python3-dev libcairo2-dev libpango1.0-dev
# TODO: Set PYPI_API_TOKEN to api token from pip in secrets
- name: Configure pypi credentials
env:
PYPI_API_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
run: poetry config http-basic.pypi __token__ "$PYPI_API_TOKEN"
- name: Set up Python 3.13
uses: actions/setup-python@v6
with:
python-version: 3.13
- name: Publish release to pypi
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Build and push release to PyPI
run: |
poetry publish --build
poetry build
uv build
uv publish
- name: Store artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
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

@ -9,12 +9,15 @@ jobs:
build-and-publish-htmldocs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Set up Python 3.11
uses: actions/setup-python@v5
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: 3.11
python-version: 3.13
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Install system dependencies
run: |
@ -30,18 +33,17 @@ jobs:
babel-english ctex doublestroke dvisvgm frcursive fundus-calligra jknapltx \
mathastext microtype physics preview ragged2e relsize rsfs setspace standalone \
wasy wasysym
python -m pip install --upgrade poetry
poetry install
uv sync --extra typst
- name: Build and package documentation
run: |
cd docs/
poetry run make html
uv run make html
cd build/html/
tar -czvf ../html-docs.tar.gz *
- name: Store artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
path: ${{ github.workspace }}/docs/build/html-docs.tar.gz
name: html-docs.tar.gz

View file

@ -1,9 +1,9 @@
default_stages: [commit, push]
default_stages: [pre-commit, pre-push]
fail_fast: false
exclude: ^(manim/grpc/gen/|docs/i18n/)
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
rev: v6.0.0
hooks:
- id: check-ast
name: Validate Python
@ -11,14 +11,17 @@ repos:
- id: mixed-line-ending
- id: end-of-file-fixer
- id: check-toml
name: Validate Poetry
- repo: https://github.com/pre-commit/pygrep-hooks
rev: v1.10.0
name: Validate pyproject.toml
- repo: https://github.com/codespell-project/codespell
rev: v2.4.1
hooks:
- id: python-check-blanket-noqa
name: Precision flake ignores
- id: codespell
files: ^.*\.(py|md|rst)$
args: ["-L", "medias,nam"]
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.5.7
rev: v0.14.10
hooks:
- id: ruff
name: ruff lint
@ -26,19 +29,9 @@ repos:
args: [--exit-non-zero-on-fix]
- id: ruff-format
types: [python]
- repo: https://github.com/PyCQA/flake8
rev: 7.1.1
hooks:
- id: flake8
additional_dependencies:
[
flake8-docstrings==1.6.0,
flake8-pytest-style==1.5.0,
flake8-rst-docstrings==0.2.3,
flake8-simplify==0.14.1,
]
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.11.1
rev: v1.19.1
hooks:
- id: mypy
additional_dependencies:
@ -50,10 +43,3 @@ repos:
types-setuptools,
]
files: ^manim/
- repo: https://github.com/codespell-project/codespell
rev: v2.3.0
hooks:
- id: codespell
files: ^.*\.(py|md|rst)$
args: ["-L", "medias,nam"]

View file

@ -1,9 +1,13 @@
version: 2
sphinx:
configuration: docs/source/conf.py
build:
os: ubuntu-22.04
tools:
python: "3.11"
python: "3.13"
apt_packages:
- libpango1.0-dev

View file

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

View file

@ -152,6 +152,7 @@ Examples of conflicts of interest include:
* The reporter or reported person is a maintainer who regularly reviews your contributions
* The reporter or reported person is your metamour.
* The reporter or reported person is your family member
Committee members do not need to state why they have a conflict of interest, only that one exists. Other team members should not ask why the person has a conflict of interest.
Anyone who has a conflict of interest will remove themselves from the discussion of the incident, and recluse themselves from voting on a response to the report.

View file

@ -1,17 +1,15 @@
<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/manim_community/"><img src="https://img.shields.io/twitter/url/https/twitter.com/cloudposse.svg?style=social&label=Follow%20%40manim_community" alt="Twitter">
<a href="https://www.manim.community/discord/"><img src="https://img.shields.io/discord/581738731934056449.svg?label=discord&color=yellow&logo=discord" alt="Discord"></a>
<a href="https://github.com/psf/black"><img src="https://img.shields.io/badge/code%20style-black-000000.svg" alt="Code style: black">
<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">
<a href="https://manim.community/discord/"><img src="https://img.shields.io/discord/581738731934056449.svg?label=discord&color=yellow&logo=discord" alt="Discord"></a>
<a href="https://docs.manim.community/"><img src="https://readthedocs.org/projects/manimce/badge/?version=latest" alt="Documentation Status"></a>
<a href="https://pepy.tech/project/manim"><img src="https://pepy.tech/badge/manim/month?" alt="Downloads"> </a>
<img src="https://github.com/ManimCommunity/manim/workflows/CI/badge.svg" alt="CI">
<br />
<br />
@ -21,21 +19,23 @@
Manim is an animation engine for explanatory math videos. It's used to create precise animations programmatically, as demonstrated in the videos of [3Blue1Brown](https://www.3blue1brown.com/).
> NOTE: This repository is maintained by the Manim Community and is not associated with Grant Sanderson or 3Blue1Brown in any way (although we are definitely indebted to him for providing his work to the world). If you would like to study how Grant makes his videos, head over to his repository ([3b1b/manim](https://github.com/3b1b/manim)). This fork is updated more frequently than his, and it's recommended to use this fork if you'd like to use Manim for your own projects.
> [!NOTE]
> The community edition of Manim (ManimCE) is a version maintained and developed by the community. It was forked from 3b1b/manim, a tool originally created and open-sourced by Grant Sanderson, also creator of the 3Blue1Brown educational math videos. While Grant Sanderson continues to maintain his own repository, we recommend this version for its continued development, improved features, enhanced documentation, and more active community-driven maintenance. If you would like to study how Grant makes his videos, head over to his repository ([3b1b/manim](https://github.com/3b1b/manim)).
## Table of Contents:
- [Installation](#installation)
- [Usage](#usage)
- [Documentation](#documentation)
- [Docker](#docker)
- [Help with Manim](#help-with-manim)
- [Contributing](#contributing)
- [License](#license)
- [Installation](#installation)
- [Usage](#usage)
- [Documentation](#documentation)
- [Docker](#docker)
- [Help with Manim](#help-with-manim)
- [Contributing](#contributing)
- [License](#license)
## Installation
> **WARNING:** These instructions are for the community version _only_. Trying to use these instructions to install [3b1b/manim](https://github.com/3b1b/manim) or instructions there to install this version will cause problems. Read [this](https://docs.manim.community/en/stable/faq/installation.html#why-are-there-different-versions-of-manim) and decide which version you wish to install, then only follow the instructions for your desired version.
> [!CAUTION]
> These instructions are for the community version _only_. Trying to use these instructions to install [3b1b/manim](https://github.com/3b1b/manim) or instructions there to install this version will cause problems. Read [this](https://docs.manim.community/en/stable/faq/installation.html#why-are-there-different-versions-of-manim) and decide which version you wish to install, then only follow the instructions for your desired version.
Manim requires a few dependencies that must be installed prior to using it. If you
want to try it out first before installing it locally, you can do so
@ -88,9 +88,9 @@ The `-p` flag in the command above is for previewing, meaning the video file wil
Some other useful flags include:
- `-s` to skip to the end and just show the final frame.
- `-n <number>` to skip ahead to the `n`'th animation of a scene.
- `-f` show the file in the file browser.
- `-s` to skip to the end and just show the final frame.
- `-n <number>` to skip ahead to the `n`'th animation of a scene.
- `-f` show the file in the file browser.
For a thorough list of command line arguments, visit the [documentation](https://docs.manim.community/en/stable/guides/configuration.html).
@ -118,8 +118,8 @@ The contribution guide may become outdated quickly; we highly recommend joining
[Discord server](https://www.manim.community/discord/) to discuss any potential
contributions and keep up to date with the latest developments.
Most developers on the project use `poetry` for management. You'll want to have poetry installed and available in your environment.
Learn more about `poetry` at its [documentation](https://python-poetry.org/docs/) and find out how to install manim with poetry at the [manim dev-installation guide](https://docs.manim.community/en/stable/contributing/development.html) in the manim documentation.
Most developers on the project use `uv` for management. You'll want to have uv installed and available in your environment.
Learn more about `uv` at its [documentation](https://docs.astral.sh/uv/) and find out how to install manim with uv at the [manim dev-installation guide](https://docs.manim.community/en/latest/contributing/development.html) in the manim documentation.
## How to Cite Manim

382
agents/typst_selector.md Normal file
View file

@ -0,0 +1,382 @@
# Design: Sub-Expression Selection for `Typst` / `TypstMath`
## Problem Statement
Users need to interact with individual parts of a Typst-rendered expression:
color a variable, animate the numerator of a fraction, morph one sub-expression
into another, etc. The `MathTex` class solves this with:
1. **`{{ ... }}` double-brace notation** — splits the TeX string into named
submobject groups at compile time.
2. **`substrings_to_isolate` / `get_part_by_tex`** — identifies submobjects
whose TeX source matches a given string.
Both mechanisms ultimately rely on injecting `\special{dvisvgm:raw <g id='...'>}`
markers into the LaTeX source so that the resulting SVG contains `<g>` elements
with known `id` attributes, which SVGMobject's parser maps to `VGroup`
sub-trees via `id_to_vgroup_dict`.
We need an analogous mechanism for Typst.
## Key Discovery: `data-typst-label` in SVG Output
Typst's SVG renderer (`typst-svg` crate) already emits a `data-typst-label`
attribute on `<g>` elements whenever a `GroupItem` (hard frame) carries a
label. The relevant code path:
```rust
// typst-svg/src/lib.rs — render_group()
if let Some(label) = group.label {
svg.init().attr("data-typst-label", label.resolve());
}
```
A **hard frame** is created by the `box` element (and `block`, etc.). Crucially,
`box` can be used inline inside math mode, and labels can be attached to it.
### Proof of Concept
The following Typst helper wraps content in a labeled `box`:
```typst
#let grp(lbl, body) = [#box(body) #label(lbl)]
```
When used in math:
```typst
$ #grp("numerator", $a + b$) / #grp("denom", $c - d$) = #grp("result", $x$) $
```
The compiled SVG contains:
```xml
<g class="typst-group" ... data-typst-label="numerator">
<!-- glyphs for a + b -->
</g>
<g class="typst-group" ... data-typst-label="denom">
<!-- glyphs for c - d -->
</g>
<g class="typst-group" ... data-typst-label="result">
<!-- glyph for x -->
</g>
```
**Nesting works.** A `grp` wrapping a fraction that itself contains `grp`-ed
sub-parts produces nested `data-typst-label` groups:
```typst
$ #grp("whole-frac", $frac(#grp("numerator", $a + b$), #grp("denom", $c - d$))$) $
```
SVG output:
```xml
<g ... data-typst-label="whole-frac">
<g ... data-typst-label="numerator"> ... </g>
<g ... data-typst-label="denom"> ... </g>
<path class="typst-shape" ... /> <!-- fraction bar -->
</g>
```
### SVG Parser Compatibility
Manim uses `svgelements` to parse SVGs. The library preserves
`data-typst-label` in the `values` dictionary of `Group` objects, and it
propagates to child elements. Manim's `SVGMobject.get_mobjects_from()` already
iterates over groups and builds `id_to_vgroup_dict` keyed by the `id` attribute.
Extending this to also key by `data-typst-label` is straightforward.
## Proposed Interface
### 1. Explicit Groups via `{{ ... }}` Notation (Compile-Time)
Mirror the `MathTex` double-brace convention. Users write:
```python
eq = TypstMath("{{ a + b }} / {{ c - d }} = {{ x }}")
```
The pre-processor splits on `{{ ... }}` (reusing the same whitespace-guard
rules as `MathTex._split_double_braces`) and wraps each group in a labeled
`box` call:
```typst
$ #box[$a + b$] <_grp-0> / #box[$c - d$] <_grp-1> = #box[$x$] <_grp-2> $
```
Each group gets an auto-generated label (`_grp-0`, `_grp-1`, ...).
The `data-typst-label` attributes then appear in the SVG, and
`SVGMobject.get_mobjects_from()` can map them to `VGroup` entries in
`label_to_vgroup_dict` (or reuse `id_to_vgroup_dict`).
These groups become sub-mobjects of the `TypstMath` instance, accessible by
index:
```python
eq[0] # VGroup for "a + b"
eq[1] # VGroup for "c - d"
eq[2] # VGroup for "x"
```
(Non-group content between groups — like `/` and `=` — also becomes
its own submobject, mirroring `MathTex` behavior.)
**For `Typst` (text mode):** the same `{{ ... }}` notation applies, but the
wrapper is `#box[...]` without math delimiters.
### 2. Named Groups via Labels
Users can also assign explicit label names for retrieval by name:
```python
eq = Typst(
r"$ #box[$a + b$] <numerator> / #box[$c - d$] <denom> $"
)
eq.select("numerator").set_color(RED)
eq.select("denom").set_color(BLUE)
```
Alternatively, an even more ergonomic approach that hides the `box` boilerplate
and uses the `{{ ... : label }}` notation:
```python
eq = TypstMath("{{ a + b : numerator }} / {{ c - d : denom }}")
eq.select("numerator").set_color(RED)
```
Here the pre-processor recognizes `{{ content : label }}` and emits
`#box[$content$] <label>` in the Typst source.
### 3. The `.select()` Method
```python
def select(self, key: str | int) -> VGroup:
"""Select a labeled sub-expression.
Parameters
----------
key
Either a label name (string) matching a ``data-typst-label``
in the SVG, or an integer index into the auto-numbered
``{{ ... }}`` groups.
Returns
-------
VGroup
The sub-mobjects corresponding to the selected group.
Raises
------
KeyError
If no group with the given label/index exists.
"""
```
This returns a `VGroup` containing exactly the submobjects (paths) that
were rendered inside the corresponding `<g data-typst-label="...">` in the SVG.
## Implementation Plan
### Step 1: Extend `SVGMobject.get_mobjects_from()` to Track Labels
In `manim/mobject/svg/svg_mobject.py`, the group-walking loop already checks
for `id` attributes. Add a parallel check for `data-typst-label`:
```python
try:
group_name = str(element.values["id"])
except Exception:
# Fall back to data-typst-label if available
label = element.values.get("data-typst-label")
if label:
group_name = f"typst-label:{label}"
else:
group_name = f"numbered_group_{group_id_number}"
group_id_number += 1
```
This automatically populates `id_to_vgroup_dict` with label-keyed entries.
### Step 2: Pre-Processing `{{ ... }}` in Typst Source
Add a `_split_and_label_groups()` method that:
1. Scans the input for `{{ ... }}` or `{{ ... : label }}` patterns
(using the same whitespace-guard rules as `MathTex._split_double_braces`).
2. Replaces each group with `#box[$content$] <label>` (math mode) or
`#box[content] <label>` (text mode).
3. Records the mapping from label → original source string for later lookup.
### Step 3: `Typst.select()` / Index Access
- Store the ordered list of group labels and their source strings.
- `select(label_or_index)` looks up the corresponding `VGroup` from
`id_to_vgroup_dict` (using the `typst-label:...` key).
- `__getitem__(int)` returns the *n*-th group's `VGroup`.
### Step 4: Compatibility with `TransformMatchingTex` (future)
`TransformMatchingTex` (and its successor `TransformMatchingShapes`) works by
matching submobjects between two `MathTex` instances by their TeX string keys.
The same approach extends to `Typst` if each `{{ ... }}` group carries its
original source string as metadata. A `TransformMatchingTypst` animation could
match groups by label name or by source string equality.
## Open Design Questions
### Q1: Context-Aware Wrapping — Math vs. Text Mode
The `box` + `label` mechanism works identically in math and text mode, but the
**wrapping** of group content must match the surrounding context:
- **In text mode:** `{{ Hello : greeting }}``#box[Hello] <greeting>`
- **In math mode:** `{{ y^2 : second }}``#box[$y^2$] <second>`
Getting this wrong is not a silent error — it produces visually broken output.
Wrapping math content with `#box[y^2]` (no `$...$`) renders `y^2` as literal
text in the body font instead of as a math superscript.
This is a real problem for `Typst()`, where a single source string can mix text
and math freely:
```python
Typst("hello world, here is a formula: $x^2 + {{ y^2 : second }} = z^2$")
```
Here `{{ y^2 : second }}` is inside a `$ ... $` block, so it needs the
math-mode wrapper, but the pre-processor has no way to know this unless it
tracks `$` delimiters.
### The `#` prefix problem and math calls
A natural idea is to translate `{{ content }}` into a Typst function call like
`grp("lbl", content)`. However, this has a subtle but critical context
sensitivity: Typst has two different call conventions depending on context:
- **Math call** (no `#` prefix): `$ grp("lbl", a^2 + b) $` — arguments are
parsed **in math mode**. The content `a^2 + b` is math. ✓
- **Code call** (`#` prefix): `$ #grp("lbl", a^2 + b) $` — arguments are
parsed **in code mode**. `a^2` is a syntax error in code! ✗
So in math mode, the function MUST be called without `#` for args to stay in
math mode. In text/markup mode, the function MUST be called WITH `#` (that's
how you invoke code from markup), and content arguments need `[...]` wrapping:
```typst
// Text context: #grp("lbl", [Hello world])
// Math context: grp("lbl", a^2 + b)
```
The function definition is the same either way:
```typst
#let grp(lbl, body) = [#box(body) #label(lbl)]
```
This means the function call approach has **exactly the same context problem**
as the raw `#box` approach: the pre-processor must know whether it's in math or
text to emit the right calling convention.
### Further complication: string literals and content blocks
Even inside `TypstMath` (where everything is math), the scanner must avoid
`{{ }}` matches inside string literals or content blocks:
```python
TypstMath('x^2 + y^2 =_("Hello {{ world }}") z^2')
```
Here `{{ world }}` is inside a `"..."` string literal — it should NOT be
processed. Similarly, content blocks `[...]` inside math switch back to text
mode.
### Options
**A. `TypstMath`: math calls with simple string-aware scanning.**
For `TypstMath`, the entire body is math, so `{{ content }}` always becomes
`grp("_grp-N", content)` (no `#`, no `$...$`). The scanner just needs to
skip `"..."` string literals and `[...]` content blocks — no `$` tracking
needed. This is clean and robust.
**B. `Typst`: context-aware scanning (full parser).**
For the general `Typst` class, the scanner must additionally track `$...$`
math blocks (toggling a mode flag on unescaped `$`) to choose between
`grp(...)` (in math) and `#grp("lbl", [...])` (in text). It must also handle
string literals and content blocks inside math that switch context back. This
is doable but non-trivial — essentially a mini Typst lexer.
**C. `Typst`: no `{{ }}`, manual groups only.**
For the general `Typst` class, don't support `{{ }}` at all. Users write
`grp(...)` / `#grp(...)` themselves (with the helper injected into the
preamble). `{{ }}` is only available on `TypstMath`. This is simpler and
avoids the parsing complexity, at the cost of ergonomics for mixed-mode
documents.
**Recommendation:** Start with A (TypstMath only) and C (manual for Typst).
Upgrade to B later if demand warrants it — the function call infrastructure
is already in place, it's only the scanner that needs upgrading.
### Q2: What about "unlabeled" content between groups?
Like `MathTex`, the pieces of content *between* `{{ ... }}` groups should also
become their own submobjects (auto-labeled with sequential indices). For
example:
```python
TypstMath("{{ a }} + {{ b }} = {{ c }}")
# group-0: "a"
# group-1: "+" (auto-group for inter-group content)
# group-2: "b"
# group-3: "=" (auto-group for inter-group content)
# group-4: "c"
```
Each segment (group or inter-group) gets wrapped in its own labeled `box`.
### Q3: What happens with `box` and baseline alignment?
`box` is an inline element in Typst, and when used inside math mode it
participates in math layout. Testing confirms that fractions, superscripts, and
other constructs render correctly when their children are `box`-wrapped.
However, `box` creates a "hard frame" boundary which may subtly affect spacing
in edge cases (e.g., math operator spacing around a boxed expression). This
needs further testing; if issues arise, we could explore `block(breakable: false)`
or invisible `rect` wrappers as alternatives.
### Q4: Can we avoid the `#grp(...)` / `#box[...] <label>` verbosity?
Yes — the `{{ ... }}` double-brace notation is purely syntactic sugar that gets
pre-processed by Manim before the source reaches the Typst compiler. Users never
need to write raw `#box` or `#label()` calls unless they want finer control.
### Q5: String-based selection without explicit groups?
A future enhancement could support:
```python
eq = TypstMath(r"a + b = c")
eq.select("a") # finds submobjects corresponding to the glyph "a"
```
This is hard to do reliably because:
- Typst SVGs embed glyphs as `<use xlink:href="#gXXX">` references; there's no
text content in the SVG itself.
- A single variable in Typst may span multiple glyphs (e.g., `"alpha"` → one
glyph) or identical glyphs may appear multiple times.
A possible approach: at pre-processing time, wrap every "token" in the Typst
math source in its own labeled `box`. This would require a Typst math tokenizer
and is better suited for a v2 implementation.
## Summary: What Typst Gives Us
| Mechanism | How it works | SVG output |
|---|---|---|
| `#box(body) <label>` | Creates a hard-frame `GroupItem` with a `Label` | `<g data-typst-label="label">...</g>` |
| `#metadata(val) <label>` | Invisible; queryable via `typst query` CLI | No visual output (useful for CLI queries, not SVG) |
| Show rules on labels | `#show <label>: ...` | Transforms visual output but no automatic SVG grouping |
| `context query(<label>)` | Document introspection (positions, counters) | In-document only; not available from Python |
The `box` + `label` mechanism is the **only** one that produces identifiable
groups in the SVG output, making it the correct tool for sub-expression
selection in Manim.

View file

@ -1,43 +0,0 @@
# This file is automatically picked by pytest
# while running tests. So, that each test is
# run on difference temporary directories and avoiding
# errors.
from __future__ import annotations
import cairo
import moderngl
# If it is running Doctest the current directory
# is changed because it also tests the config module
# itself. If it's a normal test then it uses the
# tempconfig to change directories.
import pytest
from _pytest.doctest import DoctestItem
from manim import config, tempconfig
@pytest.fixture(autouse=True)
def temp_media_dir(tmpdir, monkeypatch, request):
if isinstance(request.node, DoctestItem):
monkeypatch.chdir(tmpdir)
yield tmpdir
else:
with tempconfig({"media_dir": str(tmpdir)}):
assert config.media_dir == str(tmpdir)
yield tmpdir
def pytest_report_header(config):
ctx = moderngl.create_standalone_context()
info = ctx.info
ctx.release()
return (
f"\nCairo Version: {cairo.cairo_version()}",
"\nOpenGL information",
"------------------",
f"vendor: {info['GL_VENDOR'].strip()}",
f"renderer: {info['GL_RENDERER'].strip()}",
f"version: {info['GL_VERSION'].strip()}\n",
)

View file

@ -1,38 +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 \
pkg-config \
make \
wget \
ghostscript
libegl-dev \
&& rm -rf /var/lib/apt/lists/*
# setup a minimal texlive installation
# 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 \
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 \
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
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"
# clone and build manim
COPY . /opt/manim
WORKDIR /opt/manim
RUN pip install --no-cache .[jupyterlab]
RUN pip install --no-cache-dir .[jupyterlab]
RUN pip install -r docs/requirements.txt
# ── 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-core \
fontconfig \
&& rm -rf /var/lib/apt/lists/*
RUN fc-cache -fv
# 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
COPY --from=builder /usr/local/texlive /usr/local/texlive
# 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
@ -45,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

@ -3,3 +3,6 @@ myst-parser
sphinx>=7.3
sphinx-copybutton
sphinxext-opengraph
sphinx-design
sphinx-reredirects
typst>=0.14

View file

@ -1,2 +1,3 @@
jupyterlab
sphinxcontrib-programoutput
typst>=0.14

View file

@ -2,13 +2,18 @@
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.1-changelog
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,587 @@
*******
v0.19.0
*******
:Date: January 20, 2025
Major Changes
=============
With the release of Manim v0.19.0, we've made lots of progress with making
Manim easier to install!
One of the biggest changes in this release is the replacement of the external
``ffmpeg`` dependency with the ``pyav`` library. This means that users no longer
have to install ``ffmpeg`` in order to use Manim - they can just ``pip install manim``
and it will work!
In light of this change, we also rewrote our :ref:`installation docs <local-installation>`
to recommend using a new tool called `uv <https://docs.astral.sh/uv/>`_ to install Manim.
.. note::
Do not worry if you installed Manim with any previous methods, like homebrew, pip,
choco, or scoop. Those methods will still work, and are not deprecated. However,
the recommended way to install Manim is now with `uv <https://docs.astral.sh/uv/>`_.
Contributors
============
A total of 54 people contributed to this
release. People with a '+' by their names authored a patch for the first
time.
* Aarush Deshpande
* Abulafia
* Achille Fouilleul +
* Benjamin Hackl
* CJ Lee +
* Cameron Burdgick +
* Chin Zhe Ning
* Christopher Hampson +
* ChungLeeCN +
* Eddie Ruiz +
* F. Muenkel +
* Francisco Manríquez Novoa
* Geoo Chi +
* Henrik Skov Midtiby +
* Hugo Chargois +
* Irvanal Haq +
* Jay Gupta +
* Laifsyn +
* Larry Skuse +
* Nemo2510 +
* Nikhil Iyer
* Nikhila Gurusinghe +
* Rehmatpal Singh +
* Romit Mohane +
* Saveliy Yusufov +
* Sir James Clark Maxwell
* Sophia Wisdom +
* Tristan Schulz
* VPC +
* Victorien
* Xiuyuan (Jack) Yuan +
* alembcke
* anagorko +
* czuzu +
* fogsong233 +
* jkjkil4 +
* modjfy +
* nitzanbueno +
* yang-tsao +
The patches included in this release have been reviewed by
the following contributors.
* Aarush Deshpande
* Achille Fouilleul
* Benjamin Hackl
* Christopher Hampson
* Eddie Ruiz
* Francisco Manríquez Novoa
* Henrik Skov Midtiby
* Hugo Chargois
* Irvanal Haq
* Jay Gupta
* Jérome Eertmans
* Nemo2510
* Nikhila Gurusinghe
* OliverStrait
* Saveliy Yusufov
* Sir James Clark Maxwell
* Tristan Schulz
* VPC
* Victorien
* Xiuyuan (Jack) Yuan
* alembcke
* github-advanced-security[bot]
Pull requests merged
====================
A total of 138 pull requests were merged for this release.
Highlights
----------
* :pr:`3501`: Replaced external ``ffmpeg`` dependency with ``pyav``
This change removes the need to have ``ffmpeg`` available as a command line tool
when using Manim. While ``pyav`` technically also uses ``ffmpeg`` internally,
the maintainers of ``pyav`` distribute it in their binary wheels.
* :pr:`3518`: Created a :class:`.HSV` color class, and added support for custom color spaces
This extends the color system of Manim and adds support to implement custom color spaces.
See the implementation of :class:`.HSV` for a practical example.
* :pr:`3930`: Completely reworked the installation instructions
As a consequence of removing the need for the external ``ffmpeg`` dependency,
we have reworked and massively simplified the installation instructions. Given
that practically, user-written scenes are effectively small self-contained Python
projects, the new instructions strongly recommend using the
`project and dependency management tool uv <https://docs.astral.sh/uv/>`__ to ensure
a consistent and reproducible environment.
* :pr:`3967`: Added support for Python 3.13
This adds support for Python 3.13, which brings the range of currently supported
Python versions to 3.9 -- 3.13.
* :pr:`3966`: :class:`.VGroup` can now be initialized with :class:`.VMobject` iterables
Groups of Mobjects can now be created by passing an iterable to the :class:`.VGroup`
constructors::
my_group = VGroup(Dot() for _ in range(10))
Breaking changes
----------------
* :pr:`3797`: Replaced ``Code.styles_list`` with :meth:`.Code.get_styles_list`
The ``styles_list`` attribute of the :class:`.Code` class has been replaced with
a class method :meth:`.Code.get_styles_list`. This method returns a list of all
available values for the ``formatter_style`` argument of :class:`.Code`.
* :pr:`3884`: Renamed parameters and variables conflicting with builtin functions
To avoid having keyword arguments named after builtin functions, the following
two changes were made to user-facing functions:
- ``ManimColor.from_hex(hex=...)`` is now ``ManimColor.from_hex(hex_str=...)``
- ``Scene.next_section(type=...)`` is now ``Scene.next_section(section_type=...)``
* :pr:`3922`: Removed ``inner_radius`` and ``outer_radius`` from :class:`.Sector` constructor
To construct a :class:`.Sector`, you now need to specify a ``radius`` (and an ``angle``).
In particular, :class:`.AnnularSector` still accepts both ``inner_radius`` and ``outer_radius``
arguments.
* :pr:`3964`: Allow :class:`.SurroundingRectangle` to accept multiple Mobjects
This changes the signature of :class:`.SurroundingRectangle` to accept
a sequence of Mobjects instead of a single Mobject. As a consequence, other
arguments that could be specified as positional ones before now need to be
specified as keyword arguments::
SurroundingRectangle(some_mobject, RED, 0.3) # raises error now
SurroundingRectangle(some_mobject, color=RED, buff=0.3) # correct usage
* :pr:`4115`: Completely rewrite the implementation of the :class:`.Code` mobject
This includes several breaking changes to the interface of the class to make it
more consistent. See the documentation of :class:`.Code` for a detailed description
of the new interface, and the description of the pull request :pr:`4115` for
an overview of changes to the old keyword arguments.
New features
------------
* :pr:`3148`: Added a ``colorscale`` argument to :meth:`.CoordinateSystem.plot`
* :pr:`3612`: Add three animations that together simulate a typing animation
* :pr:`3754`: Add ``@`` shorthand for :meth:`.Axes.coords_to_point` and :meth:`.Axes.point_to_coords`
* :pr:`3876`: Add :meth:`.Animation.set_default` class method
* :pr:`3903`: Preserve colors of LaTeX coloring commands
* :pr:`3913`: Added :mod:`.DVIPSNAMES` and :mod:`.SVGNAMES` color palettes
* :pr:`3933`: Added :class:`.ConvexHull`, :class:`.ConvexHull3D`, :class:`.Label` and :class:`.LabeledPolygram`
* :pr:`3992`: Add darker, lighter and contrasting methods to :class:`.ManimColor`
* :pr:`3997`: Add a time property to scene (:attr:`.Scene.time`)
* :pr:`4039`: Added the ``delay`` parameter to :func:`.turn_animation_into_updater`
Enhancements
------------
* :pr:`3829`: Rewrite :func:`~.bezier.get_quadratic_approximation_of_cubic` to produce smoother animated curves
* :pr:`3855`: Log execution time of sample scene in the ``manim checkhealth`` command
* :pr:`3888`: Significantly reduce rendering time with a separate thread for writing frames to stream
* :pr:`3890`: Better error messages for :class:`.DrawBorderThenFill`
* :pr:`3893`: Improve line rendering performance of :class:`.Cylinder`
* :pr:`3901`: Changed :attr:`.Square.side_length` attribute to a property
* :pr:`3965`: Added the ``scale_stroke`` boolean parameter to :meth:`.VMobject.scale`
* :pr:`3974`: Made videos embedded in Google Colab by default
* :pr:`3982`: Refactored ``run_time`` validation for :class:`.Animation` and :meth:`.Scene.wait`
* :pr:`4017`: Allow animations with ``run_time=0`` and implement convenience :class:`.Add` animation
* :pr:`4034`: Draw more accurate circular :class:`.Arc` mobjects for large angles
* :pr:`4051`: Add ``__hash__`` method to :class:`.ManimColor`
* :pr:`4108`: Remove duplicate declaration of ``__all__`` in :mod:`.vectorized_mobject`
Optimizations
-------------
* :pr:`3760`: Optimize :meth:`.VMobject.pointwise_become_partial`
* :pr:`3765`: Optimize :class:`.VMobject` methods which append to ``points``
* :pr:`3766`: Created and optimized Bézier splitting functions such as :func:`~.utils.bezier.partial_bezier_points()` in :mod:`manim.utils.bezier`
* :pr:`3767`: Optimized :func:`manim.utils.bezier.get_smooth_cubic_bezier_handle_points()`
* :pr:`3768`: Optimized :func:`manim.utils.bezier.is_closed`
* :pr:`3960`: Optimized :func:`~.bezier.interpolate` and :func:`~.bezier.bezier` in :mod:`manim.utils.bezier`
Fixed bugs
----------
* :pr:`3706`: Fixed :meth:`.Line.put_start_and_end_on` to use the actual end of an :class:`.Arrow3D`
* :pr:`3732`: Fixed infinite loop in OpenGL :meth:`.BackgroundRectangle.get_color`
* :pr:`3756`: Fix assertions and improve error messages when adding submobjects
* :pr:`3778`: Fixed :func:`.there_and_back_with_pause` rate function behaviour with different ``pause_ratio`` values
* :pr:`3786`: Fix :class:`.DiGraph` edges not fading correctly on :class:`.FadeIn` and :class:`.FadeOut`
* :pr:`3790`: Fixed the :func:`.get_nth_subpath` function expecting a numpy array
* :pr:`3832`: Convert audio files to ``.wav`` before passing to pydub
* :pr:`3680`: Fixed behavior of ``config.background_opacity < 1``
* :pr:`3839`: Fixed :attr:`.ManimConfig.format` not updating movie file extension
* :pr:`3885`: Fixed :meth:`.OpenGLMobject.invert` not reassembling family
* :pr:`3951`: Call :meth:`.Animation.finish` for animations in an :class:`.AnimationGroup`
* :pr:`4013`: Fixed scene skipping for :attr:`ManimConfig.upto_animation_number` set to 0
* :pr:`4089`: Fixed bug with opacity of :class:`.ImageMobject`
* :pr:`4091`: Fixed :meth:`.VMobject.add_points_as_corners` to safely handle empty ``points`` parameter
Documentation-related changes
-----------------------------
* :pr:`3669`: Added a :mod:`manim.typing` guide
* :pr:`3715`: Added docstrings to Brace
* :pr:`3745`: Underline tag should be ``<u></u>`` in the documentation
* :pr:`3818`: Automatically document usages of :class:`typing.TypeVar`
* :pr:`3849`: Fix incorrect ``versionadded`` version number in plugin section in docs
* :pr:`3851`: Rename ``manim.typing.Image`` type aliases to :class:`.PixelArray` to avoid conflict with ``PIL.Image``
* :pr:`3857`: Update installation instructions for MacOS (via dedicated brew formula)
* :pr:`3878`: Fixed typehint in ``types.rst`` and replaced outdated reference to ``manim.typing.Image`` with :class:`manim.typing.PixelArray`
* :pr:`3924`: Fix ``SyntaxWarning`` when building docs + use Python 3.13 for readthedocs build
* :pr:`3958`: Fix: ``.to_edge``'s example demonstration in docs
* :pr:`3972`: Refining documentations for :mod:`.moving_camera_scene` module
* :pr:`4032`: Bump version and create changelog for ``v0.19.0``
* :pr:`4044`: Added support for autodocumenting type aliases that use the ``type`` syntax
* :pr:`4065`: Polish documentation of :mod:`.utils.color.core` and remove ``interpolate_array`` function
* :pr:`4077`: Update README and documentation landing page, improve way how 3b1b is credited
* :pr:`4100`: Add wavy square example to :class:`.Homotopy`
* :pr:`4107`: Corrected a typo in the deep dive guide
* :pr:`4116`: Fix broken link to Poetry installation in contribution docs
Type Hints
----------
* :pr:`3751`: Added typehints to :mod:`manim.utils.iterables`
* :pr:`3803`: Added typings to :class:`.OpenGLMobject`
* :pr:`3902`: fixed a wrong type hint in :meth:`.Scene.restructure_mobjects`
* :pr:`3916`: fixed type hint in :meth:`.DrawBorderThenFill.interpolate_submobject`
* :pr:`3926`: Fixed some typehints of :class:`.ParametricFunction`
* :pr:`3940`: Fixed ``np.float_`` to ``np.float64`` while using numpy versions above 2.0
* :pr:`3961`: Added typehints to :mod:`manim.mobject.geometry`
* :pr:`3980`: Added new :class:`.PointND` and :class:`.PointND_Array` type aliases
* :pr:`3988`: Added type hints to :mod:`manim.cli` module
* :pr:`3999`: Add type annotations to :mod:`manim.utils`
* :pr:`4006`: Stopped ignoring :mod:`manim.plugins` errors in ``mypy.ini``
* :pr:`4007`: Added typings to :mod:`manim.__main__`
* :pr:`4027`: Rename ``InternalPoint3D`` to :class:`~.typing.Point3D`, ``Point3D`` to :class:`~.Point3DLike` and other point-related type aliases
* :pr:`4038`: Fixed type hint of :meth:`.Scene.play` to allow :attr:`.Mobject.animate`
Internal Improvements and Automation
------------------------------------
* :pr:`3737`: Fixed action for building downloadable documentation
* :pr:`3761`: Use ``--py39-plus`` in pre-commit
* :pr:`3777`: Add pyproject for ruff formatting
* :pr:`3779`: Switch pre-commit to use ``ruff`` for linting
* :pr:`3795`: Replace Pyupgrade with Ruff rule
* :pr:`3812`: Fix MacOS LaTeX CI
* :pr:`3853`: Change from tempconfig to a config fixture in tests
* :pr:`3858`: Update docker to use ENV x=y instead of ENV x y
* :pr:`3872`: Use ruff for pytest style
* :pr:`3873`: Use ruff instead of flake8-simplify
* :pr:`3877`: Fix pre-commit linting
* :pr:`3780`: Add Ruff Lint
* :pr:`3781`: Ignore Ruff format in git blame
* :pr:`3881`: Standardize docstrings with ruff pydocstyle rules
* :pr:`3882`: Change flake8-comprehensions and flake8-bugbear to ruff
* :pr:`3887`: Fix typo from HSV PR
* :pr:`3923`: Use Ruff pygrep rules
* :pr:`3925`: Use Github Markdown on README
* :pr:`3955`: Use ``subprocess`` instead of ``os.system``.
* :pr:`3956`: Set AAC codec for audio in mp4 files, add transcoding utility
* :pr:`4069`: Include Noto fonts in Docker image
* :pr:`4102`: Remove PT004 from Ruff ignore rules
Dependencies
------------
* :pr:`3739`: [pre-commit.ci] pre-commit autoupdate
* :pr:`3746`: Bump tqdm from 4.66.1 to 4.66.3
* :pr:`3750`: Bump jinja2 from 3.1.3 to 3.1.4
* :pr:`3776`: Bump requests from 2.31.0 to 2.32.0
* :pr:`3784`: [pre-commit.ci] pre-commit autoupdate
* :pr:`3794`: [pre-commit.ci] pre-commit autoupdate
* :pr:`3796`: Bump tornado from 6.4 to 6.4.1
* :pr:`3801`: [pre-commit.ci] pre-commit autoupdate
* :pr:`3809`: [pre-commit.ci] pre-commit autoupdate
* :pr:`3810`: Bump urllib3 from 2.2.1 to 2.2.2
* :pr:`3823`: [pre-commit.ci] pre-commit autoupdate
* :pr:`3827`: Fix docker build
* :pr:`3834`: [pre-commit.ci] pre-commit autoupdate
* :pr:`3835`: Bump docker/build-push-action from 5 to 6
* :pr:`3841`: Bump certifi from 2024.2.2 to 2024.7.4
* :pr:`3844`: [pre-commit.ci] pre-commit autoupdate
* :pr:`3847`: Bump zipp from 3.18.2 to 3.19.1
* :pr:`3865`: [pre-commit.ci] pre-commit autoupdate
* :pr:`3880`: [pre-commit.ci] pre-commit autoupdate
* :pr:`3889`: [pre-commit.ci] pre-commit autoupdate
* :pr:`3895`: Lock `poetry.lock`
* :pr:`3896`: [pre-commit.ci] pre-commit autoupdate
* :pr:`3904`: [pre-commit.ci] pre-commit autoupdate
* :pr:`3911`: [pre-commit.ci] pre-commit autoupdate
* :pr:`3918`: [pre-commit.ci] pre-commit autoupdate
* :pr:`3929`: [pre-commit.ci] pre-commit autoupdate
* :pr:`3931`: Bump cryptography from 43.0.0 to 43.0.1
* :pr:`3987`: [pre-commit.ci] pre-commit autoupdate
* :pr:`4023`: Bump tornado from 6.4.1 to 6.4.2
* :pr:`4035`: [pre-commit.ci] pre-commit autoupdate
* :pr:`4037`: Cap ``pyav`` version

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

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

View file

@ -51,11 +51,22 @@ extensions = [
"sphinx.ext.inheritance_diagram",
"sphinxcontrib.programoutput",
"myst_parser",
"sphinx_design",
"sphinx_reredirects",
]
# Automatically generate stub pages when using the .. autosummary directive
autosummary_generate = True
myst_enable_extensions = ["colon_fence", "amsmath", "deflist"]
# redirects (for moved / deleted pages)
redirects = {
"installation/linux": "uv.html",
"installation/macos": "uv.html",
"installation/windows": "uv.html",
}
# generate documentation from type hints
ALIAS_DOCS_DICT = parse_module_attributes()[0]
autodoc_typehints = "description"
@ -113,7 +124,6 @@ html_theme_options = {
"source_repository": "https://github.com/ManimCommunity/manim/",
"source_branch": "main",
"source_directory": "docs/source/",
"top_of_page_button": None,
"light_logo": "manim-logo-sidebar.svg",
"dark_logo": "manim-logo-sidebar-dark.svg",
"light_css_variables": {
@ -146,11 +156,13 @@ html_title = f"Manim Community v{manim.__version__}"
# This specifies any additional css files that will override the theme's
html_css_files = ["custom.css"]
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

@ -0,0 +1,249 @@
# Manim Development Process
## For first-time contributors
1. Install git:
For instructions see <https://git-scm.com/>.
2. Fork the project:
Go to <https://github.com/ManimCommunity/manim> and click the "fork" button
to create a copy of the project for you to work on. You will need a
GitHub account. This will allow you to make a "Pull Request" (PR)
to the ManimCommunity repo later on.
3. Clone your fork to your local computer:
```shell
git clone https://github.com/<your-username>/manim.git
```
GitHub will provide both a SSH (`git@github.com:<your-username>/manim.git`) and
HTTPS (`https://github.com/<your-username>/manim.git`) URL for cloning.
You can use SSH if you have SSH keys setup.
:::{WARNING}
Do not clone the ManimCommunity repository. You must clone your own
fork.
:::
4. Change the directory to enter the project folder:
```shell
cd manim
```
5. Add the upstream repository, ManimCommunity:
```shell
git remote add upstream https://github.com/ManimCommunity/manim.git
```
6. Now, `git remote -v` should show two remote repositories named:
- `origin`, your forked repository
- `upstream` the ManimCommunity repository
7. Install the Python project management tool `uv`, as recommended
in our {doc}`installation guide for users </installation/uv>`.
8. Let `uv` create a virtual environment for your development
installation by running
```shell
uv sync
```
In case you need (or want) to install some of the optional dependency
groups defined in our [`pyproject.toml`](https://github.com/ManimCommunity/manim/blob/main/pyproject.toml),
run `uv sync --all-extras`, or pass the `--extra` flag with the
name of a group, for example `uv sync --extra jupyterhub`.
9. Install Pre-Commit:
```shell
uv run pre-commit install
```
This will ensure during development that each of your commits is properly
formatted against our linter and formatters.
You are now ready to work on Manim!
## Develop your contribution
1. Checkout your local repository's main branch and pull the latest
changes from ManimCommunity, `upstream`, into your local repository:
```shell
git switch main
git pull --rebase upstream main
```
2. Create a branch for the changes you want to work on rather than working
off of your local main branch:
```shell
git switch -c <new branch name> upstream/main
```
This ensures you can easily update your local repository's main with the
first step and switch branches to work on multiple features.
3. Write some awesome code!
You're ready to make changes in your local repository's branch.
You can add local files you've changed within the current directory with
`git add .`, or add specific files with
```shell
git add <file/directory>
```
and commit these changes to your local history with `git commit`. If you
have installed pre-commit, your commit will succeed only if none of the
hooks fail.
:::{tip}
When crafting commit messages, it is highly recommended that
you adhere to [these guidelines](https://www.conventionalcommits.org/en/v1.0.0/).
:::
4. Add new or update existing tests.
Depending on your changes, you may need to update or add new tests. For new
features, it is required that you include tests with your PR. Details of
our testing system are explained in the {doc}`testing guide <testing>`.
5. Update docstrings and documentation:
Update the docstrings (the text in triple quotation marks) of any functions
or classes you change and include them with any new functions you add.
See the {doc}`documentation guide <docs/docstrings>` for more information about how we
prefer our code to be documented. The content of the docstrings will be
rendered in the {doc}`reference manual <../reference>`.
:::{tip}
Use the {mod}`manim directive for Sphinx <manim.utils.docbuild.manim_directive>` to add examples
to the documentation!
:::
As far as development on your local machine goes, these are the main steps you
should follow.
(polishing-changes-and-submitting-a-pull-request)=
## Polishing Changes and Submitting a Pull Request
As soon as you are ready to share your local changes with the community
so that they can be discussed, go through the following steps to open a
pull request. A pull request signifies to the ManimCommunity organization,
"Here are some changes I wrote; I think it's worthwhile for you to maintain
them."
:::{note}
You do not need to have everything (code/documentation/tests) complete
to open a pull request (PR). If the PR is still under development, please
mark it as a draft. Community developers will still be able to review the
changes, discuss yet-to-be-implemented changes, and offer advice; however,
the more complete your PR, the quicker it will be merged.
:::
1. Update your fork on GitHub to reflect your local changes:
```shell
git push -u origin <branch name>
```
Doing so creates a new branch on your remote fork, `origin`, with the
contents of your local repository on GitHub. In subsequent pushes, this
local branch will track the branch `origin` and `git push` is enough.
2. Make a pull request (PR) on GitHub.
In order to make the ManimCommunity development team aware of your changes,
you can make a PR to the ManimCommunity repository from your fork.
:::{WARNING}
Make sure to select `ManimCommunity/manim` instead of `3b1b/manim`
as the base repository!
:::
Choose the branch from your fork as the head repository - see the
screenshot below.
```{image} /_static/pull-requests.png
:align: center
```
Please make sure you follow the template (this is the default
text you are shown when first opening the 'New Pull Request' page).
Your changes are eligible to be merged if:
1. there are no merge conflicts
2. the tests in our pipeline pass
3. at least one (two for more complex changes) Community Developer approves the changes
You can check for merge conflicts between the current upstream/main and
your branch by executing `git pull upstream main` locally. If this
generates any merge conflicts, you need to resolve them and push an
updated version of the branch to your fork of the repository.
Our pipeline consists of a series of different tests that ensure
that Manim still works as intended and that the code you added
sticks to our coding conventions.
- **Code style**: We use the code style imposed
by [Black](https://black.readthedocs.io/en/stable/), [isort](https://pycqa.github.io/isort/)
and [flake8](https://flake8.pycqa.org/en/latest/). The GitHub pipeline
makes sure that the (Python) files changed in your pull request
also adhere to this code style. If this step of the pipeline fails,
fix your code formatting automatically by running `black <file or directory>` and `isort <file or directory>`.
To fix code style problems, run `flake8 <file or directory>` for a style report, and then fix the problems
manually that were detected by `flake8`.
- **Tests**: The pipeline runs Manim's test suite on different operating systems
(the latest versions of Ubuntu, macOS, and Windows) for different versions of Python.
The test suite consists of two different kinds of tests: integration tests
and doctests. You can run them locally by executing `uv run pytest`
and `uv run pytest --doctest-modules manim`, respectively, from the
root directory of your cloned fork.
- **Documentation**: We also build a version of the documentation corresponding
to your pull request. Make sure not to introduce any Sphinx errors, and have
a look at the built HTML files to see whether the formatting of the documentation
you added looks as you intended. You can build the documentation locally
by running `make html` from the `docs` directory. Make sure you have [Graphviz](https://graphviz.org/)
installed locally in order to build the inheritance diagrams. See {doc}`docs` for
more information.
Finally, if the pipeline passes and you are satisfied with your changes: wait for
feedback and iterate over any requested changes. You will likely be asked to
edit or modify your PR in one way or another during this process. This is not
an indictment of your work, but rather a strong signal that the community
wants to merge your changes! Once approved, your changes may be merged!
### Further useful guidelines
1. When submitting a PR, please mention explicitly if it includes breaking changes.
2. When submitting a PR, make sure that your proposed changes are as general as
possible, and ready to be taken advantage of by all of Manim's users. In
particular, leave out any machine-specific configurations, or any personal
information it may contain.
3. If you are a maintainer, please label issues and PRs appropriately and
frequently.
4. When opening a new issue, if there are old issues that are related, add a link
to them in your new issue (even if the old ones are closed).
5. When submitting a code review, it is highly recommended that you adhere to
[these general guidelines](https://conventionalcomments.org/).
6. If you find stale or inactive issues that seem to be irrelevant, please post
a comment saying 'This issue should be closed', and a community developer
will take a look.
7. Please do as much as possible to keep issues, PRs, and development in
general as tidy as possible.
You can find examples for the `docs` in several places:
the {doc}`Example Gallery <../examples>`, {doc}`Tutorials <../tutorials/index>`,
and {doc}`Reference Classes <../reference>`.
**Thank you for contributing!**

View file

@ -1,288 +0,0 @@
=========================
Manim Development Process
=========================
For first-time contributors
---------------------------
#. Install git:
For instructions see https://git-scm.com/.
#. Fork the project:
Go to https://github.com/ManimCommunity/manim and click the "fork" button
to create a copy of the project for you to work on. You will need a
GitHub account. This will allow you to make a "Pull Request" (PR)
to the ManimCommunity repo later on.
#. Clone your fork to your local computer:
.. code-block:: shell
git clone https://github.com/<your-username>/manim.git
GitHub will provide both a SSH (``git@github.com:<your-username>/manim.git``) and
HTTPS (``https://github.com/<your-username>/manim.git``) URL for cloning.
You can use SSH if you have SSH keys setup.
.. WARNING::
Do not clone the ManimCommunity repository. You must clone your own
fork.
#. Change the directory to enter the project folder:
.. code-block:: shell
cd manim
#. Add the upstream repository, ManimCommunity:
.. code-block:: shell
git remote add upstream https://github.com/ManimCommunity/manim.git
#. Now, ``git remote -v`` should show two remote repositories named:
- ``origin``, your forked repository
- ``upstream`` the ManimCommunity repository
#. Install Manim:
- Follow the steps in our :doc:`installation instructions
<../installation>` to install **Manim's system dependencies**.
We also recommend installing a LaTeX distribution.
- We recommend using `Poetry <https://python-poetry.org>`__ to manage your
developer installation of Manim. Poetry is a tool for dependency
management and packaging in Python. It allows you to declare the libraries
your project depends on, and it will manage (install / update) them
for you. In addition, Poetry provides a simple interface for
managing virtual environments.
If you choose to use Poetry as well, follow `Poetry's installation
guidelines <https://python-poetry.org/docs/master/#installing-with-pipx>`__
to install it on your system, then run ``poetry install`` from
your cloned repository. Poetry will then install Manim, as well
as create and enter a virtual environment. You can always re-enter
that environment by running ``poetry shell``.
- In case you want to install extra dependencies that are defined in
the ``[tool.poetry.extras]`` section of ``pyproject.toml``, this can be done by passing
the ``-E`` flag, for example ``poetry install -E jupyterlab -E gui``.
- In case you decided against Poetry, you can install Manim via pip
by running ``python3 -m pip install .``. Note that due to our
development infrastructure being based on Poetry, we currently
do not support editable installs via ``pip``, so you will have
to re-run this command every time you make changes to the source
code.
.. note::
The following steps assume that you chose to install and work with
Poetry.
#. Install Pre-Commit:
.. code-block:: shell
poetry run pre-commit install
This will ensure during development that each of your commits is properly
formatted against our linter and formatters, ``black``, ``flake8``,
``isort`` and ``codespell``.
You are now ready to work on Manim!
Develop your contribution
-------------------------
#. Checkout your local repository's main branch and pull the latest
changes from ManimCommunity, ``upstream``, into your local repository:
.. code-block:: shell
git checkout main
git pull --rebase upstream main
#. Create a branch for the changes you want to work on rather than working
off of your local main branch:
.. code-block:: shell
git checkout -b <new branch name> upstream/main
This ensures you can easily update your local repository's main with the
first step and switch branches to work on multiple features.
#. Write some awesome code!
You're ready to make changes in your local repository's branch.
You can add local files you've changed within the current directory with
``git add .``, or add specific files with
.. code-block:: shell
git add <file/directory>
and commit these changes to your local history with ``git commit``. If you
have installed pre-commit, your commit will succeed only if none of the
hooks fail.
.. tip::
When crafting commit messages, it is highly recommended that
you adhere to `these guidelines <https://www.conventionalcommits.org/en/v1.0.0/>`_.
#. Add new or update existing tests.
Depending on your changes, you may need to update or add new tests. For new
features, it is required that you include tests with your PR. Details of
our testing system are explained in the :doc:`testing guide <testing>`.
#. Update docstrings and documentation:
Update the docstrings (the text in triple quotation marks) of any functions
or classes you change and include them with any new functions you add.
See the :doc:`documentation guide <docs/docstrings>` for more information about how we
prefer our code to be documented. The content of the docstrings will be
rendered in the :doc:`reference manual <../reference>`.
.. tip::
Use the :mod:`manim directive for Sphinx <manim.utils.docbuild.manim_directive>` to add examples
to the documentation!
As far as development on your local machine goes, these are the main steps you
should follow.
.. _polishing-changes-and-submitting-a-pull-request:
Polishing Changes and Submitting a Pull Request
-----------------------------------------------
As soon as you are ready to share your local changes with the community
so that they can be discussed, go through the following steps to open a
pull request. A pull request signifies to the ManimCommunity organization,
"Here are some changes I wrote; I think it's worthwhile for you to maintain
them."
.. note::
You do not need to have everything (code/documentation/tests) complete
to open a pull request (PR). If the PR is still under development, please
mark it as a draft. Community developers will still be able to review the
changes, discuss yet-to-be-implemented changes, and offer advice; however,
the more complete your PR, the quicker it will be merged.
#. Update your fork on GitHub to reflect your local changes:
.. code-block:: shell
git push -u origin <branch name>
Doing so creates a new branch on your remote fork, ``origin``, with the
contents of your local repository on GitHub. In subsequent pushes, this
local branch will track the branch ``origin`` and ``git push`` is enough.
#. Make a pull request (PR) on GitHub.
In order to make the ManimCommunity development team aware of your changes,
you can make a PR to the ManimCommunity repository from your fork.
.. WARNING::
Make sure to select ``ManimCommunity/manim`` instead of ``3b1b/manim``
as the base repository!
Choose the branch from your fork as the head repository - see the
screenshot below.
.. image:: /_static/pull-requests.png
:align: center
Please make sure you follow the template (this is the default
text you are shown when first opening the 'New Pull Request' page).
Your changes are eligible to be merged if:
#. there are no merge conflicts
#. the tests in our pipeline pass
#. at least one (two for more complex changes) Community Developer approves the changes
You can check for merge conflicts between the current upstream/main and
your branch by executing ``git pull upstream main`` locally. If this
generates any merge conflicts, you need to resolve them and push an
updated version of the branch to your fork of the repository.
Our pipeline consists of a series of different tests that ensure
that Manim still works as intended and that the code you added
sticks to our coding conventions.
- **Code style**: We use the code style imposed
by `Black <https://black.readthedocs.io/en/stable/>`_, `isort <https://pycqa.github.io/isort/>`_
and `flake8 <https://flake8.pycqa.org/en/latest/>`_. The GitHub pipeline
makes sure that the (Python) files changed in your pull request
also adhere to this code style. If this step of the pipeline fails,
fix your code formatting automatically by running ``black <file or directory>`` and ``isort <file or directory>``.
To fix code style problems, run ``flake8 <file or directory>`` for a style report, and then fix the problems
manually that were detected by ``flake8``.
- **Tests**: The pipeline runs Manim's test suite on different operating systems
(the latest versions of Ubuntu, macOS, and Windows) for different versions of Python.
The test suite consists of two different kinds of tests: integration tests
and doctests. You can run them locally by executing ``poetry run pytest``
and ``poetry run pytest --doctest-modules manim``, respectively, from the
root directory of your cloned fork.
- **Documentation**: We also build a version of the documentation corresponding
to your pull request. Make sure not to introduce any Sphinx errors, and have
a look at the built HTML files to see whether the formatting of the documentation
you added looks as you intended. You can build the documentation locally
by running ``make html`` from the ``docs`` directory. Make sure you have `Graphviz <https://graphviz.org/>`_
installed locally in order to build the inheritance diagrams. See :doc:`docs` for
more information.
Finally, if the pipeline passes and you are satisfied with your changes: wait for
feedback and iterate over any requested changes. You will likely be asked to
edit or modify your PR in one way or another during this process. This is not
an indictment of your work, but rather a strong signal that the community
wants to merge your changes! Once approved, your changes may be merged!
Further useful guidelines
=========================
#. When submitting a PR, please mention explicitly if it includes breaking changes.
#. When submitting a PR, make sure that your proposed changes are as general as
possible, and ready to be taken advantage of by all of Manim's users. In
particular, leave out any machine-specific configurations, or any personal
information it may contain.
#. If you are a maintainer, please label issues and PRs appropriately and
frequently.
#. When opening a new issue, if there are old issues that are related, add a link
to them in your new issue (even if the old ones are closed).
#. When submitting a code review, it is highly recommended that you adhere to
`these general guidelines <https://conventionalcomments.org/>`_.
#. If you find stale or inactive issues that seem to be irrelevant, please post
a comment saying 'This issue should be closed', and a community developer
will take a look.
#. Please do as much as possible to keep issues, PRs, and development in
general as tidy as possible.
You can find examples for the ``docs`` in several places:
the :doc:`Example Gallery <../examples>`, :doc:`Tutorials <../tutorials/index>`,
and :doc:`Reference Classes <../reference>`.
**Thank you for contributing!**

View file

@ -22,32 +22,51 @@ in space. For example:
.. code-block:: python
def status2D(coord: Point2D) -> None:
def print_point2D(coord: Point2DLike) -> None:
x, y = coord
print(f"Point at {x=},{y=}")
def status3D(coord: Point3D) -> None:
def print_point3D(coord: Point3DLike) -> None:
x, y, z = coord
print(f"Point at {x=},{y=},{z=}")
def get_statuses(coords: Point2D_Array | Point3D_Array) -> None:
def print_point_array(coords: Point2DLike_Array | Point3DLike_Array) -> None:
for coord in coords:
if len(coord) == 2:
# it's a Point2D
status2D(coord)
# it's a Point2DLike
print_point2D(coord)
else:
# it's a point3D
status3D(coord)
# it's a Point3DLike
print_point3D(coord)
It's important to realize that the status functions accepted both
tuples/lists of the correct length, and ``NDArray``'s of the correct shape.
If they only accepted ``NDArray``'s, we would use their ``Internal`` counterparts:
:class:`~.typing.InternalPoint2D`, :class:`~.typing.InternalPoint3D`, :class:`~.typing.InternalPoint2D_Array` and :class:`~.typing.InternalPoint3D_Array`.
def shift_point_up(coord: Point3DLike) -> Point3D:
result = np.asarray(coord)
result += UP
print(f"New point: {result}")
return result
In general, the type aliases prefixed with ``Internal`` should never be used on
user-facing classes and functions, but should be reserved for internal behavior.
Notice that the last function, ``shift_point_up()``, accepts a
:class:`~.Point3DLike` as a parameter and returns a :class:`~.Point3D`. A
:class:`~.Point3D` always represents a NumPy array consisting of 3 floats,
whereas a :class:`~.Point3DLike` can represent anything resembling a 3D point:
either a NumPy array or a tuple/list of 3 floats, hence the ``Like`` word. The
same happens with :class:`~.Point2D`, :class:`~.Point2D_Array` and
:class:`~.Point3D_Array`, and their ``Like`` counterparts
:class:`~.Point2DLike`, :class:`~.Point2DLike_Array` and
:class:`~.Point3DLike_Array`.
The rule for typing functions is: **make parameter types as broad as possible,
and return types as specific as possible.** Therefore, for functions which are
intended to be called by users, **we should always, if possible, accept**
``Like`` **types as parameters and return NumPy, non-** ``Like`` **types.** The
main reason is to be more flexible with users who might want to pass tuples or
lists as arguments rather than NumPy arrays, because it's more convenient. The
last function, ``shift_point_up()``, is an example of it.
Internal functions which are *not* meant to be called by users may accept
non-``Like`` parameters if necessary.
Vectors
~~~~~~~
@ -57,23 +76,17 @@ consider this slightly contrived function:
.. code-block:: python
def shift_mobject(mob: Mobject, direction: Vector3D, scale_factor: float = 1) -> mob:
M = TypeVar("M", bound=Mobject) # allow any mobject
def shift_mobject(mob: M, direction: Vector3D, scale_factor: float = 1) -> M:
return mob.shift(direction * scale_factor)
Here we see an important example of the difference. ``direction`` can not, and
should not, be typed as a :class:`~.typing.Point3D` because the function does not accept tuples/lists,
like ``direction=(0, 1, 0)``. You could type it as :class:`~.typing.InternalPoint3D` and
the type checker and linter would be happy; however, this makes the code harder
to understand.
Here we see an important example of the difference. ``direction`` should not be
typed as a :class:`~.Point3D`, because it represents a direction along
which to shift a :class:`~.Mobject`, not a position in space.
As a general rule, if a parameter is called ``direction`` or ``axis``,
it should be type hinted as some form of :class:`~.VectorND`.
.. warning::
This is not always true. For example, as of Manim 0.18.0, the direction
parameter of the :class:`.Vector` Mobject should be ``Point2D | Point3D``,
as it can also accept ``tuple[float, float]`` and ``tuple[float, float, float]``.
it should be type hinted as some form of :class:`~.VectorND` or
:class:`~.VectorNDLike`.
Colors
------
@ -129,6 +142,6 @@ There are several representations of images in Manim. The most common is
the representation as a NumPy array of floats representing the pixels of an image.
This is especially common when it comes to the OpenGL renderer.
This is the use case of the :class:`~.typing.Image` type hint. Sometimes, Manim may use ``PIL.Image``,
in which case one should use that type hint instead.
This is the use case of the :class:`~.typing.PixelArray` type hint. Sometimes, Manim may use ``PIL.Image.Image``,
which is not the same as :class:`~.typing.PixelArray`. In this case, use the ``PIL.Image.Image`` typehint.
Of course, if a more specific type of image is needed, it can be annotated as such.

View file

@ -90,7 +90,7 @@ Basic Concepts
[[i * 256 / n for i in range(0, n)] for _ in range(0, n)]
)
image = ImageMobject(imageArray).scale(2)
image.background_rectangle = SurroundingRectangle(image, GREEN)
image.background_rectangle = SurroundingRectangle(image, color=GREEN)
self.add(image, image.background_rectangle)
.. manim:: BooleanOperations
@ -299,7 +299,7 @@ Animations
path.become(previous_path)
path.add_updater(update_path)
self.add(path, dot)
self.play(Rotating(dot, radians=PI, about_point=RIGHT, run_time=2))
self.play(Rotating(dot, angle=PI, about_point=RIGHT, run_time=2))
self.wait()
self.play(dot.animate.shift(UP))
self.play(dot.animate.shift(LEFT))
@ -341,7 +341,7 @@ Plotting with Manim
axes.i2gp(TAU, cos_graph), color=YELLOW, line_func=Line
)
line_label = axes.get_graph_label(
cos_graph, "x=2\pi", x_val=TAU, direction=UR, color=WHITE
cos_graph, r"x=2\pi", x_val=TAU, direction=UR, color=WHITE
)
plot = VGroup(axes, sin_graph, cos_graph, vert_line)
@ -482,7 +482,7 @@ Plotting with Manim
tips=False,
)
labels = ax.get_axis_labels(
x_label=Tex("$\Delta Q$"), y_label=Tex("T[$^\circ C$]")
x_label=Tex(r"$\Delta Q$"), y_label=Tex(r"T[$^\circ C$]")
)
x_vals = [0, 8, 38, 39]
@ -785,8 +785,8 @@ Advanced Projects
def add_x_labels(self):
x_labels = [
MathTex("\pi"), MathTex("2 \pi"),
MathTex("3 \pi"), MathTex("4 \pi"),
MathTex(r"\pi"), MathTex(r"2 \pi"),
MathTex(r"3 \pi"), MathTex(r"4 \pi"),
]
for i in range(len(x_labels)):

View file

@ -120,19 +120,24 @@ of [ManimPango's README](https://github.com/ManimCommunity/ManimPango).
---
(not-on-path)=
## I am using Windows and get the error `X is not recognized as an internal or external command, operable program or batch file`
Regardless of whether `X` says `python` or `manim`, this means that the executable you
are trying to run is not located in one of the directories your system is looking
for them (specified by the `PATH` variable). Take a look at the instructions
{doc}`in the installation guide for Windows </installation/windows>`, or
[this StackExchange answer](https://superuser.com/questions/143119/how-do-i-add-python-to-the-windows-path/143121#143121)
to get help with editing the `PATH` variable manually.
If you have followed {doc}`our local installation instructions </installation/uv>` and
have not activated the corresponding virtual environment, make sure to use `uv run manim ...`
instead of just `manim` (or activate the virtual environment by following the instructions
printed when running `uv venv`).
If `python` is recognized but not `manim` or `pip`, you can try running
Otherwise there is a problem with the directories where your system is looking for
executables (the `PATH` variable).
If `python` is recognized, you can try running
commands by prepending `python -m`. That is, `manim` becomes `python -m manim`,
and `pip` becomes `python -m pip`.
Otherwise see
[this StackExchange answer](https://superuser.com/questions/143119/how-do-i-add-python-to-the-windows-path/143121#143121)
to get help with editing the `PATH` variable manually.
---
## I have tried using Chocolatey (`choco install manimce`) to install Manim, but it failed!

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

@ -577,7 +577,7 @@ sound odd at first. Practically, this ensures that mobjects are not
added twice, as mentioned above: if they were present in the scene
``Scene.mobjects`` list before (even if they were contained as a
child of some other mobject), they are first removed from the list.
The way :meth:`.Scene.restrucutre_mobjects` works is rather aggressive:
The way :meth:`.Scene.restructure_mobjects` works is rather aggressive:
It always operates on a given list of mobjects; in the ``add`` method
two different lists occur: the default one, ``Scene.mobjects`` (no extra
keyword argument is passed), and ``Scene.moving_mobjects`` (which we will

View file

@ -2,17 +2,24 @@
Rendering Text and Formulas
###########################
There are two different ways by which you can render **Text** in videos:
There are three different ways by which you can render **Text** in videos:
1. Using Pango (:mod:`~.text_mobject`)
2. Using LaTeX (:mod:`~.tex_mobject`)
3. Using Typst (:mod:`~.typst_mobject`)
If you want to render simple text, you should use either :class:`~.Text` or
:class:`~.MarkupText`, or one of its derivatives like :class:`~.Paragraph`.
Manim's Pango-based text classes include :class:`~.Text`,
:class:`~.MarkupText`, and derivatives such as :class:`~.Paragraph`.
See :ref:`using-text-objects` for more information.
LaTeX should be used when you need mathematical typesetting. See
:ref:`rendering-with-latex` for more information.
LaTeX rendering is available via :class:`~.Tex` and
:class:`~.MathTex`. See :ref:`rendering-with-latex` for more
information.
Typst support is available via :class:`~.Typst` and
:class:`~.TypstMath`. It offers both general markup and mathematical
typesetting through the Typst compiler without requiring a TeX
distribution. See :ref:`typst-mobjects` for more information.
.. _using-text-objects:
@ -50,7 +57,7 @@ For example:
)
self.add(text)
.. _Pango library: https://pango.gnome.org
.. _Pango library: https://pango.org
Working with :class:`~.Text`
============================
@ -291,6 +298,54 @@ and further references about PangoMarkup.
)
self.add(text)
.. _rendering-with-typst:
Text With Typst
***************
Manim also supports rendering text and formulas with Typst via
:class:`~.Typst` and :class:`~.TypstMath`.
.. important::
Typst support requires the optional ``typst`` dependency. Install it with
``pip install manim[typst]``.
Typst mobjects compile Typst markup directly to SVG and import the result as
vector graphics. This works both for general markup and for mathematical
expressions.
.. manim:: HelloTypst
:save_last_frame:
:ref_classes: Typst
class HelloTypst(Scene):
def construct(self):
text = Typst(r"*Hello* from _Typst!_", font_size=96)
self.add(text)
For mathematical expressions, use :class:`~.TypstMath`:
.. manim:: HelloTypstMath
:save_last_frame:
:ref_classes: TypstMath
class HelloTypstMath(Scene):
def construct(self):
equation = TypstMath(r"sum_(k=1)^n k = (n(n + 1)) / 2", font_size=72)
self.add(equation)
Typst also supports selecting subexpressions via labels in the Typst source,
or via Manim's ``{{ ... }}`` shorthand in :class:`~.TypstMath`:
.. code-block:: python
eq = TypstMath("{{ a + b : lhs }} = {{ c }}")
eq.select("lhs").set_color(BLUE)
eq.select(0).set_color(YELLOW)
See :ref:`typst-mobjects` for more details and additional examples.
.. _rendering-with-latex:
Text With LaTeX
@ -389,8 +444,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 +454,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 +468,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

@ -21,8 +21,11 @@ in the right place!
.. note::
Please be aware that there are different, incompatible versions
of Manim available. Check our :ref:`installation FAQ <different-versions>`
Please be aware that there are different, incompatible versions of Manim available.
This version, the Community Edition of Manim (`ManimCE <https://github.com/ManimCommunity/manim>`_),
is a separate project maintained by the community, but it was forked from `3b1b/manim <https://github.com/3b1b/manim>`_,
the original Manim created and open-sourced by Grant Sanderson, creator of `3Blue1Brown <https://www.youtube.com/channel/UCYO_jab_esuFRV4b17AJtAw>`_ educational math videos.
Check our :ref:`installation FAQ <different-versions>`
to learn more!
- The :doc:`Installation <installation>` section has the latest and
@ -91,6 +94,21 @@ or `Discord <https://www.manim.community/discord/>`_. If you're using Manim in a
context, instructions on how to cite a particular release can be found
`in our README <https://github.com/ManimCommunity/manim/blob/main/README.md>`_.
License Information
-------------------
Manim is an open-source library licensed under the **MIT License**, which applies to both the
original and the community editions of the software. This means you are free to use, modify,
and distribute the code in accordance with the MIT License terms. However, there are some
additional points to be aware of:
- **Copyrighted Assets:** Specific assets, such as the "Pi creatures" in Grant Sanderson's
(3Blue1Brown) videos, are copyrighted and protected. Please avoid using these characters in
any derivative works.
- **Content Creation and Sharing:** Videos and animations created with Manim can be freely
shared, and no attribution to Manim is required—although it is much appreciated! You are
encouraged to showcase your work online and share it with the Manim community.
Index
-----

View file

@ -8,8 +8,8 @@ require no local installation. Head over to
https://try.manim.community to give our interactive tutorial a try.
Otherwise, if you intend to use Manim to work on an animation project,
we recommend installing the library locally (either to a conda environment,
your system's Python, or via Docker).
we recommend installing the library locally (preferably to some isolated
virtual Python environment, or a conda-like environment, or via Docker).
.. warning::
@ -19,13 +19,31 @@ your system's Python, or via Docker).
versions <different-versions>` if you are unsure which
version you should install.
#. :ref:`(Recommended) Installing Manim via Python's package manager pip
<local-installation>`
#. :ref:`Installing Manim to a conda environment <conda-installation>`
#. :ref:`Installing Manim to your system's Python <local-installation>`
#. :ref:`Using Manim via Docker <docker-installation>`
#. :ref:`Interactive Jupyter notebooks via Binder / Google Colab
<interactive-online>`
.. _local-installation:
Installing Manim locally via pip
********************************
The recommended way of installing Manim is by using Python's package manager
pip. If you already have a Python environment set up, you can simply run
``pip install manim`` to install the library.
Our :doc:`local installation guide <installation/uv>` provides more detailed
instructions, including best practices for setting up a suitable local environment.
.. toctree::
:hidden:
installation/uv
.. _conda-installation:
Installing Manim via Conda and related environment managers
@ -55,48 +73,6 @@ The following pages show how to install Manim in a conda environment:
installation/conda
.. _local-installation:
Installing Manim locally
************************
Manim is a Python library, and it can be
installed via `pip <https://pypi.org/project/manim/>`__
or `conda <https://anaconda.org/conda-forge/manim/>`__. However,
in order for Manim to work properly, some additional system
dependencies need to be installed first. The following pages have
operating system specific instructions for you to follow.
Manim requires Python version ``3.9`` or above to run.
.. hint::
Depending on your particular setup, the installation process
might be slightly different. Make sure that you have tried to
follow the steps on the following pages carefully, but in case
you hit a wall we are happy to help: either `join our Discord
<https://www.manim.community/discord/>`__, or start a new
Discussion `directly on GitHub
<https://github.com/ManimCommunity/manim/discussions>`__.
.. toctree::
:maxdepth: 2
installation/windows
installation/macos
installation/linux
Once Manim is installed locally, you can proceed to our
:doc:`quickstart guide <tutorials/quickstart>` which walks you
through rendering a first simple scene.
As mentioned above, do not worry if there are errors or other
problems: consult our :doc:`FAQ section </faq/index>` for help
(including instructions for how to ask Manim's community for help).
.. _docker-installation:
Using Manim via Docker
@ -140,6 +116,11 @@ If you're using Visual Studio Code you can install an extension called
of the animation inside the editor. The extension can be installed through the
`marketplace of VS Code <https://marketplace.visualstudio.com/items?itemName=Rickaym.manim-sideview>`__.
.. caution::
This extension is not officially maintained by the Manim Community.
If you run into issues, please report them to the extension's author.
Installation for developers
***************************

View file

@ -10,15 +10,25 @@ namely `conda <https://docs.conda.io/projects/conda/en/latest/user-guide/install
After installing your package manager, you can create a new environment and install ``manim`` inside by running
.. code-block:: bash
# using conda or mamba
conda create -n my-manim-environment
conda activate my-manim-environment
conda install -c conda-forge manim
# using pixi
pixi init
pixi add manim
.. tab-set::
.. tab-item:: conda / mamba
.. code-block:: bash
# if you want to use mamba, just replace conda below with mamba
conda create -n my-manim-environment
conda activate my-manim-environment
conda install -c conda-forge manim
.. tab-item:: pixi
.. code-block:: bash
pixi init
pixi add manim
Since all dependencies (except LaTeX) are handled by conda, you don't need to worry
about needing to install additional dependencies.
@ -32,11 +42,8 @@ In order to make use of Manim's interface to LaTeX to, for example, render
equations, LaTeX has to be installed as well. Note that this is an optional
dependency: if you don't intend to use LaTeX, you don't have to install it.
You can install LaTeX by following the optional dependencies steps
for :ref:`Windows <win-optional-dependencies>`,
:ref:`Linux <linux-optional-dependencies>` or
:ref:`macOS <macos-optional-dependencies>`.
Recommendations on how to install LaTeX on different operating systems
can be found :doc:`in our local installation guide </installation/uv>`.
Working with Manim

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

@ -1,162 +0,0 @@
Linux
=====
The installation instructions depend on your particular operating
system and package manager. If you happen to know exactly what you are doing,
you can also simply ensure that your system has:
- a reasonably recent version of Python 3 (3.9 or above),
- with working Cairo bindings in the form of
`pycairo <https://cairographics.org/pycairo/>`__,
- and `Pango <https://pango.gnome.org>`__ headers.
Then, installing Manim is just a matter of running:
.. code-block:: bash
pip3 install manim
.. note::
In light of the current efforts of migrating to rendering via OpenGL,
this list might be incomplete. Please `let us know
<https://github.com/ManimCommunity/manim/issues/new/choose>` if you
ran into missing dependencies while installing.
In any case, we have also compiled instructions for several common
combinations of operating systems and package managers below.
Required Dependencies
---------------------
apt Ubuntu / Mint / Debian
****************************
To first update your sources, and then install Cairo and Pango
simply run:
.. code-block:: bash
sudo apt update
sudo apt install build-essential python3-dev libcairo2-dev libpango1.0-dev
If you don't have python3-pip installed, install it via:
.. code-block:: bash
sudo apt install python3-pip
Then, to install Manim, run:
.. code-block:: bash
pip3 install manim
Continue by reading the :ref:`optional dependencies <linux-optional-dependencies>`
section.
dnf  Fedora / CentOS / RHEL
****************************
To install Cairo and Pango:
.. code-block:: bash
sudo dnf install cairo-devel pango-devel
In order to successfully build the ``pycairo`` wheel, you will also
need the Python development headers:
.. code-block:: bash
sudo dnf install python3-devel
At this point you have all required dependencies and can install
Manim by running:
.. code-block:: bash
pip3 install manim
Continue by reading the :ref:`optional dependencies <linux-optional-dependencies>`
section.
pacman  Arch / Manjaro
***********************
.. tip::
Thanks to *groctel*, there is a `dedicated Manim package
on the AUR! <https://aur.archlinux.org/packages/manim/>`
If you don't want to use the packaged version from AUR, here is what
you need to do manually: Update your package sources, then install
Cairo and Pango:
.. code-block:: bash
sudo pacman -Syu
sudo pacman -S cairo pango
If you don't have ``python-pip`` installed, get it by running:
.. code-block:: bash
sudo pacman -S python-pip
then simply install Manim via:
.. code-block:: bash
pip3 install manim
Continue by reading the :ref:`optional dependencies <linux-optional-dependencies>`
section.
.. _linux-optional-dependencies:
Optional Dependencies
---------------------
In order to make use of Manim's interface to LaTeX for, e.g., rendering
equations, LaTeX has to be installed as well. Note that this is an optional
dependency: if you don't intend to use LaTeX, you don't have to install it.
You can use whichever LaTeX distribution you like or whichever is easiest
to install with your package manager. Usually,
`TeX Live <https://www.tug.org/texlive/>`__ is a good candidate if you don't
care too much about disk space.
For Debian-based systems (like Ubuntu), sufficient LaTeX dependencies can be
installed by running:
.. code-block:: bash
sudo apt install texlive texlive-latex-extra
For Fedora (see `docs <https://docs.fedoraproject.org/en-US/neurofedora/latex/>`__):
.. code-block:: bash
sudo dnf install texlive-scheme-full
Should you choose to work with some smaller TeX distribution like
`TinyTeX <https://yihui.org/tinytex/>`__ , the full list
of LaTeX packages which Manim interacts with in some way (a subset might
be sufficient for your particular application) is::
collection-basic amsmath babel-english cbfonts-fd cm-super ctex doublestroke
dvisvgm everysel fontspec frcursive fundus-calligra gnu-freefont jknapltx
latex-bin mathastext microtype ms physics preview ragged2e relsize rsfs
setspace standalone tipa wasy wasysym xcolor xetex xkeyval
Working with Manim
------------------
At this point, you should have a working installation of Manim, head
over to our :doc:`Quickstart Tutorial <../tutorials/quickstart>` to learn
how to make your own *Manimations*!

View file

@ -1,70 +0,0 @@
macOS
=====
For the sake of simplicity, the following instructions assume that you have
the popular `package manager Homebrew <https://brew.sh>`__ installed. While
you can certainly also install all dependencies without it, using Homebrew
makes the process much easier.
If you want to use Homebrew but do not have it installed yet, please
follow `Homebrew's installation instructions <https://docs.brew.sh/Installation>`__.
.. note::
For a while after Apple released its new ARM-based processors (the Apple Silicon chips like the *"M1 chip"*),
the recommended way of installing Manim relied on *Rosetta*, Apple's compatibility
layer between Intel and ARM architectures. This is no longer necessary, Manim can
(and is recommended to) be installed natively.
Installing Manim
----------------
As of July/2024, brew can install Manim including all required dependencies.
To install Manim:
.. code-block:: bash
brew install manim
.. _macos-optional-dependencies:
Optional Dependencies
---------------------
In order to make use of Manim's interface to LaTeX for, e.g., rendering
equations, LaTeX has to be installed as well. Note that this is an optional
dependency: if you don't intend to use LaTeX, you don't have to install it.
For macOS, the recommended LaTeX distribution is
`MacTeX <http://www.tug.org/mactex/>`__. You can install it by following
the instructions from the link, or alternatively also via Homebrew by
running:
.. code-block:: bash
brew install --cask mactex-no-gui
.. warning::
MacTeX is a *full* LaTeX distribution and will require more than 4GB of
disk space. If this is an issue for you, consider installing a smaller
distribution like
`BasicTeX <http://www.tug.org/mactex/morepackages.html>`__.
Should you choose to work with some partial TeX distribution, the full list
of LaTeX packages which Manim interacts with in some way (a subset might
be sufficient for your particular application) is::
amsmath babel-english cbfonts-fd cm-super ctex doublestroke dvisvgm everysel
fontspec frcursive fundus-calligra gnu-freefont jknapltx latex-bin
mathastext microtype ms physics preview ragged2e relsize rsfs
setspace standalone tipa wasy wasysym xcolor xetex xkeyval
Working with Manim
------------------
At this point, you should have a working installation of Manim. Head
over to our :doc:`Quickstart Tutorial <../tutorials/quickstart>` to learn
how to make your own *Manimations*!

View file

@ -0,0 +1,341 @@
# Installing Manim locally
The standard way of installing Manim is by using
Python's package manager `pip` to install the latest
release from [PyPI](https://pypi.org/project/manim/).
To make it easier for you to follow best practices when it
comes to setting up a Python project for your Manim animations,
we strongly recommend using a tool for managing Python environments
and dependencies. In particular,
[we strongly recommend using `uv`](https://docs.astral.sh/uv/#getting-started).
For the two main ways of installing Manim described below, we assume
that `uv` is available; we think it is particularly helpful if you are
new to Python or programming in general. It is not a hard requirement
whatsoever; if you know what you are doing you can just use `pip` to
install Manim directly.
:::::{admonition} Installing the Python management tool `uv`
:class: seealso
One way to install `uv` is via the dedicated console installer supporting
all large operating systems. Simply paste the following snippet into
your terminal / PowerShell -- or
[consult `uv`'s documentation](https://docs.astral.sh/uv/#getting-started)
for alternative ways to install the tool.
::::{tab-set}
:::{tab-item} MacOS and Linux
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
```
:::
:::{tab-item} Windows
```powershell
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
```
:::
::::
:::::
Of course, if you know what you are doing and prefer to setup a virtual
environment yourself, feel free to do so!
:::{important}
If you run into issues when following our instructions below, do
not worry: check our [installation FAQs](<project:/faq/installation.md>) to
see whether the problem is already addressed there -- and otherwise go and
check [how to contact our community](<project:/faq/help.md>) to get help.
:::
## Installation
### Step 1: Installing Python
We first need to check that an appropriate version of Python is available
on your machine. Open a terminal to run
```bash
uv python install
```
to install the latest version of Python. If this is successful, continue
to the next step.
(installation-optional-latex)=
### Step 2 (optional): Installing LaTeX
[LaTeX](https://en.wikibooks.org/wiki/LaTeX/Mathematics) is a very well-known
and widely used typesetting system allowing you to write formulas like
\begin{equation*}
\frac{1}{2\pi i} \oint_{\gamma} \frac{f(z)}{(z - z_0)^{n+1}}~dz
= \frac{f^{(n)}(z_0)}{n!}.
\end{equation*}
If rendering plain text is sufficient for your needs and you don't want
to render any typeset formulas, you can technically skip this step. Otherwise
select your operating system from the tab list below and follow the instructions.
:::::{tab-set}
::::{tab-item} Windows
For Windows we recommend installing LaTeX via the
[MiKTeX distribution](https://miktex.org). Simply grab
the Windows installer available from their download page,
<https://miktex.org/download> and run it.
::::
::::{tab-item} MacOS
If you are running MacOS, we recommend installing the
[MacTeX distribution](https://www.tug.org/mactex/). The latest
available PKG file can be downloaded from
<https://www.tug.org/mactex/mactex-download.html>.
Get it and follow the standard installation procedure.
::::
::::{tab-item} Linux
Given the large number of Linux distributions with different ways
of installing packages, we cannot give detailed instructions for
all package managers.
In general we recommend to install a *TeX Live* distribution
(<https://www.tug.org/texlive/>). For most Linux distributions,
TeX Live has already been packaged such that it can be installed
easily with your system package manager. Search the internet and
your usual OS resources for detailed instructions.
For example, on Debian-based systems with the package manager `apt`,
a full TeX Live distribution can be installed by running
```bash
sudo apt install texlive-full
```
For Fedora (managed via `dnf`), the corresponding command is
```bash
sudo dnf install texlive-scheme-full
```
As soon as LaTeX is installed, continue with actually installing Manim
itself.
::::
:::::
:::{dropdown} I know what I am doing and I would like to setup a minimal LaTeX installation
You are welcome to use a smaller, more customizable LaTeX distribution like
[TinyTeX](https://yihui.org/tinytex/). Manim overall requires the following
LaTeX packages to be installed in your distribution:
```text
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 preview prelim2e ragged2e relsize rsfs
setspace standalone tipa wasy wasysym xcolor xetex xkeyval
```
:::
### Step 3: Installing Manim
These steps again differ slightly between different operating systems. Make
sure you select the correct one from the tab list below, then follow
the instructions below.
::::::{tab-set}
:::::{tab-item} Windows
The following commands will
- create a new directory for a Python project,
- and add Manim as a dependency, which installs it into the corresponding
local Python environment.
The name for the Python project is *manimations*, which you can change
to anything you like.
```bash
uv init manimations
cd manimations
uv add manim
```
Manim is now installed in your local project environment!
:::::
:::::{tab-item} MacOS
Before we can install Manim, we need to make sure that the system utilities
`cairo` and `pkg-config` are present. They are needed for the [`pycairo` Python
package](https://pycairo.readthedocs.io/en/latest/), a dependency of Manim.
The easiest way of installing these utilities is by using [Homebrew](https://brew.sh/),
a fairly popular 3rd party package manager for MacOS. Check whether Homebrew is
already installed by running
```bash
brew --version
```
which will report something along the lines of `Homebrew 4.4.15-54-...`
if it is installed, and a message `command not found: brew` otherwise. In this
case, use the shell installer [as instructed on Homebrew's website](https://brew.sh/),
or get a `.pkg`-installer from
[their GitHub release page](https://github.com/Homebrew/brew/releases). Make sure to
follow the instructions of the installer carefully, especially when prompted to
modify your `.zprofile` to add Homebrew to your system's PATH.
With Homebrew available, the required utilities can be installed by running
```bash
brew install cairo pkg-config
```
With all of this preparation out of the way, now it is time to actually install
Manim itself! The following commands will
- create a new directory for a Python project,
- and add Manim as a dependency, which installs it into the corresponding
local Python environment.
The name for the Python project is *manimations*, which you can change
to anything you like.
```bash
uv init manimations
cd manimations
uv add manim
```
Manim is now installed in your local project environment!
:::::
:::::{tab-item} Linux
Practically, the instructions given in the *Windows* tab
also apply for Linux -- however, some additional dependencies are
required as Linux users need to build
[ManimPango](https://github.com/ManimCommunity/ManimPango)
(and potentially [pycairo](https://pycairo.readthedocs.io/en/latest/))
from source. More specifically, this includes:
- A C compiler,
- Python's development headers,
- the `pkg-config` tool,
- Pango and its development headers,
- and Cairo and its development headers.
Instructions for popular systems / package managers are given below.
::::{tab-set}
:::{tab-item} Debian-based / apt
```bash
sudo apt update
sudo apt install build-essential python3-dev libcairo2-dev libpango1.0-dev
```
:::
:::{tab-item} Fedora / dnf
```bash
sudo dnf install python3-devel pkg-config cairo-devel pango-devel
```
:::
:::{tab-item} Arch Linux / pacman
```bash
sudo pacman -Syu base-devel cairo pango
```
:::
::::
As soon as the required dependencies are installed, you can create
a Python project (feel free to change the name *manimations* used below
to some other name) with a local environment containing Manim by running
```bash
uv init manimations
cd manimations
uv add manim
```
:::::
::::::
To verify that your local Python project is setup correctly
and that Manim is available, simply run
```bash
uv run manim checkhealth
```
At this point, you can also open your project folder with the
IDE of your choice. All modern Python IDEs (for example VS Code
with the Python extension, or PyCharm) should automatically detect
the local environment created by `uv` such that if you put
```py
import manim
```
into a new file `my-first-animation.py`, the import is resolved
correctly and autocompletion is available.
*Happy Manimating!*
:::{dropdown} Alternative: Installing Manim as a global `uv`-managed tool
If you have Manim projects in many different directories and you do not
want to setup a local project environment for each of them, you could
also install Manim as a `uv`-managed tool.
See [`uv`'s documentation for more information](https://docs.astral.sh/uv/concepts/tools/)
on their tool mechanism.
To install Manim as a global `uv` tool, simply run
```bash
uv tool install manim
```
after which the `manim` executable will be available on your
global system path, without the need to activate any virtual
environment or prefixing your commands with `uv run`.
Note that when using this approach, setting up your code editor
to properly resolve `import manim` requires additional work, as
the global tool environment is not automatically detected: the
base path of all tool environments can be determined by running
```
uv tool dir
```
which should now contain a directory `manim` in which the appropriate
virtual environment is located. Set the Python interpreter of your IDE
to this environment to make imports properly resolve themselves.
:::
:::{dropdown} Installing Manim for a different version of Python
In case you would like to use a different version of Python
(for example, due to compatibility issues with other packages),
then `uv` allows you to do so in a fairly straightforward way.
When initializing the local Python project, simply pass the Python
version you want to use as an argument to the `init` command:
```
uv init --python 3.12 manimations
cd manimations
uv add manim
```
To change the version for an existing package, you will need to
edit the `pyproject.toml` file. If you are downgrading the python version, the
`requires-python` entry needs to be updated such that your chosen
version satisfies the requirement. Change the line to, for example
`requires-python = ">=3.12"`. After that, run `uv python pin 3.12`
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

@ -1,108 +0,0 @@
Windows
=======
The easiest way of installing Manim and its dependencies is by using a
package manager like `Chocolatey <https://chocolatey.org/>`__
or `Scoop <https://scoop.sh>`__, especially if you need optional dependencies
like LaTeX support.
If you choose to use one of the package managers, please follow
their installation instructions
(`for Chocolatey <https://chocolatey.org/install#install-step2>`__,
`for Scoop <https://scoop-docs.now.sh/docs/getting-started/Quick-Start.html>`__)
to make one of them available on your system.
Required Dependencies
---------------------
Manim requires a recent version of Python (3.9 or above)
in order to work.
Chocolatey
**********
Manim can be installed via Chocolatey simply by running:
.. code-block:: powershell
choco install manimce
That's it, no further steps required. You can continue with installing
the :ref:`optional dependencies <win-optional-dependencies>` below.
Pip
***
As mentioned above, Manim needs a reasonably recent version of
Python 3 (3.9 or above).
**Python:** Head over to https://www.python.org, download an installer
for a recent version of Python, and follow its instructions to get Python
installed on your system.
.. note::
We have received reports of problems caused by using the version of
Python that can be installed from the Windows Store. At this point,
we recommend staying away from the Windows Store version. Instead,
install Python directly from the
`official website <https://www.python.org>`__.
Then, Manim can be installed via Pip simply by running:
.. code-block:: powershell
python -m pip install manim
Manim should now be installed on your system. Continue reading
the :ref:`optional dependencies <win-optional-dependencies>` section
below.
.. _win-optional-dependencies:
Optional Dependencies
---------------------
In order to make use of Manim's interface to LaTeX to, for example, render
equations, LaTeX has to be installed as well. Note that this is an optional
dependency: if you don't intend to use LaTeX, you don't have to install it.
For Windows, the recommended LaTeX distribution is
`MiKTeX <https://miktex.org/download>`__. You can install it by using the
installer from the linked MiKTeX site, or by using the package manager
of your choice (Chocolatey: ``choco install miktex.install``,
Scoop: ``scoop install latex``, Winget: ``winget install MiKTeX.MiKTeX``).
If you are concerned about disk space, there are some alternative,
smaller distributions of LaTeX.
**Using Chocolatey:** If you used Chocolatey to install manim or are already
a chocolatey user, then you can simply run ``choco install manim-latex``. It
is a dedicated package for Manim based on TinyTeX which contains all the
required packages that Manim interacts with.
**Manual Installation:**
You can also use `TinyTeX <https://yihui.org/tinytex/>`__ (Chocolatey: ``choco install tinytex``,
Scoop: first ``scoop bucket add r-bucket https://github.com/cderv/r-bucket.git``,
then ``scoop install tinytex``) alternative installation instructions can be found at their website.
Keep in mind that you will have to manage the LaTeX packages installed on your system yourself via ``tlmgr``.
Therefore we only recommend this option if you know what you are doing.
The full list of LaTeX packages which Manim interacts with in some way
(a subset might be sufficient for your particular application) are::
amsmath babel-english cbfonts-fd cm-super ctex doublestroke dvisvgm everysel
fontspec frcursive fundus-calligra gnu-freefont jknapltx latex-bin
mathastext microtype ms physics preview ragged2e relsize rsfs
setspace standalone tipa wasy wasysym xcolor xetex xkeyval
Working with Manim
------------------
At this point, you should have a working installation of Manim, head
over to our :doc:`Quickstart Tutorial <../tutorials/quickstart>` to learn
how to make your own *Manimations*!

View file

@ -96,14 +96,15 @@ The only requirement of manim plugins is that they specify an entry point
with the group, ``"manim.plugins"``. This allows Manim to discover plugins
available in the user's environment. Everything regarding the plugin's
directory structure, build system, and naming are completely up to your
discretion as an author. The aforementioned template plugin is only a model
using Poetry since this is the build system Manim uses. The plugin's `entry
point <https://packaging.python.org/specifications/entry-points/>`_ can be
specified in Poetry as:
discretion as an author.
The standard way to specify an entry point (see
`the Python packaging guide <https://packaging.python.org/specifications/entry-points/>`__
for details) is to include the following in your ``pyproject.toml``:
.. code-block:: toml
[tool.poetry.plugins."manim.plugins"]
[project.entry-points."manim.plugins"]
"name" = "object_reference"
.. versionremoved:: 0.18.1

View file

@ -10,10 +10,12 @@ Module Index
:toctree: ../reference
~utils.bezier
cli
~utils.color
~utils.commands
~utils.config_ops
constants
data_structures
~utils.debug
~utils.deprecation
~utils.docbuild

View file

@ -327,6 +327,13 @@ Generally, you start with the starting number and add only some part of the valu
So, the logic of calculating the number to display at each step will be ``50 + alpha * (100 - 50)``.
Once you set the calculated value for the :class:`~.DecimalNumber`, you are done.
.. note::
If you're creating a custom animation and want to use a ``rate_func``, you must explicitly apply
``self.rate_func(alpha)`` to the parameter you're animating. For example, try switching the rate
function to ``rate_functions.there_and_back`` to observe how it affects the counting behavior.
Once you have defined your ``Count`` animation, you can play it in your :class:`~.Scene` for any duration you want for any :class:`~.DecimalNumber` with any rate function.
.. manim:: CountingScene
@ -343,7 +350,7 @@ Once you have defined your ``Count`` animation, you can play it in your :class:`
def interpolate_mobject(self, alpha: float) -> None:
# Set value of DecimalNumber according to alpha
value = self.start + (alpha * (self.end - self.start))
value = self.start + (self.rate_func(alpha) * (self.end - self.start))
self.mobject.set_value(value)

View file

@ -3,12 +3,22 @@ Quickstart
==========
.. note::
Before proceeding, install Manim and make sure it's running properly by
following the steps in :doc:`../installation`. For
information on using Manim with Jupyterlab or Jupyter notebook, go to the
documentation for the
:meth:`IPython magic command <manim.utils.ipython_magic.ManimMagic.manim>`,
``%%manim``.
Before proceeding, install Manim and make sure it is running properly by
following the steps in :doc:`../installation`. For
information on using Manim with Jupyterlab or Jupyter notebook, go to the
documentation for the
:meth:`IPython magic command <manim.utils.ipython_magic.ManimMagic.manim>`,
``%%manim``.
.. important::
If you installed Manim in the recommended way, using the
Python management tool ``uv``, then you either need to make sure the corresponding
virtual environment is activated (follow the instructions printed on running ``uv venv``),
or you need to remember to prefix the ``manim`` command in the console with ``uv run``;
that is, ``uv run manim ...``.
Overview
********
@ -28,45 +38,38 @@ use to modify ``Mobject``\s.
Starting a new project
**********************
Start by creating a new folder. For the purposes of this guide, name the folder ``project``:
Start by creating a new folder::
.. code-block:: bash
manim init project my-project --default
project/
This folder is the root folder for your project. It contains all the files that Manim needs to function,
The ``my-project`` folder is the root folder for your project. It contains all the files that Manim needs to function,
as well as any output that your project produces.
Animating a circle
******************
1. Open a text editor, such as Notepad. Copy the following code snippet into the window:
1. Open a text editor, such as Notepad. Open the file ``main.py`` in the ``my-project`` folder.
It should look something like this:
.. code-block:: python
.. code-block:: python
from manim import *
from manim import *
class CreateCircle(Scene):
def construct(self):
circle = Circle() # create a circle
circle.set_fill(PINK, opacity=0.5) # set the color and transparency
self.play(Create(circle)) # show the circle on screen
class CreateCircle(Scene):
def construct(self):
circle = Circle() # create a circle
circle.set_fill(PINK, opacity=0.5) # set the color and transparency
self.play(Create(circle)) # show the circle on screen
2. Save the code snippet into your project folder with the name ``scene.py``.
.. code-block:: bash
2. Open the command line, navigate to your project folder, and execute
the following command:
project/
└─scene.py
.. code-block:: bash
3. Open the command line, navigate to your project folder, and execute
the following command:
.. code-block:: bash
manim -pql scene.py CreateCircle
manim -pql main.py CreateCircle
Manim will output rendering information, then create an MP4 file.
Your default movie player will play the MP4 file, displaying the following animation.
@ -192,7 +195,7 @@ Positioning ``Mobject``\s
Next, let's go over some basic techniques for positioning ``Mobject``\s.
1. Open ``scene.py``, and add the following code snippet below the ``SquareToCircle`` method:
1. Open ``scene.py``, and add the following code snippet below the ``SquareToCircle`` class:
.. code-block:: python

View file

@ -23,7 +23,8 @@ def FrenchCursive(*tex_strings, **kwargs):
class TexFontTemplateManual(Scene):
"""An example scene that uses a manually defined TexTemplate() object to create
LaTeX output in French Cursive font"""
LaTeX output in French Cursive font
"""
def construct(self):
self.add(Tex("Tex Font Example").to_edge(UL))
@ -51,7 +52,7 @@ class TexFontTemplateLibrary(Scene):
Many of the in the TexFontTemplates collection require that specific fonts
are installed on your local machine.
For example, choosing the template TexFontTemplates.comic_sans will
not compile if the Comic Sans Micrososft font is not installed.
not compile if the Comic Sans Microsoft font is not installed.
This scene will only render those Templates that do not cause a TeX
compilation error on your system. Furthermore, some of the ones that do render,

View file

@ -2,7 +2,7 @@ from pathlib import Path
import manim.utils.opengl as opengl
from manim import *
from manim.opengl import * # type: ignore
from manim.opengl import *
# Copied from https://3b1b.github.io/manim/getting_started/example_scenes.html#surfaceexample.
# Lines that do not yet work with the Community Version are commented.

View file

@ -1,9 +1,16 @@
#!/usr/bin/env python
from __future__ import annotations
from importlib.metadata import version
from importlib.metadata import PackageNotFoundError, version
__version__ = version(__name__)
# Use installed distribution version if available; otherwise fall back to a
# sensible default so that importing from a source checkout works without an
# editable install (pip install -e .).
try:
__version__ = version(__name__)
except PackageNotFoundError:
# Package is not installed; provide a fallback version string.
__version__ = "0.0.0+unknown"
# isort: off
@ -66,6 +73,7 @@ from .mobject.text.code_mobject import *
from .mobject.text.numbers import *
from .mobject.text.tex_mobject import *
from .mobject.text.text_mobject import *
from .mobject.text.typst_mobject import *
from .mobject.three_d.polyhedra import *
from .mobject.three_d.three_d_utils import *
from .mobject.three_d.three_dimensions import *

View file

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

View file

@ -23,10 +23,9 @@ __all__ = [
parser = make_config_parser()
# The logger can be accessed from anywhere as manim.logger, or as
# logging.getLogger("manim"). The console must be accessed as manim.console.
# Throughout the codebase, use manim.console.print() instead of print().
# Use error_console to print errors so that it outputs to stderr.
# Logger usage: accessible globally as `manim.logger` or via `logging.getLogger("manim")`.
# For printing, use `manim.console.print()` instead of the built-in `print()`.
# For error output, use `error_console`, which prints to stderr.
logger, console, error_console = make_logger(
parser["logger"],
parser["CLI"]["verbosity"],
@ -45,7 +44,7 @@ frame = ManimFrame(config)
# This has to go here because it needs access to this module's config
@contextmanager
def tempconfig(temp: ManimConfig | dict[str, Any]) -> Generator[None, None, None]:
"""Context manager that temporarily modifies the global ``config`` object.
"""Temporarily modifies the global ``config`` object using a context manager.
Inside the ``with`` statement, the modified config will be used. After
context manager exits, the config will be restored to its original state.

View file

@ -1,14 +1,21 @@
"""Parses CLI context settings from the configuration file and returns a Cloup Context settings dictionary.
This module reads configuration values for help formatting, theme styles, and alignment options
used when rendering command-line interfaces in Manim.
"""
from __future__ import annotations
import configparser
from typing import Any
from cloup import Context, HelpFormatter, HelpTheme, Style
__all__ = ["parse_cli_ctx"]
def parse_cli_ctx(parser: configparser.SectionProxy) -> Context:
formatter_settings: dict[str, str | int] = {
def parse_cli_ctx(parser: configparser.SectionProxy) -> dict[str, Any]:
formatter_settings: dict[str, str | int | None] = {
"indent_increment": int(parser["indent_increment"]),
"width": int(parser["width"]),
"col1_max_width": int(parser["col1_max_width"]),
@ -27,6 +34,7 @@ def parse_cli_ctx(parser: configparser.SectionProxy) -> Context:
"col2",
"epilog",
}
# Extract and apply any style-related keys defined in the config section.
for k, v in parser.items():
if k in theme_keys and v:
theme_settings.update({k: Style(v)})
@ -36,22 +44,24 @@ def parse_cli_ctx(parser: configparser.SectionProxy) -> Context:
if theme is None:
formatter = HelpFormatter.settings(
theme=HelpTheme(**theme_settings),
**formatter_settings, # type: ignore[arg-type]
**formatter_settings,
)
elif theme.lower() == "dark":
formatter = HelpFormatter.settings(
theme=HelpTheme.dark().with_(**theme_settings),
**formatter_settings, # type: ignore[arg-type]
**formatter_settings,
)
elif theme.lower() == "light":
formatter = HelpFormatter.settings(
theme=HelpTheme.light().with_(**theme_settings),
**formatter_settings, # type: ignore[arg-type]
**formatter_settings,
)
return Context.settings(
return_val: dict[str, Any] = Context.settings(
align_option_groups=parser["align_option_groups"].lower() == "true",
align_sections=parser["align_sections"].lower() == "true",
show_constraints=True,
formatter_settings=formatter,
)
return return_val

View file

@ -16,7 +16,7 @@ import configparser
import copy
import json
import logging
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from rich import color, errors
from rich import print as printf
@ -91,7 +91,7 @@ def make_logger(
# set the rich handler
rich_handler = RichHandler(
console=console,
show_time=parser.getboolean("log_timestamps"),
show_time=parser.getboolean("log_timestamps", fallback=False),
keywords=HIGHLIGHTED_KEYWORDS,
)
@ -108,7 +108,7 @@ def make_logger(
return logger, console, error_console
def parse_theme(parser: configparser.SectionProxy) -> Theme:
def parse_theme(parser: configparser.SectionProxy) -> Theme | None:
"""Configure the rich style of logger and console output.
Parameters
@ -126,7 +126,7 @@ def parse_theme(parser: configparser.SectionProxy) -> Theme:
:func:`make_logger`.
"""
theme = {key.replace("_", "."): parser[key] for key in parser}
theme: dict[str, Any] = {key.replace("_", "."): parser[key] for key in parser}
theme["log.width"] = None if theme["log.width"] == "-1" else int(theme["log.width"])
theme["log.height"] = (
@ -188,8 +188,11 @@ class JSONFormatter(logging.Formatter):
"""Format the record in a custom JSON format."""
record_c = copy.deepcopy(record)
if record_c.args:
for arg in record_c.args:
record_c.args[arg] = "<>"
if isinstance(record_c.args, dict):
for arg in record_c.args:
record_c.args[arg] = "<>"
else:
record_c.args = ("<>",) * len(record_c.args)
return json.dumps(
{
"levelname": record_c.levelname,

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
@ -33,8 +33,7 @@ from manim.utils.tex import TexTemplate
if TYPE_CHECKING:
from enum import EnumMeta
from typing_extensions import Self
from typing import Self
from manim.typing import StrPath, Vector3D
@ -122,16 +121,20 @@ def make_config_parser(
# read_file() before calling read() for any optional files."
# https://docs.python.org/3/library/configparser.html#configparser.ConfigParser.read
parser = configparser.ConfigParser()
logger.info(f"Reading config file: {library_wide}")
with library_wide.open() as file:
parser.read_file(file) # necessary file
other_files = [user_wide, Path(custom_file) if custom_file else folder_wide]
for path in other_files:
if path.exists():
logger.info(f"Reading config file: {path}")
parser.read(other_files) # optional files
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
@ -296,6 +299,7 @@ class ManimConfig(MutableMapping):
"save_last_frame",
"save_pngs",
"scene_names",
"seed",
"show_in_file_browser",
"tex_dir",
"tex_template",
@ -334,6 +338,7 @@ class ManimConfig(MutableMapping):
def __contains__(self, key: object) -> bool:
try:
assert isinstance(key, str)
self.__getitem__(key)
return True
except AttributeError:
@ -372,7 +377,6 @@ class ManimConfig(MutableMapping):
:meth:`~ManimConfig.digest_parser`
"""
if isinstance(obj, ManimConfig):
self._d.update(obj._d)
if obj.tex_template:
@ -425,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
@ -592,6 +596,7 @@ class ManimConfig(MutableMapping):
"enable_wireframe",
"force_window",
"no_latex_cleanup",
"dry_run",
]:
setattr(self, key, parser["CLI"].getboolean(key, fallback=False))
@ -603,6 +608,7 @@ class ManimConfig(MutableMapping):
# the next two must be set BEFORE digesting frame_width and frame_height
"pixel_height",
"pixel_width",
"seed",
"window_monitor",
"zero_pad",
]:
@ -626,6 +632,7 @@ class ManimConfig(MutableMapping):
"background_color",
"renderer",
"window_position",
"preview_command",
]:
setattr(self, key, parser["CLI"].get(key, fallback="", raw=True))
@ -649,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)
@ -665,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
@ -768,6 +777,7 @@ class ManimConfig(MutableMapping):
"dry_run",
"no_latex_cleanup",
"preview_command",
"seed",
]:
if hasattr(args, key):
attr = getattr(args, key)
@ -1037,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"]
@ -1069,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"]
@ -1105,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
@ -1130,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
)
@ -1278,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]
)
@ -1288,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
@ -1305,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
@ -1414,12 +1427,12 @@ class ManimConfig(MutableMapping):
self._d.__setitem__("window_position", value)
@property
def window_size(self) -> str:
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:
@ -1448,8 +1461,8 @@ class ManimConfig(MutableMapping):
self._set_boolean("enable_gui", value)
@property
def gui_location(self) -> tuple[Any]:
"""Enable GUI interaction."""
def gui_location(self) -> tuple[int, ...]:
"""Location parameters for the GUI window (e.g., screen coordinates or layout settings)."""
return self._d["gui_location"]
@gui_location.setter
@ -1632,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)
@ -1731,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"]
@ -1760,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)
@ -1796,9 +1810,20 @@ 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
def seed(self) -> int | None:
"""Random seed for reproducibility. None means no seed is set."""
return self._d["seed"]
@seed.setter
def seed(self, value: int | None) -> None:
if value is None:
return
self._set_pos_number("seed", value, False)
# TODO: to be used in the future - see PR #620
# https://github.com/ManimCommunity/manim/pull/620
@ -1843,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:
@ -1851,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:
@ -1869,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

@ -7,19 +7,17 @@ from manim.mobject.opengl.opengl_mobject import OpenGLMobject
from .. import config, logger
from ..constants import RendererType
from ..mobject import mobject
from ..mobject.mobject import Mobject
from ..mobject.mobject import Group, Mobject
from ..mobject.opengl import opengl_mobject
from ..utils.rate_functions import linear, smooth
__all__ = ["Animation", "Wait", "override_animation"]
__all__ = ["Animation", "Wait", "Add", "override_animation"]
from collections.abc import Iterable, Sequence
from collections.abc import Callable, Iterable, Sequence
from copy import deepcopy
from functools import partialmethod
from typing import TYPE_CHECKING, Callable
from typing_extensions import Self
from typing import TYPE_CHECKING, Any, Self
if TYPE_CHECKING:
from manim.scene.scene import Scene
@ -54,7 +52,6 @@ class Animation:
For example ``rate_func(0.5)`` is the proportion of the animation that is done
after half of the animations run time.
reverse_rate_function
Reverses the rate function of the animation. Setting ``reverse_rate_function``
does not have any effect on ``remover`` or ``introducer``. These need to be
@ -121,7 +118,7 @@ class Animation:
if func is not None:
anim = func(mobject, *args, **kwargs)
logger.debug(
f"The {cls.__name__} animation has been is overridden for "
f"The {cls.__name__} animation has been overridden for "
f"{type(mobject).__name__} mobjects. use_override = False can "
f" be used as keyword argument to prevent animation overriding.",
)
@ -130,7 +127,7 @@ class Animation:
def __init__(
self,
mobject: Mobject | None,
mobject: Mobject | OpenGLMobject | None,
lag_ratio: float = DEFAULT_ANIMATION_LAG_RATIO,
run_time: float = DEFAULT_ANIMATION_RUN_TIME,
rate_func: Callable[[float], float] = smooth,
@ -141,7 +138,7 @@ class Animation:
introducer: bool = False,
*,
_on_finish: Callable[[], None] = lambda _: None,
**kwargs,
use_override: bool = True, # included here to avoid TypeError if passed from a subclass' constructor
) -> None:
self._typecheck_input(mobject)
self.run_time: float = run_time
@ -161,8 +158,6 @@ class Animation:
else:
self.starting_mobject: Mobject = Mobject()
self.mobject: Mobject = mobject if mobject is not None else Mobject()
if kwargs:
logger.debug("Animation received extra kwargs: %s", kwargs)
if hasattr(self, "CONFIG"):
logger.error(
@ -172,6 +167,19 @@ class Animation:
),
)
@property
def run_time(self) -> float:
return self._run_time
@run_time.setter
def run_time(self, value: float) -> None:
if value < 0:
raise ValueError(
f"The run_time of {self.__class__.__name__} cannot be "
f"negative. The given value was {value}."
)
self._run_time = value
def _typecheck_input(self, mobject: Mobject | None) -> None:
if mobject is None:
logger.debug("Animation with empty mobject")
@ -252,11 +260,11 @@ class Animation:
):
scene.add(self.mobject)
def create_starting_mobject(self) -> Mobject:
def create_starting_mobject(self) -> Mobject | OpenGLMobject:
# Keep track of where the mobject starts
return self.mobject.copy()
def get_all_mobjects(self) -> Sequence[Mobject]:
def get_all_mobjects(self) -> Sequence[Mobject | OpenGLMobject]:
"""Get all mobjects involved in the animation.
Ordering must match the ordering of arguments to interpolate_submobject
@ -270,9 +278,12 @@ class Animation:
def get_all_families_zipped(self) -> Iterable[tuple]:
if config["renderer"] == RendererType.OPENGL:
return zip(*(mob.get_family() for mob in self.get_all_mobjects()))
return zip(
*(mob.get_family() for mob in self.get_all_mobjects()), strict=False
)
return zip(
*(mob.family_members_with_points() for mob in self.get_all_mobjects())
*(mob.family_members_with_points() for mob in self.get_all_mobjects()),
strict=False,
)
def update_mobjects(self, dt: float) -> None:
@ -486,6 +497,8 @@ class Animation:
cls._original__init__ = cls.__init__
_original__init__ = __init__ # needed if set_default() is called with no kwargs directly from Animation
@classmethod
def set_default(cls, **kwargs) -> None:
"""Sets the default values of keyword arguments.
@ -528,7 +541,7 @@ class Animation:
def prepare_animation(
anim: Animation | mobject._AnimationBuilder,
anim: Animation | mobject._AnimationBuilder | opengl_mobject._AnimationBuilder,
) -> Animation:
r"""Returns either an unchanged animation, or the animation built
from a passed animation factory.
@ -625,6 +638,90 @@ class Wait(Animation):
pass
class Add(Animation):
"""Add Mobjects to a scene, without animating them in any other way. This
is similar to the :meth:`.Scene.add` method, but :class:`Add` is an
animation which can be grouped into other animations.
Parameters
----------
mobjects
One :class:`~.Mobject` or more to add to a scene.
run_time
The duration of the animation after adding the ``mobjects``. Defaults
to 0, which means this is an instant animation without extra wait time
after adding them.
**kwargs
Additional arguments to pass to the parent :class:`Animation` class.
Examples
--------
.. manim:: DefaultAddScene
class DefaultAddScene(Scene):
def construct(self):
text_1 = Text("I was added with Add!")
text_2 = Text("Me too!")
text_3 = Text("And me!")
texts = VGroup(text_1, text_2, text_3).arrange(DOWN)
rect = SurroundingRectangle(texts, buff=0.5)
self.play(
Create(rect, run_time=3.0),
Succession(
Wait(1.0),
# You can Add a Mobject in the middle of an animation...
Add(text_1),
Wait(1.0),
# ...or multiple Mobjects at once!
Add(text_2, text_3),
),
)
self.wait()
.. manim:: AddWithRunTimeScene
class AddWithRunTimeScene(Scene):
def construct(self):
# A 5x5 grid of circles
circles = VGroup(
*[Circle(radius=0.5) for _ in range(25)]
).arrange_in_grid(5, 5)
self.play(
Succession(
# Add a run_time of 0.2 to wait for 0.2 seconds after
# adding the circle, instead of using Wait(0.2) after Add!
*[Add(circle, run_time=0.2) for circle in circles],
rate_func=smooth,
)
)
self.wait()
"""
def __init__(
self, *mobjects: Mobject, run_time: float = 0.0, **kwargs: Any
) -> None:
mobject = mobjects[0] if len(mobjects) == 1 else Group(*mobjects)
super().__init__(mobject, run_time=run_time, introducer=True, **kwargs)
def begin(self) -> None:
pass
def finish(self) -> None:
pass
def clean_up_from_scene(self, scene: Scene) -> None:
pass
def update_mobjects(self, dt: float) -> None:
pass
def interpolate(self, alpha: float) -> None:
pass
def override_animation(
animation_class: type[Animation],
) -> Callable[[Callable], Callable]:

View file

@ -4,8 +4,10 @@ from __future__ import annotations
__all__ = ["AnimatedBoundary", "TracedPath"]
from typing import Callable
from collections.abc import Callable, Sequence
from typing import Any, Self
from manim.mobject.mobject import Mobject
from manim.mobject.opengl.opengl_compatibility import ConvertToOpenGL
from manim.mobject.types.vectorized_mobject import VGroup, VMobject
from manim.utils.color import (
@ -16,7 +18,7 @@ from manim.utils.color import (
WHITE,
ParsableManimColor,
)
from manim.utils.rate_functions import smooth
from manim.utils.rate_functions import RateFunction, smooth
class AnimatedBoundary(VGroup):
@ -38,14 +40,14 @@ class AnimatedBoundary(VGroup):
def __init__(
self,
vmobject,
colors=[BLUE_D, BLUE_B, BLUE_E, GREY_BROWN],
max_stroke_width=3,
cycle_rate=0.5,
back_and_forth=True,
draw_rate_func=smooth,
fade_rate_func=smooth,
**kwargs,
vmobject: VMobject,
colors: Sequence[ParsableManimColor] = [BLUE_D, BLUE_B, BLUE_E, GREY_BROWN],
max_stroke_width: float = 3,
cycle_rate: float = 0.5,
back_and_forth: bool = True,
draw_rate_func: RateFunction = smooth,
fade_rate_func: RateFunction = smooth,
**kwargs: Any,
):
super().__init__(**kwargs)
self.colors = colors
@ -59,10 +61,10 @@ class AnimatedBoundary(VGroup):
vmobject.copy().set_style(stroke_width=0, fill_opacity=0) for x in range(2)
]
self.add(*self.boundary_copies)
self.total_time = 0
self.total_time = 0.0
self.add_updater(lambda m, dt: self.update_boundary_copies(dt))
def update_boundary_copies(self, dt):
def update_boundary_copies(self, dt: float) -> None:
# Not actual time, but something which passes at
# an altered rate to make the implementation below
# cleaner
@ -78,9 +80,9 @@ class AnimatedBoundary(VGroup):
fade_alpha = self.fade_rate_func(alpha)
if self.back_and_forth and int(time) % 2 == 1:
bounds = (1 - draw_alpha, 1)
bounds = (1.0 - draw_alpha, 1.0)
else:
bounds = (0, draw_alpha)
bounds = (0.0, draw_alpha)
self.full_family_become_partial(growing, vmobject, *bounds)
growing.set_stroke(colors[index], width=msw)
@ -90,10 +92,12 @@ class AnimatedBoundary(VGroup):
self.total_time += dt
def full_family_become_partial(self, mob1, mob2, a, b):
def full_family_become_partial(
self, mob1: VMobject, mob2: VMobject, a: float, b: float
) -> Self:
family1 = mob1.family_members_with_points()
family2 = mob2.family_members_with_points()
for sm1, sm2 in zip(family1, family2):
for sm1, sm2 in zip(family1, family2, strict=False):
sm1.pointwise_become_partial(sm2, a, b)
return self
@ -146,20 +150,21 @@ class TracedPath(VMobject, metaclass=ConvertToOpenGL):
stroke_width: float = 2,
stroke_color: ParsableManimColor | None = WHITE,
dissipating_time: float | None = None,
**kwargs,
):
**kwargs: Any,
) -> None:
super().__init__(stroke_color=stroke_color, stroke_width=stroke_width, **kwargs)
self.traced_point_func = traced_point_func
self.dissipating_time = dissipating_time
self.time = 1 if self.dissipating_time else None
self.time = 1.0 if self.dissipating_time else None
self.add_updater(self.update_path)
def update_path(self, mob, dt):
def update_path(self, mob: Mobject, dt: float) -> None:
new_point = self.traced_point_func()
if not self.has_points():
self.start_new_path(new_point)
self.add_line_to(new_point)
if self.dissipating_time:
assert self.time is not None
self.time += dt
if self.time - 1 > self.dissipating_time:
nppcc = self.n_points_per_curve

View file

@ -2,9 +2,8 @@
from __future__ import annotations
import types
from collections.abc import Iterable, Sequence
from typing import TYPE_CHECKING, Callable
from collections.abc import Callable, Iterable, Sequence
from typing import TYPE_CHECKING, Any
import numpy as np
@ -12,7 +11,7 @@ from manim._config import config
from manim.animation.animation import Animation, prepare_animation
from manim.constants import RendererType
from manim.mobject.mobject import Group, Mobject
from manim.mobject.opengl.opengl_mobject import OpenGLGroup
from manim.mobject.opengl.opengl_mobject import OpenGLGroup, OpenGLMobject
from manim.scene.scene import Scene
from manim.utils.iterables import remove_list_redundancies
from manim.utils.parameter_parsing import flatten_iterable_parameters
@ -54,31 +53,34 @@ class AnimationGroup(Animation):
def __init__(
self,
*animations: Animation | Iterable[Animation] | types.GeneratorType[Animation],
group: Group | VGroup | OpenGLGroup | OpenGLVGroup = None,
*animations: Animation | Iterable[Animation],
group: Group | VGroup | OpenGLGroup | OpenGLVGroup | None = None,
run_time: float | None = None,
rate_func: Callable[[float], float] = linear,
lag_ratio: float = 0,
**kwargs,
) -> None:
**kwargs: Any,
):
arg_anim = flatten_iterable_parameters(animations)
self.animations = [prepare_animation(anim) for anim in arg_anim]
self.rate_func = rate_func
self.group = group
if self.group is None:
if group is None:
mobjects = remove_list_redundancies(
[anim.mobject for anim in self.animations if not anim.is_introducer()],
)
if config["renderer"] == RendererType.OPENGL:
self.group = OpenGLGroup(*mobjects)
self.group: Group | VGroup | OpenGLGroup | OpenGLVGroup = OpenGLGroup(
*mobjects
)
else:
self.group = Group(*mobjects)
else:
self.group = group
super().__init__(
self.group, rate_func=self.rate_func, lag_ratio=lag_ratio, **kwargs
)
self.run_time: float = self.init_run_time(run_time)
def get_all_mobjects(self) -> Sequence[Mobject]:
def get_all_mobjects(self) -> Sequence[Mobject | OpenGLMobject]:
return list(self.group)
def begin(self) -> None:
@ -87,19 +89,19 @@ class AnimationGroup(Animation):
f"Trying to play {self} without animations, this is not supported. "
"Please add at least one subanimation."
)
self.anim_group_time = 0.0
if self.suspend_mobject_updating:
self.group.suspend_updating()
for anim in self.animations:
anim.begin()
def _setup_scene(self, scene) -> None:
def _setup_scene(self, scene: Scene) -> None:
for anim in self.animations:
anim._setup_scene(scene)
def finish(self) -> None:
self.interpolate(1)
for anim in self.animations:
anim.finish()
self.anims_begun[:] = True
self.anims_finished[:] = True
if self.suspend_mobject_updating:
@ -118,7 +120,7 @@ class AnimationGroup(Animation):
]:
anim.update_mobjects(dt)
def init_run_time(self, run_time) -> float:
def init_run_time(self, run_time: float | None) -> float:
"""Calculates the run time of the animation, if different from ``run_time``.
Parameters
@ -146,9 +148,9 @@ class AnimationGroup(Animation):
run_times = np.array([anim.run_time for anim in self.animations])
num_animations = run_times.shape[0]
dtype = [("anim", "O"), ("start", "f8"), ("end", "f8")]
self.anims_with_timings = np.zeros(num_animations, dtype=dtype)
self.anims_begun = np.zeros(num_animations, dtype=bool)
self.anims_finished = np.zeros(num_animations, dtype=bool)
self.anims_with_timings: np.ndarray = np.zeros(num_animations, dtype=dtype)
self.anims_begun: np.ndarray = np.zeros(num_animations, dtype=bool)
self.anims_finished: np.ndarray = np.zeros(num_animations, dtype=bool)
if num_animations == 0:
return
@ -175,13 +177,17 @@ class AnimationGroup(Animation):
]
run_times = to_update["end"] - to_update["start"]
with_zero_run_time = run_times == 0
run_times[with_zero_run_time] = 1
sub_alphas = (anim_group_time - to_update["start"]) / run_times
if time_goes_back:
sub_alphas[sub_alphas < 0] = 0
sub_alphas[(sub_alphas < 0) | with_zero_run_time] = 0
else:
sub_alphas[sub_alphas > 1] = 1
sub_alphas[(sub_alphas > 1) | with_zero_run_time] = 1
for anim_to_update, sub_alpha in zip(to_update["anim"], sub_alphas):
for anim_to_update, sub_alpha in zip(
to_update["anim"], sub_alphas, strict=True
):
anim_to_update.interpolate(sub_alpha)
self.anim_group_time = anim_group_time
@ -226,7 +232,7 @@ class Succession(AnimationGroup):
))
"""
def __init__(self, *animations: Animation, lag_ratio: float = 1, **kwargs) -> None:
def __init__(self, *animations: Animation, lag_ratio: float = 1, **kwargs: Any):
super().__init__(*animations, lag_ratio=lag_ratio, **kwargs)
def begin(self) -> None:
@ -245,7 +251,7 @@ class Succession(AnimationGroup):
if self.active_animation:
self.active_animation.update_mobjects(dt)
def _setup_scene(self, scene) -> None:
def _setup_scene(self, scene: Scene | None) -> None:
if scene is None:
return
if self.is_introducer():
@ -337,7 +343,7 @@ class LaggedStart(AnimationGroup):
self,
*animations: Animation,
lag_ratio: float = DEFAULT_LAGGED_START_LAG_RATIO,
**kwargs,
**kwargs: Any,
):
super().__init__(*animations, lag_ratio=lag_ratio, **kwargs)
@ -347,7 +353,7 @@ class LaggedStartMap(LaggedStart):
Parameters
----------
AnimationClass
animation_class
:class:`~.Animation` to apply to mobject.
mobject
:class:`~.Mobject` whose submobjects the animation, and optionally the function,
@ -356,6 +362,17 @@ class LaggedStartMap(LaggedStart):
Function which will be applied to :class:`~.Mobject`.
run_time
The duration of the animation in seconds.
lag_ratio
Defines the delay after which the animation is applied to submobjects. A lag_ratio of
``n.nn`` means the next animation will play when ``nnn%`` of the current animation has played.
Defaults to 0.05, meaning that the next animation will begin when 5% of the current
animation has played.
This does not influence the total runtime of the animation. Instead the runtime
of individual animations is adjusted so that the complete animation has the defined
run time.
kwargs
Further keyword arguments that are passed to `animation_class`.
Examples
--------
@ -382,20 +399,23 @@ class LaggedStartMap(LaggedStart):
def __init__(
self,
AnimationClass: Callable[..., Animation],
animation_class: type[Animation],
mobject: Mobject,
arg_creator: Callable[[Mobject], str] = None,
arg_creator: Callable[[Mobject], Iterable[Any]] | None = None,
run_time: float = 2,
**kwargs,
) -> None:
args_list = []
for submob in mobject:
if arg_creator:
args_list.append(arg_creator(submob))
else:
args_list.append((submob,))
lag_ratio: float = DEFAULT_LAGGED_START_LAG_RATIO,
**kwargs: Any,
):
if arg_creator is None:
def identity(mob: Mobject) -> Mobject:
return mob
arg_creator = identity
args_list = [arg_creator(submob) for submob in mobject]
anim_kwargs = dict(kwargs)
if "lag_ratio" in anim_kwargs:
anim_kwargs.pop("lag_ratio")
animations = [AnimationClass(*args, **anim_kwargs) for args in args_list]
super().__init__(*animations, run_time=run_time, **kwargs)
animations = [animation_class(*args, **anim_kwargs) for args in args_list]
super().__init__(*animations, run_time=run_time, lag_ratio=lag_ratio)

View file

@ -76,8 +76,8 @@ __all__ = [
import itertools as it
from collections.abc import Iterable, Sequence
from typing import TYPE_CHECKING, Callable
from collections.abc import Callable, Iterable, Sequence
from typing import TYPE_CHECKING
import numpy as np
@ -120,7 +120,7 @@ class ShowPartial(Animation):
):
pointwise = getattr(mobject, "pointwise_become_partial", None)
if not callable(pointwise):
raise NotImplementedError("This animation is not defined for this Mobject.")
raise TypeError(f"{self.__class__.__name__} only works for VMobjects.")
super().__init__(mobject, **kwargs)
def interpolate_submobject(
@ -133,7 +133,7 @@ class ShowPartial(Animation):
starting_submobject, *self._get_bounds(alpha)
)
def _get_bounds(self, alpha: float) -> None:
def _get_bounds(self, alpha: float) -> tuple[float, float]:
raise NotImplementedError("Please use Create or ShowPassingFlash")
@ -173,7 +173,7 @@ class Create(ShowPartial):
) -> None:
super().__init__(mobject, lag_ratio=lag_ratio, introducer=introducer, **kwargs)
def _get_bounds(self, alpha: float) -> tuple[int, float]:
def _get_bounds(self, alpha: float) -> tuple[float, float]:
return (0, alpha)
@ -229,8 +229,6 @@ class DrawBorderThenFill(Animation):
rate_func: Callable[[float], float] = double_smooth,
stroke_width: float = 2,
stroke_color: str = None,
draw_border_animation_config: dict = {}, # what does this dict accept?
fill_animation_config: dict = {},
introducer: bool = True,
**kwargs,
) -> None:
@ -244,8 +242,6 @@ class DrawBorderThenFill(Animation):
)
self.stroke_width = stroke_width
self.stroke_color = stroke_color
self.draw_border_animation_config = draw_border_animation_config
self.fill_animation_config = fill_animation_config
self.outline = self.get_outline()
def _typecheck_input(self, vmobject: VMobject | OpenGLVMobject) -> None:
@ -283,7 +279,7 @@ class DrawBorderThenFill(Animation):
alpha: float,
) -> None: # Fixme: not matching the parent class? What is outline doing here?
index: int
subalpha: int
subalpha: float
index, subalpha = integer_interpolate(0, 2, alpha)
if index == 0:
submobject.pointwise_become_partial(outline, 0, subalpha)
@ -476,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):
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

@ -19,6 +19,8 @@ __all__ = [
"FadeIn",
]
from typing import Any
import numpy as np
from manim.mobject.opengl.opengl_mobject import OpenGLMobject
@ -53,7 +55,7 @@ class _Fade(Transform):
shift: np.ndarray | None = None,
target_position: np.ndarray | Mobject | None = None,
scale: float = 1,
**kwargs,
**kwargs: Any,
) -> None:
if not mobjects:
raise ValueError("At least one mobject must be passed.")
@ -85,7 +87,7 @@ class _Fade(Transform):
Mobject
The faded, shifted and scaled copy of the mobject.
"""
faded_mobject = self.mobject.copy()
faded_mobject: Mobject = self.mobject.copy() # type: ignore[assignment]
faded_mobject.fade(1)
direction_modifier = -1 if fadeIn and not self.point_target else 1
faded_mobject.shift(self.shift_vector * direction_modifier)
@ -94,7 +96,7 @@ class _Fade(Transform):
class FadeIn(_Fade):
"""Fade in :class:`~.Mobject` s.
r"""Fade in :class:`~.Mobject` s.
Parameters
----------
@ -119,7 +121,7 @@ class FadeIn(_Fade):
dot = Dot(UP * 2 + LEFT)
self.add(dot)
tex = Tex(
"FadeIn with ", "shift ", " or target\\_position", " and scale"
"FadeIn with ", "shift ", r" or target\_position", " and scale"
).scale(1)
animations = [
FadeIn(tex[0]),
@ -131,18 +133,18 @@ class FadeIn(_Fade):
"""
def __init__(self, *mobjects: Mobject, **kwargs) -> None:
def __init__(self, *mobjects: Mobject, **kwargs: Any) -> None:
super().__init__(*mobjects, introducer=True, **kwargs)
def create_target(self):
return self.mobject
def create_target(self) -> Mobject:
return self.mobject # type: ignore[return-value]
def create_starting_mobject(self):
def create_starting_mobject(self) -> Mobject:
return self._create_faded_mobject(fadeIn=True)
class FadeOut(_Fade):
"""Fade out :class:`~.Mobject` s.
r"""Fade out :class:`~.Mobject` s.
Parameters
----------
@ -166,7 +168,7 @@ class FadeOut(_Fade):
dot = Dot(UP * 2 + LEFT)
self.add(dot)
tex = Tex(
"FadeOut with ", "shift ", " or target\\_position", " and scale"
"FadeOut with ", "shift ", r" or target\_position", " and scale"
).scale(1)
animations = [
FadeOut(tex[0]),
@ -179,12 +181,12 @@ class FadeOut(_Fade):
"""
def __init__(self, *mobjects: Mobject, **kwargs) -> None:
def __init__(self, *mobjects: Mobject, **kwargs: Any) -> None:
super().__init__(*mobjects, remover=True, **kwargs)
def create_target(self):
def create_target(self) -> Mobject:
return self._create_faded_mobject(fadeIn=False)
def clean_up_from_scene(self, scene: Scene = None) -> None:
def clean_up_from_scene(self, scene: Scene) -> None:
super().clean_up_from_scene(scene)
self.interpolate(0)

View file

@ -31,16 +31,17 @@ __all__ = [
"SpinInFromNothing",
]
import typing
import numpy as np
from typing import TYPE_CHECKING, Any
from ..animation.transform import Transform
from ..constants import PI
from ..utils.paths import spiral_path
if typing.TYPE_CHECKING:
if TYPE_CHECKING:
from manim.mobject.geometry.line import Arrow
from manim.mobject.opengl.opengl_mobject import OpenGLMobject
from manim.typing import Point3DLike, Vector3DLike
from manim.utils.color import ParsableManimColor
from ..mobject.mobject import Mobject
@ -76,16 +77,20 @@ class GrowFromPoint(Transform):
"""
def __init__(
self, mobject: Mobject, point: np.ndarray, point_color: str = None, **kwargs
) -> None:
self,
mobject: Mobject,
point: Point3DLike,
point_color: ParsableManimColor | None = None,
**kwargs: Any,
):
self.point = point
self.point_color = point_color
super().__init__(mobject, introducer=True, **kwargs)
def create_target(self) -> Mobject:
def create_target(self) -> Mobject | OpenGLMobject:
return self.mobject
def create_starting_mobject(self) -> Mobject:
def create_starting_mobject(self) -> Mobject | OpenGLMobject:
start = super().create_starting_mobject()
start.scale(0)
start.move_to(self.point)
@ -118,7 +123,12 @@ class GrowFromCenter(GrowFromPoint):
"""
def __init__(self, mobject: Mobject, point_color: str = None, **kwargs) -> None:
def __init__(
self,
mobject: Mobject,
point_color: ParsableManimColor | None = None,
**kwargs: Any,
):
point = mobject.get_center()
super().__init__(mobject, point, point_color=point_color, **kwargs)
@ -153,8 +163,12 @@ class GrowFromEdge(GrowFromPoint):
"""
def __init__(
self, mobject: Mobject, edge: np.ndarray, point_color: str = None, **kwargs
) -> None:
self,
mobject: Mobject,
edge: Vector3DLike,
point_color: ParsableManimColor | None = None,
**kwargs: Any,
):
point = mobject.get_critical_point(edge)
super().__init__(mobject, point, point_color=point_color, **kwargs)
@ -183,11 +197,13 @@ class GrowArrow(GrowFromPoint):
"""
def __init__(self, arrow: Arrow, point_color: str = None, **kwargs) -> None:
def __init__(
self, arrow: Arrow, point_color: ParsableManimColor | None = None, **kwargs: Any
):
point = arrow.get_start()
super().__init__(arrow, point, point_color=point_color, **kwargs)
def create_starting_mobject(self) -> Mobject:
def create_starting_mobject(self) -> Mobject | OpenGLMobject:
start_arrow = self.mobject.copy()
start_arrow.scale(0, scale_tips=True, about_point=self.point)
if self.point_color:
@ -224,8 +240,12 @@ class SpinInFromNothing(GrowFromCenter):
"""
def __init__(
self, mobject: Mobject, angle: float = PI / 2, point_color: str = None, **kwargs
) -> None:
self,
mobject: Mobject,
angle: float = PI / 2,
point_color: ParsableManimColor | None = None,
**kwargs: Any,
):
self.angle = angle
super().__init__(
mobject, path_func=spiral_path(angle), point_color=point_color, **kwargs

View file

@ -40,7 +40,7 @@ __all__ = [
]
from collections.abc import Iterable
from typing import Callable
from typing import Any, Self
import numpy as np
@ -48,6 +48,7 @@ from manim.mobject.geometry.arc import Circle, Dot
from manim.mobject.geometry.line import Line
from manim.mobject.geometry.polygram import Rectangle
from manim.mobject.geometry.shape_matchers import SurroundingRectangle
from manim.mobject.opengl.opengl_mobject import OpenGLMobject
from manim.scene.scene import Scene
from .. import config
@ -61,9 +62,10 @@ from ..animation.updaters.update import UpdateFromFunc
from ..constants import *
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.rate_functions import smooth, there_and_back, wiggle
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
@ -87,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()
@ -95,12 +97,12 @@ class FocusOn(Transform):
def __init__(
self,
focus_point: np.ndarray | Mobject,
focus_point: Point3DLike | Mobject,
opacity: float = 0.2,
color: str = GREY,
color: ParsableManimColor = GREY,
run_time: float = 2,
**kwargs,
) -> None:
**kwargs: Any,
):
self.focus_point = focus_point
self.color = color
self.opacity = opacity
@ -151,15 +153,15 @@ class Indicate(Transform):
self,
mobject: Mobject,
scale_factor: float = 1.2,
color: str = YELLOW,
rate_func: Callable[[float, float | None], np.ndarray] = there_and_back,
**kwargs,
) -> None:
color: ParsableManimColor = PURE_YELLOW,
rate_func: RateFunction = there_and_back,
**kwargs: Any,
):
self.color = color
self.scale_factor = scale_factor
super().__init__(mobject, rate_func=rate_func, **kwargs)
def create_target(self) -> Mobject:
def create_target(self) -> Mobject | OpenGLMobject:
target = self.mobject.copy()
target.scale(self.scale_factor)
target.set_color(self.color)
@ -196,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()
@ -219,20 +221,20 @@ class Flash(AnimationGroup):
def __init__(
self,
point: np.ndarray | Mobject,
point: Point3DLike | Mobject,
line_length: float = 0.2,
num_lines: int = 12,
flash_radius: float = 0.1,
line_stroke_width: int = 3,
color: str = YELLOW,
color: ParsableManimColor = PURE_YELLOW,
time_width: float = 1,
run_time: float = 1.0,
**kwargs,
) -> None:
**kwargs: Any,
):
if isinstance(point, Mobject):
self.point = point.get_center()
self.point: Point3D = point.get_center()
else:
self.point = point
self.point = np.asarray(point)
self.color = color
self.line_length = line_length
self.num_lines = num_lines
@ -270,7 +272,7 @@ class Flash(AnimationGroup):
class ShowPassingFlash(ShowPartial):
"""Show only a sliver of the VMobject each frame.
r"""Show only a sliver of the VMobject each frame.
Parameters
----------
@ -290,7 +292,7 @@ class ShowPassingFlash(ShowPartial):
self.add(p, lbl)
p = p.copy().set_color(BLUE)
for time_width in [0.2, 0.5, 1, 2]:
lbl.become(Tex(r"\\texttt{time\\_width={{%.1f}}}"%time_width))
lbl.become(Tex(r"\texttt{time\_width={{%.1f}}}"%time_width))
self.play(ShowPassingFlash(
p.copy().set_color(BLUE),
run_time=2,
@ -303,11 +305,13 @@ class ShowPassingFlash(ShowPartial):
"""
def __init__(self, mobject: VMobject, time_width: float = 0.1, **kwargs) -> None:
def __init__(
self, mobject: VMobject, time_width: float = 0.1, **kwargs: Any
) -> None:
self.time_width = time_width
super().__init__(mobject, remover=True, introducer=True, **kwargs)
def _get_bounds(self, alpha: float) -> tuple[float]:
def _get_bounds(self, alpha: float) -> tuple[float, float]:
tw = self.time_width
upper = interpolate(0, 1 + tw, alpha)
lower = upper - tw
@ -322,7 +326,14 @@ class ShowPassingFlash(ShowPartial):
class ShowPassingFlashWithThinningStrokeWidth(AnimationGroup):
def __init__(self, vmobject, n_segments=10, time_width=0.1, remover=True, **kwargs):
def __init__(
self,
vmobject: VMobject,
n_segments: int = 10,
time_width: float = 0.1,
remover: bool = True,
**kwargs: Any,
):
self.n_segments = n_segments
self.time_width = time_width
self.remover = remover
@ -338,6 +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=True,
)
),
)
@ -389,19 +401,19 @@ class ApplyWave(Homotopy):
def __init__(
self,
mobject: Mobject,
direction: np.ndarray = UP,
direction: Vector3DLike = UP,
amplitude: float = 0.2,
wave_func: Callable[[float], float] = smooth,
wave_func: RateFunction = smooth,
time_width: float = 1,
ripples: int = 1,
run_time: float = 2,
**kwargs,
) -> None:
**kwargs: Any,
):
x_min = mobject.get_left()[0]
x_max = mobject.get_right()[0]
vect = amplitude * normalize(direction)
def wave(t):
def wave(t: float) -> float:
# Creates a wave with n ripples from a simple rate_func
# This wave is build up as follows:
# The time is split into 2*ripples phases. In every phase the amplitude
@ -467,7 +479,8 @@ class ApplyWave(Homotopy):
relative_x = inverse_interpolate(x_min, x_max, x)
wave_phase = inverse_interpolate(lower, upper, relative_x)
nudge = wave(wave_phase) * vect
return np.array([x, y, z]) + nudge
return_value: tuple[float, float, float] = np.array([x, y, z]) + nudge
return return_value
super().__init__(homotopy, mobject, run_time=run_time, **kwargs)
@ -511,24 +524,28 @@ class Wiggle(Animation):
scale_value: float = 1.1,
rotation_angle: float = 0.01 * TAU,
n_wiggles: int = 6,
scale_about_point: np.ndarray | None = None,
rotate_about_point: np.ndarray | None = None,
scale_about_point: Point3DLike | None = None,
rotate_about_point: Point3DLike | None = None,
run_time: float = 2,
**kwargs,
) -> None:
**kwargs: Any,
):
self.scale_value = scale_value
self.rotation_angle = rotation_angle
self.n_wiggles = n_wiggles
self.scale_about_point = scale_about_point
if scale_about_point is not None:
self.scale_about_point = np.array(scale_about_point)
self.rotate_about_point = rotate_about_point
if rotate_about_point is not None:
self.rotate_about_point = np.array(rotate_about_point)
super().__init__(mobject, run_time=run_time, **kwargs)
def get_scale_about_point(self) -> np.ndarray:
def get_scale_about_point(self) -> Point3D:
if self.scale_about_point is None:
return self.mobject.get_center()
return self.scale_about_point
def get_rotate_about_point(self) -> np.ndarray:
def get_rotate_about_point(self) -> Point3D:
if self.rotate_about_point is None:
return self.mobject.get_center()
return self.rotate_about_point
@ -538,7 +555,7 @@ class Wiggle(Animation):
submobject: Mobject,
starting_submobject: Mobject,
alpha: float,
) -> None:
) -> Self:
submobject.points[:, :] = starting_submobject.points
submobject.scale(
interpolate(1, self.scale_value, there_and_back(alpha)),
@ -548,10 +565,11 @@ class Wiggle(Animation):
wiggle(alpha, self.n_wiggles) * self.rotation_angle,
about_point=self.get_rotate_about_point(),
)
return self
class Circumscribe(Succession):
"""Draw a temporary line surrounding the mobject.
r"""Draw a temporary line surrounding the mobject.
Parameters
----------
@ -582,7 +600,7 @@ class Circumscribe(Succession):
class UsingCircumscribe(Scene):
def construct(self):
lbl = Tex(r"Circum-\\\\scribe").scale(2)
lbl = Tex(r"Circum-\\scribe").scale(2)
self.add(lbl)
self.play(Circumscribe(lbl))
self.play(Circumscribe(lbl, Circle))
@ -595,21 +613,21 @@ class Circumscribe(Succession):
def __init__(
self,
mobject: Mobject,
shape: type = Rectangle,
fade_in=False,
fade_out=False,
time_width=0.3,
shape: type[Rectangle] | type[Circle] = Rectangle,
fade_in: bool = False,
fade_out: bool = False,
time_width: float = 0.3,
buff: float = SMALL_BUFF,
color: ParsableManimColor = YELLOW,
run_time=1,
stroke_width=DEFAULT_STROKE_WIDTH,
**kwargs,
color: ParsableManimColor = PURE_YELLOW,
run_time: float = 1,
stroke_width: float = DEFAULT_STROKE_WIDTH,
**kwargs: Any,
):
if shape is Rectangle:
frame = SurroundingRectangle(
frame: SurroundingRectangle | Circle = SurroundingRectangle(
mobject,
color,
buff,
color=color,
buff=buff,
stroke_width=stroke_width,
)
elif shape is Circle:
@ -685,7 +703,7 @@ class Blink(Succession):
time_off: float = 0.5,
blinks: int = 1,
hide_at_end: bool = False,
**kwargs,
**kwargs: Any,
):
animations = [
UpdateFromFunc(

View file

@ -10,7 +10,8 @@ __all__ = [
"MoveAlongPath",
]
from typing import TYPE_CHECKING, Any, Callable
from collections.abc import Callable
from typing import TYPE_CHECKING, Any
import numpy as np
@ -18,7 +19,13 @@ from ..animation.animation import Animation
from ..utils.rate_functions import linear
if TYPE_CHECKING:
from ..mobject.mobject import Mobject, VMobject
from typing import Self
from manim.mobject.types.vectorized_mobject import VMobject
from manim.typing import MappingFunction, Point3D
from manim.utils.rate_functions import RateFunction
from ..mobject.mobject import Mobject
class Homotopy(Animation):
@ -44,6 +51,26 @@ class Homotopy(Animation):
Keyword arguments propagated to :meth:`.Mobject.apply_function`.
kwargs
Further keyword arguments passed to the parent class.
Examples
--------
.. manim:: HomotopyExample
class HomotopyExample(Scene):
def construct(self):
square = Square()
def homotopy(x, y, z, t):
if t <= 0.25:
progress = t / 0.25
return (x, y + progress * 0.2 * np.sin(x), z)
else:
wave_progress = (t - 0.25) / 0.75
return (x, y + 0.2 * np.sin(x + 10 * wave_progress), z)
self.play(Homotopy(homotopy, square, rate_func= linear, run_time=2))
"""
def __init__(
@ -52,27 +79,33 @@ class Homotopy(Animation):
mobject: Mobject,
run_time: float = 3,
apply_function_kwargs: dict[str, Any] | None = None,
**kwargs,
) -> None:
**kwargs: Any,
):
self.homotopy = homotopy
self.apply_function_kwargs = (
apply_function_kwargs if apply_function_kwargs is not None else {}
)
super().__init__(mobject, run_time=run_time, **kwargs)
def function_at_time_t(self, t: float) -> tuple[float, float, float]:
return lambda p: self.homotopy(*p, t)
def function_at_time_t(self, t: float) -> MappingFunction:
def mapping_function(p: Point3D) -> Point3D:
x, y, z = p
return np.array(self.homotopy(x, y, z, t))
return mapping_function
def interpolate_submobject(
self,
submobject: Mobject,
starting_submobject: Mobject,
alpha: float,
) -> None:
) -> Self:
submobject.points = starting_submobject.points
submobject.apply_function(
self.function_at_time_t(alpha), **self.apply_function_kwargs
self.function_at_time_t(alpha),
**self.apply_function_kwargs,
)
return self
class SmoothedVectorizedHomotopy(Homotopy):
@ -81,18 +114,21 @@ class SmoothedVectorizedHomotopy(Homotopy):
submobject: Mobject,
starting_submobject: Mobject,
alpha: float,
) -> None:
) -> Self:
assert isinstance(submobject, VMobject)
super().interpolate_submobject(submobject, starting_submobject, alpha)
submobject.make_smooth()
return self
class ComplexHomotopy(Homotopy):
def __init__(
self, complex_homotopy: Callable[[complex], float], mobject: Mobject, **kwargs
) -> None:
"""
Complex Homotopy a function Cx[0, 1] to C
"""
self,
complex_homotopy: Callable[[complex, float], float],
mobject: Mobject,
**kwargs: Any,
):
"""Complex Homotopy a function Cx[0, 1] to C"""
def homotopy(
x: float,
@ -113,9 +149,9 @@ class PhaseFlow(Animation):
mobject: Mobject,
virtual_time: float = 1,
suspend_mobject_updating: bool = False,
rate_func: Callable[[float], float] = linear,
**kwargs,
) -> None:
rate_func: RateFunction = linear,
**kwargs: Any,
):
self.virtual_time = virtual_time
self.function = function
super().__init__(
@ -131,7 +167,7 @@ class PhaseFlow(Animation):
self.rate_func(alpha) - self.rate_func(self.last_alpha)
)
self.mobject.apply_function(lambda p: p + dt * self.function(p))
self.last_alpha = alpha
self.last_alpha: float = alpha
class MoveAlongPath(Animation):
@ -153,9 +189,9 @@ class MoveAlongPath(Animation):
self,
mobject: Mobject,
path: VMobject,
suspend_mobject_updating: bool | None = False,
**kwargs,
) -> None:
suspend_mobject_updating: bool = False,
**kwargs: Any,
):
self.path = path
super().__init__(
mobject, suspend_mobject_updating=suspend_mobject_updating, **kwargs

View file

@ -5,7 +5,8 @@ from __future__ import annotations
__all__ = ["ChangingDecimal", "ChangeDecimalToValue"]
import typing
from collections.abc import Callable
from typing import Any
from manim.mobject.text.numbers import DecimalNumber
@ -14,12 +15,47 @@ from ..utils.bezier import interpolate
class ChangingDecimal(Animation):
"""Animate a :class:`~.DecimalNumber` to values specified by a user-supplied function.
Parameters
----------
decimal_mob
The :class:`~.DecimalNumber` instance to animate.
number_update_func
A function that returns the number to display at each point in the animation.
suspend_mobject_updating
If ``True``, the mobject is not updated outside this animation.
Raises
------
TypeError
If ``decimal_mob`` is not an instance of :class:`~.DecimalNumber`.
Examples
--------
.. manim:: ChangingDecimalExample
class ChangingDecimalExample(Scene):
def construct(self):
number = DecimalNumber(0)
self.add(number)
self.play(
ChangingDecimal(
number,
lambda a: 5 * a,
run_time=3
)
)
self.wait()
"""
def __init__(
self,
decimal_mob: DecimalNumber,
number_update_func: typing.Callable[[float], float],
suspend_mobject_updating: bool | None = False,
**kwargs,
number_update_func: Callable[[float], float],
suspend_mobject_updating: bool = False,
**kwargs: Any,
) -> None:
self.check_validity_of_input(decimal_mob)
self.number_update_func = number_update_func
@ -32,12 +68,34 @@ class ChangingDecimal(Animation):
raise TypeError("ChangingDecimal can only take in a DecimalNumber")
def interpolate_mobject(self, alpha: float) -> None:
self.mobject.set_value(self.number_update_func(self.rate_func(alpha)))
self.mobject.set_value(self.number_update_func(self.rate_func(alpha))) # type: ignore[attr-defined]
class ChangeDecimalToValue(ChangingDecimal):
"""Animate a :class:`~.DecimalNumber` to a target value using linear interpolation.
Parameters
----------
decimal_mob
The :class:`~.DecimalNumber` instance to animate.
target_number
The target value to transition to.
Examples
--------
.. manim:: ChangeDecimalToValueExample
class ChangeDecimalToValueExample(Scene):
def construct(self):
number = DecimalNumber(0)
self.add(number)
self.play(ChangeDecimalToValue(number, 10, run_time=3))
self.wait()
"""
def __init__(
self, decimal_mob: DecimalNumber, target_number: int, **kwargs
self, decimal_mob: DecimalNumber, target_number: int, **kwargs: Any
) -> None:
start_number = decimal_mob.number
super().__init__(

View file

@ -4,10 +4,8 @@ from __future__ import annotations
__all__ = ["Rotating", "Rotate"]
from collections.abc import Sequence
from typing import TYPE_CHECKING, Callable
import numpy as np
from collections.abc import Callable
from typing import TYPE_CHECKING, Any
from ..animation.animation import Animation
from ..animation.transform import Transform
@ -16,22 +14,90 @@ from ..utils.rate_functions import linear
if TYPE_CHECKING:
from ..mobject.mobject import Mobject
from ..mobject.opengl.opengl_mobject import OpenGLMobject
from ..typing import Point3DLike, Vector3DLike
class Rotating(Animation):
"""Animation that rotates a Mobject.
Parameters
----------
mobject
The mobject to be rotated.
angle
The rotation angle in radians. Predefined constants such as ``DEGREES``
can also be used to specify the angle in degrees.
axis
The rotation axis as a numpy vector.
about_point
The rotation center.
about_edge
If ``about_point`` is ``None``, this argument specifies
the direction of the bounding box point to be taken as
the rotation center.
run_time
The duration of the animation in seconds.
rate_func
The function defining the animation progress based on the relative
runtime (see :mod:`~.rate_functions`) .
**kwargs
Additional keyword arguments passed to :class:`~.Animation`.
Examples
--------
.. manim:: RotatingDemo
class RotatingDemo(Scene):
def construct(self):
circle = Circle(radius=1, color=BLUE)
line = Line(start=ORIGIN, end=RIGHT)
arrow = Arrow(start=ORIGIN, end=RIGHT, buff=0, color=GOLD)
vg = VGroup(circle,line,arrow)
self.add(vg)
anim_kw = {"about_point": arrow.get_start(), "run_time": 1}
self.play(Rotating(arrow, 180*DEGREES, **anim_kw))
self.play(Rotating(arrow, PI, **anim_kw))
self.play(Rotating(vg, PI, about_point=RIGHT))
self.play(Rotating(vg, PI, axis=UP, about_point=ORIGIN))
self.play(Rotating(vg, PI, axis=RIGHT, about_edge=UP))
self.play(vg.animate.move_to(ORIGIN))
.. manim:: RotatingDifferentAxis
class RotatingDifferentAxis(ThreeDScene):
def construct(self):
axes = ThreeDAxes()
cube = Cube()
arrow2d = Arrow(start=[0, -1.2, 1], end=[0, 1.2, 1], color=YELLOW_E)
cube_group = VGroup(cube,arrow2d)
self.set_camera_orientation(gamma=0, phi=40*DEGREES, theta=40*DEGREES)
self.add(axes, cube_group)
play_kw = {"run_time": 1.5}
self.play(Rotating(cube_group, PI), **play_kw)
self.play(Rotating(cube_group, PI, axis=UP), **play_kw)
self.play(Rotating(cube_group, 180*DEGREES, axis=RIGHT), **play_kw)
self.wait(0.5)
See also
--------
:class:`~.Rotate`, :meth:`~.Mobject.rotate`
"""
def __init__(
self,
mobject: Mobject,
axis: np.ndarray = OUT,
radians: np.ndarray = TAU,
about_point: np.ndarray | None = None,
about_edge: np.ndarray | None = None,
angle: float = TAU,
axis: Vector3DLike = OUT,
about_point: Point3DLike | None = None,
about_edge: Vector3DLike | None = None,
run_time: float = 5,
rate_func: Callable[[float], float] = linear,
**kwargs,
**kwargs: Any,
) -> None:
self.angle = angle
self.axis = axis
self.radians = radians
self.about_point = about_point
self.about_edge = about_edge
super().__init__(mobject, run_time=run_time, rate_func=rate_func, **kwargs)
@ -39,7 +105,7 @@ class Rotating(Animation):
def interpolate_mobject(self, alpha: float) -> None:
self.mobject.become(self.starting_mobject)
self.mobject.rotate(
self.rate_func(alpha) * self.radians,
self.rate_func(alpha) * self.angle,
axis=self.axis,
about_point=self.about_point,
about_edge=self.about_edge,
@ -80,16 +146,20 @@ class Rotate(Transform):
Rotate(Square(side_length=0.5), angle=2*PI, rate_func=linear),
)
See also
--------
:class:`~.Rotating`, :meth:`~.Mobject.rotate`
"""
def __init__(
self,
mobject: Mobject,
angle: float = PI,
axis: np.ndarray = OUT,
about_point: Sequence[float] | None = None,
about_edge: Sequence[float] | None = None,
**kwargs,
axis: Vector3DLike = OUT,
about_point: Point3DLike | None = None,
about_edge: Vector3DLike | None = None,
**kwargs: Any,
) -> None:
if "path_arc" not in kwargs:
kwargs["path_arc"] = angle
@ -103,7 +173,7 @@ class Rotate(Transform):
self.about_point = mobject.get_center()
super().__init__(mobject, path_arc_centers=self.about_point, **kwargs)
def create_target(self) -> Mobject:
def create_target(self) -> Mobject | OpenGLMobject:
target = self.mobject.copy()
target.rotate(
self.angle,

View file

@ -6,6 +6,7 @@ from collections.abc import Sequence
from typing import Any
from manim.animation.transform import Restore
from manim.mobject.mobject import Mobject
from ..constants import *
from .composition import LaggedStart
@ -50,7 +51,7 @@ class Broadcast(LaggedStart):
def __init__(
self,
mobject,
mobject: Mobject,
focal_point: Sequence[float] = ORIGIN,
n_mobs: int = 5,
initial_opacity: float = 1,

View file

@ -4,7 +4,8 @@ from __future__ import annotations
import inspect
import types
from typing import TYPE_CHECKING, Callable
from collections.abc import Callable
from typing import TYPE_CHECKING
from numpy import piecewise
@ -113,9 +114,9 @@ class ChangeSpeed(Animation):
self.anim = self.setup(anim)
if affects_speed_updaters:
assert (
ChangeSpeed.is_changing_dt is False
), "Only one animation at a time can play that changes speed (dt) for ChangeSpeed updaters"
assert ChangeSpeed.is_changing_dt is False, (
"Only one animation at a time can play that changes speed (dt) for ChangeSpeed updaters"
)
ChangeSpeed.is_changing_dt = True
self.t = 0
self.affects_speed_updaters = affects_speed_updaters

View file

@ -28,11 +28,12 @@ __all__ = [
import inspect
import types
from collections.abc import Iterable, Sequence
from typing import TYPE_CHECKING, Any, Callable
from collections.abc import Callable, Iterable, Sequence
from typing import TYPE_CHECKING, Any
import numpy as np
from manim.data_structures import MethodWithArgs
from manim.mobject.opengl.opengl_mobject import OpenGLGroup, OpenGLMobject
from .. import config
@ -45,11 +46,13 @@ from ..constants import (
RendererType,
)
from ..mobject.mobject import Group, Mobject
from ..mobject.types.vectorized_mobject import VGroup
from ..utils.paths import path_along_arc, path_along_circles
from ..utils.rate_functions import smooth, squish_rate_func
if TYPE_CHECKING:
from ..scene.scene import Scene
from ..typing import Point3DLike, Point3DLike_Array
class Transform(Animation):
@ -123,6 +126,10 @@ class Transform(Animation):
self.play(*anims, run_time=2)
self.wait()
See also
--------
:class:`~.ReplacementTransform`, :meth:`~.Mobject.interpolate`, :meth:`~.Mobject.align_data`
"""
def __init__(
@ -132,12 +139,12 @@ class Transform(Animation):
path_func: Callable | None = None,
path_arc: float = 0,
path_arc_axis: np.ndarray = OUT,
path_arc_centers: np.ndarray = None,
path_arc_centers: Point3DLike | Point3DLike_Array | None = None,
replace_mobject_with_target_in_scene: bool = False,
**kwargs,
) -> None:
self.path_arc_axis: np.ndarray = path_arc_axis
self.path_arc_centers: np.ndarray = path_arc_centers
self.path_arc_centers: Point3DLike | Point3DLike_Array | None = path_arc_centers
self.path_arc: float = path_arc
# path_func is a property a few lines below so it doesn't need to be set in any case
@ -204,7 +211,7 @@ class Transform(Animation):
self.mobject.align_data(self.target_copy)
super().begin()
def create_target(self) -> Mobject:
def create_target(self) -> Mobject | OpenGLMobject:
# Has no meaningful effect here, but may be useful
# in subclasses
return self.target_mobject
@ -229,8 +236,8 @@ class Transform(Animation):
self.target_copy,
]
if config.renderer == RendererType.OPENGL:
return zip(*(mob.get_family() for mob in mobs))
return zip(*(mob.family_members_with_points() for mob in mobs))
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,
@ -298,9 +305,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)
@ -431,18 +436,18 @@ class MoveToTarget(Transform):
def check_validity_of_input(self, mobject: Mobject) -> None:
if not hasattr(mobject, "target"):
raise ValueError(
"MoveToTarget called on mobject" "without attribute 'target'",
"MoveToTarget called on mobjectwithout attribute 'target'",
)
class _MethodAnimation(MoveToTarget):
def __init__(self, mobject, methods):
def __init__(self, mobject: Mobject, methods: list[MethodWithArgs]) -> None:
self.methods = methods
super().__init__(mobject)
def finish(self) -> None:
for method, method_args, method_kwargs in self.methods:
method.__func__(self.mobject, *method_args, **method_kwargs)
for item in self.methods:
item.method.__func__(self.mobject, *item.args, **item.kwargs)
super().finish()
@ -731,19 +736,36 @@ class CyclicReplace(Transform):
def __init__(
self, *mobjects: Mobject, path_arc: float = 90 * DEGREES, **kwargs
) -> None:
self.group = Group(*mobjects)
if len(mobjects) == 1 and isinstance(mobjects[0], (Group, VGroup)):
self.group = mobjects[0]
else:
self.group = Group(*mobjects)
super().__init__(self.group, path_arc=path_arc, **kwargs)
def create_target(self) -> Group:
def create_target(self) -> Group | VGroup:
target = self.group.copy()
cycled_targets = [target[-1], *target[:-1]]
for m1, m2 in zip(cycled_targets, self.group):
for m1, m2 in zip(cycled_targets, self.group, strict=True):
m1.move_to(m2)
return target
class Swap(CyclicReplace):
pass # Renaming, more understandable for two entries
"""Another name for :class:`~.CyclicReplace`, which is more understandable for two entries.
Examples
--------
.. manim :: SwapExample
class SwapExample(Scene):
def construct(self):
text_a = Text("A").move_to(LEFT)
text_b = Text("B").move_to(RIGHT)
text_group = Group(text_a, text_b)
self.play(FadeIn(text_group))
self.play(Swap(text_group))
self.wait()
"""
# TODO, this may be deprecated...worth reimplementing?
@ -831,7 +853,14 @@ class FadeTransform(Transform):
"""
def __init__(self, mobject, target_mobject, stretch=True, dim_to_match=1, **kwargs):
def __init__(
self,
mobject: Mobject,
target_mobject: Mobject,
stretch: bool = True,
dim_to_match: int = 1,
**kwargs: Any,
):
self.to_add_on_completion = target_mobject
self.stretch = stretch
self.dim_to_match = dim_to_match
@ -925,5 +954,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()):
for sm0, sm1 in zip(source.get_family(), target.get_family(), strict=True):
super().ghost_to(sm0, sm1)

View file

@ -4,12 +4,13 @@ from __future__ import annotations
__all__ = ["TransformMatchingShapes", "TransformMatchingTex"]
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
import numpy as np
from manim.mobject.opengl.opengl_mobject import OpenGLGroup, OpenGLMobject
from manim.mobject.opengl.opengl_vectorized_mobject import OpenGLVGroup, OpenGLVMobject
from manim.mobject.text.tex_mobject import MathTexPart
from .._config import config
from ..constants import RendererType
@ -74,10 +75,10 @@ class TransformMatchingAbstractBase(AnimationGroup):
transform_mismatches: bool = False,
fade_transform_mismatches: bool = False,
key_map: dict | None = None,
**kwargs,
**kwargs: Any,
):
if isinstance(mobject, OpenGLVMobject):
group_type = OpenGLVGroup
group_type: type[OpenGLVGroup | OpenGLGroup | VGroup | Group] = OpenGLVGroup
elif isinstance(mobject, OpenGLMobject):
group_type = OpenGLGroup
elif isinstance(mobject, VMobject):
@ -96,7 +97,6 @@ class TransformMatchingAbstractBase(AnimationGroup):
# target_map
transform_source = group_type()
transform_target = group_type()
kwargs["final_alpha_value"] = 0
for key in set(source_map).intersection(target_map):
transform_source.add(source_map[key])
transform_target.add(target_map[key])
@ -142,7 +142,7 @@ class TransformMatchingAbstractBase(AnimationGroup):
self.to_add = target_mobject
def get_shape_map(self, mobject: Mobject) -> dict:
shape_map = {}
shape_map: dict[int | str, VGroup | OpenGLVGroup] = {}
for sm in self.get_mobject_parts(mobject):
key = self.get_mobject_key(sm)
if key not in shape_map:
@ -150,23 +150,25 @@ class TransformMatchingAbstractBase(AnimationGroup):
shape_map[key] = OpenGLVGroup()
else:
shape_map[key] = VGroup()
shape_map[key].add(sm)
# error: Argument 1 to "add" of "OpenGLVGroup" has incompatible type "Mobject"; expected "OpenGLVMobject" [arg-type]
shape_map[key].add(sm) # type: ignore[arg-type]
return shape_map
def clean_up_from_scene(self, scene: Scene) -> None:
# Interpolate all animations back to 0 to ensure source mobjects remain unchanged.
for anim in self.animations:
anim.interpolate(0)
scene.remove(self.mobject)
# error: Argument 1 to "remove" of "Scene" has incompatible type "OpenGLMobject"; expected "Mobject" [arg-type]
scene.remove(self.mobject) # type: ignore[arg-type]
scene.remove(*self.to_remove)
scene.add(self.to_add)
@staticmethod
def get_mobject_parts(mobject: Mobject):
def get_mobject_parts(mobject: Mobject) -> list[Mobject]:
raise NotImplementedError("To be implemented in subclass.")
@staticmethod
def get_mobject_key(mobject: Mobject):
def get_mobject_key(mobject: Mobject) -> int | str:
raise NotImplementedError("To be implemented in subclass.")
@ -206,7 +208,7 @@ class TransformMatchingShapes(TransformMatchingAbstractBase):
transform_mismatches: bool = False,
fade_transform_mismatches: bool = False,
key_map: dict | None = None,
**kwargs,
**kwargs: Any,
):
super().__init__(
mobject,
@ -226,7 +228,8 @@ class TransformMatchingShapes(TransformMatchingAbstractBase):
mobject.save_state()
mobject.center()
mobject.set(height=1)
result = hash(np.round(mobject.points, 3).tobytes())
rounded_points = np.round(mobject.points, 3) + 0.0
result = hash(rounded_points.tobytes())
mobject.restore()
return result
@ -269,7 +272,7 @@ class TransformMatchingTex(TransformMatchingAbstractBase):
transform_mismatches: bool = False,
fade_transform_mismatches: bool = False,
key_map: dict | None = None,
**kwargs,
**kwargs: Any,
):
super().__init__(
mobject,
@ -294,4 +297,5 @@ class TransformMatchingTex(TransformMatchingAbstractBase):
@staticmethod
def get_mobject_key(mobject: Mobject) -> str:
assert isinstance(mobject, MathTexPart)
return mobject.tex_string

View file

@ -15,7 +15,8 @@ __all__ = [
import inspect
from typing import TYPE_CHECKING, Callable
from collections.abc import Callable
from typing import TYPE_CHECKING, TypeVar
import numpy as np
@ -28,6 +29,9 @@ if TYPE_CHECKING:
from manim.animation.animation import Animation
M = TypeVar("M", bound=Mobject)
def assert_is_mobject_method(method: Callable) -> None:
assert inspect.ismethod(method)
mobject = method.__self__
@ -42,7 +46,7 @@ def always(method: Callable, *args, **kwargs) -> Mobject:
return mobject
def f_always(method: Callable[[Mobject], None], *arg_generators, **kwargs) -> Mobject:
def f_always(method: Callable[[M], None], *arg_generators, **kwargs) -> M:
"""
More functional version of always, where instead
of taking in args, it takes in functions which output
@ -60,7 +64,7 @@ def f_always(method: Callable[[Mobject], None], *arg_generators, **kwargs) -> Mo
return mobject
def always_redraw(func: Callable[[], Mobject]) -> Mobject:
def always_redraw(func: Callable[[], M]) -> M:
"""Redraw the mobject constructed by a function every frame.
This function returns a mobject with an attached updater that
@ -106,8 +110,8 @@ def always_redraw(func: Callable[[], Mobject]) -> Mobject:
def always_shift(
mobject: Mobject, direction: np.ndarray[np.float64] = RIGHT, rate: float = 0.1
) -> Mobject:
mobject: M, direction: np.ndarray[np.float64] = RIGHT, rate: float = 0.1
) -> M:
"""A mobject which is continuously shifted along some direction
at a certain rate.
@ -144,7 +148,7 @@ def always_shift(
return mobject
def always_rotate(mobject: Mobject, rate: float = 20 * DEGREES, **kwargs) -> Mobject:
def always_rotate(mobject: M, rate: float = 20 * DEGREES, **kwargs) -> M:
"""A mobject which is continuously rotated at a certain rate.
Parameters
@ -178,7 +182,7 @@ def always_rotate(mobject: Mobject, rate: float = 20 * DEGREES, **kwargs) -> Mob
def turn_animation_into_updater(
animation: Animation, cycle: bool = False, **kwargs
animation: Animation, cycle: bool = False, delay: float = 0, **kwargs
) -> Mobject:
"""
Add an updater to the animation's mobject which applies
@ -187,6 +191,8 @@ def turn_animation_into_updater(
If cycle is True, this repeats over and over. Otherwise,
the updater will be popped upon completion
The ``delay`` parameter is the delay (in seconds) before the animation starts..
Examples
--------
@ -206,21 +212,32 @@ def turn_animation_into_updater(
mobject = animation.mobject
animation.suspend_mobject_updating = False
animation.begin()
animation.total_time = 0
animation.total_time = -delay
def update(m: Mobject, dt: float):
run_time = animation.get_run_time()
time_ratio = animation.total_time / run_time
if cycle:
alpha = time_ratio % 1
else:
alpha = np.clip(time_ratio, 0, 1)
if alpha >= 1:
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
animation.interpolate(alpha)
animation.update_mobjects(dt)
time_ratio = animation.total_time / run_time
if cycle:
alpha = time_ratio % 1
else:
alpha = np.clip(time_ratio, 0, 1)
if alpha >= 1:
animation.finish()
m.remove_updater(update)
return
animation.interpolate(alpha)
animation.update_mobjects(dt)
animation.total_time += dt
mobject.add_updater(update)

View file

@ -6,11 +6,12 @@ __all__ = ["UpdateFromFunc", "UpdateFromAlphaFunc", "MaintainPositionRelativeTo"
import operator as op
import typing
from collections.abc import Callable
from typing import TYPE_CHECKING, Any
from manim.animation.animation import Animation
if typing.TYPE_CHECKING:
if TYPE_CHECKING:
from manim.mobject.mobject import Mobject
@ -24,9 +25,9 @@ class UpdateFromFunc(Animation):
def __init__(
self,
mobject: Mobject,
update_function: typing.Callable[[Mobject], typing.Any],
update_function: Callable[[Mobject], Any],
suspend_mobject_updating: bool = False,
**kwargs,
**kwargs: Any,
) -> None:
self.update_function = update_function
super().__init__(
@ -34,16 +35,18 @@ class UpdateFromFunc(Animation):
)
def interpolate_mobject(self, alpha: float) -> None:
self.update_function(self.mobject)
self.update_function(self.mobject) # type: ignore[arg-type]
class UpdateFromAlphaFunc(UpdateFromFunc):
def interpolate_mobject(self, alpha: float) -> None:
self.update_function(self.mobject, self.rate_func(alpha))
self.update_function(self.mobject, self.rate_func(alpha)) # type: ignore[call-arg, arg-type]
class MaintainPositionRelativeTo(Animation):
def __init__(self, mobject: Mobject, tracked_mobject: Mobject, **kwargs) -> None:
def __init__(
self, mobject: Mobject, tracked_mobject: Mobject, **kwargs: Any
) -> None:
self.tracked_mobject = tracked_mobject
self.diff = op.sub(
mobject.get_center(),

View file

@ -8,26 +8,39 @@ import copy
import itertools as it
import operator as op
import pathlib
from collections.abc import Iterable
from collections.abc import Callable, Iterable
from functools import reduce
from typing import Any, Callable
from typing import TYPE_CHECKING, Any, Self
import cairo
import numpy as np
from PIL import Image
from scipy.spatial.distance import pdist
from .. import config, logger
from ..constants import *
from ..mobject.mobject import Mobject
from ..mobject.types.image_mobject import AbstractImageMobject
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:
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 = {
LineJointType.AUTO: None, # TODO: this could be improved
@ -70,13 +83,13 @@ class Camera:
def __init__(
self,
background_image: str | None = None,
frame_center: np.ndarray = ORIGIN,
frame_center: Point3D = ORIGIN,
image_mode: str = "RGBA",
n_channels: int = 4,
pixel_array_dtype: str = "uint8",
cairo_line_width_multiple: float = 0.01,
use_z_index: bool = True,
background: np.ndarray | None = None,
background: PixelArray | None = None,
pixel_height: int | None = None,
pixel_width: int | None = None,
frame_height: float | None = None,
@ -84,8 +97,8 @@ class Camera:
frame_rate: float | None = None,
background_color: ParsableManimColor | None = None,
background_opacity: float | None = None,
**kwargs,
):
**kwargs: Any,
) -> None:
self.background_image = background_image
self.frame_center = frame_center
self.image_mode = image_mode
@ -94,6 +107,9 @@ class Camera:
self.cairo_line_width_multiple = cairo_line_width_multiple
self.use_z_index = use_z_index
self.background = background
self.background_colored_vmobject_displayer: (
BackgroundColoredVMobjectDisplayer | None
) = None
if pixel_height is None:
pixel_height = config["pixel_height"]
@ -116,11 +132,13 @@ class Camera:
self.frame_rate = frame_rate
if background_color is None:
self._background_color = ManimColor.parse(config["background_color"])
self._background_color: ManimColor = ManimColor.parse(
config["background_color"]
)
else:
self._background_color = ManimColor.parse(background_color)
if background_opacity is None:
self._background_opacity = config["background_opacity"]
self._background_opacity: float = config["background_opacity"]
else:
self._background_opacity = background_opacity
@ -129,7 +147,7 @@ class Camera:
self.max_allowable_norm = config["frame_width"]
self.rgb_max_val = np.iinfo(self.pixel_array_dtype).max
self.pixel_array_to_cairo_context = {}
self.pixel_array_to_cairo_context: dict[int, cairo.Context] = {}
# Contains the correct method to process a list of Mobjects of the
# corresponding class. If a Mobject is not an instance of a class in
@ -140,7 +158,7 @@ class Camera:
self.resize_frame_shape()
self.reset()
def __deepcopy__(self, memo):
def __deepcopy__(self, memo: Any) -> Camera:
# This is to address a strange bug where deepcopying
# will result in a segfault, which is somehow related
# to the aggdraw library
@ -148,24 +166,26 @@ class Camera:
return copy.copy(self)
@property
def background_color(self):
def background_color(self) -> ManimColor:
return self._background_color
@background_color.setter
def background_color(self, color):
def background_color(self, color: ManimColor) -> None:
self._background_color = color
self.init_background()
@property
def background_opacity(self):
def background_opacity(self) -> float:
return self._background_opacity
@background_opacity.setter
def background_opacity(self, alpha):
def background_opacity(self, alpha: float) -> None:
self._background_opacity = alpha
self.init_background()
def type_or_raise(self, mobject: Mobject):
def type_or_raise(
self, mobject: Mobject
) -> type[VMobject] | type[PMobject] | type[AbstractImageMobject] | type[Mobject]:
"""Return the type of mobject, if it is a type that can be rendered.
If `mobject` is an instance of a class that inherits from a class that
@ -192,10 +212,14 @@ class Camera:
:exc:`TypeError`
When mobject is not an instance of a class that can be rendered.
"""
self.display_funcs = {
VMobject: self.display_multiple_vectorized_mobjects,
PMobject: self.display_multiple_point_cloud_mobjects,
AbstractImageMobject: self.display_multiple_image_mobjects,
from ..mobject.types.image_mobject import AbstractImageMobject
self.display_funcs: dict[
type[Mobject], Callable[[list[Mobject], PixelArray], Any]
] = {
VMobject: self.display_multiple_vectorized_mobjects, # type: ignore[dict-item]
PMobject: self.display_multiple_point_cloud_mobjects, # type: ignore[dict-item]
AbstractImageMobject: self.display_multiple_image_mobjects, # type: ignore[dict-item]
Mobject: lambda batch, pa: batch, # Do nothing
}
# We have to check each type in turn because we are dealing with
@ -206,7 +230,7 @@ class Camera:
return _type
raise TypeError(f"Displaying an object of class {_type} is not supported")
def reset_pixel_shape(self, new_height: float, new_width: float):
def reset_pixel_shape(self, new_height: float, new_width: float) -> None:
"""This method resets the height and width
of a single pixel to the passed new_height and new_width.
@ -223,7 +247,7 @@ class Camera:
self.resize_frame_shape()
self.reset()
def resize_frame_shape(self, fixed_dimension: int = 0):
def resize_frame_shape(self, fixed_dimension: int = 0) -> None:
"""
Changes frame_shape to match the aspect ratio
of the pixels, where fixed_dimension determines
@ -248,7 +272,7 @@ class Camera:
self.frame_height = frame_height
self.frame_width = frame_width
def init_background(self):
def init_background(self) -> None:
"""Initialize the background.
If self.background_image is the path of an image
the image is set as background; else, the default
@ -274,7 +298,9 @@ class Camera:
)
self.background[:, :] = background_rgba
def get_image(self, pixel_array: np.ndarray | list | tuple | None = None):
def get_image(
self, pixel_array: PixelArray | list | tuple | None = None
) -> Image.Image:
"""Returns an image from the passed
pixel array, or from the current frame
if the passed pixel array is none.
@ -286,7 +312,7 @@ class Camera:
Returns
-------
PIL.Image
PIL.Image.Image
The PIL image of the array.
"""
if pixel_array is None:
@ -294,8 +320,8 @@ class Camera:
return Image.fromarray(pixel_array, mode=self.image_mode)
def convert_pixel_array(
self, pixel_array: np.ndarray | list | tuple, convert_from_floats: bool = False
):
self, pixel_array: PixelArray | list | tuple, convert_from_floats: bool = False
) -> PixelArray:
"""Converts a pixel array from values that have floats in then
to proper RGB values.
@ -321,8 +347,8 @@ class Camera:
return retval
def set_pixel_array(
self, pixel_array: np.ndarray | list | tuple, convert_from_floats: bool = False
):
self, pixel_array: PixelArray | list | tuple, convert_from_floats: bool = False
) -> None:
"""Sets the pixel array of the camera to the passed pixel array.
Parameters
@ -332,19 +358,21 @@ class Camera:
convert_from_floats
Whether or not to convert float values to proper RGB values, by default False
"""
converted_array = self.convert_pixel_array(pixel_array, convert_from_floats)
converted_array: PixelArray = self.convert_pixel_array(
pixel_array, convert_from_floats
)
if not (
hasattr(self, "pixel_array")
and self.pixel_array.shape == converted_array.shape
):
self.pixel_array = converted_array
self.pixel_array: PixelArray = converted_array
else:
# Set in place
self.pixel_array[:, :, :] = converted_array[:, :, :]
def set_background(
self, pixel_array: np.ndarray | list | tuple, convert_from_floats: bool = False
):
self, pixel_array: PixelArray | list | tuple, convert_from_floats: bool = False
) -> None:
"""Sets the background to the passed pixel_array after converting
to valid RGB values.
@ -360,7 +388,7 @@ class Camera:
# TODO, this should live in utils, not as a method of Camera
def make_background_from_func(
self, coords_to_colors_func: Callable[[np.ndarray], np.ndarray]
):
) -> PixelArray:
"""
Makes a pixel array for the background by using coords_to_colors_func to determine each pixel's color. Each input
pixel's color. Each input to coords_to_colors_func is an (x, y) pair in space (in ordinary space coordinates; not
@ -377,7 +405,6 @@ class Camera:
np.array
The pixel array which can then be passed to set_background.
"""
logger.info("Starting set_background")
coords = self.get_coords_of_all_pixels()
new_background = np.apply_along_axis(coords_to_colors_func, 2, coords)
@ -387,7 +414,7 @@ class Camera:
def set_background_from_func(
self, coords_to_colors_func: Callable[[np.ndarray], np.ndarray]
):
) -> None:
"""
Sets the background to a pixel array using coords_to_colors_func to determine each pixel's color. Each input
pixel's color. Each input to coords_to_colors_func is an (x, y) pair in space (in ordinary space coordinates; not
@ -401,7 +428,7 @@ class Camera:
"""
self.set_background(self.make_background_from_func(coords_to_colors_func))
def reset(self):
def reset(self) -> Self:
"""Resets the camera's pixel array
to that of the background
@ -413,7 +440,7 @@ class Camera:
self.set_pixel_array(self.background)
return self
def set_frame_to_background(self, background):
def set_frame_to_background(self, background: PixelArray) -> None:
self.set_pixel_array(background)
####
@ -423,7 +450,7 @@ class Camera:
mobjects: Iterable[Mobject],
include_submobjects: bool = True,
excluded_mobjects: list | None = None,
):
) -> list[Mobject]:
"""Used to get the list of mobjects to display
with the camera.
@ -455,7 +482,7 @@ class Camera:
mobjects = list_difference_update(mobjects, all_excluded)
return list(mobjects)
def is_in_frame(self, mobject: Mobject):
def is_in_frame(self, mobject: Mobject) -> bool:
"""Checks whether the passed mobject is in
frame or not.
@ -482,7 +509,7 @@ class Camera:
],
)
def capture_mobject(self, mobject: Mobject, **kwargs: Any):
def capture_mobject(self, mobject: Mobject, **kwargs: Any) -> None:
"""Capture mobjects by storing it in :attr:`pixel_array`.
This is a single-mobject version of :meth:`capture_mobjects`.
@ -498,7 +525,7 @@ class Camera:
"""
return self.capture_mobjects([mobject], **kwargs)
def capture_mobjects(self, mobjects: Iterable[Mobject], **kwargs):
def capture_mobjects(self, mobjects: Iterable[Mobject], **kwargs: Any) -> None:
"""Capture mobjects by printing them on :attr:`pixel_array`.
This is the essential function that converts the contents of a Scene
@ -533,7 +560,7 @@ class Camera:
# NOTE: None of the methods below have been mentioned outside of their definitions. Their DocStrings are not as
# detailed as possible.
def get_cached_cairo_context(self, pixel_array: np.ndarray):
def get_cached_cairo_context(self, pixel_array: PixelArray) -> cairo.Context | None:
"""Returns the cached cairo context of the passed
pixel array if it exists, and None if it doesn't.
@ -549,7 +576,7 @@ class Camera:
"""
return self.pixel_array_to_cairo_context.get(id(pixel_array), None)
def cache_cairo_context(self, pixel_array: np.ndarray, ctx: cairo.Context):
def cache_cairo_context(self, pixel_array: PixelArray, ctx: cairo.Context) -> None:
"""Caches the passed Pixel array into a Cairo Context
Parameters
@ -561,7 +588,7 @@ class Camera:
"""
self.pixel_array_to_cairo_context[id(pixel_array)] = ctx
def get_cairo_context(self, pixel_array: np.ndarray):
def get_cairo_context(self, pixel_array: PixelArray) -> cairo.Context:
"""Returns the cairo context for a pixel array after
caching it to self.pixel_array_to_cairo_context
If that array has already been cached, it returns the
@ -586,7 +613,7 @@ class Camera:
fh = self.frame_height
fc = self.frame_center
surface = cairo.ImageSurface.create_for_data(
pixel_array,
pixel_array.data,
cairo.FORMAT_ARGB32,
pw,
ph,
@ -607,8 +634,8 @@ class Camera:
return ctx
def display_multiple_vectorized_mobjects(
self, vmobjects: list, pixel_array: np.ndarray
):
self, vmobjects: list[VMobject], pixel_array: PixelArray
) -> None:
"""Displays multiple VMobjects in the pixel_array
Parameters
@ -631,8 +658,8 @@ class Camera:
)
def display_multiple_non_background_colored_vmobjects(
self, vmobjects: list, pixel_array: np.ndarray
):
self, vmobjects: Iterable[VMobject], pixel_array: PixelArray
) -> None:
"""Displays multiple VMobjects in the cairo context, as long as they don't have
background colors.
@ -647,7 +674,7 @@ class Camera:
for vmobject in vmobjects:
self.display_vectorized(vmobject, ctx)
def display_vectorized(self, vmobject: VMobject, ctx: cairo.Context):
def display_vectorized(self, vmobject: VMobject, ctx: cairo.Context) -> Self:
"""Displays a VMobject in the cairo context
Parameters
@ -668,7 +695,7 @@ class Camera:
self.apply_stroke(ctx, vmobject)
return self
def set_cairo_context_path(self, ctx: cairo.Context, vmobject: VMobject):
def set_cairo_context_path(self, ctx: cairo.Context, vmobject: VMobject) -> Self:
"""Sets a path for the cairo context with the vmobject passed
Parameters
@ -687,7 +714,7 @@ class Camera:
# TODO, shouldn't this be handled in transform_points_pre_display?
# points = points - self.get_frame_center()
if len(points) == 0:
return
return self
ctx.new_path()
subpaths = vmobject.gen_subpaths_from_points_2d(points)
@ -703,8 +730,8 @@ class Camera:
return self
def set_cairo_context_color(
self, ctx: cairo.Context, rgbas: np.ndarray, vmobject: VMobject
):
self, ctx: cairo.Context, rgbas: FloatRGBALike_Array, vmobject: VMobject
) -> Self:
"""Sets the color of the cairo context
Parameters
@ -729,14 +756,13 @@ 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):
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
def apply_fill(self, ctx: cairo.Context, vmobject: VMobject):
def apply_fill(self, ctx: cairo.Context, vmobject: VMobject) -> Self:
"""Fills the cairo context
Parameters
@ -757,7 +783,7 @@ class Camera:
def apply_stroke(
self, ctx: cairo.Context, vmobject: VMobject, background: bool = False
):
) -> Self:
"""Applies a stroke to the VMobject in the cairo context.
Parameters
@ -796,7 +822,9 @@ class Camera:
ctx.stroke_preserve()
return self
def get_stroke_rgbas(self, vmobject: VMobject, background: bool = False):
def get_stroke_rgbas(
self, vmobject: VMobject, background: bool = False
) -> FloatRGBA_Array:
"""Gets the RGBA array for the stroke of the passed
VMobject.
@ -815,7 +843,7 @@ class Camera:
"""
return vmobject.get_stroke_rgbas(background)
def get_fill_rgbas(self, vmobject: VMobject):
def get_fill_rgbas(self, vmobject: VMobject) -> FloatRGBA_Array:
"""Returns the RGBA array of the fill of the passed VMobject
Parameters
@ -830,25 +858,27 @@ class Camera:
"""
return vmobject.get_fill_rgbas()
def get_background_colored_vmobject_displayer(self):
def get_background_colored_vmobject_displayer(
self,
) -> BackgroundColoredVMobjectDisplayer:
"""Returns the background_colored_vmobject_displayer
if it exists or makes one and returns it if not.
Returns
-------
BackGroundColoredVMobjectDisplayer
BackgroundColoredVMobjectDisplayer
Object that displays VMobjects that have the same color
as the background.
"""
# Quite wordy to type out a bunch
bcvd = "background_colored_vmobject_displayer"
if not hasattr(self, bcvd):
setattr(self, bcvd, BackgroundColoredVMobjectDisplayer(self))
return getattr(self, bcvd)
if self.background_colored_vmobject_displayer is None:
self.background_colored_vmobject_displayer = (
BackgroundColoredVMobjectDisplayer(self)
)
return self.background_colored_vmobject_displayer
def display_multiple_background_colored_vmobjects(
self, cvmobjects: list, pixel_array: np.ndarray
):
self, cvmobjects: Iterable[VMobject], pixel_array: PixelArray
) -> Self:
"""Displays multiple vmobjects that have the same color as the background.
Parameters
@ -874,8 +904,8 @@ class Camera:
# As a result, the other methods do not have as detailed docstrings as would be preferred.
def display_multiple_point_cloud_mobjects(
self, pmobjects: list, pixel_array: np.ndarray
):
self, pmobjects: Iterable[PMobject], pixel_array: PixelArray
) -> None:
"""Displays multiple PMobjects by modifying the passed pixel array.
Parameters
@ -897,11 +927,11 @@ class Camera:
def display_point_cloud(
self,
pmobject: PMobject,
points: list,
rgbas: np.ndarray,
points: Point3D_Array,
rgbas: FloatRGBA_Array,
thickness: float,
pixel_array: np.ndarray,
):
pixel_array: PixelArray,
) -> None:
"""Displays a PMobject by modifying the pixel array suitably.
TODO: Write a description for the rgbas argument.
@ -948,8 +978,10 @@ class Camera:
pixel_array[:, :] = new_pa.reshape((ph, pw, rgba_len))
def display_multiple_image_mobjects(
self, image_mobjects: list, pixel_array: np.ndarray
):
self,
image_mobjects: Iterable[AbstractImageMobject],
pixel_array: PixelArray,
) -> None:
"""Displays multiple image mobjects by modifying the passed pixel_array.
Parameters
@ -964,64 +996,119 @@ class Camera:
def display_image_mobject(
self, image_mobject: AbstractImageMobject, pixel_array: np.ndarray
):
"""Displays an ImageMobject by changing the pixel_array suitably.
) -> None:
"""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(self, pixel_array: np.ndarray, new_array: np.ndarray):
def overlay_rgba_array(
self, pixel_array: np.ndarray, new_array: np.ndarray
) -> None:
"""Overlays an RGBA array on top of the given Pixel array.
Parameters
@ -1033,7 +1120,7 @@ class Camera:
"""
self.overlay_PIL_image(pixel_array, self.get_image(new_array))
def overlay_PIL_image(self, pixel_array: np.ndarray, image: Image):
def overlay_PIL_image(self, pixel_array: np.ndarray, image: Image) -> None:
"""Overlays a PIL image on the passed pixel array.
Parameters
@ -1048,7 +1135,7 @@ class Camera:
dtype="uint8",
)
def adjust_out_of_range_points(self, points: np.ndarray):
def adjust_out_of_range_points(self, points: np.ndarray) -> np.ndarray:
"""If any of the points in the passed array are out of
the viable range, they are adjusted suitably.
@ -1079,9 +1166,9 @@ class Camera:
def transform_points_pre_display(
self,
mobject,
points,
): # TODO: Write more detailed docstrings for this method.
mobject: Mobject,
points: Point3D_Array,
) -> Point3D_Array: # TODO: Write more detailed docstrings for this method.
# NOTE: There seems to be an unused argument `mobject`.
# Subclasses (like ThreeDCamera) may want to
@ -1092,11 +1179,13 @@ class Camera:
points = np.zeros((1, 3))
return points
def points_to_pixel_coords(
def points_to_subpixel_coords(
self,
mobject,
points,
): # TODO: Write more detailed docstrings for this method.
mobject: Mobject,
points: Point3D_Array,
) -> 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
@ -1114,9 +1203,16 @@ 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 on_screen_pixels(self, pixel_coords: np.ndarray):
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
array of pixel_coordinates
@ -1155,12 +1251,12 @@ class Camera:
the camera.
"""
# TODO: This seems...unsystematic
big_sum = op.add(config["pixel_height"], config["pixel_width"])
this_sum = op.add(self.pixel_height, self.pixel_width)
big_sum: float = op.add(config["pixel_height"], config["pixel_width"])
this_sum: float = op.add(self.pixel_height, self.pixel_width)
factor = big_sum / this_sum
return 1 + (thickness - 1) * factor
def get_thickening_nudges(self, thickness: float):
def get_thickening_nudges(self, thickness: float) -> PixelArray:
"""Determine a list of vectors used to nudge
two-dimensional pixel coordinates.
@ -1177,7 +1273,9 @@ class Camera:
_range = list(range(-thickness // 2 + 1, thickness // 2 + 1))
return np.array(list(it.product(_range, _range)))
def thickened_coordinates(self, pixel_coords: np.ndarray, thickness: float):
def thickened_coordinates(
self, pixel_coords: np.ndarray, thickness: float
) -> PixelArray:
"""Returns thickened coordinates for a passed array of pixel coords and
a thickness to thicken by.
@ -1199,7 +1297,7 @@ class Camera:
return pixel_coords.reshape((size // 2, 2))
# TODO, reimplement using cairo matrix
def get_coords_of_all_pixels(self):
def get_coords_of_all_pixels(self) -> PixelArray:
"""Returns the cartesian coordinates of each pixel.
Returns
@ -1247,20 +1345,20 @@ class BackgroundColoredVMobjectDisplayer:
def __init__(self, camera: Camera):
self.camera = camera
self.file_name_to_pixel_array_map = {}
self.file_name_to_pixel_array_map: dict[str, PixelArray] = {}
self.pixel_array = np.array(camera.pixel_array)
self.reset_pixel_array()
def reset_pixel_array(self):
def reset_pixel_array(self) -> None:
self.pixel_array[:, :] = 0
def resize_background_array(
self,
background_array: np.ndarray,
background_array: PixelArray,
new_width: float,
new_height: float,
mode: str = "RGBA",
):
) -> PixelArray:
"""Resizes the pixel array representing the background.
Parameters
@ -1285,8 +1383,8 @@ class BackgroundColoredVMobjectDisplayer:
return np.array(resized_image)
def resize_background_array_to_match(
self, background_array: np.ndarray, pixel_array: np.ndarray
):
self, background_array: PixelArray, pixel_array: PixelArray
) -> PixelArray:
"""Resizes the background array to match the passed pixel array.
Parameters
@ -1305,7 +1403,9 @@ class BackgroundColoredVMobjectDisplayer:
mode = "RGBA" if pixel_array.shape[2] == 4 else "RGB"
return self.resize_background_array(background_array, width, height, mode)
def get_background_array(self, image: Image.Image | pathlib.Path | str):
def get_background_array(
self, image: Image.Image | pathlib.Path | str
) -> PixelArray:
"""Gets the background array that has the passed file_name.
Parameters
@ -1334,7 +1434,7 @@ class BackgroundColoredVMobjectDisplayer:
self.file_name_to_pixel_array_map[image_key] = back_array
return back_array
def display(self, *cvmobjects: VMobject):
def display(self, *cvmobjects: VMobject) -> PixelArray | None:
"""Displays the colored VMobjects.
Parameters

View file

@ -1,4 +1,4 @@
"""A camera that allows mapping between objects."""
"""A camera module that supports spatial mapping between objects for distortion effects."""
from __future__ import annotations
@ -17,8 +17,16 @@ from ..utils.config_ops import DictAsObject
class MappingCamera(Camera):
"""Camera object that allows mapping
between objects.
"""Parameters
----------
mapping_func : callable
Function to map 3D points to new 3D points (identity by default).
min_num_curves : int
Minimum number of curves for VMobjects to avoid visual glitches.
allow_object_intrusion : bool
If True, modifies original mobjects; else works on copies.
kwargs : dict
Additional arguments passed to Camera base class.
"""
def __init__(
@ -34,12 +42,18 @@ class MappingCamera(Camera):
super().__init__(**kwargs)
def points_to_pixel_coords(self, mobject, points):
# Map points with custom function before converting to pixels
return super().points_to_pixel_coords(
mobject,
np.apply_along_axis(self.mapping_func, 1, points),
)
def capture_mobjects(self, mobjects, **kwargs):
"""Capture mobjects for rendering after applying the spatial mapping.
Copies mobjects unless intrusion is allowed, and ensures
vector objects have enough curves for smooth distortion.
"""
mobjects = self.get_mobjects_to_display(mobjects, **kwargs)
if self.allow_object_intrusion:
mobject_copies = mobjects
@ -67,6 +81,13 @@ class MappingCamera(Camera):
# TODO, the classes below should likely be deleted
class OldMultiCamera(Camera):
"""Parameters
----------
cameras_with_start_positions : tuple
Tuples of (Camera, (start_y, start_x)) indicating camera and
its pixel offset on the final frame.
"""
def __init__(self, *cameras_with_start_positions, **kwargs):
self.shifted_cameras = [
DictAsObject(
@ -125,6 +146,15 @@ class OldMultiCamera(Camera):
class SplitScreenCamera(OldMultiCamera):
"""Initializes a split screen camera setup with two side-by-side cameras.
Parameters
----------
left_camera : Camera
right_camera : Camera
kwargs : dict
"""
def __init__(self, left_camera, right_camera, **kwargs):
Camera.__init__(self, **kwargs) # to set attributes such as pixel_width
self.left_camera = left_camera

View file

@ -1,45 +1,48 @@
"""A camera able to move through a scene.
"""Defines the MovingCamera class, a camera that can pan and zoom through a scene.
.. SEEALSO::
:mod:`.moving_camera_scene`
"""
from __future__ import annotations
__all__ = ["MovingCamera"]
import numpy as np
from collections.abc import Iterable
from typing import Any, Literal, overload
from cairo import Context
from manim.typing import PixelArray, Point3D, Point3DLike
from .. import config
from ..camera.camera import Camera
from ..constants import DOWN, LEFT, RIGHT, UP
from ..mobject.frame import ScreenRectangle
from ..mobject.mobject import Mobject
from ..utils.color import WHITE
from ..mobject.mobject import Mobject, _AnimationBuilder
from ..utils.color import WHITE, ManimColor
class MovingCamera(Camera):
"""
Stays in line with the height, width and position of it's 'frame', which is a Rectangle
"""A camera that follows and matches the size and position of its 'frame', a Rectangle (or similar Mobject).
The frame defines the region of space the camera displays and can move or resize dynamically.
.. SEEALSO::
:class:`.MovingCameraScene`
"""
def __init__(
self,
frame=None,
fixed_dimension=0, # width
default_frame_stroke_color=WHITE,
default_frame_stroke_width=0,
**kwargs,
frame: Mobject | None = None,
fixed_dimension: int = 0, # width
default_frame_stroke_color: ManimColor = WHITE,
default_frame_stroke_width: int = 0,
**kwargs: Any,
):
"""
Frame is a Mobject, (should almost certainly be a rectangle)
"""Frame is a Mobject, (should almost certainly be a rectangle)
determining which region of space the camera displays
"""
self.fixed_dimension = fixed_dimension
@ -56,7 +59,7 @@ class MovingCamera(Camera):
# TODO, make these work for a rotated frame
@property
def frame_height(self):
def frame_height(self) -> float:
"""Returns the height of the frame.
Returns
@ -66,30 +69,8 @@ class MovingCamera(Camera):
"""
return self.frame.height
@property
def frame_width(self):
"""Returns the width of the frame
Returns
-------
float
The width of the frame.
"""
return self.frame.width
@property
def frame_center(self):
"""Returns the centerpoint of the frame in cartesian coordinates.
Returns
-------
np.array
The cartesian coordinates of the center of the frame.
"""
return self.frame.get_center()
@frame_height.setter
def frame_height(self, frame_height: float):
def frame_height(self, frame_height: float) -> None:
"""Sets the height of the frame in MUnits.
Parameters
@ -99,8 +80,19 @@ class MovingCamera(Camera):
"""
self.frame.stretch_to_fit_height(frame_height)
@property
def frame_width(self) -> float:
"""Returns the width of the frame
Returns
-------
float
The width of the frame.
"""
return self.frame.width
@frame_width.setter
def frame_width(self, frame_width: float):
def frame_width(self, frame_width: float) -> None:
"""Sets the width of the frame in MUnits.
Parameters
@ -110,8 +102,19 @@ class MovingCamera(Camera):
"""
self.frame.stretch_to_fit_width(frame_width)
@property
def frame_center(self) -> Point3D:
"""Returns the centerpoint of the frame in cartesian coordinates.
Returns
-------
np.array
The cartesian coordinates of the center of the frame.
"""
return self.frame.get_center()
@frame_center.setter
def frame_center(self, frame_center: np.ndarray | list | tuple | Mobject):
def frame_center(self, frame_center: Point3DLike | Mobject) -> None:
"""Sets the centerpoint of the frame.
Parameters
@ -123,25 +126,20 @@ class MovingCamera(Camera):
"""
self.frame.move_to(frame_center)
def capture_mobjects(self, mobjects, **kwargs):
def capture_mobjects(self, mobjects: Iterable[Mobject], **kwargs: Any) -> None:
# self.reset_frame_center()
# self.realign_frame_shape()
super().capture_mobjects(mobjects, **kwargs)
# Since the frame can be moving around, the cairo
# context used for updating should be regenerated
# at each frame. So no caching.
def get_cached_cairo_context(self, pixel_array):
"""
Since the frame can be moving around, the cairo
def get_cached_cairo_context(self, pixel_array: PixelArray) -> None:
"""Since the frame can be moving around, the cairo
context used for updating should be regenerated
at each frame. So no caching.
"""
return None
def cache_cairo_context(self, pixel_array, ctx):
"""
Since the frame can be moving around, the cairo
def cache_cairo_context(self, pixel_array: PixelArray, ctx: Context) -> None:
"""Since the frame can be moving around, the cairo
context used for updating should be regenerated
at each frame. So no caching.
"""
@ -158,24 +156,41 @@ class MovingCamera(Camera):
# self.frame_shape = (self.frame.height, width)
# self.resize_frame_shape(fixed_dimension=self.fixed_dimension)
def get_mobjects_indicating_movement(self):
"""
Returns all mobjects whose movement implies that the camera
def get_mobjects_indicating_movement(self) -> list[Mobject]:
"""Returns all mobjects whose movement implies that the camera
should think of all other mobjects on the screen as moving
Returns
-------
list
list[Mobject]
"""
return [self.frame]
@overload
def auto_zoom(
self,
mobjects: list[Mobject],
mobjects: Iterable[Mobject],
margin: float,
only_mobjects_in_frame: bool,
animate: Literal[False],
) -> Mobject: ...
@overload
def auto_zoom(
self,
mobjects: Iterable[Mobject],
margin: float,
only_mobjects_in_frame: bool,
animate: Literal[True],
) -> _AnimationBuilder: ...
def auto_zoom(
self,
mobjects: Iterable[Mobject],
margin: float = 0,
only_mobjects_in_frame: bool = False,
animate: bool = True,
):
) -> _AnimationBuilder | Mobject:
"""Zooms on to a given array of mobjects (or a singular mobject)
and automatically resizes to frame all the mobjects.
@ -205,37 +220,12 @@ class MovingCamera(Camera):
or ScreenRectangle with position and size updated to zoomed position.
"""
scene_critical_x_left = None
scene_critical_x_right = None
scene_critical_y_up = None
scene_critical_y_down = None
for m in mobjects:
if (m == self.frame) or (
only_mobjects_in_frame and not self.is_in_frame(m)
):
# detected camera frame, should not be used to calculate final position of camera
continue
# initialize scene critical points with first mobjects critical points
if scene_critical_x_left is None:
scene_critical_x_left = m.get_critical_point(LEFT)[0]
scene_critical_x_right = m.get_critical_point(RIGHT)[0]
scene_critical_y_up = m.get_critical_point(UP)[1]
scene_critical_y_down = m.get_critical_point(DOWN)[1]
else:
if m.get_critical_point(LEFT)[0] < scene_critical_x_left:
scene_critical_x_left = m.get_critical_point(LEFT)[0]
if m.get_critical_point(RIGHT)[0] > scene_critical_x_right:
scene_critical_x_right = m.get_critical_point(RIGHT)[0]
if m.get_critical_point(UP)[1] > scene_critical_y_up:
scene_critical_y_up = m.get_critical_point(UP)[1]
if m.get_critical_point(DOWN)[1] < scene_critical_y_down:
scene_critical_y_down = m.get_critical_point(DOWN)[1]
(
scene_critical_x_left,
scene_critical_x_right,
scene_critical_y_up,
scene_critical_y_down,
) = self._get_bounding_box(mobjects, only_mobjects_in_frame)
# calculate center x and y
x = (scene_critical_x_left + scene_critical_x_right) / 2
@ -251,3 +241,52 @@ class MovingCamera(Camera):
return m_target.set_x(x).set_y(y).set(width=new_width + margin)
else:
return m_target.set_x(x).set_y(y).set(height=new_height + margin)
def _get_bounding_box(
self, mobjects: Iterable[Mobject], only_mobjects_in_frame: bool
) -> tuple[float, float, float, float]:
bounding_box_located = False
scene_critical_x_left: float = 0
scene_critical_x_right: float = 1
scene_critical_y_up: float = 1
scene_critical_y_down: float = 0
for m in mobjects:
if (m == self.frame) or (
only_mobjects_in_frame and not self.is_in_frame(m)
):
# detected camera frame, should not be used to calculate final position of camera
continue
# initialize scene critical points with first mobjects critical points
if not bounding_box_located:
scene_critical_x_left = m.get_critical_point(LEFT)[0]
scene_critical_x_right = m.get_critical_point(RIGHT)[0]
scene_critical_y_up = m.get_critical_point(UP)[1]
scene_critical_y_down = m.get_critical_point(DOWN)[1]
bounding_box_located = True
else:
if m.get_critical_point(LEFT)[0] < scene_critical_x_left:
scene_critical_x_left = m.get_critical_point(LEFT)[0]
if m.get_critical_point(RIGHT)[0] > scene_critical_x_right:
scene_critical_x_right = m.get_critical_point(RIGHT)[0]
if m.get_critical_point(UP)[1] > scene_critical_y_up:
scene_critical_y_up = m.get_critical_point(UP)[1]
if m.get_critical_point(DOWN)[1] < scene_critical_y_down:
scene_critical_y_down = m.get_critical_point(DOWN)[1]
if not bounding_box_located:
raise Exception(
"Could not determine bounding box of the mobjects given to 'auto_zoom'."
)
return (
scene_critical_x_left,
scene_critical_x_right,
scene_critical_y_up,
scene_critical_y_down,
)

View file

@ -5,7 +5,11 @@ from __future__ import annotations
__all__ = ["MultiCamera"]
from manim.mobject.types.image_mobject import ImageMobject
from collections.abc import Iterable
from typing import Any, Self
from manim.mobject.mobject import Mobject
from manim.mobject.types.image_mobject import ImageMobjectFromCamera
from ..camera.moving_camera import MovingCamera
from ..utils.iterables import list_difference_update
@ -16,10 +20,10 @@ class MultiCamera(MovingCamera):
def __init__(
self,
image_mobjects_from_cameras: ImageMobject | None = None,
allow_cameras_to_capture_their_own_display=False,
**kwargs,
):
image_mobjects_from_cameras: Iterable[ImageMobjectFromCamera] | None = None,
allow_cameras_to_capture_their_own_display: bool = False,
**kwargs: Any,
) -> None:
"""Initialises the MultiCamera
Parameters
@ -29,7 +33,7 @@ class MultiCamera(MovingCamera):
kwargs
Any valid keyword arguments of MovingCamera.
"""
self.image_mobjects_from_cameras = []
self.image_mobjects_from_cameras: list[ImageMobjectFromCamera] = []
if image_mobjects_from_cameras is not None:
for imfc in image_mobjects_from_cameras:
self.add_image_mobject_from_camera(imfc)
@ -38,7 +42,9 @@ class MultiCamera(MovingCamera):
)
super().__init__(**kwargs)
def add_image_mobject_from_camera(self, image_mobject_from_camera: ImageMobject):
def add_image_mobject_from_camera(
self, image_mobject_from_camera: ImageMobjectFromCamera
) -> None:
"""Adds an ImageMobject that's been obtained from the camera
into the list ``self.image_mobject_from_cameras``
@ -53,20 +59,20 @@ class MultiCamera(MovingCamera):
assert isinstance(imfc.camera, MovingCamera)
self.image_mobjects_from_cameras.append(imfc)
def update_sub_cameras(self):
def update_sub_cameras(self) -> None:
"""Reshape sub_camera pixel_arrays"""
for imfc in self.image_mobjects_from_cameras:
pixel_height, pixel_width = self.pixel_array.shape[:2]
imfc.camera.frame_shape = (
imfc.camera.frame.height,
imfc.camera.frame.width,
)
# imfc.camera.frame_shape = (
# imfc.camera.frame.height,
# imfc.camera.frame.width,
# )
imfc.camera.reset_pixel_shape(
int(pixel_height * imfc.height / self.frame_height),
int(pixel_width * imfc.width / self.frame_width),
)
def reset(self):
def reset(self) -> Self:
"""Resets the MultiCamera.
Returns
@ -79,7 +85,7 @@ class MultiCamera(MovingCamera):
super().reset()
return self
def capture_mobjects(self, mobjects, **kwargs):
def capture_mobjects(self, mobjects: Iterable[Mobject], **kwargs: Any) -> None:
self.update_sub_cameras()
for imfc in self.image_mobjects_from_cameras:
to_add = list(mobjects)
@ -88,7 +94,7 @@ class MultiCamera(MovingCamera):
imfc.camera.capture_mobjects(to_add, **kwargs)
super().capture_mobjects(mobjects, **kwargs)
def get_mobjects_indicating_movement(self):
def get_mobjects_indicating_movement(self) -> list[Mobject]:
"""Returns all mobjects whose movement implies that the camera
should think of all other mobjects on the screen as moving

View file

@ -5,7 +5,8 @@ from __future__ import annotations
__all__ = ["ThreeDCamera"]
from typing import Callable
from collections.abc import Callable, Iterable
from typing import Any
import numpy as np
@ -16,7 +17,15 @@ from manim.mobject.three_d.three_d_utils import (
get_3d_vmob_start_corner,
get_3d_vmob_start_corner_unit_normal,
)
from manim.mobject.types.vectorized_mobject import VMobject
from manim.mobject.value_tracker import ValueTracker
from manim.typing import (
FloatRGBA_Array,
MatrixMN,
Point3D,
Point3D_Array,
Point3DLike,
)
from .. import config
from ..camera.camera import Camera
@ -30,17 +39,17 @@ from ..utils.space_ops import rotation_about_z, rotation_matrix
class ThreeDCamera(Camera):
def __init__(
self,
focal_distance=20.0,
shading_factor=0.2,
default_distance=5.0,
light_source_start_point=9 * DOWN + 7 * LEFT + 10 * OUT,
should_apply_shading=True,
exponential_projection=False,
phi=0,
theta=-90 * DEGREES,
gamma=0,
zoom=1,
**kwargs,
focal_distance: float = 20.0,
shading_factor: float = 0.2,
default_distance: float = 5.0,
light_source_start_point: Point3DLike = 9 * DOWN + 7 * LEFT + 10 * OUT,
should_apply_shading: bool = True,
exponential_projection: bool = False,
phi: float = 0,
theta: float = -90 * DEGREES,
gamma: float = 0,
zoom: float = 1,
**kwargs: Any,
):
"""Initializes the ThreeDCamera
@ -68,23 +77,23 @@ class ThreeDCamera(Camera):
self.focal_distance_tracker = ValueTracker(self.focal_distance)
self.gamma_tracker = ValueTracker(self.gamma)
self.zoom_tracker = ValueTracker(self.zoom)
self.fixed_orientation_mobjects = {}
self.fixed_in_frame_mobjects = set()
self.fixed_orientation_mobjects: dict[Mobject, Callable[[], Point3D]] = {}
self.fixed_in_frame_mobjects: set[Mobject] = set()
self.reset_rotation_matrix()
@property
def frame_center(self):
def frame_center(self) -> Point3D:
return self._frame_center.points[0]
@frame_center.setter
def frame_center(self, point):
def frame_center(self, point: Point3DLike) -> None:
self._frame_center.move_to(point)
def capture_mobjects(self, mobjects, **kwargs):
def capture_mobjects(self, mobjects: Iterable[Mobject], **kwargs: Any) -> None:
self.reset_rotation_matrix()
super().capture_mobjects(mobjects, **kwargs)
def get_value_trackers(self):
def get_value_trackers(self) -> list[ValueTracker]:
"""A list of :class:`ValueTrackers <.ValueTracker>` of phi, theta, focal_distance,
gamma and zoom.
@ -101,7 +110,9 @@ class ThreeDCamera(Camera):
self.zoom_tracker,
]
def modified_rgbas(self, vmobject, rgbas):
def modified_rgbas(
self, vmobject: VMobject, rgbas: FloatRGBA_Array
) -> FloatRGBA_Array:
if not self.should_apply_shading:
return rgbas
if vmobject.shade_in_3d and (vmobject.get_num_points() > 0):
@ -127,28 +138,33 @@ class ThreeDCamera(Camera):
def get_stroke_rgbas(
self,
vmobject,
background=False,
): # NOTE : DocStrings From parent
vmobject: VMobject,
background: bool = False,
) -> FloatRGBA_Array: # NOTE : DocStrings From parent
return self.modified_rgbas(vmobject, vmobject.get_stroke_rgbas(background))
def get_fill_rgbas(self, vmobject): # NOTE : DocStrings From parent
def get_fill_rgbas(
self, vmobject: VMobject
) -> FloatRGBA_Array: # NOTE : DocStrings From parent
return self.modified_rgbas(vmobject, vmobject.get_fill_rgbas())
def get_mobjects_to_display(self, *args, **kwargs): # NOTE : DocStrings From parent
def get_mobjects_to_display(
self, *args: Any, **kwargs: Any
) -> list[Mobject]: # NOTE : DocStrings From parent
mobjects = super().get_mobjects_to_display(*args, **kwargs)
rot_matrix = self.get_rotation_matrix()
def z_key(mob):
def z_key(mob: Mobject) -> float:
if not (hasattr(mob, "shade_in_3d") and mob.shade_in_3d):
return np.inf
return np.inf # type: ignore[no-any-return]
# Assign a number to a three dimensional mobjects
# based on how close it is to the camera
return np.dot(mob.get_z_index_reference_point(), rot_matrix.T)[2]
distance: float = np.dot(mob.get_z_index_reference_point(), rot_matrix.T)[2]
return distance
return sorted(mobjects, key=z_key)
def get_phi(self):
def get_phi(self) -> float:
"""Returns the Polar angle (the angle off Z_AXIS) phi.
Returns
@ -158,7 +174,7 @@ class ThreeDCamera(Camera):
"""
return self.phi_tracker.get_value()
def get_theta(self):
def get_theta(self) -> float:
"""Returns the Azimuthal i.e the angle that spins the camera around the Z_AXIS.
Returns
@ -168,7 +184,7 @@ class ThreeDCamera(Camera):
"""
return self.theta_tracker.get_value()
def get_focal_distance(self):
def get_focal_distance(self) -> float:
"""Returns focal_distance of the Camera.
Returns
@ -178,7 +194,7 @@ class ThreeDCamera(Camera):
"""
return self.focal_distance_tracker.get_value()
def get_gamma(self):
def get_gamma(self) -> float:
"""Returns the rotation of the camera about the vector from the ORIGIN to the Camera.
Returns
@ -189,7 +205,7 @@ class ThreeDCamera(Camera):
"""
return self.gamma_tracker.get_value()
def get_zoom(self):
def get_zoom(self) -> float:
"""Returns the zoom amount of the camera.
Returns
@ -199,7 +215,7 @@ class ThreeDCamera(Camera):
"""
return self.zoom_tracker.get_value()
def set_phi(self, value: float):
def set_phi(self, value: float) -> None:
"""Sets the polar angle i.e the angle between Z_AXIS and Camera through ORIGIN in radians.
Parameters
@ -209,7 +225,7 @@ class ThreeDCamera(Camera):
"""
self.phi_tracker.set_value(value)
def set_theta(self, value: float):
def set_theta(self, value: float) -> None:
"""Sets the azimuthal angle i.e the angle that spins the camera around Z_AXIS in radians.
Parameters
@ -219,7 +235,7 @@ class ThreeDCamera(Camera):
"""
self.theta_tracker.set_value(value)
def set_focal_distance(self, value: float):
def set_focal_distance(self, value: float) -> None:
"""Sets the focal_distance of the Camera.
Parameters
@ -229,7 +245,7 @@ class ThreeDCamera(Camera):
"""
self.focal_distance_tracker.set_value(value)
def set_gamma(self, value: float):
def set_gamma(self, value: float) -> None:
"""Sets the angle of rotation of the camera about the vector from the ORIGIN to the Camera.
Parameters
@ -239,7 +255,7 @@ class ThreeDCamera(Camera):
"""
self.gamma_tracker.set_value(value)
def set_zoom(self, value: float):
def set_zoom(self, value: float) -> None:
"""Sets the zoom amount of the camera.
Parameters
@ -249,13 +265,13 @@ class ThreeDCamera(Camera):
"""
self.zoom_tracker.set_value(value)
def reset_rotation_matrix(self):
def reset_rotation_matrix(self) -> None:
"""Sets the value of self.rotation_matrix to
the matrix corresponding to the current position of the camera
"""
self.rotation_matrix = self.generate_rotation_matrix()
def get_rotation_matrix(self):
def get_rotation_matrix(self) -> MatrixMN:
"""Returns the matrix corresponding to the current position of the camera.
Returns
@ -265,7 +281,7 @@ class ThreeDCamera(Camera):
"""
return self.rotation_matrix
def generate_rotation_matrix(self):
def generate_rotation_matrix(self) -> MatrixMN:
"""Generates a rotation matrix based off the current position of the camera.
Returns
@ -286,7 +302,7 @@ class ThreeDCamera(Camera):
result = np.dot(matrix, result)
return result
def project_points(self, points: np.ndarray | list):
def project_points(self, points: Point3D_Array) -> Point3D_Array:
"""Applies the current rotation_matrix as a projection
matrix to the passed array of points.
@ -323,7 +339,7 @@ class ThreeDCamera(Camera):
points[:, i] *= factor * zoom
return points
def project_point(self, point: list | np.ndarray):
def project_point(self, point: Point3D) -> Point3D:
"""Applies the current rotation_matrix as a projection
matrix to the passed point.
@ -341,9 +357,9 @@ class ThreeDCamera(Camera):
def transform_points_pre_display(
self,
mobject,
points,
): # TODO: Write Docstrings for this Method.
mobject: Mobject,
points: Point3D_Array,
) -> Point3D_Array: # TODO: Write Docstrings for this Method.
points = super().transform_points_pre_display(mobject, points)
fixed_orientation = mobject in self.fixed_orientation_mobjects
fixed_in_frame = mobject in self.fixed_in_frame_mobjects
@ -362,8 +378,8 @@ class ThreeDCamera(Camera):
self,
*mobjects: Mobject,
use_static_center_func: bool = False,
center_func: Callable[[], np.ndarray] | None = None,
):
center_func: Callable[[], Point3D] | None = None,
) -> None:
"""This method allows the mobject to have a fixed orientation,
even when the camera moves around.
E.G If it was passed through this method, facing the camera, it
@ -384,7 +400,7 @@ class ThreeDCamera(Camera):
# This prevents the computation of mobject.get_center
# every single time a projection happens
def get_static_center_func(mobject):
def get_static_center_func(mobject: Mobject) -> Callable[[], Point3D]:
point = mobject.get_center()
return lambda: point
@ -398,7 +414,7 @@ class ThreeDCamera(Camera):
for submob in mobject.get_family():
self.fixed_orientation_mobjects[submob] = func
def add_fixed_in_frame_mobjects(self, *mobjects: Mobject):
def add_fixed_in_frame_mobjects(self, *mobjects: Mobject) -> None:
"""This method allows the mobject to have a fixed position,
even when the camera moves around.
E.G If it was passed through this method, at the top of the frame, it
@ -414,7 +430,7 @@ class ThreeDCamera(Camera):
for mobject in extract_mobject_family_members(mobjects):
self.fixed_in_frame_mobjects.add(mobject)
def remove_fixed_orientation_mobjects(self, *mobjects: Mobject):
def remove_fixed_orientation_mobjects(self, *mobjects: Mobject) -> None:
"""If a mobject was fixed in its orientation by passing it through
:meth:`.add_fixed_orientation_mobjects`, then this undoes that fixing.
The Mobject will no longer have a fixed orientation.
@ -428,7 +444,7 @@ class ThreeDCamera(Camera):
if mobject in self.fixed_orientation_mobjects:
del self.fixed_orientation_mobjects[mobject]
def remove_fixed_in_frame_mobjects(self, *mobjects: Mobject):
def remove_fixed_in_frame_mobjects(self, *mobjects: Mobject) -> None:
"""If a mobject was fixed in frame by passing it through
:meth:`.add_fixed_in_frame_mobjects`, then this undoes that fixing.
The Mobject will no longer be fixed in frame.

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,22 +2,55 @@ from __future__ import annotations
import logging
import re
import sys
from typing import TYPE_CHECKING
from cloup import Choice, option, option_group
if TYPE_CHECKING:
from click import Context, Option
__all__ = ["global_options"]
logger = logging.getLogger("manim")
def validate_gui_location(ctx, param, value):
if value:
try:
x_offset, y_offset = map(int, re.split(r"[;,\-]", value))
return (x_offset, y_offset)
except Exception:
logger.error("GUI location option is invalid.")
exit()
def validate_gui_location(
ctx: Context, param: Option, value: str | None
) -> tuple[int, int] | None:
"""If the ``value`` string is given, extract from it the GUI location,
which should be in any of these formats: 'x;y', 'x,y' or 'x-y'.
Parameters
----------
ctx
The Click context.
param
A Click option.
value
The optional string which will be parsed.
Returns
-------
tuple[int, int] | None
If ``value`` is ``None``, the return value is ``None``. Otherwise, it's
the ``(x, y)`` location for the GUI.
Raises
------
ValueError
If ``value`` has an invalid format.
"""
if value is None:
return None
try:
x_offset, y_offset = map(int, re.split(r"[;,\-]", value))
except Exception:
logger.error("GUI location option is invalid.")
sys.exit()
return (x_offset, y_offset)
global_options = option_group(
@ -92,23 +125,29 @@ global_options = option_group(
"--force_window",
is_flag=True,
help="Force window to open when using the opengl renderer, intended for debugging as it may impact performance",
default=False,
default=None,
),
option(
"--dry_run",
is_flag=True,
help="Renders animations without outputting image or video files and disables the window",
default=False,
default=None,
),
option(
"--no_latex_cleanup",
is_flag=True,
help="Prevents deletion of .aux, .dvi, and .log files produced by Tex and MathTex.",
default=False,
default=None,
),
option(
"--preview_command",
help="The command used to preview the output file (for example vlc for video files)",
default="",
default=None,
),
option(
"--seed",
type=int,
help="Set the random seed to allow reproducibility.",
default=None,
),
)

View file

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

View file

@ -1,10 +1,9 @@
"""
Constant definitions.
"""
"""Constant definitions."""
from __future__ import annotations
from enum import Enum
from typing import TypedDict
import numpy as np
from cloup import Context
@ -85,7 +84,7 @@ SCENE_NOT_FOUND_MESSAGE = """
"""
CHOOSE_NUMBER_MESSAGE = """
Choose number corresponding to desired scene/arguments.
(Use comma separated list for multiple entries)
(Use comma separated list for multiple entries or use "*" to select all scenes.)
Choice(s): """
INVALID_NUMBER_MESSAGE = "Invalid scene numbers have been specified. Aborting."
NO_SCENE_MESSAGE = """
@ -112,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
@ -199,8 +194,16 @@ TAU = 2 * PI
DEGREES = TAU / 360
"""The exchange rate between radians and degrees."""
class QualityDict(TypedDict):
flag: str | None
pixel_height: int
pixel_width: int
frame_rate: int
# Video qualities
QUALITIES: dict[str, dict[str, str | int | None]] = {
QUALITIES: dict[str, QualityDict] = {
"fourk_quality": {
"flag": "k",
"pixel_height": 2160,

31
manim/data_structures.py Normal file
View file

@ -0,0 +1,31 @@
"""Data classes and other necessary data structures for use in Manim."""
from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass
from types import MethodType
from typing import Any
@dataclass
class MethodWithArgs:
"""Object containing a :attr:`method` which is intended to be called later
with the positional arguments :attr:`args` and the keyword arguments
:attr:`kwargs`.
Attributes
----------
method : MethodType
A callable representing a method of some class.
args : Iterable[Any]
Positional arguments for :attr:`method`.
kwargs : dict[str, Any]
Keyword arguments for :attr:`method`.
"""
__slots__ = ["method", "args", "kwargs"]
method: MethodType
args: Iterable[Any]
kwargs: dict[str, Any]

View file

@ -1,84 +0,0 @@
from __future__ import annotations
from pathlib import Path
try:
import dearpygui.dearpygui as dpg
dearpygui_imported = True
except ImportError:
dearpygui_imported = False
from .. import __version__, config
from ..utils.module_ops import scene_classes_from_file
__all__ = ["configure_pygui"]
if dearpygui_imported:
dpg.create_context()
window = dpg.generate_uuid()
def configure_pygui(renderer, widgets, update=True):
if not dearpygui_imported:
raise RuntimeError("Attempted to use DearPyGUI when it isn't imported.")
if update:
dpg.delete_item(window)
else:
dpg.create_viewport()
dpg.setup_dearpygui()
dpg.show_viewport()
dpg.set_viewport_title(title=f"Manim Community v{__version__}")
dpg.set_viewport_width(1015)
dpg.set_viewport_height(540)
def rerun_callback(sender, data):
renderer.scene.queue.put(("rerun_gui", [], {}))
def continue_callback(sender, data):
renderer.scene.queue.put(("exit_gui", [], {}))
def scene_selection_callback(sender, data):
config["scene_names"] = (dpg.get_value(sender),)
renderer.scene.queue.put(("rerun_gui", [], {}))
scene_classes = scene_classes_from_file(Path(config["input_file"]), full_list=True)
scene_names = [scene_class.__name__ for scene_class in scene_classes]
with dpg.window(
id=window,
label="Manim GUI",
pos=[config["gui_location"][0], config["gui_location"][1]],
width=1000,
height=500,
):
dpg.set_global_font_scale(2)
dpg.add_button(label="Rerun", callback=rerun_callback)
dpg.add_button(label="Continue", callback=continue_callback)
dpg.add_combo(
label="Selected scene",
items=scene_names,
callback=scene_selection_callback,
default_value=config["scene_names"][0],
)
dpg.add_separator()
if len(widgets) != 0:
with dpg.collapsing_header(
label=f"{config['scene_names'][0]} widgets",
default_open=True,
):
for widget_config in widgets:
widget_config_copy = widget_config.copy()
name = widget_config_copy["name"]
widget = widget_config_copy["widget"]
if widget != "separator":
del widget_config_copy["name"]
del widget_config_copy["widget"]
getattr(dpg, f"add_{widget}")(label=name, **widget_config_copy)
else:
dpg.add_separator()
if not update:
dpg.start_dearpygui()

View file

@ -8,31 +8,34 @@ __all__ = [
]
from typing import Any
from manim.mobject.geometry.polygram import Rectangle
from .. import config
class ScreenRectangle(Rectangle):
def __init__(self, aspect_ratio=16.0 / 9.0, height=4, **kwargs):
def __init__(
self, aspect_ratio: float = 16.0 / 9.0, height: float = 4, **kwargs: Any
) -> None:
super().__init__(width=aspect_ratio * height, height=height, **kwargs)
@property
def aspect_ratio(self):
def aspect_ratio(self) -> float:
"""The aspect ratio.
When set, the width is stretched to accommodate
the new aspect ratio.
"""
return self.width / self.height
@aspect_ratio.setter
def aspect_ratio(self, value):
def aspect_ratio(self, value: float) -> None:
self.stretch_to_fit_width(value * self.height)
class FullScreenRectangle(ScreenRectangle):
def __init__(self, **kwargs):
def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs)
self.height = config["frame_height"]

View file

@ -40,14 +40,14 @@ __all__ = [
"CubicBezier",
"ArcPolygon",
"ArcPolygonFromArcs",
"TangentialArc",
]
import itertools
import warnings
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any, Self, cast
import numpy as np
from typing_extensions import Self
from manim.constants import *
from manim.mobject.opengl.opengl_compatibility import ConvertToOpenGL
@ -55,6 +55,7 @@ from manim.mobject.types.vectorized_mobject import VGroup, VMobject
from manim.utils.color import BLACK, BLUE, RED, WHITE, ParsableManimColor
from manim.utils.iterables import adjacent_pairs
from manim.utils.space_ops import (
angle_between_vectors,
angle_of_vector,
cartesian_to_spherical,
line_intersection,
@ -63,11 +64,19 @@ from manim.utils.space_ops import (
)
if TYPE_CHECKING:
from collections.abc import Iterable
import manim.mobject.geometry.tips as tips
from manim.mobject.geometry.line import Line
from manim.mobject.mobject import Mobject
from manim.mobject.text.tex_mobject import SingleStringMathTex, Tex
from manim.mobject.text.text_mobject import Text
from manim.typing import CubicBezierPoints, Point3D, QuadraticBezierPoints, Vector3D
from manim.typing import (
Point3D,
Point3DLike,
QuadraticSpline,
Vector3DLike,
)
class TipableVMobject(VMobject, metaclass=ConvertToOpenGL):
@ -91,13 +100,13 @@ class TipableVMobject(VMobject, metaclass=ConvertToOpenGL):
def __init__(
self,
tip_length: float = DEFAULT_ARROW_TIP_LENGTH,
normal_vector: Vector3D = OUT,
tip_style: dict = {},
**kwargs,
normal_vector: Vector3DLike = OUT,
tip_style: dict | None = None,
**kwargs: Any,
) -> None:
self.tip_length: float = tip_length
self.normal_vector: Vector3D = normal_vector
self.tip_style: dict = tip_style
self.normal_vector = normal_vector
self.tip_style: dict = tip_style if tip_style is not None else {}
super().__init__(**kwargs)
# Adding, Creating, Modifying tips
@ -119,17 +128,17 @@ 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
def create_tip(
self,
tip_shape: type[tips.ArrowTip] | None = None,
tip_length: float = None,
tip_width: float = None,
tip_length: float | None = None,
tip_width: float | None = None,
at_start: bool = False,
):
) -> tips.ArrowTip:
"""Stylises the tip, positions it spatially, and returns
the newly instantiated tip to the caller.
"""
@ -142,13 +151,13 @@ class TipableVMobject(VMobject, metaclass=ConvertToOpenGL):
tip_shape: type[tips.ArrowTip] | None = None,
tip_length: float | None = None,
tip_width: float | None = None,
):
) -> tips.ArrowTip | tips.ArrowTriangleFilledTip:
"""Returns a tip that has been stylistically configured,
but has not yet been given a position in space.
"""
from manim.mobject.geometry.tips import ArrowTriangleFilledTip
style = {}
style: dict[str, Any] = {}
if tip_shape is None:
tip_shape = ArrowTriangleFilledTip
@ -166,7 +175,7 @@ class TipableVMobject(VMobject, metaclass=ConvertToOpenGL):
tip = tip_shape(length=tip_length, **style)
return tip
def position_tip(self, tip: tips.ArrowTip, at_start: bool = False):
def position_tip(self, tip: tips.ArrowTip, at_start: bool = False) -> tips.ArrowTip:
# Last two control points, defining both
# the end, and the tangency direction
if at_start:
@ -180,16 +189,19 @@ class TipableVMobject(VMobject, metaclass=ConvertToOpenGL):
angles[1] - PI - tip.tip_angle,
) # Rotates the tip along the azimuthal
if not hasattr(self, "_init_positioning_axis"):
axis = [
np.sin(angles[1]),
-np.cos(angles[1]),
0,
] # Obtains the perpendicular of the tip
axis = np.array(
[
np.sin(angles[1]),
-np.cos(angles[1]),
0,
]
) # Obtains the perpendicular of the tip
tip.rotate(
-angles[2] + PI / 2,
axis=axis,
) # Rotates the tip along the vertical wrt the axis
self._init_positioning_axis = axis
tip.shift(anchor - tip.tip_point)
return tip
@ -204,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:
@ -230,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:
@ -244,23 +257,33 @@ class TipableVMobject(VMobject, metaclass=ConvertToOpenGL):
result.add(self.start_tip)
return result
def get_tip(self):
def get_tip(self) -> VMobject:
"""Returns the TipableVMobject instance's (first) tip,
otherwise throws an exception."""
otherwise throws an exception.
"""
tips = self.get_tips()
if len(tips) == 0:
raise Exception("tip not found")
else:
return tips[0]
tip: VMobject = tips[0]
return tip
def get_default_tip_length(self) -> float:
return self.tip_length
def get_first_handle(self) -> Point3D:
return self.points[1]
# Type inference of extracting an element from a list, is not
# supported by numpy, see this numpy issue
# https://github.com/numpy/numpy/issues/16544
first_handle: Point3D = self.points[1]
return first_handle
def get_last_handle(self) -> Point3D:
return self.points[-2]
# Type inference of extracting an element from a list, is not
# supported by numpy, see this numpy issue
# https://github.com/numpy/numpy/issues/16544
last_handle: Point3D = self.points[-2]
return last_handle
def get_end(self) -> Point3D:
if self.has_tip():
@ -274,9 +297,9 @@ class TipableVMobject(VMobject, metaclass=ConvertToOpenGL):
else:
return super().get_start()
def get_length(self) -> np.floating:
def get_length(self) -> float:
start, end = self.get_start_and_end()
return np.linalg.norm(start - end)
return float(np.linalg.norm(start - end))
class Arc(TipableVMobject):
@ -296,20 +319,20 @@ class Arc(TipableVMobject):
def __init__(
self,
radius: float = 1.0,
radius: float | None = 1.0,
start_angle: float = 0,
angle: float = TAU / 4,
num_components: int = 9,
arc_center: Point3D = ORIGIN,
**kwargs,
arc_center: Point3DLike = ORIGIN,
**kwargs: Any,
):
if radius is None: # apparently None is passed by ArcBetweenPoints
radius = 1.0
self.radius = radius
self.num_components: int = num_components
self.arc_center: Point3D = arc_center
self.start_angle: float = start_angle
self.angle: float = angle
self.num_components = num_components
self.arc_center: Point3D = np.asarray(arc_center)
self.start_angle = start_angle
self.angle = angle
self._failed_to_get_center: bool = False
super().__init__(**kwargs)
@ -335,7 +358,7 @@ class Arc(TipableVMobject):
@staticmethod
def _create_quadratic_bezier_points(
angle: float, start_angle: float = 0, n_components: int = 8
) -> QuadraticBezierPoints:
) -> QuadraticSpline:
samples = np.array(
[
[np.cos(a), np.sin(a), 0]
@ -374,8 +397,9 @@ class Arc(TipableVMobject):
tangent_vectors[:, 1] = anchors[:, 0]
tangent_vectors[:, 0] = -anchors[:, 1]
# Use tangent vectors to deduce anchors
handles1 = anchors[:-1] + (d_theta / 3) * tangent_vectors[:-1]
handles2 = anchors[1:] - (d_theta / 3) * tangent_vectors[1:]
factor = 4 / 3 * np.tan(d_theta / 4)
handles1 = anchors[:-1] + factor * tangent_vectors[:-1]
handles2 = anchors[1:] - factor * tangent_vectors[1:]
self.set_anchors_and_handles(anchors[:-1], handles1, handles2, anchors[1:])
def get_arc_center(self, warning: bool = True) -> Point3D:
@ -406,12 +430,15 @@ class Arc(TipableVMobject):
self._failed_to_get_center = True
return np.array(ORIGIN)
def move_arc_center_to(self, point: Point3D) -> Self:
def move_arc_center_to(self, point: Point3DLike) -> Self:
self.shift(point - self.get_arc_center())
return self
def stop_angle(self) -> float:
return angle_of_vector(self.points[-1] - self.get_arc_center()) % TAU
return cast(
float,
angle_of_vector(self.points[-1] - self.get_arc_center()) % TAU,
)
class ArcBetweenPoints(Arc):
@ -435,11 +462,11 @@ class ArcBetweenPoints(Arc):
def __init__(
self,
start: Point3D,
end: Point3D,
start: Point3DLike,
end: Point3DLike,
angle: float = TAU / 4,
radius: float = None,
**kwargs,
radius: float | None = None,
**kwargs: Any,
) -> None:
if radius is not None:
self.radius = radius
@ -459,19 +486,116 @@ class ArcBetweenPoints(Arc):
super().__init__(radius=radius, angle=angle, **kwargs)
if angle == 0:
self.set_points_as_corners([LEFT, RIGHT])
self.set_points_as_corners(np.array([LEFT, RIGHT]))
self.put_start_and_end_on(start, end)
if radius is None:
center = self.get_arc_center(warning=False)
if not self._failed_to_get_center:
self.radius = np.linalg.norm(np.array(start) - np.array(center))
# np.linalg.norm returns floating[Any] which is not compatible with float
self.radius = cast(
float, np.linalg.norm(np.array(start) - np.array(center))
)
else:
self.radius = np.inf
class TangentialArc(ArcBetweenPoints):
"""
Construct an arc that is tangent to two intersecting lines.
You can choose any of the 4 possible corner arcs via the `corner` tuple.
corner = (s1, s2) where each si is ±1 to control direction along each line.
Examples
--------
.. manim:: TangentialArcExample
:save_last_frame:
class TangentialArcExample(Scene):
def construct(self):
line1 = DashedLine(start=3 * LEFT, end=3 * RIGHT)
line1.rotate(angle=31 * DEGREES, about_point=ORIGIN)
line2 = DashedLine(start=3 * UP, end=3 * DOWN)
line2.rotate(angle=12 * DEGREES, about_point=ORIGIN)
arc = TangentialArc(line1, line2, radius=2.25, corner=(1, 1), color=TEAL)
self.add(arc, line1, line2)
The following example shows all four possible corner configurations:
.. manim:: TangentialArcCorners
:save_last_frame:
class TangentialArcCorners(Scene):
def construct(self):
# Create two intersecting lines
line1 = DashedLine(start=3 * LEFT, end=3 * RIGHT, color=GREY)
line2 = DashedLine(start=3 * UP, end=3 * DOWN, color=GREY)
# All four corner configurations with different colors
arc_pp = TangentialArc(line1, line2, radius=1.5, corner=(1, 1), color=RED)
arc_pn = TangentialArc(line1, line2, radius=1.5, corner=(1, -1), color=GREEN)
arc_np = TangentialArc(line1, line2, radius=1.5, corner=(-1, 1), color=BLUE)
arc_nn = TangentialArc(line1, line2, radius=1.5, corner=(-1, -1), color=YELLOW)
# Labels for each arc
label_pp = Text("(1,1)", font_size=24, color=RED).next_to(arc_pp, UR, buff=0.1)
label_pn = Text("(1,-1)", font_size=24, color=GREEN).next_to(arc_pn, DR, buff=0.1)
label_np = Text("(-1,1)", font_size=24, color=BLUE).next_to(arc_np, UL, buff=0.1)
label_nn = Text("(-1,-1)", font_size=24, color=YELLOW).next_to(arc_nn, DL, buff=0.1)
self.add(line1, line2, arc_pp, arc_pn, arc_np, arc_nn)
self.add(label_pp, label_pn, label_np, label_nn)
"""
def __init__(
self,
line1: Line,
line2: Line,
radius: float,
corner: Any = (1, 1),
**kwargs: Any,
):
self.line1 = line1
self.line2 = line2
intersection_point = line_intersection(
[line1.get_start(), line1.get_end()], [line2.get_start(), line2.get_end()]
)
s1, s2 = corner
# Get unit vector for specified directions
unit_vector1 = s1 * line1.get_unit_vector()
unit_vector2 = s2 * line2.get_unit_vector()
corner_angle = angle_between_vectors(unit_vector1, unit_vector2)
tangent_point_distance = radius / np.tan(corner_angle / 2)
# tangent points
tangent_point1 = intersection_point + tangent_point_distance * unit_vector1
tangent_point2 = intersection_point + tangent_point_distance * unit_vector2
cross_product = (
unit_vector1[0] * unit_vector2[1] - unit_vector1[1] * unit_vector2[0]
)
# Determine start and end points based on orientation
if cross_product < 0:
# Counterclockwise orientation - standard order
start_point = tangent_point1
end_point = tangent_point2
else:
# Clockwise orientation - reverse the points
start_point = tangent_point2
end_point = tangent_point1
super().__init__(start=start_point, end=end_point, radius=radius, **kwargs)
class CurvedArrow(ArcBetweenPoints):
def __init__(self, start_point: Point3D, end_point: Point3D, **kwargs) -> None:
def __init__(
self, start_point: Point3DLike, end_point: Point3DLike, **kwargs: Any
) -> None:
from manim.mobject.geometry.tips import ArrowTriangleFilledTip
tip_shape = kwargs.pop("tip_shape", ArrowTriangleFilledTip)
@ -480,7 +604,9 @@ class CurvedArrow(ArcBetweenPoints):
class CurvedDoubleArrow(CurvedArrow):
def __init__(self, start_point: Point3D, end_point: Point3D, **kwargs) -> None:
def __init__(
self, start_point: Point3DLike, end_point: Point3DLike, **kwargs: Any
) -> None:
if "tip_shape_end" in kwargs:
kwargs["tip_shape"] = kwargs.pop("tip_shape_end")
from manim.mobject.geometry.tips import ArrowTriangleFilledTip
@ -519,7 +645,7 @@ class Circle(Arc):
self,
radius: float | None = None,
color: ParsableManimColor = RED,
**kwargs,
**kwargs: Any,
) -> None:
super().__init__(
radius=radius,
@ -571,7 +697,6 @@ class Circle(Arc):
group = Group(group1, group2, group3).arrange(buff=1)
self.add(group)
"""
# Ignores dim_to_match and stretch; result will always be a circle
# TODO: Perhaps create an ellipse class to handle single-dimension stretching
@ -611,14 +736,14 @@ class Circle(Arc):
self.add(circle, s1, s2)
"""
start_angle = angle_of_vector(self.points[0] - self.get_center())
proportion = (angle - start_angle) / TAU
proportion = angle / TAU
proportion -= np.floor(proportion)
return self.point_from_proportion(proportion)
@staticmethod
def from_three_points(p1: Point3D, p2: Point3D, p3: Point3D, **kwargs) -> Self:
def from_three_points(
p1: Point3DLike, p2: Point3DLike, p3: Point3DLike, **kwargs: Any
) -> Circle:
"""Returns a circle passing through the specified
three points.
@ -638,10 +763,11 @@ class Circle(Arc):
self.add(NumberPlane(), circle, dots)
"""
center = line_intersection(
perpendicular_bisector([p1, p2]),
perpendicular_bisector([p2, p3]),
perpendicular_bisector([np.asarray(p1), np.asarray(p2)]),
perpendicular_bisector([np.asarray(p2), np.asarray(p3)]),
)
radius = np.linalg.norm(p1 - center)
# np.linalg.norm returns floating[Any] which is not compatible with float
radius = cast(float, np.linalg.norm(p1 - center))
return Circle(radius=radius, **kwargs).shift(center)
@ -678,12 +804,12 @@ class Dot(Circle):
def __init__(
self,
point: Point3D = ORIGIN,
point: Point3DLike = ORIGIN,
radius: float = DEFAULT_DOT_RADIUS,
stroke_width: float = 0,
fill_opacity: float = 1.0,
color: ParsableManimColor = WHITE,
**kwargs,
**kwargs: Any,
) -> None:
super().__init__(
arc_center=point,
@ -704,7 +830,7 @@ class AnnotationDot(Dot):
stroke_width: float = 5,
stroke_color: ParsableManimColor = WHITE,
fill_color: ParsableManimColor = BLUE,
**kwargs,
**kwargs: Any,
) -> None:
super().__init__(
radius=radius,
@ -726,8 +852,9 @@ class LabeledDot(Dot):
representing rendered strings like :class:`~.Text` or :class:`~.Tex`
can be passed as well.
radius
The radius of the :class:`Dot`. If ``None`` (the default), the radius
is calculated based on the size of the ``label``.
The radius of the :class:`Dot`. If provided, the ``buff`` is ignored.
If ``None`` (the default), the radius is calculated based on the size
of the ``label`` and the ``buff``.
Examples
--------
@ -753,17 +880,20 @@ class LabeledDot(Dot):
self,
label: str | SingleStringMathTex | Text | Tex,
radius: float | None = None,
**kwargs,
buff: float = SMALL_BUFF,
**kwargs: Any,
) -> None:
if isinstance(label, str):
from manim import MathTex
rendered_label = MathTex(label, color=BLACK)
rendered_label: VMobject = MathTex(label, color=BLACK)
else:
rendered_label = label
if radius is None:
radius = 0.1 + max(rendered_label.width, rendered_label.height) / 2
radius = buff + float(
np.linalg.norm([rendered_label.width, rendered_label.height]) / 2
)
super().__init__(radius=radius, **kwargs)
rendered_label.move_to(self.get_center())
self.add(rendered_label)
@ -794,7 +924,7 @@ class Ellipse(Circle):
self.add(ellipse_group)
"""
def __init__(self, width: float = 2, height: float = 1, **kwargs) -> None:
def __init__(self, width: float = 2, height: float = 1, **kwargs: Any) -> None:
super().__init__(**kwargs)
self.stretch_to_fit_width(width)
self.stretch_to_fit_height(height)
@ -855,7 +985,7 @@ class AnnularSector(Arc):
fill_opacity: float = 1,
stroke_width: float = 0,
color: ParsableManimColor = WHITE,
**kwargs,
**kwargs: Any,
) -> None:
self.inner_radius = inner_radius
self.outer_radius = outer_radius
@ -884,7 +1014,8 @@ class AnnularSector(Arc):
self.append_points(outer_arc.points)
self.add_line_to(inner_arc.points[0])
init_points = generate_points
def init_points(self) -> None:
self.generate_points()
class Sector(AnnularSector):
@ -897,17 +1028,15 @@ class Sector(AnnularSector):
class ExampleSector(Scene):
def construct(self):
sector = Sector(outer_radius=2, inner_radius=1)
sector2 = Sector(outer_radius=2.5, inner_radius=0.8).move_to([-3, 0, 0])
sector = Sector(radius=2)
sector2 = Sector(radius=2.5, angle=60*DEGREES).move_to([-3, 0, 0])
sector.set_color(RED)
sector2.set_color(PINK)
self.add(sector, sector2)
"""
def __init__(
self, outer_radius: float = 1, inner_radius: float = 0, **kwargs
) -> None:
super().__init__(inner_radius=inner_radius, outer_radius=outer_radius, **kwargs)
def __init__(self, radius: float = 1, **kwargs: Any) -> None:
super().__init__(inner_radius=0, outer_radius=radius, **kwargs)
class Annulus(Circle):
@ -936,13 +1065,13 @@ class Annulus(Circle):
def __init__(
self,
inner_radius: float | None = 1,
outer_radius: float | None = 2,
inner_radius: float = 1,
outer_radius: float = 2,
fill_opacity: float = 1,
stroke_width: float = 0,
color: ParsableManimColor = WHITE,
mark_paths_closed: bool = False,
**kwargs,
**kwargs: Any,
) -> None:
self.mark_paths_closed = mark_paths_closed # is this even used?
self.inner_radius = inner_radius
@ -960,7 +1089,8 @@ class Annulus(Circle):
self.append_points(inner_circle.points)
self.shift(self.arc_center)
init_points = generate_points
def init_points(self) -> None:
self.generate_points()
class CubicBezier(VMobject, metaclass=ConvertToOpenGL):
@ -988,11 +1118,11 @@ class CubicBezier(VMobject, metaclass=ConvertToOpenGL):
def __init__(
self,
start_anchor: CubicBezierPoints,
start_handle: CubicBezierPoints,
end_handle: CubicBezierPoints,
end_anchor: CubicBezierPoints,
**kwargs,
start_anchor: Point3DLike,
start_handle: Point3DLike,
end_handle: Point3DLike,
end_anchor: Point3DLike,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self.add_cubic_bezier_curve(start_anchor, start_handle, end_handle, end_anchor)
@ -1079,18 +1209,20 @@ class ArcPolygon(VMobject, metaclass=ConvertToOpenGL):
def __init__(
self,
*vertices: Point3D,
*vertices: Point3DLike,
angle: float = PI / 4,
radius: float | None = None,
arc_config: list[dict] | None = None,
**kwargs,
**kwargs: Any,
) -> None:
n = len(vertices)
point_pairs = [(vertices[k], vertices[(k + 1) % n]) for k in range(n)]
if not arc_config:
if radius:
all_arc_configs = itertools.repeat({"radius": radius}, len(point_pairs))
all_arc_configs: Iterable[dict] = itertools.repeat(
{"radius": radius}, len(point_pairs)
)
else:
all_arc_configs = itertools.repeat({"angle": angle}, len(point_pairs))
elif isinstance(arc_config, dict):
@ -1101,7 +1233,7 @@ class ArcPolygon(VMobject, metaclass=ConvertToOpenGL):
arcs = [
ArcBetweenPoints(*pair, **conf)
for (pair, conf) in zip(point_pairs, all_arc_configs)
for (pair, conf) in zip(point_pairs, all_arc_configs, strict=True)
]
super().__init__(**kwargs)
@ -1222,7 +1354,7 @@ class ArcPolygonFromArcs(VMobject, metaclass=ConvertToOpenGL):
self.wait(2)
"""
def __init__(self, *arcs: Arc | ArcBetweenPoints, **kwargs) -> None:
def __init__(self, *arcs: Arc | ArcBetweenPoints, **kwargs: Any) -> None:
if not all(isinstance(m, (Arc, ArcBetweenPoints)) for m in arcs):
raise ValueError(
"All ArcPolygon submobjects must be of type Arc/ArcBetweenPoints",

View file

@ -2,7 +2,7 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
import numpy as np
from pathops import Path as SkiaPath
@ -13,7 +13,7 @@ from manim.mobject.opengl.opengl_compatibility import ConvertToOpenGL
from manim.mobject.types.vectorized_mobject import VMobject
if TYPE_CHECKING:
from manim.typing import Point2D_Array, Point3D_Array
from manim.typing import Point2DLike_Array, Point3D_Array, Point3DLike_Array
from ...constants import RendererType
@ -28,7 +28,7 @@ class _BooleanOps(VMobject, metaclass=ConvertToOpenGL):
def _convert_2d_to_3d_array(
self,
points: Point2D_Array,
points: Point2DLike_Array | Point3DLike_Array,
z_dim: float = 0.0,
) -> Point3D_Array:
"""Converts an iterable with coordinates in 2D to 3D by adding
@ -36,9 +36,9 @@ class _BooleanOps(VMobject, metaclass=ConvertToOpenGL):
Parameters
----------
points:
points
An iterable of points.
z_dim:
z_dim
Default value for the Z coordinate.
Returns
@ -51,13 +51,14 @@ class _BooleanOps(VMobject, metaclass=ConvertToOpenGL):
>>> a = _BooleanOps()
>>> p = [(1, 2), (3, 4)]
>>> a._convert_2d_to_3d_array(p)
[array([1., 2., 0.]), array([3., 4., 0.])]
array([[1., 2., 0.],
[3., 4., 0.]])
"""
points = list(points)
for i, point in enumerate(points):
list_of_points = list(points)
for i, point in enumerate(list_of_points):
if len(point) == 2:
points[i] = np.array(list(point) + [z_dim])
return points
list_of_points[i] = np.append(point, z_dim)
return np.asarray(list_of_points)
def _convert_vmobject_to_skia_path(self, vmobject: VMobject) -> SkiaPath:
"""Converts a :class:`~.VMobject` to SkiaPath. This method only works for
@ -75,10 +76,10 @@ class _BooleanOps(VMobject, metaclass=ConvertToOpenGL):
"""
path = SkiaPath()
if not np.all(np.isfinite(vmobject.points)):
points = np.zeros((1, 3)) # point invalid?
else:
if np.all(np.isfinite(vmobject.points)):
points = vmobject.points
else:
points = np.zeros((1, 3)) # point invalid?
if len(points) == 0: # what? No points so return empty path
return path
@ -95,7 +96,7 @@ class _BooleanOps(VMobject, metaclass=ConvertToOpenGL):
if vmobject.consider_points_equals(subpath[0], subpath[-1]):
path.close()
elif config.renderer == RendererType.CAIRO:
subpaths = vmobject.gen_subpaths_from_points_2d(points)
subpaths = vmobject.gen_subpaths_from_points_2d(points) # type: ignore[assignment]
for subpath in subpaths:
quads = vmobject.gen_cubic_bezier_tuples_from_points(subpath)
start = subpath[0]
@ -177,13 +178,13 @@ class Union(_BooleanOps):
"""
def __init__(self, *vmobjects: VMobject, **kwargs) -> None:
def __init__(self, *vmobjects: VMobject, **kwargs: Any) -> None:
if len(vmobjects) < 2:
raise ValueError("At least 2 mobjects needed for Union.")
super().__init__(**kwargs)
paths = []
for vmobject in vmobjects:
paths.append(self._convert_vmobject_to_skia_path(vmobject))
paths = [
self._convert_vmobject_to_skia_path(vmobject) for vmobject in vmobjects
]
outpen = SkiaPath()
union(paths, outpen.getPen())
self._convert_skia_path_to_vmobject(outpen)
@ -216,7 +217,7 @@ class Difference(_BooleanOps):
"""
def __init__(self, subject: VMobject, clip: VMobject, **kwargs) -> None:
def __init__(self, subject: VMobject, clip: VMobject, **kwargs: Any) -> None:
super().__init__(**kwargs)
outpen = SkiaPath()
difference(
@ -258,7 +259,7 @@ class Intersection(_BooleanOps):
"""
def __init__(self, *vmobjects: VMobject, **kwargs) -> None:
def __init__(self, *vmobjects: VMobject, **kwargs: Any) -> None:
if len(vmobjects) < 2:
raise ValueError("At least 2 mobjects needed for Intersection.")
@ -311,7 +312,7 @@ class Exclusion(_BooleanOps):
"""
def __init__(self, subject: VMobject, clip: VMobject, **kwargs) -> None:
def __init__(self, subject: VMobject, clip: VMobject, **kwargs: Any) -> None:
super().__init__(**kwargs)
outpen = SkiaPath()
xor(

View file

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

View file

@ -14,14 +14,14 @@ __all__ = [
"RightAngle",
]
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any, Literal, cast
import numpy as np
from manim import config
from manim.constants import *
from manim.mobject.geometry.arc import Arc, ArcBetweenPoints, Dot, TipableVMobject
from manim.mobject.geometry.tips import ArrowTriangleFilledTip
from manim.mobject.geometry.tips import ArrowTip, ArrowTriangleFilledTip
from manim.mobject.mobject import Mobject
from manim.mobject.opengl.opengl_compatibility import ConvertToOpenGL
from manim.mobject.opengl.opengl_mobject import OpenGLMobject
@ -30,22 +30,75 @@ from manim.utils.color import WHITE
from manim.utils.space_ops import angle_of_vector, line_intersection, normalize
if TYPE_CHECKING:
from typing_extensions import Self
from typing import Self, TypeAlias
from manim.typing import Point2D, Point3D, Vector3D
from manim.typing import Point3D, Point3DLike, Vector2DLike, Vector3D, Vector3DLike
from manim.utils.color import ParsableManimColor
from ..matrix import Matrix # Avoid circular import
AngleQuadrant: TypeAlias = tuple[Literal[-1, 1], Literal[-1, 1]]
r"""A tuple of 2 integers which can be either +1 or -1, allowing to select
one of the 4 quadrants of the Cartesian plane.
Let :math:`L_1,\ L_2` be two lines defined by start points
:math:`S_1,\ S_2` and end points :math:`E_1,\ E_2`. We define the "positive
direction" of :math:`L_1` as the direction from :math:`S_1` to :math:`E_1`,
and its "negative direction" as the opposite one. We do the same with
:math:`L_2`.
If :math:`L_1` and :math:`L_2` intersect, they divide the plane into 4
quadrants. To pick one quadrant, choose the integers in this tuple in the
following way:
- If the 1st integer is +1, select one of the 2 quadrants towards the
positive direction of :math:`L_1`, i.e. closest to `E_1`. Otherwise, if
the 1st integer is -1, select one of the 2 quadrants towards the
negative direction of :math:`L_1`, i.e. closest to `S_1`.
- Similarly, the sign of the 2nd integer picks the positive or negative
direction of :math:`L_2` and, thus, selects one of the 2 quadrants
which are closest to :math:`E_2` or :math:`S_2` respectively.
"""
class Line(TipableVMobject):
"""A straight or curved line segment between two points or mobjects.
Parameters
----------
start
The starting point or Mobject of the line.
end
The ending point or Mobject of the line.
buff
The distance to shorten the line from both ends.
path_arc
If nonzero, the line will be curved into an arc with this angle (in radians).
kwargs
Additional arguments to be passed to :class:`TipableVMobject`
Examples
--------
.. manim:: LineExample
:save_last_frame:
class LineExample(Scene):
def construct(self):
line1 = Line(LEFT*2, RIGHT*2)
line2 = Line(LEFT*2, RIGHT*2, buff=0.5)
line3 = Line(LEFT*2, RIGHT*2, path_arc=PI/2)
grp = VGroup(line1,line2,line3).arrange(DOWN, buff=2)
self.add(grp)
"""
def __init__(
self,
start: Point3D | Mobject = LEFT,
end: Point3D | Mobject = RIGHT,
start: Point3DLike | Mobject = LEFT,
end: Point3DLike | Mobject = RIGHT,
buff: float = 0,
path_arc: float | None = None,
**kwargs,
path_arc: float = 0,
**kwargs: Any,
) -> None:
self.dim = 3
self.buff = buff
@ -63,8 +116,8 @@ class Line(TipableVMobject):
def set_points_by_ends(
self,
start: Point3D | Mobject,
end: Point3D | Mobject,
start: Point3DLike | Mobject,
end: Point3DLike | Mobject,
buff: float = 0,
path_arc: float = 0,
) -> None:
@ -88,26 +141,24 @@ class Line(TipableVMobject):
arc = ArcBetweenPoints(self.start, self.end, angle=self.path_arc)
self.set_points(arc.points)
else:
self.set_points_as_corners([self.start, self.end])
self.set_points_as_corners(np.asarray([self.start, self.end]))
self._account_for_buff(buff)
init_points = generate_points
def init_points(self) -> None:
self.generate_points()
def _account_for_buff(self, buff: float) -> Self:
if buff == 0:
def _account_for_buff(self, buff: float) -> None:
if buff <= 0:
return
#
length = self.get_length() if self.path_arc == 0 else self.get_arc_length()
#
if length < 2 * buff:
return
buff_proportion = buff / length
self.pointwise_become_partial(self, buff_proportion, 1 - buff_proportion)
return self
def _set_start_and_end_attrs(
self, start: Point3D | Mobject, end: Point3D | Mobject
self, start: Point3DLike | Mobject, end: Point3DLike | Mobject
) -> None:
# If either start or end are Mobjects, this
# gives their centers
@ -122,8 +173,8 @@ class Line(TipableVMobject):
def _pointify(
self,
mob_or_point: Mobject | Point3D,
direction: Vector3D | None = None,
mob_or_point: Mobject | Point3DLike,
direction: Vector3DLike | None = None,
) -> Point3D:
"""Transforms a mobject into its corresponding point. Does nothing if a point is passed.
@ -148,7 +199,11 @@ class Line(TipableVMobject):
self.path_arc = new_value
self.init_points()
def put_start_and_end_on(self, start: Point3D, end: Point3D) -> Self:
def put_start_and_end_on(
self,
start: Point3DLike,
end: Point3DLike,
) -> Self:
"""Sets starts and end coordinates of a line.
Examples
@ -174,8 +229,8 @@ class Line(TipableVMobject):
if np.all(curr_start == curr_end):
# TODO, any problems with resetting
# these attrs?
self.start = start
self.end = end
self.start = np.asarray(start)
self.end = np.asarray(end)
self.generate_points()
return super().put_start_and_end_on(start, end)
@ -188,7 +243,7 @@ class Line(TipableVMobject):
def get_angle(self) -> float:
return angle_of_vector(self.get_vector())
def get_projection(self, point: Point3D) -> Vector3D:
def get_projection(self, point: Point3DLike) -> Point3D:
"""Returns the projection of a point onto a line.
Parameters
@ -196,16 +251,15 @@ class Line(TipableVMobject):
point
The point to which the line is projected.
"""
start = self.get_start()
end = self.get_end()
unit_vect = normalize(end - start)
return start + np.dot(point - start, unit_vect) * unit_vect
return start + float(np.dot(point - start, unit_vect)) * unit_vect
def get_slope(self) -> float:
return np.tan(self.get_angle())
return float(np.tan(self.get_angle()))
def set_angle(self, angle: float, about_point: Point3D | None = None) -> Self:
def set_angle(self, angle: float, about_point: Point3DLike | None = None) -> Self:
if about_point is None:
about_point = self.get_start()
@ -217,7 +271,8 @@ class Line(TipableVMobject):
return self
def set_length(self, length: float) -> Self:
return self.scale(length / self.get_length())
scale_factor: float = length / self.get_length()
return self.scale(scale_factor)
class DashedLine(Line):
@ -256,10 +311,10 @@ class DashedLine(Line):
def __init__(
self,
*args,
*args: Any,
dash_length: float = DEFAULT_DASH_LENGTH,
dashed_ratio: float = 0.5,
**kwargs,
**kwargs: Any,
) -> None:
self.dash_length = dash_length
self.dashed_ratio = dashed_ratio
@ -282,7 +337,6 @@ class DashedLine(Line):
>>> DashedLine()._calculate_num_dashes()
20
"""
# Minimum number of dashes has to be 2
return max(
2,
@ -299,7 +353,6 @@ class DashedLine(Line):
>>> DashedLine().get_start()
array([-1., 0., 0.])
"""
if len(self.submobjects) > 0:
return self.submobjects[0].get_start()
else:
@ -315,7 +368,6 @@ class DashedLine(Line):
>>> DashedLine().get_end()
array([1., 0., 0.])
"""
if len(self.submobjects) > 0:
return self.submobjects[-1].get_end()
else:
@ -331,8 +383,11 @@ class DashedLine(Line):
>>> DashedLine().get_first_handle()
array([-0.98333333, 0. , 0. ])
"""
return self.submobjects[0].points[1]
# Type inference of extracting an element from a list, is not
# supported by numpy, see this numpy issue
# https://github.com/numpy/numpy/issues/16544
first_handle: Point3D = self.submobjects[0].points[1]
return first_handle
def get_last_handle(self) -> Point3D:
"""Returns the point of the last handle.
@ -344,8 +399,11 @@ class DashedLine(Line):
>>> DashedLine().get_last_handle()
array([0.98333333, 0. , 0. ])
"""
return self.submobjects[-1].points[-2]
# Type inference of extracting an element from a list, is not
# supported by numpy, see this numpy issue
# https://github.com/numpy/numpy/issues/16544
last_handle: Point3D = self.submobjects[-1].points[2]
return last_handle
class TangentLine(Line):
@ -387,7 +445,7 @@ class TangentLine(Line):
alpha: float,
length: float = 1,
d_alpha: float = 1e-6,
**kwargs,
**kwargs: Any,
) -> None:
self.length = length
self.d_alpha = d_alpha
@ -430,10 +488,10 @@ class Elbow(VMobject, metaclass=ConvertToOpenGL):
self.add(elbow_group)
"""
def __init__(self, width: float = 0.2, angle: float = 0, **kwargs) -> None:
def __init__(self, width: float = 0.2, angle: float = 0, **kwargs: Any) -> None:
self.angle = angle
super().__init__(**kwargs)
self.set_points_as_corners([UP, UP + RIGHT, RIGHT])
self.set_points_as_corners(np.array([UP, UP + RIGHT, RIGHT]))
self.scale_to_fit_width(width, about_point=ORIGIN)
self.rotate(self.angle, about_point=ORIGIN)
@ -528,24 +586,24 @@ class Arrow(Line):
def __init__(
self,
*args,
*args: Any,
stroke_width: float = 6,
buff: float = MED_SMALL_BUFF,
max_tip_length_to_length_ratio: float = 0.25,
max_stroke_width_to_length_ratio: float = 5,
**kwargs,
**kwargs: Any,
) -> None:
self.max_tip_length_to_length_ratio = max_tip_length_to_length_ratio
self.max_stroke_width_to_length_ratio = max_stroke_width_to_length_ratio
tip_shape = kwargs.pop("tip_shape", ArrowTriangleFilledTip)
super().__init__(*args, buff=buff, stroke_width=stroke_width, **kwargs)
super().__init__(*args, buff=buff, stroke_width=stroke_width, **kwargs) # type: ignore[misc]
# TODO, should this be affected when
# Arrow.set_stroke is called?
self.initial_stroke_width = self.stroke_width
self.add_tip(tip_shape=tip_shape)
self._set_stroke_width_from_length()
def scale(self, factor: float, scale_tips: bool = False, **kwargs) -> Self:
def scale(self, factor: float, scale_tips: bool = False, **kwargs: Any) -> Self: # type: ignore[override]
r"""Scale an arrow, but keep stroke width and arrow tip size fixed.
@ -590,9 +648,11 @@ class Arrow(Line):
self._set_stroke_width_from_length()
if has_tip:
self.add_tip(tip=old_tips[0])
# error: Argument "tip" to "add_tip" of "TipableVMobject" has incompatible type "VMobject"; expected "ArrowTip | None" [arg-type]
self.add_tip(tip=cast(ArrowTip, old_tips[0]))
if has_start_tip:
self.add_tip(tip=old_tips[1], at_start=True)
# error: Argument "tip" to "add_tip" of "TipableVMobject" has incompatible type "VMobject"; expected "ArrowTip | None" [arg-type]
self.add_tip(tip=cast(ArrowTip, old_tips[1]), at_start=True)
return self
def get_normal_vector(self) -> Vector3D:
@ -605,7 +665,6 @@ class Arrow(Line):
>>> np.round(Arrow().get_normal_vector()) + 0. # add 0. to avoid negative 0 in output
array([ 0., 0., -1.])
"""
p0, p1, p2 = self.tip.get_start_anchors()[:3]
return normalize(np.cross(p2 - p1, p1 - p0))
@ -625,7 +684,6 @@ class Arrow(Line):
>>> Arrow().get_default_tip_length()
0.35
"""
max_ratio = self.max_tip_length_to_length_ratio
return min(self.tip_length, max_ratio * self.get_length())
@ -633,7 +691,11 @@ class Arrow(Line):
"""Sets stroke width based on length."""
max_ratio = self.max_stroke_width_to_length_ratio
if config.renderer == RendererType.OPENGL:
self.set_stroke(
# Mypy does not recognize that the self object in this case
# is a OpenGLVMobject and that the set_stroke method is
# defined here:
# mobject/opengl/opengl_vectorized_mobject.py#L248
self.set_stroke( # type: ignore[call-arg]
width=min(self.initial_stroke_width, max_ratio * self.get_length()),
recurse=False,
)
@ -676,7 +738,10 @@ class Vector(Arrow):
"""
def __init__(
self, direction: Point2D | Point3D = RIGHT, buff: float = 0, **kwargs
self,
direction: Vector2DLike | Vector3DLike = RIGHT,
buff: float = 0,
**kwargs: Any,
) -> None:
self.buff = buff
if len(direction) == 2:
@ -689,7 +754,7 @@ class Vector(Arrow):
integer_labels: bool = True,
n_dim: int = 2,
color: ParsableManimColor | None = None,
**kwargs,
**kwargs: Any,
) -> Matrix:
"""Creates a label based on the coordinates of the vector.
@ -725,7 +790,6 @@ class Vector(Arrow):
self.add(plane, vec_1, vec_2, label_1, label_2)
"""
# avoiding circular imports
from ..matrix import Matrix
@ -793,7 +857,7 @@ class DoubleArrow(Arrow):
self.add(box, d1, d2, d3)
"""
def __init__(self, *args, **kwargs) -> None:
def __init__(self, *args: Any, **kwargs: Any) -> None:
if "tip_shape_end" in kwargs:
kwargs["tip_shape"] = kwargs.pop("tip_shape_end")
tip_shape_start = kwargs.pop("tip_shape_start", ArrowTriangleFilledTip)
@ -915,14 +979,14 @@ class Angle(VMobject, metaclass=ConvertToOpenGL):
line1: Line,
line2: Line,
radius: float | None = None,
quadrant: Point2D = (1, 1),
quadrant: AngleQuadrant = (1, 1),
other_angle: bool = False,
dot: bool = False,
dot_radius: float | None = None,
dot_distance: float = 0.55,
dot_color: ParsableManimColor = WHITE,
elbow: bool = False,
**kwargs,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self.lines = (line1, line2)
@ -959,9 +1023,9 @@ class Angle(VMobject, metaclass=ConvertToOpenGL):
+ quadrant[0] * radius * line1.get_unit_vector()
+ quadrant[1] * radius * line2.get_unit_vector()
)
angle_mobject = Elbow(**kwargs)
angle_mobject: VMobject = Elbow(**kwargs)
angle_mobject.set_points_as_corners(
[anchor_angle_1, anchor_middle, anchor_angle_2],
np.array([anchor_angle_1, anchor_middle, anchor_angle_2]),
)
else:
angle_1 = angle_of_vector(anchor_angle_1 - inter)
@ -1025,11 +1089,10 @@ class Angle(VMobject, metaclass=ConvertToOpenGL):
>>> angle.get_lines()
VGroup(Line, Line)
"""
return VGroup(*self.lines)
def get_value(self, degrees: bool = False) -> float:
"""Get the value of an angle of the :class:`Angle` class.
r"""Get the value of an angle of the :class:`Angle` class.
Parameters
----------
@ -1054,17 +1117,18 @@ class Angle(VMobject, metaclass=ConvertToOpenGL):
angle = Angle(line1, line2, radius=0.4)
value = DecimalNumber(angle.get_value(degrees=True), unit="^{\\circ}")
value = DecimalNumber(angle.get_value(degrees=True), unit=r"^{\circ}")
value.next_to(angle, UR)
self.add(line1, line2, angle, value)
"""
return self.angle_value / DEGREES if degrees else self.angle_value
@staticmethod
def from_three_points(A: Point3D, B: Point3D, C: Point3D, **kwargs) -> Angle:
"""The angle between the lines AB and BC.
def from_three_points(
A: Point3DLike, B: Point3DLike, C: Point3DLike, **kwargs: Any
) -> Angle:
r"""The angle between the lines AB and BC.
This constructs the angle :math:`\\angle ABC`.
@ -1139,6 +1203,10 @@ class RightAngle(Angle):
"""
def __init__(
self, line1: Line, line2: Line, length: float | None = None, **kwargs
self,
line1: Line,
line2: Line,
length: float | None = None,
**kwargs: Any,
) -> None:
super().__init__(line1, line2, radius=length, elbow=True, **kwargs)

View file

@ -13,11 +13,12 @@ __all__ = [
"Square",
"RoundedRectangle",
"Cutout",
"ConvexHull",
]
from math import ceil
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any, Literal
import numpy as np
@ -27,12 +28,20 @@ from manim.mobject.opengl.opengl_compatibility import ConvertToOpenGL
from manim.mobject.types.vectorized_mobject import VGroup, VMobject
from manim.utils.color import BLUE, WHITE, ParsableManimColor
from manim.utils.iterables import adjacent_n_tuples, adjacent_pairs
from manim.utils.qhull import QuickHull
from manim.utils.space_ops import angle_between_vectors, normalize, regular_vertices
if TYPE_CHECKING:
from typing_extensions import Self
from typing import Self
from manim.typing import Point3D, Point3D_Array
import numpy.typing as npt
from manim.typing import (
Point3D,
Point3D_Array,
Point3DLike,
Point3DLike_Array,
)
from manim.utils.color import ParsableManimColor
@ -72,11 +81,16 @@ class Polygram(VMobject, metaclass=ConvertToOpenGL):
"""
def __init__(
self, *vertex_groups: Point3D, color: ParsableManimColor = BLUE, **kwargs
self,
*vertex_groups: Point3DLike_Array,
color: ParsableManimColor = BLUE,
**kwargs: Any,
):
super().__init__(color=color, **kwargs)
for vertices in vertex_groups:
# The inferred type for *vertices is Any, but it should be
# Point3D_Array
first_vertex, *vertices = vertices
first_vertex = np.array(first_vertex)
@ -104,43 +118,49 @@ class Polygram(VMobject, metaclass=ConvertToOpenGL):
[-1., -1., 0.],
[ 1., -1., 0.]])
"""
return self.get_start_anchors()
def get_vertex_groups(self) -> np.ndarray[Point3D_Array]:
def get_vertex_groups(self) -> list[Point3D_Array]:
"""Gets the vertex groups of the :class:`Polygram`.
Returns
-------
:class:`numpy.ndarray`
The vertex groups of the :class:`Polygram`.
list[Point3D_Array]
The list of vertex groups of the :class:`Polygram`.
Examples
--------
::
>>> poly = Polygram([ORIGIN, RIGHT, UP], [LEFT, LEFT + UP, 2 * LEFT])
>>> poly.get_vertex_groups()
array([[[ 0., 0., 0.],
[ 1., 0., 0.],
[ 0., 1., 0.]],
<BLANKLINE>
[[-1., 0., 0.],
[-1., 1., 0.],
[-2., 0., 0.]]])
>>> poly = Polygram([ORIGIN, RIGHT, UP, LEFT + UP], [LEFT, LEFT + UP, 2 * LEFT])
>>> groups = poly.get_vertex_groups()
>>> len(groups)
2
>>> groups[0]
array([[ 0., 0., 0.],
[ 1., 0., 0.],
[ 0., 1., 0.],
[-1., 1., 0.]])
>>> groups[1]
array([[-1., 0., 0.],
[-1., 1., 0.],
[-2., 0., 0.]])
"""
vertex_groups = []
# TODO: If any of the original vertex groups contained the starting vertex N
# 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()):
for start, end in zip(
self.get_start_anchors(), self.get_end_anchors(), strict=True
):
group.append(start)
if self.consider_points_equals(end, group[0]):
vertex_groups.append(group)
vertex_groups.append(np.array(group))
group = []
return np.array(vertex_groups)
return vertex_groups
def round_corners(
self,
@ -204,24 +224,23 @@ class Polygram(VMobject, metaclass=ConvertToOpenGL):
shapes.arrange(RIGHT)
self.add(shapes)
"""
if radius == 0:
return self
new_points = []
new_points: list[Point3D] = []
for vertices in self.get_vertex_groups():
for vertex_group in self.get_vertex_groups():
arcs = []
# Repeat the radius list as necessary in order to provide a radius
# for each vertex.
if isinstance(radius, (int, float)):
radius_list = [radius] * len(vertices)
radius_list = [radius] * len(vertex_group)
else:
radius_list = radius * ceil(len(vertices) / len(radius))
radius_list = radius * ceil(len(vertex_group) / len(radius))
for currentRadius, (v1, v2, v3) in zip(
radius_list, adjacent_n_tuples(vertices, 3)
for current_radius, (v1, v2, v3) in zip(
radius_list, adjacent_n_tuples(vertex_group, 3), strict=True
):
vect1 = v2 - v1
vect2 = v3 - v2
@ -230,10 +249,10 @@ class Polygram(VMobject, metaclass=ConvertToOpenGL):
angle = angle_between_vectors(vect1, vect2)
# Negative radius gives concave curves
angle *= np.sign(currentRadius)
angle *= np.sign(current_radius)
# Distance between vertex and start of the arc
cut_off_length = currentRadius * np.tan(angle / 2)
cut_off_length = current_radius * np.tan(angle / 2)
# Determines counterclockwise vs. clockwise
sign = np.sign(np.cross(vect1, vect2)[2])
@ -248,17 +267,17 @@ class Polygram(VMobject, metaclass=ConvertToOpenGL):
if evenly_distribute_anchors:
# Determine the average length of each curve
nonZeroLengthArcs = [arc for arc in arcs if len(arc.points) > 4]
if len(nonZeroLengthArcs):
totalArcLength = sum(
[arc.get_arc_length() for arc in nonZeroLengthArcs]
nonzero_length_arcs = [arc for arc in arcs if len(arc.points) > 4]
if len(nonzero_length_arcs) > 0:
total_arc_length = sum(
[arc.get_arc_length() for arc in nonzero_length_arcs]
)
totalCurveCount = (
sum([len(arc.points) for arc in nonZeroLengthArcs]) / 4
num_curves = (
sum([len(arc.points) for arc in nonzero_length_arcs]) / 4
)
averageLengthPerCurve = totalArcLength / totalCurveCount
average_arc_length = total_arc_length / num_curves
else:
averageLengthPerCurve = 1
average_arc_length = 1.0
# To ensure that we loop through starting with last
arcs = [arcs[-1], *arcs[:-1]]
@ -271,13 +290,11 @@ class Polygram(VMobject, metaclass=ConvertToOpenGL):
# Make sure anchors are evenly distributed, if necessary
if evenly_distribute_anchors:
line.insert_n_curves(
ceil(line.get_length() / averageLengthPerCurve)
)
line.insert_n_curves(ceil(line.get_length() / average_arc_length))
new_points.extend(line.points)
self.set_points(new_points)
self.set_points(np.array(new_points))
return self
@ -312,7 +329,7 @@ class Polygon(Polygram):
self.add(isosceles, square_and_triangles)
"""
def __init__(self, *vertices: Point3D, **kwargs) -> None:
def __init__(self, *vertices: Point3DLike, **kwargs: Any) -> None:
super().__init__(vertices, **kwargs)
@ -355,7 +372,7 @@ class RegularPolygram(Polygram):
density: int = 2,
radius: float = 1,
start_angle: float | None = None,
**kwargs,
**kwargs: Any,
) -> None:
# Regular polygrams can be expressed by the number of their vertices
# and their density. This relation can be expressed as its Schläfli
@ -376,7 +393,7 @@ class RegularPolygram(Polygram):
# Utility function for generating the individual
# polygon vertices.
def gen_polygon_vertices(start_angle):
def gen_polygon_vertices(start_angle: float | None) -> tuple[list[Any], float]:
reg_vertices, start_angle = regular_vertices(
num_vertices,
radius=radius,
@ -432,7 +449,7 @@ class RegularPolygon(RegularPolygram):
self.add(poly_group)
"""
def __init__(self, n: int = 6, **kwargs) -> None:
def __init__(self, n: int = 6, **kwargs: Any) -> None:
super().__init__(n, density=1, **kwargs)
@ -502,7 +519,7 @@ class Star(Polygon):
inner_radius: float | None = None,
density: int = 2,
start_angle: float | None = TAU / 4,
**kwargs,
**kwargs: Any,
) -> None:
inner_angle = TAU / (2 * n)
@ -534,8 +551,8 @@ class Star(Polygon):
start_angle=self.start_angle + inner_angle,
)
vertices = []
for pair in zip(outer_vertices, inner_vertices):
vertices: list[npt.NDArray] = []
for pair in zip(outer_vertices, inner_vertices, strict=True):
vertices.extend(pair)
super().__init__(*vertices, **kwargs)
@ -562,7 +579,7 @@ class Triangle(RegularPolygon):
self.add(tri_group)
"""
def __init__(self, **kwargs) -> None:
def __init__(self, **kwargs: Any) -> None:
super().__init__(n=3, **kwargs)
@ -613,7 +630,7 @@ class Rectangle(Polygon):
grid_ystep: float | None = None,
mark_paths_closed: bool = True,
close_new_points: bool = True,
**kwargs,
**kwargs: Any,
):
super().__init__(UR, UL, DL, DR, color=color, **kwargs)
self.stretch_to_fit_width(width)
@ -684,10 +701,17 @@ class Square(Rectangle):
self.add(square_1, square_2, square_3)
"""
def __init__(self, side_length: float = 2.0, **kwargs) -> None:
self.side_length = side_length
def __init__(self, side_length: float = 2.0, **kwargs: Any) -> None:
super().__init__(height=side_length, width=side_length, **kwargs)
@property
def side_length(self) -> float:
return float(np.linalg.norm(self.get_vertices()[0] - self.get_vertices()[1]))
@side_length.setter
def side_length(self, value: float) -> None:
self.scale(value / self.side_length)
class RoundedRectangle(Rectangle):
"""A rectangle with rounded corners.
@ -713,7 +737,7 @@ class RoundedRectangle(Rectangle):
self.add(rect_group)
"""
def __init__(self, corner_radius: float | list[float] = 0.5, **kwargs):
def __init__(self, corner_radius: float | list[float] = 0.5, **kwargs: Any):
super().__init__(**kwargs)
self.corner_radius = corner_radius
self.round_corners(self.corner_radius)
@ -754,9 +778,77 @@ class Cutout(VMobject, metaclass=ConvertToOpenGL):
self.wait()
"""
def __init__(self, main_shape: VMobject, *mobjects: VMobject, **kwargs) -> None:
def __init__(
self, main_shape: VMobject, *mobjects: VMobject, **kwargs: Any
) -> None:
super().__init__(**kwargs)
self.append_points(main_shape.points)
sub_direction = "CCW" if main_shape.get_direction() == "CW" else "CW"
sub_direction: Literal["CCW", "CW"] = (
"CCW" if main_shape.get_direction() == "CW" else "CW"
)
for mobject in mobjects:
self.append_points(mobject.force_direction(sub_direction).points)
class ConvexHull(Polygram):
"""Constructs a convex hull for a set of points in no particular order.
Parameters
----------
points
The points to consider.
tolerance
The tolerance used by quickhull.
kwargs
Forwarded to the parent constructor.
Examples
--------
.. manim:: ConvexHullExample
:save_last_frame:
:quality: high
class ConvexHullExample(Scene):
def construct(self):
points = [
[-2.35, -2.25, 0],
[1.65, -2.25, 0],
[2.65, -0.25, 0],
[1.65, 1.75, 0],
[-0.35, 2.75, 0],
[-2.35, 0.75, 0],
[-0.35, -1.25, 0],
[0.65, -0.25, 0],
[-1.35, 0.25, 0],
[0.15, 0.75, 0]
]
hull = ConvexHull(*points, color=BLUE)
dots = VGroup(*[Dot(point) for point in points])
self.add(hull)
self.add(dots)
"""
def __init__(
self, *points: Point3DLike, tolerance: float = 1e-5, **kwargs: Any
) -> None:
# Build Convex Hull
array = np.array(points)[:, :2]
hull = QuickHull(tolerance)
hull.build(array)
# Extract Vertices
facets = set(hull.facets) - hull.removed
facet = facets.pop()
subfacets = list(facet.subfacets)
while len(subfacets) <= len(facets):
sf = subfacets[-1]
(facet,) = hull.neighbors[sf] - {facet}
(sf,) = facet.subfacets - {sf}
subfacets.append(sf)
# Setup Vertices as Point3D
coordinates = np.vstack([sf.coordinates for sf in subfacets])
vertices = np.hstack((coordinates, np.zeros((len(coordinates), 1))))
# Call Polygram
super().__init__(vertices, **kwargs)

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