Compare commits

..

43 commits

Author SHA1 Message Date
dependabot[bot]
0e83f4b09a
chore(deps): bump jupyterlab from 4.5.7 to 4.5.9 (#4822)
---
updated-dependencies:
- dependency-name: jupyterlab
  dependency-version: 4.5.9
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-20 13:14:07 -04:00
dependabot[bot]
f7fd708276
chore(deps): bump jupyter-server from 2.18.0 to 2.20.0 (#4821)
Bumps [jupyter-server](https://github.com/jupyter-server/jupyter_server) from 2.18.0 to 2.20.0.
- [Release notes](https://github.com/jupyter-server/jupyter_server/releases)
- [Changelog](https://github.com/jupyter-server/jupyter_server/blob/main/CHANGELOG.md)
- [Commits](https://github.com/jupyter-server/jupyter_server/compare/v2.18.0...v2.20.0)

---
updated-dependencies:
- dependency-name: jupyter-server
  dependency-version: 2.20.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-20 11:22:50 -04:00
Caffein3
4c4622df54
feat: support multiple TeX compilers in TexTemplate (#4810)
* feat: support multiple compilations

Allow `tex_compiler` to be a `str | list[str]` instead of just
`str`. When a list is given, document will be compiled sequentially.

* fix: suppress compilation progress log when only one compiler is used

* fix: updated docstrings and variable naming
2026-06-16 22:35:14 -04:00
dependabot[bot]
66d5a4937a
chore(deps): bump bleach from 6.3.0 to 6.4.0 (#4814)
Bumps [bleach](https://github.com/mozilla/bleach) from 6.3.0 to 6.4.0.
- [Changelog](https://github.com/mozilla/bleach/blob/main/CHANGES)
- [Commits](https://github.com/mozilla/bleach/compare/v6.3.0...v6.4.0)

---
updated-dependencies:
- dependency-name: bleach
  dependency-version: 6.4.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-17 01:49:15 +00:00
dependabot[bot]
c07137dbed
chore(deps): bump tornado from 6.5.5 to 6.5.7 (#4813)
Bumps [tornado](https://github.com/tornadoweb/tornado) from 6.5.5 to 6.5.7.
- [Changelog](https://github.com/tornadoweb/tornado/blob/master/docs/releases.rst)
- [Commits](https://github.com/tornadoweb/tornado/compare/v6.5.5...v6.5.7)

---
updated-dependencies:
- dependency-name: tornado
  dependency-version: 6.5.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-17 01:33:08 +00:00
dependabot[bot]
71ab85f960
chore(deps): bump idna from 3.11 to 3.15 (#4736)
Bumps [idna](https://github.com/kjd/idna) from 3.11 to 3.15.
- [Release notes](https://github.com/kjd/idna/releases)
- [Changelog](https://github.com/kjd/idna/blob/master/HISTORY.md)
- [Commits](https://github.com/kjd/idna/compare/v3.11...v3.15)

---
updated-dependencies:
- dependency-name: idna
  dependency-version: '3.15'
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
2026-06-17 01:09:58 +00:00
dependabot[bot]
16f0a3de3e
chore(deps): bump urllib3 from 2.6.3 to 2.7.0 (#4729)
Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.6.3 to 2.7.0.
- [Release notes](https://github.com/urllib3/urllib3/releases)
- [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst)
- [Commits](https://github.com/urllib3/urllib3/compare/2.6.3...2.7.0)

---
updated-dependencies:
- dependency-name: urllib3
  dependency-version: 2.7.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
2026-06-17 00:58:29 +00:00
dependabot[bot]
537a134360
chore(deps): bump mistune from 3.2.0 to 3.2.1 (#4725)
Bumps [mistune](https://github.com/lepture/mistune) from 3.2.0 to 3.2.1.
- [Release notes](https://github.com/lepture/mistune/releases)
- [Changelog](https://github.com/lepture/mistune/blob/main/docs/changes.rst)
- [Commits](https://github.com/lepture/mistune/compare/v3.2.0...v3.2.1)

---
updated-dependencies:
- dependency-name: mistune
  dependency-version: 3.2.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-16 23:49:46 +00:00
dependabot[bot]
b7bf2ea90f
chore(deps): bump docker/setup-buildx-action from 3 to 4 (#4716)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
2026-06-16 22:42:37 +00:00
dependabot[bot]
bdaf4497b7
chore(deps): bump jupyterlab from 4.5.2 to 4.5.7 (#4713)
Bumps [jupyterlab](https://github.com/jupyterlab/jupyterlab) from 4.5.2 to 4.5.7.
- [Release notes](https://github.com/jupyterlab/jupyterlab/releases)
- [Changelog](https://github.com/jupyterlab/jupyterlab/blob/main/RELEASE.md)
- [Commits](https://github.com/jupyterlab/jupyterlab/compare/@jupyterlab/lsp@4.5.2...@jupyterlab/lsp@4.5.7)

---
updated-dependencies:
- dependency-name: jupyterlab
  dependency-version: 4.5.7
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-16 22:05:50 +00:00
dependabot[bot]
037b376ec2
chore(deps): bump notebook from 7.5.2 to 7.5.6 (#4712)
Bumps [notebook](https://github.com/jupyter/notebook) from 7.5.2 to 7.5.6.
- [Release notes](https://github.com/jupyter/notebook/releases)
- [Changelog](https://github.com/jupyter/notebook/blob/@jupyter-notebook/tree@7.5.6/CHANGELOG.md)
- [Commits](https://github.com/jupyter/notebook/compare/@jupyter-notebook/tree@7.5.2...@jupyter-notebook/tree@7.5.6)

---
updated-dependencies:
- dependency-name: notebook
  dependency-version: 7.5.6
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
2026-06-16 17:46:31 -04:00
dependabot[bot]
e9639c2697
chore(deps): bump nbconvert from 7.17.0 to 7.17.1 (#4702)
Bumps [nbconvert](https://github.com/jupyter/nbconvert) from 7.17.0 to 7.17.1.
- [Release notes](https://github.com/jupyter/nbconvert/releases)
- [Changelog](https://github.com/jupyter/nbconvert/blob/main/CHANGELOG.md)
- [Commits](https://github.com/jupyter/nbconvert/compare/v7.17.0...v7.17.1)

---
updated-dependencies:
- dependency-name: nbconvert
  dependency-version: 7.17.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
2026-06-16 21:13:42 +00:00
dependabot[bot]
1b2d5ce72b
chore(deps-dev): bump pytest from 9.0.2 to 9.0.3 (#4688)
Bumps [pytest](https://github.com/pytest-dev/pytest) from 9.0.2 to 9.0.3.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/9.0.2...9.0.3)

---
updated-dependencies:
- dependency-name: pytest
  dependency-version: 9.0.3
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
2026-06-16 20:38:28 +00:00
dependabot[bot]
c94a7ea9fc
chore(deps): bump pillow from 12.1.1 to 12.2.0 (#4687)
Bumps [pillow](https://github.com/python-pillow/Pillow) from 12.1.1 to 12.2.0.
- [Release notes](https://github.com/python-pillow/Pillow/releases)
- [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst)
- [Commits](https://github.com/python-pillow/Pillow/compare/12.1.1...12.2.0)

---
updated-dependencies:
- dependency-name: pillow
  dependency-version: 12.2.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
2026-06-16 20:10:01 +00:00
dependabot[bot]
bb1be6ef8a
chore(deps): bump actions/upload-artifact from 6 to 7 (#4620)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
2026-06-16 19:48:22 +00:00
rsn
516c8c8ba7
Fix ThreeDScene.set_to_default_angled_camera_orientation() (#4704)
Co-authored-by: neeh <rapetisiddhu@gmail.com>
Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
2026-06-16 15:20:18 -04:00
Mayank Basena
ccee37a614
Add docstring to GenericGraph.__getitem__() (#4761)
Allows accessing edges via tuple keys like g[(1, 2)] in addition to
vertex lookups like g[1]. Previously only vertex lookups were supported.

Fixes ManimCommunity/manim#3798

Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
2026-06-16 18:52:42 +00:00
dependabot[bot]
31db147222
chore(deps): bump jupyter-server from 2.17.0 to 2.18.0 (#4720)
Bumps [jupyter-server](https://github.com/jupyter-server/jupyter_server) from 2.17.0 to 2.18.0.
- [Release notes](https://github.com/jupyter-server/jupyter_server/releases)
- [Changelog](https://github.com/jupyter-server/jupyter_server/blob/main/CHANGELOG.md)
- [Commits](https://github.com/jupyter-server/jupyter_server/compare/v2.17.0...v2.18.0)

---
updated-dependencies:
- dependency-name: jupyter-server
  dependency-version: 2.18.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
2026-06-16 17:57:48 +00:00
GniLudio
34124c3f60
Fix wrong type annotation of Animation._on_finish (#4812) 2026-06-16 13:11:45 -04:00
Benjamin Hackl
d999d422c9
Introduce first-class support for rendering text and markup via Typst (optional dependency) (#4681)
* feat: add TypstMobject and TypstMathMobject for first-class Typst support

Implement the initial TypstMobject proposal (see agents/typst.md):

- TypstMobject: renders arbitrary Typst markup to SVG via the 'typst'
  Python package (self-contained Rust binary, no system install needed),
  then imports through SVGMobject.
- TypstMathMobject: convenience subclass that wraps input in Typst math
  delimiters ($ ... $).

Pipeline: user string → wrap in minimal Typst document (auto-sized page,
transparent background) → write .typ file → compile to SVG via
typst.compile() → import via SVGMobject → scale/center/recolor.

Key details:
- Compilation helper in manim/utils/typst_file_writing.py with SHA-256
  content-hash caching (same scheme as the LaTeX pipeline).
- font_size property mirrors SingleStringMathTex: compile at fixed 11pt,
  scale after import using initial_height / SCALE_FACTOR_PER_FONT_POINT.
- init_colors() recolors black submobjects to self.color (Typst default
  fill is black), preserving any explicit Typst colors.
- path_string_config enables curve subdivision for smooth animation.
- 'typst' added as optional dependency group in pyproject.toml.
- 10 tests covering creation, font_size, caching, preamble, and repr.

* feat(typst): add sub-expression selection via {{ }} groups and .select()

- Override modify_xml_tree in Typst to convert data-typst-label
  attributes to id attributes before svgelements parsing (avoids
  attribute inheritance issue with data-* attributes)
- Add Typst.select(key) method accepting str labels or int indices
  to retrieve VGroup sub-expressions from id_to_vgroup_dict
- Implement {{ }} double-brace preprocessor on TypstMath:
  - {{ content }} → manimgrp("_grp-N", content) (auto-numbered)
  - {{ content : label }} → manimgrp("label", content) (named)
  - Skips {{ }} inside string literals and [...] content blocks
  - Uses math-mode call convention (no # prefix) to keep args in
    math mode
- Auto-inject manimgrp preamble when groups are detected
- Add 12 new tests covering label mapping, nesting, select(),
  error handling, and preprocessor edge cases

* Remove unneeded assert line

* Generalize some more critical hard-coded LaTeX assumptions

* Fix syntax error

* Support Typst labels in existing APIs

* Add ManimTextLabel typing alias

* Preserve Typst SVG stroke widths by default

* Calibrate Typst font sizing against TeX

* Add Typst mobject documentation

* Track Typst baseline frames

* Document Typst baseline frame tracking

* Add Typst section to text guide

* Polish Typst docs and label handling

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Improve Typst stroke scaling and docs examples

* Tune Typst SVG stroke scaling

* Restore Typst math example wording

* Fix pre-commit issues for Typst branch

* Fix number line runtime typing import

* Exclude Typst autogenerated .rst files

* Use Manim directive in docstrings and fix wrong key in Typst.select() docstring

* Fix GroupedMath comments

---------

Co-authored-by: Toon Verstraelen <Toon.Verstraelen@UGent.be>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
Co-authored-by: Francisco Manríquez Novoa <francisco.manriquezn@usm.cl>
2026-06-11 03:54:21 +00:00
GniLudio
561de9d72a
Use language parameter to format Code even when passing code_file (#4706)
Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
2026-06-10 21:49:59 +00:00
Sai Sridhar Tarra
05b3042ab0
Add tests for edge lookup in GenericGraph.__getitem__() (#4753)
GenericGraph.__getitem__ only handled vertex lookup. Passing a 2-tuple
(u, v) to retrieve an edge mobject raised a KeyError against the
vertices dict instead of looking up self.edges.

Route tuple keys of length 2 to self.edges so that g[(u, v)] returns
the edge mobject, consistent with how edges are stored internally.

Adds a test covering vertex lookup, edge lookup (Graph), and edge
lookup (DiGraph).

Fixes #3798

Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
2026-06-10 17:34:39 -04:00
HairlessVillager
2ece488b2c
Support edge lookup via tuple in GenericGraph.__getitem__() (#3799)
* feat: add tuple key support in GenericGraph.__getitem__()

* feat: add an error message for missing vertice or edge

Co-authored-by: adeshpande <110117391+JasonGrace2282@users.noreply.github.com>

* docs: add an example

* fix: tuple vertex return edge

---------

Co-authored-by: adeshpande <110117391+JasonGrace2282@users.noreply.github.com>
Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
2026-06-10 17:19:24 -04:00
Guillaume Vauvert
852ebd1c60
Fix empty submobjects distorting width/height of parent Mobject (#4088)
* Issue-4087 Add bug test

* Issue-4087 Fix bug

* Issue-4087 Fix comment and add type hint

* Issue-4087 Fix comment

* Issue-4087 Improve previous test instead of adding a new one

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Update tests/module/mobject/mobject/test_mobject.py

* Enhance docstring for reduce_across_dimension method

Updated docstring for reduce_across_dimension method to clarify its purpose and parameters.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Remove float typehints for coords in length_over_dim

* Modify test for empty VMobject dimensions

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-06-10 16:21:52 -04:00
Irvanal Haq
82522795f1
Allow CyclicReplace and Swap to accept single Group or VGroup and add example (#4211)
* Allow Swap to accept Group and VGroup and add example

* Modify create_target return type to Group | VGroup

Updated create_target method to allow returning either Group or VGroup.

---------

Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
2026-06-09 17:33:12 -04:00
Henrik Skov Midtiby
12c5640a32
Add type annotations to transform_matching_parts.py (#4400)
* Add type annotations to `transform_matching_parts.py`

* Make two type errors quiet

* Make the pytests pass

---------

Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
2026-06-09 17:11:01 -04:00
Laszlo
33424fe43d
More specific Callable type annotations in mobject_update_utils (#4728)
* More specific Callable type annotations in mobject_update_utils

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-06-03 17:02:00 +00:00
Leonardo Cariaggi
1b3390073c
Allow excluding inner lines of Table (#4731)
* Add option to hide/show inner lines in Table class

* Add tests for Table class inner lines visibility

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Correct indentation

* Fix os.startfile usage to check for availability on Windows

* Add ConversationFlowScene to animate user-chatbot interactions and metadata display

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Revert "Merge remote-tracking branch 'origin/conversation' into hide_table_inner_lines"

This reverts commit c0ba5b8511, reversing
changes made to 1f71f4b0e8.

* Revert change

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Leonardo Cariaggi <leonardo.cariaggi@kbc.be>
Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
2026-06-03 09:06:03 -04:00
Rafael Brusiquesi Martins
56f7eb2a1f
Add regression tests for four recent fixes in vectorized_mobject.py (#4750)
* Add regression tests for four recent fixes in vectorized_mobject.py

Adds four regression tests for fixes that landed without tests in the same PR:

  * 21cf9998 (PR #4630, fixes #3569 + #4629) -- IndexError in
    `get_nth_subpath` when `path_list` is empty; ensure it always returns
    a NumPy array.

  * f6cdb547 (PR #4219) -- `add_points_as_corners` silently dropped a
    single new point when called on a VMobject whose last subpath was
    complete.

  * 3d029c12 (PR #4320, fixes #4255) -- `pointwise_become_partial` cleared
    the target's points when the source had no cubic curves, surfacing as
    `Arrow3D.get_start()` / `get_end()` returning the origin after a
    `Create` animation.

  * 429f25328 (PR #4694) -- `scale(scale_stroke=True)` on a compound
    VMobject propagated the parent's scaled stroke width to every
    submobject, overwriting submobjects with their own (e.g. zero) stroke.

Each test reproduces the original failing condition at the unit level
and asserts the post-fix behavior. Validation: every test was confirmed
to fail when the corresponding fix is reverted on the source file, and
pass when the fix is restored.

4 tests, ~0.2s runtime. Tests go in the existing files
`tests/module/mobject/types/vectorized_mobject/test_vectorized_mobject.py`
(three tests, adjacent to their topically-related siblings) and
`tests/module/mobject/types/vectorized_mobject/test_stroke.py` (one test,
next to the existing `test_stroke_scale`).

Co-authored-by: LetMarq <LetMarq@users.noreply.github.com>

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: THE-RAF <THE-RAF@users.noreply.github.com>
Co-authored-by: LetMarq <LetMarq@users.noreply.github.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
2026-06-02 21:53:49 +00:00
Rafael Brusiquesi Martins
cfb5c684b7
Add unit tests for module-level color helpers in manim/utils/color/core.py (#4749)
* Add unit tests for module-level color helpers in manim/utils/color/core.py

Adds tests/module/utils/test_color_helpers.py covering the standalone
helper functions exported from manim.utils.color.core (color_to_rgb,
color_to_rgba, color_to_int_rgb, color_to_int_rgba, rgb_to_color,
rgba_to_color, rgb_to_hex, hex_to_rgb, invert_color, color_gradient,
interpolate_color, average_color, random_bright_color, random_color)
and the RandomColorGenerator class.

Before this PR, only the ManimColor class itself was tested
(via tests/module/utils/test_manim_color.py); the standalone helpers
had zero direct test coverage. Coverage on manim/utils/color/core.py
goes from 71% to 80% (line+branch), and 17 of the 18 testable symbols
in the file's __all__ are now exercised. The only deferred symbol is
get_shaded_rgb, which concerns lighting math rather than color
conversion and is better suited to a follow-up.

42 tests, ~0.3s runtime, no new dependencies.

Co-authored-by: LetMarq <LetMarq@users.noreply.github.com>

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Update tests/module/utils/test_color_helpers.py

Increased assertion scheme to cover all colors.

Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>

---------

Co-authored-by: THE-RAF <THE-RAF@users.noreply.github.com>
Co-authored-by: LetMarq <LetMarq@users.noreply.github.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
2026-05-29 17:23:28 -04:00
Vihaan Dutta
429f25328d
Fixed inconsistent stroke width scaling for text in compound objects (#4694)
* fixed zero-stroke mobject scaling

* updated vectorized_mobject scale docstring text
2026-04-19 16:10:14 -04:00
GoThrones
c45724989d
Refactor`Mobject.put_start_and_end_on() to shift Mobject to start when it's a closed curve (#4658)
* fix: preserve geometry in put_start_and_end_on for zero-vector mobjects

* fix: preserve geometry in put_start_and_end_on for zero-vector mobjects

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fixed asser_array_equal line

* removed commets in mobject.py and opengl_mobject.py as suggested

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* added stacklevel=2 suggestion

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
2026-04-14 19:57:06 +00:00
dependabot[bot]
af70b6fef2
chore(deps-dev): bump requests from 2.32.5 to 2.33.0 (#4659)
Bumps [requests](https://github.com/psf/requests) from 2.32.5 to 2.33.0.
- [Release notes](https://github.com/psf/requests/releases)
- [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md)
- [Commits](https://github.com/psf/requests/compare/v2.32.5...v2.33.0)

---
updated-dependencies:
- dependency-name: requests
  dependency-version: 2.33.0
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 01:57:29 +00:00
dependabot[bot]
4b32312dd1
chore(deps): bump tornado from 6.5.4 to 6.5.5 (#4635)
Bumps [tornado](https://github.com/tornadoweb/tornado) from 6.5.4 to 6.5.5.
- [Changelog](https://github.com/tornadoweb/tornado/blob/master/docs/releases.rst)
- [Commits](https://github.com/tornadoweb/tornado/compare/v6.5.4...v6.5.5)

---
updated-dependencies:
- dependency-name: tornado
  dependency-version: 6.5.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
2026-04-07 01:43:47 +00:00
GoThrones
90141df105
Change VMobject._bezier_t_values typehint to ndarray of np.float64 (#4675)
Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
2026-04-06 17:02:25 +00:00
Benjamin Hackl
82f93b6c3c
chore: combine dependabot updates for CI retest (#4677)
Includes the changes from PRs #4666, #4668, #4669, #4670, and #4671.

Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
2026-04-06 12:19:30 -04:00
Francisco Manríquez Novoa
752b46a003
Update TinyTeX Windows/macOS installation in ci.yml to fix failing pipelines (#4679)
* Update TinyTeX installation method in CI workflow

* Fix path execution for TinyTex Windows installer
2026-04-06 11:59:03 -04:00
Mingqi Geng
21cf9998cc
Fix IndexError in get_nth_subpath() and ensure it always returns a NumPy array (#4630)
* fix #3569 and #4629

* Add changes to opengl_vectorized_mobject.py

---------

Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
2026-03-18 11:15:45 -03:00
Henrik Skov Midtiby
ebb230f6f1
Update link in pyproject.toml to X / Twitter (#4642) 2026-03-18 13:40:16 +00:00
Xiuyuan (Jack) Yuan
46177d247e
Fix wrong angle ranges in Sphere documentation and add more examples (#3973)
* Made the document changes

* added additional example

* Apply some suggestions from code review

Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>

* Shorten sphere examples

* Fix line breaks and remove trailing whitespace

* Add example for showing portions of spheres

* Undo accidental overwrite of ExampleSphereOverlap

* Change name of manim code block

---------

Co-authored-by: Xiuyuan <u7678992@anu.edu.au>
Co-authored-by: TahitiX <136950383+TahitiX@users.noreply.github.com>
Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
Co-authored-by: Francisco Manríquez Novoa <francisco.manriquezn@usm.cl>
2026-03-18 00:28:52 -03:00
Henrik Skov Midtiby
468929889b
Add type annotations to mobject.py (#4388)
* Add type annotations to `mobject.py`

* More work on type annotations for mobject.py

* ...

* Almost handled all mypy errors

* Add the mypy error messages to the lines that trigger them

* Use typing.cast to avoid some mypy errors, as suggested by JasonGrace2282

* Used the ruff linter

* Fixed one typing issue and added the error descriptions to the source code for the remaning 10 errors

* Set the type of the elements in a VGroup to VMobject

* Use typing.cast to handle some specific cases.

* Code cleaning

* Updates

* When started to use typing.cast it is needed to import / define certain elements during runtime and not only during type checking

* Fix bug introduced with the type annotations.

* Made it work again

* ..

* Fixed more issues.

* Code cleanup

* Code cleanup.

Replace self.lines with self.lines_chars and self.lines_alignment.

* Handle slicing when accessing elements inside a VGroup

* Fix missing issues.

* Silence the last mypy error

* Make _Updater, _NonTimeBasedUpdater and _TimeBasedUpdater private.

* Replace | with Union[...] in one location

* Move import of Union

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Remove comment that is no longer relevant

* Overload the auto_zoom method in MovingCamera to provide better type hinting

* Codecleanup

* Code cleanup

* Renamed lines_alignment to lines_alignments and added a TODO about a future cleanup task

* More code cleanup

* Update manim/mobject/matrix.py

Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Addressing multiple comments from Chopan50

* Implementing more suggestions from Chopan50

* More suggestions by Chopan50

* ...

* More renaming and code cleanup, as suggested by Chopan50

* Restructure code

* anim_args

* Update typing in vector_space_scene

* Implemented a number of suggestions from chopan50

* Make a list of faces with the type list[ThreeDVMobject]

* Ensure to return a VGroup if slicing is used.

* Revert back to the original code in text_mobject.py

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Add the propagate_colors option to Mobject as it is used by VMobject.

Replace Self with _UpdateBuilder

* Simplify code

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Francisco Manríquez Novoa <49853152+chopan050@users.noreply.github.com>
2026-03-11 20:33:21 -03:00
xuruidong
98c458b6b2
docs(quickstart): fix incorrect terminology for SquareToCircle (#4631) 2026-03-08 09:47:57 +01:00
Colin Belhomme
d4af5b2baa
fix: fix cast crash in OpenGLMobject.arrange_in_grid() and OpenGLPoint.get_location() (#4622)
Added quotes to Vector3D and to Point3D to match other calls to `cast`, such has Chopan suggested

Issue #4550: https://github.com/ManimCommunity/manim/issues/4550

Co-authored-by: Colin Belhomme <colin.belhomme@telecom-paris.fr>
2026-03-03 23:48:31 -03:00
280 changed files with 18160 additions and 9237 deletions

View file

@ -1 +0,0 @@
BasedOnStyle: Microsoft

View file

@ -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
@ -129,7 +129,7 @@ jobs:
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'
@ -137,8 +137,8 @@ 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
tlmgr install $tinyTexPackages

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
uses: docker/setup-buildx-action@v4
- 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
uses: docker/setup-buildx-action@v4
- 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

@ -33,7 +33,7 @@ jobs:
uv publish
- name: Store artifacts
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
path: dist/*.tar.gz
name: manim.tar.gz

View file

@ -33,7 +33,7 @@ jobs:
babel-english ctex doublestroke dvisvgm frcursive fundus-calligra jknapltx \
mathastext microtype physics preview ragged2e relsize rsfs setspace standalone \
wasy wasysym
uv sync
uv sync --extra typst
- name: Build and package documentation
run: |
@ -43,7 +43,7 @@ jobs:
tar -czvf ../html-docs.tar.gz *
- name: Store artifacts
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
path: ${{ github.workspace }}/docs/build/html-docs.tar.gz
name: html-docs.tar.gz

View file

@ -34,7 +34,7 @@ Manim is an animation engine for explanatory math videos. It's used to create pr
## Installation
> [!WARNING]
> [!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
@ -71,7 +71,7 @@ In order to view the output of this scene, save the code in a file called `examp
manim -p -ql example.py SquareToCircle
```
You should see a window pop up and play a simple scene in which a square is transformed into a circle. You may find some more simple examples within this
You should see your native video player program pop up and play a simple scene in which a square is transformed into a circle. You may find some more simple examples within this
[GitHub repository](example_scenes). You can also visit the [official gallery](https://docs.manim.community/en/stable/examples.html) for more advanced examples.
Manim also ships with a `%%manim` IPython magic which allows to use it conveniently in JupyterLab (as well as classic Jupyter) notebooks. See the
@ -84,8 +84,7 @@ The general usage of Manim is as follows:
![manim-illustration](https://raw.githubusercontent.com/ManimCommunity/manim/main/docs/source/_static/command.png)
The `-p` flag in the command above is for previewing, meaning a window will show up to render it in real time.
The `-ql` flag is for a faster rendering at a lower quality.
The `-p` flag in the command above is for previewing, meaning the video file will automatically open when it is done rendering. The `-ql` flag is for a faster rendering at a lower quality.
Some other useful flags include:

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

@ -19,7 +19,7 @@ msgid "Bases: :py:class:`manim.mobject.three_d.three_dimensions.Cylinder`"
msgstr ""
#: ../../../manim/mobject/three_d/three_dimensions.py:docstring of manim.mobject.three_d.three_dimensions.Line3D:1
msgid "A cylindrical line."
msgid "A cylindrical line, for use in ThreeDScene."
msgstr ""
#: ../../../manim/mobject/three_d/three_dimensions.py:docstring of manim.mobject.three_d.three_dimensions.Line3D:4

View file

@ -35,7 +35,7 @@ msgid "A spherical dot."
msgstr ""
#: ../../source/reference/manim.mobject.three_d.three_dimensions.rst:40:<autosummary>:1
msgid "A cylindrical line."
msgid "A cylindrical line, for use in ThreeDScene."
msgstr ""
#: ../../source/reference/manim.mobject.three_d.three_dimensions.rst:40:<autosummary>:1

View file

@ -0,0 +1,97 @@
msgid ""
msgstr ""
"Project-Id-Version: Manim \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: ../../source/reference/manim.scene.three_d_scene.SpecialThreeDScene.rst:2
msgid "SpecialThreeDScene"
msgstr ""
#: ../../source/reference/manim.scene.three_d_scene.SpecialThreeDScene.rst:4
msgid "Qualified name: ``manim.scene.three\\_d\\_scene.SpecialThreeDScene``"
msgstr ""
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.SpecialThreeDScene:1
msgid "Bases: :py:class:`manim.scene.three_d_scene.ThreeDScene`"
msgstr ""
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.SpecialThreeDScene:1
msgid "An extension of :class:`ThreeDScene` with more settings."
msgstr ""
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.SpecialThreeDScene:3
msgid "It has some extra configuration for axes, spheres, and an override for low quality rendering. Further key differences are:"
msgstr ""
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.SpecialThreeDScene:7
msgid "The camera shades applicable 3DMobjects by default, except if rendering in low quality."
msgstr ""
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.SpecialThreeDScene:9
msgid "Some default params for Spheres and Axes have been added."
msgstr ""
#: ../../source/reference/manim.scene.three_d_scene.SpecialThreeDScene.rst:14
msgid "Methods"
msgstr ""
#: ../../source/reference/manim.scene.three_d_scene.SpecialThreeDScene.rst:23:<autosummary>:1
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.SpecialThreeDScene.get_axes:1
msgid "Return a set of 3D axes."
msgstr ""
#: ../../source/reference/manim.scene.three_d_scene.SpecialThreeDScene.rst:23:<autosummary>:1
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.SpecialThreeDScene.get_default_camera_position:1
msgid "Returns the default_angled_camera position."
msgstr ""
#: ../../source/reference/manim.scene.three_d_scene.SpecialThreeDScene.rst:23:<autosummary>:1
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.SpecialThreeDScene.get_sphere:1
msgid "Returns a sphere with the passed keyword arguments as properties."
msgstr ""
#: ../../source/reference/manim.scene.three_d_scene.SpecialThreeDScene.rst:23:<autosummary>:1
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.SpecialThreeDScene.set_camera_to_default_position:1
msgid "Sets the camera to its default position."
msgstr ""
#: ../../source/reference/manim.scene.three_d_scene.SpecialThreeDScene.rst:25
msgid "Attributes"
msgstr ""
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.SpecialThreeDScene.get_axes:0
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.SpecialThreeDScene.get_default_camera_position:0
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.SpecialThreeDScene.get_sphere:0
msgid "Returns"
msgstr ""
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.SpecialThreeDScene.get_axes:3
msgid "A set of 3D axes."
msgstr ""
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.SpecialThreeDScene.get_axes:0
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.SpecialThreeDScene.get_default_camera_position:0
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.SpecialThreeDScene.get_sphere:0
msgid "Return type"
msgstr ""
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.SpecialThreeDScene.get_default_camera_position:3
msgid "Dictionary of phi, theta, focal_distance, and gamma."
msgstr ""
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.SpecialThreeDScene.get_sphere:0
msgid "Parameters"
msgstr ""
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.SpecialThreeDScene.get_sphere:3
msgid "Any valid parameter of :class:`~.Sphere` or :class:`~.Surface`."
msgstr ""
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.SpecialThreeDScene.get_sphere:5
msgid "The sphere object."
msgstr ""

View file

@ -0,0 +1,210 @@
msgid ""
msgstr ""
"Project-Id-Version: Manim \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: ../../source/reference/manim.scene.three_d_scene.ThreeDScene.rst:2
msgid "ThreeDScene"
msgstr ""
#: ../../source/reference/manim.scene.three_d_scene.ThreeDScene.rst:4
msgid "Qualified name: ``manim.scene.three\\_d\\_scene.ThreeDScene``"
msgstr ""
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.ThreeDScene:1
msgid "Bases: :py:class:`manim.scene.scene.Scene`"
msgstr ""
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.ThreeDScene:1
msgid "This is a Scene, with special configurations and properties that make it suitable for Three Dimensional Scenes."
msgstr ""
#: ../../source/reference/manim.scene.three_d_scene.ThreeDScene.rst:14
msgid "Methods"
msgstr ""
#: ../../source/reference/manim.scene.three_d_scene.ThreeDScene.rst:31:<autosummary>:1
msgid "This method is used to prevent the rotation and movement of mobjects as the camera moves around."
msgstr ""
#: ../../source/reference/manim.scene.three_d_scene.ThreeDScene.rst:31:<autosummary>:1
msgid "This method is used to prevent the rotation and tilting of mobjects as the camera moves around."
msgstr ""
#: ../../source/reference/manim.scene.three_d_scene.ThreeDScene.rst:31:<autosummary>:1
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.ThreeDScene.begin_3dillusion_camera_rotation:1
msgid "This method creates a 3D camera rotation illusion around the current camera orientation."
msgstr ""
#: ../../source/reference/manim.scene.three_d_scene.ThreeDScene.rst:31:<autosummary>:1
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.ThreeDScene.begin_ambient_camera_rotation:1
msgid "This method begins an ambient rotation of the camera about the Z_AXIS, in the anticlockwise direction"
msgstr ""
#: ../../source/reference/manim.scene.three_d_scene.ThreeDScene.rst:31:<autosummary>:1
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.ThreeDScene.get_moving_mobjects:1
msgid "This method returns a list of all of the Mobjects in the Scene that are moving, that are also in the animations passed."
msgstr ""
#: ../../source/reference/manim.scene.three_d_scene.ThreeDScene.rst:31:<autosummary>:1
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.ThreeDScene.move_camera:1
msgid "This method animates the movement of the camera to the given spherical coordinates."
msgstr ""
#: ../../source/reference/manim.scene.three_d_scene.ThreeDScene.rst:31:<autosummary>:1
msgid "This method undoes what add_fixed_in_frame_mobjects does."
msgstr ""
#: ../../source/reference/manim.scene.three_d_scene.ThreeDScene.rst:31:<autosummary>:1
msgid "This method \"unfixes\" the orientation of the mobjects passed, meaning they will no longer be at the same angle relative to the camera."
msgstr ""
#: ../../source/reference/manim.scene.three_d_scene.ThreeDScene.rst:31:<autosummary>:1
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.ThreeDScene.set_camera_orientation:1
msgid "This method sets the orientation of the camera in the scene."
msgstr ""
#: ../../source/reference/manim.scene.three_d_scene.ThreeDScene.rst:31:<autosummary>:1
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.ThreeDScene.set_to_default_angled_camera_orientation:1
msgid "This method sets the default_angled_camera_orientation to the keyword arguments passed, and sets the camera to that orientation."
msgstr ""
#: ../../source/reference/manim.scene.three_d_scene.ThreeDScene.rst:31:<autosummary>:1
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.ThreeDScene.stop_3dillusion_camera_rotation:1
msgid "This method stops all illusion camera rotations."
msgstr ""
#: ../../source/reference/manim.scene.three_d_scene.ThreeDScene.rst:31:<autosummary>:1
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.ThreeDScene.stop_ambient_camera_rotation:1
msgid "This method stops all ambient camera rotation."
msgstr ""
#: ../../source/reference/manim.scene.three_d_scene.ThreeDScene.rst:33
msgid "Attributes"
msgstr ""
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.ThreeDScene.add_fixed_in_frame_mobjects:1
msgid "This method is used to prevent the rotation and movement of mobjects as the camera moves around. The mobject is essentially overlaid, and is not impacted by the camera's movement in any way."
msgstr ""
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.ThreeDScene.add_fixed_in_frame_mobjects:0
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.ThreeDScene.add_fixed_orientation_mobjects:0
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.ThreeDScene.begin_3dillusion_camera_rotation:0
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.ThreeDScene.begin_ambient_camera_rotation:0
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.ThreeDScene.get_moving_mobjects:0
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.ThreeDScene.move_camera:0
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.ThreeDScene.remove_fixed_in_frame_mobjects:0
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.ThreeDScene.remove_fixed_orientation_mobjects:0
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.ThreeDScene.set_camera_orientation:0
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.ThreeDScene.set_to_default_angled_camera_orientation:0
msgid "Parameters"
msgstr ""
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.ThreeDScene.add_fixed_in_frame_mobjects:6
msgid "The Mobjects whose orientation must be fixed."
msgstr ""
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.ThreeDScene.add_fixed_orientation_mobjects:1
msgid "This method is used to prevent the rotation and tilting of mobjects as the camera moves around. The mobject can still move in the x,y,z directions, but will always be at the angle (relative to the camera) that it was at when it was passed through this method.)"
msgstr ""
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.ThreeDScene.add_fixed_orientation_mobjects:7
msgid "The Mobject(s) whose orientation must be fixed."
msgstr ""
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.ThreeDScene.add_fixed_orientation_mobjects:9
msgid "Some valid kwargs are use_static_center_func : bool center_func : function"
msgstr ""
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.ThreeDScene.add_fixed_orientation_mobjects:11
msgid "Some valid kwargs are"
msgstr ""
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.ThreeDScene.add_fixed_orientation_mobjects:11
msgid "use_static_center_func : bool center_func : function"
msgstr ""
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.ThreeDScene.begin_3dillusion_camera_rotation:4
msgid "The rate at which the camera rotation illusion should operate."
msgstr ""
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.ThreeDScene.begin_3dillusion_camera_rotation:5
msgid "The polar angle the camera should move around. Defaults to the current phi angle."
msgstr ""
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.ThreeDScene.begin_3dillusion_camera_rotation:7
msgid "The azimutal angle the camera should move around. Defaults to the current theta angle."
msgstr ""
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.ThreeDScene.begin_ambient_camera_rotation:4
msgid "The rate at which the camera should rotate about the Z_AXIS. Negative rate means clockwise rotation."
msgstr ""
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.ThreeDScene.begin_ambient_camera_rotation:7
msgid "one of 3 options: [\"theta\", \"phi\", \"gamma\"]. defaults to theta."
msgstr ""
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.ThreeDScene.get_moving_mobjects:4
msgid "The animations whose mobjects will be checked."
msgstr ""
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.ThreeDScene.move_camera:4
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.ThreeDScene.set_camera_orientation:3
msgid "The polar angle i.e the angle between Z_AXIS and Camera through ORIGIN in radians."
msgstr ""
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.ThreeDScene.move_camera:6
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.ThreeDScene.set_camera_orientation:5
msgid "The azimuthal angle i.e the angle that spins the camera around the Z_AXIS."
msgstr ""
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.ThreeDScene.move_camera:8
msgid "The radial focal_distance between ORIGIN and Camera."
msgstr ""
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.ThreeDScene.move_camera:10
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.ThreeDScene.set_camera_orientation:9
msgid "The rotation of the camera about the vector from the ORIGIN to the Camera."
msgstr ""
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.ThreeDScene.move_camera:12
msgid "The zoom factor of the camera."
msgstr ""
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.ThreeDScene.move_camera:14
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.ThreeDScene.set_camera_orientation:13
msgid "The new center of the camera frame in cartesian coordinates."
msgstr ""
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.ThreeDScene.move_camera:16
msgid "Any other animations to be played at the same time."
msgstr ""
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.ThreeDScene.remove_fixed_in_frame_mobjects:1
msgid "This method undoes what add_fixed_in_frame_mobjects does. It allows the mobject to be affected by the movement of the camera."
msgstr ""
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.ThreeDScene.remove_fixed_in_frame_mobjects:5
msgid "The Mobjects whose position and orientation must be unfixed."
msgstr ""
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.ThreeDScene.remove_fixed_orientation_mobjects:1
msgid "This method \"unfixes\" the orientation of the mobjects passed, meaning they will no longer be at the same angle relative to the camera. This only makes sense if the mobject was passed through add_fixed_orientation_mobjects first."
msgstr ""
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.ThreeDScene.remove_fixed_orientation_mobjects:6
msgid "The Mobjects whose orientation must be unfixed."
msgstr ""
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.ThreeDScene.set_camera_orientation:7
msgid "The focal_distance of the Camera."
msgstr ""
#: ../../../manim/scene/three_d_scene.py:docstring of manim.scene.three_d_scene.ThreeDScene.set_camera_orientation:11
msgid "The zoom factor of the scene."
msgstr ""

View file

@ -5,3 +5,4 @@ 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

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

View file

@ -1,97 +0,0 @@
# Migrating from v0.19.0 to v0.20.0
This constitutes a list of all the changes needed to migrate your code
to work with the latest version of Manim
## Manager
If you ever used `Scene.render`, you must replace it with {class}`.Manager`.
Original code:
```py
scene = SceneClass()
scene.render()
```
should be changed to:
```py
with Manager(SceneClass) as manager:
manager.render()
```
If you are a plugin author that subclasses `Scene` and changed `Scene.render`, you should migrate
your code to use the specific public methods on {class}`.Manager` instead.
## ThreeDScene and Camera
`ThreeDScene` has been completely removed, and all of its functionality has been replaced
with methods on {class}`.Camera`, which can be accessed via {attr}`.Scene.camera`.
For example, the following code
```py
class MyScene(ThreeDScene):
def construct(self):
t = Text("Hello")
self.add_fixed_in_frame_mobjects(t)
self.begin_ambient_camera_rotation()
self.wait(3)
```
should be changed to
```py
# change ThreeDScene -> Scene
class MyScene(Scene):
def construct(self):
t = Text("Hello")
# add_fixed_in_frame_mobjects() no longer exists.
# Now you must use Mobject.fix_in_frame() manually for each Mobject.
t.fix_in_frame()
self.add(t)
# access the method on the camera
self.camera.begin_ambient_rotation()
self.add(self.camera)
self.wait(3)
```
## Animation
`Animation.interpolate_mobject` has been combined into `Animation.interpolate`.
Methods `Animation._setup_scene` and `Animation.clean_up_from_scene` have been removed
in favor of `Animation.begin` and `Animation.finish`. If you need to access the scene,
you can use a simple buffer to communicate. Note that this buffer cannot access
methods on the {class}`.Scene`, but can only do basic actions like {meth}`.Scene.add`,
{meth}`.Scene.remove`, and {meth}`.Scene.replace`.
For example, the following code:
```py
class MyAnimation(Animation):
def begin(self) -> None:
self._sqrs = VGroup(Square())
def _setup_scene(self, scene: Scene) -> None:
scene.add(self._sqrs)
self.scene = scene
def interpolate_mobject(self, alpha: float) -> None:
sqr = Square().move_to((alpha, 0, 0))
self._sqrs.add(sqr)
self.scene.add(sqr)
def clean_up_from_scene(self, scene: Scene) -> None:
scene.remove(self._sqrs)
```
should be changed to
```py
class MyAnimation(Animation):
def begin(self) -> None:
self._sqrs = VGroup(Square())
self.buffer.add(self._sqrs)
def interpolate(self, alpha: float) -> None:
sqr = Square().move_to((alpha, 0, 0))
self._sqrs.add(sqr)
self.buffer.add(sqr)
# tell the scene to empty the buffer
self.apply_buffer = True
def finish(self) -> None:
self.buffer.remove(self._sqrs)
```

View file

@ -24,8 +24,8 @@ to the bottom of the file:
.. code-block:: python
with tempconfig({"quality": "medium_quality", "disable_caching": True}):
manager = Manager(SceneName)
manager.render()
scene = SceneName()
scene.render()
Where ``SceneName`` is the name of the scene you want to run. You can then run the
file directly, and can thus follow the instructions for most profilers.
@ -58,8 +58,8 @@ to ``square_to_circle.py``:
with tempconfig({"quality": "medium_quality", "disable_caching": True}):
manager = Manager(SquareToCircle)
manager.render()
scene = SquareToCircle()
scene.render()
Now run the following in the terminal:

View file

@ -213,11 +213,11 @@ The decorator can be used with or without parentheses. **By default, the test on
circle = Circle()
scene.play(Animation(circle))
You can also specify, when needed, which base scene you need (VectorScene, for example) :
You can also specify, when needed, which base scene you need (ThreeDScene, for example) :
.. code:: python
@frames_comparison(last_frame=False, base_scene=VectorScene)
@frames_comparison(last_frame=False, base_scene=ThreeDScene)
def test_circle(scene):
circle = Circle()
scene.play(Animation(circle))

View file

@ -597,25 +597,25 @@ Special Camera Settings
.. manim:: FixedInFrameMObjectTest
:save_last_frame:
:ref_classes: Scene
:ref_methods: Camera.set_orientation OpenGLMobject.fix_in_frame
:ref_classes: ThreeDScene
:ref_methods: ThreeDScene.set_camera_orientation ThreeDScene.add_fixed_in_frame_mobjects
class FixedInFrameMObjectTest(Scene):
class FixedInFrameMObjectTest(ThreeDScene):
def construct(self):
axes = ThreeDAxes()
self.camera.set_orientation(theta=-45 * DEGREES, phi=75 * DEGREES)
self.set_camera_orientation(phi=75 * DEGREES, theta=-45 * DEGREES)
text3d = Text("This is a 3D text")
text3d.fix_in_frame()
self.add_fixed_in_frame_mobjects(text3d)
text3d.to_corner(UL)
self.add(axes)
self.wait()
.. manim:: ThreeDLightSourcePosition
:save_last_frame:
:ref_classes: Scene ThreeDAxes Surface
:ref_methods: Camera.set_orientation
:ref_classes: ThreeDScene ThreeDAxes Surface
:ref_methods: ThreeDScene.set_camera_orientation
class ThreeDLightSourcePosition(Scene):
class ThreeDLightSourcePosition(ThreeDScene):
def construct(self):
axes = ThreeDAxes()
sphere = Surface(
@ -626,57 +626,49 @@ Special Camera Settings
]), v_range=[0, TAU], u_range=[-PI / 2, PI / 2],
checkerboard_colors=[RED_D, RED_E], resolution=(15, 32)
)
# TODO: implement light source
self.camera.light_source.move_to(3*IN) # changes the source of the light
self.camera.set_orientation(theta=30 * DEGREES, phi=75 * DEGREES)
self.renderer.camera.light_source.move_to(3*IN) # changes the source of the light
self.set_camera_orientation(phi=75 * DEGREES, theta=30 * DEGREES)
self.add(axes, sphere)
.. manim:: ThreeDCameraRotation
:ref_classes: Circle Scene ThreeDAxes
:ref_methods: Camera.begin_ambient_rotation Camera.stop_ambient_rotation
:ref_classes: ThreeDScene ThreeDAxes
:ref_methods: ThreeDScene.begin_ambient_camera_rotation ThreeDScene.stop_ambient_camera_rotation
class ThreeDCameraRotation(Scene):
class ThreeDCameraRotation(ThreeDScene):
def construct(self):
axes = ThreeDAxes()
circle = Circle()
self.camera.set_orientation(theta=30 * DEGREES, phi=75 * DEGREES)
circle=Circle()
self.set_camera_orientation(phi=75 * DEGREES, theta=30 * DEGREES)
self.add(circle,axes)
self.camera.begin_ambient_rotation(rate=0.1)
self.add(self.camera)
self.begin_ambient_camera_rotation(rate=0.1)
self.wait()
self.camera.stop_ambient_rotation()
self.play(
self.camera.animate.set_orientation(
theta=30 * DEGREES, phi=75 * DEGREES
),
)
self.stop_ambient_camera_rotation()
self.move_camera(phi=75 * DEGREES, theta=30 * DEGREES)
self.wait()
.. manim:: ThreeDCameraPrecession
:ref_classes: Circle Scene ThreeDAxes
:ref_methods: Camera.begin_precession Camera.stop_precession
.. manim:: ThreeDCameraIllusionRotation
:ref_classes: ThreeDScene ThreeDAxes
:ref_methods: ThreeDScene.begin_3dillusion_camera_rotation ThreeDScene.stop_3dillusion_camera_rotation
class ThreeDCameraPrecession(Scene):
class ThreeDCameraIllusionRotation(ThreeDScene):
def construct(self):
axes = ThreeDAxes()
circle = Circle()
self.camera.set_orientation(theta=30 * DEGREES, phi=75 * DEGREES)
circle=Circle()
self.set_camera_orientation(phi=75 * DEGREES, theta=30 * DEGREES)
self.add(circle,axes)
self.camera.begin_precession(rate=2)
self.add(self.camera)
self.begin_3dillusion_camera_rotation(rate=2)
self.wait(PI/2)
self.camera.stop_precession()
self.stop_3dillusion_camera_rotation()
.. manim:: ThreeDSurfacePlot
:save_last_frame:
:ref_classes: Scene Surface
:ref_methods: Camera.set_orientation
:ref_classes: ThreeDScene Surface
class ThreeDSurfacePlot(Scene):
class ThreeDSurfacePlot(ThreeDScene):
def construct(self):
resolution_fa = 24
self.camera.set_orientation(theta=-30 * DEGREES, phi=75 * DEGREES)
self.set_camera_orientation(phi=75 * DEGREES, theta=-30 * DEGREES)
def param_gauss(u, v):
x = u

View file

@ -357,10 +357,10 @@ A list of all config options
'log_dir', 'log_to_file', 'max_files_cached', 'media_dir', 'media_width',
'movie_file_extension', 'notify_outdated_version', 'output_file', 'partial_movie_dir',
'pixel_height', 'pixel_width', 'plugins', 'preview',
'progress_bar', 'quality', 'right_side', 'save_last_frame',
'scene_names', 'show_in_file_browser', 'sound', 'tex_dir',
'progress_bar', 'quality', 'right_side', 'save_as_gif', 'save_last_frame',
'save_pngs', 'scene_names', 'show_in_file_browser', 'sound', 'tex_dir',
'tex_template', 'tex_template_file', 'text_dir', 'top', 'transparent',
'upto_animation_number', 'verbosity', 'video_dir',
'upto_animation_number', 'use_opengl_renderer', 'verbosity', 'video_dir',
'window_position', 'window_monitor', 'window_size', 'write_all', 'write_to_movie',
'enable_wireframe', 'force_window']

View file

@ -1,11 +1,11 @@
A deep dive into Manim's internals
==================================
**Authors:** `Benjamin Hackl <https://benjamin-hackl.at>`__ and `Aarush Deshpande <https://github.com/JasonGrace2282>`__
**Author:** `Benjamin Hackl <https://benjamin-hackl.at>`__
.. admonition:: Disclaimer
This guide reflects the state of the library as of version ``v0.20.0``
This guide reflects the state of the library as of version ``v0.16.0``
and primarily treats the Cairo renderer. The situation in the latest
version of Manim might be different; in case of substantial deviations
we will add a note below.
@ -84,7 +84,7 @@ discussing the contents of the following chapters on a very high level.
to prepare a scene for rendering; right until the point where the user-overridden
``construct`` method is ran. This includes a brief discussion on using Manim's CLI
versus other means of rendering (e.g., via Jupyter notebooks, or in your Python
script by calling the :meth:`.Manager.render` method yourself).
script by calling the :meth:`.Scene.render` method yourself).
- `Mobject Initialization`_: For the second chapter we dive into creating and handling
Mobjects, the basic elements that should be displayed in our scene.
We discuss the :class:`.Mobject` base class, how there are essentially
@ -107,25 +107,6 @@ discussing the contents of the following chapters on a very high level.
:meth:`.Scene.construct` has been run, the library combines the partial movie
files to one video.
.. hint::
As we move forward, try to keep in mind the responsibilities of every
class we introduce. We'll talk more about them in detail, but here's a brief
overview
* :class:`.Scene` is responsible for managing the classes :class:`Mobject`, :class:`.Animation`,
and :class:`.Camera`.
* :class:`.Manager` is responsible for coordinating the :class:`.Scene`, :class:`.Renderer`,
and :class:`.FileWriter`.
* :class:`.FileWriter` is responsible for writing frames and partial movie files, as well
as combining them all into a final movie file.
* :class:`.Renderer` is an abstract class which has to be subclassed.
It's job is to take information related to the :class:`.Camera`, and the mobjects
on the :class:`.Scene` at a certain frame, and to return the pixels in a frame.
And with that, let us get *in medias res*.
Preliminaries
@ -142,8 +123,8 @@ like
::
with tempconfig({"quality": "medium_quality", "preview": True}):
manager = Manager(ToyExample)
manager.render()
scene = ToyExample()
scene.render()
or whether you are rendering the code in a Jupyter notebook, you are still telling your
python interpreter to import the library. The usual pattern used to do this is
@ -221,8 +202,8 @@ have created a file ``toy_example.py`` which looks like this::
self.play(FadeOut(blue_circle, small_dot))
with tempconfig({"quality": "medium_quality", "preview": True}):
manager = Manager(ToyExample)
manager.render()
scene = ToyExample()
scene.render()
With such a file, the desired scene is rendered by simply running this Python
script via ``python toy_example.py``. Then, as described above, the library
@ -237,10 +218,10 @@ dictionary, and upon leaving the context the original version of the
configuration is restored. TL;DR: it provides a fancy way of temporarily setting
configuration options.
Inside the context manager, two things happen: a :class:`.Manager` is created for
the ``ToyExample``-scene, and the ``render`` method is called. Every way of using
Inside the context manager, two things happen: an actual ``ToyExample``-scene
object is instantiated, and the ``render`` method is called. Every way of using
Manim ultimately does something along of these lines, the library always instantiates
the manager of the scene object and then calls its ``render`` method. To illustrate that this
the scene object and then calls its ``render`` method. To illustrate that this
really is the case, let us briefly look at the two most common ways of rendering
scenes:
@ -262,75 +243,54 @@ and the code creating the scene class and calling its render method is located
`here <https://github.com/ManimCommunity/manim/blob/ac1ee9a683ce8b92233407351c681f7d71a4f2db/manim/utils/ipython_magic.py#L137-L138>`__.
Now that we know that either way, a :class:`.Manager` for a :class:`.Scene` object is created, let us investigate
what Manim does when that happens. When instantiating our manager
Now that we know that either way, a :class:`.Scene` object is created, let us investigate
what Manim does when that happens. When instantiating our scene object
::
manager = Manager(ToyExample)
scene = ToyExample()
The :meth:`.Manager.__init__` method is called. Looking at the source code (`here <https://github.com/ManimCommunity/manim/blob/experimental/manim/manager.py>`__),
we see that the :meth:`.Scene.__init__` method is called,
given that we did not implement our own initialization
method. Inspecting the corresponding code (see `here <https://github.com/ManimCommunity/manim/blob/main/manim/scene/scene.py>`__)
reveals that :class:`Scene.__init__` first sets several attributes of the scene objects that do not
depend on any configuration options set in ``config``. It then initializes it's :class:`.Camera`.
The purpose of a :class:`.Camera` is to keep track of what you can see in the scene. Think of it
as a pair of eyes, that limit how far you can look sideways and vertically.
the ``Scene.__init__`` method is called, given that we did not implement our own initialization
method. Inspecting the corresponding code (see
`here <https://github.com/ManimCommunity/manim/blob/main/manim/scene/scene.py>`__)
reveals that ``Scene.__init__`` first sets several attributes of the scene objects that do not
depend on any configuration options set in ``config``. Then the scene inspects the value of
``config.renderer``, and based on its value, either instantiates a ``CairoRenderer`` or an
``OpenGLRenderer`` object and assigns it to its ``renderer`` attribute.
The :class:`.Scene` also sets up :attr:`.Scene.mobjects`. This attribute keeps track of all the :class:`.Mobject`
that have been added to the scene.
The scene then asks its renderer to initialize the scene by calling
The :class:`.Manager` then continues on to create a :class:`.Window`, which is the popopen interactive window,
and creates the renderer::
::
self.renderer = self.create_renderer()
self.renderer.use_window()
self.renderer.init_scene(self)
If you hover over :attr:`.Manager.renderer`, you might see that the type is a :class:`.RendererProtocol`.
A :class:`~typing.Protocol` is a contract for a class. It says that whatever the class is, it will implement
the methods defined inside the protocol. In this case, it means that the renderer will have all the methods
defined in :class:`.RendererProtocol`.
Inspecting both the default Cairo renderer and the OpenGL renderer shows that the ``init_scene``
method effectively makes the renderer instantiate a :class:`.SceneFileWriter` object, which
basically is Manim's interface to ``libav`` (FFMPEG) and actually writes the movie file. The Cairo
renderer (see the implementation `here <https://github.com/ManimCommunity/manim/blob/main/manim/renderer/cairo_renderer.py>`__) does not require any further initialization. The OpenGL renderer
does some additional setup to enable the realtime rendering preview window, which we do not go
into detail further here.
.. note::
.. warning::
The point of using :class:`~typing.Protocol` is so that in the future, plugins
can swap out the renderer with their own version - either for speed, or for a different
behavior.
Currently, there is a lot of interplay between a scene and its renderer. This is a flaw
in Manim's current architecture, and we are working on reducing this interdependency to
achieve a less convoluted code flow.
For the rest of this article to take a concrete example, we'll use :class:`.OpenGLRenderer`.
Finally, the :class:`.Manager` creates a :class:`.FileWriter`. This is the object that actually
writes the partial movie files.
After the renderer has been instantiated and initialized its file writer, the scene populates
further initial attributes (notable mention: the ``mobjects`` attribute which keeps track
of the mobjects that have been added to the scene). It is then done with its instantiation
and ready to be rendered.
The rest of this article is concerned with the last line in our toy example script::
manager.render()
scene.render()
This is where the actual magic happens.
.. note::
TODO TO REVIEWERS - Replace this link with the proper permanent link
Inspecting the `implementation of the render method <https://github.com/ManimCommunity/manim/blob/df1a60421ea1119cbbbd143ef288d294851baaac/manim/scene/scene.py#L211>`__
we see that there are two passes of rendering.
.. note::
As of the experimental branch at June 30th, 2024, two pass rendering
does not exist. This will proceed to explain the single pass rendering system.
Looking around, we find that there are several hooks that can be used for pre- or postprocessing
a scene (check out :meth:`.Manager._setup`, and :meth:`.Manager._tear_down`).
.. note::
You might notice :attr:`.Manager.virtual_animation_start_time` and :attr:`.Manager.real_animation_start_time`
when looking through :meth:`.Manager._setup`. These will be explained later.
Unsurprisingly, :meth:`.Manager.render` describes the full *render cycle*
reveals that there are several hooks that can be used for pre- or postprocessing
a scene. Unsurprisingly, :meth:`.Scene.render` describes the full *render cycle*
of a scene. During this life cycle, there are three custom methods whose base
implementation is empty and that can be overwritten to suit your purposes. In
the order they are called, these customizable methods are:
@ -348,14 +308,14 @@ the order they are called, these customizable methods are:
Python scripts).
After these three methods are run, the animations have been fully rendered,
and Manim calls :meth:`.Manager.tear_down` to gracefully
and Manim calls :meth:`.CairoRenderer.scene_finished` to gracefully
complete the rendering process. This checks whether any animations have been
played -- and if so, it tells the :class:`.SceneFileWriter` to close the output
file. If not, Manim assumes that a static image should be output
which it then renders using the same strategy by calling the render loop
(see below) once.
**Back in our toy example,** the call to :meth:`.Manager.render` first
**Back in our toy example,** the call to :meth:`.Scene.render` first
triggers :meth:`.Scene.setup` (which only consists of ``pass``), followed by
a call of :meth:`.Scene.construct`. At this point, our *animation script*
is run, starting with the initialization of ``orange_square``.
@ -388,12 +348,16 @@ of :class:`.Mobject`, you will find that not too much happens in there:
- and finally, ``init_colors`` is called.
Digging deeper, you will find that :meth:`.Mobject.reset_points` simply
sets the ``points`` attribute of the mobject to an empty NumPy array,
sets the ``points`` attribute of the mobject to an empty NumPy vector,
while the other two methods, :meth:`.Mobject.generate_points` and
:meth:`.Mobject.init_colors` are just implemented as ``pass``.
This makes sense: :class:`.Mobject` is not supposed to be used as
an *actual* object that is displayed on screen.
an *actual* object that is displayed on screen; in fact the camera
(which we will discuss later in more detail; it is the class that is,
for the Cairo renderer, responsible for "taking a picture" of the
current scene) does not process "pure" :class:`Mobjects <.Mobject>`
in any way, they *cannot* even appear in the rendered output.
This is where different types of mobjects come into play. Roughly
speaking, the Cairo renderer setup knows three different types of
@ -412,24 +376,24 @@ mobjects that can be rendered:
As just mentioned, :class:`VMobjects <.VMobject>` represent vectorized
mobjects. To render a :class:`.VMobject`, the camera looks at the
:attr:`~.VMobject.points` attribute of a :class:`.VMobject` and divides it into sets
of three points each. Each of these sets is then used to construct a
quadratic Bézier curve with the first and last entry describing the
end points of the curve ("anchors"), and the second entry
describing the control points in between ("handle").
``points`` attribute of a :class:`.VMobject` and divides it into sets
of four points each. Each of these sets is then used to construct a
cubic Bézier curve with the first and last entry describing the
end points of the curve ("anchors"), and the second and third entry
describing the control points in between ("handles").
.. hint::
To learn more about Bézier curves, take a look at the excellent
online textbook `A Primer on Bézier curves <https://pomax.github.io/bezierinfo/>`__
by `Pomax <https://twitter.com/TheRealPomax>`__ -- there is a playground representing
quadratic Bézier curves `in §1 <https://pomax.github.io/bezierinfo/#introduction>`__,
cubic Bézier curves `in §1 <https://pomax.github.io/bezierinfo/#introduction>`__,
the red and yellow points are "anchors", and the green and blue
points are "handles".
In contrast to :class:`.Mobject`, :class:`.VMobject` can be displayed
on screen (even though, technically, it is still considered a base class).
To illustrate how points are processed, consider the following short example
of a :class:`.VMobject` with 6 points (and thus made out of 6/3 = 2 cubic
of a :class:`.VMobject` with 8 points (and thus made out of 8/4 = 2 cubic
Bézier curves). The resulting :class:`.VMobject` is drawn in green.
The handles are drawn as red dots with a line to their closest anchor.
@ -466,7 +430,6 @@ The handles are drawn as red dots with a line to their closest anchor.
.. warning::
Manually setting the points of your :class:`.VMobject` is usually
discouraged; there are specialized methods that can take care of
that for you -- but it might be relevant when implementing your own,
@ -598,12 +561,59 @@ is not a "flat" list of mobjects, but a list of mobjects which
might contain mobjects themselves, and so on.
Stepping through the code in :meth:`.Scene.add`, we see that first
we remove all the mobjects that are being added -- this is to make
sure we don't add a :class:`.Mobject` twice! After that, we can safely
add it to :attr:`.Scene.mobjects`.
it is checked whether we are currently using the OpenGL renderer
(which we are not) -- adding mobjects to the scene works slightly
different (and actually easier!) for the OpenGL renderer. Then, the
code branch for the Cairo renderer is entered and the list of so-called
foreground mobjects (which are rendered on top of all other mobjects)
is added to the list of passed mobjects. This is to ensure that the
foreground mobjects will stay above of the other mobjects, even after
adding the new ones. In our case, the list of foreground mobjects
is actually empty, and nothing changes.
We will hear more from :class:`.Scene` soon.
Before we do that, let us look at the next line
Next, :meth:`.Scene.restructure_mobjects` is called with the list
of mobjects to be added as the ``to_remove`` argument, which might
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.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
discuss later in more detail). It iterates through all of the members of
the list, and checks whether any of the mobjects passed in ``to_remove``
are contained as children (in any nesting level). If so, **their parent
mobject is deconstructed** and their siblings are inserted directly
one level higher. Consider the following example::
>>> from manim import Scene, Square, Circle, Group
>>> test_scene = Scene()
>>> mob1 = Square()
>>> mob2 = Circle()
>>> mob_group = Group(mob1, mob2)
>>> test_scene.add(mob_group)
<manim.scene.scene.Scene object at ...>
>>> test_scene.mobjects
[Group]
>>> test_scene.restructure_mobjects(to_remove=[mob1])
<manim.scene.scene.Scene object at ...>
>>> test_scene.mobjects
[Circle]
Note that the group is disbanded and the circle moves into the
root layer of mobjects in ``test_scene.mobjects``.
After the mobject list is "restructured", the mobject to be added
are simply appended to ``Scene.mobjects``. In our toy example,
the ``Scene.mobjects`` list is actually empty, so the
``restructure_mobjects`` method does not actually do anything. The
``orange_square`` is simply added to ``Scene.mobjects``, and as
the aforementioned ``Scene.moving_mobjects`` list is, at this point,
also still empty, nothing happens and :meth:`.Scene.add` returns.
We will hear more about the ``moving_mobject`` list when we discuss
the render loop. Before we do that, let us look at the next line
of code in our toy example, which includes the initialization of
an animation class,
::
@ -632,11 +642,11 @@ the run time of animations is also fixed and known beforehand.
The initialization of animations actually is not very exciting,
:meth:`.Animation.__init__` merely sets some attributes derived
from the passed keyword arguments and additionally ensures that
the :attr:`~Animation.starting_mobject` and :attr:`~.Animation.mobject`
the ``Animation.starting_mobject`` and ``Animation.mobject``
attributes are populated. Once the animation is played, the
:attr:`~.Animation.starting_mobject` attribute holds an unmodified copy of the
``starting_mobject`` attribute holds an unmodified copy of the
mobject the animation is attached to; during the initialization
it is set to a placeholder mobject. The :attr:`~.Animation.mobject` attribute
it is set to a placeholder mobject. The ``mobject`` attribute
is set to the mobject the animation is attached to.
Animations have a few special methods which are called during the
@ -671,80 +681,77 @@ animation (like its ``run_time``, the ``rate_func``, etc.) are
processed there -- and then the animation object is fully
initialized and ready to be played.
The Animation Buffer
^^^^^^^^^^^^^^^^^^^^
There's an attribute of animations that we have glossed
over, and that is :attr:`.Animation.buffer`, of type :class:`.SceneBuffer`.
The :attr:`~.Animation.buffer` is the animations way of communicating
with what happens on the scene. If you want to modify
the scene during the interpolation stage (outside of :meth:`~.Animation.begin` or :meth:`~.Animation.finish`),
the attribute :attr:`.Animation.apply_buffer` is what tells the scene that the buffer
should be processed.
For example, an animation that adds a circle to the scene every frame might look like this
.. code-block:: python
class CircleAnimation(Animation):
def begin(self) -> None:
self.circles = VGroup()
def interpolate(self, alpha: float) -> None:
# create and arrange the circles
self.circles.add(Circle())
self.circles().arrange()
# add the new circle to the scene
self.buffer.add(self.circles[-1])
# make sure the scene actually realizes something changed
self.apply_buffer = True
Every time the :class:`.Scene` applies the buffer, it gets emptied out
for use the next time.
The ``play`` call: preparing to enter Manim's render loop
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
We are finally there, the render loop is in our reach. Let us
walk through the code that is run when :meth:`.Scene.play` is called.
.. note::
.. hint::
In the future, control will not be passed to the Manager.
Instead, the Scene will keep track of every animation and
at the very end, the Manager will render everything.
Recall that this article is specifically about the Cairo renderer.
Up to here, things were more or less the same for the OpenGL renderer
as well; while some base mobjects might be different, the control flow
and lifecycle of mobjects is still more or less the same. There are more
substantial differences when it comes to the rendering loop.
As you will see when inspecting the method, :meth:`.Scene.play` almost
immediately passes over to the :class:`~.Manager._play` method of the :class:`.Manager`.
The one thing :meth:`.Scene.play` does before that is preparing the animations.
Whenever :attr:`.Mobject.animate` is called, it creates a new object called a
:class:`._AnimationBuilder`. We have to make sure to convert that into an actual
animation by calling it's :meth:`._AnimationBuilder.build` method.
We also have to update the animations with the correct rate functions, lag ratios,
and run time.
.. note::
Methods in :class:`.Manager` starting with an underscore ``_`` are intended to be
private, and are not guaranteed to be stable across versions of Manim. The :class:`.Manager`
class provides some "public" methods (methods not prefixed with ``_``) that can be overridden to
change the behavior of the program.
immediately passes over to the ``play`` method of the renderer,
in our case :class:`.CairoRenderer.play`. The one thing :meth:`.Scene.play`
takes care of is the management of subcaptions that you might have
passed to it (see the the documentation of :meth:`.Scene.play` and
:meth:`.Scene.add_subcaption` for more information).
.. warning::
Subcaptions and audio is still in progress
As has been said before, the communication between scene and renderer
is not in a very clean state at this point, so the following paragraphs
might be confusing if you don't run a debugger and step through the
code yourself a bit.
Inside :meth:`.CairoRenderer.play`, the renderer first checks whether
it may skip rendering of the current play call. This might happen, for example,
when ``-s`` is passed to the CLI (i.e., only the last frame should be rendered),
or when the ``-n`` flag is passed and the current play call is outside of the
specified render bounds. The "skipping status" is updated in form of the
call to :meth:`.CairoRenderer.update_skipping_status`.
Next, the renderer asks the scene to process the animations in the play
call so that renderer obtains all of the information it needs. To
be more concrete, :meth:`.Scene.compile_animation_data` is called,
which then takes care of several things:
- The method processes all animations and the keyword arguments passed
to the initial :meth:`.Scene.play` call. In particular, this means
that it makes sure all arguments passed to the play call are actually
animations (or ``.animate`` syntax calls, which are also assembled to
be actual :class:`.Animation`-objects at that point). It also propagates
any animation-related keyword arguments (like ``run_time``,
or ``rate_func``) passed to :class:`.Scene.play` to each individual
animation. The processed animations are then stored in the ``animations``
attribute of the scene (which the renderer later reads...).
- It adds all mobjects to which the animations that are played are
bound to to the scene (provided the animation is not an mobject-introducing
animation -- for these, the addition to the scene happens later).
- In case the played animation is a :class:`.Wait` animation (this is the
case in a :meth:`.Scene.wait` call), the method checks whether a static
image should be rendered, or whether the render loop should be processed
as usual (see :meth:`.Scene.should_update_mobjects` for the exact conditions,
basically it checks whether there are any time-dependent updater functions
and so on).
- Finally, the method determines the total run time of the play call (which
at this point is computed as the maximum of the run times of the passed
animations). This is stored in the ``duration`` attribute of the scene.
After the :class:`.Scene` has done all the processing of animations,
it hands out control to the :class:`.Manager`. The :class:`.Manager`
then updates the skipping status of the :class:`.Scene`. This makes sure
that if ``-s`` or ``-n`` is used for sections, the scene does the correct
thing.
After the animation data has been compiled by the scene, the renderer
continues to prepare for entering the render loop. It now checks the
skipping status which has been determined before. If the renderer can
skip this play call, it does so: it sets the current play call hash (which
we will get back to in a moment) to ``None`` and increases the time of the
renderer by the determined animation run time.
The next important line is::
self._write_hashed_movie_file()
Here, the :class:`.Manager` checks whether or not Manim's caching system should
Otherwise, the renderer checks whether or not Manim's caching system should
be used. The idea of the caching system is simple: for every play call, a
hash value is computed, which is then stored and upon re-rendering the scene,
the hash is generated again and checked against the stored value. If it is the
@ -754,8 +761,8 @@ to learn more, the :func:`.get_hash_from_play_call` function in the
:mod:`.utils.hashing` module is essentially the entry point to the caching
mechanism.
In the event that the animation has to be rendered, the manager asks
its :class:`.FileWriter` to open an output container. The process
In the event that the animation has to be rendered, the renderer asks
its :class:`.SceneFileWriter` to open an output container. The process
is started by a call to ``libav`` and opens a container to which rendered
raw frames can be written. As long as the output is open, the container
can be accessed via the ``output_container`` attribute of the file writer.
@ -763,18 +770,31 @@ With the writing process in place, the renderer then asks the scene
to "begin" the animations.
First, it literally *begins* all of the animations by calling their
setup methods (:meth:`.Animation.begin`).
setup methods (:meth:`.Animation._setup_scene`, :meth:`.Animation.begin`).
In doing so, the mobjects that are newly introduced by an animation
(like via :class:`.Create` etc.) are added to the scene. Furthermore, the
animation suspends updater functions being called on its mobject, and
it sets its mobject to the state that corresponds to the first frame
of the animation.
.. note::
After this has happened for all animations in the current ``play`` call,
the Cairo renderer determines which of the scene's mobjects can be
painted statically to the background, and which ones have to be
redrawn every frame. It does so by calling
:meth:`.Scene.get_moving_and_static_mobjects`, and the resulting
partition of mobjects is stored in the corresponding ``moving_mobjects``
and ``static_mobjects`` attributes.
Implementation of figuring out which mobjects have to be redrawn
is still in progress.
.. NOTE::
The mechanism that determines static and moving mobjects is
specific for the Cairo renderer, the OpenGL renderer works differently.
Basically, moving mobjects are determined by checking whether they,
any of their children, or any of the mobjects "below" them (in the
sense of the order in which mobjects are processed in the scene)
either have an update function attached, or whether they appear
in one of the current animations. See the implementation of
:meth:`.Scene.get_moving_mobjects` for more details.
Up to this very point, we did not actually render any (partial)
image or movie files from the scene yet. This is, however, about to change.
@ -815,28 +835,68 @@ Time to render some frames.
The render loop (for real this time)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Now we get to the meat of rendering, which happens in :meth:`.Manager._progress_through_animations`.
- The manager determines the run time of the animations by calling
:meth:`.Manager._calc_run_time`. This method basically takes the maximum
As mentioned above, due to the mechanism that determines static and moving
mobjects in the scene, the renderer knows which mobjects it can paint
statically to the background of the scene. Practically, this means that
it partially renders a scene (to produce a background image), and then
when iterating through the time progression of the animation only the
"moving mobjects" are re-painted on top of the static background.
The renderer calls :meth:`.CairoRenderer.save_static_frame_data`, which
first checks whether there are currently any static mobjects, and if there
are, it updates the frame (only with the static mobjects; more about how
exactly this works in a moment) and then saves a NumPy array representing
the rendered frame in the ``static_image`` attribute. In our toy example,
there are no static mobjects, and so the ``static_image`` attribute is
simply set to ``None``.
Next, the renderer asks the scene whether the current animation is
a "frozen frame" animation, which would mean that the renderer actually
does not have to repaint the moving mobjects in every frame of the time
progression. It can then just take the latest static frame, and display it
throughout the animation.
.. NOTE::
An animation is considered a "frozen frame" animation if only a
static :class:`.Wait` animation is played. See the description
of :meth:`.Scene.compile_animation_data` above, or the
implementation of :meth:`.Scene.should_update_mobjects` for
more details.
If this is not the case (just as in our toy example), the renderer
then calls the :meth:`.Scene.play_internal` method, which is the
integral part of the render loop (in which the library steps through
the time progression of the animation and renders the corresponding
frames).
Within :meth:`.Scene.play_internal`, the following steps are performed:
- The scene determines the run time of the animations by calling
:meth:`.Scene.get_run_time`. This method basically takes the maximum
``run_time`` attribute of all of the animations passed to the
:meth:`.Scene.play` call.
- Then, the progressbar is created by :meth:`.Manager._create_progressbar`,
which returns a ``tqdm`` `progress bar object <https://tqdm.github.io>`__
object (from the ``tqdm`` library), or a fake progressbar if
:attr:`.ManimConfig.write_to_movie` is ``False``.
- Then the *time progression* is constructed via
:meth:`.Manager._calc_time_progression` method, which returns
``np.arange(0, run_time, 1 / config.frame_rate)``. In
- Then the *time progression* is constructed via the (internal)
:meth:`.Scene._get_animation_time_progression` method, which wraps
the actual :meth:`.Scene.get_time_progression` method. The time
progression is a ``tqdm`` `progress bar object <https://tqdm.github.io>`__
for an iterator over ``np.arange(0, run_time, 1 / config.frame_rate)``. In
other words, the time progression holds the time stamps (relative to the
current animations, so starting at 0 and ending at the total animation run time,
with the step size determined by the render frame rate) of the timeline where
a new animation frame should be rendered.
- Then the scene iterates over the time progression: for each time stamp ``t``,
we find the time difference between the current and previous frame (AKA ``dt``).
We then update the animations in the scene using ``dt`` by
- iterating over each animation
- next, we update the animations mobjects
:meth:`.Scene.update_to_time` is called, which ...
- ... first computes the time passed since the last update (which might be 0,
especially for the initial call) and references it as ``dt``,
- then (in the order in which the animations are passed to :meth:`.Scene.play`)
calls :meth:`.Animation.update_mobjects` to trigger all updater functions that
are attached to the respective animation except for the "main mobject" of
the animation (that is, for example, for :class:`.Transform` the unmodified
copies of start and target mobject -- see :meth:`.Animation.get_all_mobjects_to_update`
for more details),
- then the relative time progression with respect to the current animation
is computed (``alpha = t / animation.run_time``), which is then used to
update the state of the animation with a call to :meth:`.Animation.interpolate`.
@ -844,29 +904,62 @@ Now we get to the meat of rendering, which happens in :meth:`.Manager._progress_
of all mobjects in the scene, all meshes, and finally those attached to
the scene itself are run.
After updating the animations, we pass ``dt`` to :meth:`.Manager._update_frame` which...
- ... updates the total time passed
- Updates all the mobjects by calling :meth:`.Scene._update_mobjects`. This in turn
iterates over all the mobjects on the screen and updates them.
- After that, the current state of the scene is computed by :meth:`.Scene.get_state`,
which returns a :class:`.SceneState`.
- The state is then passed into :meth:`.Manager._render_frame`, which gets
the renderer to create the pixels. With :class:`.OpenGLRenderer`, this
also updates the window. :meth:`~.Manager._render_frame` also checks if it should write a frame,
and if so, writes a frame via the :class:`.FileWriter`.
- Finally, it uses a concept of virtual time vs real time to see
if the right amount of time has passed in the window. The virtual
time is the amount of time that is supposed to have passed (that is, ``t``).
The real time is how much time has actually passed in the window
(current time - start time of play). If the animations are progressing
faster than they would in real life, it will slow down the window by calling
:meth:`~.Manager._update_frame` with ``dt=0`` until that's no longer the case.
This is to make sure that animations never go too fast: it doesn't do anything if
animations are too slow!
At this point, the internal (Python) state of all mobjects has been updated
to match the currently processed timestamp.
to match the currently processed timestamp. If rendering should not be skipped,
then it is now time to *take a picture*!
.. NOTE::
The update of the internal state (iteration over the time progression) happens
*always* once :meth:`.Scene.play_internal` is entered. This ensures that even
if frames do not need to be rendered (because, e.g., the ``-n`` CLI flag has
been passed, something has been cached, or because we might be in a *Section*
with skipped rendering), updater functions still run correctly, and the state
of the first frame that *is* rendered is kept consistent.
To render an image, the scene calls the corresponding method of its renderer,
:meth:`.CairoRenderer.render` and passes just the list of *moving mobjects* (remember,
the *static mobjects* are assumed to have already been painted statically to
the background of the scene). All of the hard work then happens when the renderer
updates its current frame via a call to :meth:`.CairoRenderer.update_frame`:
First, the renderer prepares its :class:`.Camera` by checking whether the renderer
has a ``static_image`` different from ``None`` stored already. If so, it sets the
image as the *background image* of the camera via :meth:`.Camera.set_frame_to_background`,
and otherwise it just resets the camera via :meth:`.Camera.reset`. The camera is then
asked to capture the scene with a call to :meth:`.Camera.capture_mobjects`.
Things get a bit technical here, and at some point it is more efficient to
delve into the implementation -- but here is a summary of what happens once the
camera is asked to capture the scene:
- First, a flat list of mobjects is created (so submobjects get extracted from
their parents). This list is then processed in groups of the same type of
mobjects (e.g., a batch of vectorized mobjects, followed by a batch of image mobjects,
followed by more vectorized mobjects, etc. -- in many cases there will just be
one batch of vectorized mobjects).
- Depending on the type of the currently processed batch, the camera uses dedicated
*display functions* to convert the :class:`.Mobject` Python object to
a NumPy array stored in the camera's ``pixel_array`` attribute.
The most important example in that context is the display function for
vectorized mobjects, :meth:`.Camera.display_multiple_vectorized_mobjects`,
or the more particular (in case you did not add a background image to your
:class:`.VMobject`), :meth:`.Camera.display_multiple_non_background_colored_vmobjects`.
This method first gets the current Cairo context, and then, for every (vectorized)
mobject in the batch, calls :meth:`.Camera.display_vectorized`. There,
the actual background stroke, fill, and then stroke of the mobject is
drawn onto the context. See :meth:`.Camera.apply_stroke` and
:meth:`.Camera.set_cairo_context_color` for more details -- but it does not get
much deeper than that, in the latter method the actual Bézier curves
determined by the points of the mobject are drawn; this is where the low-level
interaction with Cairo happens.
After all batches have been processed, the camera has an image representation
of the Scene at the current time stamp in form of a NumPy array stored in its
``pixel_array`` attribute. The renderer then takes this array and passes it to
its :class:`.SceneFileWriter`. This concludes one iteration of the render loop,
and once the time progression has been processed completely, a final bit
of cleanup is performed before the :meth:`.Scene.play_internal` call is completed.
A TL;DR for the render loop, in the context of our toy example, reads as follows:
@ -875,20 +968,23 @@ A TL;DR for the render loop, in the context of our toy example, reads as follows
medium render quality, the frame rate is 30 frames per second, and so the time
progression with steps ``[0, 1/30, 2/30, ..., 89/30]`` is created.
- In the internal render loop, each of these time stamps is processed:
there are no updater functions, so effectively the manager updates the
there are no updater functions, so effectively the scene updates the
state of the transformation animation to the desired time stamp (for example,
at time stamp ``t = 45/30``, the animation is completed to a rate of
``alpha = 0.5``).
- Then the manager asks the renderer to do its job. The renderer then produces
the pixels, which are then fed into the :class:`.FileWriter`.
- Then the scene asks the renderer to do its job. The renderer asks its camera
to capture the scene, the only mobject that needs to be processed at this point
is the main mobject attached to the transformation; the camera converts the
current state of the mobject to entries in a NumPy array. The renderer passes
this array to the file writer.
- At the end of the loop, 90 frames have been passed to the file writer.
Completing the render loop
^^^^^^^^^^^^^^^^^^^^^^^^^^
The last few steps in the :meth:`.Manager._play` call are not too
The last few steps in the :meth:`.Scene.play_internal` call are not too
exciting: for every animation, the corresponding :meth:`.Animation.finish`
method is called.
and :meth:`.Animation.clean_up_from_scene` methods are called.
.. NOTE::
@ -903,6 +999,10 @@ method is called.
would be slightly longer than 1 second. We decided against this at some point.
In the end, the time progression is closed (which completes the displayed progress bar)
in the terminal. With the closing of the time progression, the
:meth:`.Scene.play_internal` call is completed, and we return to the renderer,
which now orders the :class:`.SceneFileWriter` to close the output container that has
been opened for this animation: a partial movie file is written.
This pretty much concludes the walkthrough of a :class:`.Scene.play` call,
and actually there is not too much more to say for our toy example either: at
@ -925,4 +1025,5 @@ which triggers the combination of the partial movie files into the final product
And there you go! This is a more or less detailed description of how Manim works
under the hood. While we did not discuss every single line of code in detail
in this walkthrough, it should still give you a fairly good idea of how the general
structural design of the library looks like.
structural design of the library and at least the Cairo rendering flow in particular
looks like.

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

View file

@ -39,8 +39,12 @@ Cameras
.. inheritance-diagram::
manim.camera.camera
manim.camera.mapping_camera
manim.camera.moving_camera
manim.camera.multi_camera
manim.camera.three_d_camera
:parts: 1
:top-classes: manim.camera.camera.Camera, manim.mobject.opengl.opengl_mobject.OpenGLMobject
:top-classes: manim.camera.camera.Camera, manim.mobject.mobject.Mobject
Mobjects
********

View file

@ -297,9 +297,9 @@ Creating a custom animation
Even though Manim has many built-in animations, you will find times when you need to smoothly animate from one state of a :class:`~.Mobject` to another.
If you find yourself in that situation, then you can define your own custom animation.
You start by extending the :class:`~.Animation` class and overriding its :meth:`~.Animation.interpolate`.
The :meth:`~.Animation.interpolate` method receives alpha as a parameter that starts at 0 and changes throughout the animation.
So, you just have to manipulate self.mobject inside Animation according to the alpha value in its interpolate method.
You start by extending the :class:`~.Animation` class and overriding its :meth:`~.Animation.interpolate_mobject`.
The :meth:`~.Animation.interpolate_mobject` method receives alpha as a parameter that starts at 0 and changes throughout the animation.
So, you just have to manipulate self.mobject inside Animation according to the alpha value in its interpolate_mobject method.
Then you get all the benefits of :class:`~.Animation` such as playing it for different run times or using different rate functions.
Let's say you start with a number and want to create a :class:`~.Transform` animation that transforms it to a target number.
@ -312,11 +312,11 @@ The class can have a constructor with three arguments, a :class:`~.DecimalNumber
The constructor will pass the :class:`~.DecimalNumber` Mobject to the super constructor (in this case, the :class:`~.Animation` constructor) and will set start and end.
The only thing that you need to do is to define how you want it to look at every step of the animation.
Manim provides you with the alpha value in the :meth:`~.Animation.interpolate` method based on frame rate of video, rate function, and run time of animation played.
Manim provides you with the alpha value in the :meth:`~.Animation.interpolate_mobject` method based on frame rate of video, rate function, and run time of animation played.
The alpha parameter holds a value between 0 and 1 representing the step of the currently playing animation.
For example, 0 means the beginning of the animation, 0.5 means halfway through the animation, and 1 means the end of the animation.
In the case of the ``Count`` animation, you just have to figure out a way to determine the number to display at the given alpha value and then set that value in the :meth:`~.Animation.interpolate` method of the ``Count`` animation.
In the case of the ``Count`` animation, you just have to figure out a way to determine the number to display at the given alpha value and then set that value in the :meth:`~.Animation.interpolate_mobject` method of the ``Count`` animation.
Suppose you are starting at 50 and incrementing until the :class:`~.DecimalNumber` reaches 100 at the end of the animation.
* If alpha is 0, you want the value to be 50.
@ -338,7 +338,7 @@ Once you have defined your ``Count`` animation, you can play it in your :class:`
.. manim:: CountingScene
:ref_classes: Animation DecimalNumber
:ref_methods: Animation.interpolate Scene.play
:ref_methods: Animation.interpolate_mobject Scene.play
class Count(Animation):
def __init__(self, number: DecimalNumber, start: float, end: float, **kwargs) -> None:
@ -348,7 +348,7 @@ Once you have defined your ``Count`` animation, you can play it in your :class:`
self.start = start
self.end = end
def interpolate(self, alpha: float) -> None:
def interpolate_mobject(self, alpha: float) -> None:
# Set value of DecimalNumber according to alpha
value = self.start + (self.rate_func(alpha) * (self.end - self.start))
self.mobject.set_value(value)

View file

@ -266,44 +266,6 @@ You can also skip rendering all animations belonging to a section like this:
Groups
******
Sections are a powerful tool to organize your animations into different parts. However, sometimes it's
more useful to look at bigger parts of your animations. *Groups* are effectively sections of sections.
The syntax is fairly simple::
class MyScene(Scene):
# enable groups
groups_api = True
@group
def introduction(self) -> None:
self.play(Write(Text("Hello World!")))
self.next_section(...)
self.play(Write(Text("This is a group!")))
self.next_section(...)
@group
def main_part(self) -> None:
self.play(Write(Text("This is the main part!")))
self.next_section(...)
self.play(Write(Text("This is a group as well!")))
self.next_section(...)
@group
def conclusion(self) -> None:
self.play(FadeOut(*self.mobjects))
You can then play specific groups by using the ``--groups`` flag::
manim --groups introduction,conclusion scene.py
Note that they must be separated by commas and without spaces.
Alternatively, you can set it on Manim's global ``config`` variable::
config.groups = ["introduction", "conclusion"]
Some command line flags
***********************

View file

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

View file

@ -1,9 +0,0 @@
from manim import *
class Test(Scene):
def construct(scene):
scene.camera.set_euler_angles(phi=75 * DEGREES, theta=-45 * DEGREES)
text = Tex("This is a 3D tex").fix_in_frame()
scene.add(text)
scene.wait()

View file

@ -1,148 +0,0 @@
import time
import numpy as np
# import pyglet
from pyglet.gl import Config
from pyglet.window import Window
import manim.utils.color.manim_colors as col
from manim._config import tempconfig
from manim.animation.creation import DrawBorderThenFill
from manim.camera.camera import Camera
from manim.constants import LEFT, OUT, RIGHT, UP
from manim.mobject.geometry.arc import Circle
from manim.mobject.geometry.polygram import Square
from manim.mobject.logo import ManimBanner
from manim.mobject.text.numbers import DecimalNumber
from manim.renderer.opengl_renderer import OpenGLRenderer
if __name__ == "__main__":
with tempconfig({"renderer": "opengl"}):
win = Window(
width=1920,
height=1080,
fullscreen=True,
vsync=True,
config=Config(double_buffer=True, samples=0),
)
renderer = OpenGLRenderer(1920, 1080, background_color=col.GRAY)
# vm = OpenGLVMobject([col.RED, col.GREEN])
vm = (
Circle(
radius=1,
stroke_color=col.YELLOW,
)
.shift(3 * RIGHT + OUT)
.set_opacity(0.6)
)
vm2 = Square(stroke_color=col.GREEN, fill_opacity=0, stroke_opacity=1).move_to(
(0, 0, -0.5)
)
vm3 = ManimBanner().set_opacity(0.6)
vm4 = (
Circle(0.5, col.GREEN)
.set_opacity(0.6)
.shift(OUT)
.set_fill(col.BLUE, opacity=0.2)
)
# vm.set_points_as_corners([[-1920/2, 0, 0], [1920/2, 0, 0], [0, 1080/2, 0]])
# print(vm.color)
# print(vm.fill_color)
# print(vm.stroke_color)
clock_mobject = DecimalNumber(0.0).shift(4 * LEFT + 2.5 * UP)
clock_mobject.fix_in_frame()
camera = Camera()
camera.save_state()
# renderer.init_camera(camera)
# renderer.render(camera, [vm, vm2])
# image = renderer.get_pixels()
# print(image.shape)
# Image.fromarray(image, "RGBA").show()
# exit(0)
renderer.use_window()
# clock = pyglet.clock.get_default()
def update_circle(dt):
vm.move_to((np.sin(dt) * 4, np.cos(dt) * 4, -1))
def p2m(x, y, z):
from manim._config import config
return (
config.frame_width * (x / config.pixel_width - 0.5),
config.frame_height * (y / config.pixel_height - 0.5),
z,
)
@win.event
def on_close():
win.close()
@win.event
def on_mouse_motion(x, y, dx, dy):
# vm.move_to((14.2222 * (x / 1920 - 0.5), 8 * (y / 1080 - 0.5), 0))
# camera.move_to(p2m(x,y,camera.get_center()[2]))
from scipy.spatial.transform import Rotation
camera.set_orientation(
Rotation.from_rotvec(
(-UP * (x / 1920 - 0.5) + RIGHT * (y / 1080 - 0.5)) * 2 * 3.1415
)
)
# vm.set_color(col.RED.interpolate(col.GREEN,x/1920))
# print(x,y)
@win.event
def on_draw():
# dt = clock.update_time()
renderer.render(camera, [vm2, vm3, vm4, clock_mobject, vm])
# update_circle(counter)
@win.event
def on_resize(width, height):
super(Window, win).on_resize(width, height)
# pyglet.app.run()
has_started = False
is_finished = False
run_time = 5
new_vm = Square(fill_color=col.GREEN, stroke_color=col.BLUE).shift(
2.5 * RIGHT - UP + 2 * OUT
)
animation = DrawBorderThenFill(vm3, run_time=run_time)
real_time = 0
virtual_time = 0
start_timestamp = time.time()
dt = 1 / 30
while True:
# pyglet.app.platform_event_loop.step()
win.switch_to()
if not has_started:
animation.begin()
has_started = True
real_time = time.time() - start_timestamp
while virtual_time < real_time:
virtual_time += dt
if not is_finished:
if virtual_time >= run_time:
animation.finish()
buffer = str(animation.buffer)
print(f"buffer = {buffer}")
has_finished = True
else:
animation.update_mobjects(dt)
animation.interpolate(virtual_time / run_time)
# update_circle(virtual_time)
clock_mobject.set_value(virtual_time)
win.dispatch_event("on_draw")
win.dispatch_events()
win.flip()

View file

@ -1,12 +1,8 @@
from pathlib import Path
from manim.opengl import *
import manim.utils.opengl as opengl
from manim import *
from manim.mobject.opengl.opengl_surface import OpenGLTexturedSurface
from manim.mobject.opengl.opengl_three_dimensions import OpenGLSurfaceMesh
from manim.mobject.opengl.shader import FullScreenQuad, Mesh, Shader
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,114 +0,0 @@
import numpy as np
import pyglet
from pyglet.gl import Config
from pyglet.window import Window
import manim.utils.color.manim_colors as col
from manim._config import tempconfig
from manim.camera.camera import Camera
from manim.constants import OUT, RIGHT, UP
from manim.mobject.geometry.arc import Circle
from manim.mobject.geometry.polygram import Square
from manim.mobject.logo import ManimBanner
from manim.mobject.opengl.opengl_vectorized_mobject import OpenGLVMobject
from manim.mobject.text.numbers import DecimalNumber
from manim.renderer.opengl_renderer import OpenGLRenderer
if __name__ == "__main__":
with tempconfig({"renderer": "opengl"}):
win = Window(
width=1920,
height=1080,
vsync=True,
config=Config(double_buffer=True, samples=0),
)
renderer = OpenGLRenderer(1920, 1080, background_color=col.GRAY)
# vm = OpenGLVMobject([col.RED, col.GREEN])
vm = (
Circle(
radius=1,
stroke_color=col.YELLOW,
)
.shift(RIGHT)
.set_opacity(0.5)
)
vm2 = Square(stroke_color=col.GREEN, fill_opacity=0, stroke_opacity=1).move_to(
(0, 0, -0.5)
)
vm3 = ManimBanner().set_opacity(1.0)
vm4 = (
Circle(0.5, col.GREEN)
.set_opacity(0.6)
.shift(OUT)
.set_fill(col.BLUE, opacity=0.2)
)
# vm.set_points_as_corners([[-1920/2, 0, 0], [1920/2, 0, 0], [0, 1080/2, 0]])
# print(vm.color)
# print(vm.fill_color)
# print(vm.stroke_color)
camera = Camera()
camera.save_state()
renderer.init_camera(camera)
# renderer.render(camera, [vm, vm2])
# image = renderer.get_pixels()
# print(image.shape)
# Image.fromarray(image, "RGBA").show()
# exit(0)
renderer.use_window()
clock = pyglet.clock.get_default()
def update_circle(dt):
vm.move_to((np.sin(dt) * 4, np.cos(dt) * 4, -1))
def p2m(x, y, z):
from manim._config import config
return (
config.frame_width * (x / config.pixel_width - 0.5),
config.frame_height * (y / config.pixel_height - 0.5),
z,
)
@win.event
def on_close():
win.close()
@win.event
def on_mouse_motion(x, y, dx, dy):
# vm.move_to((14.2222 * (x / 1920 - 0.5), 8 * (y / 1080 - 0.5), 0))
# camera.move_to(p2m(x,y,camera.get_center()[2]))
from scipy.spatial.transform import Rotation
camera.set_orientation(
Rotation.from_rotvec(
(-UP * (x / 1920 - 0.5) + RIGHT * (y / 1080 - 0.5)) * 2 * 3.1415
)
)
# vm.set_color(col.RED.interpolate(col.GREEN,x/1920))
# print(x,y)
@win.event
def on_draw():
dt = clock.update_time()
fps: OpenGLVMobject = DecimalNumber(dt)
fps.fix_in_frame()
renderer.render(camera, [vm, vm2, vm3, vm4, fps])
# update_circle(counter)
@win.event
def on_resize(width, height):
super(Window, win).on_resize(width, height)
pyglet.app.run()
# while True:
# pyglet.clock.tick()
# pyglet.app.platform_event_loop.step()
# win.switch_to()
# counter += 0.01
# update_circle(counter)
# win.dispatch_event("on_draw")
# win.dispatch_events()
# win.flip()

View file

@ -1,96 +0,0 @@
from manim import *
class Test(Scene):
groups_api = True
@group
def first_section(self) -> None:
line = Line()
line.add_updater(lambda m, dt: m.rotate(PI * dt))
line.to_edge(LEFT)
self.add(line)
square = Square()
tex = Tex(
"Hello, ",
"world",
r" $e^{i\theta}$",
stroke_color=RED,
fill_color=BLUE,
stroke_width=2,
).to_edge(RIGHT)
tex.set_color_by_tex("world", GREEN)
self.add(tex)
self.play(Create(tex), Rotate(square, PI / 2))
self.wait(1)
self.play(FadeOut(square))
@group
def three_mobjects(self) -> None:
hexagon = RegularPolygon(6)
circle = Circle()
star = Star()
VGroup(hexagon, circle, star).arrange()
self.play(
Succession(
Create(hexagon),
DrawBorderThenFill(circle),
SpinInFromNothing(star),
)
)
self.play(FadeOut(VGroup(hexagon, circle, star)))
@group
def manim_banner(self) -> None:
banner = ManimBanner().scale(0.5)
self.play(banner.create())
self.play(banner.expand())
self.wait(1)
self.play(Unwrite(banner))
@group
def graph(self):
vertices = [1, 2, 3]
edges = [(1, 2), (2, 3), (3, 1)]
graph = Graph(vertices, edges, layout="circular")
self.play(Create(graph))
self.play(
graph.animate.add_vertices(
4,
5,
vertex_config={4: {"fill_color": RED}, 5: {"fill_color": RED}},
positions={4: [2, 1, 0], 5: [2, -1, 0]},
)
)
self.play( # TODO: this animation is currently broken
graph.animate.add_edges(
(2, 4),
(3, 5),
(4, 5),
edge_config={
(2, 4): {"stroke_color": GREEN},
(3, 5): {"stroke_color": GREEN},
(4, 5): {"stroke_color": YELLOW},
},
)
)
self.wait(1)
self.play(graph.animate.remove_vertices(1))
self.play(graph.animate.remove_edges((4, 5)))
self.play(Uncreate(graph))
if __name__ == "__main__":
with (
tempconfig(
{
"preview": True,
"write_to_movie": False,
"disable_caching": True,
"frame_rate": 60,
"disable_caching_warning": True,
}
),
Manager(Test) as manager,
):
manager.render()

View file

@ -17,91 +17,99 @@ except PackageNotFoundError:
# Importing the config module should be the first thing we do, since other
# modules depend on the global config dict for initialization.
from manim._config import *
from ._config import *
# many scripts depend on this -> has to be loaded first
from manim.utils.commands import *
from .utils.commands import *
# isort: on
import numpy as np
from manim.animation.animation import *
from manim.animation.changing import *
from manim.animation.composition import *
from manim.animation.creation import *
from manim.animation.fading import *
from manim.animation.growing import *
from manim.animation.indication import *
from manim.animation.movement import *
from manim.animation.numbers import *
from manim.animation.rotation import *
from manim.animation.specialized import *
from manim.animation.speedmodifier import *
from manim.animation.transform import *
from manim.animation.transform_matching_parts import *
from manim.animation.updaters.mobject_update_utils import *
from manim.animation.updaters.update import *
from manim.constants import *
from manim.file_writer import *
from manim.manager import *
from manim.mobject.frame import *
from manim.mobject.geometry.arc import *
from manim.mobject.geometry.boolean_ops import *
from manim.mobject.geometry.labeled import *
from manim.mobject.geometry.line import *
from manim.mobject.geometry.polygram import *
from manim.mobject.geometry.shape_matchers import *
from manim.mobject.geometry.tips import *
from manim.mobject.graph import *
from manim.mobject.graphing.coordinate_systems import *
from manim.mobject.graphing.functions import *
from manim.mobject.graphing.number_line import *
from manim.mobject.graphing.probability import *
from manim.mobject.graphing.scale import *
from manim.mobject.logo import *
from manim.mobject.matrix import *
from manim.mobject.mobject import *
from manim.mobject.opengl.dot_cloud import *
from manim.mobject.opengl.opengl_point_cloud_mobject import *
from manim.mobject.opengl.opengl_vectorized_mobject import *
from manim.mobject.svg.brace import *
from manim.mobject.svg.svg_mobject import *
from manim.mobject.table import *
from manim.mobject.text.code_mobject import *
from manim.mobject.text.numbers import *
from manim.mobject.text.tex_mobject import *
from manim.mobject.text.text_mobject import *
from manim.mobject.three_d.polyhedra import *
from manim.mobject.three_d.three_d_utils import *
from manim.mobject.three_d.three_dimensions import *
from manim.mobject.types.image_mobject import *
from manim.mobject.types.point_cloud_mobject import *
from manim.mobject.types.vectorized_mobject import *
from manim.mobject.value_tracker import *
from manim.mobject.vector_field import *
from manim.scene.scene import *
from manim.scene.sections import *
from manim.scene.vector_space_scene import *
from manim.utils import color, rate_functions, unit
from manim.utils.bezier import *
from manim.utils.color import *
from manim.utils.config_ops import *
from manim.utils.debug import *
from manim.utils.file_ops import *
from manim.utils.images import *
from manim.utils.iterables import *
from manim.utils.paths import *
from manim.utils.rate_functions import *
from manim.utils.simple_functions import *
from manim.utils.sounds import *
from manim.utils.space_ops import *
from manim.utils.tex import *
from manim.utils.tex_templates import *
from .animation.animation import *
from .animation.changing import *
from .animation.composition import *
from .animation.creation import *
from .animation.fading import *
from .animation.growing import *
from .animation.indication import *
from .animation.movement import *
from .animation.numbers import *
from .animation.rotation import *
from .animation.specialized import *
from .animation.speedmodifier import *
from .animation.transform import *
from .animation.transform_matching_parts import *
from .animation.updaters.mobject_update_utils import *
from .animation.updaters.update import *
from .camera.camera import *
from .camera.mapping_camera import *
from .camera.moving_camera import *
from .camera.multi_camera import *
from .camera.three_d_camera import *
from .constants import *
from .mobject.frame import *
from .mobject.geometry.arc import *
from .mobject.geometry.boolean_ops import *
from .mobject.geometry.labeled import *
from .mobject.geometry.line import *
from .mobject.geometry.polygram import *
from .mobject.geometry.shape_matchers import *
from .mobject.geometry.tips import *
from .mobject.graph import *
from .mobject.graphing.coordinate_systems import *
from .mobject.graphing.functions import *
from .mobject.graphing.number_line import *
from .mobject.graphing.probability import *
from .mobject.graphing.scale import *
from .mobject.logo import *
from .mobject.matrix import *
from .mobject.mobject import *
from .mobject.opengl.dot_cloud import *
from .mobject.opengl.opengl_point_cloud_mobject import *
from .mobject.svg.brace import *
from .mobject.svg.svg_mobject import *
from .mobject.table import *
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 *
from .mobject.types.image_mobject import *
from .mobject.types.point_cloud_mobject import *
from .mobject.types.vectorized_mobject import *
from .mobject.value_tracker import *
from .mobject.vector_field import *
from .renderer.cairo_renderer import *
from .scene.moving_camera_scene import *
from .scene.scene import *
from .scene.scene_file_writer import *
from .scene.section import *
from .scene.three_d_scene import *
from .scene.vector_space_scene import *
from .scene.zoomed_scene import *
from .utils import color, rate_functions, unit
from .utils.bezier import *
from .utils.color import *
from .utils.config_ops import *
from .utils.debug import *
from .utils.file_ops import *
from .utils.images import *
from .utils.iterables import *
from .utils.paths import *
from .utils.rate_functions import *
from .utils.simple_functions import *
from .utils.sounds import *
from .utils.space_ops import *
from .utils.tex import *
from .utils.tex_templates import *
try:
from IPython import get_ipython
from manim.utils.ipython_magic import ManimMagic
from .utils.ipython_magic import ManimMagic
except ImportError:
pass
else:
@ -109,4 +117,4 @@ else:
if ipy is not None:
ipy.register_magics(ManimMagic)
from manim.plugins import *
from .plugins import *

View file

@ -7,18 +7,18 @@
# Each of the following will be set to True if the corresponding CLI flag
# is present when executing manim. If the flag is not present, they will
# be set to the value found here. For example, running manim with the --format=mp4
# flag will set FORMAT to mp4. However, since the default value
# of FORMAT defined in this file is also mp4, running manim
# without the --format=mp4 value will also output an mp4 movie file. To change that, set
# FORMAT = webm so that running manim without the --format=mp4 flag will not
# generate an mp4 movie file.
# be set to the value found here. For example, running manim with the -w
# flag will set WRITE_TO_MOVIE to True. However, since the default value
# of WRITE_TO_MOVIE defined in this file is also True, running manim
# without the -w value will also output a movie file. To change that, set
# WRITE_TO_MOVIE = False so that running manim without the -w flag will not
# generate a movie file. Note all of the following accept boolean values.
# --notify_outdated_version
notify_outdated_version = True
# -w, --write_to_movie
write_to_movie = False
write_to_movie = True
format = mp4
@ -29,9 +29,15 @@ save_last_frame = False
# -a, --write_all
write_all = False
# -g, --save_pngs
save_pngs = False
# -0, --zero_pad
zero_pad = 4
# -i, --save_as_gif
save_as_gif = False
# --save_sections
save_sections = False
@ -88,7 +94,7 @@ text_dir = {media_dir}/texts
partial_movie_dir = {video_dir}/partial_movie_files/{scene_name}
# --renderer [cairo|opengl]
renderer = opengl
renderer = cairo
# --enable_gui
enable_gui = False
@ -115,6 +121,12 @@ window_monitor = 0
# --force_window
force_window = False
# --use_projection_fill_shaders
use_projection_fill_shaders = False
# --use_projection_stroke_shaders
use_projection_stroke_shaders = False
movie_file_extension = .mp4
# These now override the --quality option.

View file

@ -20,7 +20,7 @@ import logging
import os
import re
import sys
from collections.abc import Iterator, Mapping, MutableMapping, Sequence
from collections.abc import Iterator, Mapping, MutableMapping
from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar, NoReturn
@ -276,7 +276,6 @@ class ManimConfig(MutableMapping):
"frame_x_radius",
"frame_y_radius",
"from_animation_number",
"groups",
"images_dir",
"input_file",
"media_embed",
@ -295,8 +294,10 @@ class ManimConfig(MutableMapping):
"preview",
"progress_bar",
"quality",
"save_as_gif",
"save_sections",
"save_last_frame",
"save_pngs",
"scene_names",
"seed",
"show_in_file_browser",
@ -308,6 +309,8 @@ class ManimConfig(MutableMapping):
"renderer",
"enable_gui",
"gui_location",
"use_projection_fill_shaders",
"use_projection_stroke_shaders",
"verbosity",
"video_dir",
"sections_dir",
@ -326,22 +329,6 @@ class ManimConfig(MutableMapping):
def __init__(self) -> None:
self._d: dict[str, Any | None] = dict.fromkeys(self._OPTS)
def _warn_about_config_options(self) -> None:
"""Warns about incorrect config options, or permutations of config options."""
logger = logging.getLogger("manim")
if self.format == "webm":
logger.warning(
"Output format set as webm, this can be slower than other formats",
)
if not self.preview and not self.write_to_movie:
logger.warning(
"preview and write_to_movie disabled, this is a dry run. Try passing -p or -w."
)
elif self.preview and self.write_to_movie:
logger.warning(
"Both preview and write_to_movie enabled, this can be slower than just previewing."
)
# behave like a dict
def __iter__(self) -> Iterator[str]:
return iter(self._d)
@ -592,6 +579,8 @@ class ManimConfig(MutableMapping):
"write_to_movie",
"save_last_frame",
"write_all",
"save_pngs",
"save_as_gif",
"save_sections",
"preview",
"show_in_file_browser",
@ -602,6 +591,8 @@ class ManimConfig(MutableMapping):
"custom_folders",
"enable_gui",
"fullscreen",
"use_projection_fill_shaders",
"use_projection_stroke_shaders",
"enable_wireframe",
"force_window",
"no_latex_cleanup",
@ -709,8 +700,6 @@ class ManimConfig(MutableMapping):
if quality:
self.quality = _determine_quality(quality)
self.groups = parser["CLI"].get("groups", fallback="", raw=True) or []
return self
def digest_args(self, args: argparse.Namespace) -> Self:
@ -765,6 +754,8 @@ class ManimConfig(MutableMapping):
"show_in_file_browser",
"write_to_movie",
"save_last_frame",
"save_pngs",
"save_as_gif",
"save_sections",
"write_all",
"disable_caching",
@ -778,6 +769,8 @@ class ManimConfig(MutableMapping):
"background_color",
"enable_gui",
"fullscreen",
"use_projection_fill_shaders",
"use_projection_stroke_shaders",
"zero_pad",
"enable_wireframe",
"force_window",
@ -950,7 +943,6 @@ class ManimConfig(MutableMapping):
def notify_outdated_version(self, value: bool) -> None:
self._set_boolean("notify_outdated_version", value)
# TODO: Rename to write_to_file
@property
def write_to_movie(self) -> bool:
"""Whether to render the scene to a movie file (-w)."""
@ -978,6 +970,24 @@ class ManimConfig(MutableMapping):
def write_all(self, value: bool) -> None:
self._set_boolean("write_all", value)
@property
def save_pngs(self) -> bool:
"""Whether to save all frames in the scene as images files (-g)."""
return self._d["save_pngs"]
@save_pngs.setter
def save_pngs(self, value: bool) -> None:
self._set_boolean("save_pngs", value)
@property
def save_as_gif(self) -> bool:
"""Whether to save the rendered scene in .gif format (-i)."""
return self._d["save_as_gif"]
@save_as_gif.setter
def save_as_gif(self, value: bool) -> None:
self._set_boolean("save_as_gif", value)
@property
def save_sections(self) -> bool:
"""Whether to save single videos for each section in addition to the movie file."""
@ -1049,6 +1059,10 @@ class ManimConfig(MutableMapping):
[None, "png", "gif", "mp4", "mov", "webm"],
)
self.resolve_movie_file_extension(self.transparent)
if self.format == "webm":
logger.warning(
"Output format set as webm, this can be slower than other formats",
)
@property
def ffmpeg_loglevel(self) -> str:
@ -1204,24 +1218,6 @@ class ManimConfig(MutableMapping):
def upto_animation_number(self, value: int) -> None:
self._set_pos_number("upto_animation_number", value, True)
@property
def groups(self) -> tuple[str, ...]:
"""The name of the groups to play.
If not passed, it will play all groups. Otherwise,
it will play only the groups passed in.
"""
return self._d["groups"] # type: ignore[misc]
@groups.setter
def groups(self, value: str | Sequence[str]) -> None:
if isinstance(value, str):
self._set_str("groups", value.replace(" ", "").split(","))
else:
if not all(isinstance(v, str) for v in value):
raise ValueError("groups must be a string or a sequence of strings")
self._d["groups"] = tuple(value)
@property
def max_files_cached(self) -> int:
"""Maximum number of files cached. Use -1 for infinity (no flag)."""
@ -1482,6 +1478,24 @@ class ManimConfig(MutableMapping):
def fullscreen(self, value: bool) -> None:
self._set_boolean("fullscreen", value)
@property
def use_projection_fill_shaders(self) -> bool:
"""Use shaders for OpenGLVMobject fill which are compatible with transformation matrices."""
return self._d["use_projection_fill_shaders"]
@use_projection_fill_shaders.setter
def use_projection_fill_shaders(self, value: bool) -> None:
self._set_boolean("use_projection_fill_shaders", value)
@property
def use_projection_stroke_shaders(self) -> bool:
"""Use shaders for OpenGLVMobject stroke which are compatible with transformation matrices."""
return self._d["use_projection_stroke_shaders"]
@use_projection_stroke_shaders.setter
def use_projection_stroke_shaders(self, value: bool) -> None:
self._set_boolean("use_projection_stroke_shaders", value)
@property
def zero_pad(self) -> int:
"""PNG zero padding. A number between 0 (no zero padding) and 9 (9 columns minimum)."""

View file

@ -2,45 +2,32 @@
from __future__ import annotations
from manim.mobject.opengl.opengl_mobject import OpenGLMobject
from .. import config, logger
from ..constants import RendererType
from ..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", "Add", "override_animation"]
from collections.abc import Callable, Iterable, Sequence
from copy import deepcopy
from functools import partialmethod
from typing import TYPE_CHECKING, Any, Self, assert_never, cast, overload
import numpy as np
from typing_extensions import TypeVar
from manim.mobject.opengl.opengl_mobject import (
OpenGLGroup as Group,
)
from manim.mobject.opengl.opengl_mobject import (
OpenGLMobject as Mobject,
)
from manim.mobject.opengl.opengl_mobject import (
_AnimationBuilder,
)
from .. import logger
from ..utils.rate_functions import linear, smooth
from .protocol import AnimationProtocol, MobjectAnimation
from .scene_buffer import SceneBuffer, SceneOperation
from typing import TYPE_CHECKING, Any, Self
if TYPE_CHECKING:
from typing import Self
from manim.scene.scene import Scene
M = TypeVar("M", bound=Mobject)
__all__ = ["Animation", "Wait", "override_animation"]
DEFAULT_ANIMATION_RUN_TIME: float = 1.0
DEFAULT_ANIMATION_LAG_RATIO: float = 0.0
class Animation(AnimationProtocol):
class Animation:
"""An animation.
Animations have a fixed time span.
@ -82,9 +69,9 @@ class Animation(AnimationProtocol):
.. NOTE::
In the current implementation of this class, the specified rate function is applied
within :meth:`.Animation.interpolate` call as part of the call to
within :meth:`.Animation.interpolate_mobject` call as part of the call to
:meth:`.Animation.interpolate_submobject`. For subclasses of :class:`.Animation`
that are implemented by overriding :meth:`interpolate`, the rate function
that are implemented by overriding :meth:`interpolate_mobject`, the rate function
has to be applied manually (e.g., by passing ``self.rate_func(alpha)`` instead
of just ``alpha``).
@ -140,33 +127,37 @@ class Animation(AnimationProtocol):
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,
reverse_rate_function: bool = False,
name: str = "",
remover: bool = False, # remove a mobject from the screen at end of animation
name: str = None,
remover: bool = False, # remove a mobject from the screen?
suspend_mobject_updating: bool = True,
introducer: bool = False,
*,
_on_finish: Callable[[Scene], None] = lambda _: None,
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
self.rate_func: Callable[[float], float] = rate_func
self.reverse_rate_function: bool = reverse_rate_function
self.name: str = name
self.name: str | None = name
self.remover: bool = remover
self.introducer: bool = introducer
self.suspend_mobject_updating: bool = suspend_mobject_updating
self.lag_ratio: float = lag_ratio
self.buffer = SceneBuffer()
self.apply_buffer = False # ask scene to apply buffer
self.starting_mobject: Mobject = Mobject()
self.mobject: Mobject = mobject if mobject is not None else Mobject()
self._on_finish: Callable[[Scene], None] = _on_finish
if config["renderer"] == RendererType.OPENGL:
self.starting_mobject: OpenGLMobject = OpenGLMobject()
self.mobject: OpenGLMobject = (
mobject if mobject is not None else OpenGLMobject()
)
else:
self.starting_mobject: Mobject = Mobject()
self.mobject: Mobject = mobject if mobject is not None else Mobject()
if hasattr(self, "CONFIG"):
logger.error(
@ -192,7 +183,7 @@ class Animation(AnimationProtocol):
def _typecheck_input(self, mobject: Mobject | None) -> None:
if mobject is None:
logger.debug("Animation with empty mobject")
elif not isinstance(mobject, Mobject):
elif not isinstance(mobject, (Mobject, OpenGLMobject)):
raise TypeError("Animation only works on Mobjects")
def __str__(self) -> str:
@ -203,17 +194,6 @@ class Animation(AnimationProtocol):
def __repr__(self) -> str:
return str(self)
def update_rate_info(
self,
run_time: float | None = None,
rate_func: Callable[[float], float] | None = None,
lag_ratio: float | None = None,
):
self.run_time = run_time or self.run_time
self.rate_func = rate_func or self.rate_func
self.lag_ratio = lag_ratio or self.lag_ratio
return self
def begin(self) -> None:
"""Begin the animation.
@ -233,12 +213,10 @@ class Animation(AnimationProtocol):
self.mobject.suspend_updating()
self.interpolate(0)
# TODO: Figure out a way to check
# if self.mobject in scene.get_mobject_family
if self.introducer:
self.buffer.add(self.mobject)
def finish(self) -> None:
# TODO: begin and finish should require a scene as parameter.
# That way Animation.clean_up_from_screen and Scene.add_mobjects_from_animations
# could be removed as they fulfill basically the same purpose.
"""Finish the animation.
This method gets called when the animation is over.
@ -248,14 +226,45 @@ class Animation(AnimationProtocol):
if self.suspend_mobject_updating and self.mobject is not None:
self.mobject.resume_updating()
if self.remover:
self.buffer.remove(self.mobject)
def clean_up_from_scene(self, scene: Scene) -> None:
"""Clean up the :class:`~.Scene` after finishing the animation.
def create_starting_mobject(self) -> Mobject:
This includes to :meth:`~.Scene.remove` the Animation's
:class:`~.Mobject` if the animation is a remover.
Parameters
----------
scene
The scene the animation should be cleaned up from.
"""
self._on_finish(scene)
if self.is_remover():
scene.remove(self.mobject)
def _setup_scene(self, scene: Scene) -> None:
"""Setup up the :class:`~.Scene` before starting the animation.
This includes to :meth:`~.Scene.add` the Animation's
:class:`~.Mobject` if the animation is an introducer.
Parameters
----------
scene
The scene the animation should be cleaned up from.
"""
if scene is None:
return
if (
self.is_introducer()
and self.mobject not in scene.get_mobject_family_members()
):
scene.add(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
@ -268,7 +277,14 @@ class Animation(AnimationProtocol):
return self.mobject, self.starting_mobject
def get_all_families_zipped(self) -> Iterable[tuple]:
return zip(*(mob.get_family() for mob in self.get_all_mobjects()), strict=False)
if config["renderer"] == RendererType.OPENGL:
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()),
strict=False,
)
def update_mobjects(self, dt: float) -> None:
"""
@ -281,24 +297,7 @@ class Animation(AnimationProtocol):
for mob in self.get_all_mobjects_to_update():
mob.update(dt)
def process_subanimation_buffer(self, buffer: SceneBuffer):
"""
This is used in animations that are proxies around
other animations, like :class:`.AnimationGroup`
"""
for op, args, kwargs in buffer:
match op:
case SceneOperation.ADD:
self.buffer.add(*args, **kwargs)
case SceneOperation.REMOVE:
self.buffer.remove(*args, **kwargs)
case SceneOperation.REPLACE:
self.buffer.replace(*args, **kwargs)
case _:
assert_never(op)
buffer.clear()
def get_all_mobjects_to_update(self) -> Sequence[Mobject]:
def get_all_mobjects_to_update(self) -> list[Mobject]:
"""Get all mobjects to be updated during the animation.
Returns
@ -309,9 +308,9 @@ class Animation(AnimationProtocol):
# The surrounding scene typically handles
# updating of self.mobject. Besides, in
# most cases its updating is suspended anyway
return [m for m in self.get_all_mobjects() if m is not self.mobject]
return list(filter(lambda m: m is not self.mobject, self.get_all_mobjects()))
def copy(self) -> Self:
def copy(self) -> Animation:
"""Create a copy of the animation.
Returns
@ -325,6 +324,19 @@ class Animation(AnimationProtocol):
# TODO: stop using alpha as parameter name in different meanings.
def interpolate(self, alpha: float) -> None:
"""Set the animation progress.
This method gets called for every frame during an animation.
Parameters
----------
alpha
The relative time to set the animation to, 0 meaning the start, 1 meaning
the end.
"""
self.interpolate_mobject(alpha)
def interpolate_mobject(self, alpha: float) -> None:
"""Interpolates the mobject of the :class:`Animation` based on alpha value.
Parameters
@ -334,10 +346,10 @@ class Animation(AnimationProtocol):
is completed. For example, alpha-values of 0, 0.5, and 1 correspond
to the animation being completed 0%, 50%, and 100%, respectively.
"""
families = tuple(self.get_all_families_zipped())
families = list(self.get_all_families_zipped())
for i, mobs in enumerate(families):
sub_alpha = self.get_sub_alpha(alpha, i, len(families))
self.interpolate_submobject(*mobs, sub_alpha) # type: ignore[call-arg]
self.interpolate_submobject(*mobs, sub_alpha)
def interpolate_submobject(
self,
@ -346,7 +358,8 @@ class Animation(AnimationProtocol):
# target_copy: Mobject, #Todo: fix - signature of interpolate_submobject differs in Transform().
alpha: float,
) -> Animation:
raise NotImplementedError("Implement in subclass")
# Typically implemented by subclass
pass
def get_sub_alpha(self, alpha: float, index: int, num_submobjects: int) -> float:
"""Get the animation progress of any submobjects subanimation.
@ -372,14 +385,13 @@ class Animation(AnimationProtocol):
full_length = (num_submobjects - 1) * lag_ratio + 1
value = alpha * full_length
lower = index * lag_ratio
raw_sub_alpha = np.clip((value - lower), 0, 1)
if self.reverse_rate_function:
return self.rate_func(1 - raw_sub_alpha)
return self.rate_func(1 - (value - lower))
else:
return self.rate_func(raw_sub_alpha)
return self.rate_func(value - lower)
# Getters and setters
def set_run_time(self, run_time: float) -> Self:
def set_run_time(self, run_time: float) -> Animation:
"""Set the run time of the animation.
Parameters
@ -414,7 +426,7 @@ class Animation(AnimationProtocol):
def set_rate_func(
self,
rate_func: Callable[[float], float],
) -> Self:
) -> Animation:
"""Set the rate function of the animation.
Parameters
@ -443,7 +455,7 @@ class Animation(AnimationProtocol):
"""
return self.rate_func
def set_name(self, name: str) -> Self:
def set_name(self, name: str) -> Animation:
"""Set the name of the animation.
Parameters
@ -459,6 +471,26 @@ class Animation(AnimationProtocol):
self.name = name
return self
def is_remover(self) -> bool:
"""Test if the animation is a remover.
Returns
-------
bool
``True`` if the animation is a remover, ``False`` otherwise.
"""
return self.remover
def is_introducer(self) -> bool:
"""Test if the animation is an introducer.
Returns
-------
bool
``True`` if the animation is an introducer, ``False`` otherwise.
"""
return self.introducer
@classmethod
def __init_subclass__(cls, **kwargs) -> None:
super().__init_subclass__(**kwargs)
@ -508,19 +540,9 @@ class Animation(AnimationProtocol):
cls.__init__ = cls._original__init__
@overload
def prepare_animation(anim: MobjectAnimation[M]) -> MobjectAnimation[M]: ...
@overload
def prepare_animation(
anim: AnimationProtocol | _AnimationBuilder | Mobject,
) -> AnimationProtocol: ...
def prepare_animation(
anim: AnimationProtocol | _AnimationBuilder | Mobject,
) -> AnimationProtocol:
anim: Animation | mobject._AnimationBuilder | opengl_mobject._AnimationBuilder,
) -> Animation:
r"""Returns either an unchanged animation, or the animation built
from a passed animation factory.
@ -547,17 +569,16 @@ def prepare_animation(
TypeError: Object 42 cannot be converted to an animation
"""
if isinstance(anim, _AnimationBuilder):
if isinstance(anim, mobject._AnimationBuilder):
return anim.build()
# if it has these three methods it probably is an AnimationProtocol
# but we don't use isinstance because it's slow
try:
for method in ("begin", "finish", "update_mobjects"):
getattr(anim, method)
return cast(AnimationProtocol, anim)
except AttributeError:
raise TypeError(f"Object {anim} cannot be converted to an animation") from None
if isinstance(anim, opengl_mobject._AnimationBuilder):
return anim.build()
if isinstance(anim, Animation):
return anim
raise TypeError(f"Object {anim} cannot be converted to an animation")
class Wait(Animation):
@ -594,9 +615,12 @@ class Wait(Animation):
if stop_condition and frozen_frame:
raise ValueError("A static Wait animation cannot have a stop condition.")
self.duration: float = run_time
self.stop_condition = stop_condition
self.is_static_wait: bool = bool(frozen_frame)
self.is_static_wait: bool = frozen_frame
super().__init__(None, run_time=run_time, rate_func=rate_func, **kwargs)
# quick fix to work in opengl setting:
self.mobject.shader_wrapper_list = []
def begin(self) -> None:
pass
@ -604,6 +628,9 @@ class Wait(Animation):
def finish(self) -> None:
pass
def clean_up_from_scene(self, scene: Scene) -> None:
pass
def update_mobjects(self, dt: float) -> None:
pass
@ -733,10 +760,9 @@ def override_animation(
self.play(FadeIn(MySquare()))
"""
_F = TypeVar("_F", bound=Callable)
def decorator(func: _F) -> _F:
func._override_animation = animation_class # type: ignore[attr-defined]
def decorator(func):
func._override_animation = animation_class
return func
return decorator

View file

@ -4,13 +4,12 @@ from __future__ import annotations
__all__ = ["AnimatedBoundary", "TracedPath"]
from typing import TYPE_CHECKING, Any, Self
import numpy as np
from collections.abc import Callable, Sequence
from typing import Any, Self
from manim.mobject.mobject import Mobject
from manim.mobject.opengl.opengl_vectorized_mobject import OpenGLVGroup as VGroup
from manim.mobject.opengl.opengl_vectorized_mobject import OpenGLVMobject as VMobject
from manim.mobject.opengl.opengl_compatibility import ConvertToOpenGL
from manim.mobject.types.vectorized_mobject import VGroup, VMobject
from manim.utils.color import (
BLUE_B,
BLUE_D,
@ -21,11 +20,6 @@ from manim.utils.color import (
)
from manim.utils.rate_functions import RateFunction, smooth
if TYPE_CHECKING:
from collections.abc import Callable, Sequence
import numpy.typing as npt
class AnimatedBoundary(VGroup):
"""Boundary of a :class:`.VMobject` with animated color change.
@ -68,7 +62,7 @@ class AnimatedBoundary(VGroup):
]
self.add(*self.boundary_copies)
self.total_time = 0.0
self.add_updater(lambda _, dt: self.update_boundary_copies(dt))
self.add_updater(lambda m, dt: self.update_boundary_copies(dt))
def update_boundary_copies(self, dt: float) -> None:
# Not actual time, but something which passes at
@ -108,7 +102,7 @@ class AnimatedBoundary(VGroup):
return self
class TracedPath(VMobject):
class TracedPath(VMobject, metaclass=ConvertToOpenGL):
"""Traces the path of a point returned by a function call.
Parameters
@ -152,32 +146,23 @@ class TracedPath(VMobject):
def __init__(
self,
traced_point_func: Callable[
[], npt.NDArray[npt.float]
], # TODO: Replace with Callable[[], Point3D]
traced_point_func: Callable,
stroke_width: float = 2,
stroke_color: ParsableManimColor | None = WHITE,
dissipating_time: float | None = None,
fill_opacity: float = 0.0,
**kwargs: Any,
):
super().__init__(
stroke_color=stroke_color,
stroke_width=stroke_width,
fill_opacity=fill_opacity,
**kwargs,
)
) -> 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.0 if self.dissipating_time else None
self.add_updater(self.update_path)
def update_path(self, _mob: Mobject, dt: float) -> None:
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)
if not np.allclose(self.get_end(), new_point):
self.add_line_to(new_point)
self.add_line_to(new_point)
if self.dissipating_time:
assert self.time is not None
self.time += dt

View file

@ -7,19 +7,19 @@ from typing import TYPE_CHECKING, Any
import numpy as np
from manim._config import config
from manim.animation.animation import Animation, prepare_animation
from manim.mobject.opengl.opengl_mobject import (
OpenGLGroup as Group,
)
from manim.mobject.opengl.opengl_mobject import (
OpenGLMobject as Mobject,
)
from manim.constants import RendererType
from manim.mobject.mobject import Group, Mobject
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
from manim.utils.rate_functions import linear
if TYPE_CHECKING:
from manim.mobject.opengl.opengl_vectorized_mobject import OpenGLVGroup as VGroup
from manim.mobject.opengl.opengl_vectorized_mobject import OpenGLVGroup
from manim.mobject.types.vectorized_mobject import VGroup
__all__ = ["AnimationGroup", "Succession", "LaggedStart", "LaggedStartMap"]
@ -54,21 +54,25 @@ class AnimationGroup(Animation):
def __init__(
self,
*animations: Animation | Iterable[Animation],
group: Group | VGroup | None = None,
group: Group | VGroup | OpenGLGroup | OpenGLVGroup | None = None,
run_time: float | None = None,
rate_func: Callable[[float], float] = linear,
lag_ratio: float = 0,
**kwargs: Any,
):
arg_anim = flatten_iterable_parameters(animations)
self.animations = [prepare_animation(anim) for anim in arg_anim]
self.rate_func = rate_func
if group is None:
mobjects = remove_list_redundancies(
[anim.mobject for anim in self.animations if not anim.introducer],
[anim.mobject for anim in self.animations if not anim.is_introducer()],
)
self.group = Group(*mobjects)
if config["renderer"] == RendererType.OPENGL:
self.group: Group | VGroup | OpenGLGroup | OpenGLVGroup = OpenGLGroup(
*mobjects
)
else:
self.group = Group(*mobjects)
else:
self.group = group
super().__init__(
@ -76,7 +80,7 @@ class AnimationGroup(Animation):
)
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:
@ -85,31 +89,31 @@ class AnimationGroup(Animation):
f"Trying to play {self} without animations, this is not supported. "
"Please add at least one subanimation."
)
for anim in self.animations:
if self.introducer:
anim.introducer = True
anim.begin()
self.process_subanimation_buffer(anim.buffer)
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: Scene) -> None:
for anim in self.animations:
anim._setup_scene(scene)
def finish(self) -> None:
for anim in self.animations:
anim.finish()
self.anims_begun[:] = True
self.anims_finished[:] = True
for anim in self.animations:
if self.remover:
anim.remover = True
anim.finish()
self.process_subanimation_buffer(anim.buffer)
if self.suspend_mobject_updating:
self.group.resume_updating()
def clean_up_from_scene(self, scene: Scene) -> None:
self._on_finish(scene)
for anim in self.animations:
if self.remover:
anim.remover = self.remover
anim.clean_up_from_scene(scene)
def update_mobjects(self, dt: float) -> None:
for anim in self.anims_with_timings["anim"][
self.anims_begun & ~self.anims_finished
@ -247,6 +251,16 @@ class Succession(AnimationGroup):
if self.active_animation:
self.active_animation.update_mobjects(dt)
def _setup_scene(self, scene: Scene | None) -> None:
if scene is None:
return
if self.is_introducer():
for anim in self.animations:
if not anim.is_introducer() and anim.mobject is not None:
scene.add(anim.mobject)
self.scene = scene
def update_active_animation(self, index: int) -> None:
self.active_index = index
if index >= len(self.animations):
@ -255,9 +269,8 @@ class Succession(AnimationGroup):
self.active_end_time: float | None = None
else:
self.active_animation = self.animations[index]
self.active_animation._setup_scene(self.scene)
self.active_animation.begin()
self.process_subanimation_buffer(self.active_animation.buffer)
self.apply_buffer = True
self.active_start_time = self.anims_with_timings[index]["start"]
self.active_end_time = self.anims_with_timings[index]["end"]
@ -268,7 +281,6 @@ class Succession(AnimationGroup):
"""
if self.active_animation is not None:
self.active_animation.finish()
self.process_subanimation_buffer(self.active_animation.buffer)
self.update_active_animation(self.active_index + 1)
def interpolate(self, alpha: float) -> None:

View file

@ -82,26 +82,19 @@ from typing import TYPE_CHECKING
import numpy as np
if TYPE_CHECKING:
from typing import Any, Self
from manim.mobject.text.text_mobject import Text
from manim.scene.scene import Scene
from manim.constants import RIGHT, TAU
from manim.mobject.opengl.opengl_surface import OpenGLSurface
from manim.mobject.opengl.opengl_vectorized_mobject import OpenGLVMobject as VMobject
from manim.mobject.opengl.opengl_vectorized_mobject import OpenGLVMobject
from manim.utils.color import ManimColor
from manim.utils.space_ops import rotate_vector
from .. import config
from ..animation.animation import Animation
from ..animation.composition import Succession
from ..mobject.opengl.opengl_mobject import (
OpenGLGroup as Group,
)
from ..mobject.opengl.opengl_mobject import (
OpenGLMobject as Mobject,
)
from ..mobject.mobject import Group, Mobject
from ..mobject.types.vectorized_mobject import VMobject
from ..utils.bezier import integer_interpolate
from ..utils.rate_functions import double_smooth, linear
@ -120,7 +113,11 @@ class ShowPartial(Animation):
"""
def __init__(self, mobject: VMobject | OpenGLSurface | None, **kwargs: Any):
def __init__(
self,
mobject: VMobject | OpenGLVMobject | OpenGLSurface | None,
**kwargs,
):
pointwise = getattr(mobject, "pointwise_become_partial", None)
if not callable(pointwise):
raise TypeError(f"{self.__class__.__name__} only works for VMobjects.")
@ -131,11 +128,10 @@ class ShowPartial(Animation):
submobject: Mobject,
starting_submobject: Mobject,
alpha: float,
) -> Self:
) -> None:
submobject.pointwise_become_partial(
starting_submobject, *self._get_bounds(alpha)
)
return self
def _get_bounds(self, alpha: float) -> tuple[float, float]:
raise NotImplementedError("Please use Create or ShowPassingFlash")
@ -170,7 +166,7 @@ class Create(ShowPartial):
def __init__(
self,
mobject: VMobject | OpenGLSurface,
mobject: VMobject | OpenGLVMobject | OpenGLSurface,
lag_ratio: float = 1.0,
introducer: bool = True,
**kwargs,
@ -200,7 +196,7 @@ class Uncreate(Create):
def __init__(
self,
mobject: VMobject,
mobject: VMobject | OpenGLVMobject,
reverse_rate_function: bool = True,
remover: bool = True,
**kwargs,
@ -228,13 +224,11 @@ class DrawBorderThenFill(Animation):
def __init__(
self,
vmobject: VMobject,
vmobject: VMobject | OpenGLVMobject,
run_time: float = 2,
rate_func: Callable[[float], float] = double_smooth,
stroke_width: float = 2,
stroke_color: ManimColor | None = None,
draw_border_animation_config: dict = {}, # what does this dict accept?
fill_animation_config: dict = {},
stroke_color: str = None,
introducer: bool = True,
**kwargs,
) -> None:
@ -250,15 +244,13 @@ class DrawBorderThenFill(Animation):
self.stroke_color = stroke_color
self.outline = self.get_outline()
def _typecheck_input(self, vmobject: VMobject) -> None:
if not isinstance(vmobject, VMobject):
def _typecheck_input(self, vmobject: VMobject | OpenGLVMobject) -> None:
if not isinstance(vmobject, (VMobject, OpenGLVMobject)):
raise TypeError(
f"{self.__class__.__name__} only works for vectorized Mobjects"
)
def begin(self) -> None:
# this self.get_outline() has to be called
# before super().begin(), for whatever reason
self.outline = self.get_outline()
super().begin()
@ -269,7 +261,7 @@ class DrawBorderThenFill(Animation):
sm.set_stroke(color=self.get_stroke_color(sm), width=self.stroke_width)
return outline
def get_stroke_color(self, vmobject: VMobject) -> ManimColor:
def get_stroke_color(self, vmobject: VMobject | OpenGLVMobject) -> ManimColor:
if self.stroke_color:
return self.stroke_color
elif vmobject.get_stroke_width() > 0:
@ -283,9 +275,9 @@ class DrawBorderThenFill(Animation):
self,
submobject: Mobject,
starting_submobject: Mobject,
outline: Mobject,
outline,
alpha: float,
) -> None:
) -> None: # Fixme: not matching the parent class? What is outline doing here?
index: int
subalpha: float
index, subalpha = integer_interpolate(0, 2, alpha)
@ -325,13 +317,13 @@ class Write(DrawBorderThenFill):
def __init__(
self,
vmobject: VMobject,
vmobject: VMobject | OpenGLVMobject,
rate_func: Callable[[float], float] = linear,
reverse: bool = False,
run_time: float | None = None,
lag_ratio: float | None = None,
**kwargs,
) -> None:
run_time: float | None = kwargs.pop("run_time", None)
lag_ratio: float | None = kwargs.pop("lag_ratio", None)
run_time, lag_ratio = self._set_default_config_from_length(
vmobject,
run_time,
@ -351,7 +343,7 @@ class Write(DrawBorderThenFill):
def _set_default_config_from_length(
self,
vmobject: VMobject,
vmobject: VMobject | OpenGLVMobject,
run_time: float | None,
lag_ratio: float | None,
) -> tuple[float, float]:
@ -362,15 +354,18 @@ class Write(DrawBorderThenFill):
lag_ratio = min(4.0 / max(1.0, length), 0.2)
return run_time, lag_ratio
def reverse_submobjects(self) -> None:
self.mobject.invert(recursive=True)
def begin(self) -> None:
if self.reverse:
self.mobject.reverse_submobjects(recursive=True)
self.reverse_submobjects()
super().begin()
def finish(self) -> None:
super().finish()
if self.reverse:
self.mobject.reverse_submobjects(recursive=True)
self.reverse_submobjects()
class Unwrite(Write):
@ -457,33 +452,28 @@ class SpiralIn(Animation):
self,
shapes: Mobject,
scale_factor: float = 8,
fade_in_fraction: float = 0.3,
**kwargs: Any,
fade_in_fraction=0.3,
**kwargs,
) -> None:
self.shapes = shapes.copy()
self.scale_factor = scale_factor
self.shape_center = shapes.get_center()
self.fade_in_fraction = fade_in_fraction
self.final_positions = [shape.get_center() for shape in shapes]
self.initial_positions = [
final_pos + (final_pos - self.shape_center) * self.scale_factor
for final_pos in self.final_positions
]
for shape in shapes:
shape.final_position = shape.get_center()
shape.initial_position = (
shape.final_position
+ (shape.final_position - self.shape_center) * self.scale_factor
)
shape.move_to(shape.initial_position)
shape.save_state()
super().__init__(shapes, introducer=True, **kwargs)
def interpolate(self, alpha: float) -> None:
def interpolate_mobject(self, alpha: float) -> None:
alpha = self.rate_func(alpha)
for i, shape in enumerate(self.mobject):
initial_pos = self.initial_positions[i]
final_pos = self.final_positions[i]
# Avoid shape.rotate() in order to preserve the bounding box of the shape.
# Instead, rotate the vector itself to calculate the current shape position.
vector = initial_pos - self.shape_center + (final_pos - initial_pos) * alpha
vector = rotate_vector(vector, TAU * alpha)
shape.move_to(self.shape_center + vector)
original_shape = self.shapes[i]
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()
new_fill_opacity = min(
@ -492,6 +482,9 @@ class SpiralIn(Animation):
new_stroke_opacity = min(
stroke_opacity, alpha * stroke_opacity / self.fade_in_fraction
)
shape.shift((shape.final_position - shape.initial_position) * alpha)
shape.rotate(TAU * alpha, about_point=self.shape_center)
shape.rotate(-TAU * alpha, about_point=shape.get_center_of_mass())
shape.set_fill(opacity=new_fill_opacity)
shape.set_stroke(opacity=new_stroke_opacity)
@ -531,7 +524,7 @@ class ShowIncreasingSubsets(Animation):
**kwargs,
)
def interpolate(self, alpha: float) -> None:
def interpolate_mobject(self, alpha: float) -> None:
n_submobs = len(self.all_submobs)
value = (
1 - self.rate_func(alpha)

View file

@ -23,15 +23,12 @@ from typing import Any
import numpy as np
from manim.mobject.opengl.opengl_mobject import (
OpenGLGroup as Group,
)
from manim.mobject.opengl.opengl_mobject import (
OpenGLMobject as Mobject,
)
from manim.mobject.opengl.opengl_mobject import OpenGLMobject
from ..animation.transform import Transform
from ..constants import ORIGIN
from ..mobject.mobject import Group, Mobject
from ..scene.scene import Scene
class _Fade(Transform):
@ -67,7 +64,7 @@ class _Fade(Transform):
self.point_target = False
if shift is None:
if target_position is not None:
if isinstance(target_position, Mobject):
if isinstance(target_position, (Mobject, OpenGLMobject)):
target_position = target_position.get_center()
shift = target_position - mobject.get_center()
self.point_target = True
@ -77,12 +74,12 @@ class _Fade(Transform):
self.scale_factor = scale
super().__init__(mobject, **kwargs)
def _create_faded_mobject(self, fade_in: bool) -> Mobject:
def _create_faded_mobject(self, fadeIn: bool) -> Mobject:
"""Create a faded, shifted and scaled copy of the mobject.
Parameters
----------
fade_in
fadeIn
Whether the faded mobject is used to fade in.
Returns
@ -92,7 +89,7 @@ class _Fade(Transform):
"""
faded_mobject: Mobject = self.mobject.copy() # type: ignore[assignment]
faded_mobject.fade(1)
direction_modifier = -1 if fade_in and not self.point_target else 1
direction_modifier = -1 if fadeIn and not self.point_target else 1
faded_mobject.shift(self.shift_vector * direction_modifier)
faded_mobject.scale(self.scale_factor)
return faded_mobject
@ -140,10 +137,10 @@ class FadeIn(_Fade):
super().__init__(*mobjects, introducer=True, **kwargs)
def create_target(self) -> Mobject:
return self.mobject
return self.mobject # type: ignore[return-value]
def create_starting_mobject(self) -> Mobject:
return self._create_faded_mobject(fade_in=True)
return self._create_faded_mobject(fadeIn=True)
class FadeOut(_Fade):
@ -188,8 +185,8 @@ class FadeOut(_Fade):
super().__init__(*mobjects, remover=True, **kwargs)
def create_target(self) -> Mobject:
return self._create_faded_mobject(fade_in=False)
return self._create_faded_mobject(fadeIn=False)
def begin(self) -> None:
super().begin()
def clean_up_from_scene(self, scene: Scene) -> None:
super().clean_up_from_scene(scene)
self.interpolate(0)

View file

@ -39,10 +39,12 @@ from ..utils.paths import spiral_path
if TYPE_CHECKING:
from manim.mobject.geometry.line import Arrow
from manim.mobject.opengl.opengl_mobject import OpenGLMobject as Mobject
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
class GrowFromPoint(Transform):
"""Introduce an :class:`~.Mobject` by growing it from a point.
@ -85,10 +87,10 @@ class GrowFromPoint(Transform):
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)
@ -167,7 +169,7 @@ class GrowFromEdge(GrowFromPoint):
point_color: ParsableManimColor | None = None,
**kwargs: Any,
):
point = mobject.get_bounding_box_point(edge)
point = mobject.get_critical_point(edge)
super().__init__(mobject, point, point_color=point_color, **kwargs)
@ -201,7 +203,7 @@ class GrowArrow(GrowFromPoint):
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:

View file

@ -48,7 +48,8 @@ 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 as Mobject
from manim.mobject.opengl.opengl_mobject import OpenGLMobject
from manim.scene.scene import Scene
from .. import config
from ..animation.animation import Animation
@ -59,12 +60,8 @@ from ..animation.movement import Homotopy
from ..animation.transform import Transform
from ..animation.updaters.update import UpdateFromFunc
from ..constants import *
from ..mobject.opengl.opengl_vectorized_mobject import (
OpenGLVGroup as VGroup,
)
from ..mobject.opengl.opengl_vectorized_mobject import (
OpenGLVMobject as VMobject,
)
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, PURE_YELLOW, ParsableManimColor
@ -164,7 +161,7 @@ class Indicate(Transform):
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)
@ -322,8 +319,8 @@ class ShowPassingFlash(ShowPartial):
lower = max(lower, 0)
return (lower, upper)
def finish(self) -> None:
super().finish()
def clean_up_from_scene(self, scene: Scene) -> None:
super().clean_up_from_scene(scene)
for submob, start in self.get_all_families_zipped():
submob.pointwise_become_partial(start, 0, 1)
@ -410,7 +407,6 @@ class ApplyWave(Homotopy):
time_width: float = 1,
ripples: int = 1,
run_time: float = 2,
introducer: bool = True,
**kwargs: Any,
):
x_min = mobject.get_left()[0]
@ -486,9 +482,7 @@ class ApplyWave(Homotopy):
return_value: tuple[float, float, float] = np.array([x, y, z]) + nudge
return return_value
super().__init__(
homotopy, mobject, run_time=run_time, introducer=introducer, **kwargs
)
super().__init__(homotopy, mobject, run_time=run_time, **kwargs)
class Wiggle(Animation):
@ -574,7 +568,6 @@ class Wiggle(Animation):
return self
# TODO: get rid of this if condition madness
class Circumscribe(Succession):
r"""Draw a temporary line surrounding the mobject.

View file

@ -82,7 +82,9 @@ class Homotopy(Animation):
**kwargs: Any,
):
self.homotopy = homotopy
self.apply_function_kwargs = apply_function_kwargs or {}
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) -> MappingFunction:
@ -98,7 +100,7 @@ class Homotopy(Animation):
starting_submobject: Mobject,
alpha: float,
) -> Self:
submobject.match_points(starting_submobject)
submobject.points = starting_submobject.points
submobject.apply_function(
self.function_at_time_t(alpha),
**self.apply_function_kwargs,
@ -159,7 +161,7 @@ class PhaseFlow(Animation):
**kwargs,
)
def interpolate(self, alpha: float) -> None:
def interpolate_mobject(self, alpha: float) -> None:
if hasattr(self, "last_alpha"):
dt = self.virtual_time * (
self.rate_func(alpha) - self.rate_func(self.last_alpha)
@ -195,6 +197,6 @@ class MoveAlongPath(Animation):
mobject, suspend_mobject_updating=suspend_mobject_updating, **kwargs
)
def interpolate(self, alpha: float) -> None:
def interpolate_mobject(self, alpha: float) -> None:
point = self.path.point_from_proportion(self.rate_func(alpha))
self.mobject.move_to(point)

View file

@ -67,7 +67,7 @@ class ChangingDecimal(Animation):
if not isinstance(decimal_mob, DecimalNumber):
raise TypeError("ChangingDecimal can only take in a DecimalNumber")
def interpolate(self, alpha: float) -> None:
def interpolate_mobject(self, alpha: float) -> None:
self.mobject.set_value(self.number_update_func(self.rate_func(alpha))) # type: ignore[attr-defined]

View file

@ -1,82 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Protocol
from typing_extensions import TypeVar
if TYPE_CHECKING:
from manim.mobject.opengl.opengl_mobject import OpenGLMobject as Mobject
from manim.utils.rate_functions import RateFunction
from .scene_buffer import SceneBuffer
M = TypeVar("M", bound="Mobject", default="Mobject")
__all__ = ("AnimationProtocol",)
class AnimationProtocol(Protocol):
"""A protocol that all animations must implement."""
buffer: SceneBuffer
"""The interface to the scene. This can be used to add, remove, or replace mobjects on the scene."""
apply_buffer: bool
"""Normally, the buffer is only applied at the beginning and end of an animation.
To apply it mid animation, set :attr:`apply_buffer` to ``True``."""
def begin(self) -> object:
"""Called before the animation starts.
This is where all setup for the animation should be done, such
as creating copies/targets of the mobject to animate, etc.
"""
def finish(self) -> object:
"""Called after the animation finishes.
This is where all cleanup should happen, such as removing
mobjects from the scene, etc.
"""
def interpolate(self, alpha: float) -> object:
"""This is called every frame of the animation.
This method should update the animation to the given ``alpha`` value.
Parameters
----------
alpha : a value in the interval :math:`[0, 1]` representing the proportion of the animation that has passed.
"""
def get_run_time(self) -> float:
"""Compute and return the run time of the animation."""
raise NotImplementedError
def update_rate_info(
self,
run_time: float | None,
rate_func: RateFunction | None,
lag_ratio: float | None,
) -> object:
"""Update the rate information for the animation.
If any value is ``None``, it should not update
the animation's corresponding attribute.
"""
def update_mobjects(self, dt: float) -> object:
"""Update the mobjects during the animation.
This method is called every frame of the animation
"""
class MobjectAnimation(AnimationProtocol, Protocol[M]):
mobject: M
"""The mobject that is being animated."""
suspend_mobject_updating: bool
"""Whether to suspend updating the mobject during the animation."""

View file

@ -4,16 +4,18 @@ from __future__ import annotations
__all__ = ["Rotating", "Rotate"]
from collections.abc import Callable
from typing import TYPE_CHECKING, Any
from manim.animation.animation import Animation
from manim.constants import ORIGIN, OUT, PI, TAU
from manim.utils.rate_functions import RateFunction, linear
from ..animation.animation import Animation
from ..animation.transform import Transform
from ..constants import OUT, PI, TAU
from ..utils.rate_functions import linear
if TYPE_CHECKING:
from manim.mobject.opengl.opengl_mobject import OpenGLMobject as Mobject
from manim.typing import Point3DLike, Vector3DLike
from manim.utils.rate_functions import RateFunction
from ..mobject.mobject import Mobject
from ..mobject.opengl.opengl_mobject import OpenGLMobject
from ..typing import Point3DLike, Vector3DLike
class Rotating(Animation):
@ -90,30 +92,18 @@ class Rotating(Animation):
axis: Vector3DLike = OUT,
about_point: Point3DLike | None = None,
about_edge: Vector3DLike | None = None,
rate_func: RateFunction = linear,
suspend_mobject_updating: bool = False,
run_time: float = 5,
rate_func: Callable[[float], float] = linear,
**kwargs: Any,
):
super().__init__(
mobject,
rate_func=rate_func,
suspend_mobject_updating=suspend_mobject_updating,
**kwargs,
)
) -> None:
self.angle = angle
self.axis = axis
self.about_point = about_point
self.about_edge = about_edge
super().__init__(mobject, run_time=run_time, rate_func=rate_func, **kwargs)
def interpolate(self, alpha: float) -> None:
pairs = zip(
self.mobject.family_members_with_points(),
self.starting_mobject.family_members_with_points(),
strict=True,
)
for sm1, sm2 in pairs:
sm1.points[:] = sm2.points
def interpolate_mobject(self, alpha: float) -> None:
self.mobject.become(self.starting_mobject)
self.mobject.rotate(
self.rate_func(alpha) * self.angle,
axis=self.axis,
@ -122,7 +112,7 @@ class Rotating(Animation):
)
class Rotate(Rotating):
class Rotate(Transform):
"""Animation that rotates a Mobject.
Parameters
@ -154,7 +144,7 @@ class Rotate(Rotating):
rate_func=linear,
),
Rotate(Square(side_length=0.5), angle=2*PI, rate_func=linear),
)
)
See also
--------
@ -167,16 +157,28 @@ class Rotate(Rotating):
mobject: Mobject,
angle: float = PI,
axis: Vector3DLike = OUT,
run_time: float = 1,
about_edge: Vector3DLike = ORIGIN,
about_point: Point3DLike | None = None,
about_edge: Vector3DLike | None = None,
**kwargs: Any,
):
super().__init__(
mobject,
angle,
axis,
run_time=run_time,
about_edge=about_edge,
introducer=True,
**kwargs,
) -> None:
if "path_arc" not in kwargs:
kwargs["path_arc"] = angle
if "path_arc_axis" not in kwargs:
kwargs["path_arc_axis"] = axis
self.angle = angle
self.axis = axis
self.about_edge = about_edge
self.about_point = about_point
if self.about_point is None:
self.about_point = mobject.get_center()
super().__init__(mobject, path_arc_centers=self.about_point, **kwargs)
def create_target(self) -> Mobject | OpenGLMobject:
target = self.mobject.copy()
target.rotate(
self.angle,
axis=self.axis,
about_point=self.about_point,
about_edge=self.about_edge,
)
return target

View file

@ -1,79 +0,0 @@
from __future__ import annotations
from collections.abc import Iterator, Sequence
from enum import Enum
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from manim.mobject.opengl.opengl_mobject import OpenGLMobject as Mobject
__all__ = ["SceneBuffer", "SceneOperation"]
class SceneOperation(Enum):
ADD = "add"
REMOVE = "remove"
REPLACE = "replace"
class SceneBuffer:
"""
A "buffer" between :class:`.Scene` and :class:`.Animation`
Operations an animation wants to do on :class:`.Scene` should be
done here (eg. :meth:`.Scene.add`, :meth:`.Scene.remove`). The
scene will then apply these changes at specific points (namely
at the beginning and end of animations)
It is the scenes job to clear the buffer in between the beginning
and end of animations.
To iterate over the operations, simply iterate over the buffer.
Example
-------
.. code-block:: pycon
>>> buffer = SceneBuffer()
>>> buffer.add(Square())
>>> buffer.remove(Circle())
>>> buffer.replace(Square(), Circle(), flag=True)
>>> for operation in buffer:
... print(operation)
(SceneOperation.ADD, (Square(),), {})
(SceneOperation.REMOVE, (Circle(),), {})
(SceneOperation.REPLACE, (Square(), Circle()), {"flag": True})
"""
def __init__(self) -> None:
self.operations: list[
tuple[SceneOperation, Sequence[Mobject], dict[str, Any]]
] = []
def add(self, *mobs: Mobject, **kwargs: Any) -> None:
"""Add mobjects to the scene."""
self.operations.append((SceneOperation.ADD, mobs, kwargs))
def remove(self, *mobs: Mobject, **kwargs: Any) -> None:
"""Remove mobjects from the scene."""
self.operations.append((SceneOperation.REMOVE, mobs, kwargs))
def replace(self, mob: Mobject, *replacements: Mobject, **kwargs: Any) -> None:
"""Replace a ``mob`` with ``replacements`` on the scene."""
self.operations.append((SceneOperation.REPLACE, (mob, *replacements), kwargs))
def clear(self) -> None:
"""Clear the buffer."""
self.operations.clear()
def __str__(self) -> str:
operations = self.operations
return f"{type(self).__name__}({operations=})"
__repr__ = __str__
def __iter__(
self,
) -> Iterator[tuple[SceneOperation, Sequence[Mobject], dict[str, Any]]]:
return iter(self.operations)

View file

@ -12,10 +12,10 @@ from numpy import piecewise
from ..animation.animation import Animation, Wait, prepare_animation
from ..animation.composition import AnimationGroup
from ..mobject.mobject import Mobject, _AnimationBuilder
from ..scene.scene import Scene
if TYPE_CHECKING:
from ..mobject.mobject import Updater
from .protocol import MobjectAnimation
__all__ = ["ChangeSpeed"]
@ -102,7 +102,7 @@ class ChangeSpeed(Animation):
affects_speed_updaters: bool = True,
**kwargs,
) -> None:
if isinstance(anim, AnimationGroup):
if issubclass(type(anim), AnimationGroup):
self.anim = type(anim)(
*map(self.setup, anim.animations),
group=anim.group,
@ -209,11 +209,11 @@ class ChangeSpeed(Animation):
super().__init__(
self.anim.mobject,
rate_func=self.rate_func,
run_time=scaled_total_time * self.anim.get_run_time(),
run_time=scaled_total_time * self.anim.run_time,
**kwargs,
)
def setup(self, anim: MobjectAnimation):
def setup(self, anim):
if type(anim) is Wait:
anim.interpolate = types.MethodType(
lambda self, alpha: self.rate_func(alpha), anim
@ -282,11 +282,15 @@ class ChangeSpeed(Animation):
def update_mobjects(self, dt: float) -> None:
self.anim.update_mobjects(dt)
def begin(self) -> None:
self.anim.begin()
self.process_subanimation_buffer(self.anim.buffer)
def finish(self) -> None:
ChangeSpeed.is_changing_dt = False
self.anim.finish()
self.process_subanimation_buffer(self.anim.buffer)
def begin(self) -> None:
self.anim.begin()
def clean_up_from_scene(self, scene: Scene) -> None:
self.anim.clean_up_from_scene(scene)
def _setup_scene(self, scene) -> None:
self.anim._setup_scene(scene)

View file

@ -2,8 +2,6 @@
from __future__ import annotations
from manim.typing import PathFuncType
__all__ = [
"Transform",
"ReplacementTransform",
@ -30,31 +28,31 @@ __all__ = [
import inspect
import types
from collections.abc import Callable, Sequence
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 as Group,
)
from manim.mobject.opengl.opengl_mobject import (
OpenGLMobject as Mobject,
)
from manim.mobject.opengl.opengl_mobject import OpenGLGroup, OpenGLMobject
from .. import config
from ..animation.animation import Animation
from ..constants import (
DEFAULT_POINTWISE_FUNCTION_RUN_TIME,
DEGREES,
ORIGIN,
OUT,
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 manim.typing import Point3DLike, Point3DLike_Array
from ..scene.scene import Scene
from ..typing import Point3DLike, Point3DLike_Array
class Transform(Animation):
@ -182,33 +180,46 @@ class Transform(Animation):
@property
def path_func(
self,
) -> PathFuncType:
) -> Callable[
[Iterable[np.ndarray], Iterable[np.ndarray], float],
Iterable[np.ndarray],
]:
return self._path_func
@path_func.setter
def path_func(
self,
path_func: PathFuncType,
path_func: Callable[
[Iterable[np.ndarray], Iterable[np.ndarray], float],
Iterable[np.ndarray],
],
) -> None:
if path_func is not None:
self._path_func = path_func
def begin(self) -> None:
# Use a copy of target_mobject for the align_data
# call so that the actual target_mobject stays
# preserved.
self.target_mobject = self.create_target()
self.target_copy = self.target_mobject.copy()
self.mobject.align_data_and_family(self.target_copy)
# Note, this potentially changes the structure
# of both mobject and target_mobject
if config.renderer == RendererType.OPENGL:
self.mobject.align_data_and_family(self.target_copy)
else:
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
def finish(self) -> None:
super().finish()
def clean_up_from_scene(self, scene: Scene) -> None:
super().clean_up_from_scene(scene)
if self.replace_mobject_with_target_in_scene:
self.buffer.replace(self.mobject, self.target_mobject)
scene.replace(self.mobject, self.target_mobject)
def get_all_mobjects(self) -> Sequence[Mobject]:
return [
@ -218,13 +229,15 @@ class Transform(Animation):
self.target_copy,
]
def get_all_families_zipped(self) -> zip[tuple[Mobject, Mobject, Mobject]]:
def get_all_families_zipped(self) -> Iterable[tuple]: # more precise typing?
mobs = [
self.mobject,
self.starting_mobject,
self.target_copy,
]
return zip(*(mob.get_family() for mob in mobs), strict=True)
if config.renderer == RendererType.OPENGL:
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,
@ -471,7 +484,7 @@ class ApplyMethod(Transform):
"Whoops, looks like you accidentally invoked "
"the method you want to animate",
)
assert isinstance(method.__self__, Mobject)
assert isinstance(method.__self__, (Mobject, OpenGLMobject))
def create_target(self) -> Mobject:
method = self.method
@ -615,7 +628,7 @@ class ApplyFunction(Transform):
def create_target(self) -> Any:
target = self.function(self.mobject.copy())
if not isinstance(target, Mobject):
if not isinstance(target, (Mobject, OpenGLMobject)):
raise TypeError(
"Functions passed to ApplyFunction must return object of type Mobject",
)
@ -680,7 +693,6 @@ class ApplyComplexFunction(ApplyMethod):
super().__init__(method, function, **kwargs)
def _init_path_func(self) -> None:
# TODO: this seems broken?
func1 = self.function(complex(1))
self.path_arc = np.log(func1).imag
super()._init_path_func()
@ -724,10 +736,13 @@ 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, strict=True):
@ -736,7 +751,21 @@ class CyclicReplace(Transform):
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?
@ -824,12 +853,22 @@ 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
mobject.save_state()
group = Group(mobject, target_mobject.copy())
if config.renderer == RendererType.OPENGL:
group = OpenGLGroup(mobject, target_mobject.copy())
else:
group = Group(mobject, target_mobject.copy())
super().__init__(group, **kwargs)
def begin(self):
@ -870,11 +909,11 @@ class FadeTransform(Transform):
def get_all_families_zipped(self):
return Animation.get_all_families_zipped(self)
def finish(self):
Animation.finish(self) # TODO: is this really needed over super()?
self.buffer.remove(self.mobject)
def clean_up_from_scene(self, scene):
Animation.clean_up_from_scene(self, scene)
scene.remove(self.mobject)
self.mobject[0].restore()
self.buffer.add(self.to_add_on_completion)
scene.add(self.to_add_on_completion)
class FadeTransformPieces(FadeTransform):

View file

@ -4,28 +4,25 @@ from __future__ import annotations
__all__ = ["TransformMatchingShapes", "TransformMatchingTex"]
from typing import TYPE_CHECKING, Any
import numpy as np
from manim.mobject.opengl.opengl_mobject import (
OpenGLGroup as Group,
)
from manim.mobject.opengl.opengl_mobject import (
OpenGLMobject as Mobject,
)
from manim.mobject.opengl.opengl_vectorized_mobject import (
OpenGLVGroup as VGroup,
)
from manim.mobject.opengl.opengl_vectorized_mobject import (
OpenGLVMobject as VMobject,
)
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
from ..mobject.mobject import Group, Mobject
from ..mobject.types.vectorized_mobject import VGroup, VMobject
from .composition import AnimationGroup
from .fading import FadeIn, FadeOut
from .transform import FadeTransformPieces, Transform
if TYPE_CHECKING:
from ..scene.scene import Scene
class TransformMatchingAbstractBase(AnimationGroup):
"""Abstract base class for transformations that keep track of matching parts.
@ -78,9 +75,16 @@ class TransformMatchingAbstractBase(AnimationGroup):
transform_mismatches: bool = False,
fade_transform_mismatches: bool = False,
key_map: dict | None = None,
**kwargs,
**kwargs: Any,
):
group_type = VGroup if isinstance(mobject, VMobject) else Group
if isinstance(mobject, OpenGLVMobject):
group_type: type[OpenGLVGroup | OpenGLGroup | VGroup | Group] = OpenGLVGroup
elif isinstance(mobject, OpenGLMobject):
group_type = OpenGLGroup
elif isinstance(mobject, VMobject):
group_type = VGroup
else:
group_type = Group
source_map = self.get_shape_map(mobject)
target_map = self.get_shape_map(target_mobject)
@ -138,32 +142,33 @@ 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:
if config["renderer"] == RendererType.OPENGL:
shape_map[key] = VGroup()
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 finish(self) -> None:
super().finish()
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)
self.buffer.remove(self.mobject)
self.buffer.remove(*self.to_remove)
self.buffer.add(self.to_add)
# 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.")
@ -203,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,
@ -267,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,
@ -280,7 +285,7 @@ class TransformMatchingTex(TransformMatchingAbstractBase):
@staticmethod
def get_mobject_parts(mobject: Mobject) -> list[Mobject]:
if isinstance(mobject, (Group, VGroup)):
if isinstance(mobject, (Group, VGroup, OpenGLGroup, OpenGLVGroup)):
return [
p
for s in mobject.submobjects
@ -292,4 +297,5 @@ class TransformMatchingTex(TransformMatchingAbstractBase):
@staticmethod
def get_mobject_key(mobject: Mobject) -> str:
assert isinstance(mobject, MathTexPart)
return mobject.tex_string

View file

@ -3,9 +3,12 @@
from __future__ import annotations
__all__ = [
"assert_is_mobject_method",
"always",
"f_always",
"always_redraw",
"always_shift",
"always_rotate",
"turn_animation_into_updater",
"cycle_animation",
]
@ -13,56 +16,44 @@ __all__ = [
import inspect
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, TypeVar, cast
from typing import TYPE_CHECKING, TypeVar
import numpy as np
from manim.mobject.opengl.opengl_mobject import OpenGLMobject as Mobject
from manim.constants import DEGREES, RIGHT
from manim.mobject.mobject import Mobject
from manim.opengl import OpenGLMobject
from manim.utils.space_ops import normalize
if TYPE_CHECKING:
import types
from typing import Concatenate
from typing_extensions import ParamSpec, TypeIs
from manim.animation.protocol import MobjectAnimation
P = ParamSpec("P")
from manim.animation.animation import Animation
M = TypeVar("M", bound=Mobject)
# TODO: figure out how to typehint as MethodType[Mobject] to avoid the cast
# madness in always/f_always
def is_mobject_method(method: Callable[..., Any]) -> TypeIs[types.MethodType]:
return inspect.ismethod(method) and isinstance(method.__self__, Mobject)
def assert_is_mobject_method(method: Callable) -> None:
assert inspect.ismethod(method)
mobject = method.__self__
assert isinstance(mobject, (Mobject, OpenGLMobject))
def always(
method: Callable[Concatenate[M, P], object], *args: P.args, **kwargs: P.kwargs
) -> M:
if not is_mobject_method(method):
raise ValueError("always must take a method of a Mobject")
mobject = cast(M, method.__self__)
def always(method: Callable, *args, **kwargs) -> Mobject:
assert_is_mobject_method(method)
mobject = method.__self__
func = method.__func__
mobject.add_updater(lambda m: func(m, *args, **kwargs))
return mobject
def f_always(
method: Callable[Concatenate[M, ...], None],
*arg_generators: Callable[[], object],
**kwargs,
) -> M:
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
the relevant arguments.
"""
if not is_mobject_method(method):
raise ValueError("f_always must take a method of a Mobject")
mobject = cast(M, method.__self__)
assert_is_mobject_method(method)
mobject = method.__self__
func = method.__func__
def updater(mob):
@ -88,6 +79,7 @@ def always_redraw(func: Callable[[], M]) -> M:
Examples
--------
.. manim:: TangentAnimation
class TangentAnimation(Scene):
@ -117,11 +109,81 @@ def always_redraw(func: Callable[[], M]) -> M:
return mob
def turn_animation_into_updater(
animation: MobjectAnimation[M],
cycle: bool = False,
delay: float = 0,
def always_shift(
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.
Parameters
----------
mobject
The mobject to shift.
direction
The direction to shift. The vector is normalized, the specified magnitude
is not relevant.
rate
Length in Manim units which the mobject travels in one
second along the specified direction.
Examples
--------
.. manim:: ShiftingSquare
class ShiftingSquare(Scene):
def construct(self):
sq = Square().set_fill(opacity=1)
tri = Triangle()
VGroup(sq, tri).arrange(LEFT)
# construct a square which is continuously
# shifted to the right
always_shift(sq, RIGHT, rate=5)
self.add(sq)
self.play(tri.animate.set_fill(opacity=1))
"""
mobject.add_updater(lambda m, dt: m.shift(dt * rate * normalize(direction)))
return mobject
def always_rotate(mobject: M, rate: float = 20 * DEGREES, **kwargs) -> M:
"""A mobject which is continuously rotated at a certain rate.
Parameters
----------
mobject
The mobject to be rotated.
rate
The angle which the mobject is rotated by
over one second.
kwags
Further arguments to be passed to :meth:`.Mobject.rotate`.
Examples
--------
.. manim:: SpinningTriangle
class SpinningTriangle(Scene):
def construct(self):
tri = Triangle().set_fill(opacity=1).set_z_index(2)
sq = Square().to_edge(LEFT)
# will keep spinning while there is an animation going on
always_rotate(tri, rate=2*PI, about_point=ORIGIN)
self.add(tri, sq)
self.play(sq.animate.to_edge(RIGHT), rate_func=linear, run_time=1)
"""
mobject.add_updater(lambda m, dt: m.rotate(dt * rate, **kwargs))
return mobject
def turn_animation_into_updater(
animation: Animation, cycle: bool = False, delay: float = 0, **kwargs
) -> Mobject:
"""
Add an updater to the animation's mobject which applies
the interpolation and update functions of the animation
@ -150,12 +212,10 @@ def turn_animation_into_updater(
mobject = animation.mobject
animation.suspend_mobject_updating = False
animation.begin()
animation.total_time = -delay
total_time = -delay
def update(m: M, dt: float):
nonlocal total_time
if total_time >= 0:
def update(m: Mobject, dt: float):
if animation.total_time >= 0:
run_time = animation.get_run_time()
# handle zero/negative runtime safely
@ -167,7 +227,7 @@ def turn_animation_into_updater(
m.remove_updater(update)
return
time_ratio = total_time / run_time
time_ratio = animation.total_time / run_time
if cycle:
alpha = time_ratio % 1
else:
@ -178,11 +238,11 @@ def turn_animation_into_updater(
return
animation.interpolate(alpha)
animation.update_mobjects(dt)
total_time += dt
animation.total_time += dt
mobject.add_updater(update)
return mobject
def cycle_animation(animation: MobjectAnimation[M], **kwargs) -> M:
def cycle_animation(animation: Animation, **kwargs) -> Mobject:
return turn_animation_into_updater(animation, cycle=True, **kwargs)

View file

@ -12,7 +12,7 @@ from typing import TYPE_CHECKING, Any
from manim.animation.animation import Animation
if TYPE_CHECKING:
from manim.mobject.opengl.opengl_mobject import OpenGLMobject as Mobject
from manim.mobject.mobject import Mobject
class UpdateFromFunc(Animation):
@ -25,22 +25,22 @@ class UpdateFromFunc(Animation):
def __init__(
self,
mobject: Mobject,
update_function: Callable[[Mobject], object],
update_function: Callable[[Mobject], Any],
suspend_mobject_updating: bool = False,
**kwargs: Any,
) -> None:
self.update_function = update_function
super().__init__(
mobject, suspend_mobject_updating=suspend_mobject_updating, **kwargs
)
self.update_function = update_function
def interpolate(self, alpha: float) -> None:
self.update_function(self.mobject)
def interpolate_mobject(self, alpha: float) -> None:
self.update_function(self.mobject) # type: ignore[arg-type]
class UpdateFromAlphaFunc(UpdateFromFunc):
def interpolate(self, alpha: float) -> None:
self.update_function(self.mobject, self.rate_func(alpha)) # type: ignore[call-arg]
def interpolate_mobject(self, alpha: float) -> None:
self.update_function(self.mobject, self.rate_func(alpha)) # type: ignore[call-arg, arg-type]
class MaintainPositionRelativeTo(Animation):
@ -54,7 +54,7 @@ class MaintainPositionRelativeTo(Animation):
)
super().__init__(mobject, **kwargs)
def interpolate(self, alpha: float) -> None:
def interpolate_mobject(self, alpha: float) -> None:
target = self.tracked_mobject.get_center()
location = self.mobject.get_center()
self.mobject.shift(target - location + self.diff)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,170 @@
"""A camera module that supports spatial mapping between objects for distortion effects."""
from __future__ import annotations
__all__ = ["MappingCamera", "OldMultiCamera", "SplitScreenCamera"]
import math
import numpy as np
from ..camera.camera import Camera
from ..mobject.types.vectorized_mobject import VMobject
from ..utils.config_ops import DictAsObject
# TODO: Add an attribute to mobjects under which they can specify that they should just
# map their centers but remain otherwise undistorted (useful for labels, etc.)
class MappingCamera(Camera):
"""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__(
self,
mapping_func=lambda p: p,
min_num_curves=50,
allow_object_intrusion=False,
**kwargs,
):
self.mapping_func = mapping_func
self.min_num_curves = min_num_curves
self.allow_object_intrusion = allow_object_intrusion
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
else:
mobject_copies = [mobject.copy() for mobject in mobjects]
for mobject in mobject_copies:
if (
isinstance(mobject, VMobject)
and 0 < mobject.get_num_curves() < self.min_num_curves
):
mobject.insert_n_curves(self.min_num_curves)
super().capture_mobjects(
mobject_copies,
include_submobjects=False,
excluded_mobjects=None,
)
# Note: This allows layering of multiple cameras onto the same portion of the pixel array,
# the later cameras overwriting the former
#
# TODO: Add optional separator borders between cameras (or perhaps peel this off into a
# CameraPlusOverlay class)
# 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(
{
"camera": camera_with_start_positions[0],
"start_x": camera_with_start_positions[1][1],
"start_y": camera_with_start_positions[1][0],
"end_x": camera_with_start_positions[1][1]
+ camera_with_start_positions[0].pixel_width,
"end_y": camera_with_start_positions[1][0]
+ camera_with_start_positions[0].pixel_height,
},
)
for camera_with_start_positions in cameras_with_start_positions
]
super().__init__(**kwargs)
def capture_mobjects(self, mobjects, **kwargs):
for shifted_camera in self.shifted_cameras:
shifted_camera.camera.capture_mobjects(mobjects, **kwargs)
self.pixel_array[
shifted_camera.start_y : shifted_camera.end_y,
shifted_camera.start_x : shifted_camera.end_x,
] = shifted_camera.camera.pixel_array
def set_background(self, pixel_array, **kwargs):
for shifted_camera in self.shifted_cameras:
shifted_camera.camera.set_background(
pixel_array[
shifted_camera.start_y : shifted_camera.end_y,
shifted_camera.start_x : shifted_camera.end_x,
],
**kwargs,
)
def set_pixel_array(self, pixel_array, **kwargs):
super().set_pixel_array(pixel_array, **kwargs)
for shifted_camera in self.shifted_cameras:
shifted_camera.camera.set_pixel_array(
pixel_array[
shifted_camera.start_y : shifted_camera.end_y,
shifted_camera.start_x : shifted_camera.end_x,
],
**kwargs,
)
def init_background(self):
super().init_background()
for shifted_camera in self.shifted_cameras:
shifted_camera.camera.init_background()
# A OldMultiCamera which, when called with two full-size cameras, initializes itself
# as a split screen, also taking care to resize each individual camera within it
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
self.right_camera = right_camera
half_width = math.ceil(self.pixel_width / 2)
for camera in [self.left_camera, self.right_camera]:
camera.reset_pixel_shape(camera.pixel_height, half_width)
super().__init__(
(left_camera, (0, 0)),
(right_camera, (0, half_width)),
)

View file

@ -0,0 +1,292 @@
"""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"]
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, _AnimationBuilder
from ..utils.color import WHITE, ManimColor
class MovingCamera(Camera):
"""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: 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)
determining which region of space the camera displays
"""
self.fixed_dimension = fixed_dimension
self.default_frame_stroke_color = default_frame_stroke_color
self.default_frame_stroke_width = default_frame_stroke_width
if frame is None:
frame = ScreenRectangle(height=config["frame_height"])
frame.set_stroke(
self.default_frame_stroke_color,
self.default_frame_stroke_width,
)
self.frame = frame
super().__init__(**kwargs)
# TODO, make these work for a rotated frame
@property
def frame_height(self) -> float:
"""Returns the height of the frame.
Returns
-------
float
The height of the frame.
"""
return self.frame.height
@frame_height.setter
def frame_height(self, frame_height: float) -> None:
"""Sets the height of the frame in MUnits.
Parameters
----------
frame_height
The new frame_height.
"""
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) -> None:
"""Sets the width of the frame in MUnits.
Parameters
----------
frame_width
The new frame_width.
"""
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: Point3DLike | Mobject) -> None:
"""Sets the centerpoint of the frame.
Parameters
----------
frame_center
The point to which the frame must be moved.
If is of type mobject, the frame will be moved to
the center of that mobject.
"""
self.frame.move_to(frame_center)
def capture_mobjects(self, mobjects: Iterable[Mobject], **kwargs: Any) -> None:
# self.reset_frame_center()
# self.realign_frame_shape()
super().capture_mobjects(mobjects, **kwargs)
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: 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.
"""
pass
# def reset_frame_center(self):
# self.frame_center = self.frame.get_center()
# def realign_frame_shape(self):
# height, width = self.frame_shape
# if self.fixed_dimension == 0:
# self.frame_shape = (height, self.frame.width
# else:
# self.frame_shape = (self.frame.height, width)
# self.resize_frame_shape(fixed_dimension=self.fixed_dimension)
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[Mobject]
"""
return [self.frame]
@overload
def auto_zoom(
self,
mobjects: Iterable[Mobject],
margin: float,
only_mobjects_in_frame: bool,
animate: Literal[False],
) -> Mobject: ...
@overload
def auto_zoom(
self,
mobjects: Iterable[Mobject],
margin: float,
only_mobjects_in_frame: bool,
animate: Literal[True],
) -> _AnimationBuilder: ...
def auto_zoom(
self,
mobjects: Iterable[Mobject],
margin: float = 0,
only_mobjects_in_frame: bool = False,
animate: bool = True,
) -> _AnimationBuilder | Mobject:
"""Zooms on to a given array of mobjects (or a singular mobject)
and automatically resizes to frame all the mobjects.
.. NOTE::
This method only works when 2D-objects in the XY-plane are considered, it
will not work correctly when the camera has been rotated.
Parameters
----------
mobjects
The mobject or array of mobjects that the camera will focus on.
margin
The width of the margin that is added to the frame (optional, 0 by default).
only_mobjects_in_frame
If set to ``True``, only allows focusing on mobjects that are already in frame.
animate
If set to ``False``, applies the changes instead of returning the corresponding animation
Returns
-------
Union[_AnimationBuilder, ScreenRectangle]
_AnimationBuilder that zooms the camera view to a given list of mobjects
or ScreenRectangle with position and size updated to zoomed position.
"""
(
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
y = (scene_critical_y_up + scene_critical_y_down) / 2
# calculate proposed width and height of zoomed scene
new_width = abs(scene_critical_x_left - scene_critical_x_right)
new_height = abs(scene_critical_y_up - scene_critical_y_down)
m_target = self.frame.animate if animate else self.frame
# zoom to fit all mobjects along the side that has the largest size
if new_width / self.frame.width > new_height / self.frame.height:
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

@ -0,0 +1,107 @@
"""A camera supporting multiple perspectives."""
from __future__ import annotations
__all__ = ["MultiCamera"]
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
class MultiCamera(MovingCamera):
"""Camera Object that allows for multiple perspectives."""
def __init__(
self,
image_mobjects_from_cameras: Iterable[ImageMobjectFromCamera] | None = None,
allow_cameras_to_capture_their_own_display: bool = False,
**kwargs: Any,
) -> None:
"""Initialises the MultiCamera
Parameters
----------
image_mobjects_from_cameras
kwargs
Any valid keyword arguments of MovingCamera.
"""
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)
self.allow_cameras_to_capture_their_own_display = (
allow_cameras_to_capture_their_own_display
)
super().__init__(**kwargs)
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``
Parameters
----------
image_mobject_from_camera
The ImageMobject to add to self.image_mobject_from_cameras
"""
# A silly method to have right now, but maybe there are things
# we want to guarantee about any imfc's added later.
imfc = image_mobject_from_camera
assert isinstance(imfc.camera, MovingCamera)
self.image_mobjects_from_cameras.append(imfc)
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.reset_pixel_shape(
int(pixel_height * imfc.height / self.frame_height),
int(pixel_width * imfc.width / self.frame_width),
)
def reset(self) -> Self:
"""Resets the MultiCamera.
Returns
-------
MultiCamera
The reset MultiCamera
"""
for imfc in self.image_mobjects_from_cameras:
imfc.camera.reset()
super().reset()
return self
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)
if not self.allow_cameras_to_capture_their_own_display:
to_add = list_difference_update(to_add, imfc.get_family())
imfc.camera.capture_mobjects(to_add, **kwargs)
super().capture_mobjects(mobjects, **kwargs)
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
"""
return [self.frame] + [
imfc.camera.frame for imfc in self.image_mobjects_from_cameras
]

View file

@ -0,0 +1,459 @@
"""A camera that can be positioned and oriented in three-dimensional space."""
from __future__ import annotations
__all__ = ["ThreeDCamera"]
from collections.abc import Callable, Iterable
from typing import Any
import numpy as np
from manim.mobject.mobject import Mobject
from manim.mobject.three_d.three_d_utils import (
get_3d_vmob_end_corner,
get_3d_vmob_end_corner_unit_normal,
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
from ..constants import *
from ..mobject.types.point_cloud_mobject import Point
from ..utils.color import get_shaded_rgb
from ..utils.family import extract_mobject_family_members
from ..utils.space_ops import rotation_about_z, rotation_matrix
class ThreeDCamera(Camera):
def __init__(
self,
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
Parameters
----------
*kwargs
Any keyword argument of Camera.
"""
self._frame_center = Point(kwargs.get("frame_center", ORIGIN), stroke_width=0)
super().__init__(**kwargs)
self.focal_distance = focal_distance
self.phi = phi
self.theta = theta
self.gamma = gamma
self.zoom = zoom
self.shading_factor = shading_factor
self.default_distance = default_distance
self.light_source_start_point = light_source_start_point
self.light_source = Point(self.light_source_start_point)
self.should_apply_shading = should_apply_shading
self.exponential_projection = exponential_projection
self.max_allowable_norm = 3 * config["frame_width"]
self.phi_tracker = ValueTracker(self.phi)
self.theta_tracker = ValueTracker(self.theta)
self.focal_distance_tracker = ValueTracker(self.focal_distance)
self.gamma_tracker = ValueTracker(self.gamma)
self.zoom_tracker = ValueTracker(self.zoom)
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) -> Point3D:
return self._frame_center.points[0]
@frame_center.setter
def frame_center(self, point: Point3DLike) -> None:
self._frame_center.move_to(point)
def capture_mobjects(self, mobjects: Iterable[Mobject], **kwargs: Any) -> None:
self.reset_rotation_matrix()
super().capture_mobjects(mobjects, **kwargs)
def get_value_trackers(self) -> list[ValueTracker]:
"""A list of :class:`ValueTrackers <.ValueTracker>` of phi, theta, focal_distance,
gamma and zoom.
Returns
-------
list
list of ValueTracker objects
"""
return [
self.phi_tracker,
self.theta_tracker,
self.focal_distance_tracker,
self.gamma_tracker,
self.zoom_tracker,
]
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):
light_source_point = self.light_source.points[0]
if len(rgbas) < 2:
shaded_rgbas = rgbas.repeat(2, axis=0)
else:
shaded_rgbas = np.array(rgbas[:2])
shaded_rgbas[0, :3] = get_shaded_rgb(
shaded_rgbas[0, :3],
get_3d_vmob_start_corner(vmobject),
get_3d_vmob_start_corner_unit_normal(vmobject),
light_source_point,
)
shaded_rgbas[1, :3] = get_shaded_rgb(
shaded_rgbas[1, :3],
get_3d_vmob_end_corner(vmobject),
get_3d_vmob_end_corner_unit_normal(vmobject),
light_source_point,
)
return shaded_rgbas
return rgbas
def get_stroke_rgbas(
self,
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: VMobject
) -> FloatRGBA_Array: # NOTE : DocStrings From parent
return self.modified_rgbas(vmobject, vmobject.get_fill_rgbas())
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: Mobject) -> float:
if not (hasattr(mob, "shade_in_3d") and mob.shade_in_3d):
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
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) -> float:
"""Returns the Polar angle (the angle off Z_AXIS) phi.
Returns
-------
float
The Polar angle in radians.
"""
return self.phi_tracker.get_value()
def get_theta(self) -> float:
"""Returns the Azimuthal i.e the angle that spins the camera around the Z_AXIS.
Returns
-------
float
The Azimuthal angle in radians.
"""
return self.theta_tracker.get_value()
def get_focal_distance(self) -> float:
"""Returns focal_distance of the Camera.
Returns
-------
float
The focal_distance of the Camera in MUnits.
"""
return self.focal_distance_tracker.get_value()
def get_gamma(self) -> float:
"""Returns the rotation of the camera about the vector from the ORIGIN to the Camera.
Returns
-------
float
The angle of rotation of the camera about the vector
from the ORIGIN to the Camera in radians
"""
return self.gamma_tracker.get_value()
def get_zoom(self) -> float:
"""Returns the zoom amount of the camera.
Returns
-------
float
The zoom amount of the camera.
"""
return self.zoom_tracker.get_value()
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
----------
value
The new value of the polar angle in radians.
"""
self.phi_tracker.set_value(value)
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
----------
value
The new value of the azimuthal angle in radians.
"""
self.theta_tracker.set_value(value)
def set_focal_distance(self, value: float) -> None:
"""Sets the focal_distance of the Camera.
Parameters
----------
value
The focal_distance of the Camera.
"""
self.focal_distance_tracker.set_value(value)
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
----------
value
The new angle of rotation of the camera.
"""
self.gamma_tracker.set_value(value)
def set_zoom(self, value: float) -> None:
"""Sets the zoom amount of the camera.
Parameters
----------
value
The zoom amount of the camera.
"""
self.zoom_tracker.set_value(value)
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) -> MatrixMN:
"""Returns the matrix corresponding to the current position of the camera.
Returns
-------
np.array
The matrix corresponding to the current position of the camera.
"""
return self.rotation_matrix
def generate_rotation_matrix(self) -> MatrixMN:
"""Generates a rotation matrix based off the current position of the camera.
Returns
-------
np.array
The matrix corresponding to the current position of the camera.
"""
phi = self.get_phi()
theta = self.get_theta()
gamma = self.get_gamma()
matrices = [
rotation_about_z(-theta - 90 * DEGREES),
rotation_matrix(-phi, RIGHT),
rotation_about_z(gamma),
]
result = np.identity(3)
for matrix in matrices:
result = np.dot(matrix, result)
return result
def project_points(self, points: Point3D_Array) -> Point3D_Array:
"""Applies the current rotation_matrix as a projection
matrix to the passed array of points.
Parameters
----------
points
The list of points to project.
Returns
-------
np.array
The points after projecting.
"""
frame_center = self.frame_center
focal_distance = self.get_focal_distance()
zoom = self.get_zoom()
rot_matrix = self.get_rotation_matrix()
points = points - frame_center
points = np.dot(points, rot_matrix.T)
zs = points[:, 2]
for i in 0, 1:
if self.exponential_projection:
# Proper projection would involve multiplying
# x and y by d / (d-z). But for points with high
# z value that causes weird artifacts, and applying
# the exponential helps smooth it out.
factor = np.exp(zs / focal_distance)
lt0 = zs < 0
factor[lt0] = focal_distance / (focal_distance - zs[lt0])
else:
factor = focal_distance / (focal_distance - zs)
factor[(focal_distance - zs) < 0] = 10**6
points[:, i] *= factor * zoom
return points
def project_point(self, point: Point3D) -> Point3D:
"""Applies the current rotation_matrix as a projection
matrix to the passed point.
Parameters
----------
point
The point to project.
Returns
-------
np.array
The point after projection.
"""
return self.project_points(point.reshape((1, 3)))[0, :]
def transform_points_pre_display(
self,
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
if fixed_in_frame:
return points
if fixed_orientation:
center_func = self.fixed_orientation_mobjects[mobject]
center = center_func()
new_center = self.project_point(center)
return points + (new_center - center)
else:
return self.project_points(points)
def add_fixed_orientation_mobjects(
self,
*mobjects: Mobject,
use_static_center_func: bool = False,
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
will continue to face the camera even as the camera moves.
Highly useful when adding labels to graphs and the like.
Parameters
----------
*mobjects
The mobject whose orientation must be fixed.
use_static_center_func
Whether or not to use the function that takes the mobject's
center as centerpoint, by default False
center_func
The function which returns the centerpoint
with respect to which the mobject will be oriented, by default None
"""
# This prevents the computation of mobject.get_center
# every single time a projection happens
def get_static_center_func(mobject: Mobject) -> Callable[[], Point3D]:
point = mobject.get_center()
return lambda: point
for mobject in mobjects:
if center_func:
func = center_func
elif use_static_center_func:
func = get_static_center_func(mobject)
else:
func = mobject.get_center
for submob in mobject.get_family():
self.fixed_orientation_mobjects[submob] = func
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
will continue to be displayed at the top of the frame.
Highly useful when displaying Titles or formulae or the like.
Parameters
----------
**mobjects
The mobject to fix in frame.
"""
for mobject in extract_mobject_family_members(mobjects):
self.fixed_in_frame_mobjects.add(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.
Parameters
----------
mobjects
The mobjects whose orientation need not be fixed any longer.
"""
for mobject in extract_mobject_family_members(mobjects):
if mobject in self.fixed_orientation_mobjects:
del self.fixed_orientation_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.
Parameters
----------
mobjects
The mobjects which need not be fixed in frame any longer.
"""
for mobject in extract_mobject_family_members(mobjects):
if mobject in self.fixed_in_frame_mobjects:
self.fixed_in_frame_mobjects.remove(mobject)

View file

@ -84,9 +84,7 @@ def checkhealth() -> None:
self.execution_time = timeit.timeit(self._inner_construct, number=1)
with mn.tempconfig({"preview": True, "disable_caching": True}):
with mn.Manager(CheckHealthDemo) as manager:
manager.render()
scene = CheckHealthDemo()
scene.render()
click.echo(
f"Scene rendered in {manager.scene.execution_time:.2f} seconds."
)
click.echo(f"Scene rendered in {scene.execution_time:.2f} seconds.")

View file

@ -31,8 +31,7 @@ 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
from manim.manager import Manager
from manim.constants import EPILOG, RendererType
from manim.utils.module_ops import scene_classes_from_file
__all__ = ["render"]
@ -76,6 +75,14 @@ def render(**kwargs: Any) -> ClickArgs | dict[str, Any]:
SCENES is an optional list of scenes in the file.
"""
if kwargs["save_as_gif"]:
logger.warning("--save_as_gif is deprecated, please use --format=gif instead!")
kwargs["format"] = "gif"
if kwargs["save_pngs"]:
logger.warning("--save_pngs is deprecated, please use --format=png instead!")
kwargs["format"] = "png"
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.",
@ -87,13 +94,38 @@ def render(**kwargs: Any) -> ClickArgs | dict[str, Any]:
config.digest_args(click_args)
file = Path(config.input_file)
try:
if config.renderer == RendererType.OPENGL:
from manim.renderer.opengl_renderer import OpenGLRenderer
try:
renderer = OpenGLRenderer()
keep_running = True
while keep_running:
for SceneClass in scene_classes_from_file(file):
with tempconfig({}):
scene = SceneClass(renderer)
rerun = scene.render()
if rerun or config["write_all"]:
renderer.num_plays = 0
continue
else:
keep_running = False
break
if config["write_all"]:
keep_running = False
except Exception:
error_console.print_exception()
sys.exit(1)
else:
for SceneClass in scene_classes_from_file(file):
with tempconfig({}), Manager(SceneClass) as manager:
manager.render()
except Exception:
error_console.print_exception()
sys.exit(1)
try:
with tempconfig({}):
scene = SceneClass()
scene.render()
except Exception:
error_console.print_exception()
sys.exit(1)
if config.notify_outdated_version:
manim_info_url = "https://pypi.org/pypi/manim/json"

View file

@ -12,7 +12,6 @@ if TYPE_CHECKING:
__all__ = ["global_options"]
logger = logging.getLogger("manim")

View file

@ -21,11 +21,10 @@ output_options = option_group(
help="Zero padding for PNG file names.",
),
option(
"-w",
"--write_to_movie",
is_flag=True,
default=None,
help="Write the video to a file.",
help="Write the video rendered with opengl to a file.",
),
option(
"--media_dir",

View file

@ -7,7 +7,7 @@ from typing import TYPE_CHECKING
from cloup import Choice, option, option_group
from manim.constants import QUALITIES
from manim.constants import QUALITIES, RendererType
if TYPE_CHECKING:
from click import Context, Option
@ -16,8 +16,6 @@ __all__ = ["render_options"]
logger = logging.getLogger("manim")
__all__ = ["render_options"]
def validate_scene_range(
ctx: Context, param: Option, value: str | None
@ -114,13 +112,6 @@ render_options = option_group(
"renders all scenes after n_0.",
default=None,
),
option(
"-g",
"--groups",
callback=lambda ctx, param, value: value.split(","),
help="Render only the specified groups.",
default=[],
),
option(
"-a",
"--write_all",
@ -174,6 +165,29 @@ render_options = option_group(
default=None,
help="Render at this frame rate.",
),
option(
"--renderer",
type=Choice(
[renderer_type.value for renderer_type in RendererType],
case_sensitive=False,
),
help="Select a renderer for your Scene.",
default="cairo",
),
option(
"-g",
"--save_pngs",
is_flag=True,
default=None,
help="Save each frame as png (Deprecated).",
),
option(
"-i",
"--save_as_gif",
default=None,
is_flag=True,
help="Save as a gif (Deprecated).",
),
option(
"--save_sections",
default=None,
@ -186,4 +200,16 @@ render_options = option_group(
is_flag=True,
help="Render scenes with alpha channel.",
),
option(
"--use_projection_fill_shaders",
is_flag=True,
help="Use shaders for OpenGLVMobject fill which are compatible with transformation matrices.",
default=None,
),
option(
"--use_projection_stroke_shaders",
is_flag=True,
help="Use shaders for OpenGLVMobject stroke which are compatible with transformation matrices.",
default=None,
),
)

View file

@ -67,11 +67,12 @@ __all__ = [
"PI",
"TAU",
"DEGREES",
"RADIANS",
"QUALITIES",
"DEFAULT_QUALITY",
"EPILOG",
"CONTEXT_SETTINGS",
"SHIFT_VALUE",
"CTRL_VALUE",
"RendererType",
"LineJointType",
"CapStyleType",
@ -193,9 +194,6 @@ TAU = 2 * PI
DEGREES = TAU / 360
"""The exchange rate between radians and degrees."""
RADIANS: float = 1.0
"""Just a default to select for camera."""
class QualityDict(TypedDict):
flag: str | None
@ -247,6 +245,8 @@ QUALITIES: dict[str, QualityDict] = {
DEFAULT_QUALITY = "high_quality"
EPILOG = "Made with <3 by Manim Community developers."
SHIFT_VALUE = 65505
CTRL_VALUE = 65507
CONTEXT_SETTINGS = Context.settings(
align_option_groups=True,

View file

@ -1,7 +0,0 @@
from __future__ import annotations
from manim.event_handler.event_dispatcher import EventDispatcher
# This is supposed to be a Singleton
# i.e., during runtime there should be only one object of Event Dispatcher
EVENT_DISPATCHER = EventDispatcher()

View file

@ -1,93 +0,0 @@
from __future__ import annotations
from typing import Any, Self
import numpy as np
from manim.event_handler.event_listener import EventListener
from manim.event_handler.event_type import EventType
class EventDispatcher:
def __init__(self) -> None:
self.event_listeners: dict[EventType, list[EventListener]] = {
event_type: [] for event_type in EventType
}
self.mouse_point = np.array((0.0, 0.0, 0.0))
self.mouse_drag_point = np.array((0.0, 0.0, 0.0))
self.pressed_keys: set[int] = set()
self.draggable_object_listeners: list[EventListener] = []
def add_listener(self, event_listener: EventListener) -> Self:
assert isinstance(event_listener, EventListener)
self.event_listeners[event_listener.event_type].append(event_listener)
return self
def remove_listener(self, event_listener: EventListener) -> Self:
assert isinstance(event_listener, EventListener)
try:
while event_listener in self.event_listeners[event_listener.event_type]:
self.event_listeners[event_listener.event_type].remove(event_listener)
except Exception:
# raise ValueError("Handler is not handling this event, so cannot remove it.")
pass
return self
def dispatch(self, event_type: EventType, **event_data: Any) -> bool | None:
if event_type == EventType.MouseMotionEvent:
self.mouse_point = event_data["point"]
elif event_type == EventType.MouseDragEvent:
self.mouse_drag_point = event_data["point"]
elif event_type == EventType.KeyPressEvent:
self.pressed_keys.add(event_data["symbol"]) # Modifiers?
elif event_type == EventType.KeyReleaseEvent:
self.pressed_keys.difference_update({event_data["symbol"]}) # Modifiers?
elif event_type == EventType.MousePressEvent:
self.draggable_object_listeners = [
listener
for listener in self.event_listeners[EventType.MouseDragEvent]
if listener.mobject.is_point_touching(self.mouse_point)
]
elif event_type == EventType.MouseReleaseEvent:
self.draggable_object_listeners = []
propagate_event = None
if event_type == EventType.MouseDragEvent:
for listener in self.draggable_object_listeners:
assert isinstance(listener, EventListener)
propagate_event = listener.callback(listener.mobject, event_data)
if propagate_event is not None and propagate_event is False:
return propagate_event
elif event_type.value.startswith("mouse"):
for listener in self.event_listeners[event_type]:
if listener.mobject.is_point_touching(self.mouse_point):
propagate_event = listener.callback(listener.mobject, event_data)
if propagate_event is not None and propagate_event is False:
return propagate_event
elif event_type.value.startswith("key"):
for listener in self.event_listeners[event_type]:
propagate_event = listener.callback(listener.mobject, event_data)
if propagate_event is not None and propagate_event is False:
return propagate_event
return propagate_event
def get_listeners_count(self) -> int:
return sum([len(value) for key, value in self.event_listeners.items()])
def get_mouse_point(self) -> np.ndarray:
return self.mouse_point
def get_mouse_drag_point(self) -> np.ndarray:
return self.mouse_drag_point
def is_key_pressed(self, symbol: int) -> bool:
return symbol in self.pressed_keys
__iadd__ = add_listener
__isub__ = remove_listener
__call__ = dispatch
__len__ = get_listeners_count

View file

@ -1,34 +0,0 @@
from __future__ import annotations
import contextlib
from collections.abc import Callable
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any
from manim.event_handler.event_type import EventType
from manim.mobject.opengl.opengl_mobject import OpenGLMobject as Mobject
class EventListener:
def __init__(
self,
mobject: Mobject,
event_type: EventType,
event_callback: Callable[[Mobject, dict[str, str]], None],
) -> None:
self.mobject = mobject
self.event_type = event_type
self.callback = event_callback
def __eq__(self, other: Any) -> bool:
return_val = False
if isinstance(other, EventListener):
with contextlib.suppress(Exception):
return_val = (
self.callback == other.callback
and self.mobject == other.mobject
and self.event_type == other.event_type
)
return return_val

View file

@ -1,13 +0,0 @@
from __future__ import annotations
from enum import Enum
class EventType(Enum):
MouseMotionEvent = "mouse_motion_event"
MousePressEvent = "mouse_press_event"
MouseReleaseEvent = "mouse_release_event"
MouseDragEvent = "mouse_drag_event"
MouseScrollEvent = "mouse_scroll_event"
KeyPressEvent = "key_press_event"
KeyReleaseEvent = "key_release_event"

View file

@ -1,14 +0,0 @@
from __future__ import annotations
from typing import Protocol
class WindowProtocol(Protocol):
@property
def is_closing(self) -> bool: ...
def swap_buffers(self) -> object: ...
def close(self) -> object: ...
def clear(self) -> object: ...

View file

@ -1,4 +0,0 @@
from __future__ import annotations
from .file_writer import FileWriter
from .sections import *

View file

@ -1,33 +0,0 @@
from __future__ import annotations
from typing import Protocol
from manim.typing import PixelArray
class FileWriterProtocol(Protocol):
"""Protocol for a file writer.
This is mainly useful for testing purposes, to create
a mock file writer. However, it can be used in plugins.
"""
num_plays: int
def __init__(self, scene_name: str) -> None: ...
def begin_animation(self, allow_write: bool = False) -> object: ...
def end_animation(self, allow_write: bool = False) -> object: ...
def is_already_cached(self, hash_invocation: str) -> bool: ...
def add_partial_movie_file(self, hash_animation: str) -> object: ...
def write_frame(self, frame: PixelArray) -> object: ...
def next_section(self, name: str, type_: str, skip_animations: bool) -> object: ...
def finish(self) -> None: ...
def save_image(self, image: PixelArray) -> object: ...

View file

@ -1,482 +0,0 @@
from __future__ import annotations
__all__ = ["Manager"]
import contextlib
import platform
import time
import warnings
from collections.abc import Iterable, Iterator, Sequence
from typing import TYPE_CHECKING, Generic, TypeVar
import numpy as np
from manim import config, logger
from manim.animation.animation import Wait
from manim.event_handler.window import WindowProtocol
from manim.file_writer import FileWriter
from manim.renderer.opengl_renderer import OpenGLRenderer
from manim.renderer.opengl_renderer_window import Window
from manim.scene.scene import Scene, SceneState
from manim.utils.exceptions import EndSceneEarlyException
from manim.utils.hashing import get_hash_from_play_call
from manim.utils.progressbar import (
ExperimentalProgressBarWarning,
NullProgressBar,
ProgressBar,
ProgressBarProtocol,
)
if TYPE_CHECKING:
from types import TracebackType
from typing import Any, Self
import numpy.typing as npt
from manim.animation.protocol import AnimationProtocol
from manim.file_writer.protocols import FileWriterProtocol
from manim.renderer.renderer import RendererProtocol
SceneT = TypeVar("SceneT", bound=Scene)
class Manager(Generic[SceneT]):
"""
The Brain of Manim
.. admonition:: Warning for Developers
Only methods of this class that are not prefixed with an
underscore (``_``) are stable. If you override any of the
``_`` methods, consider pinning your version of Manim.
Usage
-----
.. code-block:: python
class Manimation(Scene):
def construct(self):
self.play(FadeIn(Circle()))
# make sure to use it as a context manager
# to ensure proper resource cleanup
with Manager(Manimation) as manager:
manager.render()
"""
def __init__(self, scene_cls: type[SceneT]) -> None:
config._warn_about_config_options()
self.scene: SceneT = scene_cls(manager=self)
if not isinstance(self.scene, Scene):
raise ValueError(f"{self.scene!r} is not an instance of Scene")
self.time = 0.0
# Initialize window, if applicable
self.window = self.create_window()
# this must be done AFTER instantiating a window
self.renderer = self.create_renderer()
self.file_writer = self.create_file_writer()
self._write_files = config.write_to_movie
# internal state
self._skipping = config.save_last_frame
def __str__(self) -> str:
return f"{self.__class__.__name__}({self.scene!r}) at time {self.time:.2f}s"
def __enter__(self) -> Self:
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> None:
self.release()
# keep these as instance methods so subclasses
# have access to everything
def create_renderer(self) -> RendererProtocol:
"""Create and return a renderer instance.
This can be overridden in subclasses (plugins), if more processing
is needed.
Returns
-------
An instance of a renderer
"""
renderer = OpenGLRenderer(
background_color=config.background_color,
background_opacity=config.background_opacity,
)
if config.preview:
renderer.use_window()
return renderer
def create_window(self) -> WindowProtocol | None:
"""Create and return a window instance.
This can be overridden in subclasses (plugins), if more
processing is needed.
Returns
-------
A window if previewing, else None
"""
return Window() if config.preview else None
def create_file_writer(self) -> FileWriterProtocol:
"""Create and return a file writer instance.
This can be overridden in subclasses (plugins), if more
processing is needed.
Returns
-------
A file writer satisfying :class:`.FileWriterProtocol`
"""
return FileWriter(scene_name=self.scene.get_default_scene_name())
def setup(self) -> None:
"""Set up processes and manager"""
self.scene.setup()
# these are used for making sure it feels like the correct
# amount of time has passed in the window instead of rendering
# at full speed
# See the docstring of :meth:`_wait_for_animation_time`
self.virtual_animation_start_time = 0.0
self.real_animation_start_time = time.perf_counter()
def render(self) -> None:
"""
Entry point to running a Manim class
Example
-------
.. code-block:: python
class MyScene(Scene):
def construct(self):
self.play(Create(Circle()))
with tempconfig({"preview": True}), Manager(MyScene) as manager:
manager.render()
"""
self._render_first_pass()
self._render_second_pass()
def _render_first_pass(self) -> None:
"""
Temporarily use the normal single pass
rendering system
"""
self.setup()
with contextlib.suppress(EndSceneEarlyException):
self.construct()
self.post_construct()
self._interact()
self.tear_down()
def construct(self) -> None:
if not self.scene.groups_api:
self.scene.construct()
return
for group in self.scene.find_groups():
if not config.groups or group.name in config.groups:
group()
elif group.name not in config.groups:
with self.no_render():
group()
def _render_second_pass(self) -> None:
"""
In the future, this method could be used
for two pass rendering
"""
...
def release(self) -> None:
self.renderer.release()
def post_construct(self) -> None:
"""Run post-construct hooks, and clean up the file writer."""
should_write_image = config.save_last_frame or (
config.write_to_movie and not self.file_writer.num_plays
)
if self.file_writer.num_plays:
self.file_writer.finish()
# otherwise no animations were played
if should_write_image:
self.render_state(write_frame=False)
# FIXME: for some reason the OpenGLRenderer does not give out the
# correct frame values here
frame = self.renderer.get_pixels()
self.file_writer.save_image(frame)
self._write_files = False
def tear_down(self) -> None:
"""Tear down the scene and the window."""
self.scene.tear_down()
if self.window is not None:
self.window.close()
self.window = None
def _interact(self) -> None:
"""Live interaction with the Window"""
if self.window is None:
return
logger.info(
"\nTips: Using the keys `d`, `f`, or `z` "
"you can interact with the scene. "
"Press `command + q` or `esc` to quit"
)
last_time = time.perf_counter()
while not self.window.is_closing:
current_time = time.perf_counter()
dt = current_time - last_time
last_time = current_time
self._update_frame(dt)
@contextlib.contextmanager
def no_render(self) -> Iterator[None]:
"""Context manager to temporarily disable rendering.
Usage
-----
.. code-block:: python
with manager.no_render():
manager._play(FadeIn(Circle()))
"""
self._skipping = True
yield
self._skipping = False
# ----------------------------------#
# Animation Pipeline #
# ----------------------------------#
def _update_frame(
self, dt: float, *, write_frame: bool | None = None, run_updaters: bool = True
) -> None:
"""Update the current frame by ``dt``
Parameters
----------
dt : the time in between frames
write_frame : Whether to write the result to the output stream (videos ONLY).
Default value checks :attr:`_write_files` to see if it should be written.
"""
self.time += dt
if run_updaters:
self.scene._update_mobjects(dt)
self.scene.time = self.time
if self.window is not None:
if not self._skipping:
self.window.clear()
# if it's closing, then any subsequent methods will
# raise an error because the internal C window pointer is nullptr.
if self.window.is_closing:
raise EndSceneEarlyException()
if not self._skipping:
self.render_state(write_frame=write_frame)
self._wait_for_animation_time()
def _wait_for_animation_time(self) -> None:
"""Wait for the real time to catch up to the "virtual" animation time.
Animations can render faster than real time, so we have to
slow the window down for the correct amount of time, such
as during a wait animation.
"""
if self.window is None:
return
self.window.swap_buffers()
if self._skipping:
return
vt = self.time - self.virtual_animation_start_time
rt = time.perf_counter() - self.real_animation_start_time
# we can't sleep because we still need to poll for events,
# e.g. hitting Escape or close
while rt < vt:
if self.window.is_closing:
raise EndSceneEarlyException()
# make sure to poll for events
self.window.swap_buffers()
rt = time.perf_counter() - self.real_animation_start_time
def _play(self, *animations: AnimationProtocol) -> None:
"""Play a bunch of animations"""
self.scene.pre_play()
if self.window is not None:
self.real_animation_start_time = time.perf_counter()
self.virtual_animation_start_time = self.time
self._write_hashed_movie_file(animations)
self.scene.begin_animations(animations)
self._progress_through_animations(animations)
self.scene.finish_animations(animations)
self.scene.post_play()
self.file_writer.end_animation(allow_write=self._write_files)
def _write_hashed_movie_file(self, animations: Sequence[AnimationProtocol]) -> None:
"""Compute the hash of a self.play call, and write it to a file
Essentially, a series of methods that need to be called to successfully
render a frame.
"""
if not config.write_to_movie or self._skipping:
return
if config.disable_caching:
if not config.disable_caching_warning:
logger.info("Caching disabled...")
hash_current_play = f"uncached_{self.file_writer.num_plays:05}"
else:
hash_current_play = get_hash_from_play_call(
self.scene,
self.scene.camera,
animations,
self.scene.mobjects,
)
if self.file_writer.is_already_cached(hash_current_play):
logger.info(
f"Animation {self.file_writer.num_plays} : Using cached data (hash : {hash_current_play})"
)
# TODO: think about how to skip
raise NotImplementedError(
"Skipping cached animations is not implemented yet"
)
self.file_writer.add_partial_movie_file(hash_current_play)
self.file_writer.begin_animation(allow_write=self._write_files)
def _create_progressbar(
self, total: float, description: str, **kwargs: Any
) -> contextlib.AbstractContextManager[ProgressBarProtocol]:
"""Create a progressbar"""
if not config.progress_bar:
return contextlib.nullcontext(NullProgressBar())
with warnings.catch_warnings():
if config.verbosity != "DEBUG":
# Note: update when rich/notebook tqdm is no longer experimental
warnings.simplefilter("ignore", category=ExperimentalProgressBarWarning)
return ProgressBar(
total=total,
unit="frames",
desc=description % {"num": self.file_writer.num_plays},
ascii=True if platform.system() == "Windows" else None,
leave=config.progress_bar == "leave",
disable=config.progress_bar == "none",
**kwargs,
)
def _progress_through_animations(
self, animations: Sequence[AnimationProtocol]
) -> None:
last_t = 0.0
run_time = _calc_runtime(animations)
progression = _calc_time_progression(run_time)
with self._create_progressbar(
progression.shape[0],
f"Animation %(num)d: {animations[0]}{', etc.' if len(animations) > 1 else ''}",
) as progress:
if self._skipping:
self.scene._update_animations(animations, run_time, run_time)
self._update_frame(run_time, run_updaters=False)
return
for t in progression:
dt, last_t = t - last_t, t
self.scene._update_animations(animations, t, dt)
run_updaters = not self.scene.is_current_animation_frozen_frame(
animations
)
self._update_frame(dt, run_updaters=run_updaters)
for anim in animations:
if (
isinstance(anim, Wait)
and anim.stop_condition is not None
and anim.stop_condition()
):
return
progress.update(1)
# -------------------------#
# Rendering #
# -------------------------#
def render_state(self, write_frame: bool | None = None) -> None:
"""Render the current state of the scene.
Any extra kwargs are passed to :meth:`_render_frame`.
"""
state = self.scene.get_state()
self._render_frame(state, write_frame=write_frame)
def _render_frame(
self, state: SceneState, *, write_frame: bool | None = None
) -> None:
"""Renders a frame based on a state, and writes it to the file writers stream.
This is used for writing a single frame. Any extra kwargs are passed to :meth:`write_frame`.
.. warning::
This method will not work if :meth:`.FileWriter.begin_animation` and
:meth:`.FileWriter.add_partial_movie_file` have not been called. Do NOT
use this to write a single frame!
"""
self.renderer.render(state)
should_write = write_frame if write_frame is not None else self._write_files
if should_write:
self.write_frame()
def write_frame(self) -> None:
"""Take a frame from the renderer and write it in the file writer."""
frame = self.renderer.get_pixels()
self.file_writer.write_frame(frame)
def _calc_time_progression(run_time: float) -> npt.NDArray[np.float64]:
"""Compute the time values at which to evaluate the animation"""
return np.arange(0, run_time, 1 / config.frame_rate)
def _calc_runtime(animations: Iterable[AnimationProtocol]) -> float:
"""Calculate the runtime of an iterable of animations.
.. warning::
If animations is a generator, this will consume the generator.
"""
return max(animation.get_run_time() for animation in animations)

View file

@ -50,8 +50,8 @@ from typing import TYPE_CHECKING, Any, Self, cast
import numpy as np
from manim.constants import *
from manim.mobject.opengl.opengl_vectorized_mobject import OpenGLVGroup as VGroup
from manim.mobject.opengl.opengl_vectorized_mobject import OpenGLVMobject as VMobject
from manim.mobject.opengl.opengl_compatibility import ConvertToOpenGL
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 (
@ -68,7 +68,7 @@ if TYPE_CHECKING:
import manim.mobject.geometry.tips as tips
from manim.mobject.geometry.line import Line
from manim.mobject.opengl.opengl_mobject import OpenGLMobject as Mobject
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 (
@ -79,7 +79,7 @@ if TYPE_CHECKING:
)
class TipableVMobject(VMobject):
class TipableVMobject(VMobject, metaclass=ConvertToOpenGL):
"""Meant for shared functionality between Arc and Line.
Functionality can be classified broadly into these groups:
@ -1093,7 +1093,7 @@ class Annulus(Circle):
self.generate_points()
class CubicBezier(VMobject):
class CubicBezier(VMobject, metaclass=ConvertToOpenGL):
"""A cubic Bézier curve.
Example
@ -1128,7 +1128,7 @@ class CubicBezier(VMobject):
self.add_cubic_bezier_curve(start_anchor, start_handle, end_handle, end_anchor)
class ArcPolygon(VMobject):
class ArcPolygon(VMobject, metaclass=ConvertToOpenGL):
"""A generalized polygon allowing for points to be connected with arcs.
This version tries to stick close to the way :class:`Polygon` is used. Points
@ -1249,7 +1249,7 @@ class ArcPolygon(VMobject):
self.arcs = arcs
class ArcPolygonFromArcs(VMobject):
class ArcPolygonFromArcs(VMobject, metaclass=ConvertToOpenGL):
"""A generalized polygon allowing for points to be connected with arcs.
This version takes in pre-defined arcs to generate the arcpolygon and introduces

View file

@ -8,16 +8,19 @@ import numpy as np
from pathops import Path as SkiaPath
from pathops import PathVerb, difference, intersection, union, xor
from manim.mobject.opengl.opengl_vectorized_mobject import OpenGLVMobject as VMobject
from manim import config
from manim.mobject.opengl.opengl_compatibility import ConvertToOpenGL
from manim.mobject.types.vectorized_mobject import VMobject
if TYPE_CHECKING:
from manim.typing import Point2DLike_Array, Point3D_Array, Point3DLike_Array
from ...constants import RendererType
__all__ = ["Union", "Intersection", "Difference", "Exclusion"]
class _BooleanOps(VMobject):
class _BooleanOps(VMobject, metaclass=ConvertToOpenGL):
"""This class contains some helper functions which
helps to convert to and from skia objects and manim
objects (:class:`~.VMobject`).
@ -81,15 +84,29 @@ class _BooleanOps(VMobject):
if len(points) == 0: # what? No points so return empty path
return path
subpaths = vmobject.get_subpaths_from_points(points)
for subpath in subpaths:
quads = vmobject.get_bezier_tuples_from_points(subpath)
start = subpath[0]
path.moveTo(*start[:2])
for _p0, p1, p2 in quads:
path.quadTo(*p1[:2], *p2[:2])
if vmobject.consider_points_equals(subpath[0], subpath[-1]):
path.close()
# In OpenGL it's quadratic beizer curves while on Cairo it's cubic...
if config.renderer == RendererType.OPENGL:
subpaths = vmobject.get_subpaths_from_points(points)
for subpath in subpaths:
quads = vmobject.get_bezier_tuples_from_points(subpath)
start = subpath[0]
path.moveTo(*start[:2])
for _p0, p1, p2 in quads:
path.quadTo(*p1[:2], *p2[:2])
if vmobject.consider_points_equals(subpath[0], subpath[-1]):
path.close()
elif config.renderer == RendererType.CAIRO:
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]
path.moveTo(*start[:2])
for _p0, p1, p2, p3 in quads:
path.cubicTo(*p1[:2], *p2[:2], *p3[:2])
if vmobject.consider_points_equals_2d(subpath[0], subpath[-1]):
path.close()
return path
def _convert_skia_path_to_vmobject(self, path: SkiaPath) -> VMobject:

View file

@ -15,14 +15,15 @@ from manim.mobject.geometry.shape_matchers import (
BackgroundRectangle,
SurroundingRectangle,
)
from manim.mobject.opengl.opengl_vectorized_mobject import OpenGLVGroup as VGroup
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.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 Point3DLike_Array
from manim.typing import ManimTextLabel, Point3DLike_Array
class Label(VGroup):
@ -61,7 +62,7 @@ class Label(VGroup):
def __init__(
self,
label: str | Tex | MathTex | Text,
label: str | ManimTextLabel,
label_config: dict[str, Any] | None = None,
box_config: dict[str, Any] | None = None,
frame_config: dict[str, Any] | None = None,
@ -94,13 +95,15 @@ class Label(VGroup):
frame_config = default_frame_config | (frame_config or {})
# Determine the type of label and instantiate the appropriate object
self.rendered_label: MathTex | Tex | Text
self.rendered_label: ManimTextLabel
if isinstance(label, str):
self.rendered_label = MathTex(label, **label_config)
elif isinstance(label, (MathTex, Tex, Text)):
elif isinstance(label, (MathTex, Text, Typst)):
self.rendered_label = label
else:
raise TypeError("Unsupported label type. Must be MathTex, Tex, or Text.")
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)
@ -155,7 +158,7 @@ class LabeledLine(Line):
def __init__(
self,
label: str | Tex | MathTex | Text,
label: str | ManimTextLabel,
label_position: float = 0.5,
label_config: dict[str, Any] | None = None,
box_config: dict[str, Any] | None = None,
@ -343,7 +346,7 @@ class LabeledPolygram(Polygram):
def __init__(
self,
*vertex_groups: Point3DLike_Array,
label: str | Tex | MathTex | Text,
label: str | ManimTextLabel,
precision: float = 0.01,
label_config: dict[str, Any] | None = None,
box_config: dict[str, Any] | None = None,

View file

@ -14,19 +14,18 @@ __all__ = [
"RightAngle",
]
from typing import TYPE_CHECKING, Any, Literal
from typing import TYPE_CHECKING, Any, Literal, cast
import numpy as np
from manim import config
from manim.constants import *
from manim.mobject.geometry.arc import Arc, ArcBetweenPoints, Dot, TipableVMobject
from manim.mobject.geometry.tips import ArrowTriangleFilledTip
from manim.mobject.opengl.opengl_mobject import OpenGLMobject as Mobject
from manim.mobject.opengl.opengl_vectorized_mobject import (
OpenGLDashedVMobject as DashedVMobject,
)
from manim.mobject.opengl.opengl_vectorized_mobject import OpenGLVGroup as VGroup
from manim.mobject.opengl.opengl_vectorized_mobject import OpenGLVMobject as VMobject
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
from manim.mobject.types.vectorized_mobject import DashedVMobject, VGroup, VMobject
from manim.utils.color import WHITE
from manim.utils.space_ops import angle_of_vector, line_intersection, normalize
@ -188,7 +187,7 @@ class Line(TipableVMobject):
direction
The direction.
"""
if isinstance(mob_or_point, Mobject):
if isinstance(mob_or_point, (Mobject, OpenGLMobject)):
mob = mob_or_point
if direction is None:
return mob.get_center()
@ -459,7 +458,7 @@ class TangentLine(Line):
self.scale(self.length / self.get_length())
class Elbow(VMobject):
class Elbow(VMobject, metaclass=ConvertToOpenGL):
"""Two lines that create a right angle about each other: L-shape.
Parameters
@ -600,7 +599,7 @@ class Arrow(Line):
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 = stroke_width
self.initial_stroke_width = self.stroke_width
self.add_tip(tip_shape=tip_shape)
self._set_stroke_width_from_length()
@ -649,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:
@ -689,13 +690,20 @@ class Arrow(Line):
def _set_stroke_width_from_length(self) -> Self:
"""Sets stroke width based on length."""
max_ratio = self.max_stroke_width_to_length_ratio
self.set_stroke(
width=min(
self.initial_stroke_width,
[max_ratio * self.get_length()] * len(self.initial_stroke_width),
),
recurse=False,
)
if config.renderer == RendererType.OPENGL:
# 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,
)
else:
self.set_stroke(
width=min(self.initial_stroke_width, max_ratio * self.get_length()),
family=False,
)
return self
@ -857,7 +865,7 @@ class DoubleArrow(Arrow):
self.add_tip(at_start=True, tip_shape=tip_shape_start)
class Angle(VMobject):
class Angle(VMobject, metaclass=ConvertToOpenGL):
"""A circular arc or elbow-type mobject representing an angle of two lines.
Parameters

View file

@ -24,12 +24,8 @@ import numpy as np
from manim.constants import *
from manim.mobject.geometry.arc import ArcBetweenPoints
from manim.mobject.opengl.opengl_vectorized_mobject import (
OpenGLVGroup as VGroup,
)
from manim.mobject.opengl.opengl_vectorized_mobject import (
OpenGLVMobject as VMobject,
)
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
@ -49,7 +45,7 @@ if TYPE_CHECKING:
from manim.utils.color import ParsableManimColor
class Polygram(VMobject):
class Polygram(VMobject, metaclass=ConvertToOpenGL):
"""A generalized :class:`Polygon`, allowing for disconnected sets of edges.
Parameters
@ -747,7 +743,7 @@ class RoundedRectangle(Rectangle):
self.round_corners(self.corner_radius)
class Cutout(VMobject):
class Cutout(VMobject, metaclass=ConvertToOpenGL):
"""A shape with smaller cutouts.
Parameters

View file

@ -17,8 +17,9 @@ from manim.constants import (
)
from manim.mobject.geometry.line import Line
from manim.mobject.geometry.polygram import RoundedRectangle
from manim.mobject.opengl.opengl_mobject import OpenGLMobject as Mobject
from manim.mobject.opengl.opengl_vectorized_mobject import OpenGLVGroup as VGroup
from manim.mobject.mobject import Mobject
from manim.mobject.opengl.opengl_mobject import OpenGLMobject
from manim.mobject.types.vectorized_mobject import VGroup
from manim.utils.color import BLACK, PURE_YELLOW, RED, ParsableManimColor
@ -54,10 +55,12 @@ class SurroundingRectangle(RoundedRectangle):
corner_radius: float = 0.0,
**kwargs: Any,
) -> None:
from manim.mobject.opengl.opengl_mobject import OpenGLGroup as Group
from manim.mobject.mobject import Group
if not all(isinstance(mob, Mobject) for mob in mobjects):
raise TypeError("Expected all inputs for parameter mobjects to be Mobjects")
if not all(isinstance(mob, (Mobject, OpenGLMobject)) for mob in mobjects):
raise TypeError(
"Expected all inputs for parameter mobjects to be a Mobjects"
)
if isinstance(buff, tuple):
buff_x = buff[0]
@ -124,7 +127,7 @@ class BackgroundRectangle(SurroundingRectangle):
buff=buff,
**kwargs,
)
self.original_fill_opacity: float = self.get_fill_opacity()
self.original_fill_opacity: float = self.fill_opacity
def pointwise_become_partial(self, mobject: Mobject, a: Any, b: float) -> Self:
self.set_fill(opacity=b * self.original_fill_opacity)

View file

@ -20,14 +20,15 @@ import numpy as np
from manim.constants import *
from manim.mobject.geometry.arc import Circle
from manim.mobject.geometry.polygram import Square, Triangle
from manim.mobject.opengl.opengl_vectorized_mobject import OpenGLVMobject as VMobject
from manim.mobject.opengl.opengl_compatibility import ConvertToOpenGL
from manim.mobject.types.vectorized_mobject import VMobject
from manim.utils.space_ops import angle_of_vector
if TYPE_CHECKING:
from manim.typing import Point3D, Vector3D
class ArrowTip(VMobject):
class ArrowTip(VMobject, metaclass=ConvertToOpenGL):
r"""Base class for arrow tips.
.. seealso::

View file

@ -18,6 +18,7 @@ import numpy as np
if TYPE_CHECKING:
from typing import TypeAlias
from manim.scene.scene import Scene
from manim.typing import Point3D, Point3DLike
NxGraph: TypeAlias = nx.classes.graph.Graph | nx.classes.digraph.DiGraph
@ -26,12 +27,11 @@ from manim.animation.composition import AnimationGroup
from manim.animation.creation import Create, Uncreate
from manim.mobject.geometry.arc import Dot, LabeledDot
from manim.mobject.geometry.line import Line
from manim.mobject.opengl.opengl_mobject import (
override_animate,
)
from manim.mobject.opengl.opengl_vectorized_mobject import OpenGLVGroup as VGroup
from manim.mobject.opengl.opengl_vectorized_mobject import OpenGLVMobject as VMobject
from manim.mobject.mobject import Mobject, override_animate
from manim.mobject.opengl.opengl_compatibility import ConvertToOpenGL
from manim.mobject.opengl.opengl_mobject import OpenGLMobject
from manim.mobject.text.tex_mobject import MathTex
from manim.mobject.types.vectorized_mobject import VMobject
from manim.utils.color import BLACK
@ -476,7 +476,7 @@ def _determine_graph_layout(
) from e
class GenericGraph(VMobject):
class GenericGraph(VMobject, metaclass=ConvertToOpenGL):
"""Abstract base class for graphs (that is, a collection of vertices
connected with edges).
@ -569,10 +569,10 @@ class GenericGraph(VMobject):
layout: LayoutName | dict[Hashable, Point3DLike] | LayoutFunction = "spring",
layout_scale: float | tuple[float, float, float] = 2,
layout_config: dict | None = None,
vertex_type: type[VMobject] = Dot,
vertex_type: type[Mobject] = Dot,
vertex_config: dict | None = None,
vertex_mobjects: dict | None = None,
edge_type: type[VMobject] = Line,
edge_type: type[Mobject] = Line,
partitions: Sequence[Sequence[Hashable]] | None = None,
root_vertex: Hashable | None = None,
edge_config: dict | None = None,
@ -664,13 +664,35 @@ class GenericGraph(VMobject):
raise NotImplementedError("To be implemented in concrete subclasses")
def _populate_edge_dict(
self, edges: list[tuple[Hashable, Hashable]], edge_type: type[VMobject]
self, edges: list[tuple[Hashable, Hashable]], edge_type: type[Mobject]
):
"""Helper method for populating the edges of the graph."""
raise NotImplementedError("To be implemented in concrete subclasses")
def __getitem__(self: Graph, v: Hashable) -> VMobject:
return self.vertices[v]
def __getitem__(self: Graph, k: Hashable | tuple[Hashable, Hashable]) -> Mobject:
"""Get a vertex or edge by its name/identifier.
Parameters
----------
k
A vertex name (hashable) or an edge tuple ``(u, v)``.
Returns
-------
Mobject
The :class:`~.Mobject` corresponding to the given vertex or edge.
Raises
------
KeyError
If ``k`` is not a valid vertex or edge.
"""
if k in self.vertices:
return self.vertices[k]
elif k in self.edges:
return self.edges[k]
else:
raise ValueError(f"Could not find {k} in vertices or edges")
def _create_vertex(
self,
@ -678,10 +700,10 @@ class GenericGraph(VMobject):
position: Point3DLike | None = None,
label: bool = False,
label_fill_color: str = BLACK,
vertex_type: type[VMobject] = Dot,
vertex_type: type[Mobject] = Dot,
vertex_config: dict | None = None,
vertex_mobject: dict | None = None,
) -> tuple[Hashable, Point3D, dict, VMobject]:
) -> tuple[Hashable, Point3D, dict, Mobject]:
np_position: Point3D = (
self.get_center() if position is None else np.asarray(position)
)
@ -698,7 +720,7 @@ class GenericGraph(VMobject):
label = MathTex(vertex, color=label_fill_color)
elif vertex in self._labels:
label = self._labels[vertex]
elif not isinstance(label, VMobject):
elif not isinstance(label, (Mobject, OpenGLMobject)):
label = None
base_vertex_config = copy(self.default_vertex_config)
@ -722,8 +744,8 @@ class GenericGraph(VMobject):
vertex: Hashable,
position: Point3DLike,
vertex_config: dict,
vertex_mobject: VMobject,
) -> VMobject:
vertex_mobject: Mobject,
) -> Mobject:
if vertex in self.vertices:
raise ValueError(
f"Vertex identifier '{vertex}' is already used for a vertex in this graph.",
@ -749,10 +771,10 @@ class GenericGraph(VMobject):
position: Point3DLike | None = None,
label: bool = False,
label_fill_color: str = BLACK,
vertex_type: type[VMobject] = Dot,
vertex_type: type[Mobject] = Dot,
vertex_config: dict | None = None,
vertex_mobject: dict | None = None,
) -> VMobject:
) -> Mobject:
"""Add a vertex to the graph.
Parameters
@ -767,7 +789,7 @@ class GenericGraph(VMobject):
Controls whether or not the vertex is labeled. If ``False`` (the default),
the vertex is not labeled; if ``True`` it is labeled using its
names (as specified in ``vertex``) via :class:`~.MathTex`. Alternatively,
any :class:`~.VMobject` can be passed to be used as the label.
any :class:`~.Mobject` can be passed to be used as the label.
label_fill_color
Sets the fill color of the default labels generated when ``labels``
is set to ``True``. Has no effect for other values of ``label``.
@ -798,10 +820,10 @@ class GenericGraph(VMobject):
positions: dict | None = None,
labels: bool = False,
label_fill_color: str = BLACK,
vertex_type: type[VMobject] = Dot,
vertex_type: type[Mobject] = Dot,
vertex_config: dict | None = None,
vertex_mobjects: dict | None = None,
) -> Iterable[tuple[Hashable, Point3D, dict, VMobject]]:
) -> Iterable[tuple[Hashable, Point3D, dict, Mobject]]:
if positions is None:
positions = {}
if vertex_mobjects is None:
@ -852,10 +874,10 @@ class GenericGraph(VMobject):
positions: dict | None = None,
labels: bool = False,
label_fill_color: str = BLACK,
vertex_type: type[VMobject] = Dot,
vertex_type: type[Mobject] = Dot,
vertex_config: dict | None = None,
vertex_mobjects: dict | None = None,
) -> VGroup:
):
"""Add a list of vertices to the graph.
Parameters
@ -870,7 +892,7 @@ class GenericGraph(VMobject):
Controls whether or not the vertex is labeled. If ``False`` (the default),
the vertex is not labeled; if ``True`` it is labeled using its
names (as specified in ``vertex``) via :class:`~.MathTex`. Alternatively,
any :class:`~.VMobject` can be passed to be used as the label.
any :class:`~.Mobject` can be passed to be used as the label.
label_fill_color
Sets the fill color of the default labels generated when ``labels``
is set to ``True``. Has no effect for other values of ``labels``.
@ -884,7 +906,7 @@ class GenericGraph(VMobject):
values are mobjects that should be used as vertices. Overrides
all other vertex customization options.
"""
return VGroup(
return [
self._add_created_vertex(*v)
for v in self._create_vertices(
*vertices,
@ -895,30 +917,29 @@ class GenericGraph(VMobject):
vertex_config=vertex_config,
vertex_mobjects=vertex_mobjects,
)
)
]
@override_animate(add_vertices)
def _add_vertices_animation(
self,
*vertices: Hashable,
anim_args: dict[str, Any] | None = None,
**kwargs: Any,
) -> AnimationGroup:
# Use introducer=False to prevent re-adding the vertices when animating them
base_anim_args = {"animation": Create, "introducer": False}
if anim_args is not None:
base_anim_args.update(anim_args)
animation = base_anim_args.pop("animation")
def _add_vertices_animation(self, *args, anim_args=None, **kwargs):
if anim_args is None:
anim_args = {}
animation = anim_args.pop("animation", Create)
vertex_mobjects = self._create_vertices(*args, **kwargs)
def on_finish(scene: Scene):
for v in vertex_mobjects:
scene.remove(v[-1])
self._add_created_vertex(*v)
vertex_mobjects = self.add_vertices(*vertices, **kwargs)
return AnimationGroup(
*(
animation(vertex_mobject, **base_anim_args)
for vertex_mobject in vertex_mobjects
),
*(animation(v[-1], **anim_args) for v in vertex_mobjects),
group=self,
_on_finish=on_finish,
)
def _remove_vertex(self, vertex: Hashable) -> VGroup:
def _remove_vertex(self, vertex):
"""Remove a vertex (as well as all incident edges) from the graph.
Parameters
@ -952,9 +973,9 @@ class GenericGraph(VMobject):
to_remove.append(self.vertices.pop(vertex))
self.remove(*to_remove)
return VGroup(*to_remove)
return self.get_group_class()(*to_remove)
def remove_vertices(self, *vertices: Hashable) -> VGroup:
def remove_vertices(self, *vertices):
"""Remove several vertices from the graph.
Parameters
@ -968,8 +989,7 @@ class GenericGraph(VMobject):
::
>>> G = Graph([1, 2, 3], [(1, 2), (2, 3)])
>>> removed = G.remove_vertices(2, 3)
>>> removed
>>> removed = G.remove_vertices(2, 3); removed
VGroup(Line, Line, Dot, Dot)
>>> G
Undirected graph on 1 vertices and 0 edges
@ -978,32 +998,26 @@ class GenericGraph(VMobject):
mobjects = []
for v in vertices:
mobjects.extend(self._remove_vertex(v).submobjects)
return VGroup(*mobjects)
return self.get_group_class()(*mobjects)
@override_animate(remove_vertices)
def _remove_vertices_animation(
self, *vertices: Hashable, anim_args: dict[str, Any] | None = None
) -> AnimationGroup:
base_anim_args = {"animation": Uncreate}
if anim_args is not None:
base_anim_args.update(anim_args)
animation = base_anim_args.pop("animation")
def _remove_vertices_animation(self, *vertices, anim_args=None):
if anim_args is None:
anim_args = {}
vertex_and_edge_mobjects = self.remove_vertices(*vertices)
animation = anim_args.pop("animation", Uncreate)
mobjects = self.remove_vertices(*vertices)
return AnimationGroup(
*(
animation(vertex_or_edge_mobject, **anim_args)
for vertex_or_edge_mobject in vertex_and_edge_mobjects
),
introducer=True, # Reintroduce vertices and edges temporarily to animate them
*(animation(mobj, **anim_args) for mobj in mobjects), group=self
)
def _add_edge(
self,
edge: tuple[Hashable, Hashable],
edge_type: type[VMobject] = Line,
edge_type: type[Mobject] = Line,
edge_config: dict | None = None,
) -> VGroup:
):
"""Add a new edge to the graph.
Parameters
@ -1047,17 +1061,15 @@ class GenericGraph(VMobject):
self.add(edge_mobject)
added_mobjects.append(edge_mobject)
return VGroup(*added_mobjects)
return self.get_group_class()(*added_mobjects)
def add_edges(
self,
*edges: tuple[Hashable, Hashable],
edge_type: type[VMobject] = Line,
edge_config: dict[str, Any]
| dict[tuple[Hashable, Hashable], dict[str, Any]]
| None = None,
**kwargs: Any,
) -> VGroup:
edge_type: type[Mobject] = Line,
edge_config: dict | None = None,
**kwargs,
):
"""Add new edges to the graph.
Parameters
@ -1110,33 +1122,20 @@ class GenericGraph(VMobject):
),
added_vertices,
)
return VGroup(*added_mobjects)
return self.get_group_class()(*added_mobjects)
@override_animate(add_edges)
def _add_edges_animation(
self,
*edges: tuple[Hashable, Hashable],
anim_args: dict[str, Any] | None = None,
**kwargs: Any,
) -> AnimationGroup:
# TODO: the animation is broken with introducer=False, but not passing it
# disbands the graph upon re-adding the edges and vertices. Fix this
def _add_edges_animation(self, *args, anim_args=None, **kwargs):
if anim_args is None:
anim_args = {}
animation = anim_args.pop("animation", Create)
# Use introducer=False to prevent re-adding the edges and vertices when animating
base_anim_args = {"animation": Create, "introducer": False}
if anim_args is not None:
base_anim_args.update(anim_args)
animation = base_anim_args.pop("animation")
edge_and_vertex_mobjects = self.add_edges(*edges, **kwargs)
mobjects = self.add_edges(*args, **kwargs)
return AnimationGroup(
*(
animation(edge_or_vertex_mobject, **base_anim_args)
for edge_or_vertex_mobject in edge_and_vertex_mobjects
)
*(animation(mobj, **anim_args) for mobj in mobjects), group=self
)
def _remove_edge(self, edge: tuple[Hashable]) -> VMobject:
def _remove_edge(self, edge: tuple[Hashable]):
"""Remove an edge from the graph.
Parameters
@ -1148,7 +1147,7 @@ class GenericGraph(VMobject):
Returns
-------
VMobject
Mobject
The removed edge.
"""
@ -1163,7 +1162,7 @@ class GenericGraph(VMobject):
self.remove(edge_mobject)
return edge_mobject
def remove_edges(self, *edges: tuple[Hashable]) -> VGroup:
def remove_edges(self, *edges: tuple[Hashable]):
"""Remove several edges from the graph.
Parameters
@ -1178,25 +1177,17 @@ class GenericGraph(VMobject):
"""
edge_mobjects = [self._remove_edge(edge) for edge in edges]
return VGroup(*edge_mobjects)
return self.get_group_class()(*edge_mobjects)
@override_animate(remove_edges)
def _remove_edges_animation(
self, *edges: tuple[Hashable, Hashable], anim_args: dict[str, Any] | None = None
) -> AnimationGroup:
base_anim_args = {"animation": Uncreate}
if anim_args is not None:
base_anim_args.update(anim_args)
animation = base_anim_args.pop("animation")
def _remove_edges_animation(self, *edges, anim_args=None):
if anim_args is None:
anim_args = {}
edge_and_vertex_mobjects = self.remove_edges(*edges)
return AnimationGroup(
*(
animation(edge_or_vertex_mobject, **anim_args)
for edge_or_vertex_mobject in edge_and_vertex_mobjects
),
introducer=True, # Reintroduce edges and vertices temporarily to animate them
)
animation = anim_args.pop("animation", Uncreate)
mobjects = self.remove_edges(*edges)
return AnimationGroup(*(animation(mobj, **anim_args) for mobj in mobjects))
@classmethod
def from_networkx(
@ -1373,6 +1364,11 @@ class Graph(GenericGraph):
g[2].animate.move_to([-1, 1, 0]),
g[3].animate.move_to([1, -1, 0]),
g[4].animate.move_to([-1, -1, 0]))
self.play(LaggedStart(Wiggle(g[(1, 2)]),
Wiggle(g[(2, 3)]),
Wiggle(g[(3, 4)]),
Wiggle(g[(1, 3)]),
Wiggle(g[(1, 4)])))
self.wait()
There are several automatic positioning algorithms to choose from:
@ -1568,7 +1564,7 @@ class Graph(GenericGraph):
return nx.Graph()
def _populate_edge_dict(
self, edges: list[tuple[Hashable, Hashable]], edge_type: type[VMobject]
self, edges: list[tuple[Hashable, Hashable]], edge_type: type[Mobject]
):
self.edges = {
(u, v): edge_type(
@ -1593,9 +1589,6 @@ class Graph(GenericGraph):
def __repr__(self: Graph) -> str:
return f"Undirected graph on {len(self.vertices)} vertices and {len(self.edges)} edges"
def __str__(self: Graph) -> str:
return self.__repr__()
class DiGraph(GenericGraph):
"""A directed graph.
@ -1778,7 +1771,7 @@ class DiGraph(GenericGraph):
return nx.DiGraph()
def _populate_edge_dict(
self, edges: list[tuple[Hashable, Hashable]], edge_type: type[VMobject]
self, edges: list[tuple[Hashable, Hashable]], edge_type: type[Mobject]
):
self.edges = {
(u, v): edge_type(
@ -1801,7 +1794,7 @@ class DiGraph(GenericGraph):
"""
for (u, v), edge in graph.edges.items():
tip = edge.pop_tips()[0]
# Passing the VMobject instead of the vertex makes the tip
# Passing the Mobject instead of the vertex makes the tip
# stop on the bounding box of the vertex.
edge.set_points_by_ends(
graph[u],
@ -1813,6 +1806,3 @@ class DiGraph(GenericGraph):
def __repr__(self: DiGraph) -> str:
return f"Directed graph on {len(self.vertices)} vertices and {len(self.edges)} edges"
def __str__(self: DiGraph) -> str:
return self.__repr__()

View file

@ -26,22 +26,17 @@ from manim.mobject.geometry.polygram import Polygon, Rectangle, RegularPolygon
from manim.mobject.graphing.functions import ImplicitFunction, ParametricFunction
from manim.mobject.graphing.number_line import NumberLine
from manim.mobject.graphing.scale import LinearBase
from manim.mobject.opengl.opengl_mobject import OpenGLMobject as Mobject
from manim.mobject.mobject import Mobject
from manim.mobject.opengl.opengl_compatibility import ConvertToOpenGL
from manim.mobject.opengl.opengl_surface import OpenGLSurface
from manim.mobject.opengl.opengl_vectorized_mobject import (
OpenGLVDict as VDict,
)
from manim.mobject.opengl.opengl_vectorized_mobject import (
OpenGLVectorizedPoint as VectorizedPoint,
)
from manim.mobject.opengl.opengl_vectorized_mobject import (
OpenGLVGroup as VGroup,
)
from manim.mobject.opengl.opengl_vectorized_mobject import (
OpenGLVMobject as VMobject,
)
from manim.mobject.text.tex_mobject import MathTex
from manim.mobject.three_d.three_dimensions import Surface
from manim.mobject.types.vectorized_mobject import (
VDict,
VectorizedPoint,
VGroup,
VMobject,
)
from manim.utils.color import (
BLACK,
BLUE,
@ -60,6 +55,7 @@ from manim.utils.simple_functions import binary_search
from manim.utils.space_ops import angle_of_vector
if TYPE_CHECKING:
from manim.mobject.mobject import Mobject
from manim.typing import (
ManimFloat,
Point2D,
@ -979,10 +975,10 @@ class CoordinateSystem:
.. manim:: PlotSurfaceExample
:save_last_frame:
class PlotSurfaceExample(Scene):
class PlotSurfaceExample(ThreeDScene):
def construct(self):
resolution_fa = 16
self.camera.set_orientation(theta=-60 * DEGREES, phi=75 * DEGREES)
self.set_camera_orientation(phi=75 * DEGREES, theta=-60 * DEGREES)
axes = ThreeDAxes(x_range=(-3, 3, 1), y_range=(-3, 3, 1), z_range=(-5, 5, 1))
def param_trig(u, v):
x = u
@ -998,8 +994,21 @@ class CoordinateSystem:
)
self.add(axes, trig_plane)
"""
if config.renderer == RendererType.OPENGL:
if config.renderer == RendererType.CAIRO:
surface = Surface(
lambda u, v: self.c2p(u, v, function(u, v)),
u_range=u_range,
v_range=v_range,
**kwargs,
)
if colorscale:
surface.set_fill_by_value(
axes=self.copy(),
colorscale=colorscale,
axis=colorscale_axis,
)
elif config.renderer == RendererType.OPENGL:
surface = OpenGLSurface(
lambda u, v: self.c2p(u, v, function(u, v)),
u_range=u_range,
v_range=v_range,
@ -1008,9 +1017,6 @@ class CoordinateSystem:
colorscale_axis=colorscale_axis,
**kwargs,
)
elif config.renderer == RendererType.CAIRO:
# TODO: CairoSurface?
raise NotImplementedError
return surface
@ -1867,7 +1873,7 @@ class CoordinateSystem:
def _origin_shift(axis_range: Sequence[float]) -> float: ...
class Axes(VGroup, CoordinateSystem):
class Axes(VGroup, CoordinateSystem, metaclass=ConvertToOpenGL):
"""Creates a set of axes.
Parameters
@ -2506,7 +2512,6 @@ class ThreeDAxes(Axes):
self.z_axis = z_axis
if config.renderer == RendererType.CAIRO:
# TODO: check in how far these methods are supported by new VMobject class
self._add_3d_pieces()
self._set_axis_shading()
@ -2568,11 +2573,11 @@ class ThreeDAxes(Axes):
.. manim:: GetYAxisLabelExample
:save_last_frame:
class GetYAxisLabelExample(Scene):
class GetYAxisLabelExample(ThreeDScene):
def construct(self):
ax = ThreeDAxes()
lab = ax.get_y_axis_label(Tex("$y$-label"))
self.camera.set_orientation(theta=PI/5, phi=2*PI/5)
self.set_camera_orientation(phi=2*PI/5, theta=PI/5)
self.add(ax, lab)
"""
positioned_label = self._get_axis_label(
@ -2618,11 +2623,11 @@ class ThreeDAxes(Axes):
.. manim:: GetZAxisLabelExample
:save_last_frame:
class GetZAxisLabelExample(Scene):
class GetZAxisLabelExample(ThreeDScene):
def construct(self):
ax = ThreeDAxes()
lab = ax.get_z_axis_label(Tex("$z$-label"))
self.camera.set_orientation(theta=PI/5, phi=2*PI/5)
self.set_camera_orientation(phi=2*PI/5, theta=PI/5)
self.add(ax, lab)
"""
positioned_label = self._get_axis_label(
@ -2669,9 +2674,9 @@ class ThreeDAxes(Axes):
.. manim:: GetAxisLabelsExample
:save_last_frame:
class GetAxisLabelsExample(Scene):
class GetAxisLabelsExample(ThreeDScene):
def construct(self):
self.camera.set_orientation(theta=PI/5, phi=2*PI/5)
self.set_camera_orientation(phi=2*PI/5, theta=PI/5)
axes = ThreeDAxes()
labels = axes.get_axis_labels(
Text("x-axis").scale(0.7), Text("y-axis").scale(0.45), Text("z-axis").scale(0.45)

View file

@ -13,7 +13,8 @@ from isosurfaces import plot_isoline
from manim import config
from manim.mobject.graphing.scale import LinearBase, _ScaleBase
from manim.mobject.opengl.opengl_vectorized_mobject import OpenGLVMobject as VMobject
from manim.mobject.opengl.opengl_compatibility import ConvertToOpenGL
from manim.mobject.types.vectorized_mobject import VMobject
if TYPE_CHECKING:
from typing import Any, Self
@ -24,7 +25,7 @@ if TYPE_CHECKING:
from manim.utils.color import PURE_YELLOW
class ParametricFunction(VMobject):
class ParametricFunction(VMobject, metaclass=ConvertToOpenGL):
"""A parametric curve.
Parameters
@ -65,7 +66,7 @@ class ParametricFunction(VMobject):
.. manim:: ThreeDParametricSpring
:save_last_frame:
class ThreeDParametricSpring(Scene):
class ThreeDParametricSpring(ThreeDScene):
def construct(self):
curve1 = ParametricFunction(
lambda u: (
@ -76,7 +77,7 @@ class ParametricFunction(VMobject):
).set_shade_in_3d(True)
axes = ThreeDAxes()
self.add(axes, curve1)
self.camera.set_orientation(theta=-60 * DEGREES, phi=80 * DEGREES)
self.set_camera_orientation(phi=80 * DEGREES, theta=-60 * DEGREES)
self.wait()
.. attention::
@ -236,7 +237,7 @@ class FunctionGraph(ParametricFunction):
return self.parametric_function(x)
class ImplicitFunction(VMobject):
class ImplicitFunction(VMobject, metaclass=ConvertToOpenGL):
def __init__(
self,
func: Callable[[float, float], float],

View file

@ -2,17 +2,20 @@
from __future__ import annotations
from manim.mobject.mobject import Mobject
from manim.mobject.opengl.opengl_vectorized_mobject import OpenGLVMobject
__all__ = ["NumberLine", "UnitInterval"]
from collections.abc import Callable, Iterable, Sequence
from typing import TYPE_CHECKING
from collections.abc import Iterable, Sequence
from typing import TYPE_CHECKING, Any, cast
if TYPE_CHECKING:
from typing import Any, Self
from typing import Self
from manim.mobject.geometry.tips import ArrowTip
from manim.typing import Point3D, Point3DLike, Vector3D
from manim.typing import ManimTextLabel, Point3D, Point3DLike, Vector3D
import numpy as np
@ -20,16 +23,10 @@ from manim import config
from manim.constants import *
from manim.mobject.geometry.line import Line
from manim.mobject.graphing.scale import LinearBase, _ScaleBase
from manim.mobject.opengl.opengl_mobject import OpenGLMobject as Mobject
from manim.mobject.opengl.opengl_vectorized_mobject import (
OpenGLVGroup as VGroup,
)
from manim.mobject.opengl.opengl_vectorized_mobject import (
OpenGLVMobject as VMobject,
)
from manim.mobject.text.numbers import DecimalNumber, Integer
from manim.mobject.text.tex_mobject import MathTex, Tex
from manim.mobject.text.text_mobject import Text
from manim.mobject.text.numbers import DecimalNumber
from manim.mobject.text.tex_mobject import MathTex, SingleStringMathTex, Tex
from manim.mobject.text.typst_mobject import Typst, TypstMath
from manim.mobject.types.vectorized_mobject import VGroup, VMobject
from manim.utils.bezier import interpolate
from manim.utils.config_ops import merge_dicts_recursively
from manim.utils.space_ops import normalize
@ -164,7 +161,7 @@ class NumberLine(Line):
include_numbers: bool = False,
font_size: float = 36,
label_direction: Point3DLike = DOWN,
label_constructor: type[MathTex] = MathTex,
label_constructor: type[ManimTextLabel] = MathTex,
scaling: _ScaleBase = LinearBase(),
line_to_number_buff: float = MED_SMALL_BUFF,
decimal_number_config: dict | None = None,
@ -453,7 +450,7 @@ class NumberLine(Line):
direction: Vector3D | None = None,
buff: float | None = None,
font_size: float | None = None,
label_constructor: type[MathTex] | None = None,
label_constructor: type[SingleStringMathTex] | None = None,
**number_config: dict[str, Any],
) -> VMobject:
"""Generates a positioned :class:`~.DecimalNumber` mobject
@ -490,7 +487,7 @@ class NumberLine(Line):
if font_size is None:
font_size = self.font_size
if label_constructor is None:
label_constructor = self.label_constructor
label_constructor = cast(type[SingleStringMathTex], self.label_constructor)
num_mob = DecimalNumber(
x,
@ -518,7 +515,7 @@ class NumberLine(Line):
x_values: Iterable[float] | None = None,
excluding: Iterable[float] | None = None,
font_size: float | None = None,
label_constructor: type[MathTex] | None = None,
label_constructor: type[SingleStringMathTex] | None = None,
**kwargs: Any,
) -> Self:
"""Adds :class:`~.DecimalNumber` mobjects representing their position
@ -550,7 +547,7 @@ class NumberLine(Line):
font_size = self.font_size
if label_constructor is None:
label_constructor = self.label_constructor
label_constructor = cast(type[SingleStringMathTex], self.label_constructor)
numbers = VGroup()
for x in x_values:
@ -574,7 +571,7 @@ class NumberLine(Line):
direction: Point3DLike | None = None,
buff: float | None = None,
font_size: float | None = None,
label_constructor: type[MathTex] | None = None,
label_constructor: type[ManimTextLabel] | None = None,
) -> Self:
"""Adds specifically positioned labels to the :class:`~.NumberLine` using a ``dict``.
The labels can be accessed after creation via ``self.labels``.
@ -612,14 +609,18 @@ class NumberLine(Line):
# TODO: remove this check and ability to call
# this method via CoordinateSystem.add_coordinates()
# must be explicitly called
if isinstance(label, str) and label_constructor is MathTex:
label = Tex(label)
if isinstance(label, str):
if label_constructor is MathTex:
label = Tex(label)
elif label_constructor is TypstMath:
label = Typst(label)
else:
label = self._create_label_tex(label, label_constructor)
else:
label = self._create_label_tex(label, label_constructor)
if hasattr(label, "font_size"):
assert isinstance(label, (MathTex, Tex, Text, Integer)), label
label.font_size = font_size
cast(Any, label).font_size = font_size
else:
raise AttributeError(f"{label} is not compatible with add_labels.")
label.next_to(self.number_to_point(x), direction=direction, buff=buff)
@ -632,7 +633,7 @@ class NumberLine(Line):
def _create_label_tex(
self,
label_tex: str | float | VMobject,
label_constructor: Callable | None = None,
label_constructor: type[ManimTextLabel] | None = None,
**kwargs: Any,
) -> VMobject:
"""Checks if the label is a :class:`~.VMobject`, otherwise, creates a
@ -654,7 +655,7 @@ class NumberLine(Line):
:class:`~.VMobject`
The label.
"""
if isinstance(label_tex, VMobject):
if isinstance(label_tex, (VMobject, OpenGLVMobject)):
return label_tex
if label_constructor is None:
label_constructor = self.label_constructor

View file

@ -14,14 +14,10 @@ from manim import config, logger
from manim.constants import *
from manim.mobject.geometry.polygram import Rectangle
from manim.mobject.graphing.coordinate_systems import Axes
from manim.mobject.opengl.opengl_vectorized_mobject import (
OpenGLVGroup as VGroup,
)
from manim.mobject.opengl.opengl_vectorized_mobject import (
OpenGLVMobject as VMobject,
)
from manim.mobject.opengl.opengl_vectorized_mobject import OpenGLVMobject
from manim.mobject.svg.brace import Brace
from manim.mobject.text.tex_mobject import MathTex, Tex
from manim.mobject.types.vectorized_mobject import VGroup, VMobject
from manim.typing import Vector3D
from manim.utils.color import (
BLUE_E,
@ -148,7 +144,7 @@ class SampleSpace(Rectangle):
def get_subdivision_braces_and_labels(
self,
parts: VGroup,
labels: list[str | VMobject],
labels: list[str | VMobject | OpenGLVMobject],
direction: Vector3D,
buff: float = SMALL_BUFF,
min_num_quads: int = 1,
@ -157,7 +153,7 @@ class SampleSpace(Rectangle):
braces = VGroup()
for label, part in zip(labels, parts, strict=False):
brace = Brace(part, direction, min_num_quads=min_num_quads, buff=buff)
if isinstance(label, VMobject):
if isinstance(label, (VMobject, OpenGLVMobject)):
label_mob = label
else:
label_mob = MathTex(label)
@ -178,7 +174,7 @@ class SampleSpace(Rectangle):
def get_side_braces_and_labels(
self,
labels: list[str | VMobject],
labels: list[str | VMobject | OpenGLVMobject],
direction: Vector3D = LEFT,
**kwargs: Any,
) -> VGroup:
@ -189,14 +185,14 @@ class SampleSpace(Rectangle):
)
def get_top_braces_and_labels(
self, labels: list[str | VMobject], **kwargs: Any
self, labels: list[str | VMobject | OpenGLVMobject], **kwargs: Any
) -> VGroup:
assert hasattr(self, "vertical_parts")
parts = self.vertical_parts
return self.get_subdivision_braces_and_labels(parts, labels, UP, **kwargs)
def get_bottom_braces_and_labels(
self, labels: list[str | VMobject], **kwargs: Any
self, labels: list[str | VMobject | OpenGLVMobject], **kwargs: Any
) -> VGroup:
assert hasattr(self, "vertical_parts")
parts = self.vertical_parts
@ -211,13 +207,11 @@ class SampleSpace(Rectangle):
if hasattr(parts, subattr):
self.add(getattr(parts, subattr))
def __getitem__(self, index: int) -> SampleSpace:
def __getitem__(self, index: int) -> VMobject:
if hasattr(self, "horizontal_parts"):
val: SampleSpace = self.horizontal_parts[index]
return val
return self.horizontal_parts[index]
elif hasattr(self, "vertical_parts"):
val = self.vertical_parts[index]
return val
return self.vertical_parts[index]
return self.split()[index]
@ -377,7 +371,7 @@ class BarChart(Axes):
# to accommodate negative bars, the label may need to be
# below or above the x_axis depending on the value of the bar
direction = UP if self.values[i] < 0 else DOWN
bar_name_label: MathTex = self.x_axis.label_constructor(bar_name)
bar_name_label = self.x_axis.label_constructor(bar_name)
bar_name_label.font_size = self.x_axis.font_size
bar_name_label.next_to(

View file

@ -13,9 +13,7 @@ from manim.mobject.text.numbers import Integer
if TYPE_CHECKING:
from collections.abc import Callable
from manim.mobject.opengl.opengl_vectorized_mobject import (
OpenGLVMobject as VMobject,
)
from manim.mobject.types.vectorized_mobject import VMobject
class _ScaleBase:

View file

@ -11,12 +11,7 @@ import svgelements as se
from manim.animation.updaters.update import UpdateFromAlphaFunc
from manim.mobject.geometry.arc import Circle
from manim.mobject.geometry.polygram import Square, Triangle
from manim.mobject.opengl.opengl_vectorized_mobject import (
OpenGLVGroup as VGroup,
)
from manim.mobject.opengl.opengl_vectorized_mobject import (
OpenGLVMobject as VMobject,
)
from manim.mobject.mobject import Mobject
from manim.typing import Vector3D
from .. import constants as cst
@ -25,6 +20,7 @@ from ..animation.composition import AnimationGroup, Succession
from ..animation.creation import Create, SpiralIn
from ..animation.fading import FadeIn
from ..mobject.svg.svg_mobject import VMobjectFromSVGPath
from ..mobject.types.vectorized_mobject import VGroup
from ..utils.rate_functions import ease_in_out_cubic, smooth
MANIM_SVG_PATHS: list[se.Path] = [
@ -157,7 +153,7 @@ class ManimBanner(VGroup):
self.scale_factor = 1.0
self.M = VMobjectFromSVGPath(MANIM_SVG_PATHS[0]).flip(cst.RIGHT).center()
self.M.set_stroke(width=0).scale(
self.M.set(stroke_width=0).scale(
7 * cst.DEFAULT_FONT_SIZE * cst.SCALE_FACTOR_PER_FONT_POINT
)
self.M.set_fill(color=self.font_color, opacity=1).shift(
@ -174,7 +170,7 @@ class ManimBanner(VGroup):
anim = VGroup()
for ind, path in enumerate(MANIM_SVG_PATHS[1:]):
tex = VMobjectFromSVGPath(path).flip(cst.RIGHT).center()
tex.set_stroke(width=0).scale(
tex.set(stroke_width=0).scale(
cst.DEFAULT_FONT_SIZE * cst.SCALE_FACTOR_PER_FONT_POINT
)
if ind > 0:
@ -269,7 +265,7 @@ class ManimBanner(VGroup):
)
"""
if direction.lower() not in {"left", "right", "center"}:
if direction not in ["left", "right", "center"]:
raise ValueError("direction must be 'left', 'right' or 'center'.")
m_shape_offset = 6.25 * self.scale_factor
@ -296,7 +292,7 @@ class ManimBanner(VGroup):
elif direction == "left":
left_group.shift(-vector)
def slide_and_uncover(mob: VMobject, alpha: float) -> None:
def slide_and_uncover(mob: Mobject, alpha: float) -> None:
shift(alpha * (m_shape_offset + shape_sliding_overshoot) * cst.RIGHT)
# Add letters when they are covered
@ -309,11 +305,11 @@ class ManimBanner(VGroup):
if alpha == 1:
self.remove(*[self.anim])
self.add_to_back(self.anim)
mob.shapes.set_z(0)
mob.shapes.set_z_index(0)
mob.shapes.save_state()
mob.M.save_state()
def slide_back(mob: VMobject, alpha: float) -> None:
def slide_back(mob: Mobject, alpha: float) -> None:
if alpha == 0:
m_clone.set_opacity(1)
m_clone.move_to(mob.anim[-1])
@ -331,13 +327,11 @@ class ManimBanner(VGroup):
slide_and_uncover,
run_time=run_time * 2 / 3,
rate_func=ease_in_out_cubic,
introducer=True,
),
UpdateFromAlphaFunc(
self,
slide_back,
run_time=run_time * 1 / 3,
rate_func=smooth,
introducer=True,
),
)

View file

@ -40,22 +40,18 @@ __all__ = [
import itertools as it
from collections.abc import Callable, Iterable, Sequence
from collections.abc import Callable, Iterable
from typing import Any, Self
import numpy as np
from manim.mobject.opengl.opengl_mobject import OpenGLMobject as Mobject
from manim.mobject.opengl.opengl_vectorized_mobject import (
OpenGLVGroup as VGroup,
)
from manim.mobject.opengl.opengl_vectorized_mobject import (
OpenGLVMobject as VMobject,
)
from manim.mobject.opengl.opengl_compatibility import ConvertToOpenGL
from manim.mobject.text.numbers import DecimalNumber, Integer
from manim.mobject.text.tex_mobject import MathTex, Tex
from manim.typing import Vector2DLike, Vector3DLike
from ..constants import *
from ..mobject.types.vectorized_mobject import VGroup, VMobject
# TO DO : The following two functions are not used in this file.
# Not sure if we should keep it or not.
@ -76,7 +72,7 @@ def matrix_to_mobject(matrix: np.ndarray) -> MathTex:
return MathTex(matrix_to_tex_string(matrix))
class Matrix(VMobject):
class Matrix(VMobject, metaclass=ConvertToOpenGL):
r"""A mobject that displays a matrix on the screen.
Parameters
@ -168,16 +164,16 @@ class Matrix(VMobject):
def __init__(
self,
matrix: Iterable,
matrix: Iterable[Iterable[Any] | Vector2DLike],
v_buff: float = 0.8,
h_buff: float = 1.3,
bracket_h_buff: float = MED_SMALL_BUFF,
bracket_v_buff: float = MED_SMALL_BUFF,
add_background_rectangles_to_entries: bool = False,
include_background_rectangle: bool = False,
element_to_mobject: type[Mobject] | Callable[..., Mobject] = MathTex,
element_to_mobject_config: dict = {},
element_alignment_corner: Sequence[float] = DR,
element_to_mobject: type[VMobject] | Callable[..., VMobject] = MathTex,
element_to_mobject_config: dict[str, Any] = {},
element_alignment_corner: Vector3DLike = DR,
left_bracket: str = "[",
right_bracket: str = "]",
stretch_brackets: bool = True,
@ -210,7 +206,9 @@ class Matrix(VMobject):
if self.include_background_rectangle:
self.add_background_rectangle()
def _matrix_to_mob_matrix(self, matrix: np.ndarray) -> list[list[Mobject]]:
def _matrix_to_mob_matrix(
self, matrix: Iterable[Iterable[Any]]
) -> list[list[VMobject]]:
return [
[
self.element_to_mobject(item, **self.element_to_mobject_config)
@ -219,7 +217,7 @@ class Matrix(VMobject):
for row in matrix
]
def _organize_mob_matrix(self, matrix: list[list[Mobject]]) -> Self:
def _organize_mob_matrix(self, matrix: list[list[VMobject]]) -> Self:
for i, row in enumerate(matrix):
for j, _ in enumerate(row):
mob = matrix[i][j]
@ -405,7 +403,7 @@ class Matrix(VMobject):
mob.add_background_rectangle()
return self
def get_mob_matrix(self) -> list[list[Mobject]]:
def get_mob_matrix(self) -> list[list[VMobject]]:
"""Return the underlying mob matrix mobjects.
Returns
@ -487,8 +485,8 @@ class DecimalMatrix(Matrix):
def __init__(
self,
matrix: Iterable,
element_to_mobject: type[Mobject] = DecimalNumber,
matrix: Iterable[Iterable[Any]],
element_to_mobject: type[VMobject] | Callable[..., VMobject] = DecimalNumber,
element_to_mobject_config: dict[str, Any] = {"num_decimal_places": 1},
**kwargs: Any,
):
@ -532,8 +530,8 @@ class IntegerMatrix(Matrix):
def __init__(
self,
matrix: Iterable,
element_to_mobject: type[Mobject] = Integer,
matrix: Iterable[Iterable[Any]],
element_to_mobject: type[VMobject] | Callable[..., VMobject] = Integer,
**kwargs: Any,
):
"""
@ -570,8 +568,8 @@ class MobjectMatrix(Matrix):
def __init__(
self,
matrix: Iterable,
element_to_mobject: type[Mobject] | Callable[..., Mobject] = lambda m: m,
matrix: Iterable[Iterable[Any]],
element_to_mobject: type[VMobject] | Callable[..., VMobject] = lambda m: m,
**kwargs: Any,
):
super().__init__(matrix, element_to_mobject=element_to_mobject, **kwargs)

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -4,6 +4,7 @@ __all__ = ["OpenGLPMobject", "OpenGLPGroup", "OpenGLPMPoint"]
from typing import TYPE_CHECKING
import moderngl
import numpy as np
from manim.constants import *
@ -17,6 +18,7 @@ from manim.utils.color import (
color_gradient,
color_to_rgba,
)
from manim.utils.config_ops import _Uniforms
from manim.utils.iterables import resize_with_interpolation
if TYPE_CHECKING:
@ -33,17 +35,25 @@ __all__ = ["OpenGLPMobject", "OpenGLPGroup", "OpenGLPMPoint"]
class OpenGLPMobject(OpenGLMobject):
shader_folder = "true_dot"
# Scale for consistency with cairo units
OPENGL_POINT_RADIUS_SCALE_FACTOR = 0.01
shader_dtype = [
("point", np.float32, (3,)),
("color", np.float32, (4,)),
]
point_radius = _Uniforms()
def __init__(
self,
stroke_width: float = 2.0,
color: ParsableManimColor = PURE_YELLOW,
render_primitive: int = moderngl.POINTS,
**kwargs,
):
self.stroke_width = stroke_width
super().__init__(color=color, **kwargs)
super().__init__(color=color, render_primitive=render_primitive, **kwargs)
self.point_radius = (
self.stroke_width * OpenGLPMobject.OPENGL_POINT_RADIUS_SCALE_FACTOR
)
@ -138,26 +148,21 @@ class OpenGLPMobject(OpenGLMobject):
def filter_out(self, condition):
for mob in self.family_members_with_points():
to_keep = ~np.apply_along_axis(condition, 1, mob.points)
for attr_name in mob.get_array_attrs():
array = getattr(mob, attr_name)
filtered_array = array[to_keep]
setattr(mob, attr_name, filtered_array)
for key in mob.data:
mob.data[key] = mob.data[key][to_keep]
return self
def sort_points(self, function=lambda p: p[0]):
"""function is any map from R^3 to R"""
for mob in self.family_members_with_points():
indices = np.argsort(np.apply_along_axis(function, 1, mob.points))
for attr_name in mob.get_array_attrs():
array = getattr(mob, attr_name)
sorted_array = array[indices]
setattr(mob, attr_name, sorted_array)
for key in mob.data:
mob.data[key] = mob.data[key][indices]
return self
def ingest_submobjects(self):
for attr_name in self.get_array_attrs():
submob_arrays = [getattr(sm, attr_name) for sm in self.get_family()]
setattr(self, attr_name, np.vstack(submob_arrays))
for key in self.data:
self.data[key] = np.vstack([sm.data[key] for sm in self.get_family()])
return self
def point_from_proportion(self, alpha):
@ -167,12 +172,16 @@ class OpenGLPMobject(OpenGLMobject):
def pointwise_become_partial(self, pmobject, a, b):
lower_index = int(a * pmobject.get_num_points())
upper_index = int(b * pmobject.get_num_points())
for attr_name in self.get_array_attrs():
pmob_array = getattr(pmobject, attr_name)
partial_pmob_array = pmob_array[lower_index:upper_index]
setattr(self, attr_name, partial_pmob_array)
for key in self.data:
self.data[key] = pmobject.data[key][lower_index:upper_index]
return self
def get_shader_data(self):
shader_data = np.zeros(len(self.points), dtype=self.shader_dtype)
self.read_data_to_shader(shader_data, "point", "points")
self.read_data_to_shader(shader_data, "color", "rgbas")
return shader_data
@staticmethod
def get_mobject_type_class():
return OpenGLPMobject

View file

@ -4,6 +4,7 @@ from collections.abc import Iterable
from pathlib import Path
from typing import TYPE_CHECKING
import moderngl
import numpy as np
from PIL import Image
@ -11,6 +12,7 @@ from manim.constants import *
from manim.mobject.opengl.opengl_mobject import OpenGLMobject
from manim.utils.bezier import integer_interpolate, interpolate
from manim.utils.color import *
from manim.utils.config_ops import _Data, _Uniforms
from manim.utils.images import change_to_rgba_array, get_full_raster_image_path
from manim.utils.iterables import listify
from manim.utils.space_ops import normalize_along_axis
@ -23,7 +25,6 @@ if TYPE_CHECKING:
__all__ = ["OpenGLSurface", "OpenGLTexturedSurface"]
# TODO: Those will not work in the current state we will have to think about a different method to render these with shaders in our current pipeline
class OpenGLSurface(OpenGLMobject):
r"""Creates a Surface.
@ -56,6 +57,14 @@ class OpenGLSurface(OpenGLMobject):
to 1 being fully opaque. Defaults to 1.
"""
shader_dtype = [
("point", np.float32, (3,)),
("du_point", np.float32, (3,)),
("dv_point", np.float32, (3,)),
("color", np.float32, (4,)),
]
shader_folder = "surface"
def __init__(
self,
uv_func=None,
@ -76,7 +85,9 @@ class OpenGLSurface(OpenGLMobject):
# For du and dv steps. Much smaller and numerical error
# can crop up in the shaders.
epsilon=1e-5,
render_primitive=moderngl.TRIANGLES,
depth_test=True,
shader_folder=None,
**kwargs: Any,
):
self.passed_uv_func = uv_func
@ -100,6 +111,8 @@ class OpenGLSurface(OpenGLMobject):
opacity=opacity,
gloss=gloss,
shadow=shadow,
shader_folder=shader_folder if shader_folder is not None else "surface",
render_primitive=render_primitive,
depth_test=depth_test,
**kwargs,
)
@ -239,6 +252,106 @@ class OpenGLSurface(OpenGLMobject):
tri_is[k::3] = tri_is[k::3][indices]
return self
# For shaders
def get_shader_data(self):
"""Called by parent Mobject to calculate and return
the shader data.
Returns
-------
shader_dtype
An array containing the shader data (vertices and
color of each vertex)
"""
s_points, du_points, dv_points = self.get_surface_points_and_nudged_points()
shader_data = np.zeros(len(s_points), dtype=self.shader_dtype)
if "points" not in self.locked_data_keys:
shader_data["point"] = s_points
shader_data["du_point"] = du_points
shader_data["dv_point"] = dv_points
if self.colorscale:
if not hasattr(self, "color_by_val"):
self.color_by_val = self._get_color_by_value(s_points)
shader_data["color"] = self.color_by_val
else:
self.fill_in_shader_color_info(shader_data)
return shader_data
def fill_in_shader_color_info(self, shader_data):
"""Fills in the shader color data when the surface
is all one color.
Parameters
----------
shader_data
The vertices of the surface.
Returns
-------
shader_dtype
An array containing the shader data (vertices and
color of each vertex)
"""
self.read_data_to_shader(shader_data, "color", "rgbas")
return shader_data
def _get_color_by_value(self, s_points):
"""Matches each vertex to a color associated to it's z-value.
Parameters
----------
s_points
The vertices of the surface.
Returns
-------
List
A list of colors matching the vertex inputs.
"""
if type(self.colorscale[0]) in (list, tuple):
new_colors, pivots = [
[i for i, j in self.colorscale],
[j for i, j in self.colorscale],
]
else:
new_colors = self.colorscale
pivot_min = self.axes.z_range[0]
pivot_max = self.axes.z_range[1]
pivot_frequency = (pivot_max - pivot_min) / (len(new_colors) - 1)
pivots = np.arange(
start=pivot_min,
stop=pivot_max + pivot_frequency,
step=pivot_frequency,
)
return_colors = []
for point in s_points:
axis_value = self.axes.point_to_coords(point)[self.colorscale_axis]
if axis_value <= pivots[0]:
return_colors.append(color_to_rgba(new_colors[0], self.opacity))
elif axis_value >= pivots[-1]:
return_colors.append(color_to_rgba(new_colors[-1], self.opacity))
else:
for i, pivot in enumerate(pivots):
if pivot > axis_value:
color_index = (axis_value - pivots[i - 1]) / (
pivots[i] - pivots[i - 1]
)
color_index = max(min(color_index, 1), 0)
temp_color = interpolate_color(
new_colors[i - 1],
new_colors[i],
color_index,
)
break
return_colors.append(color_to_rgba(temp_color, self.opacity))
return return_colors
def get_shader_vert_indices(self):
return self.get_triangle_indices()
class OpenGLSurfaceGroup(OpenGLSurface):
def __init__(self, *parametric_surfaces, resolution=None, **kwargs):
@ -251,14 +364,29 @@ class OpenGLSurfaceGroup(OpenGLSurface):
class OpenGLTexturedSurface(OpenGLSurface):
shader_dtype = [
("point", np.float32, (3,)),
("du_point", np.float32, (3,)),
("dv_point", np.float32, (3,)),
("im_coords", np.float32, (2,)),
("opacity", np.float32, (1,)),
]
shader_folder = "textured_surface"
im_coords = _Data()
opacity = _Data()
num_textures = _Uniforms()
def __init__(
self,
uv_surface: OpenGLSurface,
image_file: str | Path | npt.NDArray,
dark_image_file: str | Path = None,
image_mode: str | Iterable[str] = "RGBA",
shader_folder: str | Path = None,
**kwargs,
):
self.uniforms = {}
if not isinstance(uv_surface, OpenGLSurface):
raise Exception("uv_surface must be of type OpenGLSurface")
if isinstance(image_file, np.ndarray):
@ -268,8 +396,7 @@ class OpenGLTexturedSurface(OpenGLSurface):
if isinstance(image_mode, (str, Path)):
image_mode = [image_mode] * 2
image_mode_light, image_mode_dark = image_mode
# TODO: move to renderer
_texture_paths = {
texture_paths = {
"LightTexture": self.get_image_from_file(
image_file,
image_mode_light,
@ -288,7 +415,7 @@ class OpenGLTexturedSurface(OpenGLSurface):
self.v_range = uv_surface.v_range
self.resolution = uv_surface.resolution
self.gloss = self.uv_surface.gloss
super().__init__(**kwargs)
super().__init__(texture_paths=texture_paths, **kwargs)
def get_image_from_file(
self,

File diff suppressed because it is too large Load diff

View file

@ -12,8 +12,8 @@ import svgelements as se
from manim._config import config
from manim.mobject.geometry.arc import Arc
from manim.mobject.geometry.line import Line
from manim.mobject.opengl.opengl_mobject import OpenGLMobject as Mobject
from manim.mobject.opengl.opengl_vectorized_mobject import OpenGLVMobject as VMobject
from manim.mobject.mobject import Mobject
from manim.mobject.opengl.opengl_compatibility import ConvertToOpenGL
from manim.mobject.text.tex_mobject import MathTex, SingleStringMathTex, Tex
from manim.mobject.text.text_mobject import Text
@ -22,6 +22,7 @@ from ...animation.composition import AnimationGroup
from ...animation.fading import FadeIn
from ...animation.growing import GrowFromCenter
from ...constants import *
from ...mobject.types.vectorized_mobject import VMobject
from ...utils.color import BLACK
from ..svg.svg_mobject import VMobjectFromSVGPath
@ -203,7 +204,7 @@ class Brace(VMobjectFromSVGPath):
return vect / np.linalg.norm(vect)
class BraceLabel(VMobject):
class BraceLabel(VMobject, metaclass=ConvertToOpenGL):
"""Create a brace with a label attached.
Parameters

View file

@ -11,8 +11,7 @@ import numpy as np
import svgelements as se
from manim import config, logger
from manim.mobject.opengl.opengl_vectorized_mobject import OpenGLVGroup as VGroup
from manim.mobject.opengl.opengl_vectorized_mobject import OpenGLVMobject as VMobject
from manim.utils.color import ManimColor, ParsableManimColor
from ...constants import RIGHT
from ...utils.bezier import get_quadratic_approximation_of_cubic
@ -21,6 +20,8 @@ from ...utils.iterables import hash_obj
from ..geometry.arc import Circle
from ..geometry.line import Line
from ..geometry.polygram import Polygon, Rectangle, RoundedRectangle
from ..opengl.opengl_compatibility import ConvertToOpenGL
from ..types.vectorized_mobject import VGroup, VMobject
__all__ = ["SVGMobject", "VMobjectFromSVGPath"]
@ -32,7 +33,7 @@ def _convert_point_to_3d(x: float, y: float) -> np.ndarray:
return np.array([x, y, 0.0])
class SVGMobject(VMobject):
class SVGMobject(VMobject, metaclass=ConvertToOpenGL):
"""A vectorized mobject created from importing an SVG file.
Parameters
@ -99,21 +100,36 @@ class SVGMobject(VMobject):
should_center: bool = True,
height: float | None = 2,
width: float | None = None,
color: ParsableManimColor | None = None,
opacity: float | None = None,
fill_color: ParsableManimColor | None = None,
fill_opacity: float | None = None,
stroke_color: ParsableManimColor | None = None,
stroke_opacity: float | None = None,
stroke_width: float | None = None,
svg_default: dict | None = None,
path_string_config: dict | None = None,
use_svg_cache: bool = True,
**kwargs: Any,
):
super().__init__(**kwargs)
super().__init__(color=None, stroke_color=None, fill_color=None, **kwargs)
# process keyword arguments
self.file_name = Path(file_name) if file_name is not None else None
self.should_center = should_center
self.svg_height = height
self.svg_width = width
self.color = ManimColor(color)
self.opacity = opacity
self.fill_color = fill_color
self.fill_opacity = fill_opacity # type: ignore[assignment]
self.stroke_color = stroke_color
self.stroke_opacity = stroke_opacity # type: ignore[assignment]
self.stroke_width = stroke_width # type: ignore[assignment]
self.id_to_vgroup_dict: dict[str, VGroup] = {}
if self.stroke_width is None:
self.stroke_width = 0
if svg_default is None:
svg_default = {
@ -121,20 +137,24 @@ class SVGMobject(VMobject):
"opacity": None,
"fill_color": None,
"fill_opacity": None,
"stroke_width": [0],
"stroke_width": 0,
"stroke_color": None,
"stroke_opacity": None,
}
self.svg_default = svg_default
self.path_string_config = path_string_config or {}
if path_string_config is None:
path_string_config = {}
self.path_string_config = path_string_config
self.init_svg_mobject(use_svg_cache=use_svg_cache)
self.set_style(
fill_color=self.fill_color,
stroke_color=self.stroke_color,
stroke_width=self.stroke_width,
fill_color=fill_color,
fill_opacity=fill_opacity,
stroke_color=stroke_color,
stroke_opacity=stroke_opacity,
stroke_width=stroke_width,
)
self.move_into_position()
@ -477,7 +497,7 @@ class SVGMobject(VMobject):
self.set(width=self.svg_width)
class VMobjectFromSVGPath(VMobject):
class VMobjectFromSVGPath(VMobject, metaclass=ConvertToOpenGL):
"""A vectorized mobject representing an SVG path.
.. note::
@ -527,12 +547,13 @@ class VMobjectFromSVGPath(VMobject):
self.handle_commands()
if self.should_subdivide_sharp_curves:
# For a healthy triangulation later
self.subdivide_sharp_curves()
if self.should_remove_null_curves:
# Get rid of any null curves
self.set_points(self.get_points_without_null_curves())
if config.renderer == "opengl":
if self.should_subdivide_sharp_curves:
# For a healthy triangulation later
self.subdivide_sharp_curves()
if self.should_remove_null_curves:
# Get rid of any null curves
self.set_points(self.get_points_without_null_curves())
def init_points(self) -> None:
self.generate_points()

View file

@ -70,12 +70,6 @@ from collections.abc import Callable, Iterable, Sequence
from manim.mobject.geometry.line import Line
from manim.mobject.geometry.polygram import Polygon
from manim.mobject.geometry.shape_matchers import BackgroundRectangle
from manim.mobject.opengl.opengl_vectorized_mobject import (
OpenGLVGroup as VGroup,
)
from manim.mobject.opengl.opengl_vectorized_mobject import (
OpenGLVMobject as VMobject,
)
from manim.mobject.text.numbers import DecimalNumber, Integer
from manim.mobject.text.tex_mobject import MathTex
from manim.mobject.text.text_mobject import Paragraph
@ -84,7 +78,9 @@ from ..animation.animation import Animation
from ..animation.composition import AnimationGroup
from ..animation.creation import Create, Write
from ..animation.fading import FadeIn
from ..mobject.types.vectorized_mobject import VGroup, VMobject
from ..utils.color import BLACK, PURE_YELLOW, ManimColor, ParsableManimColor
from .utils import get_vectorized_mobject_class
class Table(VGroup):
@ -108,6 +104,8 @@ class Table(VGroup):
Horizontal buffer passed to :meth:`~.Mobject.arrange_in_grid`, by default 1.3.
include_outer_lines
``True`` if the table should include outer lines, by default False.
include_inner_lines
``True`` if the table should include inner lines, by default True.
add_background_rectangles_to_entries
``True`` if background rectangles should be added to entries, by default ``False``.
entries_background_color
@ -197,6 +195,7 @@ class Table(VGroup):
v_buff: float = 0.8,
h_buff: float = 1.3,
include_outer_lines: bool = False,
include_inner_lines: bool = True,
add_background_rectangles_to_entries: bool = False,
entries_background_color: ParsableManimColor = BLACK,
include_background_rectangle: bool = False,
@ -218,6 +217,7 @@ class Table(VGroup):
self.v_buff = v_buff
self.h_buff = h_buff
self.include_outer_lines = include_outer_lines
self.include_inner_lines = include_inner_lines
self.add_background_rectangles_to_entries = add_background_rectangles_to_entries
self.entries_background_color = ManimColor(entries_background_color)
self.include_background_rectangle = include_background_rectangle
@ -327,7 +327,8 @@ class Table(VGroup):
mob_table.insert(0, col_labels)
else:
# Placeholder to use arrange_in_grid if top_left_entry is not set.
dummy_mobject = VMobject()
# Import OpenGLVMobject to work with --renderer=opengl
dummy_mobject = get_vectorized_mobject_class()()
col_labels = [dummy_mobject] + self.col_labels
mob_table.insert(0, col_labels)
else:
@ -352,15 +353,19 @@ class Table(VGroup):
)
line_group.add(line)
self.add(line)
for k in range(len(self.mob_table) - 1):
anchor = self.get_rows()[k + 1].get_top()[1] + 0.5 * (
self.get_rows()[k].get_bottom()[1] - self.get_rows()[k + 1].get_top()[1]
)
line = Line(
[anchor_left, anchor, 0], [anchor_right, anchor, 0], **self.line_config
)
line_group.add(line)
self.add(line)
if self.include_inner_lines:
for k in range(len(self.mob_table) - 1):
anchor = self.get_rows()[k + 1].get_top()[1] + 0.5 * (
self.get_rows()[k].get_bottom()[1]
- self.get_rows()[k + 1].get_top()[1]
)
line = Line(
[anchor_left, anchor, 0],
[anchor_right, anchor, 0],
**self.line_config,
)
line_group.add(line)
self.add(line)
self.horizontal_lines = line_group
return self
@ -382,16 +387,19 @@ class Table(VGroup):
)
line_group.add(line)
self.add(line)
for k in range(len(self.mob_table[0]) - 1):
anchor = self.get_columns()[k + 1].get_left()[0] + 0.5 * (
self.get_columns()[k].get_right()[0]
- self.get_columns()[k + 1].get_left()[0]
)
line = Line(
[anchor, anchor_bottom, 0], [anchor, anchor_top, 0], **self.line_config
)
line_group.add(line)
self.add(line)
if self.include_inner_lines:
for k in range(len(self.mob_table[0]) - 1):
anchor = self.get_columns()[k + 1].get_left()[0] + 0.5 * (
self.get_columns()[k].get_right()[0]
- self.get_columns()[k + 1].get_left()[0]
)
line = Line(
[anchor, anchor_bottom, 0],
[anchor, anchor_top, 0],
**self.line_config,
)
line_group.add(line)
self.add(line)
self.vertical_lines = line_group
return self

View file

@ -1,4 +1,4 @@
"""Mobjects used to display Text using Pango or LaTeX.
"""Mobjects used to display Text using Pango, LaTeX, or Typst.
Modules
=======
@ -10,4 +10,5 @@ Modules
~numbers
~tex_mobject
~text_mobject
~typst_mobject
"""

View file

@ -19,17 +19,13 @@ from pygments.styles import get_all_styles
from manim.constants import *
from manim.mobject.geometry.arc import Dot
from manim.mobject.geometry.shape_matchers import SurroundingRectangle
from manim.mobject.opengl.opengl_vectorized_mobject import (
OpenGLVGroup as VGroup,
)
from manim.mobject.opengl.opengl_vectorized_mobject import (
OpenGLVMobject as VMobject,
)
from manim.mobject.opengl.opengl_compatibility import ConvertToOpenGL
from manim.mobject.types.vectorized_mobject import VGroup, VMobject
from manim.typing import StrPath
from manim.utils.color import WHITE, ManimColor
class Code(VMobject):
class Code(VMobject, metaclass=ConvertToOpenGL):
"""A highlighted source code listing.
Examples
@ -143,7 +139,10 @@ class Code(VMobject):
if code_file is not None:
code_file = Path(code_file)
code_string = code_file.read_text(encoding="utf-8")
lexer = guess_lexer_for_filename(code_file.name, code_string)
if language is not None:
lexer = get_lexer_by_name(language)
else:
lexer = guess_lexer_for_filename(code_file.name, code_string)
elif code_string is not None:
if language is not None:
lexer = get_lexer_by_name(language)

View file

@ -10,16 +10,17 @@ import numpy as np
from manim import config
from manim.constants import *
from manim.mobject.opengl.opengl_vectorized_mobject import OpenGLVMobject as VMobject
from manim.mobject.opengl.opengl_compatibility import ConvertToOpenGL
from manim.mobject.text.tex_mobject import MathTex, SingleStringMathTex, Tex
from manim.mobject.text.text_mobject import Text
from manim.mobject.types.vectorized_mobject import VMobject
from manim.mobject.value_tracker import ValueTracker
from manim.typing import Vector3DLike
string_to_mob_map: dict[str, SingleStringMathTex] = {}
class DecimalNumber(VMobject):
class DecimalNumber(VMobject, metaclass=ConvertToOpenGL):
r"""An mobject representing a decimal number.
Parameters
@ -153,8 +154,6 @@ class DecimalNumber(VMobject):
def _set_submobjects_from_number(self, number: float) -> None:
self.number = number
# the self.add below will recalculate the family,
# no need to do it here.
self.submobjects = []
num_string = self._get_num_string(number)
@ -342,7 +341,7 @@ class Integer(DecimalNumber):
return int(np.round(super().get_value()))
class Variable(VMobject):
class Variable(VMobject, metaclass=ConvertToOpenGL):
"""A class for displaying text that shows "label = value" with
the value continuously updated from a :class:`~.ValueTracker`.

View file

@ -33,12 +33,13 @@ from typing import Any, Self
from manim import config, logger
from manim.constants import *
from manim.mobject.geometry.line import Line
from manim.mobject.opengl.opengl_vectorized_mobject import OpenGLVGroup as VGroup
from manim.mobject.opengl.opengl_vectorized_mobject import OpenGLVMobject as VMobject
from manim.mobject.svg.svg_mobject import SVGMobject
from manim.mobject.types.vectorized_mobject import VGroup, VMobject
from manim.utils.tex import TexTemplate
from manim.utils.tex_file_writing import tex_to_svg_file
from ..opengl.opengl_compatibility import ConvertToOpenGL
MATHTEX_SUBSTRING = "substring"
@ -66,12 +67,15 @@ class SingleStringMathTex(SVGMobject):
color: ParsableManimColor | None = None,
**kwargs: Any,
):
if color is None:
color = VMobject().color
self._font_size = font_size
self.organize_left_to_right = organize_left_to_right
self.tex_environment = tex_environment
if tex_template is None:
tex_template = config.tex_template
self.tex_template = tex_template
tex_template = config["tex_template"]
self.tex_template: TexTemplate = tex_template
self.tex_string = tex_string
file_name = tex_to_svg_file(
@ -308,7 +312,7 @@ class MathTex(SingleStringMathTex):
# Save the original tex_string
self.tex_string = self.arg_separator.join(self.tex_strings)
self._break_up_by_substrings()
except ValueError:
except ValueError as compilation_error:
if self.brace_notation_split_occurred:
logger.error(
dedent(
@ -322,7 +326,7 @@ class MathTex(SingleStringMathTex):
""",
),
)
raise
raise compilation_error
self.set_color_by_tex_to_color_map(self.tex_to_color_map)
if self.organize_left_to_right:
@ -530,12 +534,6 @@ class MathTex(SingleStringMathTex):
)
new_submobjects.append(self.id_to_vgroup_dict["root"])
self.submobjects = new_submobjects
# 5 hours of work went into this line
# and it's still not perfect
# July 18, 2024
self.note_changed_family()
return self
def get_part_by_tex(self, tex: str, **kwargs: Any) -> VGroup | None:
@ -589,7 +587,7 @@ class MathTex(SingleStringMathTex):
self.id_to_vgroup_dict[match[1]].set_color(color)
return self
def index_of_part(self, part: MathTex) -> int:
def index_of_part(self, part: VMobject) -> int:
split_self = self.split()
if part not in split_self:
raise ValueError("Trying to get index of part not in MathTex")
@ -597,10 +595,9 @@ class MathTex(SingleStringMathTex):
def sort_alphabetically(self) -> None:
self.submobjects.sort(key=lambda m: m.get_tex_string())
self.note_changed_family()
class MathTexPart(VMobject):
class MathTexPart(VMobject, metaclass=ConvertToOpenGL):
tex_string: str
def __repr__(self) -> str:

View file

@ -70,16 +70,14 @@ from manimpango import MarkupUtils, PangoUtils, TextSetting
from manim import config, logger
from manim.constants import *
from manim.mobject.geometry.arc import Dot
from manim.mobject.opengl.opengl_vectorized_mobject import (
OpenGLVGroup as VGroup,
)
from manim.mobject.opengl.opengl_vectorized_mobject import (
OpenGLVMobject as VMobject,
)
from manim.mobject.svg.svg_mobject import SVGMobject
from manim.mobject.types.vectorized_mobject import VGroup, VMobject
from manim.typing import Point3D
from manim.utils.color import ManimColor, ParsableManimColor, color_gradient
if TYPE_CHECKING:
from typing import Self
from manim.typing import Point3D
TEXT_MOB_SCALE_FACTOR = 0.05
@ -168,9 +166,12 @@ class Paragraph(VGroup):
lines_str_list = lines_str.split("\n")
self.chars = self._gen_chars(lines_str_list)
self.lines = [list(self.chars), [self.alignment] * len(self.chars)]
self.lines_initial_positions = [line.get_center() for line in self.lines[0]]
self.add(*self.lines[0])
# TODO: If possible get rid of self.lines_chars, as it seems to be a
# listified duplicate of self.chars.
self.lines_chars = list(self.chars)
self.lines_alignments = [self.alignment] * len(self.chars)
self.lines_initial_positions = [line.get_center() for line in self.lines_chars]
self.add(*self.lines_chars)
self.move_to(np.array([0, 0, 0]))
if self.alignment:
self._set_all_lines_alignments(self.alignment)
@ -223,7 +224,7 @@ class Paragraph(VGroup):
alignment
Defines the alignment of paragraph. Possible values are "left", "right", "center".
"""
for line_no in range(len(self.lines[0])):
for line_no in range(len(self.lines_chars)):
self._change_alignment_for_a_line(alignment, line_no)
return self
@ -242,8 +243,8 @@ class Paragraph(VGroup):
def _set_all_lines_to_initial_positions(self) -> Paragraph:
"""Set all lines to their initial positions."""
self.lines[1] = [None] * len(self.lines[0])
for line_no in range(len(self.lines[0])):
self.lines_alignments = [None] * len(self.lines_chars)
for line_no in range(len(self.lines_chars)):
self[line_no].move_to(
self.get_center() + self.lines_initial_positions[line_no],
)
@ -257,7 +258,7 @@ class Paragraph(VGroup):
line_no
Defines the line number for which we want to set given alignment.
"""
self.lines[1][line_no] = None
self.lines_alignments[line_no] = None
self[line_no].move_to(self.get_center() + self.lines_initial_positions[line_no])
return self
@ -271,12 +272,12 @@ class Paragraph(VGroup):
line_no
Defines the line number for which we want to set given alignment.
"""
self.lines[1][line_no] = alignment
if self.lines[1][line_no] == "center":
self.lines_alignments[line_no] = alignment
if self.lines_alignments[line_no] == "center":
self[line_no].move_to(
np.array([self.get_center()[0], self[line_no].get_center()[1], 0]),
)
elif self.lines[1][line_no] == "right":
elif self.lines_alignments[line_no] == "right":
self[line_no].move_to(
np.array(
[
@ -286,7 +287,7 @@ class Paragraph(VGroup):
],
),
)
elif self.lines[1][line_no] == "left":
elif self.lines_alignments[line_no] == "left":
self[line_no].move_to(
np.array(
[
@ -512,7 +513,7 @@ class Text(SVGMobject):
else:
self.line_spacing = self._font_size + self._font_size * self.line_spacing
parsed_color = ManimColor(color)
parsed_color: ManimColor = ManimColor(color) if color else VMobject().color
file_name = self._text2svg(parsed_color.to_hex())
PangoUtils.remove_last_M(file_name)
super().__init__(
@ -528,7 +529,6 @@ class Text(SVGMobject):
self.text = text
if self.disable_ligatures:
self.submobjects = [*self._gen_chars()]
self.note_changed_family()
self.chars = self.get_group_class()(*self.submobjects)
self.text = text_without_tabs.replace(" ", "").replace("\n", "")
nppc = self.n_points_per_curve
@ -591,11 +591,6 @@ class Text(SVGMobject):
# anti-aliasing
if height is None and width is None:
self.scale(TEXT_MOB_SCALE_FACTOR)
# Just a temporary hack to get better triangulation
# See pr #1552 for details
for i in self.submobjects:
i.insert_n_curves(len(i.get_all_points()))
self.initial_height = self.height
def __repr__(self) -> str:
@ -835,6 +830,13 @@ class Text(SVGMobject):
return svg_file
def init_colors(self, propagate_colors: bool = True) -> Self:
if config.renderer == RendererType.OPENGL:
super().init_colors()
elif config.renderer == RendererType.CAIRO:
super().init_colors(propagate_colors=propagate_colors)
return self
class MarkupText(SVGMobject):
r"""Display (non-LaTeX) text rendered using `Pango <https://pango.org/>`_.
@ -1209,7 +1211,7 @@ class MarkupText(SVGMobject):
else:
self.line_spacing = self._font_size + self._font_size * self.line_spacing
parsed_color = ManimColor(color)
parsed_color: ManimColor = ManimColor(color) if color else VMobject().color
file_name = self._text2svg(parsed_color)
PangoUtils.remove_last_M(file_name)

View file

@ -0,0 +1,818 @@
"""Mobjects representing text rendered using Typst.
.. _typst-mobjects:
.. important::
The ``typst`` Python package must be installed to use these classes.
Install it via ``pip install typst>=0.14`` or add the ``typst`` optional
dependency group (``pip install manim[typst]``).
Typst mobjects compile Typst markup directly to SVG using the ``typst``
Python package and then import the result through :class:`~.SVGMobject`.
Use :class:`~.Typst` for general Typst markup and :class:`~.TypstMath`
for display-style math.
Examples
--------
Basic text and math
^^^^^^^^^^^^^^^^^^^
.. manim:: TypstTextReferenceExample
:save_last_frame:
:ref_classes: Typst
class TypstTextReferenceExample(Scene):
def construct(self):
text = Typst(
r"*Hello* from _Typst!_",
color=YELLOW,
font_size=72,
)
self.add(text)
.. manim:: TypstMathReferenceExample
:save_last_frame:
:ref_classes: TypstMath
class TypstMathReferenceExample(Scene):
def construct(self):
equation = TypstMath(
r"sum_(k=1)^n k = frac(n(n + 1), 2)",
font_size=72,
)
self.add(equation)
Selecting subexpressions
^^^^^^^^^^^^^^^^^^^^^^^^
Typst mobjects expose label-based selection via :meth:`~.Typst.select`.
There are two common ways to create selectable groups:
- use ordinary Typst labels in :class:`~.Typst`
- use Manim's ``{{ ... }}`` shorthand in :class:`~.TypstMath`
.. note::
The ``{{ ... }}`` shorthand is currently only supported by
:class:`~.TypstMath`. For :class:`~.Typst`, create labels directly in the
Typst source, for example with ``#box[body] <label>``.
.. manim:: TypstLabelSelectionExample
:save_last_frame:
:ref_classes: Typst
:ref_methods: Typst.select
class TypstLabelSelectionExample(Scene):
def construct(self):
text = Typst(
r'''
#box[
*Typst* labels also work in regular markup.
] <headline>
#let pick(body) = [#box(body) <picked>]
We can highlight #pick[multiple] #pick[fragments] at once.
''',
font_size=42,
)
text.select("headline").set_color(BLUE)
text.select("picked").set_color(YELLOW)
self.add(text)
.. manim:: TypstMathSelectionExample
:save_last_frame:
:ref_classes: TypstMath
:ref_methods: Typst.select
class TypstMathSelectionExample(Scene):
def construct(self):
equation = TypstMath(
"{{ a^2 + b^2 : lhs }} = {{ c^2 }}",
font_size=72,
)
equation.select("lhs").set_color(BLUE)
equation.select(0).set_color(YELLOW)
self.add(equation)
Inspecting baseline frames
^^^^^^^^^^^^^^^^^^^^^^^^^^
For debugging or alignment tasks, Typst mobjects can optionally track a
per-element baseline frame. Enable this with ``track_baselines=True`` and
query either :attr:`~.Typst.baseline_frames` for all tracked leaf elements or
:meth:`~.Typst.get_baseline_frame` for a specific selected submobject.
.. code-block:: python
text = Typst("Ggf", track_baselines=True)
orig, right, up = text.baseline_frames[0]
eq = TypstMath("{{ a^2 + b^2 : lhs }} = c^2", track_baselines=True)
for part in eq.select("lhs"):
orig, right, up = eq.get_baseline_frame(part)
print(orig, right, up)
"""
from __future__ import annotations
__all__ = [
"Typst",
"TypstMath",
]
import re
from pathlib import Path
from typing import Any, cast
from xml.etree import ElementTree as ET
import numpy as np
import svgelements as se
from manim import config
from manim.constants import DEFAULT_FONT_SIZE, SCALE_FACTOR_PER_FONT_POINT, RendererType
from manim.mobject.svg.svg_mobject import SVGMobject
from manim.mobject.types.vectorized_mobject import VGroup, VMobject
from manim.utils.color import BLACK, ParsableManimColor
from manim.utils.typst_file_writing import typst_to_svg_file
_MANIMGRP_PREAMBLE = "#let manimgrp(lbl, body) = [#box(body) #label(lbl)]"
# Pattern for the label part of {{ content : label }}.
# The label must be a valid Typst label identifier.
_LABEL_RE = re.compile(r"^(.*)\s*:\s*([a-zA-Z_][a-zA-Z0-9_-]*)\s*$", re.DOTALL)
_INTERNAL_TYPST_ID_RE = re.compile(r"g[0-9A-Fa-f]+")
_DUPLICATE_LABEL_SUFFIX = "__manim_typst_dup_"
# Empirical correction so Typst-authored SVG strokes (fraction bars,
# underlines, etc.) visually match the weight of TeX-derived geometry more
# closely after import into Manim's pixel-based stroke model.
_TYPST_SVG_STROKE_WIDTH_SCALE = 0.5
class Typst(SVGMobject):
"""A mobject rendered from a Typst markup string.
The Typst source is compiled to SVG via the ``typst`` Python package
(a self-contained Rust binary extension no system-level install
required) and then imported through :class:`~.SVGMobject`.
Parameters
----------
typst_code
Raw Typst markup to be compiled. This string is placed verbatim
into the body of a minimal Typst document.
font_size
Font size in Manim font-size units (default: ``DEFAULT_FONT_SIZE``,
i.e. 48). The actual scaling is applied *after* SVG import, matching
the approach used by :class:`~.SingleStringMathTex`.
typst_preamble
Extra Typst code inserted before the body. Useful for ``#import``,
``#set``, or ``#show`` rules. Default: ``""``.
color
The color of the mobject. By default the standard VMobject color
(white in dark mode). Overrides the Typst text fill color.
stroke_width
SVG stroke width override. If ``None`` (default), the stroke widths
from Typst's SVG output are preserved.
font_paths
Optional list of additional font directories passed to the Typst
compiler (e.g. for custom fonts not installed system-wide).
track_baselines
Whether to keep enough per-element reference data to recover the
current Typst baseline frame for each imported submobject.
When enabled, :attr:`baseline_frames` and
:meth:`get_baseline_frame` can be used to retrieve the current
``(orig, right, up)`` positions for the imported SVG elements.
should_center
Whether to center the mobject after import (default ``True``).
height
Target height of the mobject. If ``None`` (default), the height is
determined by ``font_size``.
**kwargs
Forwarded to :class:`~.SVGMobject`.
Examples
--------
.. manim:: TypstExample
:ref_classes: Typst
class TypstExample(Scene):
def construct(self):
formula = Typst(r"$ integral_a^b f(x) dif x $")
self.play(Write(formula))
.. manim:: TypstTextExample
:save_last_frame:
:ref_classes: Typst
class TypstTextExample(Scene):
def construct(self):
text = Typst(
r"*Hello* from _Typst!_",
font_size=72,
)
self.add(text)
"""
def __init__(
self,
typst_code: str,
*,
font_size: float = DEFAULT_FONT_SIZE,
typst_preamble: str = "",
color: ParsableManimColor | None = None,
stroke_width: float | None = None,
font_paths: list[str | Path] | None = None,
track_baselines: bool = False,
should_center: bool = True,
height: float | None = None,
**kwargs: Any,
):
if color is None:
color = VMobject().color
self._font_size = font_size
self.typst_code = typst_code
self.typst_preamble = typst_preamble
self.track_baselines = track_baselines
self._preserve_svg_stroke_widths = stroke_width is None
self._baseline_tracked_submobjects: list[VMobject] = []
self._stroke_width_tracked_submobjects: list[VMobject] = []
self._label_aliases: dict[str, list[str]] = {}
file_name = typst_to_svg_file(
typst_code,
preamble=typst_preamble,
font_paths=font_paths,
)
super().__init__(
file_name=file_name,
should_center=should_center,
stroke_width=stroke_width,
height=height,
color=color,
path_string_config={
"should_subdivide_sharp_curves": True,
"should_remove_null_curves": True,
},
**kwargs,
)
self._rebuild_label_aliases()
self._refresh_svg_stroke_widths()
self.init_colors()
# Used for scaling via font_size property (mirrors SingleStringMathTex).
self.initial_height = self.height
if height is None:
self.font_size = self._font_size
def __repr__(self) -> str:
return f"{type(self).__name__}({self.typst_code!r})"
@property
def hash_seed(self) -> tuple:
"""Include baseline tracking in the SVG cache key."""
return (*super().hash_seed, self.track_baselines)
# -- font_size property (same approach as SingleStringMathTex) -----------
@property
def font_size(self) -> float:
"""The font size of the Typst mobject."""
return self.height / self.initial_height / SCALE_FACTOR_PER_FONT_POINT
@font_size.setter
def font_size(self, val: float) -> None:
if val <= 0:
raise ValueError("font_size must be greater than 0.")
if self.height > 0:
self.scale(val / self.font_size)
def scale(
self,
scale_factor: float,
scale_stroke: bool = False,
*,
about_point: np.ndarray | None = None,
about_edge: np.ndarray | None = None,
) -> Typst:
result = super().scale(
scale_factor,
scale_stroke=scale_stroke,
about_point=about_point,
about_edge=about_edge,
)
self._refresh_svg_stroke_widths()
return result
def _refresh_svg_stroke_widths(self) -> None:
"""Refresh pixel stroke widths for Typst-authored SVG strokes.
SVG stroke widths are specified in the SVG's local coordinate system,
while Manim stroke widths are pixel-based. For Typst-authored strokes
such as fraction bars or underlines, rescale them according to the
current geometric scale of the imported element so their visual weight
stays proportional to the rest of the expression.
"""
if not self._preserve_svg_stroke_widths:
return
pixels_per_unit = config.pixel_width / config.frame_width
for submobject in self._stroke_width_tracked_submobjects:
submobject_any = cast(Any, submobject)
reference_size = cast(float, submobject_any._typst_reference_size)
source_stroke_width = cast(
float,
submobject_any._typst_source_stroke_width,
)
current_size = max(submobject.width, submobject.height)
if reference_size <= 0:
continue
current_stroke_width = source_stroke_width * current_size / reference_size
submobject.set_stroke(
width=current_stroke_width
* pixels_per_unit
* _TYPST_SVG_STROKE_WIDTH_SCALE,
family=False,
)
# -- baseline frame tracking ---------------------------------------------
def get_mob_from_shape_element(self, shape: se.SVGElement) -> VMobject | None:
"""Attach Typst-specific metadata to imported shape mobjects."""
mob = super().get_mob_from_shape_element(shape)
if mob is None or not mob.has_points():
return mob
if self._preserve_svg_stroke_widths and shape.stroke_width not in (None, 0):
reference_size = max(mob.width, mob.height)
if reference_size > 0:
mob_any = cast(Any, mob)
mob_any._typst_reference_size = reference_size
mob_any._typst_source_stroke_width = shape.stroke_width
self._stroke_width_tracked_submobjects.append(mob)
if not self.track_baselines:
return mob
baseline_marks = self._get_reference_baseline_frame(shape)
if baseline_marks is None:
return mob
reference_points = mob.points.copy()
reference_xy = np.column_stack(
[
reference_points[:, 0],
reference_points[:, 1],
np.ones(len(reference_points)),
],
)
if np.linalg.matrix_rank(reference_xy) < 3:
return mob
mob_any = cast(Any, mob)
mob_any._typst_reference_points = reference_points
mob_any._typst_reference_baseline_frame = baseline_marks
self._baseline_tracked_submobjects.append(mob)
return mob
@staticmethod
def _get_reference_baseline_frame(
shape: se.SVGElement,
) -> np.ndarray | None:
"""Return the reference ``(orig, right, up)`` frame for a Typst SVG element.
The frame is expressed in the same pre-centering coordinate system as the
imported submobject points after the element's own SVG transform has been
applied.
"""
if not isinstance(shape, se.Transformable):
return None
matrix = shape.transform if shape.apply else se.Matrix()
return np.array(
[
[matrix.e, matrix.f, 0.0],
[matrix.a + matrix.e, matrix.b + matrix.f, 0.0],
[matrix.c + matrix.e, matrix.d + matrix.f, 0.0],
],
)
def get_baseline_frame(
self, submobject: VMobject
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Return the current Typst baseline frame for a tracked submobject.
The returned tuple contains the current positions of ``(orig, right, up)``.
These are recovered from the stored reference frame and the submobject's
current affine position in the scene.
"""
try:
submobject_any = cast(Any, submobject)
reference_points = cast(
np.ndarray,
submobject_any._typst_reference_points,
)
reference_frame = cast(
np.ndarray,
submobject_any._typst_reference_baseline_frame,
)
except AttributeError as err:
raise ValueError(
"No tracked Typst baseline frame is available for this submobject. "
"Construct the Typst mobject with track_baselines=True.",
) from err
reference_xy = np.column_stack(
[
reference_points[:, 0],
reference_points[:, 1],
np.ones(len(reference_points)),
],
)
if np.linalg.matrix_rank(reference_xy) < 3:
raise ValueError(
"The stored Typst reference geometry is degenerate, so its baseline "
"frame cannot be recovered.",
)
transform, _, _, _ = np.linalg.lstsq(
reference_xy, submobject.points, rcond=None
)
frame_xy = np.column_stack(
[
reference_frame[:, 0],
reference_frame[:, 1],
np.ones(len(reference_frame)),
],
)
current_frame = frame_xy @ transform
return tuple(
cast(tuple[np.ndarray, np.ndarray, np.ndarray], tuple(current_frame))
)
@property
def baseline_frames(self) -> list[tuple[np.ndarray, np.ndarray, np.ndarray]]:
"""Current Typst baseline frames for all tracked leaf submobjects."""
if not self.track_baselines:
return []
return [
self.get_baseline_frame(submobject)
for submobject in self._baseline_tracked_submobjects
]
def _rebuild_label_aliases(self) -> None:
"""Rebuild user-facing label aliases from imported SVG ids."""
aliases: dict[str, list[str]] = {}
for key in self.id_to_vgroup_dict:
if key == "root" or key.startswith("numbered_group_"):
continue
if _INTERNAL_TYPST_ID_RE.fullmatch(key) is not None:
continue
base_label = key
if _DUPLICATE_LABEL_SUFFIX in key:
base_label, _, _ = key.partition(_DUPLICATE_LABEL_SUFFIX)
aliases.setdefault(base_label, []).append(key)
self._label_aliases = aliases
def _select_label(self, label: str) -> VGroup:
if label not in self._label_aliases:
raise KeyError(
f"No group with label {label!r} found. "
f"Available labels: {self._user_label_keys()}"
)
result = VGroup()
seen_ids: set[int] = set()
for group_id in self._label_aliases[label]:
for submobject in self.id_to_vgroup_dict[group_id]:
submobject_id = id(submobject)
if submobject_id in seen_ids:
continue
seen_ids.add(submobject_id)
result.add(submobject)
return result
# -- SVG post-processing -------------------------------------------------
def modify_xml_tree(self, element_tree: ET.ElementTree) -> ET.ElementTree:
"""Convert ``data-typst-label`` attributes to ``id`` before parsing.
Typst's SVG renderer emits ``data-typst-label`` on ``<g>`` elements
that carry a label (created via ``#box(body) <label>``). The
``svgelements`` library propagates custom ``data-*`` attributes from
parent groups to all children, making them unusable as unique group
keys. ``id`` attributes, on the other hand, are *not* inherited.
This method walks the XML tree and promotes every
``data-typst-label`` to ``id`` (on ``<g>`` elements only), so that
:meth:`~.SVGMobject.get_mobjects_from` can pick them up via its
existing ``id``-based grouping logic.
"""
# Let the base class inject default style wrappers first.
element_tree = super().modify_xml_tree(element_tree)
# Walk all elements regardless of namespace — ElementTree
# qualifies tags with the namespace URI, so a bare ``"g"``
# won't match ``{http://www.w3.org/2000/svg}g``.
label_counts: dict[str, int] = {}
for element in element_tree.iter():
label = element.get("data-typst-label")
if label is not None:
count = label_counts.get(label, 0)
label_counts[label] = count + 1
svg_id = label
if count > 0:
svg_id = f"{label}{_DUPLICATE_LABEL_SUFFIX}{count}"
element.set("id", svg_id)
del element.attrib["data-typst-label"]
return element_tree
# -- sub-expression selection --------------------------------------------
def select(self, key: str | int) -> VGroup:
"""Select a labeled sub-expression.
Labels are created in the Typst source either manually via the
``manimgrp`` helper or automatically through the ``{{ }}``
double-brace notation in :class:`TypstMath`.
Parameters
----------
key
A label name (``str``) matching a ``data-typst-label`` in the
SVG, or an integer index into the auto-numbered ``{{ }}``
groups (``_grp-0``, ``_grp-1``, ).
Returns
-------
VGroup
The submobjects corresponding to the selected group.
Raises
------
KeyError
If no group with the given label exists.
IndexError
If an integer index is out of range.
Examples
--------
.. manim:: TypstSelectExample
:save_last_frame:
:ref_classes: TypstMath
:ref_methods: Typst.select
class TypstSelectExample(Scene):
def construct(self):
eq = TypstMath(
"{{ a + b : num }} / {{ c : den }} = {{ lambda }} {{ x }}"
)
eq.select("num").set_color(RED) # "a + b"
eq.select("den").set_color(BLUE) # "c"
eq.select(0).set_color(YELLOW) # "lambda" (auto-numbered: "grp-0")
eq.select(1).set_color(GREEN) # "x" (auto-numbered: "grp-1")
self.add(eq)
"""
if isinstance(key, int):
label = f"_grp-{key}"
if label not in self._label_aliases:
raise IndexError(
f"Group index {key} out of range. "
f"Available labels: {self._user_label_keys()}"
)
return self._select_label(label)
return self._select_label(key)
def _user_label_keys(self) -> list[str]:
"""Return the label keys that were created from ``data-typst-label``
attributes (filtering out internal Typst group IDs and auto-numbered
groups).
"""
return list(self._label_aliases)
# -- color handling ------------------------------------------------------
def init_colors(self, propagate_colors: bool = True) -> Typst:
"""Recolor black submobjects to ``self.color``.
Typst renders text in black (``fill="#000000"``) by default.
This mirrors the approach of :meth:`SingleStringMathTex.init_colors`:
any submobject whose color is black is recolored to ``self.color``,
while explicitly colored submobjects (non-black) are preserved.
"""
for submobject in self.submobjects:
if submobject.color != BLACK:
continue
submobject.color = self.color
if config.renderer == RendererType.OPENGL:
submobject.init_colors()
elif config.renderer == RendererType.CAIRO:
submobject.init_colors(propagate_colors=propagate_colors)
return self
class TypstMath(Typst):
r"""Convenience wrapper: wraps the input in Typst math delimiters.
The expression is rendered as a display-level equation
(``$ ... $`` with surrounding spaces).
Supports the ``{{ ... }}`` double-brace notation for grouping
sub-expressions. Each ``{{ content }}`` is wrapped in a labeled
``manimgrp`` call so that the resulting SVG contains identifiable
groups accessible via :meth:`~.Typst.select`.
Groups can optionally be given explicit labels:
``{{ content : label }}``. Without a label, groups are
auto-numbered (``_grp-0``, ``_grp-1``, ).
Parameters
----------
math_expression
Typst math-mode content **without** the ``$ ... $`` delimiters.
May contain ``{{ ... }}`` groups.
**kwargs
Forwarded to :class:`Typst`.
Examples
--------
.. manim:: DisplayMath
:save_last_frame:
:ref_classes: TypstMath
class DisplayMath(Scene):
def construct(self):
eq = TypstMath(r"sum_(k=0)^n k = (n(n+1)) / 2")
self.add(eq)
.. manim:: GroupedMath
:save_last_frame:
:ref_classes: TypstMath
:ref_methods: Typst.select
class GroupedMath(Scene):
def construct(self):
eq = TypstMath("{{ a^2 + b^2 : lhs }} = {{ c^2 }}")
eq.select("lhs").set_color(RED) # "a^2 + b^2"
eq.select(0).set_color(BLUE) # "c^2" (auto-numbered: "grp-0")
self.add(eq)
"""
def __init__(self, math_expression: str, **kwargs: Any):
processed, labels = self._preprocess_groups(math_expression)
self._group_labels = labels
# Inject the manimgrp helper when groups are present.
if labels:
preamble = kwargs.get("typst_preamble", "")
if _MANIMGRP_PREAMBLE not in preamble:
preamble = (
f"{_MANIMGRP_PREAMBLE}\n{preamble}"
if preamble
else _MANIMGRP_PREAMBLE
)
kwargs["typst_preamble"] = preamble
super().__init__(f"$ {processed} $", **kwargs)
# -- double-brace preprocessor -------------------------------------------
@staticmethod
def _preprocess_groups(math_expr: str) -> tuple[str, list[str]]:
"""Replace ``{{ ... }}`` groups with ``manimgrp(...)`` calls.
Parameters
----------
math_expr
The raw math expression (without ``$ ... $`` delimiters).
Returns
-------
tuple[str, list[str]]
The processed expression and an ordered list of group labels.
"""
result: list[str] = []
labels: list[str] = []
auto_index = 0
i = 0
n = len(math_expr)
outer_in_string = False
outer_bracket_depth = 0
while i < n:
ch = math_expr[i]
# Track string literals at the outer level.
if outer_in_string:
result.append(ch)
if ch == "\\" and i + 1 < n:
result.append(math_expr[i + 1])
i += 2
continue
if ch == '"':
outer_in_string = False
i += 1
continue
if ch == '"':
outer_in_string = True
result.append(ch)
i += 1
continue
# Track [...] content blocks at the outer level.
if ch == "[":
outer_bracket_depth += 1
result.append(ch)
i += 1
continue
if ch == "]" and outer_bracket_depth > 0:
outer_bracket_depth -= 1
result.append(ch)
i += 1
continue
if outer_bracket_depth > 0:
result.append(ch)
i += 1
continue
# Look for opening {{ (not a single {)
if i + 1 < n and ch == "{" and math_expr[i + 1] == "{":
i += 2 # skip {{
content_start = i
depth = 1
in_string = False
bracket_depth = 0
while i < n and depth > 0:
ch = math_expr[i]
if in_string:
if ch == "\\" and i + 1 < n:
i += 2
continue
if ch == '"':
in_string = False
i += 1
continue
if ch == '"':
in_string = True
i += 1
continue
if ch == "[":
bracket_depth += 1
i += 1
continue
if ch == "]" and bracket_depth > 0:
bracket_depth -= 1
i += 1
continue
if bracket_depth > 0:
i += 1
continue
if ch == "{" and i + 1 < n and math_expr[i + 1] == "{":
depth += 1
i += 2
continue
if ch == "}" and i + 1 < n and math_expr[i + 1] == "}":
depth -= 1
if depth == 0:
content = math_expr[content_start:i]
i += 2 # skip }}
break
i += 2
continue
i += 1
else:
# Unclosed {{ — emit literally and stop.
result.append("{{")
result.append(math_expr[content_start:])
return "".join(result), labels
# Check for optional `: label` suffix.
m = _LABEL_RE.match(content)
if m is not None:
body = m.group(1).strip()
label = m.group(2)
else:
body = content.strip()
label = f"_grp-{auto_index}"
auto_index += 1
labels.append(label)
result.append(f'manimgrp("{label}", {body})')
else:
result.append(math_expr[i])
i += 1
return "".join(result), labels

View file

@ -9,12 +9,12 @@ import numpy as np
from manim.mobject.geometry.polygram import Polygon
from manim.mobject.graph import Graph
from manim.mobject.opengl.opengl_vectorized_mobject import OpenGLVGroup as VGroup
from manim.mobject.three_d.three_dimensions import Dot3D
from manim.mobject.types.vectorized_mobject import VGroup
from manim.utils.qhull import QuickHull
if TYPE_CHECKING:
from manim.mobject.opengl.opengl_mobject import OpenGLMobject as Mobject
from manim.mobject.mobject import Mobject
from manim.typing import Point3D, Point3DLike_Array
__all__ = [
@ -52,9 +52,9 @@ class Polyhedron(VGroup):
.. manim:: SquarePyramidScene
:save_last_frame:
class SquarePyramidScene(Scene):
class SquarePyramidScene(ThreeDScene):
def construct(self):
self.camera.set_orientation(theta=30 * DEGREES, phi=75 * DEGREES)
self.set_camera_orientation(phi=75 * DEGREES, theta=30 * DEGREES)
vertex_coords = [
[1, 1, 0],
[1, -1, 0],
@ -86,9 +86,9 @@ class Polyhedron(VGroup):
.. manim:: PolyhedronSubMobjects
:save_last_frame:
class PolyhedronSubMobjects(Scene):
class PolyhedronSubMobjects(ThreeDScene):
def construct(self):
self.camera.set_orientation(theta=30 * DEGREES, phi=75 * DEGREES)
self.set_camera_orientation(phi=75 * DEGREES, theta=30 * DEGREES)
octahedron = Octahedron(edge_length = 3)
octahedron.graph[0].set_color(RED)
octahedron.faces[2].set_color(YELLOW)
@ -173,9 +173,9 @@ class Tetrahedron(Polyhedron):
.. manim:: TetrahedronScene
:save_last_frame:
class TetrahedronScene(Scene):
class TetrahedronScene(ThreeDScene):
def construct(self):
self.camera.set_orientation(theta=30 * DEGREES, phi=75 * DEGREES)
self.set_camera_orientation(phi=75 * DEGREES, theta=30 * DEGREES)
obj = Tetrahedron()
self.add(obj)
"""
@ -208,9 +208,9 @@ class Octahedron(Polyhedron):
.. manim:: OctahedronScene
:save_last_frame:
class OctahedronScene(Scene):
class OctahedronScene(ThreeDScene):
def construct(self):
self.camera.set_orientation(theta=30 * DEGREES, phi=75 * DEGREES)
self.set_camera_orientation(phi=75 * DEGREES, theta=30 * DEGREES)
obj = Octahedron()
self.add(obj)
"""
@ -254,9 +254,9 @@ class Icosahedron(Polyhedron):
.. manim:: IcosahedronScene
:save_last_frame:
class IcosahedronScene(Scene):
class IcosahedronScene(ThreeDScene):
def construct(self):
self.camera.set_orientation(theta=30 * DEGREES, phi=75 * DEGREES)
self.set_camera_orientation(phi=75 * DEGREES, theta=30 * DEGREES)
obj = Icosahedron()
self.add(obj)
"""
@ -319,9 +319,9 @@ class Dodecahedron(Polyhedron):
.. manim:: DodecahedronScene
:save_last_frame:
class DodecahedronScene(Scene):
class DodecahedronScene(ThreeDScene):
def construct(self):
self.camera.set_orientation(theta=30 * DEGREES, phi=75 * DEGREES)
self.set_camera_orientation(phi=75 * DEGREES, theta=30 * DEGREES)
obj = Dodecahedron()
self.add(obj)
"""
@ -389,9 +389,9 @@ class ConvexHull3D(Polyhedron):
:save_last_frame:
:quality: high
class ConvexHull3DExample(Scene):
class ConvexHull3DExample(ThreeDScene):
def construct(self):
self.camera.set_orientation(theta=30 * DEGREES, phi=75 * DEGREES)
self.set_camera_orientation(phi=75 * DEGREES, theta=30 * DEGREES)
points = [
[ 1.93192757, 0.44134585, -1.52407061],
[-0.93302521, 1.23206983, 0.64117067],

View file

@ -22,11 +22,10 @@ from manim.constants import ORIGIN, UP
from manim.utils.space_ops import get_unit_normal
if TYPE_CHECKING:
from manim.mobject.opengl.opengl_vectorized_mobject import (
OpenGLVMobject as VMobject,
)
from manim.typing import Point3D, Vector3D
from ..types.vectorized_mobject import VMobject
def get_3d_vmob_gradient_start_and_end_points(
vmob: VMobject,

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