diff --git a/benchmarks/bench_lissajous.py b/benchmarks/bench_lissajous.py index aa5b75deaa..bac01c3336 100644 --- a/benchmarks/bench_lissajous.py +++ b/benchmarks/bench_lissajous.py @@ -354,7 +354,7 @@ def construct(self) -> None: self.add_path_updaters() self.wait_until(lambda: self.is_path_traced_once()) - self.wait(1 / self.camera.frame_rate) + self.wait(1 / config.frame_rate) self.suspend_circles_updating() self.wait(2) diff --git a/docs/source/changelog/0.12.0-changelog.rst b/docs/source/changelog/0.12.0-changelog.rst index b67a3b7440..9126312885 100644 --- a/docs/source/changelog/0.12.0-changelog.rst +++ b/docs/source/changelog/0.12.0-changelog.rst @@ -246,7 +246,7 @@ Code quality improvements and similar refactors * :pr:`2200`: Addressed some maintenance TODOs - Changed an `Exception` to `ValueError` - - Fixed :meth:`.MappingCamera.points_to_pixel_coords` by adding the ``mobject`` argument of the parent + - Fixed ``MappingCamera.points_to_pixel_coords`` by adding the ``mobject`` argument of the parent - Rounded up width in :class:`.SplitScreenCamera` - Added docstring to :meth:`.Camera.capture_mobject` diff --git a/docs/source/guides/cameras.rst b/docs/source/guides/cameras.rst new file mode 100644 index 0000000000..93551d8bbc --- /dev/null +++ b/docs/source/guides/cameras.rst @@ -0,0 +1,245 @@ +Working with cameras and scene images +===================================== + +A camera represents a view of the current scene: in short, the camera describes +where the scene is being viewed from and how much of the scene is visible. The +renderer "draws" the scene from this view and turns it into an image. + +Most often, interaction with the camera happens inside a scene class using +``self.camera``. + +The camera frame +---------------- + +In 2D scenes, the camera's view is described by its *frame* (not to be confused +with a frame of a video). This frame is roughly equivalent to a picture frame +that is laid on top of the scene, with the camera "seeing" everything that lies +inside the frame. When the camera pans you can think of it as the frame sliding +across the "surface" of the scene, and when the camera zooms in or out, you can +think of it as the frame getting smaller or bigger (since less or more of the +scene, respectively, will fit into the picture frame). + +Moving the Cairo camera +----------------------- + +In the ordinary Cairo :class:`.Camera`, the frame is an actual mobject called +``frame``. You can modify or animate this frame like any other mobject:: + + class CameraExample(Scene): + def construct(self): + square = Square().shift(2 * RIGHT) + self.add(square) + self.play(self.camera.frame.animate.move_to(square)) + self.play(self.camera.frame.animate.scale(0.5)) + +A smaller frame zooms in; a larger frame shows more of the scene. Save and restore the +frame with the usual mobject operations:: + + self.camera.frame.save_state() + self.play(self.camera.auto_zoom([square])) + self.play(Restore(self.camera.frame)) + +Moving the OpenGL camera +------------------------ + +With the OpenGL renderer, the camera itself is a mobject. Animate +``self.camera`` directly to pan or zoom:: + + class OpenGLCameraExample(Scene): + def construct(self): + square = Square().shift(2 * RIGHT) + self.add(square) + self.play(self.camera.animate.move_to(square)) + self.play(self.camera.animate.scale(0.5)) + +Run this example with ``--renderer=opengl``. For orientation controls, see +:class:`.OpenGLCamera` and :class:`.ThreeDScene`. + +Choosing a camera class +----------------------- + +To select a different Cairo camera, pass ``camera_class`` to the scene's +constructor. For example, :class:`.MultiCamera` supports picture-in-picture views:: + + class CustomCameraScene(Scene): + def __init__(self, **kwargs): + super().__init__(camera_class=MultiCamera, **kwargs) + +Use the same pattern with your own :class:`.Camera` subclass to customize its +settings or projection. The renderer creates the camera during scene +initialization, before ``setup()`` and ``construct()`` are called. + +Camera view and image resolution +-------------------------------- + +The dimensions of the camera's frame and of the images output by the renderer +are specified separately. Camera frame dimensions are defined in scene units, +while output dimensions are defined in pixels. The output image's rectangular +pixel area is called the *viewport*. Configure its pixel dimensions before +constructing a camera, scene, or renderer:: + + with tempconfig({"pixel_width": 640, "pixel_height": 360}): + scene = Scene() + scene.add(Square()) + image = scene.get_image() + +A default Cairo camera uses ``config.frame_width`` for its width and derives its +height from the viewport's aspect ratio. This keeps circles circular and squares +square, including in square or portrait output. + +Passing only ``frame_width`` or ``frame_height`` to :class:`.Camera` derives the +other dimension from that same aspect ratio. Passing both dimensions or a custom +``frame`` uses the dimensions you specify:: + + camera = Camera(frame_width=8, frame_height=4) + camera.frame.move_to([2, 1, 0]) + +When both the width and height of the camera frame are explicitly provided, you +should ensure that frame dimensions and pixel dimensions have the same aspect +ratio; otherwise, the camera's output will be distorted when it is rendered. + +Inspecting the current scene +---------------------------- + +:meth:`.Scene.get_image` freshly draws the current scene and returns a PIL image. +It includes manual changes since the last animation and the current camera view:: + + class InspectExample(Scene): + def construct(self): + square = Square() + self.add(square) + self.get_image().save("before.png") + self.play(square.animate.shift(RIGHT)) + self.get_image().save("after.png") + +Use ``scene.show()`` to open a fresh image in PIL's external image viewer. In a +notebook, call ``display(scene.get_image())`` or put ``scene.get_image()`` as the +cell's final expression. Saving the image to disk is explicit, as in the example. + +Request snapshots between animations or at an idle prompt to inspect the mobjects +as they currently stand. Animation playback and updaters run separately, so a +snapshot after ``self.play()`` shows the state after the animation has finished. +See :meth:`.Scene.get_image` for details on snapshot timing. + +.. note:: + + For OpenGL, request snapshots on the thread that created the rendering context. + +Inspecting individual mobjects +------------------------------ + +For ordinary Cairo mobjects, use :meth:`.Mobject.get_image` or :meth:`.Mobject.show`:: + + Square().show() + Group(Square().shift(LEFT), Circle().shift(RIGHT)).get_image().save("objects.png") + image = square.get_image(camera=self.camera) + +These methods render an image from the view of a camera such that only the +chosen mobject and its submobjects are drawn; anything else in the scene is +ignored. + +The ``camera`` parameter allows for a different camera to be used to generate +the image. Without it, a new default :class:`.Camera` is created. To use the +view of the current camera, pass ``camera=self.camera``. + +These standalone helpers are Cairo-specific; use ``scene.get_image()`` for an +OpenGL scene, including its meshes. + +Three-dimensional and nested views +---------------------------------- + +Use :class:`.ThreeDScene` and its camera orientation methods for three-dimensional +scenes. Image inspection uses the current projection and fixed-object declarations, +just like ordinary drawing. + +:class:`.ZoomedScene` sets up a secondary camera and an inset display to show a +magnified region of the scene:: + + class DetailExample(ZoomedScene): + def construct(self): + self.add(Square()) + self.activate_zooming(animate=False) + self.get_image().save("detail.png") + +Multiple camera views +--------------------- + +The Cairo backend supports several camera views within one scene through +:class:`.MultiCamera`. The primary camera draws the overall scene; each +secondary camera supplies an image displayed by an +:class:`.ImageMobjectFromCamera` mobject. +During the execution of the scene, the renderer draws each camera's view into its +display mobject. This API is not supported by the OpenGL backend. + +There are two independent controls: + +* The secondary camera's ``frame`` selects the region to look at. Move it to pan, + or shrink it to zoom in. +* The display mobject selects where that view appears in the primary scene, as a + "picture-in-picture" display. This mobject can be manipulated like any other. + +For example, this scene places two detail views above the original objects:: + + class TwoCameraViews(Scene): + def __init__(self, **kwargs): + super().__init__(camera_class=MultiCamera, **kwargs) + + def construct(self): + circle = Circle(color=YELLOW).shift(2 * LEFT + DOWN) + square = Square(color=BLUE).shift(2 * RIGHT + DOWN) + self.add(circle, square) + + left_camera = Camera(frame_width=4, frame_height=3) + right_camera = Camera(frame_width=4, frame_height=3) + left_camera.frame.move_to(circle) + right_camera.frame.move_to(square) + + left_view = ImageMobjectFromCamera(left_camera) + right_view = ImageMobjectFromCamera(right_camera) + left_view.scale_to_fit_width(3).to_corner(UL) + right_view.scale_to_fit_width(3).to_corner(UR) + + for view in (left_view, right_view): + view.add_display_frame() + self.camera.add_image_mobject_from_camera(view) + self.add(view) + + # Zoom the left view without resizing its display. + self.play(left_camera.frame.animate.scale(0.5)) + # Pan the right view from the square to the circle. + self.play(right_camera.frame.animate.move_to(circle)) + self.wait() + +Run this example with ``--renderer=cairo``. + +Call ``self.camera.add_image_mobject_from_camera(view)`` to refresh the display's +image from its source camera on each draw, then ``self.add(view)`` to show it in +the scene. +``view.add_display_frame()`` adds a visible border around the display. +To show the region which the secondary camera is currently looking at, give its +``frame`` a visible stroke and add it to the scene:: + + left_camera.frame.set_stroke(YELLOW, width=2) + self.add(left_camera.frame) + +A display initially matches the aspect ratio of its source camera. When resizing +this display, make sure it is scaled uniformly to preserve its aspect ratio; +stretching only its width or height can distort the image. +Each inset's pixel resolution follows its display size relative to the primary +camera frame. + +All cameras view the same scene contents. Each display and its border are excluded +from their own camera's view. In the example, both detail cameras look below the +insets, keeping the insets out of each other's views. + +For nested insets, use a :class:`.MultiCamera` as a secondary camera and register +its displays there. Keep this hierarchy acyclic: camera registrations that form +a cycle raise an error. Cameras registered at the same level are drawn in order; +place their displays outside each other's views, as above, for independent insets. + +To remove a view entirely, remove both its visible mobject and its registration:: + + self.remove(left_view) + self.camera.image_mobjects_from_cameras.remove(left_view) + +``self.get_image()`` captures the scene together with its current inset views. diff --git a/docs/source/guides/deep_dive.rst b/docs/source/guides/deep_dive.rst index e315d5b0d4..b507a12757 100644 --- a/docs/source/guides/deep_dive.rst +++ b/docs/source/guides/deep_dive.rst @@ -326,7 +326,7 @@ renderer reference or read mutable global configuration. Directories are created lazily when their owning operation first writes. The writer remains Manim's interface to ``libav`` for media assembly. The Cairo renderer (see the implementation `here -`__) +`__) does not require further renderer-specific initialization. OpenGL creates a window only when the resolved presentation specification requests a live preview. The ``-p`` / ``--preview`` option does not create this window; it opens the @@ -998,44 +998,31 @@ 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 passes a top-left-origin, -C-contiguous ``uint8`` RGBA array to its :class:`.SceneFileWriter`. OpenGL uses -the same array contract and performs GPU readback at this renderer boundary only -when file output needs a frame. This concludes one iteration of the render loop, -and once the time progression has been processed completely, a final bit +First, the renderer prepares its Cairo image buffer. If a reusable ``static_image`` +is available, the renderer copies it into that buffer; otherwise it resets the buffer +using the camera's background color or image. Background images are resized to match +the buffer's dimensions. + +Things get a bit technical here, and at some point it is more efficient to delve into +the implementation -- but the drawing process can be summarized as follows: + +- The camera supplies a flat, ordered list of visible mobjects and applies + view/projection and shading transformations. Its animatable ``frame`` describes the + region being viewed. +- Private Cairo renderer helpers process consecutive batches of vectorized, point + cloud, and image mobjects without changing their draw order. +- Vectorized mobjects are converted to Cairo paths and drawn with their background + stroke, fill, and foreground stroke. Point clouds and image mobjects are converted + to target pixel coordinates and composited by renderer helpers. +- With a :class:`.MultiCamera`, the renderer draws each secondary camera's view into + a separate image buffer, excluding that camera's own display mobject. It then draws + the resulting image in the corresponding :class:`.ImageMobjectFromCamera` in the + primary view. + +After all batches have been processed, :meth:`.CairoRenderer.get_frame` copies the +rendered image into a top-left-origin, C-contiguous ``uint8`` RGBA array. The renderer +passes this array to :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: @@ -1049,11 +1036,11 @@ A TL;DR for the render loop, in the context of our toy example, reads as follows 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 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. +- Then the scene asks the renderer to do its job. The only mobject that needs to + be processed at this point is the main mobject attached to the transformation. + The camera supplies its view transform, while Cairo drawing helpers draw the + transformed square into the renderer's image buffer. The renderer passes a copy + of that buffer's RGBA pixels to the file writer. - At the end of the loop, 90 frames have been passed to the file writer. Completing the render loop diff --git a/docs/source/guides/index.rst b/docs/source/guides/index.rst index 0f0a92b98d..55af61c2da 100644 --- a/docs/source/guides/index.rst +++ b/docs/source/guides/index.rst @@ -7,6 +7,7 @@ Thematic Guides :glob: configuration + cameras deep_dive using_text add_voiceovers diff --git a/docs/source/reference.rst b/docs/source/reference.rst index 5352f83223..7889ef2373 100644 --- a/docs/source/reference.rst +++ b/docs/source/reference.rst @@ -38,13 +38,10 @@ Cameras ******* .. inheritance-diagram:: - manim.camera.camera - manim.camera.mapping_camera - manim.camera.moving_camera - manim.camera.multi_camera - manim.camera.three_d_camera + manim.renderer.cairo.camera + manim.renderer.opengl.camera :parts: 1 - :top-classes: manim.camera.camera.Camera, manim.mobject.mobject.Mobject + :top-classes: manim.renderer.cairo.camera.Camera, manim.mobject.mobject.Mobject, manim.mobject.opengl.opengl_mobject.OpenGLMobject Mobjects ******** diff --git a/docs/source/reference_index/cameras.rst b/docs/source/reference_index/cameras.rst index b56577bf22..edac5c9428 100644 --- a/docs/source/reference_index/cameras.rst +++ b/docs/source/reference_index/cameras.rst @@ -6,8 +6,5 @@ Cameras .. autosummary:: :toctree: ../reference - ~camera.camera - ~camera.mapping_camera - ~camera.moving_camera - ~camera.multi_camera - ~camera.three_d_camera + ~renderer.cairo.camera + ~renderer.opengl.camera diff --git a/manim/__init__.py b/manim/__init__.py index 9fd8e65f1d..1e929b0a54 100644 --- a/manim/__init__.py +++ b/manim/__init__.py @@ -41,11 +41,6 @@ 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 .manager import * from .mobject.frame import * @@ -83,7 +78,7 @@ from .mobject.types.vectorized_mobject import * from .mobject.value_tracker import * from .mobject.vector_field import * -from .renderer.cairo_renderer import * +from .renderer.cairo import * from .scene.moving_camera_scene import * from .scene.scene import * from .scene.scene_file_writer import * diff --git a/manim/camera/__init__.py b/manim/camera/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/manim/camera/camera.py b/manim/camera/camera.py deleted file mode 100644 index 1d54e5cf95..0000000000 --- a/manim/camera/camera.py +++ /dev/null @@ -1,1505 +0,0 @@ -"""A camera converts the mobjects contained in a Scene into an array of pixels.""" - -from __future__ import annotations - -__all__ = ["Camera", "BackgroundColoredVMobjectDisplayer"] - -import copy -import itertools as it -import operator as op -import pathlib -from collections.abc import Callable, Iterable -from functools import reduce -from typing import TYPE_CHECKING, Any, Self - -import cairo -import numpy as np -from PIL import Image - -from manim._config import config, logger -from manim.constants import * -from manim.mobject.mobject import Mobject -from manim.mobject.types.point_cloud_mobject import PMobject -from manim.mobject.types.vectorized_mobject import VMobject -from manim.utils.color import ManimColor, ParsableManimColor, color_to_int_rgba -from manim.utils.family import extract_mobject_family_members -from manim.utils.images import get_full_raster_image_path -from manim.utils.iterables import list_difference_update -from manim.utils.space_ops import cross2d - -if TYPE_CHECKING: - import numpy.typing as npt - - from manim.mobject.types.image_mobject import AbstractImageMobject - from manim.typing import ( - FloatRGBA_Array, - FloatRGBALike_Array, - ManimFloat, - ManimInt, - PixelArray, - Point3D, - Point3D_Array, - ) - - -LINE_JOIN_MAP = { - LineJointType.AUTO: None, # TODO: this could be improved - LineJointType.ROUND: cairo.LineJoin.ROUND, - LineJointType.BEVEL: cairo.LineJoin.BEVEL, - LineJointType.MITER: cairo.LineJoin.MITER, -} - - -CAP_STYLE_MAP = { - CapStyleType.AUTO: None, # TODO: this could be improved - CapStyleType.ROUND: cairo.LineCap.ROUND, - CapStyleType.BUTT: cairo.LineCap.BUTT, - CapStyleType.SQUARE: cairo.LineCap.SQUARE, -} - - -class Camera: - """Base camera class. - - This is the object which takes care of what exactly is displayed - on screen at any given moment. - - Parameters - ---------- - background_image - The path to an image that should be the background image. - If not set, the background is filled with :attr:`self.background_color` - background - What :attr:`background` is set to. By default, ``None``. - pixel_height - The height of the scene in pixels. - pixel_width - The width of the scene in pixels. - kwargs - Additional arguments (``background_color``, ``background_opacity``) - to be set. - """ - - def __init__( - self, - background_image: str | None = None, - frame_center: Point3D = ORIGIN, - image_mode: str = "RGBA", - n_channels: int = 4, - pixel_array_dtype: str = "uint8", - cairo_line_width_multiple: float = 0.01, - use_z_index: bool = True, - background: PixelArray | None = None, - pixel_height: int | None = None, - pixel_width: int | None = None, - frame_height: float | None = None, - frame_width: float | None = None, - frame_rate: float | None = None, - background_color: ParsableManimColor | None = None, - background_opacity: float | None = None, - **kwargs: Any, - ) -> None: - self.background_image = background_image - self.frame_center = frame_center - self.image_mode = image_mode - self.n_channels = n_channels - self.pixel_array_dtype = pixel_array_dtype - self.cairo_line_width_multiple = cairo_line_width_multiple - self.use_z_index = use_z_index - self.background = background - self.background_colored_vmobject_displayer: ( - BackgroundColoredVMobjectDisplayer | None - ) = None - - if pixel_height is None: - pixel_height = config["pixel_height"] - self.pixel_height = pixel_height - - if pixel_width is None: - pixel_width = config["pixel_width"] - self.pixel_width = pixel_width - - if frame_height is None: - frame_height = config["frame_height"] - self.frame_height = frame_height - - if frame_width is None: - frame_width = config["frame_width"] - self.frame_width = frame_width - - if frame_rate is None: - frame_rate = config["frame_rate"] - self.frame_rate = frame_rate - - if background_color is None: - self._background_color: ManimColor = ManimColor.parse( - config["background_color"] - ) - else: - self._background_color = ManimColor.parse(background_color) - if background_opacity is None: - self._background_opacity: float = config["background_opacity"] - else: - self._background_opacity = background_opacity - - # This one is in the same boat as the above, but it doesn't have the - # same name as the corresponding key so it has to be handled on its own - self.max_allowable_norm = config["frame_width"] - - self.rgb_max_val = np.iinfo(self.pixel_array_dtype).max - self.pixel_array_to_cairo_context: dict[int, cairo.Context] = {} - - # Contains the correct method to process a list of Mobjects of the - # corresponding class. If a Mobject is not an instance of a class in - # this dict (or an instance of a class that inherits from a class in - # this dict), then it cannot be rendered. - - self.init_background() - self.resize_frame_shape() - self.reset() - - def __deepcopy__(self, memo: Any) -> Camera: - # This is to address a strange bug where deepcopying - # will result in a segfault, which is somehow related - # to the aggdraw library - self.canvas = None - return copy.copy(self) - - @property - def background_color(self) -> ManimColor: - return self._background_color - - @background_color.setter - def background_color(self, color: ManimColor) -> None: - self._background_color = color - self.init_background() - - @property - def background_opacity(self) -> float: - return self._background_opacity - - @background_opacity.setter - def background_opacity(self, alpha: float) -> None: - self._background_opacity = alpha - self.init_background() - - def type_or_raise( - self, mobject: Mobject - ) -> type[VMobject] | type[PMobject] | type[AbstractImageMobject] | type[Mobject]: - """Return the type of mobject, if it is a type that can be rendered. - - If `mobject` is an instance of a class that inherits from a class that - can be rendered, return the super class. For example, an instance of a - Square is also an instance of VMobject, and these can be rendered. - Therefore, `type_or_raise(Square())` returns True. - - Parameters - ---------- - mobject - The object to take the type of. - - Notes - ----- - For a list of classes that can currently be rendered, see :meth:`display_funcs`. - - Returns - ------- - Type[:class:`~.Mobject`] - The type of mobjects, if it can be rendered. - - Raises - ------ - :exc:`TypeError` - When mobject is not an instance of a class that can be rendered. - """ - from ..mobject.types.image_mobject import AbstractImageMobject - - self.display_funcs: dict[ - type[Mobject], Callable[[list[Mobject], PixelArray], Any] - ] = { - VMobject: self.display_multiple_vectorized_mobjects, # type: ignore[dict-item] - PMobject: self.display_multiple_point_cloud_mobjects, # type: ignore[dict-item] - AbstractImageMobject: self.display_multiple_image_mobjects, # type: ignore[dict-item] - Mobject: lambda batch, pa: batch, # Do nothing - } - # We have to check each type in turn because we are dealing with - # super classes. For example, if square = Square(), then - # type(square) != VMobject, but isinstance(square, VMobject) == True. - for _type in self.display_funcs: - if isinstance(mobject, _type): - return _type - raise TypeError(f"Displaying an object of class {_type} is not supported") - - def reset_pixel_shape(self, new_height: float, new_width: float) -> None: - """This method resets the height and width - of a single pixel to the passed new_height and new_width. - - Parameters - ---------- - new_height - The new height of the entire scene in pixels - new_width - The new width of the entire scene in pixels - """ - self.pixel_width = new_width - self.pixel_height = new_height - self.init_background() - self.resize_frame_shape() - self.reset() - - def resize_frame_shape(self, fixed_dimension: int = 0) -> None: - """ - Changes frame_shape to match the aspect ratio - of the pixels, where fixed_dimension determines - whether frame_height or frame_width - remains fixed while the other changes accordingly. - - Parameters - ---------- - fixed_dimension - If 0, height is scaled with respect to width - else, width is scaled with respect to height. - """ - pixel_height = self.pixel_height - pixel_width = self.pixel_width - frame_height = self.frame_height - frame_width = self.frame_width - aspect_ratio = pixel_width / pixel_height - if fixed_dimension == 0: - frame_height = frame_width / aspect_ratio - else: - frame_width = aspect_ratio * frame_height - self.frame_height = frame_height - self.frame_width = frame_width - - def init_background(self) -> None: - """Initialize the background. - If self.background_image is the path of an image - the image is set as background; else, the default - background color fills the background. - """ - height = self.pixel_height - width = self.pixel_width - if self.background_image is not None: - path = get_full_raster_image_path(self.background_image) - image = Image.open(path).convert(self.image_mode) - # TODO, how to gracefully handle backgrounds - # with different sizes? - self.background = np.array(image)[:height, :width] - self.background = self.background.astype(self.pixel_array_dtype) - else: - background_rgba = color_to_int_rgba( - self.background_color, - self.background_opacity, - ) - self.background = np.zeros( - (height, width, self.n_channels), - dtype=self.pixel_array_dtype, - ) - self.background[:, :] = background_rgba - - def get_image( - self, pixel_array: PixelArray | list | tuple | None = None - ) -> Image.Image: - """Returns an image from the passed - pixel array, or from the current frame - if the passed pixel array is none. - - Parameters - ---------- - pixel_array - The pixel array from which to get an image, by default None - - Returns - ------- - PIL.Image.Image - The PIL image of the array. - """ - if pixel_array is None: - pixel_array = self.pixel_array - return Image.fromarray(pixel_array, mode=self.image_mode) - - def convert_pixel_array(self, pixel_array: PixelArray | list | tuple) -> PixelArray: - """Converts a pixel array with float values to proper RGB values. - - Parameters - ---------- - pixel_array - Pixel array to convert. - - Returns - ------- - np.array - The new, converted pixel array. - """ - pixel_array = np.asarray(pixel_array) - return np.apply_along_axis( - lambda f: (f * self.rgb_max_val).astype(self.pixel_array_dtype), - 2, - pixel_array, - ) - - def set_pixel_array( - self, pixel_array: PixelArray | list | tuple, convert_from_floats: bool = False - ) -> None: - """Sets the pixel array of the camera to the passed pixel array. - - Parameters - ---------- - pixel_array - The pixel array to convert and then set as the camera's pixel array. - convert_from_floats - Whether or not to convert float values to proper RGB values, by default False - """ - converted_array: PixelArray = ( - self.convert_pixel_array(pixel_array) - if convert_from_floats - else np.asarray(pixel_array) - ) - if ( - hasattr(self, "pixel_array") - and self.pixel_array.shape == converted_array.shape - ): - # Set in place - np.copyto(self.pixel_array, converted_array, casting="unsafe") - else: - self.pixel_array: PixelArray = converted_array.copy() - - def set_background( - self, pixel_array: PixelArray | list | tuple, convert_from_floats: bool = False - ) -> None: - """Sets the background to the passed pixel_array after converting - to valid RGB values. - - Parameters - ---------- - pixel_array - The pixel array to set the background to. - convert_from_floats - Whether or not to convert floats values to proper RGB valid ones, by default False - """ - self.background = ( - self.convert_pixel_array(pixel_array) - if convert_from_floats - else np.array(pixel_array) - ) - - # TODO, this should live in utils, not as a method of Camera - def make_background_from_func( - self, coords_to_colors_func: Callable[[np.ndarray], np.ndarray] - ) -> PixelArray: - """ - Makes a pixel array for the background by using coords_to_colors_func to determine each pixel's color. Each input - pixel's color. Each input to coords_to_colors_func is an (x, y) pair in space (in ordinary space coordinates; not - pixel coordinates), and each output is expected to be an RGBA array of 4 floats. - - Parameters - ---------- - coords_to_colors_func - The function whose input is an (x,y) pair of coordinates and - whose return values must be the colors for that point - - Returns - ------- - np.array - The pixel array which can then be passed to set_background. - """ - logger.info("Starting set_background") - coords = self.get_coords_of_all_pixels() - new_background = np.apply_along_axis(coords_to_colors_func, 2, coords) - logger.info("Ending set_background") - - return self.convert_pixel_array(new_background) - - def set_background_from_func( - self, coords_to_colors_func: Callable[[np.ndarray], np.ndarray] - ) -> None: - """ - Sets the background to a pixel array using coords_to_colors_func to determine each pixel's color. Each input - pixel's color. Each input to coords_to_colors_func is an (x, y) pair in space (in ordinary space coordinates; not - pixel coordinates), and each output is expected to be an RGBA array of 4 floats. - - Parameters - ---------- - coords_to_colors_func - The function whose input is an (x,y) pair of coordinates and - whose return values must be the colors for that point - """ - self.set_background(self.make_background_from_func(coords_to_colors_func)) - - def reset(self) -> Self: - """Resets the camera's pixel array - to that of the background - - Returns - ------- - Camera - The camera object after setting the pixel array. - """ - assert self.background is not None - self.set_pixel_array(self.background) - return self - - def set_frame_to_background(self, background: PixelArray) -> None: - self.set_pixel_array(background) - - #### - - def get_mobjects_to_display( - self, - mobjects: Iterable[Mobject], - include_submobjects: bool = True, - excluded_mobjects: list | None = None, - ) -> list[Mobject]: - """Used to get the list of mobjects to display - with the camera. - - Parameters - ---------- - mobjects - The Mobjects - include_submobjects - Whether or not to include the submobjects of mobjects, by default True - excluded_mobjects - Any mobjects to exclude, by default None - - Returns - ------- - list - list of mobjects - """ - if include_submobjects: - mobjects = extract_mobject_family_members( - mobjects, - use_z_index=self.use_z_index, - only_those_with_points=True, - ) - if excluded_mobjects: - all_excluded = extract_mobject_family_members( - excluded_mobjects, - use_z_index=self.use_z_index, - ) - mobjects = list_difference_update(mobjects, all_excluded) - return list(mobjects) - - def is_in_frame(self, mobject: Mobject) -> bool: - """Checks whether the passed mobject is in - frame or not. - - Parameters - ---------- - mobject - The mobject for which the checking needs to be done. - - Returns - ------- - bool - True if in frame, False otherwise. - """ - fc = self.frame_center - fh = self.frame_height - fw = self.frame_width - return not reduce( - op.or_, - [ - mobject.get_right()[0] < fc[0] - fw / 2, - mobject.get_bottom()[1] > fc[1] + fh / 2, - mobject.get_left()[0] > fc[0] + fw / 2, - mobject.get_top()[1] < fc[1] - fh / 2, - ], - ) - - def capture_mobject(self, mobject: Mobject, **kwargs: Any) -> None: - """Capture mobjects by storing it in :attr:`pixel_array`. - - This is a single-mobject version of :meth:`capture_mobjects`. - - Parameters - ---------- - mobject - Mobject to capture. - - kwargs - Keyword arguments to be passed to :meth:`get_mobjects_to_display`. - - """ - return self.capture_mobjects([mobject], **kwargs) - - def capture_mobjects(self, mobjects: Iterable[Mobject], **kwargs: Any) -> None: - """Capture mobjects by printing them on :attr:`pixel_array`. - - This is the essential function that converts the contents of a Scene - into an array, which is then converted to an image or video. - - Parameters - ---------- - mobjects - Mobjects to capture. - - kwargs - Keyword arguments to be passed to :meth:`get_mobjects_to_display`. - - Notes - ----- - For a list of classes that can currently be rendered, see :meth:`display_funcs`. - - """ - # The mobjects will be processed in batches (or runs) of mobjects of - # the same type. That is, if the list mobjects contains objects of - # types [VMobject, VMobject, VMobject, PMobject, PMobject, VMobject], - # then they will be captured in three batches: [VMobject, VMobject, - # VMobject], [PMobject, PMobject], and [VMobject]. This must be done - # without altering their order. it.groupby computes exactly this - # partition while at the same time preserving order. - mobjects = self.get_mobjects_to_display(mobjects, **kwargs) - for group_type, group in it.groupby(mobjects, self.type_or_raise): - self.display_funcs[group_type](list(group), self.pixel_array) - - # Methods associated with svg rendering - - # NOTE: None of the methods below have been mentioned outside of their definitions. Their DocStrings are not as - # detailed as possible. - - def get_cached_cairo_context(self, pixel_array: PixelArray) -> cairo.Context | None: - """Returns the cached cairo context of the passed - pixel array if it exists, and None if it doesn't. - - Parameters - ---------- - pixel_array - The pixel array to check. - - Returns - ------- - cairo.Context - The cached cairo context. - """ - return self.pixel_array_to_cairo_context.get(id(pixel_array), None) - - def cache_cairo_context(self, pixel_array: PixelArray, ctx: cairo.Context) -> None: - """Caches the passed Pixel array into a Cairo Context - - Parameters - ---------- - pixel_array - The pixel array to cache - ctx - The context to cache it into. - """ - self.pixel_array_to_cairo_context[id(pixel_array)] = ctx - - def get_cairo_context(self, pixel_array: PixelArray) -> cairo.Context: - """Returns the cairo context for a pixel array after - caching it to self.pixel_array_to_cairo_context - If that array has already been cached, it returns the - cached version instead. - - Parameters - ---------- - pixel_array - The Pixel array to get the cairo context of. - - Returns - ------- - cairo.Context - The cairo context of the pixel array. - """ - cached_ctx = self.get_cached_cairo_context(pixel_array) - if cached_ctx: - return cached_ctx - pw = self.pixel_width - ph = self.pixel_height - fw = self.frame_width - fh = self.frame_height - fc = self.frame_center - surface = cairo.ImageSurface.create_for_data( - pixel_array.data, - cairo.FORMAT_ARGB32, - pw, - ph, - ) - ctx = cairo.Context(surface) - ctx.scale(pw, ph) - ctx.set_matrix( - cairo.Matrix( - (pw / fw), - 0, - 0, - -(ph / fh), - (pw / 2) - fc[0] * (pw / fw), - (ph / 2) + fc[1] * (ph / fh), - ), - ) - self.cache_cairo_context(pixel_array, ctx) - return ctx - - def display_multiple_vectorized_mobjects( - self, vmobjects: list[VMobject], pixel_array: PixelArray - ) -> None: - """Displays multiple VMobjects in the pixel_array - - Parameters - ---------- - vmobjects - list of VMobjects to display - pixel_array - The pixel array - """ - if len(vmobjects) == 0: - return - batch_image_pairs = it.groupby(vmobjects, lambda vm: vm.get_background_image()) - for image, batch in batch_image_pairs: - if image: - self.display_multiple_background_colored_vmobjects(batch, pixel_array) - else: - self.display_multiple_non_background_colored_vmobjects( - batch, - pixel_array, - ) - - def display_multiple_non_background_colored_vmobjects( - self, vmobjects: Iterable[VMobject], pixel_array: PixelArray - ) -> None: - """Displays multiple VMobjects in the cairo context, as long as they don't have - background colors. - - Parameters - ---------- - vmobjects - list of the VMobjects - pixel_array - The Pixel array to add the VMobjects to. - """ - ctx = self.get_cairo_context(pixel_array) - for vmobject in vmobjects: - self.display_vectorized(vmobject, ctx) - - def display_vectorized(self, vmobject: VMobject, ctx: cairo.Context) -> Self: - """Displays a VMobject in the cairo context - - Parameters - ---------- - vmobject - The Vectorized Mobject to display - ctx - The cairo context to use. - - Returns - ------- - Camera - The camera object - """ - self.set_cairo_context_path(ctx, vmobject) - self.apply_stroke(ctx, vmobject, background=True) - self.apply_fill(ctx, vmobject) - self.apply_stroke(ctx, vmobject) - return self - - def set_cairo_context_path(self, ctx: cairo.Context, vmobject: VMobject) -> Self: - """Sets a path for the cairo context with the vmobject passed - - Parameters - ---------- - ctx - The cairo context - vmobject - The VMobject - - Returns - ------- - Camera - Camera object after setting cairo_context_path - """ - points = self.transform_points_pre_display(vmobject, vmobject.points) - if len(points) == 0: - return self - - nppcc = vmobject.n_points_per_cubic_curve # 4 for cubic bezier - - ctx.new_path() - - # Subpath boundaries are computed by VMobject; a split occurs wherever - # one curve's end anchor is not close to the next curve's start anchor. - split_indices = vmobject.get_subpath_split_indices_from_points(points, n_dims=2) - if len(split_indices) == 0: - return self - - # Precompute flat xy array for fast indexing - pts_xy = points[:, :2].ravel() # [x0, y0, x1, y1, ...] - - # Local references for speed (avoid attribute lookups in loop) - _move_to = ctx.move_to - _curve_to = ctx.curve_to - _new_sub_path = ctx.new_sub_path - _close_path = ctx.close_path - - for start_idx, end_idx in split_indices: - start_idx = int(start_idx) - end_idx = int(end_idx) - if end_idx - start_idx < nppcc: - continue - - _new_sub_path() - # move_to first point - base = start_idx * 2 - _move_to(pts_xy[base], pts_xy[base + 1]) - - # Emit all cubic curves in this subpath. - # Points are: [anchor, handle1, handle2, anchor, handle1, handle2, anchor, ...] - # Each curve uses indices 1,2,3 relative to the start of each group of 4. - for i in range(start_idx, end_idx - nppcc + 1, nppcc): - b = (i + 1) * 2 # handle1 - _curve_to( - pts_xy[b], - pts_xy[b + 1], - pts_xy[b + 2], - pts_xy[b + 3], - pts_xy[b + 4], - pts_xy[b + 5], - ) - - # Close if first and last points are equal. - if vmobject.consider_points_equals_2d( - points[start_idx], points[end_idx - 1] - ): - _close_path() - - return self - - def set_cairo_context_color( - self, ctx: cairo.Context, rgbas: FloatRGBALike_Array, vmobject: VMobject - ) -> Self: - """Sets the color of the cairo context - - Parameters - ---------- - ctx - The cairo context - rgbas - The RGBA array with which to color the context. - vmobject - The VMobject with which to set the color. - - Returns - ------- - Camera - The camera object - """ - if len(rgbas) == 1: - # Use reversed rgb because cairo surface is - # encodes it in reverse order - ctx.set_source_rgba(*rgbas[0][2::-1], rgbas[0][3]) - else: - points = vmobject.get_gradient_start_and_end_points() - points = self.transform_points_pre_display(vmobject, points) - pat = cairo.LinearGradient(*it.chain(*(point[:2] for point in points))) - offsets = np.linspace(0, 1, len(rgbas)) - for rgba, offset in zip(rgbas, offsets, strict=True): - pat.add_color_stop_rgba(offset, *rgba[2::-1], rgba[3]) - ctx.set_source(pat) - return self - - def apply_fill(self, ctx: cairo.Context, vmobject: VMobject) -> Self: - """Fills the cairo context - - Parameters - ---------- - ctx - The cairo context - vmobject - The VMobject - - Returns - ------- - Camera - The camera object. - """ - self.set_cairo_context_color(ctx, self.get_fill_rgbas(vmobject), vmobject) - ctx.fill_preserve() - return self - - def apply_stroke( - self, ctx: cairo.Context, vmobject: VMobject, background: bool = False - ) -> Self: - """Applies a stroke to the VMobject in the cairo context. - - Parameters - ---------- - ctx - The cairo context - vmobject - The VMobject - background - Whether or not to consider the background when applying this - stroke width, by default False - - Returns - ------- - Camera - The camera object with the stroke applied. - """ - width = vmobject.get_stroke_width(background) - if width == 0: - return self - self.set_cairo_context_color( - ctx, - self.get_stroke_rgbas(vmobject, background=background), - vmobject, - ) - ctx.set_line_width( - width - * self.cairo_line_width_multiple - * (self.frame_width / self.frame_width), - # This ensures lines have constant width as you zoom in on them. - ) - if vmobject.joint_type != LineJointType.AUTO: - ctx.set_line_join(LINE_JOIN_MAP[vmobject.joint_type]) - if vmobject.cap_style != CapStyleType.AUTO: - ctx.set_line_cap(CAP_STYLE_MAP[vmobject.cap_style]) - ctx.stroke_preserve() - return self - - def get_stroke_rgbas( - self, vmobject: VMobject, background: bool = False - ) -> FloatRGBA_Array: - """Gets the RGBA array for the stroke of the passed - VMobject. - - Parameters - ---------- - vmobject - The VMobject - background - Whether or not to consider the background when getting the stroke - RGBAs, by default False - - Returns - ------- - np.ndarray - The RGBA array of the stroke. - """ - return vmobject.get_stroke_rgbas(background) - - def get_fill_rgbas(self, vmobject: VMobject) -> FloatRGBA_Array: - """Returns the RGBA array of the fill of the passed VMobject - - Parameters - ---------- - vmobject - The VMobject - - Returns - ------- - np.array - The RGBA Array of the fill of the VMobject - """ - return vmobject.get_fill_rgbas() - - def get_background_colored_vmobject_displayer( - self, - ) -> BackgroundColoredVMobjectDisplayer: - """Returns the background_colored_vmobject_displayer - if it exists or makes one and returns it if not. - - Returns - ------- - BackgroundColoredVMobjectDisplayer - Object that displays VMobjects that have the same color - as the background. - """ - if self.background_colored_vmobject_displayer is None: - self.background_colored_vmobject_displayer = ( - BackgroundColoredVMobjectDisplayer(self) - ) - return self.background_colored_vmobject_displayer - - def display_multiple_background_colored_vmobjects( - self, cvmobjects: Iterable[VMobject], pixel_array: PixelArray - ) -> Self: - """Displays multiple vmobjects that have the same color as the background. - - Parameters - ---------- - cvmobjects - List of Colored VMobjects - pixel_array - The pixel array. - - Returns - ------- - Camera - The camera object. - """ - displayer = self.get_background_colored_vmobject_displayer() - cvmobject_pixel_array = displayer.display(*cvmobjects) - self.overlay_rgba_array(pixel_array, cvmobject_pixel_array) - return self - - # Methods for other rendering - - # NOTE: Out of the following methods, only `transform_points_pre_display` and `points_to_pixel_coords` have been mentioned outside of their definitions. - # As a result, the other methods do not have as detailed docstrings as would be preferred. - - def display_multiple_point_cloud_mobjects( - self, pmobjects: Iterable[PMobject], pixel_array: PixelArray - ) -> None: - """Displays multiple PMobjects by modifying the passed pixel array. - - Parameters - ---------- - pmobjects - List of PMobjects - pixel_array - The pixel array to modify. - """ - for pmobject in pmobjects: - self.display_point_cloud( - pmobject, - pmobject.points, - pmobject.rgbas, - self.adjusted_thickness(pmobject.stroke_width), - pixel_array, - ) - - def display_point_cloud( - self, - pmobject: PMobject, - points: Point3D_Array, - rgbas: FloatRGBA_Array, - thickness: float, - pixel_array: PixelArray, - ) -> None: - """Displays a PMobject by modifying the pixel array suitably. - - TODO: Write a description for the rgbas argument. - - Parameters - ---------- - pmobject - Point Cloud Mobject - points - The points to display in the point cloud mobject - rgbas - - thickness - The thickness of each point of the PMobject - pixel_array - The pixel array to modify. - - """ - if len(points) == 0: - return - pixel_coords = self.points_to_pixel_coords(pmobject, points) - pixel_coords = self.thickened_coordinates(pixel_coords, thickness) - rgba_len = pixel_array.shape[2] - - rgbas = (self.rgb_max_val * rgbas).astype(self.pixel_array_dtype) - target_len = len(pixel_coords) - factor = target_len // len(rgbas) - rgbas = np.array([rgbas] * factor).reshape((target_len, rgba_len)) - - on_screen_indices = self.on_screen_pixels(pixel_coords) - pixel_coords = pixel_coords[on_screen_indices] - rgbas = rgbas[on_screen_indices] - - ph = self.pixel_height - pw = self.pixel_width - - flattener = np.array([1, pw], dtype="int") - flattener = flattener.reshape((2, 1)) - indices = np.dot(pixel_coords, flattener)[:, 0] - indices = indices.astype("int") - - new_pa = pixel_array.reshape((ph * pw, rgba_len)) - new_pa[indices] = rgbas - pixel_array[:, :] = new_pa.reshape((ph, pw, rgba_len)) - - def display_multiple_image_mobjects( - self, - image_mobjects: Iterable[AbstractImageMobject], - pixel_array: PixelArray, - ) -> None: - """Displays multiple image mobjects by modifying the passed pixel_array. - - Parameters - ---------- - image_mobjects - list of ImageMobjects - pixel_array - The pixel array to modify. - """ - for image_mobject in image_mobjects: - self.display_image_mobject(image_mobject, pixel_array) - - def display_image_mobject( - self, image_mobject: AbstractImageMobject, pixel_array: np.ndarray - ) -> None: - """Display an :class:`~.ImageMobject` by changing the ``pixel_array`` suitably. - - Parameters - ---------- - image_mobject - The :class:`~.ImageMobject` to display. - pixel_array - The pixel array to put the :class:`~.ImageMobject` in. - """ - sub_image = Image.fromarray(image_mobject.get_pixel_array(), mode="RGBA") - original_coords = np.array( - [ - [0, 0], - [sub_image.width, 0], - [0, sub_image.height], - [sub_image.width, sub_image.height], - ] - ) - target_coords = self.points_to_subpixel_coords( - image_mobject, image_mobject.points - ) - int_target_coords = target_coords.astype(np.int64) - - # Temporarily translate target coords to upper left corner to calculate the - # smallest possible size for the target image. - shift_vector = np.array( - [ - min(*[x for x, y in int_target_coords]), - min(*[y for x, y in int_target_coords]), - ] - ) - target_coords -= shift_vector - int_target_coords -= shift_vector - target_size = ( - max(*[x for x, y in int_target_coords]), - max(*[y for x, y in int_target_coords]), - ) - - # Check that the quadrilateral of the transformed image can actually contain any - # pixels by checking that its height from the longest side is longer than 0.5 pixels. - # If it's not, do not render the image. Otherwise, the perspective transform - # coefficients below might have broken values due to the extreme distortion (for - # example, when the image is perpendicular to the camera). - ordered_vertices = [target_coords[i] for i in (0, 1, 3, 2)] - sides = [ordered_vertices[(i + 1) % 4] - ordered_vertices[i] for i in range(4)] - side_lengths_in_pixels = np.linalg.norm(sides, axis=1) - - longest_side_index = np.argmax(side_lengths_in_pixels) - longest_side = sides[longest_side_index] - longest_side_length_in_pixels = side_lengths_in_pixels[longest_side_index] - if longest_side_length_in_pixels == 0: - return - - previous_side = sides[(longest_side_index - 1) % 4] - next_side = sides[(longest_side_index - 1) % 4] - - # height = area / base - h1 = abs(cross2d(longest_side, previous_side)) / longest_side_length_in_pixels - h2 = abs(cross2d(longest_side, next_side)) / longest_side_length_in_pixels - height_from_longest_side_in_pixels = max(h1, h2) - - if height_from_longest_side_in_pixels < 0.5: - return - - # Use PIL.Image.Image.transform() to apply a perspective transform to the image. - # The transform coefficients must be calculated. The following is adapted from: - # https://pc-pillow.readthedocs.io/en/latest/Image_class/Image_transform.html#transform-perspective-coefficients - # https://stackoverflow.com/questions/14177744/how-does-perspective-transformation-work-in-pil - # The derivation can be found here: - # https://web.archive.org/web/20150222120106/xenia.media.mit.edu/~cwren/interpolator/ - homography_matrix = [] - for (x, y), (X, Y) in zip(target_coords, original_coords, strict=True): - homography_matrix.append([x, y, 1, 0, 0, 0, -X * x, -X * y]) - homography_matrix.append([0, 0, 0, x, y, 1, -Y * x, -Y * y]) - - A = np.array(homography_matrix, dtype=np.float64) - b = original_coords.reshape(8).astype(np.float64) - - try: - transform_coefficients = np.linalg.solve(A, b) - except np.linalg.LinAlgError: - # The matrix A might be singular if three points are collinear. - # In this case, do nothing and return. - return - - sub_image = sub_image.transform( - size=target_size, # Use the smallest possible size for speed. - method=Image.Transform.PERSPECTIVE, - data=transform_coefficients, - resample=image_mobject.resampling_algorithm, - ) - - # Paste into an image as large as the camera's pixel array. - full_image = Image.fromarray( - np.zeros((self.pixel_height, self.pixel_width)), - mode="RGBA", - ) - full_image.paste( - sub_image, - box=( - shift_vector[0], - shift_vector[1], - shift_vector[0] + target_size[0], - shift_vector[1] + target_size[1], - ), - ) - # Paint on top of existing pixel array. - self.overlay_PIL_image(pixel_array, full_image) - - def overlay_rgba_array( - self, pixel_array: np.ndarray, new_array: np.ndarray - ) -> None: - """Overlays an RGBA array on top of the given Pixel array. - - Parameters - ---------- - pixel_array - The original pixel array to modify. - new_array - The new pixel array to overlay. - """ - self.overlay_PIL_image(pixel_array, self.get_image(new_array)) - - def overlay_PIL_image(self, pixel_array: np.ndarray, image: Image) -> None: - """Overlays a PIL image on the passed pixel array. - - Parameters - ---------- - pixel_array - The Pixel array - image - The Image to overlay. - """ - pixel_array[:, :] = np.array( - Image.alpha_composite(self.get_image(pixel_array), image), - dtype="uint8", - ) - - def adjust_out_of_range_points(self, points: np.ndarray) -> np.ndarray: - """If any of the points in the passed array are out of - the viable range, they are adjusted suitably. - - Parameters - ---------- - points - The points to adjust - - Returns - ------- - np.array - The adjusted points. - """ - if not np.any(points > self.max_allowable_norm): - return points - norms = np.apply_along_axis(np.linalg.norm, 1, points) - violator_indices = norms > self.max_allowable_norm - violators = points[violator_indices, :] - violator_norms = norms[violator_indices] - reshaped_norms = np.repeat( - violator_norms.reshape((len(violator_norms), 1)), - points.shape[1], - 1, - ) - rescaled = self.max_allowable_norm * violators / reshaped_norms - points[violator_indices] = rescaled - return points - - def transform_points_pre_display( - self, - mobject: Mobject, - points: Point3D_Array, - ) -> Point3D_Array: # TODO: Write more detailed docstrings for this method. - # NOTE: There seems to be an unused argument `mobject`. - - # Subclasses (like ThreeDCamera) may want to - # adjust points further before they're shown - if not np.all(np.isfinite(points)): - # TODO, print some kind of warning about - # mobject having invalid points? - points = np.zeros((1, 3)) - return points - - def points_to_subpixel_coords( - self, - mobject: Mobject, - points: Point3D_Array, - ) -> npt.NDArray[ - ManimFloat - ]: # TODO: Write more detailed docstrings for this method. - points = self.transform_points_pre_display(mobject, points) - shifted_points = points - self.frame_center - - result = np.zeros((len(points), 2)) - pixel_height = self.pixel_height - pixel_width = self.pixel_width - frame_height = self.frame_height - frame_width = self.frame_width - width_mult = pixel_width / frame_width - width_add = pixel_width / 2 - height_mult = pixel_height / frame_height - height_add = pixel_height / 2 - # Flip on y-axis as you go - height_mult *= -1 - - result[:, 0] = shifted_points[:, 0] * width_mult + width_add - result[:, 1] = shifted_points[:, 1] * height_mult + height_add - return result - - def points_to_pixel_coords( - self, - mobject: Mobject, - points: Point3D_Array, - ) -> npt.NDArray[ManimInt]: # TODO: Write more detailed docstrings for this method. - return self.points_to_subpixel_coords(mobject, points).astype(np.int64) - - def on_screen_pixels(self, pixel_coords: np.ndarray) -> PixelArray: - """Returns array of pixels that are on the screen from a given - array of pixel_coordinates - - Parameters - ---------- - pixel_coords - The pixel coords to check. - - Returns - ------- - np.array - The pixel coords on screen. - """ - return reduce( - op.and_, - [ - pixel_coords[:, 0] >= 0, - pixel_coords[:, 0] < self.pixel_width, - pixel_coords[:, 1] >= 0, - pixel_coords[:, 1] < self.pixel_height, - ], - ) - - def adjusted_thickness(self, thickness: float) -> float: - """Computes the adjusted stroke width for a zoomed camera. - - Parameters - ---------- - thickness - The stroke width of a mobject. - - Returns - ------- - float - The adjusted stroke width that reflects zooming in with - the camera. - """ - # TODO: This seems...unsystematic - big_sum: float = op.add(config["pixel_height"], config["pixel_width"]) - this_sum: float = op.add(self.pixel_height, self.pixel_width) - factor = big_sum / this_sum - return 1 + (thickness - 1) * factor - - def get_thickening_nudges(self, thickness: float) -> PixelArray: - """Determine a list of vectors used to nudge - two-dimensional pixel coordinates. - - Parameters - ---------- - thickness - - Returns - ------- - np.array - - """ - thickness = int(thickness) - _range = list(range(-thickness // 2 + 1, thickness // 2 + 1)) - return np.array(list(it.product(_range, _range))) - - def thickened_coordinates( - self, pixel_coords: np.ndarray, thickness: float - ) -> PixelArray: - """Returns thickened coordinates for a passed array of pixel coords and - a thickness to thicken by. - - Parameters - ---------- - pixel_coords - Pixel coordinates - thickness - Thickness - - Returns - ------- - np.array - Array of thickened pixel coords. - """ - nudges = self.get_thickening_nudges(thickness) - pixel_coords = np.array([pixel_coords + nudge for nudge in nudges]) - size = pixel_coords.size - return pixel_coords.reshape((size // 2, 2)) - - # TODO, reimplement using cairo matrix - def get_coords_of_all_pixels(self) -> PixelArray: - """Returns the cartesian coordinates of each pixel. - - Returns - ------- - np.ndarray - The array of cartesian coordinates. - """ - # These are in x, y order, to help me keep things straight - full_space_dims = np.array([self.frame_width, self.frame_height]) - full_pixel_dims = np.array([self.pixel_width, self.pixel_height]) - - # These are addressed in the same y, x order as in pixel_array, but the values in them - # are listed in x, y order - uncentered_pixel_coords = np.indices([self.pixel_height, self.pixel_width])[ - ::-1 - ].transpose(1, 2, 0) - uncentered_space_coords = ( - uncentered_pixel_coords * full_space_dims - ) / full_pixel_dims - # Could structure above line's computation slightly differently, but figured (without much - # thought) multiplying by frame_shape first, THEN dividing by pixel_shape, is probably - # better than the other order, for avoiding underflow quantization in the division (whereas - # overflow is unlikely to be a problem) - - centered_space_coords = uncentered_space_coords - (full_space_dims / 2) - - # Have to also flip the y coordinates to account for pixel array being listed in - # top-to-bottom order, opposite of screen coordinate convention - centered_space_coords = centered_space_coords * (1, -1) - - return centered_space_coords - - -# NOTE: The methods of the following class have not been mentioned outside of their definitions. -# Their DocStrings are not as detailed as preferred. -class BackgroundColoredVMobjectDisplayer: - """Auxiliary class that handles displaying vectorized mobjects with - a set background image. - - Parameters - ---------- - camera - Camera object to use. - """ - - def __init__(self, camera: Camera): - self.camera = camera - self.file_name_to_pixel_array_map: dict[str, PixelArray] = {} - self.pixel_array = np.array(camera.pixel_array) - self.reset_pixel_array() - - def reset_pixel_array(self) -> None: - self.pixel_array[:, :] = 0 - - def resize_background_array( - self, - background_array: PixelArray, - new_width: float, - new_height: float, - mode: str = "RGBA", - ) -> PixelArray: - """Resizes the pixel array representing the background. - - Parameters - ---------- - background_array - The pixel - new_width - The new width of the background - new_height - The new height of the background - mode - The PIL image mode, by default "RGBA" - - Returns - ------- - np.array - The numpy pixel array of the resized background. - """ - image = Image.fromarray(background_array) - image = image.convert(mode) - resized_image = image.resize((new_width, new_height)) - return np.array(resized_image) - - def resize_background_array_to_match( - self, background_array: PixelArray, pixel_array: PixelArray - ) -> PixelArray: - """Resizes the background array to match the passed pixel array. - - Parameters - ---------- - background_array - The prospective pixel array. - pixel_array - The pixel array whose width and height should be matched. - - Returns - ------- - np.array - The resized background array. - """ - height, width = pixel_array.shape[:2] - mode = "RGBA" if pixel_array.shape[2] == 4 else "RGB" - return self.resize_background_array(background_array, width, height, mode) - - def get_background_array( - self, image: Image.Image | pathlib.Path | str - ) -> PixelArray: - """Gets the background array that has the passed file_name. - - Parameters - ---------- - image - The background image or its file name. - - Returns - ------- - np.ndarray - The pixel array of the image. - """ - image_key = str(image) - - if image_key in self.file_name_to_pixel_array_map: - return self.file_name_to_pixel_array_map[image_key] - if isinstance(image, str): - full_path = get_full_raster_image_path(image) - image = Image.open(full_path) - back_array = np.array(image) - - pixel_array = self.pixel_array - if not np.all(pixel_array.shape == back_array.shape): - back_array = self.resize_background_array_to_match(back_array, pixel_array) - - self.file_name_to_pixel_array_map[image_key] = back_array - return back_array - - def display(self, *cvmobjects: VMobject) -> PixelArray | None: - """Displays the colored VMobjects. - - Parameters - ---------- - *cvmobjects - The VMobjects - - Returns - ------- - np.array - The pixel array with the `cvmobjects` displayed. - """ - batch_image_pairs = it.groupby(cvmobjects, lambda cv: cv.get_background_image()) - curr_array = None - for image, batch in batch_image_pairs: - background_array = self.get_background_array(image) - pixel_array = self.pixel_array - self.camera.display_multiple_non_background_colored_vmobjects( - batch, - pixel_array, - ) - new_array = np.array( - (background_array * pixel_array.astype("float") / 255), - dtype=self.camera.pixel_array_dtype, - ) - if curr_array is None: - curr_array = new_array - else: - curr_array = np.maximum(curr_array, new_array) - self.reset_pixel_array() - return curr_array diff --git a/manim/camera/mapping_camera.py b/manim/camera/mapping_camera.py deleted file mode 100644 index 4d347d02a3..0000000000 --- a/manim/camera/mapping_camera.py +++ /dev/null @@ -1,170 +0,0 @@ -"""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)), - ) diff --git a/manim/camera/moving_camera.py b/manim/camera/moving_camera.py deleted file mode 100644 index 3bb61120d2..0000000000 --- a/manim/camera/moving_camera.py +++ /dev/null @@ -1,292 +0,0 @@ -"""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, - ) diff --git a/manim/camera/multi_camera.py b/manim/camera/multi_camera.py deleted file mode 100644 index 1ccf11fd1b..0000000000 --- a/manim/camera/multi_camera.py +++ /dev/null @@ -1,107 +0,0 @@ -"""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 - ] diff --git a/manim/camera/three_d_camera.py b/manim/camera/three_d_camera.py deleted file mode 100644 index e20512ab9e..0000000000 --- a/manim/camera/three_d_camera.py +++ /dev/null @@ -1,459 +0,0 @@ -"""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) diff --git a/manim/cli/render/commands.py b/manim/cli/render/commands.py index 063c13dcaa..f954545a6b 100644 --- a/manim/cli/render/commands.py +++ b/manim/cli/render/commands.py @@ -95,7 +95,7 @@ def render(**kwargs: Any) -> ClickArgs | dict[str, Any]: _validate_scene_batch_output_name(scene_classes) if config.renderer == RendererType.OPENGL: - from manim.renderer.opengl_renderer import OpenGLRenderer + from manim.renderer.opengl import OpenGLRenderer renderer = OpenGLRenderer() keep_running = True diff --git a/manim/manager.py b/manim/manager.py index 4a1acd4de3..a048908552 100644 --- a/manim/manager.py +++ b/manim/manager.py @@ -13,13 +13,16 @@ from .utils.file_ops import open_media_file if TYPE_CHECKING: + from PIL.Image import Image + from ._config.output import OutputSpec from ._config.render_session import RenderSessionSpec from .animation.animation import Animation - from .camera.camera import Camera from .mobject.mobject import Mobject, _AnimationBuilder - from .renderer.cairo_renderer import CairoRenderer - from .renderer.opengl_renderer import OpenGLCamera, OpenGLRenderer + from .renderer.cairo import CairoRenderer + from .renderer.cairo.camera import Camera + from .renderer.opengl.camera import OpenGLCamera + from .renderer.opengl.renderer import OpenGLRenderer from .scene.scene import Scene from .scene.scene_file_writer import SceneFileWriter @@ -184,6 +187,10 @@ def render(self, preview: bool = False) -> bool: return False + def get_image(self) -> Image: + """Materialize current state without entering a timed/output transaction.""" + return self.renderer._get_scene_image(self.scene) + def setup(self) -> None: """Run the managed scene's :meth:`~manim.scene.scene.Scene.setup` hook.""" self.scene.setup() diff --git a/manim/mobject/mobject.py b/manim/mobject/mobject.py index 0084cb7382..7f43655700 100644 --- a/manim/mobject/mobject.py +++ b/manim/mobject/mobject.py @@ -66,7 +66,7 @@ ) from ..animation.animation import Animation - from ..camera.camera import Camera + from ..renderer.cairo.camera import Camera _TimeBasedUpdater: TypeAlias = Callable[["Mobject", float], object] @@ -997,10 +997,20 @@ def apply_over_attr_arrays(self, func: MultiMappingFunction) -> Self: # Displaying def get_image(self, camera: Camera | None = None) -> Image.Image: - if camera is None: - camera = Camera() - camera.capture_mobject(self) - return camera.get_image() + """Draw this mobject and its submobjects using Cairo and return a PIL image. + + Pass a ``camera`` to select the view, or omit it to create a default + :class:`.Camera`. To use a scene's current view, pass ``camera=scene.camera``. + """ + from manim.renderer.cairo import CairoRenderer + from manim.renderer.cairo.camera import Camera + + renderer = CairoRenderer(camera=Camera() if camera is None else camera) + try: + renderer.render_mobjects([self]) + return renderer.get_image() + finally: + renderer.close() def show(self, camera: Camera | None = None) -> None: self.get_image(camera=camera).show() diff --git a/manim/mobject/opengl/opengl_mobject.py b/manim/mobject/opengl/opengl_mobject.py index b8e33da418..57fcbf9f7c 100644 --- a/manim/mobject/opengl/opengl_mobject.py +++ b/manim/mobject/opengl/opengl_mobject.py @@ -34,7 +34,7 @@ from manim import config, logger from manim.constants import * from manim.data_structures import MethodWithArgs -from manim.renderer.shader_wrapper import get_colormap_code +from manim.renderer.opengl.shader_wrapper import get_colormap_code from manim.typing import ( Point3D, Point3D_Array, @@ -72,7 +72,7 @@ if TYPE_CHECKING: from manim.animation.animation import Animation - from manim.renderer.shader_wrapper import ShaderWrapper + from manim.renderer.opengl.shader_wrapper import ShaderWrapper from manim.typing import ( FloatRGB_Array, FloatRGBA_Array, @@ -3013,7 +3013,7 @@ def refresh_shader_wrapper_id(self) -> Self: return self def get_shader_wrapper(self) -> "ShaderWrapper": # noqa: UP037 - from manim.renderer.shader_wrapper import ShaderWrapper + from manim.renderer.opengl.shader_wrapper import ShaderWrapper # if hasattr(self, "shader_wrapper"): # return self.shader_wrapper diff --git a/manim/mobject/opengl/opengl_vectorized_mobject.py b/manim/mobject/opengl/opengl_vectorized_mobject.py index cc1756d198..62a3c3a053 100644 --- a/manim/mobject/opengl/opengl_vectorized_mobject.py +++ b/manim/mobject/opengl/opengl_vectorized_mobject.py @@ -12,7 +12,7 @@ from manim import config from manim.constants import * from manim.mobject.opengl.opengl_mobject import OpenGLMobject, OpenGLPoint -from manim.renderer.shader_wrapper import ShaderWrapper +from manim.renderer.opengl.shader_wrapper import ShaderWrapper from manim.typing import Point3D, Point3DLike, Point3DLike_Array from manim.utils.bezier import ( bezier, diff --git a/manim/mobject/types/image_mobject.py b/manim/mobject/types/image_mobject.py index 0fc5a8f86d..a834755284 100644 --- a/manim/mobject/types/image_mobject.py +++ b/manim/mobject/types/image_mobject.py @@ -14,7 +14,6 @@ from manim.mobject.geometry.shape_matchers import SurroundingRectangle from ... import config -from ...camera.moving_camera import MovingCamera from ...constants import * from ...mobject.mobject import Mobject from ...utils.bezier import interpolate @@ -34,9 +33,18 @@ import numpy.typing as npt + from manim.renderer.cairo.camera import Camera from manim.typing import PixelArray, StrPath - from ...camera.moving_camera import MovingCamera + +def _validate_resampling_algorithm(resampling_algorithm: int) -> None: + if resampling_algorithm not in RESAMPLING_ALGORITHMS.values(): + raise ValueError( + "resampling_algorithm has to be an int, one of the values defined in " + "RESAMPLING_ALGORITHMS or a Pillow resampling filter constant. " + "Available algorithms: 'bicubic' (or 'cubic'), 'nearest' (or 'none'), " + "'bilinear' (or 'linear').", + ) class AbstractImageMobject(Mobject): @@ -98,14 +106,7 @@ def set_resampling_algorithm(self, resampling_algorithm: int) -> Self: * 'hamming' * 'lanczos' or 'antialias' """ - if resampling_algorithm not in RESAMPLING_ALGORITHMS.values(): - raise ValueError( - "resampling_algorithm has to be an int, one of the values defined in " - "RESAMPLING_ALGORITHMS or a Pillow resampling filter constant. " - "Available algorithms: 'bicubic' (or 'cubic'), 'nearest' (or 'none'), " - "'bilinear' (or 'linear').", - ) - + _validate_resampling_algorithm(resampling_algorithm) self.resampling_algorithm = resampling_algorithm return self @@ -124,7 +125,7 @@ def reset_points(self) -> Self: if self.scale_to_resolution: height = h / self.scale_to_resolution * config["frame_height"] else: - height = 3 # this is the case for ImageMobjectFromCamera + height = 3 self.stretch_to_fit_height(height) self.stretch_to_fit_width(height * w / h) return self @@ -308,16 +309,18 @@ def get_style(self) -> dict[str, Any]: } -# TODO, add the ability to have the dimensions/orientation of this -# mobject more strongly tied to the frame of the camera it contains, -# in the case where that's a MovingCamera +class ImageMobjectFromCamera(Mobject): + """A semantic camera view whose pixels are supplied by CairoRenderer. + The mobject describes where a secondary camera view is composited. It owns + geometry and sampling settings, but it never exposes or stores raster pixels. + """ -class ImageMobjectFromCamera(AbstractImageMobject): def __init__( self, - camera: MovingCamera, + camera: Camera, default_display_frame_config: dict[str, Any] | None = None, + resampling_algorithm: Resampling = Resampling.BICUBIC, **kwargs: Any, ) -> None: self.camera = camera @@ -328,13 +331,31 @@ def __init__( "buff": 0, } self.default_display_frame_config = default_display_frame_config - self.pixel_array = self.camera.pixel_array - super().__init__(scale_to_resolution=False, **kwargs) + self.set_resampling_algorithm(resampling_algorithm) + super().__init__(**kwargs) - # TODO: Get rid of this. - def get_pixel_array(self) -> PixelArray: - self.pixel_array = self.camera.pixel_array - return self.pixel_array + def reset_points(self) -> Self: + """Set the view geometry from the source camera's logical aspect ratio.""" + self.points = np.array( + [ + UP + LEFT, + UP + RIGHT, + DOWN + LEFT, + DOWN + RIGHT, + ], + ) + self.center() + self.stretch_to_fit_height(3) + self.stretch_to_fit_width( + self.height * self.camera.frame_width / self.camera.frame_height, + ) + return self + + def set_resampling_algorithm(self, resampling_algorithm: int) -> Self: + """Set the Pillow sampling algorithm used when compositing the view.""" + _validate_resampling_algorithm(resampling_algorithm) + self.resampling_algorithm = resampling_algorithm + return self def add_display_frame(self, **kwargs: Any) -> Self: config = dict(self.default_display_frame_config) @@ -344,18 +365,10 @@ def add_display_frame(self, **kwargs: Any) -> Self: return self def interpolate_color( - self, mobject1: Mobject, mobject2: Mobject, alpha: float + self, + mobject1: Mobject, + mobject2: Mobject, + alpha: float, ) -> Self: - assert isinstance(mobject1, ImageMobjectFromCamera) - assert isinstance(mobject2, ImageMobjectFromCamera) - assert mobject1.pixel_array.shape == mobject2.pixel_array.shape, ( - f"Mobject pixel array shapes incompatible for interpolation.\n" - f"Mobject 1 ({mobject1}) : {mobject1.pixel_array.shape}\n" - f"Mobject 2 ({mobject2}) : {mobject2.pixel_array.shape}" - ) - self.pixel_array = interpolate( - mobject1.pixel_array, - mobject2.pixel_array, - alpha, - ).astype(self.pixel_array_dtype) + """Keep renderer-supplied camera pixels independent of mobject styling.""" return self diff --git a/manim/opengl/__init__.py b/manim/opengl/__init__.py index e5bad5cd2c..0333865ea5 100644 --- a/manim/opengl/__init__.py +++ b/manim/opengl/__init__.py @@ -14,5 +14,5 @@ from manim.mobject.opengl.opengl_three_dimensions import * from manim.mobject.opengl.opengl_vectorized_mobject import * -from ..renderer.shader import * +from ..renderer.opengl.shader import * from ..utils.opengl import * diff --git a/manim/renderer/cairo/__init__.py b/manim/renderer/cairo/__init__.py new file mode 100644 index 0000000000..8e1eab36fd --- /dev/null +++ b/manim/renderer/cairo/__init__.py @@ -0,0 +1,41 @@ +"""Cairo rendering backend.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from .camera import Camera, MovingCamera, MultiCamera, ThreeDCamera + from .renderer import CairoRenderer + +__all__ = [ + "Camera", + "CairoRenderer", + "MovingCamera", + "MultiCamera", + "ThreeDCamera", +] + + +def __getattr__(name: str) -> Any: + if name not in __all__: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + value: Any + if name == "CairoRenderer": + from .renderer import CairoRenderer + + value = CairoRenderer + else: + from .camera import Camera, MovingCamera, MultiCamera, ThreeDCamera + + camera_classes: dict[str, Any] = { + "Camera": Camera, + "MovingCamera": MovingCamera, + "MultiCamera": MultiCamera, + "ThreeDCamera": ThreeDCamera, + } + value = camera_classes[name] + + globals()[name] = value + return value diff --git a/manim/renderer/cairo/camera.py b/manim/renderer/cairo/camera.py new file mode 100644 index 0000000000..159c204ea6 --- /dev/null +++ b/manim/renderer/cairo/camera.py @@ -0,0 +1,794 @@ +"""Camera views and projection controls for the Cairo renderer.""" + +from __future__ import annotations + +__all__ = ["Camera", "MovingCamera", "MultiCamera", "ThreeDCamera"] + +import operator as op +from collections.abc import Callable, Iterable +from functools import reduce +from typing import TYPE_CHECKING, Any, Literal, overload + +import numpy as np + +from manim._config import config +from manim.constants import DEGREES, DOWN, LEFT, ORIGIN, OUT, RIGHT, UP +from manim.mobject.frame import ScreenRectangle +from manim.mobject.mobject import Mobject, _AnimationBuilder +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.image_mobject import ImageMobjectFromCamera +from manim.mobject.types.point_cloud_mobject import Point +from manim.mobject.types.vectorized_mobject import VMobject +from manim.mobject.value_tracker import ValueTracker +from manim.utils.color import WHITE, ManimColor, ParsableManimColor, get_shaded_rgb +from manim.utils.family import extract_mobject_family_members +from manim.utils.iterables import list_difference_update +from manim.utils.space_ops import rotation_about_z, rotation_matrix + +if TYPE_CHECKING: + from manim.typing import ( + FloatRGBA_Array, + MatrixMN, + Point3D, + Point3D_Array, + Point3DLike, + ) + + +class _CameraFrame(ScreenRectangle): + """Frame whose center is computed directly from its boundary points.""" + + def get_points_defining_boundary(self) -> Point3D_Array: + if self.submobjects or len(self.points) <= 1: + return super().get_points_defining_boundary() + # Use VMobject's boundary anchors directly for a frame without submobjects. + # These also give the bounds when the frame has been deformed. + curves = self.points.reshape( + -1, self.n_points_per_cubic_curve, self.points.shape[1] + ) + return curves[:, [0, -1], :].reshape(-1, self.points.shape[1]) + + def get_center(self) -> Point3D: + points = self.get_points_defining_boundary() + if len(points) == 0: + return np.zeros(self.dim) + # Projecting many small mobjects asks for this center repeatedly. Reduce + # all axes together instead of the generic per-axis critical-point path. + points = points[:, : self.dim] + return ((points.min(axis=0) + points.max(axis=0)) / 2).astype(float, copy=False) + + +class Camera: + """Configure the camera view and background for Cairo rendering. + + A camera has an animatable frame, background settings, and methods for ordering + and projecting mobjects. The renderer is responsible for drawing this view + into an image. + + By default, the frame width is ``config.frame_width`` and its height is derived + from the viewport's pixel aspect ratio. Supplying one frame dimension derives + the other from that ratio; supplying both dimensions or a custom ``frame`` uses + the dimensions you specify. Frame dimensions are measured in Manim units. + """ + + def __init__( + self, + background_image: str | None = None, + frame_center: Point3DLike | Mobject | None = None, + frame: Mobject | None = None, + default_frame_stroke_color: ManimColor = WHITE, + default_frame_stroke_width: float = 0, + use_z_index: bool = True, + frame_height: float | None = None, + frame_width: float | None = None, + background_color: ParsableManimColor | None = None, + background_opacity: float | None = None, + ) -> None: + self.background_image = background_image + self.use_z_index = use_z_index + + if frame is None: + if frame_width is None: + resolved_width = ( + float(config["frame_width"]) + if frame_height is None + else frame_height * config.aspect_ratio + ) + else: + resolved_width = frame_width + resolved_height = ( + resolved_width / config.aspect_ratio + if frame_height is None + else frame_height + ) + if resolved_height <= 0 or resolved_width <= 0: + raise ValueError("Camera frame dimensions must be positive.") + frame = _CameraFrame( + aspect_ratio=resolved_width / resolved_height, + height=resolved_height, + ) + frame.set_stroke( + ManimColor(default_frame_stroke_color), + default_frame_stroke_width, + ) + else: + if frame_height is not None: + if frame_height <= 0: + raise ValueError("Camera frame height must be positive.") + frame.stretch_to_fit_height(frame_height) + if frame_width is not None: + if frame_width <= 0: + raise ValueError("Camera frame width must be positive.") + frame.stretch_to_fit_width(frame_width) + self.frame = frame + if frame_center is not None: + self.frame_center = frame_center + + self._background_color = ManimColor( + config["background_color"] + if background_color is None + else background_color, + ) + self._background_opacity = ( + config["background_opacity"] + if background_opacity is None + else background_opacity + ) + + @property + def background_color(self) -> ManimColor: + return self._background_color + + @background_color.setter + def background_color(self, color: ParsableManimColor) -> None: + self._background_color = ManimColor(color) + + @property + def background_opacity(self) -> float: + return self._background_opacity + + @background_opacity.setter + def background_opacity(self, alpha: float) -> None: + self._background_opacity = alpha + + @property + def frame_height(self) -> float: + """Height of the camera frame in Manim units.""" + return self.frame.height + + @frame_height.setter + def frame_height(self, frame_height: float) -> None: + self.frame.stretch_to_fit_height(frame_height) + + @property + def frame_width(self) -> float: + """Width of the camera frame in Manim units.""" + return self.frame.width + + @frame_width.setter + def frame_width(self, frame_width: float) -> None: + self.frame.stretch_to_fit_width(frame_width) + + @property + def frame_center(self) -> Point3D: + """Center of the camera frame in scene coordinates.""" + return self.frame.get_center() + + @frame_center.setter + def frame_center(self, frame_center: Point3DLike | Mobject) -> None: + self.frame.move_to(frame_center) + + def get_mobjects_to_display( + self, + mobjects: Iterable[Mobject], + include_submobjects: bool = True, + excluded_mobjects: list[Mobject] | None = None, + ) -> list[Mobject]: + """Return the mobjects and included submobjects in drawing order.""" + if include_submobjects: + mobjects = extract_mobject_family_members( + mobjects, + use_z_index=self.use_z_index, + only_those_with_points=True, + ) + if excluded_mobjects: + all_excluded = extract_mobject_family_members( + excluded_mobjects, + use_z_index=self.use_z_index, + ) + mobjects = list_difference_update(mobjects, all_excluded) + return list(mobjects) + + def is_in_frame(self, mobject: Mobject) -> bool: + """Whether ``mobject`` intersects the camera frame's bounds.""" + center = self.frame_center + height = self.frame_height + width = self.frame_width + return not reduce( + op.or_, + [ + mobject.get_right()[0] < center[0] - width / 2, + mobject.get_bottom()[1] > center[1] + height / 2, + mobject.get_left()[0] > center[0] + width / 2, + mobject.get_top()[1] < center[1] - height / 2, + ], + ) + + def get_mobjects_indicating_movement(self) -> list[Mobject]: + """Return mobjects whose animation requires the scene to be redrawn. + + The scene uses these controls to decide whether it can reuse an image of + its stationary mobjects during an animation. + """ + return [self.frame] + + @overload + def auto_zoom( + self, + mobjects: Iterable[Mobject], + margin: float = 0, + only_mobjects_in_frame: bool = False, + animate: Literal[False] = False, + ) -> Mobject: ... + + @overload + def auto_zoom( + self, + mobjects: Iterable[Mobject], + margin: float = 0, + only_mobjects_in_frame: bool = False, + animate: Literal[True] = True, + ) -> _AnimationBuilder: ... + + def auto_zoom( + self, + mobjects: Iterable[Mobject], + margin: float = 0, + only_mobjects_in_frame: bool = False, + animate: bool = True, + ) -> _AnimationBuilder | Mobject: + """Move and resize the frame to contain the supplied 2D mobjects.""" + ( + left, + right, + top, + bottom, + ) = self._get_bounding_box(mobjects, only_mobjects_in_frame) + x = (left + right) / 2 + y = (top + bottom) / 2 + new_width = abs(left - right) + new_height = abs(top - bottom) + target = self.frame.animate if animate else self.frame + if new_width / self.frame.width > new_height / self.frame.height: + return target.set_x(x).set_y(y).set(width=new_width + margin) + return 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]: + bounds: tuple[float, float, float, float] | None = None + for mobject in mobjects: + if mobject is self.frame or ( + only_mobjects_in_frame and not self.is_in_frame(mobject) + ): + continue + mobject_bounds = ( + float(mobject.get_critical_point(LEFT)[0]), + float(mobject.get_critical_point(RIGHT)[0]), + float(mobject.get_critical_point(UP)[1]), + float(mobject.get_critical_point(DOWN)[1]), + ) + if bounds is None: + bounds = mobject_bounds + else: + bounds = ( + min(bounds[0], mobject_bounds[0]), + max(bounds[1], mobject_bounds[1]), + max(bounds[2], mobject_bounds[2]), + min(bounds[3], mobject_bounds[3]), + ) + if bounds is None: + raise ValueError( + "Could not determine the bounding box of the mobjects given to " + "Camera.auto_zoom().", + ) + return bounds + + def _prepare_for_render(self) -> None: + """Update derived camera values before drawing.""" + + def get_view_transform_center(self) -> Point3D: + """Return the center applied by the renderer's 2D view transform.""" + return self.frame_center + + def get_stroke_rgbas( + self, + vmobject: VMobject, + background: bool = False, + ) -> FloatRGBA_Array: + """Return stroke colors after camera-specific shading.""" + return vmobject.get_stroke_rgbas(background) + + def get_fill_rgbas(self, vmobject: VMobject) -> FloatRGBA_Array: + """Return fill colors after camera-specific shading.""" + return vmobject.get_fill_rgbas() + + def transform_points_pre_display( + self, + mobject: Mobject, + points: Point3D_Array, + ) -> Point3D_Array: + """Project the mobject's points for display.""" + if not np.all(np.isfinite(points)): + return np.zeros((1, 3)) + return points + + +class MovingCamera(Camera): + """Named camera subclass with the standard movable-frame behavior.""" + + +class MultiCamera(Camera): + """Describe a primary view with camera-backed image mobjects.""" + + def __init__( + self, + image_mobjects_from_cameras: Iterable[ImageMobjectFromCamera] | None = None, + **kwargs: Any, + ) -> None: + self.image_mobjects_from_cameras: list[ImageMobjectFromCamera] = [] + if image_mobjects_from_cameras is not None: + for image_mobject in image_mobjects_from_cameras: + self.add_image_mobject_from_camera(image_mobject) + super().__init__(**kwargs) + + def add_image_mobject_from_camera( + self, + image_mobject_from_camera: ImageMobjectFromCamera, + ) -> None: + """Register a display whose image is rendered from another camera.""" + if not isinstance(image_mobject_from_camera.camera, Camera): + raise TypeError("Nested Cairo views require a Cairo Camera.") + self.image_mobjects_from_cameras.append(image_mobject_from_camera) + + def get_mobjects_indicating_movement(self) -> list[Mobject]: + """Return camera controls for the primary and nested views.""" + + def collect(camera: Camera, visited: set[int]) -> list[Mobject]: + if id(camera) in visited: + return [] + visited.add(id(camera)) + if not isinstance(camera, MultiCamera): + return camera.get_mobjects_indicating_movement() + + indicators = Camera.get_mobjects_indicating_movement(camera) + for image_mobject in camera.image_mobjects_from_cameras: + indicators.extend(collect(image_mobject.camera, visited)) + return indicators + + return collect(self, set()) + + +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. + """ + 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.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() + + def _prepare_for_render(self) -> None: + self.reset_rotation_matrix() + + def get_view_transform_center(self) -> Point3D: + # project_points() already translates by frame_center. + return ORIGIN.copy() + + def get_mobjects_indicating_movement(self) -> list[Mobject]: + return [self.frame, self.light_source, *self.get_value_trackers()] + + 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] + if self.exponential_projection: + # Proper projection would involve multiplying x and y by d / (d-z). + # The exponential avoids artifacts for high positive z values. + 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 + scale = factor * zoom + for i in 0, 1: + points[:, i] *= scale + 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) diff --git a/manim/renderer/cairo/renderer.py b/manim/renderer/cairo/renderer.py new file mode 100644 index 0000000000..ea85d5a470 --- /dev/null +++ b/manim/renderer/cairo/renderer.py @@ -0,0 +1,419 @@ +from __future__ import annotations + +from collections.abc import Iterable +from typing import TYPE_CHECKING, Any + +from PIL import Image + +from manim.utils.hashing import get_hash_from_play_call + +from ... import config, logger +from ..._config.video_encoder import video_encoder_fingerprint +from ...mobject.mobject import Mobject, _AnimationBuilder +from ...mobject.types.image_mobject import ImageMobjectFromCamera +from ...scene.scene_file_writer import SceneFileWriter +from ...utils.exceptions import EndSceneEarlyException +from ...utils.iterables import list_update +from ..protocol import RendererCapabilities +from .camera import Camera, MultiCamera +from .rendering import _CairoDrawingContext +from .target import _CairoRasterSettings, _CairoRenderTarget + +if TYPE_CHECKING: + from manim._config.render_session import RenderSessionSpec + from manim.animation.animation import Animation + from manim.scene.scene import Scene + from manim.scene.scene_file_writer import _SceneFileWriterSettings + + from ...typing import RGBAPixelArray + +__all__ = ["CairoRenderer"] + + +class CairoRenderer: + """A renderer using Cairo. + + Cameras supplied to this renderer contain semantic view/projection state only. + CairoRenderer owns all pixel arrays, PyCairo contexts, nested targets, drawing, + static raster reuse, and readback. + """ + + capabilities = RendererCapabilities(live_preview=False) + + def __init__( + self, + file_writer_class: type[SceneFileWriter] = SceneFileWriter, + camera_class: type[Camera] | None = None, + camera: Camera | None = None, + skip_animations: bool = False, + *, + _raster_settings: _CairoRasterSettings | None = None, + ) -> None: + if camera is not None and camera_class is not None: + raise ValueError("Pass either camera or camera_class, not both.") + self._file_writer_class = file_writer_class + camera_cls = camera_class if camera_class is not None else Camera + self.camera = camera if camera is not None else camera_cls() + self._original_skipping_status = skip_animations + self.skip_animations = skip_animations + self.animations_hashes: list[str | None] = [] + self.num_plays = 0 + self.time = 0.0 + self._frame_rate = float(config["frame_rate"]) + settings = _raster_settings or _CairoRasterSettings( + pixel_width=int(config["pixel_width"]), + pixel_height=int(config["pixel_height"]), + base_pixel_width=int(config["pixel_width"]), + base_pixel_height=int(config["pixel_height"]), + ) + self._target = _CairoRenderTarget(settings) + self._sub_targets: dict[int, _CairoRenderTarget] = {} + self._camera_view_pixels: dict[int, RGBAPixelArray] = {} + self.static_image: RGBAPixelArray | None = None + self._render_all_mobjects = False + self._closed = False + + def _ensure_open(self) -> None: + if self._closed: + raise RuntimeError("The Cairo renderer is closed.") + + def init_scene( + self, + scene: Scene, + session_spec: RenderSessionSpec, + file_writer_settings: _SceneFileWriterSettings, + ) -> None: + self._ensure_open() + self.file_writer: Any = self._file_writer_class(file_writer_settings) + + def play( + self, + scene: Scene, + *args: Animation | Mobject | _AnimationBuilder, + **kwargs: Any, + ) -> None: + self._ensure_open() + self.skip_animations = self._original_skipping_status + self.update_skipping_status() + scene.compile_animation_data(*args, **kwargs) + + if self.skip_animations: + logger.debug(f"Skipping animation {self.num_plays}") + hash_current_animation = None + self.time += scene.duration + else: + if config["disable_caching"]: + logger.info("Caching disabled.") + hash_current_animation = f"uncached_{self.num_plays:05}" + else: + assert scene.animations is not None + hash_current_animation = get_hash_from_play_call( + scene, + self.camera, + scene.animations, + scene.mobjects, + backend="cairo", + encoder_fingerprint=video_encoder_fingerprint( + scene.session_spec.video_encoder, + ), + renderer_state=(), + ) + if self.file_writer.is_already_cached(hash_current_animation): + logger.info( + f"Animation {self.num_plays} : Using cached data (hash : %(hash_current_animation)s)", + {"hash_current_animation": hash_current_animation}, + ) + self.skip_animations = True + self.time += scene.duration + self.file_writer.add_partial_movie_file(hash_current_animation) + self.animations_hashes.append(hash_current_animation) + logger.debug( + "List of the first few animation hashes of the scene: %(h)s", + {"h": str(self.animations_hashes[:5])}, + ) + + self.file_writer.begin_animation( + not self.skip_animations, + animation_index=self.num_plays, + ) + scene.begin_animations() + self.save_static_frame_data(scene, scene.static_mobjects) + + if scene.is_current_animation_frozen_frame(): + self.update_frame(scene, mobjects=scene.moving_mobjects) + self.freeze_current_frame(scene.duration) + else: + scene.play_internal() + self.file_writer.end_animation(not self.skip_animations) + self.num_plays += 1 + + def _sub_target_for( + self, + image_mobject: ImageMobjectFromCamera, + *, + parent_camera: Camera, + parent_target: _CairoRenderTarget, + ) -> _CairoRenderTarget: + parent_settings = parent_target.settings + pixel_height = max( + 1, + int( + parent_settings.pixel_height + * image_mobject.height + / parent_camera.frame_height + ), + ) + pixel_width = max( + 1, + int( + parent_settings.pixel_width + * image_mobject.width + / parent_camera.frame_width + ), + ) + key = id(image_mobject) + target = self._sub_targets.get(key) + if target is not None and ( + target.settings.pixel_width != pixel_width + or target.settings.pixel_height != pixel_height + ): + target.close() + target = None + if target is None: + target = _CairoRenderTarget( + parent_settings.resized( + pixel_width=pixel_width, + pixel_height=pixel_height, + ), + ) + self._sub_targets[key] = target + return target + + def _render_camera( + self, + *, + camera: Camera, + target: _CairoRenderTarget, + mobjects: Iterable[Mobject], + include_submobjects: bool, + excluded_mobjects: list[Mobject] | None, + camera_stack: tuple[int, ...], + ) -> None: + camera_id = id(camera) + if camera_id in camera_stack: + raise RuntimeError("Cairo camera views cannot contain a composition cycle.") + next_stack = (*camera_stack, camera_id) + mobject_list = list(mobjects) + + if isinstance(camera, MultiCamera): + for image_mobject in camera.image_mobjects_from_cameras: + sub_target = self._sub_target_for( + image_mobject, + parent_camera=camera, + parent_target=target, + ) + sub_target.reset(image_mobject.camera) + sub_excluded_mobjects = list_update( + list(excluded_mobjects or []), + [image_mobject], + ) + self._render_camera( + camera=image_mobject.camera, + target=sub_target, + mobjects=mobject_list, + include_submobjects=include_submobjects, + excluded_mobjects=sub_excluded_mobjects, + camera_stack=next_stack, + ) + self._camera_view_pixels[id(image_mobject)] = sub_target.pixels + + def resolve_image( + image_mobject: ImageMobjectFromCamera, + ) -> RGBAPixelArray | None: + return self._camera_view_pixels.get(id(image_mobject)) + + _CairoDrawingContext( + camera=camera, + target=target, + image_resolver=resolve_image, + ).draw( + mobject_list, + include_submobjects=include_submobjects, + excluded_mobjects=excluded_mobjects, + ) + + def _draw_frame( + self, + *, + camera: Camera, + mobjects: Iterable[Mobject], + include_submobjects: bool = True, + excluded_mobjects: list[Mobject] | None = None, + ) -> None: + self._camera_view_pixels.clear() + try: + self._render_camera( + camera=camera, + target=self._target, + mobjects=mobjects, + include_submobjects=include_submobjects, + excluded_mobjects=excluded_mobjects, + camera_stack=(), + ) + finally: + # Only completed views lend pixels to this frame. Retire targets for + # removed views and any view whose drawing failed before completion. + unused = self._sub_targets.keys() - self._camera_view_pixels.keys() + for key in unused: + self._sub_targets.pop(key).close() + + def update_frame( + self, + scene: Scene, + mobjects: Iterable[Mobject] | None = None, + include_submobjects: bool = True, + ignore_skipping: bool = True, + **kwargs: Any, + ) -> None: + """Render one scene state into the owned Cairo target.""" + self._ensure_open() + if self.skip_animations and not ignore_skipping: + return + if not mobjects: + mobjects = list_update(scene.mobjects, scene.foreground_mobjects) + if self.static_image is not None: + self._target.set_pixels(self.static_image) + else: + self._target.reset(self.camera) + + self._draw_frame( + camera=self.camera, + mobjects=mobjects, + include_submobjects=include_submobjects, + excluded_mobjects=kwargs.get("excluded_mobjects"), + ) + + def render_mobjects( + self, + mobjects: Iterable[Mobject], + *, + camera: Camera | None = None, + ) -> None: + """Render explicit mobjects for direct image materialization.""" + self._ensure_open() + render_camera = self.camera if camera is None else camera + self._target.reset(render_camera) + self._draw_frame(camera=render_camera, mobjects=mobjects) + + def render( + self, + scene: Scene, + time: float, + moving_mobjects: Iterable[Mobject] | None = None, + ) -> None: + if self._render_all_mobjects: + moving_mobjects = None + self.update_frame(scene, moving_mobjects) + self.add_frame(self.get_frame()) + + def get_frame(self) -> RGBAPixelArray: + """Return a fresh owned top-left-origin RGBA frame.""" + return self._target.read_pixels() + + def _get_scene_image(self, scene: Scene) -> Image.Image: + """Draw current state in an independent scope, even after raster cleanup.""" + renderer = CairoRenderer( + camera=self.camera, _raster_settings=self._target.settings + ) + try: + renderer.render_mobjects( + list_update(scene.mobjects, scene.foreground_mobjects) + ) + return renderer.get_image() + finally: + renderer.close() + + def get_image(self) -> Image.Image: + """Return the current target as a PIL image.""" + return Image.fromarray(self.get_frame()) + + def add_frame(self, frame: RGBAPixelArray, num_frames: int = 1) -> None: + self._ensure_open() + if self.skip_animations: + return + self.time += num_frames / self._frame_rate + self.file_writer.write_frame(frame, repeat=num_frames) + + def freeze_current_frame(self, duration: float) -> None: + self.add_frame( + self.get_frame(), + num_frames=int(duration * self._frame_rate), + ) + + def show_frame(self, scene: Scene) -> None: + self.update_frame(scene, ignore_skipping=True) + self.get_image().show() + + def save_static_frame_data( + self, + scene: Scene, + static_mobjects: Iterable[Mobject], + ) -> RGBAPixelArray | None: + self._ensure_open() + self.static_image = None + # A nested view can contain any dynamic scene mobject regardless of the + # view's position in the primary draw order. Keep all primary inputs + # dynamic until the Manager cutover supplies explicit dynamic roots. + self._render_all_mobjects = isinstance(self.camera, MultiCamera) and bool( + scene.moving_mobjects, + ) + if self._render_all_mobjects or not static_mobjects: + return None + self.update_frame(scene, mobjects=static_mobjects) + self.static_image = self.get_frame() + return self.static_image + + def update_skipping_status(self) -> None: + if self.file_writer.sections[-1].skip_animations: + self.skip_animations = True + if self.file_writer.output_spec.is_still: + self.skip_animations = True + if ( + config.from_animation_number > 0 + and self.num_plays < config.from_animation_number + ): + self.skip_animations = True + if ( + config.upto_animation_number >= 0 + and self.num_plays > config.upto_animation_number + ): + self.skip_animations = True + raise EndSceneEarlyException() + + def scene_finished(self, scene: Scene) -> None: + self._ensure_open() + output = self.file_writer.output_spec + if self.num_plays and (output.is_video or output.is_image_sequence): + self.file_writer.finish() + elif not self.num_plays: + self.static_image = None + self.update_frame(scene) + + if output.is_still or (not self.num_plays and output.fallback_to_still): + if self.num_plays: + self.static_image = None + self.update_frame(scene) + self.file_writer.save_image(self.get_frame()) + + def close(self) -> None: + """Release all Cairo targets and static pixels; subsequent drawing fails.""" + if self._closed: + return + self._closed = True + self._target.close() + for target in self._sub_targets.values(): + target.close() + self._sub_targets.clear() + self._camera_view_pixels.clear() + self.static_image = None + self._render_all_mobjects = False diff --git a/manim/renderer/cairo/rendering.py b/manim/renderer/cairo/rendering.py new file mode 100644 index 0000000000..1950d0979e --- /dev/null +++ b/manim/renderer/cairo/rendering.py @@ -0,0 +1,480 @@ +"""Private drawing helpers for :class:`~manim.renderer.cairo.CairoRenderer`.""" + +from __future__ import annotations + +import itertools as it +import operator as op +from collections.abc import Callable, Iterable +from functools import reduce +from typing import TYPE_CHECKING, Any + +import cairo +import numpy as np +from PIL import Image + +from manim.constants import CapStyleType, LineJointType +from manim.mobject.mobject import Mobject +from manim.mobject.types.image_mobject import ( + AbstractImageMobject, + ImageMobjectFromCamera, +) +from manim.mobject.types.point_cloud_mobject import PMobject +from manim.mobject.types.vectorized_mobject import VMobject +from manim.utils.space_ops import cross2d + +from .target import _CairoRenderTarget + +if TYPE_CHECKING: + from manim.typing import ( + FloatRGBA_Array, + FloatRGBALike_Array, + Point3D_Array, + RGBAPixelArray, + ) + + from .camera import Camera + +_LINE_JOIN_MAP = { + LineJointType.AUTO: None, + LineJointType.ROUND: cairo.LineJoin.ROUND, + LineJointType.BEVEL: cairo.LineJoin.BEVEL, + LineJointType.MITER: cairo.LineJoin.MITER, +} + +_CAP_STYLE_MAP = { + CapStyleType.AUTO: None, + CapStyleType.ROUND: cairo.LineCap.ROUND, + CapStyleType.BUTT: cairo.LineCap.BUTT, + CapStyleType.SQUARE: cairo.LineCap.SQUARE, +} + +_ImageResolver = Callable[[ImageMobjectFromCamera], np.ndarray | None] + + +class _CairoDrawingContext: + """Draw mobjects into one renderer-owned target using one semantic camera.""" + + def __init__( + self, + *, + camera: Camera, + target: _CairoRenderTarget, + image_resolver: _ImageResolver, + ) -> None: + self.camera = camera + self.target = target + self.image_resolver = image_resolver + + def draw( + self, + mobjects: Iterable[Mobject], + *, + include_submobjects: bool = True, + excluded_mobjects: list[Mobject] | None = None, + ) -> None: + self.target._ensure_open() + display_funcs: dict[type[Mobject], Callable[[list[Any]], None]] = { + VMobject: self._display_vectorized_mobjects, + PMobject: self._display_point_cloud_mobjects, + ImageMobjectFromCamera: self._display_image_mobjects, + AbstractImageMobject: self._display_image_mobjects, + Mobject: lambda batch: None, + } + + def type_or_raise(mobject: Mobject) -> type[Mobject]: + for mobject_type in display_funcs: + if isinstance(mobject, mobject_type): + return mobject_type + raise TypeError( + f"Displaying an object of class {type(mobject).__name__} is not supported", + ) + + self.camera._prepare_for_render() + to_display = self.camera.get_mobjects_to_display( + mobjects, + include_submobjects=include_submobjects, + excluded_mobjects=excluded_mobjects, + ) + for group_type, group in it.groupby(to_display, type_or_raise): + display_funcs[group_type](list(group)) + + def _display_vectorized_mobjects(self, vmobjects: list[VMobject]) -> None: + if not vmobjects: + return + for image, batch in it.groupby( + vmobjects, + lambda vmobject: vmobject.get_background_image(), + ): + if image: + self._display_background_colored_vmobjects(list(batch)) + else: + self._display_non_background_colored_vmobjects(batch) + + def _display_non_background_colored_vmobjects( + self, + vmobjects: Iterable[VMobject], + ) -> None: + context = self.target.get_context(self.camera) + for vmobject in vmobjects: + self._display_vectorized(vmobject, context) + + def _display_vectorized( + self, + vmobject: VMobject, + context: cairo.Context, + ) -> None: + self._set_cairo_context_path(context, vmobject) + self._apply_stroke(context, vmobject, background=True) + self._apply_fill(context, vmobject) + self._apply_stroke(context, vmobject) + + def _set_cairo_context_path( + self, + context: cairo.Context, + vmobject: VMobject, + ) -> None: + points = self.camera.transform_points_pre_display(vmobject, vmobject.points) + if len(points) == 0: + return + + nppcc = vmobject.n_points_per_cubic_curve + split_indices = vmobject.get_subpath_split_indices_from_points(points, n_dims=2) + if len(split_indices) == 0: + return + + points_xy = points[:, :2].ravel() + context.new_path() + move_to = context.move_to + curve_to = context.curve_to + new_sub_path = context.new_sub_path + close_path = context.close_path + + for start_index, end_index in split_indices: + start_index = int(start_index) + end_index = int(end_index) + if end_index - start_index < nppcc: + continue + + new_sub_path() + base = start_index * 2 + move_to(points_xy[base], points_xy[base + 1]) + for index in range( + start_index, + end_index - nppcc + 1, + nppcc, + ): + handle = (index + 1) * 2 + curve_to( + points_xy[handle], + points_xy[handle + 1], + points_xy[handle + 2], + points_xy[handle + 3], + points_xy[handle + 4], + points_xy[handle + 5], + ) + if vmobject.consider_points_equals_2d( + points[start_index], + points[end_index - 1], + ): + close_path() + + def _set_cairo_context_color( + self, + context: cairo.Context, + rgbas: FloatRGBALike_Array, + vmobject: VMobject, + ) -> None: + if len(rgbas) == 1: + context.set_source_rgba(*rgbas[0][2::-1], rgbas[0][3]) + return + + points = vmobject.get_gradient_start_and_end_points() + points = self.camera.transform_points_pre_display(vmobject, points) + pattern = cairo.LinearGradient( + *it.chain(*(point[:2] for point in points)), + ) + for rgba, offset in zip( + rgbas, + np.linspace(0, 1, len(rgbas)), + strict=True, + ): + pattern.add_color_stop_rgba(offset, *rgba[2::-1], rgba[3]) + context.set_source(pattern) + + def _apply_fill(self, context: cairo.Context, vmobject: VMobject) -> None: + self._set_cairo_context_color( + context, + self.camera.get_fill_rgbas(vmobject), + vmobject, + ) + context.fill_preserve() + + def _apply_stroke( + self, + context: cairo.Context, + vmobject: VMobject, + background: bool = False, + ) -> None: + width = vmobject.get_stroke_width(background) + if width == 0: + return + self._set_cairo_context_color( + context, + self.camera.get_stroke_rgbas(vmobject, background=background), + vmobject, + ) + context.set_line_width( + width * self.target.settings.cairo_line_width_multiple, + ) + if vmobject.joint_type != LineJointType.AUTO: + context.set_line_join(_LINE_JOIN_MAP[vmobject.joint_type]) + if vmobject.cap_style != CapStyleType.AUTO: + context.set_line_cap(_CAP_STYLE_MAP[vmobject.cap_style]) + context.stroke_preserve() + + def _display_background_colored_vmobjects( + self, + vmobjects: list[VMobject], + ) -> None: + scratch = self.target.get_scratch_target() + scratch_context = _CairoDrawingContext( + camera=self.camera, + target=scratch, + image_resolver=self.image_resolver, + ) + current: RGBAPixelArray | None = None + for image, batch in it.groupby( + vmobjects, + lambda vmobject: vmobject.get_background_image(), + ): + scratch.clear() + scratch_context._display_non_background_colored_vmobjects(batch) + background = self.target.get_background_image(image) + colored = np.asarray( + background * scratch.pixels.astype(float) / 255, + dtype=np.uint8, + ) + current = colored if current is None else np.maximum(current, colored) + if current is not None: + self._overlay_rgba_array(self.target.pixels, current) + + def _display_point_cloud_mobjects(self, pmobjects: list[PMobject]) -> None: + for pmobject in pmobjects: + self._display_point_cloud( + pmobject, + pmobject.points, + pmobject.rgbas, + self._adjusted_thickness(pmobject.stroke_width), + ) + + def _display_point_cloud( + self, + pmobject: PMobject, + points: Point3D_Array, + rgbas: FloatRGBA_Array, + thickness: float, + ) -> None: + if len(points) == 0: + return + pixel_coords = self._points_to_pixel_coords(pmobject, points) + pixel_coords = self._thickened_coordinates(pixel_coords, thickness) + pixel_array = self.target.pixels + rgba_len = pixel_array.shape[2] + + int_rgbas = (255 * rgbas).astype(np.uint8) + target_len = len(pixel_coords) + factor = target_len // len(int_rgbas) + int_rgbas = np.array([int_rgbas] * factor).reshape((target_len, rgba_len)) + + on_screen_indices = self._on_screen_pixels(pixel_coords) + pixel_coords = pixel_coords[on_screen_indices] + int_rgbas = int_rgbas[on_screen_indices] + + height = self.target.settings.pixel_height + width = self.target.settings.pixel_width + flattener = np.array([1, width], dtype="int").reshape((2, 1)) + indices = np.dot(pixel_coords, flattener)[:, 0].astype("int") + flattened = pixel_array.reshape((height * width, rgba_len)) + flattened[indices] = int_rgbas + pixel_array[:, :] = flattened.reshape((height, width, rgba_len)) + + def _display_image_mobjects( + self, + image_mobjects: list[AbstractImageMobject | ImageMobjectFromCamera], + ) -> None: + for image_mobject in image_mobjects: + self._display_image_mobject(image_mobject) + + def _display_image_mobject( + self, + image_mobject: AbstractImageMobject | ImageMobjectFromCamera, + ) -> None: + source_pixels = ( + self.image_resolver(image_mobject) + if isinstance(image_mobject, ImageMobjectFromCamera) + else image_mobject.get_pixel_array() + ) + if source_pixels is None: + return + sub_image = Image.fromarray(source_pixels, mode="RGBA") + original_coords = np.array( + [ + [0, 0], + [sub_image.width, 0], + [0, sub_image.height], + [sub_image.width, sub_image.height], + ], + ) + target_coords = self._points_to_subpixel_coords( + image_mobject, + image_mobject.points, + ) + int_target_coords = target_coords.astype(np.int64) + shift_vector = np.array( + [ + min(x for x, _ in int_target_coords), + min(y for _, y in int_target_coords), + ], + ) + target_coords -= shift_vector + int_target_coords -= shift_vector + target_size = ( + max(x for x, _ in int_target_coords), + max(y for _, y in int_target_coords), + ) + if min(target_size) <= 0: + return + + ordered_vertices = [target_coords[index] for index in (0, 1, 3, 2)] + sides = [ + ordered_vertices[(index + 1) % 4] - ordered_vertices[index] + for index in range(4) + ] + side_lengths = np.linalg.norm(sides, axis=1) + longest_index = int(np.argmax(side_lengths)) + longest_side = sides[longest_index] + longest_length = side_lengths[longest_index] + if longest_length == 0: + return + previous_side = sides[(longest_index - 1) % 4] + next_side = sides[(longest_index - 1) % 4] + height_1 = abs(cross2d(longest_side, previous_side)) / longest_length + height_2 = abs(cross2d(longest_side, next_side)) / longest_length + if max(height_1, height_2) < 0.5: + return + + homography_matrix = [] + for (x, y), (target_x, target_y) in zip( + target_coords, + original_coords, + strict=True, + ): + homography_matrix.append( + [x, y, 1, 0, 0, 0, -target_x * x, -target_x * y], + ) + homography_matrix.append( + [0, 0, 0, x, y, 1, -target_y * x, -target_y * y], + ) + matrix = np.array(homography_matrix, dtype=np.float64) + target = original_coords.reshape(8).astype(np.float64) + try: + coefficients = np.linalg.solve(matrix, target) + except np.linalg.LinAlgError: + return + + sub_image = sub_image.transform( + size=target_size, + method=Image.Transform.PERSPECTIVE, + data=coefficients, + resample=image_mobject.resampling_algorithm, + ) + settings = self.target.settings + full_image = Image.new( + "RGBA", + (settings.pixel_width, settings.pixel_height), + (0, 0, 0, 0), + ) + full_image.paste( + sub_image, + box=( + int(shift_vector[0]), + int(shift_vector[1]), + int(shift_vector[0] + target_size[0]), + int(shift_vector[1] + target_size[1]), + ), + ) + self._overlay_pil_image(self.target.pixels, full_image) + + def _overlay_rgba_array( + self, + pixel_array: RGBAPixelArray, + new_array: RGBAPixelArray, + ) -> None: + self._overlay_pil_image(pixel_array, Image.fromarray(new_array)) + + @staticmethod + def _overlay_pil_image( + pixel_array: RGBAPixelArray, + image: Image.Image, + ) -> None: + pixel_array[:, :] = np.asarray( + Image.alpha_composite(Image.fromarray(pixel_array), image), + dtype=np.uint8, + ) + + def _points_to_subpixel_coords( + self, + mobject: Mobject, + points: Point3D_Array, + ) -> np.ndarray: + points = self.camera.transform_points_pre_display(mobject, points) + shifted_points = points - self.camera.frame_center + settings = self.target.settings + result = np.zeros((len(points), 2)) + result[:, 0] = ( + shifted_points[:, 0] * settings.pixel_width / self.camera.frame_width + + settings.pixel_width / 2 + ) + result[:, 1] = ( + -shifted_points[:, 1] * settings.pixel_height / self.camera.frame_height + + settings.pixel_height / 2 + ) + return result + + def _points_to_pixel_coords( + self, + mobject: Mobject, + points: Point3D_Array, + ) -> np.ndarray: + return self._points_to_subpixel_coords(mobject, points).astype(np.int64) + + def _on_screen_pixels(self, pixel_coords: np.ndarray) -> np.ndarray: + settings = self.target.settings + return reduce( + op.and_, + [ + pixel_coords[:, 0] >= 0, + pixel_coords[:, 0] < settings.pixel_width, + pixel_coords[:, 1] >= 0, + pixel_coords[:, 1] < settings.pixel_height, + ], + ) + + def _adjusted_thickness(self, thickness: float) -> float: + settings = self.target.settings + base_sum = settings.base_pixel_height + settings.base_pixel_width + target_sum = settings.pixel_height + settings.pixel_width + return 1 + (thickness - 1) * base_sum / target_sum + + @staticmethod + def _thickened_coordinates( + pixel_coords: np.ndarray, + thickness: float, + ) -> np.ndarray: + thickness = int(thickness) + coordinate_range = list( + range(-thickness // 2 + 1, thickness // 2 + 1), + ) + nudges = np.array(list(it.product(coordinate_range, coordinate_range))) + thickened = np.array([pixel_coords + nudge for nudge in nudges]) + return thickened.reshape((thickened.size // 2, 2)) diff --git a/manim/renderer/cairo/target.py b/manim/renderer/cairo/target.py new file mode 100644 index 0000000000..00ec5f80ae --- /dev/null +++ b/manim/renderer/cairo/target.py @@ -0,0 +1,213 @@ +"""Private raster-target primitives for :mod:`manim.renderer.cairo`.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING + +import cairo +import numpy as np +from PIL import Image + +from manim.utils.color import color_to_int_rgba +from manim.utils.images import get_full_raster_image_path + +if TYPE_CHECKING: + from manim.typing import RGBAPixelArray + + from .camera import Camera + + +@dataclass(frozen=True, slots=True) +class _CairoRasterSettings: + """Immutable dimensions and scaling used by one Cairo raster target.""" + + pixel_width: int + pixel_height: int + base_pixel_width: int + base_pixel_height: int + cairo_line_width_multiple: float = 0.01 + + def __post_init__(self) -> None: + if ( + min( + self.pixel_width, + self.pixel_height, + self.base_pixel_width, + self.base_pixel_height, + ) + <= 0 + ): + raise ValueError("Cairo raster dimensions must be positive.") + + def resized(self, *, pixel_width: int, pixel_height: int) -> _CairoRasterSettings: + return _CairoRasterSettings( + pixel_width=pixel_width, + pixel_height=pixel_height, + base_pixel_width=self.base_pixel_width, + base_pixel_height=self.base_pixel_height, + cairo_line_width_multiple=self.cairo_line_width_multiple, + ) + + +class _CairoRenderTarget: + """Own one top-left-origin RGBA target and its PyCairo context.""" + + def __init__(self, settings: _CairoRasterSettings) -> None: + self.settings = settings + self._pixels = np.zeros( + (settings.pixel_height, settings.pixel_width, 4), + dtype=np.uint8, + ) + self._background_key: tuple[object, ...] | None = None + self._background = np.zeros_like(self._pixels) + self._context_key: tuple[float, ...] | None = None + self._context: cairo.Context | None = None + self._scratch_target: _CairoRenderTarget | None = None + self.background_image_cache: dict[str, RGBAPixelArray] = {} + self._closed = False + + def _ensure_open(self) -> None: + if self._closed: + raise RuntimeError("The Cairo render target is closed.") + + @property + def pixels(self) -> RGBAPixelArray: + self._ensure_open() + return self._pixels + + def _camera_background_key(self, camera: Camera) -> tuple[object, ...]: + return ( + camera.background_image, + tuple( + camera.background_color.to_rgba_with_alpha( + camera.background_opacity, + ), + ), + ) + + def _load_background(self, camera: Camera) -> RGBAPixelArray: + settings = self.settings + if camera.background_image is None: + background = np.empty_like(self._pixels) + background[:, :] = color_to_int_rgba( + camera.background_color, + camera.background_opacity, + ) + return background + + path = get_full_raster_image_path(camera.background_image) + with Image.open(path) as source: + image = source.convert("RGBA") + if image.size != (settings.pixel_width, settings.pixel_height): + image = image.resize((settings.pixel_width, settings.pixel_height)) + return np.asarray(image, dtype=np.uint8).copy() + + def reset(self, camera: Camera) -> None: + self._ensure_open() + key = self._camera_background_key(camera) + if key != self._background_key: + self._background = self._load_background(camera) + self._background_key = key + np.copyto(self._pixels, self._background) + + def clear(self) -> None: + """Clear this target to transparent black without allocating an array.""" + self.pixels.fill(0) + + def get_scratch_target(self) -> _CairoRenderTarget: + """Return a reusable same-sized target for intermediate composition.""" + self._ensure_open() + if self._scratch_target is None: + self._scratch_target = _CairoRenderTarget(self.settings) + return self._scratch_target + + def set_pixels(self, pixels: RGBAPixelArray) -> None: + self._ensure_open() + if pixels.shape != self._pixels.shape: + raise ValueError( + f"Cairo target pixels must have shape {self._pixels.shape}; " + f"got {pixels.shape}.", + ) + if pixels.dtype != np.uint8: + raise TypeError("Cairo target pixels must use uint8.") + np.copyto(self._pixels, pixels) + + def get_context(self, camera: Camera) -> cairo.Context: + self._ensure_open() + settings = self.settings + center = camera.get_view_transform_center() + view_key = ( + float(center[0]), + float(center[1]), + float(camera.frame_width), + float(camera.frame_height), + ) + if self._context is not None and self._context_key == view_key: + return self._context + + surface = cairo.ImageSurface.create_for_data( + self._pixels.data, + cairo.FORMAT_ARGB32, + settings.pixel_width, + settings.pixel_height, + ) + context = cairo.Context(surface) + context.scale(settings.pixel_width, settings.pixel_height) + context.set_matrix( + cairo.Matrix( + settings.pixel_width / camera.frame_width, + 0, + 0, + -(settings.pixel_height / camera.frame_height), + (settings.pixel_width / 2) + - center[0] * (settings.pixel_width / camera.frame_width), + (settings.pixel_height / 2) + + center[1] * (settings.pixel_height / camera.frame_height), + ), + ) + self._context = context + self._context_key = view_key + return context + + def get_background_image(self, image: Image.Image | Path | str) -> RGBAPixelArray: + self._ensure_open() + image_key = str(image) + cached = self.background_image_cache.get(image_key) + if cached is not None: + return cached + + if isinstance(image, (str, Path)): + path = get_full_raster_image_path(image) + with Image.open(path) as source: + source_image = source.convert("RGBA") + array = np.asarray(source_image, dtype=np.uint8).copy() + else: + array = np.asarray(image.convert("RGBA"), dtype=np.uint8).copy() + + expected_shape = self._pixels.shape + if array.shape != expected_shape: + resized = Image.fromarray(array).resize( + (self.settings.pixel_width, self.settings.pixel_height), + ) + array = np.asarray(resized, dtype=np.uint8).copy() + self.background_image_cache[image_key] = array + return array + + def read_pixels(self) -> RGBAPixelArray: + return self.pixels.copy() + + def close(self) -> None: + if self._closed: + return + self._closed = True + self._context = None + self._context_key = None + if self._scratch_target is not None: + self._scratch_target.close() + self._scratch_target = None + self.background_image_cache.clear() + self._background_key = None + self._pixels = np.empty((0, 0, 4), dtype=np.uint8) + self._background = self._pixels diff --git a/manim/renderer/cairo_renderer.py b/manim/renderer/cairo_renderer.py deleted file mode 100644 index 755ecb0688..0000000000 --- a/manim/renderer/cairo_renderer.py +++ /dev/null @@ -1,299 +0,0 @@ -from __future__ import annotations - -from collections.abc import Iterable -from typing import TYPE_CHECKING, Any - -from manim.utils.hashing import get_hash_from_play_call - -from .. import config, logger -from .._config.video_encoder import video_encoder_fingerprint -from ..camera.camera import Camera -from ..mobject.mobject import Mobject, _AnimationBuilder -from ..scene.scene_file_writer import SceneFileWriter -from ..utils.exceptions import EndSceneEarlyException -from ..utils.iterables import list_update -from .protocol import RendererCapabilities - -if TYPE_CHECKING: - from manim._config.render_session import RenderSessionSpec - from manim.animation.animation import Animation - from manim.scene.scene import Scene - from manim.scene.scene_file_writer import _SceneFileWriterSettings - - from ..typing import PixelArray - -__all__ = ["CairoRenderer"] - - -class CairoRenderer: - """A renderer using Cairo. - - Attributes - ---------- - num_plays : int - Number of play() functions in the scene. - - time : float - Time elapsed since initialisation of scene. - """ - - capabilities = RendererCapabilities(live_preview=False) - - def __init__( - self, - file_writer_class: type[SceneFileWriter] = SceneFileWriter, - camera_class: type[Camera] | None = None, - skip_animations: bool = False, - **kwargs: Any, - ): - # All of the following are set to EITHER the value passed via kwargs, - # OR the value stored in the global config dict at the time of - # _instance construction_. - self._file_writer_class = file_writer_class - camera_cls = camera_class if camera_class is not None else Camera - self.camera = camera_cls() - self._original_skipping_status = skip_animations - self.skip_animations = skip_animations - self.animations_hashes: list[str | None] = [] - self.num_plays = 0 - self.time = 0.0 - self.static_image: PixelArray | None = None - - def init_scene( - self, - scene: Scene, - session_spec: RenderSessionSpec, - file_writer_settings: _SceneFileWriterSettings, - ) -> None: - self.file_writer: Any = self._file_writer_class(file_writer_settings) - - def play( - self, - scene: Scene, - *args: Animation | Mobject | _AnimationBuilder, - **kwargs: Any, - ) -> None: - # Reset skip_animations to the original state. - # Needed when rendering only some animations, and skipping others. - self.skip_animations = self._original_skipping_status - self.update_skipping_status() - - scene.compile_animation_data(*args, **kwargs) - - if self.skip_animations: - logger.debug(f"Skipping animation {self.num_plays}") - hash_current_animation = None - self.time += scene.duration - else: - if config["disable_caching"]: - logger.info("Caching disabled.") - hash_current_animation = f"uncached_{self.num_plays:05}" - else: - assert scene.animations is not None - hash_current_animation = get_hash_from_play_call( - scene, - self.camera, - scene.animations, - scene.mobjects, - backend="cairo", - encoder_fingerprint=video_encoder_fingerprint( - scene.session_spec.video_encoder, - ), - renderer_state=(), - ) - if self.file_writer.is_already_cached(hash_current_animation): - logger.info( - f"Animation {self.num_plays} : Using cached data (hash : %(hash_current_animation)s)", - {"hash_current_animation": hash_current_animation}, - ) - self.skip_animations = True - self.time += scene.duration - # adding None as a partial movie file will make file_writer ignore the latter. - self.file_writer.add_partial_movie_file(hash_current_animation) - self.animations_hashes.append(hash_current_animation) - logger.debug( - "List of the first few animation hashes of the scene: %(h)s", - {"h": str(self.animations_hashes[:5])}, - ) - - self.file_writer.begin_animation( - not self.skip_animations, - animation_index=self.num_plays, - ) - scene.begin_animations() - - # Save a static image, to avoid rendering non moving objects. - self.save_static_frame_data(scene, scene.static_mobjects) - - if scene.is_current_animation_frozen_frame(): - self.update_frame(scene, mobjects=scene.moving_mobjects) - # self.duration stands for the total run time of all the animations. - # In this case, as there is only a wait, it will be the length of the wait. - self.freeze_current_frame(scene.duration) - else: - scene.play_internal() - self.file_writer.end_animation(not self.skip_animations) - - self.num_plays += 1 - - def update_frame( # TODO Description in Docstring - self, - scene: Scene, - mobjects: Iterable[Mobject] | None = None, - include_submobjects: bool = True, - ignore_skipping: bool = True, - **kwargs: Any, - ) -> None: - """Update the frame. - - Parameters - ---------- - scene - - mobjects - list of mobjects - - include_submobjects - - ignore_skipping - - **kwargs - """ - if self.skip_animations and not ignore_skipping: - return - if not mobjects: - mobjects = list_update( - scene.mobjects, - scene.foreground_mobjects, - ) - if self.static_image is not None: - self.camera.set_frame_to_background(self.static_image) - else: - self.camera.reset() - - kwargs["include_submobjects"] = include_submobjects - self.camera.capture_mobjects(mobjects, **kwargs) - - def render( - self, - scene: Scene, - time: float, - moving_mobjects: Iterable[Mobject] | None = None, - ) -> None: - self.update_frame(scene, moving_mobjects) - self.add_frame(self.get_frame()) - - def get_frame(self) -> PixelArray: - """Gets the current frame as NumPy array. - - Returns - ------- - PixelArray - NumPy array of pixel values of each pixel in screen. - The shape of the array is height x width x 3. - """ - return self.camera.pixel_array.copy() - - def add_frame(self, frame: PixelArray, num_frames: int = 1) -> None: - """Adds a frame to the video_file_stream - - Parameters - ---------- - frame - The frame to add, as a pixel array. - num_frames - The number of times to add frame. - """ - dt = 1 / self.camera.frame_rate - if self.skip_animations: - return - self.time += num_frames * dt - self.file_writer.write_frame(frame, repeat=num_frames) - - def freeze_current_frame(self, duration: float) -> None: - """Adds a static frame to the movie for a given duration. The static frame is the current frame. - - Parameters - ---------- - duration - [description] - """ - dt = 1 / self.camera.frame_rate - self.add_frame( - self.get_frame(), - num_frames=int(duration / dt), - ) - - def show_frame(self, scene: Scene) -> None: - """Opens the current frame in the Default Image Viewer - of your system. - """ - self.update_frame(scene, ignore_skipping=True) - self.camera.get_image().show() - - def save_static_frame_data( - self, - scene: Scene, - static_mobjects: Iterable[Mobject], - ) -> PixelArray | None: - """Compute and save the static frame, that will be reused at each frame - to avoid unnecessarily computing static mobjects. - - Parameters - ---------- - scene - The scene played. - static_mobjects - Static mobjects of the scene. If None, self.static_image is set to None. - - Returns - ------- - PixelArray | None - The static image computed. The return value is None if there are no static mobjects in the scene. - """ - self.static_image = None - if not static_mobjects: - return None - self.update_frame(scene, mobjects=static_mobjects) - self.static_image = self.get_frame() - return self.static_image - - def update_skipping_status(self) -> None: - """This method is used internally to check if the current - animation needs to be skipped or not. It also checks if - the number of animations that were played correspond to - the number of animations that need to be played, and - raises an EndSceneEarlyException if they don't correspond. - """ - # there is always at least one section -> no out of bounds here - if self.file_writer.sections[-1].skip_animations: - self.skip_animations = True - if self.file_writer.output_spec.is_still: - self.skip_animations = True - if ( - config.from_animation_number > 0 - and self.num_plays < config.from_animation_number - ): - self.skip_animations = True - if ( - config.upto_animation_number >= 0 - and self.num_plays > config.upto_animation_number - ): - self.skip_animations = True - raise EndSceneEarlyException() - - def scene_finished(self, scene: Scene) -> None: - output = self.file_writer.output_spec - if self.num_plays and (output.is_video or output.is_image_sequence): - self.file_writer.finish() - elif not self.num_plays: - self.static_image = None - self.update_frame(scene) - - # Automatically selected video output falls back to a last-frame PNG - # when a scene has no play calls. - if output.is_still or (not self.num_plays and output.fallback_to_still): - if self.num_plays: - self.static_image = None - self.update_frame(scene) - self.file_writer.save_image(self.get_frame()) diff --git a/manim/renderer/opengl/__init__.py b/manim/renderer/opengl/__init__.py new file mode 100644 index 0000000000..120c1f0dda --- /dev/null +++ b/manim/renderer/opengl/__init__.py @@ -0,0 +1,28 @@ +"""OpenGL rendering backend.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from .camera import OpenGLCamera + from .renderer import OpenGLRenderer + +__all__ = ["OpenGLCamera", "OpenGLRenderer"] + + +def __getattr__(name: str) -> Any: + value: Any + if name == "OpenGLCamera": + from .camera import OpenGLCamera + + value = OpenGLCamera + elif name == "OpenGLRenderer": + from .renderer import OpenGLRenderer + + value = OpenGLRenderer + else: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + globals()[name] = value + return value diff --git a/manim/renderer/opengl/camera.py b/manim/renderer/opengl/camera.py new file mode 100644 index 0000000000..0836ba98a4 --- /dev/null +++ b/manim/renderer/opengl/camera.py @@ -0,0 +1,413 @@ +"""Semantic camera state for the OpenGL rendering backend.""" + +from __future__ import annotations + +import typing +from functools import cached_property +from typing import TYPE_CHECKING, Any, Self + +import numpy as np +from typing_extensions import override + +from manim import config +from manim.constants import * +from manim.mobject.opengl.opengl_mobject import OpenGLMobject, OpenGLPoint +from manim.typing import MatrixMN, Point3D +from manim.utils import opengl +from manim.utils.paths import straight_path +from manim.utils.simple_functions import clip +from manim.utils.space_ops import ( + angle_of_vector, + quaternion_from_angle_axis, + quaternion_mult, + rotation_matrix_transpose, + rotation_matrix_transpose_from_quaternion, +) + +if TYPE_CHECKING: + from manim.typing import PathFuncType, Point3DLike, Vector3DLike + from manim.utils.opengl import FlattenedMatrix4x4 + +__all__ = ["OpenGLCamera"] + + +class OpenGLCamera(OpenGLMobject): + """ + An OpenGL-based camera for 3D scene rendering. + + + Attributes + ---------- + frame_shape : tuple[float, float] + The width and height of the camera frame. + center_point : np.ndarray + The center point of the camera in 3D space. + euler_angles : np.ndarray + The Euler angles (theta, phi, gamma) representing the camera's orientation. + focal_distance : float + The focal distance of the camera. + light_source_position : np.ndarray + The position of the light source in 3D space. + orthographic : bool + Whether the camera uses orthographic projection instead of perspective. + minimum_polar_angle : float + The minimum polar angle for camera rotation. + maximum_polar_angle : float + The maximum polar angle for camera rotation. + inverse_rotation_matrix : np.ndarray + The inverse rotation matrix of the camera. + """ + + def __init__( + self, + frame_shape: tuple[float, float] | None = None, + center_point: Point3DLike | None = None, + # Theta, phi, gamma + euler_angles: Point3DLike | None = None, + focal_distance: float = 2.0, + light_source_position: Point3DLike | None = None, + orthographic: bool = False, + minimum_polar_angle: float = -PI / 2, + maximum_polar_angle: float = PI / 2, + model_matrix: MatrixMN | None = None, + **kwargs: Any, + ) -> None: + """ + Initializes an OpenGLCamera instance. + + Parameters + ---------- + frame_shape : tuple[float, float], optional + The width and height of the camera frame. If not provided, defaults to + the global manim config values `frame_width` and `frame_height`. + center_point : Point3DLike, optional + The center point of the camera in 3D space. + If not provided, defaults to the origin (0, 0, 0). + euler_angles : Point3DLike, optional + The Euler angles (theta, phi, gamma) representing the camera's orientation. + If not provided, defaults to (0, 0, 0) (i.e., no rotation). + focal_distance : float, optional + The focal distance of the camera. Default is 2.0. + light_source_position : Point3DLike, optional + The position of the light source in 3D space. + If not provided, defaults to (-10, 10, 10). + orthographic : bool, optional + Whether the camera uses orthographic projection instead of perspective. + Default is False (perspective). + minimum_polar_angle : float, optional + The minimum polar angle in radian for camera rotation. Default is -π/2, + i.e. no restriction. + maximum_polar_angle : float, optional + The maximum polar angle in radian for camera rotation. Default is π/2, + i.e. no restriction. + model_matrix : MatrixMN, optional + The initial model matrix for the camera. If not provided, defaults to a + translation matrix that positions the camera at (0, 0, 11). + **kwargs : Any + Additional keyword arguments passed to the OpenGLMobject constructor. + """ + self.use_z_index = True + self.orthographic = orthographic + self.minimum_polar_angle = minimum_polar_angle + self.maximum_polar_angle = maximum_polar_angle + if self.orthographic: + self.projection_matrix = opengl.orthographic_projection_matrix() + self.unformatted_projection_matrix = opengl.orthographic_projection_matrix( + format_=False, + ) + else: + self.projection_matrix = opengl.perspective_projection_matrix() + self.unformatted_projection_matrix = opengl.perspective_projection_matrix( + format_=False, + ) + + if frame_shape is None: + self.frame_shape = (config["frame_width"], config["frame_height"]) + else: + self.frame_shape = frame_shape + + if center_point is None: + self.center_point = ORIGIN + else: + self.center_point = np.asarray(center_point, dtype=float) + + if model_matrix is None: + model_matrix = opengl.translation_matrix(0, 0, 11) + + self.focal_distance = focal_distance + + self.light_source_position = np.asarray( + light_source_position or [-10, 10, 10], dtype=float + ) + + self.light_source = OpenGLPoint(self.light_source_position) + + self.default_model_matrix = model_matrix + super().__init__(model_matrix=model_matrix, should_render=False, **kwargs) + + euler_angles = np.asarray(euler_angles or [0, 0, 0], dtype=float) + + self.euler_angles: Point3D = euler_angles + self.refresh_rotation_matrix() + + def get_position(self) -> Point3D: + """Retrieve the camera's position in 3D space.""" + return self.model_matrix[:, 3][:3] + + def set_position(self, position: Point3D) -> Self: + """Set the camera's position in 3D space.""" + self.model_matrix[:, 3][:3] = position + return self + + @cached_property + def formatted_view_matrix(self) -> FlattenedMatrix4x4: + """The formatted view matrix for shader input.""" + return opengl.matrix_to_shader_input(self.unformatted_view_matrix) + + @cached_property + def unformatted_view_matrix(self) -> MatrixMN: + return typing.cast(MatrixMN, np.linalg.inv(self.model_matrix)) + + def init_points(self) -> Self: + """Initialize the camera's points based on frame shape and center point.""" + self.set_points([ORIGIN, LEFT, RIGHT, DOWN, UP]) + self.set_width(self.frame_shape[0], stretch=True) + self.set_height(self.frame_shape[1], stretch=True) + self.move_to(self.center_point) + return self + + def to_default_state(self) -> Self: + """Reset the camera to its default state + (config frame size, centered at origin, no rotation). + """ + self.center() + self.set_height(config["frame_height"]) + self.set_width(config["frame_width"]) + self.set_euler_angles(0, 0, 0) + self.model_matrix = self.default_model_matrix + return self + + def refresh_rotation_matrix(self) -> Self: + """Refresh the camera's inverse rotation matrix based on its Euler angles.""" + # Rotate based on camera orientation + theta, phi, gamma = self.euler_angles + quat = quaternion_mult( + quaternion_from_angle_axis(theta, OUT, axis_normalized=True), + quaternion_from_angle_axis(phi, RIGHT, axis_normalized=True), + quaternion_from_angle_axis(gamma, OUT, axis_normalized=True), + ) + self.inverse_rotation_matrix = rotation_matrix_transpose_from_quaternion( + np.asarray(quat, dtype=float) + ) + return self + + @override + def rotate( + self, + angle: float, + axis: Vector3DLike = OUT, + about_point: Point3DLike | None = None, + **kwargs: Any, + ) -> Self: + """ + Rotate the camera by a given angle around a specified axis. + + Parameters + ---------- + angle : float + The angle in radians to rotate the camera. + axis : Vector3DLike, optional + The axis around which to rotate the camera. Default is OUT (z-axis). + about_point : Point3DLike, optional + Ignored. For OpenGLCamera, rotation is always about the camera's center. + + **kwargs : Any + Not used for OpenGLCamera. Passing additional keyword arguments + has no effect. + + Returns + ------- + Self + The rotated camera instance. Returned for chaining. + """ + curr_rot_T = self.inverse_rotation_matrix + added_rot_T = rotation_matrix_transpose(angle, axis) + new_rot_T = np.dot(curr_rot_T, added_rot_T) + Fz = new_rot_T[2] + phi = np.arccos(Fz[2]) + theta = angle_of_vector(Fz[:2]) + PI / 2 + partial_rot_T = np.dot( + rotation_matrix_transpose(phi, RIGHT), + rotation_matrix_transpose(theta, OUT), + ) + gamma = angle_of_vector(np.dot(partial_rot_T, new_rot_T.T)[:, 0]) + self.set_euler_angles(theta, phi, gamma) + return self + + def set_euler_angles( + self, + theta: float | None = None, + phi: float | None = None, + gamma: float | None = None, + ) -> Self: + """ + Set the camera's Euler angles [2]_ (theta, phi, gamma). + + Parameters + ---------- + theta : float | None, optional + The angle in radians for rotation around the OUT (z) axis. + If None, the current theta value is retained. + phi : float | None, optional + The angle in radians for rotation around the RIGHT (x) axis. + If None, the current phi value is retained. + gamma : float | None, optional + The angle in radians for rotation around the OUT (z) axis. + If None, the current gamma value is retained. + + Returns + ------- + Self + The camera instance with updated Euler angles. Returned for chaining. + + See Also + -------- + set_theta : Set the theta Euler angle. + set_phi : Set the phi Euler angle. + set_gamma : Set the gamma Euler angle. + + References + ---------- + .. [2] Wikipedia, "Euler angles", + https://en.wikipedia.org/wiki/Euler_angles + """ + if theta is not None: + self.euler_angles[0] = theta + if phi is not None: + self.euler_angles[1] = phi + if gamma is not None: + self.euler_angles[2] = gamma + self.refresh_rotation_matrix() + return self + + def set_theta(self, theta: float) -> Self: + """ + Set the camera's theta Euler angle (in radians). + + See Also + -------- + set_euler_angles : Set all Euler angles at once. + set_phi : Set the phi Euler angle. + set_gamma : Set the gamma Euler angle. + """ + return self.set_euler_angles(theta=theta) + + def set_phi(self, phi: float) -> Self: + """ + Set the camera's phi Euler angle (in radians). + + See Also + -------- + set_euler_angles : Set all Euler angles at once. + set_theta : Set the theta Euler angle. + set_gamma : Set the gamma Euler angle. + """ + return self.set_euler_angles(phi=phi) + + def set_gamma(self, gamma: float) -> Self: + """ + Set the camera's gamma Euler angle (in radians). + + See Also + -------- + set_euler_angles : Set all Euler angles at once. + set_theta : Set the theta Euler angle. + set_phi : Set the phi Euler angle. + """ + return self.set_euler_angles(gamma=gamma) + + def increment_theta(self, dtheta: float) -> Self: + """ + Increment the camera's theta Euler angle by a given amount (in radians). + + See Also + -------- + set_euler_angles : Set all Euler angles at once. + set_theta : Set the theta Euler angle. + """ + self.euler_angles[0] += dtheta + self.refresh_rotation_matrix() + return self + + def increment_phi(self, dphi: float) -> Self: + """ + Increment the camera's phi Euler angle by a given amount (in radians). + + See Also + -------- + set_euler_angles : Set all Euler angles at once. + set_phi : Set the phi Euler angle. + """ + phi = self.euler_angles[1] + new_phi = clip(phi + dphi, -PI / 2, PI / 2) + self.euler_angles[1] = new_phi + self.refresh_rotation_matrix() + return self + + def increment_gamma(self, dgamma: float) -> Self: + """ + Increment the camera's gamma Euler angle by a given amount (in radians). + + See Also + -------- + set_euler_angles : Set all Euler angles at once. + set_gamma : Set the gamma Euler angle. + """ + self.euler_angles[2] += dgamma + self.refresh_rotation_matrix() + return self + + def get_shape(self) -> tuple[float, float]: + """Retrieve the width and height of the camera frame.""" + return (self.get_width(), self.get_height()) + + def get_center(self) -> Point3D: + """ + Retrieve the center point of the camera in 3D space. + + Notes + ----- + The center point is assumed to be the first point in the camera's points array. + """ + # Assumes first point is at the center + return typing.cast(Point3D, self.points[0]) + + def get_width(self) -> float: + """Retrieve the width of the camera frame.""" + points = self.points + out = points[2, 0] - points[1, 0] + return float(out) + + def get_height(self) -> float: + """Retrieve the height of the camera frame.""" + points = self.points + out = points[4, 1] - points[3, 1] + return float(out) + # return points[4, 1] - points[3, 1] + + def get_focal_distance(self) -> float: + """Retrieve the focal distance of the camera.""" + return self.focal_distance * self.get_height() + + @override + def interpolate( + self, + mobject1: OpenGLMobject, + mobject2: OpenGLMobject, + alpha: float, + path_func: PathFuncType = straight_path(), + ) -> Self: + """Interpolate camera mobject state and refresh its rotation matrix.""" + super().interpolate(mobject1, mobject2, alpha, path_func) + self.refresh_rotation_matrix() + return self diff --git a/manim/renderer/opengl_renderer.py b/manim/renderer/opengl/renderer.py similarity index 65% rename from manim/renderer/opengl_renderer.py rename to manim/renderer/opengl/renderer.py index cbaee2bc9b..f59f21458d 100644 --- a/manim/renderer/opengl_renderer.py +++ b/manim/renderer/opengl/renderer.py @@ -2,41 +2,29 @@ import contextlib import itertools as it +import threading import time import typing -from functools import cached_property -from typing import TYPE_CHECKING, Any, Self +from typing import TYPE_CHECKING, Any import moderngl import numpy as np from moderngl import Framebuffer from PIL import Image -from typing_extensions import override from manim import config from manim.mobject.opengl.opengl_mobject import ( OpenGLMobject, - OpenGLPoint, ) from manim.mobject.opengl.opengl_vectorized_mobject import OpenGLVMobject -from manim.typing import MatrixMN, Point3D +from manim.typing import Point3D from manim.utils.caching import handle_caching_play from manim.utils.color import color_to_rgba from manim.utils.exceptions import EndSceneEarlyException -from manim.utils.paths import straight_path - -from ..constants import * -from ..scene.scene_file_writer import SceneFileWriter -from ..utils import opengl -from ..utils.simple_functions import clip -from ..utils.space_ops import ( - angle_of_vector, - quaternion_from_angle_axis, - quaternion_mult, - rotation_matrix_transpose, - rotation_matrix_transpose_from_quaternion, -) -from .protocol import RendererCapabilities + +from ...constants import * +from ...scene.scene_file_writer import SceneFileWriter +from ..protocol import RendererCapabilities from .shader import Mesh, Shader from .vectorized_mobject_rendering import ( render_opengl_vectorized_mobject_fill, @@ -45,7 +33,6 @@ if TYPE_CHECKING: from collections.abc import Iterable - from typing import Self from manim._config.render_session import RenderSessionSpec from manim.animation.animation import Animation @@ -54,405 +41,15 @@ from manim.scene.scene_file_writer import _SceneFileWriterSettings from manim.typing import ( FloatRGBA, - PathFuncType, - Point3DLike, RGBAPixelArray, - Vector3DLike, ) from manim.utils.color.core import ParsableManimColor - from manim.utils.opengl import FlattenedMatrix4x4 - - from .opengl_renderer_window import Window - - -__all__ = ["OpenGLCamera", "OpenGLRenderer"] - - -class OpenGLCamera(OpenGLMobject): - """ - An OpenGL-based camera for 3D scene rendering. - - Attributes - ---------- - frame_shape : tuple[float, float] - The width and height of the camera frame. - center_point : np.ndarray - The center point of the camera in 3D space. - euler_angles : np.ndarray - The Euler angles (theta, phi, gamma) representing the camera's orientation. - focal_distance : float - The focal distance of the camera. - light_source_position : np.ndarray - The position of the light source in 3D space. - orthographic : bool - Whether the camera uses orthographic projection instead of perspective. - minimum_polar_angle : float - The minimum polar angle for camera rotation. - maximum_polar_angle : float - The maximum polar angle for camera rotation. - inverse_rotation_matrix : np.ndarray - The inverse rotation matrix of the camera. - """ - - def __init__( - self, - frame_shape: tuple[float, float] | None = None, - center_point: Point3DLike | None = None, - # Theta, phi, gamma - euler_angles: Point3DLike | None = None, - focal_distance: float = 2.0, - light_source_position: Point3DLike | None = None, - orthographic: bool = False, - minimum_polar_angle: float = -PI / 2, - maximum_polar_angle: float = PI / 2, - model_matrix: MatrixMN | None = None, - **kwargs: Any, - ) -> None: - """ - Initializes an OpenGLCamera instance. + from .window import Window - Parameters - ---------- - frame_shape : tuple[float, float], optional - The width and height of the camera frame. If not provided, defaults to - the global manim config values `frame_width` and `frame_height`. - center_point : Point3DLike, optional - The center point of the camera in 3D space. - If not provided, defaults to the origin (0, 0, 0). - euler_angles : Point3DLike, optional - The Euler angles (theta, phi, gamma) representing the camera's orientation. - If not provided, defaults to (0, 0, 0) (i.e., no rotation). - focal_distance : float, optional - The focal distance of the camera. Default is 2.0. - light_source_position : Point3DLike, optional - The position of the light source in 3D space. - If not provided, defaults to (-10, 10, 10). - orthographic : bool, optional - Whether the camera uses orthographic projection instead of perspective. - Default is False (perspective). - minimum_polar_angle : float, optional - The minimum polar angle in radian for camera rotation. Default is -π/2, - i.e. no restriction. - maximum_polar_angle : float, optional - The maximum polar angle in radian for camera rotation. Default is π/2, - i.e. no restriction. - model_matrix : MatrixMN, optional - The initial model matrix [1]_ for the camera. If not provided, - defaults to a translation matrix that positions the camera at (0, 0, 11). - **kwargs : Any - Additional keyword arguments passed to the OpenGLMobject constructor. +from .camera import OpenGLCamera - References - ---------- - .. [1] Wikipedia, "Camera matrix", - https://en.wikipedia.org/wiki/Camera_matrix - """ - self.use_z_index = True - self.frame_rate = 60 - self.orthographic = orthographic - self.minimum_polar_angle = minimum_polar_angle - self.maximum_polar_angle = maximum_polar_angle - if self.orthographic: - self.projection_matrix = opengl.orthographic_projection_matrix() - self.unformatted_projection_matrix = opengl.orthographic_projection_matrix( - format_=False, - ) - else: - self.projection_matrix = opengl.perspective_projection_matrix() - self.unformatted_projection_matrix = opengl.perspective_projection_matrix( - format_=False, - ) - - if frame_shape is None: - self.frame_shape = (config["frame_width"], config["frame_height"]) - else: - self.frame_shape = frame_shape - - if center_point is None: - self.center_point = ORIGIN - else: - self.center_point = np.asarray(center_point, dtype=float) - - if model_matrix is None: - model_matrix = opengl.translation_matrix(0, 0, 11) - - self.focal_distance = focal_distance - - self.light_source_position = np.asarray( - light_source_position or [-10, 10, 10], dtype=float - ) - - self.light_source = OpenGLPoint(self.light_source_position) - - self.default_model_matrix = model_matrix - super().__init__(model_matrix=model_matrix, should_render=False, **kwargs) - - euler_angles = np.asarray(euler_angles or [0, 0, 0], dtype=float) - - self.euler_angles: Point3D = euler_angles - self.refresh_rotation_matrix() - - def get_position(self) -> Point3D: - """Retrieve the camera's position in 3D space.""" - return self.model_matrix[:, 3][:3] - - def set_position(self, position: Point3D) -> Self: - """Set the camera's position in 3D space.""" - self.model_matrix[:, 3][:3] = position - return self - - @cached_property - def formatted_view_matrix(self) -> FlattenedMatrix4x4: - """The formatted view matrix for shader input.""" - return opengl.matrix_to_shader_input(self.unformatted_view_matrix) - - @cached_property - def unformatted_view_matrix(self) -> MatrixMN: - return typing.cast(MatrixMN, np.linalg.inv(self.model_matrix)) - - def init_points(self) -> Self: - """Initialize the camera's points based on frame shape and center point.""" - self.set_points([ORIGIN, LEFT, RIGHT, DOWN, UP]) - self.set_width(self.frame_shape[0], stretch=True) - self.set_height(self.frame_shape[1], stretch=True) - self.move_to(self.center_point) - return self - - def to_default_state(self) -> Self: - """Reset the camera to its default state - (config frame size, centered at origin, no rotation). - """ - self.center() - self.set_height(config["frame_height"]) - self.set_width(config["frame_width"]) - self.set_euler_angles(0, 0, 0) - self.model_matrix = self.default_model_matrix - return self - - def refresh_rotation_matrix(self) -> Self: - """Refresh the camera's inverse rotation matrix based on its Euler angles.""" - # Rotate based on camera orientation - theta, phi, gamma = self.euler_angles - quat = quaternion_mult( - quaternion_from_angle_axis(theta, OUT, axis_normalized=True), - quaternion_from_angle_axis(phi, RIGHT, axis_normalized=True), - quaternion_from_angle_axis(gamma, OUT, axis_normalized=True), - ) - self.inverse_rotation_matrix = rotation_matrix_transpose_from_quaternion( - np.asarray(quat, dtype=float) - ) - return self - - @override - def rotate( - self, - angle: float, - axis: Vector3DLike = OUT, - about_point: Point3DLike | None = None, - **kwargs: Any, - ) -> Self: - """ - Rotate the camera by a given angle around a specified axis. - - Parameters - ---------- - angle : float - The angle in radians to rotate the camera. - axis : Vector3DLike, optional - The axis around which to rotate the camera. Default is OUT (z-axis). - about_point : Point3DLike, optional - Ignored. For OpenGLCamera, rotation is always about the camera's center. - - **kwargs : Any - Not used for OpenGLCamera. Passing additional keyword arguments - has no effect. - - Returns - ------- - Self - The rotated camera instance. Returned for chaining. - """ - curr_rot_T = self.inverse_rotation_matrix - added_rot_T = rotation_matrix_transpose(angle, axis) - new_rot_T = np.dot(curr_rot_T, added_rot_T) - Fz = new_rot_T[2] - phi = np.arccos(Fz[2]) - theta = angle_of_vector(Fz[:2]) + PI / 2 - partial_rot_T = np.dot( - rotation_matrix_transpose(phi, RIGHT), - rotation_matrix_transpose(theta, OUT), - ) - gamma = angle_of_vector(np.dot(partial_rot_T, new_rot_T.T)[:, 0]) - self.set_euler_angles(theta, phi, gamma) - return self - - def set_euler_angles( - self, - theta: float | None = None, - phi: float | None = None, - gamma: float | None = None, - ) -> Self: - """ - Set the camera's Euler angles [1]_ (theta, phi, gamma). - - Parameters - ---------- - theta : float | None, optional - The angle in radians for rotation around the OUT (z) axis. - If None, the current theta value is retained. - phi : float | None, optional - The angle in radians for rotation around the RIGHT (x) axis. - If None, the current phi value is retained. - gamma : float | None, optional - The angle in radians for rotation around the OUT (z) axis. - If None, the current gamma value is retained. - - Returns - ------- - Self - The camera instance with updated Euler angles. Returned for chaining. - - See Also - -------- - set_theta : Set the theta Euler angle. - set_phi : Set the phi Euler angle. - set_gamma : Set the gamma Euler angle. - - References - ---------- - .. [1] Wikipedia, "Euler angles", - https://en.wikipedia.org/wiki/Euler_angles - """ - if theta is not None: - self.euler_angles[0] = theta - if phi is not None: - self.euler_angles[1] = phi - if gamma is not None: - self.euler_angles[2] = gamma - self.refresh_rotation_matrix() - return self - - def set_theta(self, theta: float) -> Self: - """ - Set the camera's theta Euler angle (in radians). - - See Also - -------- - set_euler_angles : Set all Euler angles at once. - set_phi : Set the phi Euler angle. - set_gamma : Set the gamma Euler angle. - """ - return self.set_euler_angles(theta=theta) - - def set_phi(self, phi: float) -> Self: - """ - Set the camera's phi Euler angle (in radians). - - See Also - -------- - set_euler_angles : Set all Euler angles at once. - set_theta : Set the theta Euler angle. - set_gamma : Set the gamma Euler angle. - """ - return self.set_euler_angles(phi=phi) - - def set_gamma(self, gamma: float) -> Self: - """ - Set the camera's gamma Euler angle (in radians). - - See Also - -------- - set_euler_angles : Set all Euler angles at once. - set_theta : Set the theta Euler angle. - set_phi : Set the phi Euler angle. - """ - return self.set_euler_angles(gamma=gamma) - - def increment_theta(self, dtheta: float) -> Self: - """ - Increment the camera's theta Euler angle by a given amount (in radians). - - See Also - -------- - set_euler_angles : Set all Euler angles at once. - set_theta : Set the theta Euler angle. - """ - self.euler_angles[0] += dtheta - self.refresh_rotation_matrix() - return self - - def increment_phi(self, dphi: float) -> Self: - """ - Increment the camera's phi Euler angle by a given amount (in radians). - - See Also - -------- - set_euler_angles : Set all Euler angles at once. - set_phi : Set the phi Euler angle. - """ - phi = self.euler_angles[1] - new_phi = clip(phi + dphi, -PI / 2, PI / 2) - self.euler_angles[1] = new_phi - self.refresh_rotation_matrix() - return self - - def increment_gamma(self, dgamma: float) -> Self: - """ - Increment the camera's gamma Euler angle by a given amount (in radians). - - See Also - -------- - set_euler_angles : Set all Euler angles at once. - set_gamma : Set the gamma Euler angle. - """ - self.euler_angles[2] += dgamma - self.refresh_rotation_matrix() - return self - - def get_shape(self) -> tuple[float, float]: - """Retrieve the width and height of the camera frame.""" - return (self.get_width(), self.get_height()) - - def get_center(self) -> Point3D: - """ - Retrieve the center point of the camera in 3D space. - - Notes - ----- - The center point is assumed to be the first point in the camera's points array. - """ - # Assumes first point is at the center - return typing.cast(Point3D, self.points[0]) - - def get_width(self) -> float: - """Retrieve the width of the camera frame.""" - points = self.points - out = points[2, 0] - points[1, 0] - return float(out) - - def get_height(self) -> float: - """Retrieve the height of the camera frame.""" - points = self.points - out = points[4, 1] - points[3, 1] - return float(out) - # return points[4, 1] - points[3, 1] - - def get_focal_distance(self) -> float: - """Retrieve the focal distance of the camera.""" - return self.focal_distance * self.get_height() - - @override - def interpolate( - self, - mobject1: OpenGLMobject, - mobject2: OpenGLMobject, - alpha: float, - path_func: PathFuncType = straight_path(), - ) -> Self: - super().interpolate(mobject1, mobject2, alpha, path_func) - self.refresh_rotation_matrix() - return self +__all__ = ["OpenGLRenderer"] class OpenGLRenderer: @@ -550,7 +147,7 @@ def init_scene( self.background_color = config["background_color"] if self.should_create_window(session_spec): - from .opengl_renderer_window import Window + from .window import Window self.window = Window(self) self.context = self.window.ctx @@ -566,6 +163,8 @@ def init_scene( ) self.frame_buffer_object = self.get_frame_buffer_object(self.context, 0) self.frame_buffer_object.use() + self._context_thread = threading.get_ident() + self._capturing_image = False self.context.enable(moderngl.BLEND) self.context.wireframe = config["enable_wireframe"] self.context.blend_func = ( @@ -937,6 +536,11 @@ def update_frame(self, scene: Scene) -> None: scene : Scene The scene to render the frame for. """ + self._draw_scene(scene) + self.animation_elapsed_time = time.time() - self.animation_start_time + + def _draw_scene(self, scene: Scene) -> None: + """Draw current objects and meshes without updating animation timing.""" self.frame_buffer_object.clear(*self.background_color) # TODO: make the type of 'camera' generic in the 'Scene' class @@ -958,7 +562,38 @@ def update_frame(self, scene: Scene) -> None: mesh.set_uniforms(self) mesh.render() - self.animation_elapsed_time = time.time() - self.animation_start_time + def _get_scene_image(self, scene: Scene) -> Image.Image: + """Capture on the context's owning thread without touching its live target.""" + if threading.get_ident() != self._context_thread: + raise RuntimeError( + "OpenGL scene images must be requested on the render thread." + ) + if self._capturing_image: + raise RuntimeError("Recursive OpenGL scene image capture is not supported.") + target = self.frame_buffer_object + bound = self.context.fbo + viewport = self.context.viewport + size = target.size + self._capturing_image = True + try: + with contextlib.ExitStack() as resources: + color = self.context.texture(size, components=4) + resources.callback(color.release) + depth = self.context.depth_renderbuffer(size) + resources.callback(depth.release) + frame = self.context.framebuffer(color, depth) + resources.callback(frame.release) + try: + self.frame_buffer_object = frame + frame.use() + self._draw_scene(scene) + return Image.fromarray(self.get_frame()) + finally: + self.frame_buffer_object = target + (target if bound is None else bound).use() + self.context.viewport = viewport + finally: + self._capturing_image = False def scene_finished(self, scene: Scene) -> None: """Finalize configured output for the scene. diff --git a/manim/renderer/shader.py b/manim/renderer/opengl/shader.py similarity index 99% rename from manim/renderer/shader.py rename to manim/renderer/opengl/shader.py index e57964daa3..c074301d3a 100644 --- a/manim/renderer/shader.py +++ b/manim/renderer/opengl/shader.py @@ -13,7 +13,7 @@ import numpy.typing as npt if TYPE_CHECKING: - from manim.renderer.opengl_renderer import OpenGLRenderer + from manim.renderer.opengl.renderer import OpenGLRenderer MeshTimeBasedUpdater: TypeAlias = Callable[["Object3D", float], None] MeshNonTimeBasedUpdater: TypeAlias = Callable[["Object3D"], None] @@ -21,8 +21,8 @@ from manim.typing import MatrixMN, Point3D -from .. import config -from ..utils import opengl +from ... import config +from ...utils import opengl SHADER_FOLDER = Path(__file__).parent / "shaders" shader_program_cache: dict[str, moderngl.Program] = {} diff --git a/manim/renderer/shader_wrapper.py b/manim/renderer/opengl/shader_wrapper.py similarity index 93% rename from manim/renderer/shader_wrapper.py rename to manim/renderer/opengl/shader_wrapper.py index d35953a622..73d0bb737f 100644 --- a/manim/renderer/shader_wrapper.py +++ b/manim/renderer/opengl/shader_wrapper.py @@ -25,7 +25,7 @@ logger = logging.getLogger("manim") -def get_shader_dir(): +def get_shader_dir() -> Path: return Path(__file__).parent / "shaders" @@ -58,7 +58,7 @@ def __init__( texture_paths: Mapping[str, Path | str] | None = None, depth_test: bool = False, render_primitive: int | str = moderngl.TRIANGLE_STRIP, - ): + ) -> None: self.vert_data: _ShaderData = vert_data self.vert_indices: Sequence[int] | None = vert_indices self.vert_attributes: tuple[str, ...] | None = vert_data.dtype.names @@ -70,8 +70,8 @@ def __init__( self.init_program_code() self.refresh_id() - def copy(self): - result = copy.copy(self) + def copy(self) -> Self: + result: Self = copy.copy(self) result.vert_data = np.array(self.vert_data) if result.vert_indices is not None: result.vert_indices = np.array(self.vert_indices) @@ -96,7 +96,7 @@ def get_id(self) -> str: def get_program_id(self) -> int: return self.program_id - def create_id(self): + def create_id(self) -> str: # A unique id for a shader return "|".join( map( @@ -115,7 +115,7 @@ def refresh_id(self) -> None: self.program_id: int = self.create_program_id() self.id: str = self.create_id() - def create_program_id(self): + def create_program_id(self) -> int: return hash( "".join( self.program_code[f"{name}_shader"] or "" @@ -123,7 +123,7 @@ def create_program_id(self): ), ) - def init_program_code(self): + def init_program_code(self) -> None: def get_code(name: str) -> str | None: return get_shader_code_from_file( self.shader_folder / f"{name}.glsl", @@ -135,7 +135,7 @@ def get_code(name: str) -> str | None: "fragment_shader": get_code("frag"), } - def get_program_code(self): + def get_program_code(self) -> dict[str, str | None]: return self.program_code def replace_code(self, old: str, new: str) -> None: @@ -151,10 +151,10 @@ def combine_with(self, *shader_wrappers: "ShaderWrapper") -> Self: # noqa: UP03 return self if self.vert_indices is not None: num_verts = len(self.vert_data) - indices_list = [self.vert_indices] + indices_list = [np.asarray(self.vert_indices)] data_list = [self.vert_data] for sw in shader_wrappers: - indices_list.append(sw.vert_indices + num_verts) + indices_list.append(np.asarray(sw.vert_indices) + num_verts) data_list.append(sw.vert_data) num_verts += len(sw.vert_data) self.vert_indices = np.hstack(indices_list) @@ -167,7 +167,7 @@ def combine_with(self, *shader_wrappers: "ShaderWrapper") -> Self: # noqa: UP03 # For caching -filename_to_code_map: dict = {} +filename_to_code_map: dict[Path, str] = {} def get_shader_code_from_file(filename: Path) -> str | None: diff --git a/manim/renderer/shaders/default/frag.glsl b/manim/renderer/opengl/shaders/default/frag.glsl similarity index 100% rename from manim/renderer/shaders/default/frag.glsl rename to manim/renderer/opengl/shaders/default/frag.glsl diff --git a/manim/renderer/shaders/default/vert.glsl b/manim/renderer/opengl/shaders/default/vert.glsl similarity index 100% rename from manim/renderer/shaders/default/vert.glsl rename to manim/renderer/opengl/shaders/default/vert.glsl diff --git a/manim/renderer/shaders/design.frag b/manim/renderer/opengl/shaders/design.frag similarity index 100% rename from manim/renderer/shaders/design.frag rename to manim/renderer/opengl/shaders/design.frag diff --git a/manim/renderer/shaders/design_2.frag b/manim/renderer/opengl/shaders/design_2.frag similarity index 100% rename from manim/renderer/shaders/design_2.frag rename to manim/renderer/opengl/shaders/design_2.frag diff --git a/manim/renderer/shaders/design_3.frag b/manim/renderer/opengl/shaders/design_3.frag similarity index 100% rename from manim/renderer/shaders/design_3.frag rename to manim/renderer/opengl/shaders/design_3.frag diff --git a/manim/renderer/shaders/image/frag.glsl b/manim/renderer/opengl/shaders/image/frag.glsl similarity index 100% rename from manim/renderer/shaders/image/frag.glsl rename to manim/renderer/opengl/shaders/image/frag.glsl diff --git a/manim/renderer/shaders/image/vert.glsl b/manim/renderer/opengl/shaders/image/vert.glsl similarity index 100% rename from manim/renderer/shaders/image/vert.glsl rename to manim/renderer/opengl/shaders/image/vert.glsl diff --git a/manim/renderer/shaders/include/NOTE.md b/manim/renderer/opengl/shaders/include/NOTE.md similarity index 100% rename from manim/renderer/shaders/include/NOTE.md rename to manim/renderer/opengl/shaders/include/NOTE.md diff --git a/manim/renderer/shaders/include/add_light.glsl b/manim/renderer/opengl/shaders/include/add_light.glsl similarity index 100% rename from manim/renderer/shaders/include/add_light.glsl rename to manim/renderer/opengl/shaders/include/add_light.glsl diff --git a/manim/renderer/shaders/include/camera_uniform_declarations.glsl b/manim/renderer/opengl/shaders/include/camera_uniform_declarations.glsl similarity index 100% rename from manim/renderer/shaders/include/camera_uniform_declarations.glsl rename to manim/renderer/opengl/shaders/include/camera_uniform_declarations.glsl diff --git a/manim/renderer/shaders/include/finalize_color.glsl b/manim/renderer/opengl/shaders/include/finalize_color.glsl similarity index 100% rename from manim/renderer/shaders/include/finalize_color.glsl rename to manim/renderer/opengl/shaders/include/finalize_color.glsl diff --git a/manim/renderer/shaders/include/get_gl_Position.glsl b/manim/renderer/opengl/shaders/include/get_gl_Position.glsl similarity index 100% rename from manim/renderer/shaders/include/get_gl_Position.glsl rename to manim/renderer/opengl/shaders/include/get_gl_Position.glsl diff --git a/manim/renderer/shaders/include/get_rotated_surface_unit_normal_vector.glsl b/manim/renderer/opengl/shaders/include/get_rotated_surface_unit_normal_vector.glsl similarity index 100% rename from manim/renderer/shaders/include/get_rotated_surface_unit_normal_vector.glsl rename to manim/renderer/opengl/shaders/include/get_rotated_surface_unit_normal_vector.glsl diff --git a/manim/renderer/shaders/include/get_unit_normal.glsl b/manim/renderer/opengl/shaders/include/get_unit_normal.glsl similarity index 100% rename from manim/renderer/shaders/include/get_unit_normal.glsl rename to manim/renderer/opengl/shaders/include/get_unit_normal.glsl diff --git a/manim/renderer/shaders/include/position_point_into_frame.glsl b/manim/renderer/opengl/shaders/include/position_point_into_frame.glsl similarity index 100% rename from manim/renderer/shaders/include/position_point_into_frame.glsl rename to manim/renderer/opengl/shaders/include/position_point_into_frame.glsl diff --git a/manim/renderer/shaders/include/quadratic_bezier_distance.glsl b/manim/renderer/opengl/shaders/include/quadratic_bezier_distance.glsl similarity index 100% rename from manim/renderer/shaders/include/quadratic_bezier_distance.glsl rename to manim/renderer/opengl/shaders/include/quadratic_bezier_distance.glsl diff --git a/manim/renderer/shaders/include/quadratic_bezier_geometry_functions.glsl b/manim/renderer/opengl/shaders/include/quadratic_bezier_geometry_functions.glsl similarity index 100% rename from manim/renderer/shaders/include/quadratic_bezier_geometry_functions.glsl rename to manim/renderer/opengl/shaders/include/quadratic_bezier_geometry_functions.glsl diff --git a/manim/renderer/shaders/manim_coords/frag.glsl b/manim/renderer/opengl/shaders/manim_coords/frag.glsl similarity index 100% rename from manim/renderer/shaders/manim_coords/frag.glsl rename to manim/renderer/opengl/shaders/manim_coords/frag.glsl diff --git a/manim/renderer/shaders/manim_coords/vert.glsl b/manim/renderer/opengl/shaders/manim_coords/vert.glsl similarity index 100% rename from manim/renderer/shaders/manim_coords/vert.glsl rename to manim/renderer/opengl/shaders/manim_coords/vert.glsl diff --git a/manim/renderer/shaders/quadratic_bezier_fill/frag.glsl b/manim/renderer/opengl/shaders/quadratic_bezier_fill/frag.glsl similarity index 100% rename from manim/renderer/shaders/quadratic_bezier_fill/frag.glsl rename to manim/renderer/opengl/shaders/quadratic_bezier_fill/frag.glsl diff --git a/manim/renderer/shaders/quadratic_bezier_fill/geom.glsl b/manim/renderer/opengl/shaders/quadratic_bezier_fill/geom.glsl similarity index 100% rename from manim/renderer/shaders/quadratic_bezier_fill/geom.glsl rename to manim/renderer/opengl/shaders/quadratic_bezier_fill/geom.glsl diff --git a/manim/renderer/shaders/quadratic_bezier_fill/vert.glsl b/manim/renderer/opengl/shaders/quadratic_bezier_fill/vert.glsl similarity index 100% rename from manim/renderer/shaders/quadratic_bezier_fill/vert.glsl rename to manim/renderer/opengl/shaders/quadratic_bezier_fill/vert.glsl diff --git a/manim/renderer/shaders/quadratic_bezier_stroke/frag.glsl b/manim/renderer/opengl/shaders/quadratic_bezier_stroke/frag.glsl similarity index 100% rename from manim/renderer/shaders/quadratic_bezier_stroke/frag.glsl rename to manim/renderer/opengl/shaders/quadratic_bezier_stroke/frag.glsl diff --git a/manim/renderer/shaders/quadratic_bezier_stroke/geom.glsl b/manim/renderer/opengl/shaders/quadratic_bezier_stroke/geom.glsl similarity index 100% rename from manim/renderer/shaders/quadratic_bezier_stroke/geom.glsl rename to manim/renderer/opengl/shaders/quadratic_bezier_stroke/geom.glsl diff --git a/manim/renderer/shaders/quadratic_bezier_stroke/vert.glsl b/manim/renderer/opengl/shaders/quadratic_bezier_stroke/vert.glsl similarity index 100% rename from manim/renderer/shaders/quadratic_bezier_stroke/vert.glsl rename to manim/renderer/opengl/shaders/quadratic_bezier_stroke/vert.glsl diff --git a/manim/renderer/shaders/simple_vert.glsl b/manim/renderer/opengl/shaders/simple_vert.glsl similarity index 100% rename from manim/renderer/shaders/simple_vert.glsl rename to manim/renderer/opengl/shaders/simple_vert.glsl diff --git a/manim/renderer/shaders/surface/frag.glsl b/manim/renderer/opengl/shaders/surface/frag.glsl similarity index 100% rename from manim/renderer/shaders/surface/frag.glsl rename to manim/renderer/opengl/shaders/surface/frag.glsl diff --git a/manim/renderer/shaders/surface/vert.glsl b/manim/renderer/opengl/shaders/surface/vert.glsl similarity index 100% rename from manim/renderer/shaders/surface/vert.glsl rename to manim/renderer/opengl/shaders/surface/vert.glsl diff --git a/manim/renderer/shaders/test/frag.glsl b/manim/renderer/opengl/shaders/test/frag.glsl similarity index 100% rename from manim/renderer/shaders/test/frag.glsl rename to manim/renderer/opengl/shaders/test/frag.glsl diff --git a/manim/renderer/shaders/test/vert.glsl b/manim/renderer/opengl/shaders/test/vert.glsl similarity index 100% rename from manim/renderer/shaders/test/vert.glsl rename to manim/renderer/opengl/shaders/test/vert.glsl diff --git a/manim/renderer/shaders/textured_surface/frag.glsl b/manim/renderer/opengl/shaders/textured_surface/frag.glsl similarity index 100% rename from manim/renderer/shaders/textured_surface/frag.glsl rename to manim/renderer/opengl/shaders/textured_surface/frag.glsl diff --git a/manim/renderer/shaders/textured_surface/vert.glsl b/manim/renderer/opengl/shaders/textured_surface/vert.glsl similarity index 100% rename from manim/renderer/shaders/textured_surface/vert.glsl rename to manim/renderer/opengl/shaders/textured_surface/vert.glsl diff --git a/manim/renderer/shaders/true_dot/frag.glsl b/manim/renderer/opengl/shaders/true_dot/frag.glsl similarity index 100% rename from manim/renderer/shaders/true_dot/frag.glsl rename to manim/renderer/opengl/shaders/true_dot/frag.glsl diff --git a/manim/renderer/shaders/true_dot/geom.glsl b/manim/renderer/opengl/shaders/true_dot/geom.glsl similarity index 100% rename from manim/renderer/shaders/true_dot/geom.glsl rename to manim/renderer/opengl/shaders/true_dot/geom.glsl diff --git a/manim/renderer/shaders/true_dot/vert.glsl b/manim/renderer/opengl/shaders/true_dot/vert.glsl similarity index 100% rename from manim/renderer/shaders/true_dot/vert.glsl rename to manim/renderer/opengl/shaders/true_dot/vert.glsl diff --git a/manim/renderer/shaders/vectorized_mobject_fill/frag.glsl b/manim/renderer/opengl/shaders/vectorized_mobject_fill/frag.glsl similarity index 100% rename from manim/renderer/shaders/vectorized_mobject_fill/frag.glsl rename to manim/renderer/opengl/shaders/vectorized_mobject_fill/frag.glsl diff --git a/manim/renderer/shaders/vectorized_mobject_fill/vert.glsl b/manim/renderer/opengl/shaders/vectorized_mobject_fill/vert.glsl similarity index 100% rename from manim/renderer/shaders/vectorized_mobject_fill/vert.glsl rename to manim/renderer/opengl/shaders/vectorized_mobject_fill/vert.glsl diff --git a/manim/renderer/shaders/vectorized_mobject_stroke/frag.glsl b/manim/renderer/opengl/shaders/vectorized_mobject_stroke/frag.glsl similarity index 100% rename from manim/renderer/shaders/vectorized_mobject_stroke/frag.glsl rename to manim/renderer/opengl/shaders/vectorized_mobject_stroke/frag.glsl diff --git a/manim/renderer/shaders/vectorized_mobject_stroke/vert.glsl b/manim/renderer/opengl/shaders/vectorized_mobject_stroke/vert.glsl similarity index 100% rename from manim/renderer/shaders/vectorized_mobject_stroke/vert.glsl rename to manim/renderer/opengl/shaders/vectorized_mobject_stroke/vert.glsl diff --git a/manim/renderer/shaders/vertex_colors/frag.glsl b/manim/renderer/opengl/shaders/vertex_colors/frag.glsl similarity index 100% rename from manim/renderer/shaders/vertex_colors/frag.glsl rename to manim/renderer/opengl/shaders/vertex_colors/frag.glsl diff --git a/manim/renderer/shaders/vertex_colors/vert.glsl b/manim/renderer/opengl/shaders/vertex_colors/vert.glsl similarity index 100% rename from manim/renderer/shaders/vertex_colors/vert.glsl rename to manim/renderer/opengl/shaders/vertex_colors/vert.glsl diff --git a/manim/renderer/vectorized_mobject_rendering.py b/manim/renderer/opengl/vectorized_mobject_rendering.py similarity index 97% rename from manim/renderer/vectorized_mobject_rendering.py rename to manim/renderer/opengl/vectorized_mobject_rendering.py index f4c85b05d6..f173cf9f01 100644 --- a/manim/renderer/vectorized_mobject_rendering.py +++ b/manim/renderer/opengl/vectorized_mobject_rendering.py @@ -7,14 +7,13 @@ import numpy as np if TYPE_CHECKING: - from manim.renderer.opengl_renderer import ( - OpenGLRenderer, - OpenGLVMobject, - ) + from manim.mobject.opengl.opengl_vectorized_mobject import OpenGLVMobject from manim.typing import MatrixMN -from ..utils import opengl -from ..utils.space_ops import cross2d, earclip_triangulation + from .renderer import OpenGLRenderer + +from ...utils import opengl +from ...utils.space_ops import cross2d, earclip_triangulation from .shader import Shader __all__ = [ diff --git a/manim/renderer/opengl_renderer_window.py b/manim/renderer/opengl/window.py similarity index 98% rename from manim/renderer/opengl_renderer_window.py rename to manim/renderer/opengl/window.py index 3cf2215d26..b7391716b2 100644 --- a/manim/renderer/opengl_renderer_window.py +++ b/manim/renderer/opengl/window.py @@ -7,10 +7,10 @@ from moderngl_window.timers.clock import Timer from screeninfo import Monitor, get_monitors -from .. import __version__, config +from ... import __version__, config if TYPE_CHECKING: - from .opengl_renderer import OpenGLRenderer + from .renderer import OpenGLRenderer __all__ = ["Window"] diff --git a/manim/scene/moving_camera_scene.py b/manim/scene/moving_camera_scene.py index 70157898ef..e5e2d0a858 100644 --- a/manim/scene/moving_camera_scene.py +++ b/manim/scene/moving_camera_scene.py @@ -1,4 +1,4 @@ -"""A scene whose camera can be moved around. +"""Scene variant configured with :class:`MovingCamera`. .. SEEALSO:: @@ -10,7 +10,7 @@ .. manim:: ChangingCameraWidthAndRestore - class ChangingCameraWidthAndRestore(MovingCameraScene): + class ChangingCameraWidthAndRestore(Scene): def construct(self): text = Text("Hello World").set_color(BLUE) self.add(text) @@ -22,7 +22,7 @@ def construct(self): .. manim:: MovingCameraCenter - class MovingCameraCenter(MovingCameraScene): + class MovingCameraCenter(Scene): def construct(self): s = Square(color=RED, fill_opacity=0.5).move_to(2 * LEFT) t = Triangle(color=GREEN, fill_opacity=0.5).move_to(2 * RIGHT) @@ -35,7 +35,7 @@ def construct(self): .. manim:: MovingAndZoomingCamera - class MovingAndZoomingCamera(MovingCameraScene): + class MovingAndZoomingCamera(Scene): def construct(self): s = Square(color=BLUE, fill_opacity=0.5).move_to(2 * LEFT) t = Triangle(color=YELLOW, fill_opacity=0.5).move_to(2 * RIGHT) @@ -48,7 +48,7 @@ def construct(self): .. manim:: MovingCameraOnGraph - class MovingCameraOnGraph(MovingCameraScene): + class MovingCameraOnGraph(Scene): def construct(self): self.camera.frame.save_state() @@ -66,7 +66,7 @@ def construct(self): .. manim:: SlidingMultipleFrames - class SlidingMultipleFrames(MovingCameraScene): + class SlidingMultipleFrames(Scene): def construct(self): def create_frame(number): frame = Rectangle(width=16, height=9) @@ -91,51 +91,17 @@ def create_frame(number): from typing import Any -from manim.animation.animation import Animation -from manim.mobject.mobject import Mobject - -from ..camera.camera import Camera -from ..camera.moving_camera import MovingCamera +from ..renderer.cairo.camera import Camera, MovingCamera from ..scene.scene import Scene -from ..utils.family import extract_mobject_family_members -from ..utils.iterables import list_update class MovingCameraScene(Scene): - """ - This is a Scene, with special configurations and properties that - make it suitable for cases where the camera must be moved around. - - Note: Examples are included in the moving_camera_scene module - documentation, see below in the 'see also' section. + """A scene whose default camera class is :class:`MovingCamera`. - .. SEEALSO:: - - :mod:`.moving_camera_scene` - :class:`.MovingCamera` + Its camera exposes the standard animatable frame and auto-zoom behavior. """ def __init__( self, camera_class: type[Camera] = MovingCamera, **kwargs: Any ) -> None: super().__init__(camera_class=camera_class, **kwargs) - - def get_moving_mobjects(self, *animations: Animation) -> list[Mobject]: - """ - This method returns a list of all of the Mobjects in the Scene that - are moving, that are also in the animations passed. - - Parameters - ---------- - *animations - The Animations whose mobjects will be checked. - """ - moving_mobjects = super().get_moving_mobjects(*animations) - all_moving_mobjects = extract_mobject_family_members(moving_mobjects) - movement_indicators = self.renderer.camera.get_mobjects_indicating_movement() # type: ignore[union-attr] - for movement_indicator in movement_indicators: - if movement_indicator in all_moving_mobjects: - # When one of these is moving, the camera should - # consider all mobjects to be moving - return list_update(self.mobjects, moving_mobjects) - return moving_mobjects diff --git a/manim/scene/scene.py b/manim/scene/scene.py index 29f69d5c52..dd4d16e4b2 100644 --- a/manim/scene/scene.py +++ b/manim/scene/scene.py @@ -40,7 +40,7 @@ from manim import __version__ from manim.data_structures import MethodWithArgs from manim.mobject.mobject import Mobject -from manim.mobject.opengl.opengl_mobject import OpenGLPoint +from manim.mobject.opengl.opengl_mobject import OpenGLMobject, OpenGLPoint from .. import config, logger from .._config.logger_utils import set_file_logger @@ -53,12 +53,13 @@ ) from .._config.render_session import resolve_render_session from ..animation.animation import Animation, Wait, prepare_animation -from ..camera.camera import Camera from ..constants import * from ..manager import Manager -from ..renderer.cairo_renderer import CairoRenderer -from ..renderer.opengl_renderer import OpenGLCamera, OpenGLMobject, OpenGLRenderer -from ..renderer.shader import Object3D +from ..renderer.cairo import CairoRenderer +from ..renderer.cairo.camera import Camera +from ..renderer.opengl.camera import OpenGLCamera +from ..renderer.opengl.renderer import OpenGLRenderer +from ..renderer.opengl.shader import Object3D from ..scene.scene_file_writer import _SceneFileWriterSettings from ..utils import opengl, space_ops from ..utils.exceptions import RerunSceneException @@ -71,6 +72,8 @@ from types import FrameType from typing import Self, TypeAlias + from PIL.Image import Image + from manim.typing import Point3D SceneInteractAction: TypeAlias = ( @@ -319,6 +322,36 @@ def render(self, preview: bool = False) -> bool: """ return self._get_manager().render(preview) + def get_image(self) -> Image: + """Return a snapshot of the scene's current mobjects as a PIL image. + + The snapshot uses the current camera view, including manual changes since + the last animation, and the pixel dimensions chosen when the renderer was + created. Call between animations or at an idle prompt:: + + self.add(Square()) + self.get_image().save("checkpoint.png") + + Notes + ----- + A snapshot leaves animations, updaters, and scene time unchanged. It draws + the existing mobjects without calling :meth:`construct` or appending a + movie frame. After :meth:`play`, it shows the result of animation finish + and cleanup, which can differ from the last frame written to the video. + + The returned image retains its pixels after the renderer is closed. + OpenGL snapshots must be requested on the thread that created the + rendering context. + """ + return self._get_manager().get_image() + + def show(self) -> None: + """Draw current state and open it with PIL's external image viewer. + + In a notebook, display :meth:`get_image`'s result directly instead. + """ + self.get_image().show() + def _get_manager(self) -> Manager[Self]: """Return this scene's manager, creating it for legacy entry points.""" manager = self.manager @@ -970,8 +1003,21 @@ def consider_moving(mob: Mobject) -> bool: or any(consider_moving(m) for m in mob.submobjects) ) + if self.always_update_mobjects or self.updaters: + return list(self.mobjects) + i = next((i for i, mob in enumerate(mobjects) if consider_moving(mob)), None) - return [] if i is None else mobjects[i:] + moving_mobjects = [] if i is None else mobjects[i:] + movement_indicators: list[Mobject] = getattr( + self.camera, + "get_mobjects_indicating_movement", + lambda: [], + )() + moving_family = extract_mobject_family_members(moving_mobjects) + if any(indicator in moving_family for indicator in movement_indicators): + # A camera control changes the projection of all scene mobjects. + return list_update(self.mobjects, moving_mobjects) + return moving_mobjects def get_moving_and_static_mobjects( self, animations: Iterable[Animation] diff --git a/manim/scene/three_d_scene.py b/manim/scene/three_d_scene.py index 7d2337437e..9d3edab568 100644 --- a/manim/scene/three_d_scene.py +++ b/manim/scene/three_d_scene.py @@ -19,11 +19,11 @@ from .. import config from ..animation.animation import Animation from ..animation.transform import Transform -from ..camera.three_d_camera import ThreeDCamera -from ..constants import DEGREES, RendererType +from ..constants import DEGREES from ..mobject.mobject import Mobject from ..mobject.types.vectorized_mobject import VectorizedPoint, VGroup -from ..renderer.opengl_renderer import OpenGLCamera +from ..renderer.cairo.camera import ThreeDCamera +from ..renderer.opengl.camera import OpenGLCamera from ..scene.scene import Scene from ..utils.config_ops import merge_dicts_recursively @@ -86,18 +86,35 @@ def set_camera_orientation( The new center of the camera frame in cartesian coordinates. """ - if phi is not None: - self.renderer.camera.set_phi(phi) - if theta is not None: - self.renderer.camera.set_theta(theta) - if focal_distance is not None: - self.renderer.camera.set_focal_distance(focal_distance) - if gamma is not None: - self.renderer.camera.set_gamma(gamma) - if zoom is not None: - self.renderer.camera.set_zoom(zoom) - if frame_center is not None: - self.renderer.camera._frame_center.move_to(frame_center) + if isinstance(self.camera, ThreeDCamera): + if phi is not None: + self.camera.set_phi(phi) + if theta is not None: + self.camera.set_theta(theta) + if focal_distance is not None: + self.camera.set_focal_distance(focal_distance) + if gamma is not None: + self.camera.set_gamma(gamma) + if zoom is not None: + self.camera.set_zoom(zoom) + if frame_center is not None: + self.camera.frame.move_to(frame_center) + elif isinstance(self.camera, OpenGLCamera): + if phi is not None: + self.camera.set_phi(phi) + if theta is not None: + self.camera.set_theta(theta) + if gamma is not None: + self.camera.set_gamma(gamma) + if zoom is not None: + self.camera.scale(config.frame_height / (zoom * self.camera.height)) + if focal_distance is not None: + warnings.warn( + "focal distance of OpenGLCamera can not be adjusted.", + stacklevel=2, + ) + if frame_center is not None: + self.camera.move_to(frame_center) def begin_ambient_camera_rotation(self, rate: float = 0.02, about: str = "theta"): """ @@ -116,7 +133,7 @@ def begin_ambient_camera_rotation(self, rate: float = 0.02, about: str = "theta" # can begin and end smoothly about: str = about.lower() try: - if config.renderer == RendererType.CAIRO: + if isinstance(self.camera, ThreeDCamera): trackers = { "theta": self.camera.theta_tracker, "phi": self.camera.phi_tracker, @@ -125,14 +142,15 @@ def begin_ambient_camera_rotation(self, rate: float = 0.02, about: str = "theta" x: ValueTracker = trackers[about] x.add_updater(lambda m, dt: x.increment_value(rate * dt)) self.add(x) - elif config.renderer == RendererType.OPENGL: - cam: OpenGLCamera = self.camera + elif isinstance(self.camera, OpenGLCamera): methods = { - "theta": cam.increment_theta, - "phi": cam.increment_phi, - "gamma": cam.increment_gamma, + "theta": self.camera.increment_theta, + "phi": self.camera.increment_phi, + "gamma": self.camera.increment_gamma, } - cam.add_updater(lambda m, dt: methods[about](rate * dt)) + self.camera.add_updater( + lambda m, dt: methods[about](rate * dt), + ) self.add(self.camera) except Exception as e: raise ValueError("Invalid ambient rotation angle.") from e @@ -141,7 +159,7 @@ def stop_ambient_camera_rotation(self, about="theta"): """This method stops all ambient camera rotation.""" about: str = about.lower() try: - if config.renderer == RendererType.CAIRO: + if isinstance(self.camera, ThreeDCamera): trackers = { "theta": self.camera.theta_tracker, "phi": self.camera.phi_tracker, @@ -150,7 +168,7 @@ def stop_ambient_camera_rotation(self, about="theta"): x: ValueTracker = trackers[about] x.clear_updaters() self.remove(x) - elif config.renderer == RendererType.OPENGL: + elif isinstance(self.camera, OpenGLCamera): self.camera.clear_updaters() except Exception as e: raise ValueError("Invalid ambient rotation angle.") from e @@ -249,8 +267,7 @@ def move_camera( """ anims = [] - if config.renderer == RendererType.CAIRO: - self.camera: ThreeDCamera + if isinstance(self.camera, ThreeDCamera): value_tracker_pairs = [ (phi, self.camera.phi_tracker), (theta, self.camera.theta_tracker), @@ -262,10 +279,9 @@ def move_camera( if value is not None: anims.append(tracker.animate.set_value(value)) if frame_center is not None: - anims.append(self.camera._frame_center.animate.move_to(frame_center)) - elif config.renderer == RendererType.OPENGL: - cam: OpenGLCamera = self.camera - cam2 = cam.copy() + anims.append(self.camera.frame.animate.move_to(frame_center)) + elif isinstance(self.camera, OpenGLCamera): + cam2 = self.camera.copy() methods = { "theta": cam2.set_theta, "phi": cam2.set_phi, @@ -280,7 +296,7 @@ def move_camera( zoom_value = None if zoom is not None: - zoom_value = config.frame_height / (zoom * cam.height) + zoom_value = config.frame_height / (zoom * self.camera.height) for value, method in [ [theta, "theta"], @@ -298,34 +314,13 @@ def move_camera( stacklevel=2, ) - anims += [Transform(cam, cam2)] + anims += [Transform(self.camera, cam2)] self.play(*anims + added_anims, **kwargs) - # These lines are added to improve performance. If manim thinks that frame_center is moving, - # it is required to redraw every object. These lines remove frame_center from the Scene once - # its animation is done, ensuring that manim does not think that it is moving. Since the - # frame_center is never actually drawn, this shouldn't break anything. - if frame_center is not None and config.renderer == RendererType.CAIRO: - self.remove(self.camera._frame_center) - - def get_moving_mobjects(self, *animations: Animation): - """ - This method returns a list of all of the Mobjects in the Scene that - are moving, that are also in the animations passed. - - Parameters - ---------- - *animations - The animations whose mobjects will be checked. - """ - moving_mobjects = super().get_moving_mobjects(*animations) - camera_mobjects = self.renderer.camera.get_value_trackers() + [ - self.renderer.camera._frame_center, - ] - if any(cm in moving_mobjects for cm in camera_mobjects): - return self.mobjects - return moving_mobjects + # The semantic camera frame is a control mobject, not scene content. + if frame_center is not None and isinstance(self.camera, ThreeDCamera): + self.remove(self.camera.frame) def add_fixed_orientation_mobjects(self, *mobjects: Mobject, **kwargs): """ @@ -345,10 +340,10 @@ def add_fixed_orientation_mobjects(self, *mobjects: Mobject, **kwargs): use_static_center_func : bool center_func : function """ - if config.renderer == RendererType.CAIRO: + if isinstance(self.camera, ThreeDCamera): self.add(*mobjects) - self.renderer.camera.add_fixed_orientation_mobjects(*mobjects, **kwargs) - elif config.renderer == RendererType.OPENGL: + self.camera.add_fixed_orientation_mobjects(*mobjects, **kwargs) + elif isinstance(self.camera, OpenGLCamera): for mob in mobjects: mob: OpenGLMobject mob.fix_orientation() @@ -366,11 +361,10 @@ def add_fixed_in_frame_mobjects(self, *mobjects: Mobject): *mobjects The Mobjects whose orientation must be fixed. """ - if config.renderer == RendererType.CAIRO: + if isinstance(self.camera, ThreeDCamera): self.add(*mobjects) - self.camera: ThreeDCamera self.camera.add_fixed_in_frame_mobjects(*mobjects) - elif config.renderer == RendererType.OPENGL: + elif isinstance(self.camera, OpenGLCamera): for mob in mobjects: mob: OpenGLMobject mob.fix_in_frame() @@ -388,9 +382,9 @@ def remove_fixed_orientation_mobjects(self, *mobjects: Mobject): *mobjects The Mobjects whose orientation must be unfixed. """ - if config.renderer == RendererType.CAIRO: - self.renderer.camera.remove_fixed_orientation_mobjects(*mobjects) - elif config.renderer == RendererType.OPENGL: + if isinstance(self.camera, ThreeDCamera): + self.camera.remove_fixed_orientation_mobjects(*mobjects) + elif isinstance(self.camera, OpenGLCamera): for mob in mobjects: mob: OpenGLMobject mob.unfix_orientation() @@ -407,9 +401,9 @@ def remove_fixed_in_frame_mobjects(self, *mobjects: Mobject): *mobjects The Mobjects whose position and orientation must be unfixed. """ - if config.renderer == RendererType.CAIRO: - self.renderer.camera.remove_fixed_in_frame_mobjects(*mobjects) - elif config.renderer == RendererType.OPENGL: + if isinstance(self.camera, ThreeDCamera): + self.camera.remove_fixed_in_frame_mobjects(*mobjects) + elif isinstance(self.camera, OpenGLCamera): for mob in mobjects: mob: OpenGLMobject mob.unfix_from_frame() diff --git a/manim/scene/vector_space_scene.py b/manim/scene/vector_space_scene.py index 4efdfed6dd..1bbc89797c 100644 --- a/manim/scene/vector_space_scene.py +++ b/manim/scene/vector_space_scene.py @@ -10,13 +10,13 @@ import numpy as np from manim.animation.creation import DrawBorderThenFill, Group -from manim.camera.camera import Camera from manim.mobject.geometry.arc import Dot from manim.mobject.geometry.line import Arrow, Line, Vector from manim.mobject.geometry.polygram import Rectangle from manim.mobject.graphing.coordinate_systems import Axes, NumberPlane from manim.mobject.opengl.opengl_mobject import OpenGLMobject from manim.mobject.text.tex_mobject import MathTex, Tex +from manim.renderer.cairo.camera import Camera from manim.utils.config_ops import update_dict_recursively from .. import config diff --git a/manim/scene/zoomed_scene.py b/manim/scene/zoomed_scene.py index 57c89b1ad6..8e0d7908bd 100644 --- a/manim/scene/zoomed_scene.py +++ b/manim/scene/zoomed_scene.py @@ -52,12 +52,10 @@ def construct(self): from typing import TYPE_CHECKING, Any from ..animation.transform import ApplyMethod -from ..camera.camera import Camera -from ..camera.moving_camera import MovingCamera -from ..camera.multi_camera import MultiCamera from ..constants import * from ..mobject.types.image_mobject import ImageMobjectFromCamera -from ..renderer.opengl_renderer import OpenGLCamera +from ..renderer.cairo.camera import Camera, MovingCamera, MultiCamera +from ..renderer.opengl.camera import OpenGLCamera from ..scene.moving_camera_scene import MovingCameraScene if TYPE_CHECKING: diff --git a/manim/utils/caching.py b/manim/utils/caching.py index 78e011071a..e226957f36 100644 --- a/manim/utils/caching.py +++ b/manim/utils/caching.py @@ -79,7 +79,7 @@ def clear_segment_cache(directory: Path) -> int: if TYPE_CHECKING: - from manim.renderer.opengl_renderer import OpenGLRenderer + from manim.renderer.opengl import OpenGLRenderer from manim.scene.scene import Scene diff --git a/manim/utils/hashing.py b/manim/utils/hashing.py index 3b36e00176..a58743b349 100644 --- a/manim/utils/hashing.py +++ b/manim/utils/hashing.py @@ -18,9 +18,9 @@ if TYPE_CHECKING: from manim.animation.animation import Animation - from manim.camera.camera import Camera from manim.mobject.mobject import Mobject - from manim.renderer.opengl_renderer import OpenGLCamera + from manim.renderer.cairo.camera import Camera + from manim.renderer.opengl.camera import OpenGLCamera from manim.scene.scene import Scene __all__ = ["KEYS_TO_FILTER_OUT", "get_hash_from_play_call", "get_json"] diff --git a/manim/utils/ipython_magic.py b/manim/utils/ipython_magic.py index 7585768d11..b11187c124 100644 --- a/manim/utils/ipython_magic.py +++ b/manim/utils/ipython_magic.py @@ -9,7 +9,7 @@ from typing import Any from manim import config, logger, tempconfig -from manim.renderer.shader import shader_program_cache +from manim.renderer.opengl.shader import shader_program_cache from ..constants import RendererType @@ -136,7 +136,7 @@ def construct(self): renderer = None if config.renderer == RendererType.OPENGL: - from manim.renderer.opengl_renderer import OpenGLRenderer + from manim.renderer.opengl import OpenGLRenderer renderer = OpenGLRenderer() diff --git a/manim/utils/testing/_test_class_makers.py b/manim/utils/testing/_test_class_makers.py index 49a1a85148..9915db180b 100644 --- a/manim/utils/testing/_test_class_makers.py +++ b/manim/utils/testing/_test_class_makers.py @@ -3,8 +3,8 @@ from collections.abc import Callable from typing import Any -from manim.renderer.cairo_renderer import CairoRenderer -from manim.renderer.opengl_renderer import OpenGLRenderer +from manim.renderer.cairo import CairoRenderer +from manim.renderer.opengl import OpenGLRenderer from manim.scene.scene import Scene from manim.scene.scene_file_writer import SceneFileWriter, _SceneFileWriterSettings from manim.typing import PixelArray, StrPath diff --git a/manim/utils/testing/frames_comparison.py b/manim/utils/testing/frames_comparison.py index 01061d6860..ead2b866e4 100644 --- a/manim/utils/testing/frames_comparison.py +++ b/manim/utils/testing/frames_comparison.py @@ -13,9 +13,9 @@ from manim import Scene from manim._config import tempconfig from manim._config.utils import ManimConfig -from manim.camera.three_d_camera import ThreeDCamera -from manim.renderer.cairo_renderer import CairoRenderer -from manim.renderer.opengl_renderer import OpenGLRenderer +from manim.renderer.cairo import CairoRenderer +from manim.renderer.cairo.camera import ThreeDCamera +from manim.renderer.opengl import OpenGLRenderer from manim.scene.three_d_scene import ThreeDScene from manim.typing import StrPath diff --git a/tests/control_data/logs_data/BasicSceneLoggingTest.txt b/tests/control_data/logs_data/BasicSceneLoggingTest.txt index 0f49c5618f..e0df62e239 100644 --- a/tests/control_data/logs_data/BasicSceneLoggingTest.txt +++ b/tests/control_data/logs_data/BasicSceneLoggingTest.txt @@ -2,7 +2,7 @@ {"levelname": "DEBUG", "module": "hashing", "message": "Hashing ..."} {"levelname": "DEBUG", "module": "hashing", "message": "Hashing done in <> s."} {"levelname": "DEBUG", "module": "hashing", "message": "Hash generated : <>"} -{"levelname": "DEBUG", "module": "cairo_renderer", "message": "List of the first few animation hashes of the scene: <>"} +{"levelname": "DEBUG", "module": "renderer", "message": "List of the first few animation hashes of the scene: <>"} {"levelname": "INFO", "module": "scene_file_writer", "message": "Animation 0 : Partial movie file written in <>"} {"levelname": "INFO", "module": "scene_file_writer", "message": "Combining to Movie file."} {"levelname": "DEBUG", "module": "scene_file_writer", "message": "Partial movie files to combine (1 files): <>"} diff --git a/tests/module/test_cairo_target.py b/tests/module/test_cairo_target.py new file mode 100644 index 0000000000..870723e6e0 --- /dev/null +++ b/tests/module/test_cairo_target.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import gc +import weakref + +import numpy as np +import pytest +from PIL import Image + +from manim import Camera +from manim.renderer.cairo.rendering import _CairoDrawingContext +from manim.renderer.cairo.target import _CairoRasterSettings, _CairoRenderTarget + + +@pytest.fixture +def target(): + target = _CairoRenderTarget( + _CairoRasterSettings( + pixel_width=8, + pixel_height=4, + base_pixel_width=8, + base_pixel_height=4, + ) + ) + try: + yield target + finally: + target.close() + + +@pytest.mark.parametrize( + "operation", + [ + lambda target: target.pixels, + lambda target: target.reset(Camera(background_image="missing.png")), + lambda target: target.clear(), + lambda target: target.get_scratch_target(), + lambda target: target.set_pixels(np.zeros((4, 8, 4), dtype=np.uint8)), + lambda target: target.get_context(Camera()), + lambda target: target.get_background_image("missing.png"), + lambda target: target.read_pixels(), + lambda target: _CairoDrawingContext( + camera=Camera(), target=target, image_resolver=lambda _: None + ).draw([]), + ], + ids=[ + "pixels", + "reset", + "clear", + "scratch", + "set", + "context", + "background", + "read", + "draw", + ], +) +def test_closed_target_rejects_use(target, operation): + target.close() + with pytest.raises(RuntimeError, match="closed"): + operation(target) + + +def test_transferred_pixels_survive_close(target): + target.reset(Camera()) + pixels = target.read_pixels() + expected = pixels.copy() + target.close() + np.testing.assert_array_equal(pixels, expected) + + +def test_close_releases_owned_buffers_and_scratch(target): + camera = Camera() + target.reset(camera) + target.get_context(camera) + target.get_background_image(Image.new("RGBA", (8, 4))) + scratch = target.get_scratch_target() + scratch.reset(camera) + scratch.get_context(camera) + buffers = [ + weakref.ref(array) + for array in ( + target.pixels, + target._background, + scratch.pixels, + scratch._background, + *target.background_image_cache.values(), + ) + ] + + target.close() + target.close() + gc.collect() + + assert all(reference() is None for reference in buffers) + assert target.background_image_cache == {} + with pytest.raises(RuntimeError, match="closed"): + _ = scratch.pixels diff --git a/tests/module/test_camera_projection.py b/tests/module/test_camera_projection.py new file mode 100644 index 0000000000..30585b156e --- /dev/null +++ b/tests/module/test_camera_projection.py @@ -0,0 +1,94 @@ +"""Default frame queries stay fresh without changing projection arithmetic.""" + +import numpy as np +import pytest + +from manim import ( + RIGHT, + Camera, + Mobject, + ScreenRectangle, + Square, + ThreeDCamera, + VMobject, +) + + +@pytest.mark.parametrize("dtype", [np.float32, np.float64]) +def test_default_frame_center_matches_generic_query_after_mutations(dtype): + camera = Camera() + frame = camera.frame + frame.points = frame.points.astype(dtype) + + def check(): + np.testing.assert_array_equal( + frame.get_points_defining_boundary(), + VMobject.get_points_defining_boundary(frame), + ) + np.testing.assert_array_equal(camera.frame_center, Mobject.get_center(frame)) + assert camera.frame_center.dtype == np.float64 + + check() + frame.shift([2, -1, 0.5]).rotate(0.3).stretch(1.7, 0) + check() + # Direct point edits must not require a draw or cache invalidation. + frame.points[0] += [3, 4, 5] + check() + child = Square().shift(10 * RIGHT) + frame.add(child) + check() + child.shift(5 * RIGHT) + check() + frame.remove(child) + frame.clear_points() + check() + frame.set_points(np.array([[1.0, 2.0, 3.0]])) + check() + boundary = frame.get_points_defining_boundary() + boundary[:] = 0 + np.testing.assert_array_equal(frame.points, [[1, 2, 3]]) + + +def test_custom_frame_center_and_critical_point_hooks_remain_supported(): + class CustomFrame(ScreenRectangle): + def get_critical_point(self, direction): + return np.array([3.0, 4.0, 5.0]) + + frame = CustomFrame() + camera = ThreeDCamera(frame=frame) + np.testing.assert_array_equal(camera.frame_center, [3, 4, 5]) + frame.get_center = lambda: np.array([6.0, 7.0, 8.0]) + np.testing.assert_array_equal(camera.frame_center, [6, 7, 8]) + assert camera.frame is frame + + +@pytest.mark.parametrize("exponential", [False, True]) +@pytest.mark.parametrize("empty", [False, True]) +def test_projection_matches_previous_arithmetic(exponential, empty): + camera = ThreeDCamera( + phi=0.2, + theta=-0.7, + gamma=0.1, + focal_distance=4, + zoom=1.5, + exponential_projection=exponential, + ) + camera.frame.shift([1, 2, 3]) + points = np.array([[1, 2, -10], [1, 0, 0], [2, -3, 6], [0, 2, 20]], dtype=float) + if empty: + points = points[:0] + original = points.copy() + expected = np.dot(points - camera.frame_center, camera.get_rotation_matrix().T) + zs = expected[:, 2] + distance = camera.get_focal_distance() + for i in (0, 1): + if exponential: + factor = np.exp(zs / distance) + negative = zs < 0 + factor[negative] = distance / (distance - zs[negative]) + else: + factor = distance / (distance - zs) + factor[(distance - zs) < 0] = 10**6 + expected[:, i] *= factor * camera.get_zoom() + np.testing.assert_array_equal(camera.project_points(points), expected) + np.testing.assert_array_equal(points, original) diff --git a/tests/test_camera.py b/tests/test_camera.py index 44c54e7e4f..461547bf9b 100644 --- a/tests/test_camera.py +++ b/tests/test_camera.py @@ -1,6 +1,32 @@ from __future__ import annotations -from manim import MovingCamera, Square +import gc +import weakref +from types import SimpleNamespace +from unittest.mock import patch + +import numpy as np +import pytest +from PIL import Image + +from manim import ( + BLUE, + RED, + Camera, + Group, + ImageMobjectFromCamera, + Mobject, + MovingCamera, + MultiCamera, + Scene, + Square, + ThreeDCamera, + config, + tempconfig, +) +from manim.renderer.cairo import CairoRenderer +from manim.renderer.cairo.rendering import _CairoDrawingContext +from manim.utils.color import color_to_int_rgba def test_movingcamera_auto_zoom(): @@ -9,3 +35,375 @@ def test_movingcamera_auto_zoom(): margin = 0.5 camera.auto_zoom([square], margin=margin, animate=False) assert camera.frame.height == square.height + margin + + +def test_default_camera_is_movable(): + camera = Camera() + + camera.frame.move_to([2, 1, 0]).set(width=6) + + assert camera.frame_center.tolist() == [2, 1, 0] + assert camera.frame_width == 6 + + +def test_camera_frame_geometry_is_semantic_and_explicit(): + camera = Camera(frame_width=8, frame_height=4) + assert camera.frame_width == 8 + assert camera.frame_height == 4 + + custom_frame = Square(side_length=3).move_to([2, 1, 0]) + custom_camera = Camera(frame=custom_frame) + assert custom_camera.frame is custom_frame + assert custom_camera.frame_width == 3 + assert custom_camera.frame_height == 3 + np.testing.assert_array_equal(custom_camera.frame_center, [2, 1, 0]) + + +@pytest.mark.parametrize( + "camera_class", [Camera, MovingCamera, MultiCamera, ThreeDCamera] +) +@pytest.mark.parametrize("pixel_shape", [(128, 128), (96, 160), (192, 96)]) +def test_default_camera_preserves_square_geometry(camera_class, pixel_shape): + width, height = pixel_shape + with tempconfig({"pixel_width": width, "pixel_height": height}): + camera = camera_class() + assert camera.frame_width == pytest.approx(config.frame_width) + assert camera.frame_width / camera.frame_height == pytest.approx(width / height) + frame_points = camera.frame.points.copy() + renderer = CairoRenderer(camera=camera) + try: + renderer.render_mobjects( + [Square(fill_color="#ffffff", fill_opacity=1, stroke_width=0)], + ) + pixels = renderer.get_frame() + rows, columns = np.where(pixels[:, :, 0] > 128) + assert len(rows) > 0 + assert np.ptp(columns) == pytest.approx(np.ptp(rows), abs=1) + np.testing.assert_array_equal(camera.frame.points, frame_points) + finally: + renderer.close() + + +@pytest.mark.parametrize( + ("dimensions", "expected_width", "expected_height"), + [ + ({"frame_width": 6}, 6, 12), + ({"frame_height": 6}, 3, 6), + ({"frame_width": 6, "frame_height": 3}, 6, 3), + ], +) +def test_camera_resolves_only_unspecified_dimensions( + dimensions, expected_width, expected_height +): + with tempconfig({"pixel_width": 60, "pixel_height": 120}): + camera = Camera(**dimensions) + assert camera.frame_width == pytest.approx(expected_width) + assert camera.frame_height == pytest.approx(expected_height) + + +def test_default_scene_camera_auto_zoom(): + with tempconfig({"dry_run": True, "quality": "low_quality"}): + scene = Scene() + square = Square().move_to([2, 0, 0]) + scene.play(scene.camera.auto_zoom([square], margin=0.5)) + + assert scene.camera.frame_center.tolist() == square.get_center().tolist() + assert scene.camera.frame_height == square.height + 0.5 + + +def test_mobject_get_image_uses_temporary_renderer(): + with tempconfig({"pixel_width": 32, "pixel_height": 18}): + image = Square(color=BLUE, fill_opacity=1).get_image() + + pixels = np.asarray(image) + assert image.size == (32, 18) + assert np.any(pixels[:, :, 2] > 0) + + +def test_camera_backed_image_preserves_camera_aspect(): + camera = Camera() + + image = ImageMobjectFromCamera(camera) + + assert image.camera is camera + assert image.width / image.height == pytest.approx( + camera.frame_width / camera.frame_height, + ) + + +def test_renderer_owns_background_readback(): + with tempconfig({"pixel_width": 8, "pixel_height": 4}): + renderer = CairoRenderer( + camera=Camera(background_color=RED, background_opacity=0.5), + ) + try: + renderer.update_frame(None, mobjects=[Mobject()]) + pixels = renderer.get_frame() + expected = color_to_int_rgba(RED, 0.5) + assert np.all(pixels == expected) + + pixels[:] = 0 + assert np.all(renderer.get_frame() == expected) + finally: + renderer.close() + + +def test_background_image_is_loaded_by_renderer(tmp_path): + source = np.array( + [ + [[255, 0, 0, 255], [0, 255, 0, 192]], + [[0, 0, 255, 128], [255, 255, 0, 64]], + ], + dtype=np.uint8, + ) + image_path = tmp_path / "asymmetric.png" + Image.fromarray(source, mode="RGBA").save(image_path) + + with tempconfig({"pixel_width": 2, "pixel_height": 2}): + camera = Camera(background_image=str(image_path)) + renderer = CairoRenderer(camera=camera) + try: + renderer.update_frame(None, mobjects=[Mobject()]) + np.testing.assert_array_equal(renderer.get_frame(), source) + finally: + renderer.close() + + +def test_background_image_is_resized_to_target(tmp_path): + source = np.zeros((2, 4, 4), dtype=np.uint8) + source[:, :2] = [255, 0, 0, 255] + source[:, 2:] = [0, 0, 255, 255] + image_path = tmp_path / "wide.png" + Image.fromarray(source, mode="RGBA").save(image_path) + + with tempconfig({"pixel_width": 2, "pixel_height": 2}): + renderer = CairoRenderer(camera=Camera(background_image=str(image_path))) + try: + renderer.update_frame(None, mobjects=[Mobject()]) + expected = np.asarray( + Image.fromarray(source, mode="RGBA").resize((2, 2)), + dtype=np.uint8, + ) + np.testing.assert_array_equal(renderer.get_frame(), expected) + finally: + renderer.close() + + +def test_background_colored_mobjects_reuse_renderer_scratch_target(tmp_path): + image_path = tmp_path / "background.png" + Image.new("RGBA", (8, 4), (20, 40, 60, 255)).save(image_path) + + with tempconfig({"pixel_width": 8, "pixel_height": 4}): + square = Square().color_using_background_image(str(image_path)) + renderer = CairoRenderer() + try: + renderer.update_frame(None, mobjects=[square]) + scratch = renderer._target.get_scratch_target() + renderer.update_frame(None, mobjects=[square]) + assert renderer._target.get_scratch_target() is scratch + finally: + renderer.close() + + with pytest.raises(RuntimeError, match="closed"): + _ = scratch.pixels + + +def test_nested_view_excludes_its_own_display(): + nested_camera = Camera() + view = ImageMobjectFromCamera(nested_camera) + primary_camera = MultiCamera([view]) + renderer = CairoRenderer(camera=primary_camera) + + try: + with patch.object(_CairoDrawingContext, "draw", autospec=True) as draw: + renderer.update_frame(None, mobjects=[Group(view)]) + nested_draw = next( + call for call in draw.call_args_list if call.args[0].camera is nested_camera + ) + excluded = nested_draw.kwargs["excluded_mobjects"] + assert view in excluded + finally: + renderer.close() + + +def test_nested_target_size_tracks_display_size_and_closes(): + with tempconfig({"pixel_width": 100, "pixel_height": 50}): + view = ImageMobjectFromCamera(Camera()) + view.stretch_to_fit_width(4).stretch_to_fit_height(2) + primary_camera = MultiCamera([view]) + renderer = CairoRenderer(camera=primary_camera) + main_target = renderer._target + + renderer.update_frame(None, mobjects=[view]) + first_target = renderer._sub_targets[id(view)] + assert first_target.settings.pixel_width == max( + 1, + int(100 * view.width / primary_camera.frame_width), + ) + assert first_target.settings.pixel_height == max( + 1, + int(50 * view.height / primary_camera.frame_height), + ) + + view.stretch_to_fit_width(6) + renderer.update_frame(None, mobjects=[view]) + second_target = renderer._sub_targets[id(view)] + assert second_target is not first_target + with pytest.raises(RuntimeError, match="closed"): + _ = first_target.pixels + + renderer.close() + renderer.close() + assert renderer._sub_targets == {} + with pytest.raises(RuntimeError, match="closed"): + _ = main_target.pixels + with pytest.raises(RuntimeError, match="closed"): + _ = second_target.pixels + + +@pytest.mark.parametrize("pixel_shape", [(128, 128), (96, 160)]) +def test_nested_view_preserves_square_geometry(pixel_shape): + width, height = pixel_shape + with tempconfig({"pixel_width": width, "pixel_height": height}): + view = ImageMobjectFromCamera(Camera()) + view.set(width=8) + renderer = CairoRenderer(camera=MultiCamera([view])) + try: + renderer.render_mobjects( + [Square(fill_color="#ffffff", fill_opacity=1, stroke_width=0), view] + ) + pixels = renderer._sub_targets[id(view)].read_pixels() + rows, columns = np.where(pixels[:, :, 0] > 128) + assert len(rows) > 0 + assert np.ptp(columns) == pytest.approx(np.ptp(rows), abs=1) + finally: + renderer.close() + + +def test_removed_nested_views_release_their_target_subtree(): + with tempconfig({"pixel_width": 64, "pixel_height": 32}): + leaf = ImageMobjectFromCamera(Camera()) + branch = ImageMobjectFromCamera(MultiCamera([leaf])) + retained = ImageMobjectFromCamera(Camera()) + camera = MultiCamera([branch, retained]) + renderer = CairoRenderer(camera=camera) + try: + renderer.render_mobjects([branch, retained]) + branch_target = renderer._sub_targets[id(branch)] + leaf_target = renderer._sub_targets[id(leaf)] + retained_target = renderer._sub_targets[id(retained)] + + camera.image_mobjects_from_cameras.remove(branch) + renderer.render_mobjects([retained]) + + assert renderer._sub_targets == {id(retained): retained_target} + for retired in (branch_target, leaf_target): + with pytest.raises(RuntimeError, match="closed"): + _ = retired.pixels + assert retained_target.pixels.size > 0 + finally: + renderer.close() + + +def test_failed_nested_draw_releases_unfinished_target(): + with tempconfig({"pixel_width": 64, "pixel_height": 32}): + view = ImageMobjectFromCamera(Camera()) + renderer = CairoRenderer(camera=MultiCamera([view])) + try: + renderer.render_mobjects([view]) + target = renderer._sub_targets[id(view)] + with ( + patch.object( + _CairoDrawingContext, "draw", side_effect=ValueError("draw failed") + ), + pytest.raises(ValueError, match="draw failed"), + ): + renderer.render_mobjects([view]) + + assert renderer._sub_targets == {} + with pytest.raises(RuntimeError, match="closed"): + _ = target.pixels + renderer.render_mobjects([view]) + assert renderer._sub_targets[id(view)].pixels.size > 0 + finally: + renderer.close() + + +@pytest.mark.parametrize( + "operation", + [ + lambda renderer: renderer.render_mobjects([]), + lambda renderer: renderer.update_frame(None), + lambda renderer: renderer.update_frame(None, ignore_skipping=False), + lambda renderer: renderer.get_frame(), + lambda renderer: renderer.get_image(), + lambda renderer: renderer.save_static_frame_data( + SimpleNamespace(moving_mobjects=[]), [] + ), + ], + ids=["draw", "update", "skipped-update", "pixels", "image", "static-frame"], +) +def test_closed_renderer_rejects_drawing(operation): + with tempconfig({"pixel_width": 8, "pixel_height": 4}): + renderer = CairoRenderer(skip_animations=True) + renderer.close() + with pytest.raises(RuntimeError, match="closed"): + operation(renderer) + + +def test_renderer_close_releases_static_pixels(): + with tempconfig({"pixel_width": 32, "pixel_height": 18}): + renderer = CairoRenderer() + try: + renderer.save_static_frame_data( + SimpleNamespace(moving_mobjects=[]), [Square()] + ) + snapshot = weakref.ref(renderer.static_image) + renderer.close() + gc.collect() + assert renderer.static_image is None + assert snapshot() is None + finally: + renderer.close() + + +def test_nested_views_disable_unsafe_static_reuse(): + view = ImageMobjectFromCamera(Camera()) + renderer = CairoRenderer(camera=MultiCamera([view])) + scene = SimpleNamespace(moving_mobjects=[Square()]) + + try: + assert renderer.save_static_frame_data(scene, [view]) is None + assert renderer._render_all_mobjects is True + finally: + renderer.close() + + +def test_multicamera_reports_recursive_view_controls_without_recursing_cycles(): + three_d_camera = ThreeDCamera() + nested_camera = MultiCamera([ImageMobjectFromCamera(three_d_camera)]) + primary_camera = MultiCamera([ImageMobjectFromCamera(nested_camera)]) + nested_camera.add_image_mobject_from_camera(ImageMobjectFromCamera(primary_camera)) + + indicators = primary_camera.get_mobjects_indicating_movement() + + assert primary_camera.frame in indicators + assert nested_camera.frame in indicators + assert three_d_camera.theta_tracker in indicators + assert three_d_camera.zoom_tracker in indicators + + +def test_nested_camera_cycles_are_rejected(): + first = MultiCamera() + second = MultiCamera() + second_view = ImageMobjectFromCamera(second) + first_view = ImageMobjectFromCamera(first) + first.add_image_mobject_from_camera(second_view) + second.add_image_mobject_from_camera(first_view) + renderer = CairoRenderer(camera=first) + + try: + with pytest.raises(RuntimeError, match="composition cycle"): + renderer.render_mobjects([first_view, second_view]) + finally: + renderer.close() diff --git a/tests/test_graphical_units/control_data/camera/moving_camera_frame.npz b/tests/test_graphical_units/control_data/camera/moving_camera_frame.npz new file mode 100644 index 0000000000..0b2a666e3b Binary files /dev/null and b/tests/test_graphical_units/control_data/camera/moving_camera_frame.npz differ diff --git a/tests/test_graphical_units/control_data/camera/zoomed_camera_view.npz b/tests/test_graphical_units/control_data/camera/zoomed_camera_view.npz new file mode 100644 index 0000000000..48d4bb5fa2 Binary files /dev/null and b/tests/test_graphical_units/control_data/camera/zoomed_camera_view.npz differ diff --git a/tests/test_graphical_units/test_camera.py b/tests/test_graphical_units/test_camera.py new file mode 100644 index 0000000000..228075ce8a --- /dev/null +++ b/tests/test_graphical_units/test_camera.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from manim import ( + BLUE, + GREEN, + RED, + RIGHT, + Dot, + MultiCamera, + NumberPlane, + Scene, + Square, + ZoomedScene, +) +from manim.utils.testing.frames_comparison import frames_comparison + +__module_test__ = "camera" + + +@frames_comparison(base_scene=Scene) +def test_moving_camera_frame(scene: Scene): + plane = NumberPlane() + marker = Dot(2 * RIGHT, color=RED) + scene.add(plane, marker) + scene.play(scene.camera.frame.animate.set(width=6).move_to(marker)) + + +class _ZoomedCameraControlScene(ZoomedScene): + def __init__(self, renderer=None, **kwargs): + if renderer is not None: + renderer.camera = MultiCamera() + super().__init__(renderer=renderer, **kwargs) + + +@frames_comparison(base_scene=_ZoomedCameraControlScene) +def test_zoomed_camera_view(scene: ZoomedScene): + square = Square(color=BLUE, fill_opacity=0.35) + dot = Dot(color=GREEN) + scene.add(square, dot) + scene.activate_zooming(animate=False) + scene.play(dot.animate.shift(RIGHT)) diff --git a/tests/test_graphical_units/test_opengl.py b/tests/test_graphical_units/test_opengl.py index 6d36f8761a..c35a30fd2c 100644 --- a/tests/test_graphical_units/test_opengl.py +++ b/tests/test_graphical_units/test_opengl.py @@ -1,7 +1,7 @@ from __future__ import annotations from manim import * -from manim.renderer.opengl_renderer import OpenGLRenderer +from manim.renderer.opengl import OpenGLRenderer from manim.utils.testing.frames_comparison import frames_comparison __module_test__ = "opengl" diff --git a/tests/test_scene_rendering/opengl/test_opengl_renderer.py b/tests/test_scene_rendering/opengl/test_opengl_renderer.py index c2925b7372..419c8ed9c1 100644 --- a/tests/test_scene_rendering/opengl/test_opengl_renderer.py +++ b/tests/test_scene_rendering/opengl/test_opengl_renderer.py @@ -6,7 +6,8 @@ import numpy as np import pytest -from manim.renderer.opengl_renderer import OpenGLRenderer +from manim import Square +from manim.renderer.opengl import OpenGLRenderer from tests.assert_utils import assert_file_exists from tests.test_scene_rendering.simple_scenes import * @@ -96,7 +97,16 @@ def test_get_frame_with_live_preview_enabled(config, using_opengl_renderer): assert renderer.get_pixel_shape()[1] == frame.shape[0] assert frame.dtype == np.uint8 assert frame.flags.c_contiguous - renderer.window.close() + try: + elapsed = renderer.animation_elapsed_time + scene.add(Square(fill_opacity=1)) + image = scene.get_image() + assert image.size == renderer.get_pixel_shape() + assert not np.array_equal(image, frame) + np.testing.assert_array_equal(renderer.get_frame(), frame) + assert renderer.animation_elapsed_time == elapsed + finally: + renderer.window.close() def test_render_without_frame_output_skips_gpu_readback( diff --git a/tests/test_scene_rendering/test_cairo_renderer.py b/tests/test_scene_rendering/test_cairo_renderer.py index 0a2f38d28f..b0d713c371 100644 --- a/tests/test_scene_rendering/test_cairo_renderer.py +++ b/tests/test_scene_rendering/test_cairo_renderer.py @@ -88,7 +88,7 @@ def test_hash_logic_is_not_called_when_caching_is_disabled( using_temp_config, disabling_caching, ): - with patch("manim.renderer.cairo_renderer.get_hash_from_play_call") as mocked: + with patch("manim.renderer.cairo.renderer.get_hash_from_play_call") as mocked: scene = SquareToCircle() scene.render() mocked.assert_not_called() @@ -96,10 +96,10 @@ def test_hash_logic_is_not_called_when_caching_is_disabled( def test_hash_logic_is_called_when_caching_is_enabled(using_temp_config): - from manim.renderer.cairo_renderer import get_hash_from_play_call + from manim.renderer.cairo.renderer import get_hash_from_play_call with patch( - "manim.renderer.cairo_renderer.get_hash_from_play_call", + "manim.renderer.cairo.renderer.get_hash_from_play_call", wraps=get_hash_from_play_call, ) as mocked: scene = SquareToCircle() diff --git a/tests/test_scene_rendering/test_cli_flags.py b/tests/test_scene_rendering/test_cli_flags.py index ba9f38e904..06a679a8eb 100644 --- a/tests/test_scene_rendering/test_cli_flags.py +++ b/tests/test_scene_rendering/test_cli_flags.py @@ -72,6 +72,45 @@ def test_resolution_flag(tmp_path, manim_cfg_file, simple_scenes_path): assert (width, height) == (meta["width"], meta["height"]) +@pytest.mark.slow +@pytest.mark.parametrize("resolution", [(128, 128), (96, 160)]) +def test_resolution_flag_preserves_square_geometry(tmp_path, resolution): + source = tmp_path / "square_scene.py" + source.write_text( + "from manim import Scene, Square\n" + "class SquareScene(Scene):\n" + " def construct(self):\n" + ' self.add(Square(fill_color="#ffffff", fill_opacity=1, stroke_width=0))\n', + encoding="utf-8", + ) + output = tmp_path / "square.png" + width, height = resolution + _, err, exit_code = capture( + [ + sys.executable, + "-m", + "manim", + "--renderer=cairo", + "--format=png", + "--resolution", + f"{width},{height}", + "--media_dir", + str(tmp_path / "media"), + "-o", + str(output), + str(source), + "SquareScene", + ] + ) + assert exit_code == 0, err + with Image.open(output) as image: + assert image.size == resolution + pixels = np.asarray(image) + rows, columns = np.where(pixels[:, :, 0] > 128) + assert len(rows) > 0 + assert np.ptp(columns) == pytest.approx(np.ptp(rows), abs=1) + + @pytest.mark.slow @video_comparison( "SquareToCircleWithlFlag.json", diff --git a/tests/test_scene_rendering/test_scene_images.py b/tests/test_scene_rendering/test_scene_images.py new file mode 100644 index 0000000000..7f17dccefa --- /dev/null +++ b/tests/test_scene_rendering/test_scene_images.py @@ -0,0 +1,181 @@ +"""Current-state inspection is independent of animation and movie emission.""" + +from concurrent.futures import ThreadPoolExecutor + +import numpy as np +import pytest +from PIL import Image + +from manim import RIGHT, Scene, Square, ThreeDScene, ZoomedScene, tempconfig + + +@pytest.fixture(params=["cairo", "opengl"]) +def image_scene(request): + with tempconfig( + { + "renderer": request.param, + "dry_run": True, + "pixel_width": 128, + "pixel_height": 128, + "frame_rate": 4, + } + ): + scene = Scene() + yield scene + if request.param == "cairo": + scene.renderer.close() + else: + scene.renderer.context.release() + + +def test_fresh_image_without_evaluation(image_scene, monkeypatch): + scene = image_scene + calls = [] + square = Square(fill_opacity=1, stroke_width=0) + square.add_updater(lambda m, dt: calls.append(dt)) + scene.add(square) + calls.clear() + + def unexpected(*args, **kwargs): + pytest.fail("Image inspection must not execute or emit movie frames") + + monkeypatch.setattr(scene, "construct", unexpected) + monkeypatch.setattr(scene.renderer.file_writer, "write_frame", unexpected) + scene.renderer.update_frame(scene) + old = scene.renderer.get_frame() + square.shift(3 * RIGHT) + image = scene.get_image() + assert isinstance(image, Image.Image) + assert image.size == (128, 128) + assert not np.array_equal(old, np.asarray(image)) + np.testing.assert_array_equal(scene.renderer.get_frame(), old) + assert calls == [] + assert scene.time == 0 + assert scene.renderer.num_plays == 0 + square.shift(-3 * RIGHT) + np.testing.assert_array_equal(np.asarray(scene.get_image()), old) + + +def test_image_uses_existing_resolution(image_scene): + image_scene.add(Square()) + with tempconfig({"pixel_width": 72, "pixel_height": 40}): + assert image_scene.get_image().size == (128, 128) + + +def test_show_uses_fresh_image(image_scene, monkeypatch): + images = [] + monkeypatch.setattr(Image.Image, "show", lambda image: images.append(image)) + image_scene.add(Square()) + image_scene.show() + assert len(images) == 1 + np.testing.assert_array_equal(images[0], image_scene.get_image()) + + +def test_capture_during_construct_and_after_render(image_scene, monkeypatch): + images = [] + + def construct(): + square = Square(fill_opacity=1) + image_scene.add(square) + images.append(image_scene.get_image()) + image_scene.play(square.animate.shift(3 * RIGHT)) + clock = image_scene.time + images.append(image_scene.get_image()) + assert image_scene.time == clock + assert image_scene.renderer.num_plays == 1 + + monkeypatch.setattr(image_scene, "construct", construct) + image_scene.render() + assert not np.array_equal(images[0], images[1]) + np.testing.assert_array_equal(images[1], image_scene.get_image()) + + +def test_cairo_image_after_close_and_with_static_cache(): + with tempconfig({"dry_run": True, "pixel_width": 128, "pixel_height": 128}): + scene = Scene() + scene.add(Square(fill_opacity=1)) + renderer = scene.renderer + renderer.static_image = np.full((128, 128, 4), 77, dtype=np.uint8) + static = renderer.static_image + image = scene.get_image() + assert renderer.static_image is static + renderer.close() + np.testing.assert_array_equal(image, scene.get_image()) + assert renderer._closed + assert renderer._target._pixels.size == 0 + + +@pytest.mark.parametrize("scene_class", [Scene, ThreeDScene, ZoomedScene]) +def test_cairo_snapshot_camera_and_nested_view_parity(scene_class): + with tempconfig({"dry_run": True, "pixel_width": 128, "pixel_height": 128}): + scene = scene_class() + try: + scene.setup() + scene.add(Square(fill_opacity=1)) + if isinstance(scene, ZoomedScene): + scene.activate_zooming(animate=False) + scene.camera.frame.shift(RIGHT) + image = scene.get_image() + scene.renderer.update_frame(scene) + np.testing.assert_array_equal(image, scene.renderer.get_frame()) + finally: + scene.renderer.close() + + +@pytest.mark.parametrize("image_scene", ["opengl"], indirect=True) +def test_opengl_snapshot_restores_target_on_failure(image_scene, monkeypatch): + renderer = image_scene.renderer + target = renderer.frame_buffer_object + viewport = renderer.context.viewport + elapsed = renderer.animation_elapsed_time + original = renderer._draw_scene + + def fail(scene): + raise ValueError("drawing failed") + + monkeypatch.setattr(renderer, "_draw_scene", fail) + with pytest.raises(ValueError, match="drawing failed"): + image_scene.get_image() + assert renderer.frame_buffer_object is target + assert renderer.context.fbo is target + assert renderer.context.viewport == viewport + assert renderer.animation_elapsed_time == elapsed + monkeypatch.setattr(renderer, "_draw_scene", original) + assert image_scene.get_image().size == (128, 128) + + +@pytest.mark.parametrize("image_scene", ["opengl"], indirect=True) +def test_opengl_snapshot_includes_meshes(image_scene): + from manim.renderer.opengl.shader import Mesh, Shader + + shader = Shader( + image_scene.renderer.context, + source={ + "vertex_shader": """ + #version 330 + in vec3 point; + void main() { gl_Position = vec4(point, 1.0); } + """, + "fragment_shader": """ + #version 330 + out vec4 color; + void main() { color = vec4(1.0, 0.0, 0.0, 1.0); } + """, + }, + ) + attributes = np.zeros(3, dtype=[("point", np.float32, (3,))]) + attributes["point"] = [(-0.5, -0.5, 0), (0.5, -0.5, 0), (0, 0.5, 0)] + mesh = Mesh(shader=shader, attributes=attributes) + image_scene.add(mesh) + pixels = np.asarray(image_scene.get_image()) + np.testing.assert_array_equal(pixels[64, 64, :3], [255, 0, 0]) + image_scene.renderer.update_frame(image_scene) + np.testing.assert_array_equal(pixels, image_scene.renderer.get_frame()) + + +@pytest.mark.parametrize("image_scene", ["opengl"], indirect=True) +def test_opengl_snapshot_requires_owner_thread(image_scene): + with ThreadPoolExecutor(1) as pool: + future = pool.submit(image_scene.get_image) + with pytest.raises(RuntimeError, match="render thread"): + future.result()