diff --git a/manim/animation/composition.py b/manim/animation/composition.py index 82488425f8..dea73aa58d 100644 --- a/manim/animation/composition.py +++ b/manim/animation/composition.py @@ -42,13 +42,18 @@ class AnimationGroup(Animation): The function defining the animation progress based on the relative runtime (see :mod:`~.rate_functions`) . lag_ratio - Defines the delay after which the animation is applied to submobjects. A lag_ratio of - ``n.nn`` means the next animation will play when ``nnn%`` of the current animation has played. - Defaults to 0.0, meaning that all animations will be played together. + Defines the delay after which each subsequent animation begins, relative to the previous animation's duration. - This does not influence the total runtime of the animation. Instead the runtime - of individual animations is adjusted so that the complete animation has the defined - run time. + Example: With `lag_ratio=0.5`, animation 2 starts when animation 1 is 50% complete, + animation 3 starts when animation 2 is 50% complete, and so on. + + This is analogous to how `lag_ratio` works for submobjects in other Manim animations. + (e.g., `self.play(animation, lag_ratio=x)`): + - `lag_ratio=0.0` (default): All animations play together (parallel) + - `lag_ratio=1.0`: Animations play sequentially (current submobject's animation finishes before next starts) + + Note: The total runtime (`run_time`) is preserved. Each individual animation's run_time is scaled + to fit the user-provided `run_time` duration, with overlaps determined by the `lag_ratio`. """ def __init__( @@ -203,14 +208,18 @@ class Succession(AnimationGroup): animations Sequence of :class:`~.Animation` objects to be played. lag_ratio - Defines the delay after which the animation is applied to submobjects. A lag_ratio of - ``n.nn`` means the next animation will play when ``nnn%`` of the current animation has played. - Defaults to 1.0, meaning that the next animation will begin when 100% of the current - animation has played. + Defines the delay after which each subsequent animation begins, relative to the previous animation's duration. + + Example: With `lag_ratio=0.5`, animation 2 starts when animation 1 is 50% complete, + animation 3 starts when animation 2 is 50% complete, and so on. - This does not influence the total runtime of the animation. Instead the runtime - of individual animations is adjusted so that the complete animation has the defined - run time. + This is analogous to how `lag_ratio` works for submobjects in other Manim animations. + (e.g., `self.play(animation, lag_ratio=x)`): + - `lag_ratio=0.0` (default): All animations play together (parallel) + - `lag_ratio=1.0`: Animations play sequentially (current submobject's animation finishes before next starts) + + Note: The total runtime (`run_time`) is preserved. Each individual animation's run_time is scaled + to fit the user-provided `run_time` duration, with overlaps determined by the `lag_ratio`. Examples -------- @@ -302,14 +311,18 @@ class LaggedStart(AnimationGroup): animations Sequence of :class:`~.Animation` objects to be played. lag_ratio - Defines the delay after which the animation is applied to submobjects. A lag_ratio of - ``n.nn`` means the next animation will play when ``nnn%`` of the current animation has played. - Defaults to 0.05, meaning that the next animation will begin when 5% of the current - animation has played. + Defines the delay after which each subsequent animation begins, relative to the previous animation's duration. + + Example: With `lag_ratio=0.5`, animation 2 starts when animation 1 is 50% complete, + animation 3 starts when animation 2 is 50% complete, and so on. - This does not influence the total runtime of the animation. Instead the runtime - of individual animations is adjusted so that the complete animation has the defined - run time. + This is analogous to how `lag_ratio` works for submobjects in other Manim animations. + (e.g., `self.play(animation, lag_ratio=x)`): + - `lag_ratio=0.0` (default): All animations play together (parallel) + - `lag_ratio=1.0`: Animations play sequentially (current submobject's animation finishes before next starts) + + Note: The total runtime (`run_time`) is preserved. Each individual animation's run_time is scaled + to fit the user-provided `run_time` duration, with overlaps determined by the `lag_ratio`. Examples -------- @@ -363,14 +376,18 @@ class LaggedStartMap(LaggedStart): run_time The duration of the animation in seconds. lag_ratio - Defines the delay after which the animation is applied to submobjects. A lag_ratio of - ``n.nn`` means the next animation will play when ``nnn%`` of the current animation has played. - Defaults to 0.05, meaning that the next animation will begin when 5% of the current - animation has played. - - This does not influence the total runtime of the animation. Instead the runtime - of individual animations is adjusted so that the complete animation has the defined - run time. + Defines the delay after which each subsequent animation begins, relative to the previous animation's duration. + + Example: With `lag_ratio=0.5`, animation 2 starts when animation 1 is 50% complete, + animation 3 starts when animation 2 is 50% complete, and so on. + + This is analogous to how `lag_ratio` works for submobjects in other Manim animations. + (e.g., `self.play(animation, lag_ratio=x)`): + - `lag_ratio=0.0` (default): All animations play together (parallel) + - `lag_ratio=1.0`: Animations play sequentially (current submobject's animation finishes before next starts) + + Note: The total runtime (`run_time`) is preserved. Each individual animation's run_time is scaled + to fit the user-provided `run_time` duration, with overlaps determined by the `lag_ratio`. kwargs Further keyword arguments that are passed to `animation_class`. diff --git a/manim/camera/camera.py b/manim/camera/camera.py index 2ee433d28b..7cce2b08c7 100644 --- a/manim/camera/camera.py +++ b/manim/camera/camera.py @@ -8,6 +8,7 @@ import itertools as it import operator as op import pathlib +import warnings from collections.abc import Callable, Iterable from functools import reduce from typing import TYPE_CHECKING, Any, Self @@ -61,23 +62,158 @@ class Camera: """Base camera class. - This is the object which takes care of what exactly is displayed - on screen at any given moment. + This class is responsible for converting Mobjects into pixels + by determining which Mobjects are visible within the frame and + rasterizing them onto the pixel canvas using Cairo. + + The camera maps a rectangular region of Manim's coordinate system, + defined by `frame_width` and `frame_height`, onto the pixel canvas + (`pixel_width` x `pixel_height`). This region is called the frame, + and its center is `frame_center`. + + In this base class, `frame_width`, `frame_height`, and `frame_center` + can be set directly in the code, but cannot be smoothly animated during + a `self.play()` call. + + For a movable, animatable frame, see :class:`~.MovingCameraScene` + and :class:`~.ZoomedScene`, where the frame is a + :class:`~.ScreenRectangle` VMobject that can be: + - panned (by shifting/moving `frame_center`), + - zoomed in (by reducing `frame_width` or `frame_height`), or + - zoomed out (by increasing `frame_width` or `frame_height`). 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` + If not set, the background is filled with :attr:`self.background_color`. + + frame_center + Center of the frame. Default is `ORIGIN`. + Frame is defined by frame_width(14.2 units) and Frame height (8 units). + In this base class, frame is constant. But in MovingCameraScene and + ZoomedScene, frame is either made smaller(for zooming in) or bigger(for zooming out), and/or, + the frame_center is also shifted to give the effect of moving camera. + + image_mode + The PIL image mode used when converting and creating images. + Must be `"RGBA"` (default). + + n_channels + Number of color channels for each pixel. Must be `4` (default), + corresponding to `[Alpha, Red, Green, Blue]`, where each + component of this array is an integer from 0 to 255. + + cairo_line_width_multiple + Scaling factor used to convert a VMobject's stroke width from Manim's + stroke-width unit to scene-space unit, before the CTM(Cairo Transformation Matrix) transforms it to + pixel space. + + When deciding the stroke_width of the VMobject,the camera calls `ctx.set_line_width` + with:: + stroke_width * cairo_line_width_multiple + + This applies to both `stroke_width` and `background_stroke_width`, + both of which are passed to Cairo via `ctx.set_line_width()` inside + :meth:`~.apply_stroke()`. + + A typical `stroke_width=4` at 1080p resolves to approximately 5.4 pixels: + + 4 * 0.01 = 0.04 scene units + (0.04 / 8) * 1080 ≈ 5.4 pixels + + Decrease cairo_line_width_multiple to render thinner lines globally; increase it for + thicker ones. Overriding it on a `ZoomedScene`'s camera can compensate + for changes in stroke-width when the frame is scaled. + background - What :attr:`background` is set to. By default, ``None``. + Deprecated. Will be removed in a future version. + Use `background_image` for image-based backgrounds or + `background_color` for solid color backgrounds instead. + pixel_height - The height of the scene in pixels. + Height of the rendered output in pixels. + + This is the vertical dimension of the pixel array that Cairo paints into, + and corresponds to the maximum y coordinate in pixel space (from 0 at the + top to pixel_height at the bottom, with origin at the top-left corner of + the screen). + + Can be accessed in 3 ways: + + 1. Via Manim's global config: config.pixel_height + + 2. Directly on the Camera instance: self.pixel_height + + 3. Dict-style access on config: config["pixel_height"] + + The default value is set in `manim/_config/default.cfg` before the scene renders + and is typically one of the standard resolutions: 480, 720, 1080, or 1440, + though it can be any value that the user specifies. + To override the default, see :doc:`guides/configuration` + pixel_width - The width of the scene in pixels. + Width of the rendered output in pixels. + + This is the horizontal dimension of the pixel array that Cairo paints into, + and corresponds to the maximum x coordinate in pixel space (from 0 at the + left to pixel_width at the right, with origin at the top-left corner of + the screen). + + Can be accessed in 3 ways: + + 1. Via Manim's global config: config.pixel_width + + 2. Directly on the Camera instance: self.pixel_width + + 3. Dict-style access on config: config["pixel_width"] + + The default value is set in `manim/_config/default.cfg` before the scene renders + and is typically one of the standard resolutions: 854, 1280, 1920, or 2560, + though it can be any value that the user specifies. + To override the default, see :doc:`/guides/configuration`. + + frame_height + Height of the scene in scene-space units. + + This is the vertical dimension of the visible region of the scene, + and corresponds to the maximum y coordinate in Manim's scene space + (from -frame_height/2 at the bottom to +frame_height/2 at the top, + with origin at the frame_center which defaults to ORIGIN, i.e. [0,0,0]. + + Together with `pixel_height`, it determines how many pixels + correspond to 1 scene unit in the y direction:: + + pixels_per_scene_unit = pixel_height / frame_height + # e.g. 1080 / 8 = 135 pixels per scene unit in the y direction at 1080p. + + The default value is 8.0 scene units. + + frame_width + Width of the scene in scene-space units. + + This is the horizontal dimension of the visible region of the scene, + and corresponds to the maximum x coordinate in Manim's scene space + (from -frame_width/2 at the left to +frame_width/2 at the right, + with origin at the frame_center which defaults to ORIGIN). + + Together with `pixel_width`, it determines how many pixels + correspond to 1 scene unit in the x direction:: + + pixels_per_scene_unit = pixel_width / frame_width + # e.g. 1920 / 14.222 ≈ 135 pixels per scene unit in the x direction at 1080p. + + The default value is approximately 14.222 scene units, derived from + `frame_height * aspect_ratio` (8.0 * 16/9). + + background_color + Background color of the scene. Defaults to `BLACK`. + + background_opacity + Background opacity of the scene, in the range [0, 1]. Defaults to 1 (i.e. fully opaque). + kwargs - Additional arguments (``background_color``, ``background_opacity``) - to be set. + Additional keyword arguments that are to be accepted but silently ignored. """ def __init__( @@ -106,7 +242,6 @@ def __init__( 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 @@ -153,7 +288,14 @@ def __init__( # 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. - + if background is not None: + warnings.warn( + "The 'background' parameter is deprecated and will be removed in a future version. " + "Use 'background_image' for image-based backgrounds or " + "'background_color' for solid color backgrounds instead.", + DeprecationWarning, + stacklevel=2, + ) self.init_background() self.resize_frame_shape() self.reset() @@ -220,7 +362,7 @@ def type_or_raise( 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 + Mobject: self._warning_regarding_plain_mobjects, } # We have to check each type in turn because we are dealing with # super classes. For example, if square = Square(), then @@ -228,18 +370,32 @@ def type_or_raise( for _type in self.display_funcs: if isinstance(mobject, _type): return _type - raise TypeError(f"Displaying an object of class {_type} is not supported") + raise TypeError( + f"Displaying an object of class {type(mobject).__name__} is not supported" + ) + + def _warning_regarding_plain_mobjects( + self, batch: list[Mobject], _pixel_array: PixelArray + ) -> None: + warnings.warn( + f"{len(batch)} plain Mobject(s) were skipped and cannot be rendered directly. " + "Use a subclass such as VMobject, PMobject, or ImageMobject instead.", + UserWarning, + stacklevel=2, + ) 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. + """Resets the pixel dimensions of the scene. + + Updates the scene's `pixel_height` and `pixel_width` to the user provided new_height and new_width, + reinitializes the background, resizes the frame shape, and resets the scene. Parameters ---------- new_height - The new height of the entire scene in pixels + The new pixel height of the scene. new_width - The new width of the entire scene in pixels + The new pixel width of the scene. """ self.pixel_width = new_width self.pixel_height = new_height @@ -257,8 +413,8 @@ def resize_frame_shape(self, fixed_dimension: int = 0) -> None: Parameters ---------- fixed_dimension - If 0, height is scaled with respect to width - else, width is scaled with respect to height. + If 0, height is scaled with respect to width. This is the default behaviour. + If not 0, width is scaled with respect to height. """ pixel_height = self.pixel_height pixel_width = self.pixel_width @@ -459,9 +615,9 @@ def get_mobjects_to_display( mobjects The Mobjects include_submobjects - Whether or not to include the submobjects of mobjects, by default True + Whether or not to include the submobjects of mobjects. Default value is True. excluded_mobjects - Any mobjects to exclude, by default None + Any mobjects to exclude. Default value is None. Returns ------- @@ -739,7 +895,10 @@ def set_cairo_context_color( ctx The cairo context rgbas - The RGBA array with which to color the context. + It's a 2D numpy array of shape (N, 4), where each row is a list of RGBA + values [R, G, B, A], and each component is a float in the range [0, 1]. + Each row is fed to the Cairo context after it's converted to [B, G, R, A], + because the underlying cairo surface stores channels in reversed byte order. vmobject The VMobject with which to set the color. @@ -748,18 +907,25 @@ def set_cairo_context_color( Camera The camera object """ - if len(rgbas) == 1: - # Use reversed rgb because cairo surface is + if len(rgbas) == 1: # If only 1 color has been provided, then color it flatly. + # Use reversed rgb because cairo surface # encodes it in reverse order ctx.set_source_rgba(*rgbas[0][2::-1], rgbas[0][3]) + return self + + elif vmobject.gradient_type == "radial": + params = vmobject.get_radial_gradient_parameters() + pattern = cairo.RadialGradient(*params) + 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) + pattern = 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): + pattern.add_color_stop_rgba(offset, *rgba[2::-1], rgba[3]) + ctx.set_source(pattern) return self def apply_fill(self, ctx: cairo.Context, vmobject: VMobject) -> Self: @@ -1168,14 +1334,44 @@ def transform_points_pre_display( self, mobject: Mobject, points: Point3D_Array, - ) -> Point3D_Array: # TODO: Write more detailed docstrings for this method. + ) -> Point3D_Array: + """ + Preprocesses points before conversion to continuous pixel-space coordinates. + + In this base :class:`Camera`, this method validates that + all coordinates in `points` are finite. If any coordinate is + non-finite (NaN or +inf or -inf), the entire `points` array is + replaced with an array containing a single origin point. + + Subclasses may override this method to apply additional geometric + transformations before rendering. + For example, :class:`ThreeDCamera` modifies the `points` array + to convert 3D points into renderable coordinates. + + Parameters + ---------- + mobject + The mobject associated with the points. The base :class:`Camera` + doesn't use this parameter directly, but subclasses may use it + for mobject-specific rendering behavior. + + points + A 2D numpy array of shape (N, 3) representing geometry to be + rendered. This is not guaranteed to be identical to + `mobject.points`. + + Returns + ------- + Point3D_Array + """ # 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? + warnings.warn( + f"{mobject} contains invalid points.", + UserWarning, + stacklevel=2, + ) points = np.zeros((1, 3)) return points @@ -1183,12 +1379,63 @@ def points_to_subpixel_coords( self, mobject: Mobject, points: Point3D_Array, - ) -> npt.NDArray[ - ManimFloat - ]: # TODO: Write more detailed docstrings for this method. + ) -> npt.NDArray[ManimFloat]: + """Converts an array of 3D Manim coordinate-space points into 2D floating-point + pixel coordinates relative to the camera's frame. + + Unlike :meth:`points_to_pixel_coords`, this method returns coordinates + as floats (subpixel precision) rather than integers, preserving + fractional pixel positions. This is useful for anti-aliasing and + smooth rendering before snapping to integer pixel positions. + + Manim scene space: + Origin at ORIGIN (center of frame) + Positive x → right, positive y → up + Dimensions: frame_width x frame_height (e.g. ~14.2 x 8 units for 16:9) + This is what the user works with. + + Pixel space: + Origin at top-left corner + Positive x → right, positive y → down + Dimensions: pixel_width x pixel_height (e.g. 1920 x 1080) + The final rasterized output + + The conversion from Manim-space to pixel-space involves three steps: + + 1. Check for finiteness of the points via + :meth:`transform_points_pre_display`. + 2. Shift the points so the frame center becomes the origin. + 3. Scale and offset x and y separately to map from Manim-space + to pixel-space. The y-axis is flipped during this step because + pixel coordinates increase downward while Manim's coordinate + system increases upward. + + Parameters + ---------- + mobject + The :class:`~.Mobject` the points belong to. Passed to + :meth:`transform_points_pre_display` to check for + finiteness of points before coordinate conversion. + + points + A 2D numpy array of shape `(N, 3)` containing the 3D Manim-space + points. + + Returns + ------- + A 2D numpy array of shape `(N, 2)` containing the corresponding + floating-point pixel coordinates, where each row is + coordinate of the pixel in the form `(pixel_x, pixel_y)`, where both + pixel_x and pixel_y are floating point numbers. + + See Also + -------- + :meth:`points_to_pixel_coords` : Same conversion but returns + integer pixel coordinates by casting the result of this method + to `int64`. + """ 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 @@ -1209,7 +1456,34 @@ def points_to_pixel_coords( self, mobject: Mobject, points: Point3D_Array, - ) -> npt.NDArray[ManimInt]: # TODO: Write more detailed docstrings for this method. + ) -> npt.NDArray[ManimInt]: + """Converts an array of 3D Manim coordinate-space points into 2D integer + pixel coordinates relative to the camera's frame. + + This is a thin wrapper around :meth:`points_to_subpixel_coords` that + truncates the floating-point subpixel coordinates to integer pixel + positions by casting to `int64`. + + Parameters + ---------- + mobject + The :class:`~.Mobject` the points belong to. Passed to + :meth:`points_to_subpixel_coords`. + points + A 2D numpy array of shape `(N, 3)` containing the 3D Manim coordinate-space + points. + + Returns + ------- + :class:`numpy.ndarray` + A 2D numpy array of shape `(N, 2)` containing the corresponding + integer pixel coordinates, where each row is `(pixel_x, pixel_y)` + and both pixel_x and pixel_y are integers. + + See Also + -------- + :meth:`points_to_subpixel_coords` : returns floating-point subpixel coordinates instead of integers. + """ return self.points_to_subpixel_coords(mobject, points).astype(np.int64) def on_screen_pixels(self, pixel_coords: np.ndarray) -> PixelArray: @@ -1251,6 +1525,8 @@ def adjusted_thickness(self, thickness: float) -> float: the camera. """ # TODO: This seems...unsystematic + # factor is always 1, even in case of zoomed scene. + # So, effectively, this method just returns the thickness as it is. 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 diff --git a/manim/mobject/geometry/arc.py b/manim/mobject/geometry/arc.py index 32b1133a6b..6bff38fb16 100644 --- a/manim/mobject/geometry/arc.py +++ b/manim/mobject/geometry/arc.py @@ -740,6 +740,22 @@ def construct(self): proportion -= np.floor(proportion) return self.point_from_proportion(proportion) + def get_radial_gradient_parameters( + self, + ) -> tuple[float, float, float, float, float, float]: + current_center = self.get_center() + + offset = current_center - self.rg_original_center + start_center = self.rg_start_center + offset + end_center = self.rg_end_center + offset + + start_radius = self.rg_start_radius + is_in_group = getattr(self, "is_in_group", False) + end_radius = ( + self.rg_end_radius if is_in_group else min(self.rg_end_radius, self.radius) + ) + return (*start_center[:2], start_radius, *end_center[:2], end_radius) + @staticmethod def from_three_points( p1: Point3DLike, p2: Point3DLike, p3: Point3DLike, **kwargs: Any diff --git a/manim/mobject/types/vectorized_mobject.py b/manim/mobject/types/vectorized_mobject.py index 768091ea65..02657b4566 100644 --- a/manim/mobject/types/vectorized_mobject.py +++ b/manim/mobject/types/vectorized_mobject.py @@ -48,12 +48,17 @@ if TYPE_CHECKING: from collections.abc import Iterator - from typing import Self + from typing import Literal, Self import numpy.typing as npt from manim.typing import ( + BezierPath, + BezierPathLike, CubicBezierPath, + CubicBezierPathLike, + CubicBezierPoints, + CubicBezierPoints_Array, CubicBezierPointsLike, CubicSpline, FloatRGBA, @@ -128,6 +133,7 @@ def __init__( tolerance_for_point_equality: float = 1e-6, n_points_per_cubic_curve: int = 4, cap_style: CapStyleType = CapStyleType.AUTO, + gradient_type: Literal["linear", "radial"] | None = "linear", **kwargs: Any, ): self.fill_opacity = fill_opacity @@ -159,6 +165,7 @@ def __init__( 0, 1, n_points_per_cubic_curve ) self.cap_style: CapStyleType = cap_style + self.gradient_type = gradient_type super().__init__(**kwargs) self.submobjects: list[VMobject] @@ -194,6 +201,7 @@ def init_colors(self, propagate_colors: bool = True) -> Self: self.set_fill( color=self.fill_color, opacity=self.fill_opacity, + gradient_type=self.gradient_type, family=propagate_colors, ) self.set_stroke( @@ -281,21 +289,71 @@ def update_rgbas_array( def set_fill( self, - color: ParsableManimColor | None = None, + color: ParsableManimColor | Iterable[ParsableManimColor] | None = None, opacity: float | None = None, family: bool = True, + *, + gradient_type: Literal["linear", "radial"] | None = None, + start_center: np.ndarray | None = None, + start_radius: float = 0, + end_center: np.ndarray | None = None, + end_radius: float | None = None, ) -> Self: """Set the fill color and fill opacity of a :class:`VMobject`. Parameters ---------- color - Fill color of the :class:`VMobject`. + Fill color of the :class:`VMobject`. It can also be an Iterable of colors. opacity Fill opacity of the :class:`VMobject`. family If ``True``, the fill color of all submobjects is also set. + gradient_type + The type of gradient to apply to the fill color of the mobject. + If `None`, no gradient is applied and the fill color is flat. + + - `"linear"`: Colors are interpolated along a straight line + from one side of the mobject to the other, determined by the + mobject's orientation and bounding box. + + - `"radial"`: Colors are interpolated outward in concentric + circles from `start_center` at `start_radius` to + `end_center` at `end_radius`. + + Default value is `None`. + + start_center + The center point of the inner circle of the radial gradient in scene + coordinates. The first color in the color list begins at this point. + If `None`, defaults to the center of the mobject (for VMobject) or + the center of the VGroup (for VGroup). + + start_radius + The radius of the inner circle of the radial gradient in Manim scene + units. The first color in the color list starts at this radius from + ``start_center``. Anything inside this radius is filled with the first + color solidly. Defaults to ``0``, meaning the first color begins at + the very center. Interpolation of color starts from `start_radius` and ends at `end_radius`. + + end_center + The center point of the outer circle of the radial gradient in scene + coordinates. The last color in the color list ends at the end of radius of this circle. + If `None`, defaults to the center of the mobject (for VMobject) or + the center of the VGroup (for VGroup). In most cases this should be + the same as `start_center`, giving a perfectly circular gradient. + + end_radius + The radius of the outer circle of the radial gradient in Manim scene + units. The last color in the color list ends at this radius from + `end_center`. Anything beyond this radius is filled with the last + color solidly. If `None`, defaults to the distance from the center + to the UR corner of the mobject's bounding box (for VMobject), or the + distance from the center to the UR corner of the VGroup's bounding box + (for VGroup). + + Returns ------- :class:`VMobject` @@ -320,7 +378,47 @@ def construct(self): -------- :meth:`~.VMobject.set_style` """ - if family: + if gradient_type is not None: + if gradient_type == "radial": + self.rg_original_center = self.get_center().copy() + self.gradient_type = gradient_type + self.rg_start_center = ( + start_center if start_center is not None else self.get_center() + ) + self.rg_start_radius = start_radius if start_radius is not None else 0 + self.rg_end_center = ( + end_center if end_center is not None else self.get_center() + ) + self.rg_end_radius = ( + end_radius + if end_radius is not None + else np.linalg.norm(self.get_corner(UR) - self.get_center()) + ) + if family: + for submobject in self.submobjects: + submobject.set_fill( + color, + opacity, + family, + gradient_type=gradient_type, + start_center=start_center, + start_radius=start_radius, + end_center=end_center, + end_radius=end_radius, + ) + elif gradient_type == "linear": + self.gradient_type = gradient_type + if family: + for submobject in self.submobjects: + submobject.set_fill( + color, opacity, family, gradient_type=gradient_type + ) + else: + raise ValueError( + "The gradient_type parameter should only be either 'linear' or 'radial'. " + f"You have passed '{gradient_type}', which is invalid. " + ) + elif family: for submobject in self.submobjects: submobject.set_fill(color, opacity, family) self.update_rgbas_array("fill_rgbas", color, opacity) @@ -770,6 +868,19 @@ def get_gradient_start_and_end_points(self) -> tuple[Point3D, Point3D]: offset = np.dot(bases, direction) return (c - offset, c + offset) + def get_radial_gradient_parameters( + self, + ) -> tuple[float, float, float, float, float, float]: + current_center = self.get_center() + + offset = current_center - self.rg_original_center + start_center = self.rg_start_center + offset + end_center = self.rg_end_center + offset + + start_radius = self.rg_start_radius + end_radius = self.rg_end_radius + return (*start_center[:2], start_radius, *end_center[:2], end_radius) + def color_using_background_image(self, background_image: Image | str) -> Self: self.background_image: Image | str = background_image self.set_color(WHITE) @@ -2334,6 +2445,72 @@ def __getitem__(self, key: int | slice) -> VMobject: return VGroup(self.submobjects[key]) return self.submobjects[key] + def set_fill( + self, + color=None, + opacity=None, + family=True, + *, + gradient_type: Literal["linear", "radial"] | None = None, + start_center: np.ndarray | None = None, + start_radius: float = 0, + end_center: np.ndarray | None = None, + end_radius: float | None = None, + recurse: bool | None = None, + ) -> Self: + if recurse is not None: + family = recurse + if gradient_type is not None: + if gradient_type == "radial": + vgroup_center = self.get_center() + max_radius = np.linalg.norm(self.get_corner(UR) - vgroup_center) + for submob in self.submobjects: + submob.is_in_group = True + submob.rg_start_center = ( + start_center if start_center is not None else vgroup_center + ) + submob.rg_start_radius = ( + start_radius if start_radius is not None else 0 + ) + submob.rg_end_center = ( + end_center if end_center is not None else vgroup_center + ) + submob.rg_end_radius = ( + end_radius if end_radius is not None else max_radius + ) + submob.set_fill( + color, + opacity, + family=family, + gradient_type=gradient_type, + start_center=submob.rg_start_center, + start_radius=submob.rg_start_radius, + end_center=submob.rg_end_center, + end_radius=submob.rg_end_radius, + ) + elif gradient_type == "linear": + self.gradient_type = gradient_type + if family: + for submobject in self.submobjects: + submobject.set_fill( + color, opacity, family, gradient_type=gradient_type + ) + else: + raise ValueError( + "The gradient_type parameter should only be either 'linear' or 'radial'. " + f"You have passed '{gradient_type}', which is invalid. " + ) + + elif family: + for submob in self.submobjects: + submob.set_fill(color, opacity, family) + if hasattr(self, "update_rgbas_array"): + self.update_rgbas_array("fill_rgbas", color, opacity) + # self.fill_rgbas: FloatRGBA_Array + if opacity is not None: + self.fill_opacity = opacity + return self + class VDict(VMobject, metaclass=ConvertToOpenGL): """A VGroup-like class, also offering submobject access by