diff --git a/benchmarks/bench_lissajous.py b/benchmarks/bench_lissajous.py new file mode 100644 index 0000000000..0d072a15cc --- /dev/null +++ b/benchmarks/bench_lissajous.py @@ -0,0 +1,400 @@ +"""Real-world rendering benchmark: Lissajous Table animation. + +A heavy animation workload — grid of circles with updaters tracing +Lissajous curves. Exercises the per-frame render hot path (path +building, fill, stroke) far more than gallery-style static scenes. + +Adapted from Abhijith Muthyala's project: +https://github.com/abhijithmuthyala/manim-projects/tree/main/pragyaan + +Usage: + python benchmarks/bench_lissajous.py +""" + +import tempfile +import time + +import numpy as np + +from manim import * + +# ─── Helper functions (from functions.py) ───────────────────────────────────── + + +def color_map(speed, min_value, max_value, *colors): + alpha = (speed - min_value) / (max_value - min_value) + if len(colors) == 0: + raise ValueError("At least 1 color needed, passed 0") + if len(colors) == 1: + colors = list(colors) * 2 + rgba_s = np.array(list(map(color_to_rgba, colors))) + interpolated_color = rgba_to_color(bezier(rgba_s)(alpha)) + return interpolated_color + + +def get_circles( + radius, n_circles, speeds, buff, arrange_direction=RIGHT, **circle_kwargs +): + circle_kwargs["radius"] = radius + circles = VGroup() + for i in range(n_circles): + circles.add(LissajousCircle(speed=speeds[i], **circle_kwargs)) + return circles.arrange(arrange_direction, buff) + + +def get_intersection_point(row_circ, column_circ): + return row_circ.dot.get_x() * RIGHT + column_circ.dot.get_y() * UP + + +# ─── Custom mobject (from mobjects.py) ──────────────────────────────────────── + + +class LissajousCircle(Circle): + def __init__( + self, + radius=1, + speed=0.5, + point_type=Dot, + point_kwargs=None, + include_radius_line=True, + radius_line_kwargs=None, + start_angle=0, + **circle_kwargs, + ): + if point_kwargs is None: + point_kwargs = {} + if radius_line_kwargs is None: + radius_line_kwargs = {} + circle_kwargs["radius"] = radius + super().__init__(**circle_kwargs) + + self.speed = speed + self.theta = start_angle + self.include_radius_line = include_radius_line + point = self.point_from_proportion(self.theta / TAU) + self.dot = point_type(point=point, **point_kwargs) + if include_radius_line: + radius_line = Line(self.get_center(), point, **radius_line_kwargs) + self.radius_line = radius_line + self.add(radius_line) + self.add(self.dot) + + def update_point(self, dt, speed=None): + speed = speed or self.speed + self.theta += speed * dt + if self.theta > TAU: + self.theta -= TAU + self.cycle_incremented = True + else: + self.cycle_incremented = False + self.dot.move_to(self.point_from_proportion(self.theta / TAU)) + if self.include_radius_line: + self.radius_line.set_angle(self.theta) + + +# ─── Scene (from scenes.py) ────────────────────────────────────────────────── + +COLORS = (GOLD, MAROON, PURPLE, GREEN) +MIN_SPEED = 1 +MAX_SPEED = 3 + + +def speed_to_color_map(speed): + return color_map(speed, MIN_SPEED, MAX_SPEED, *COLORS) + + +class LissajousTableScene(Scene): + def __init__( + self, + radius=0.75, + circle_kwargs=None, + row_buff=0.25, + column_buff=0.25, + left_edge_buff=0.5, + top_edge_buff=0.5, + include_radius_line=True, + row_circle_speeds_range=(1, 3), + column_circle_speeds_range=(1, 3), + **kwargs, + ): + if circle_kwargs is None: + circle_kwargs = {} + self.radius = radius + self.circle_kwargs = circle_kwargs + self.row_buff = row_buff + self.column_buff = column_buff + self.left_edge_buff = left_edge_buff + self.top_edge_buff = top_edge_buff + self.include_radius_line = include_radius_line + self.n_rows, self.n_cols = self.get_grid_size() + self.row_circle_speeds = np.linspace(*row_circle_speeds_range, self.n_rows - 1) + self.column_circle_speeds = np.linspace( + *column_circle_speeds_range, self.n_cols - 1 + ) + self.row_speed_range = row_circle_speeds_range + self.column_speed_range = column_circle_speeds_range + super().__init__(**kwargs) + + def setup(self): + self.row_circles = get_circles( + self.radius, self.n_rows - 1, self.row_circle_speeds, self.row_buff + ) + self.column_circles = get_circles( + self.radius, + self.n_cols - 1, + self.column_circle_speeds, + self.column_buff, + DOWN, + ) + self.arrange_row_circles_to_match_buff() + self.arrange_column_circles_to_match_buff() + + def get_grid_size(self): + row_length = config["frame_width"] - 2 * self.left_edge_buff + column_length = config["frame_height"] - 2 * self.top_edge_buff + n_rows = self.get_max_circles(row_length, self.row_buff) + n_cols = self.get_max_circles(column_length, self.column_buff) + return (n_rows, n_cols) + + def get_max_circles(self, length, buff, radius=None): + radius = radius or self.radius + return int((length + buff) / (2 * radius + buff)) + + def add_circle_updaters(self, circles=None): + if circles is None: + circles = [*self.row_circles, *self.column_circles] + for c in circles: + c.add_updater(lambda c, dt: c.update_point(dt)) + + def arrange_row_circles_to_match_buff(self, row_circles=None): + circles = row_circles or self.row_circles + y = config["frame_height"] / 2 - (self.top_edge_buff + self.radius) + x = ( + 2 * self.radius + + self.row_buff + + self.left_edge_buff + - config["frame_width"] / 2 + ) + return circles.next_to(x * RIGHT + y * UP, buff=0) + + def arrange_column_circles_to_match_buff(self, column_circles=None): + circles = column_circles or self.column_circles + x = self.left_edge_buff + self.radius - config["frame_width"] / 2 + y = config["frame_height"] / 2 - ( + self.top_edge_buff + 2 * self.radius + self.column_buff + ) + aligned_edge = self.column_circles.get_critical_point(UP) + return circles.next_to(x * RIGHT + y * UP, DOWN, 0, aligned_edge) + + def get_horizontal_lines(self, column_circles=None, line_style=Line, **style): + circles = column_circles or self.column_circles + lines = VGroup() + for circ in circles: + start = circ.dot.get_center() + end = np.array( + [config["frame_width"] / 2 - self.left_edge_buff, circ.dot.get_y(), 0] + ) + lines.add(line_style(start, end)) + return lines.set_style(**style) + + def get_vertical_lines(self, row_circles=None, line_style=Line, **style): + circles = row_circles or self.row_circles + lines = VGroup() + for circ in circles: + start = circ.dot.get_center() + end = np.array( + [circ.dot.get_x(), self.top_edge_buff - config["frame_height"] / 2, 0] + ) + lines.add(line_style(start, end)) + return lines.set_style(**style) + + def add_lines_updaters(self, h_lines, v_lines): + h_lines.add_updater( + lambda h: h.become(self.get_horizontal_lines(**h_lines.get_style())) + ) + v_lines.add_updater( + lambda v: v.become(self.get_vertical_lines(**v_lines.get_style())) + ) + + def initiate_paths(self, **style): + paths = VGroup() + for col_circ in self.column_circles: + for row_circ in self.row_circles: + point = get_intersection_point(row_circ, col_circ) + path = VMobject(**style).set_points_as_corners( + [point, point * 1.0000000001] + ) + path.set_color( + interpolate_color(row_circ.get_color(), col_circ.get_color(), 0.5) + ) + dot = Dot(point=point) + path.add(dot) + path.dot = dot + path.row_circle = row_circ + path.column_circle = col_circ + paths.add(path) + self.paths = paths + + def add_path_updaters(self): + def path_update_func(path, dt): + rc, cc = path.row_circle, path.column_circle + if not (rc.cycle_incremented and cc.cycle_incremented): + point = get_intersection_point(rc, cc) + path.add_points_as_corners([point]) + path.dot.move_to(point) + + for path in self.paths: + path.add_updater(path_update_func) + + def is_path_traced_once(self): + for cc in self.column_circles: + for rc in self.row_circles: + if not (rc.cycle_incremented and cc.cycle_incremented): + return False + return True + + def set_circle_colors_by_speed(self): + for c in [*self.row_circles, *self.column_circles]: + c.set_color(speed_to_color_map(c.speed)) + + def suspend_circles_updating(self): + for c in [*self.row_circles, *self.column_circles]: + c.suspend_updating() + + def resume_circles_updating(self): + for c in [*self.row_circles, *self.column_circles]: + c.resume_updating() + + +class DrawLissajousFigures(LissajousTableScene): + def __init__(self, **kwargs): + super().__init__(**kwargs) + + def construct(self): + self.camera.background_color = "#0C2D48" + + lines_style = {"stroke_width": 0.75} + vert_lines = self.get_vertical_lines(**lines_style) + hor_lines = self.get_horizontal_lines(**lines_style) + + self.set_circle_colors_by_speed() + for c in [*self.row_circles, *self.column_circles]: + c.dot.set_color(WHITE) + self.initiate_paths(stroke_width=2) + + self.wait(2) + self.play( + AnimationGroup( + LaggedStart( + *[FadeIn(c, shift=0.25 * LEFT) for c in self.row_circles], + lag_ratio=0.25, + ), + LaggedStart( + *[FadeIn(c, shift=0.25 * UP) for c in self.column_circles], + lag_ratio=0.25, + ), + lag_ratio=1, + run_time=4, + ) + ) + self.wait(2) + self.play( + AnimationGroup( + Create(VGroup(hor_lines, vert_lines), lag_ratio=1), + Create(self.paths, lag_ratio=0.25), + lag_ratio=1, + run_time=4, + ) + ) + self.wait(2) + + self.add_circle_updaters() + self.add_lines_updaters(hor_lines, vert_lines) + self.add_path_updaters() + + self.wait_until(lambda: self.is_path_traced_once()) + self.wait(1 / self.camera.frame_rate) + self.suspend_circles_updating() + self.wait(2) + + +class RadiusOne(DrawLissajousFigures): + def __init__(self): + super().__init__( + radius=1, + row_buff=0.5, + column_buff=0.4, + top_edge_buff=0.5, + left_edge_buff=0.5, + ) + + +class RadiusHalf(DrawLissajousFigures): + def __init__(self): + super().__init__( + radius=0.5, + row_buff=0.25, + column_buff=0.3, + top_edge_buff=0.5, + left_edge_buff=0.5, + ) + + +class RadiusThreeFourths(DrawLissajousFigures): + def __init__(self): + super().__init__( + radius=0.75, + row_buff=0.25, + column_buff=0.25, + top_edge_buff=0.5, + left_edge_buff=0.5, + ) + + +# ─── Benchmark runner ───────────────────────────────────────────────────────── + +ALL_SCENES = [RadiusOne, RadiusHalf, RadiusThreeFourths] +N_RUNS = 1 + + +def bench_scene(scene_cls): + times = [] + for _ in range(N_RUNS): + with tempfile.TemporaryDirectory() as tmpdir: + config.pixel_width = 1920 + config.pixel_height = 1080 + config.frame_rate = 60 + config.media_dir = tmpdir + config.format = None + config.write_to_movie = False + config.save_last_frame = False + config.disable_caching = True + config.dry_run = True + + t0 = time.perf_counter() + scene = scene_cls() + scene.render() + elapsed = time.perf_counter() - t0 + times.append(elapsed) + return times + + +if __name__ == "__main__": + print(f"Lissajous Table Benchmark — 1920x1080 @ 60fps, {N_RUNS} runs each") + print(f"{'=' * 75}") + + grand_total = 0 + for scene_cls in ALL_SCENES: + try: + times = bench_scene(scene_cls) + avg = sum(times) / len(times) + grand_total += avg + runs_str = ", ".join(f"{t * 1000:.0f}" for t in times) + print( + f" {scene_cls.__name__:<30s} {avg * 1000:>8.0f}ms (runs: {runs_str}ms)" + ) + except Exception as e: + print(f" {scene_cls.__name__:<30s} FAILED: {e}") + + print("-" * 75) + print(f" {'TOTAL':<30s} {grand_total * 1000:>8.0f}ms") diff --git a/docs/source/guides/deep_dive.rst b/docs/source/guides/deep_dive.rst index a5dc298d1b..303bed2578 100644 --- a/docs/source/guides/deep_dive.rst +++ b/docs/source/guides/deep_dive.rst @@ -403,17 +403,18 @@ The handles are drawn as red dots with a line to their closest anchor. class VMobjectDemo(Scene): def construct(self): plane = NumberPlane() - my_vmobject = VMobject(color=GREEN) - my_vmobject.points = [ - np.array([-2, -1, 0]), # start of first curve - np.array([-3, 1, 0]), - np.array([0, 3, 0]), - np.array([1, 3, 0]), # end of first curve - np.array([1, 3, 0]), # start of second curve - np.array([0, 1, 0]), - np.array([4, 3, 0]), - np.array([4, -2, 0]), # end of second curve - ] + my_vmobject = VMobject(color=GREEN).set_points( + [ + [-2, -1, 0], # start of first curve + [-3, 1, 0], + [0, 3, 0], + [1, 3, 0], # end of first curve + [1, 3, 0], # start of second curve + [0, 1, 0], + [4, 3, 0], + [4, -2, 0], # end of second curve + ] + ) handles = [ Dot(point, color=RED) for point in [[-3, 1, 0], [0, 3, 0], [0, 1, 0], [4, 3, 0]] diff --git a/manim/camera/camera.py b/manim/camera/camera.py index 2ee433d28b..12234a2e76 100644 --- a/manim/camera/camera.py +++ b/manim/camera/camera.py @@ -319,32 +319,25 @@ def get_image( pixel_array = self.pixel_array return Image.fromarray(pixel_array, mode=self.image_mode) - def convert_pixel_array( - self, pixel_array: PixelArray | list | tuple, convert_from_floats: bool = False - ) -> PixelArray: - """Converts a pixel array from values that have floats in then - to proper RGB values. + 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. - convert_from_floats - Whether or not to convert float values to ints, by default False Returns ------- np.array The new, converted pixel array. """ - retval = np.array(pixel_array) - if convert_from_floats: - retval = np.apply_along_axis( - lambda f: (f * self.rgb_max_val).astype(self.pixel_array_dtype), - 2, - retval, - ) - return retval + 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 @@ -358,17 +351,19 @@ def set_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, convert_from_floats + converted_array: PixelArray = ( + self.convert_pixel_array(pixel_array) + if convert_from_floats + else np.asarray(pixel_array) ) - if not ( + if ( hasattr(self, "pixel_array") and self.pixel_array.shape == converted_array.shape ): - self.pixel_array: PixelArray = converted_array - else: # Set in place - self.pixel_array[:, :, :] = converted_array[:, :, :] + np.copyto(self.pixel_array, converted_array) + else: + self.pixel_array: PixelArray = converted_array.copy() def set_background( self, pixel_array: PixelArray | list | tuple, convert_from_floats: bool = False @@ -383,7 +378,11 @@ def set_background( 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, convert_from_floats) + 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( @@ -410,7 +409,7 @@ def make_background_from_func( new_background = np.apply_along_axis(coords_to_colors_func, 2, coords) logger.info("Ending set_background") - return self.convert_pixel_array(new_background, convert_from_floats=True) + return self.convert_pixel_array(new_background) def set_background_from_func( self, coords_to_colors_func: Callable[[np.ndarray], np.ndarray] @@ -437,6 +436,7 @@ def reset(self) -> Self: Camera The camera object after setting the pixel array. """ + assert self.background is not None self.set_pixel_array(self.background) return self @@ -711,22 +711,64 @@ def set_cairo_context_path(self, ctx: cairo.Context, vmobject: VMobject) -> Self Camera object after setting cairo_context_path """ points = self.transform_points_pre_display(vmobject, vmobject.points) - # TODO, shouldn't this be handled in transform_points_pre_display? - # points = points - self.get_frame_center() if len(points) == 0: return self + nppcc = vmobject.n_points_per_cubic_curve # 4 for cubic bezier + atol = vmobject.tolerance_for_point_equality + rtol = 1.0e-5 + ctx.new_path() - subpaths = vmobject.gen_subpaths_from_points_2d(points) - for subpath in subpaths: - quads = vmobject.gen_cubic_bezier_tuples_from_points(subpath) - ctx.new_sub_path() - start = subpath[0] - ctx.move_to(*start[:2]) - for _p0, p1, p2, p3 in quads: - ctx.curve_to(*p1[:2], *p2[:2], *p3[:2]) - if vmobject.consider_points_equals_2d(subpath[0], subpath[-1]): - ctx.close_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 + last_base = (end_idx - 1) * 2 + dx = abs(pts_xy[base] - pts_xy[last_base]) + dy = abs(pts_xy[base + 1] - pts_xy[last_base + 1]) + if dx <= atol + rtol * abs(pts_xy[last_base]) and dy <= atol + rtol * abs( + pts_xy[last_base + 1] + ): + _close_path() + return self def set_cairo_context_color( diff --git a/manim/mobject/types/vectorized_mobject.py b/manim/mobject/types/vectorized_mobject.py index 768091ea65..7de8641add 100644 --- a/manim/mobject/types/vectorized_mobject.py +++ b/manim/mobject/types/vectorized_mobject.py @@ -1381,6 +1381,56 @@ def gen_subpaths_from_points_2d( lambda n: not self.consider_points_equals_2d(points[n - 1], points[n]), ) + def get_subpath_split_indices_from_points( + self, points: CubicBezierPathLike, n_dims: int = 3 + ) -> npt.NDArray[np.int_]: + """Return the point indices delimiting each subpath in ``points``. + + A subpath is a run of consecutive cubic Bézier curves where every + curve's end anchor coincides with the next curve's start anchor; a + split is introduced wherever two consecutive anchors differ. This is + the vectorized equivalent of the comparison done by + :meth:`consider_points_equals` (or :meth:`consider_points_equals_2d` + when ``n_dims == 2``). + + Parameters + ---------- + points + The array of points to split into subpaths. + n_dims + The number of coordinates to compare when deciding whether two + anchors coincide: 3 for the full 3D points, or 2 to consider only + their ``x`` and ``y`` coordinates. Default is 3. + + Returns + ------- + np.ndarray + A ``(n_subpaths, 2)`` int array whose rows are the ``[start, end]`` + point index ranges (end-exclusive) of each subpath. + """ + points = np.asarray(points) + nppcc = self.n_points_per_cubic_curve + n_pts = len(points) + if n_pts < nppcc: + return np.empty((0, 2), dtype=int) + + # Point indices where each new cubic curve starts. + boundary_indices = np.arange(nppcc, n_pts, nppcc) + if len(boundary_indices) == 0: + # A single cubic curve: no internal boundaries to split on. + return np.array([[0, n_pts]]) + + # A boundary is a split where the previous curve's end anchor is not + # close to the next curve's start anchor. + rtol = 1.0e-5 # default from np.isclose() + atol = self.tolerance_for_point_equality + ends = points[boundary_indices - 1, :n_dims] # end of previous curve + starts = points[boundary_indices, :n_dims] # start of next curve + is_split = np.any(np.abs(ends - starts) > atol + rtol * np.abs(starts), axis=1) + + split_points = np.concatenate([[0], boundary_indices[is_split], [n_pts]]) + return np.stack([split_points[:-1], split_points[1:]], axis=1) + def get_subpaths(self) -> list[CubicSpline]: """Returns subpaths formed by the curves of the VMobject. diff --git a/manim/renderer/cairo_renderer.py b/manim/renderer/cairo_renderer.py index bcbe3a4fc7..da7cab133d 100644 --- a/manim/renderer/cairo_renderer.py +++ b/manim/renderer/cairo_renderer.py @@ -3,8 +3,6 @@ from collections.abc import Iterable from typing import TYPE_CHECKING, Any -import numpy as np - from manim.utils.hashing import get_hash_from_play_call from .. import config, logger @@ -178,7 +176,7 @@ def get_frame(self) -> PixelArray: NumPy array of pixel values of each pixel in screen. The shape of the array is height x width x 3. """ - return np.array(self.camera.pixel_array) + 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 diff --git a/tests/module/mobject/types/vectorized_mobject/test_vectorized_mobject.py b/tests/module/mobject/types/vectorized_mobject/test_vectorized_mobject.py index 6c942d41a6..63e12a7cd2 100644 --- a/tests/module/mobject/types/vectorized_mobject/test_vectorized_mobject.py +++ b/tests/module/mobject/types/vectorized_mobject/test_vectorized_mobject.py @@ -480,6 +480,56 @@ def path_length(p): assert tuple(map(path_length, o2.get_subpaths())) == (2, 2) +def test_get_subpath_split_indices_from_points(): + # A VMobject made of three subpaths of differing lengths. + o = VMobject() + o.start_new_path(np.array([0.0, 0, 0])) + o.add_line_to(np.array([1.0, 0, 0])) + o.add_line_to(np.array([2.0, 0, 0])) + o.start_new_path(np.array([0.0, 1, 0])) + o.add_line_to(np.array([1.0, 2, 0])) + o.start_new_path(np.array([3.0, 3, 0])) + o.add_line_to(np.array([4.0, 4, 0])) + + for mob in (o, Circle(), Square(), RegularPolygon(n=5)): + points = mob.points + nppcc = mob.n_points_per_cubic_curve + # n_dims=3 mirrors get_subpaths_from_points; n_dims=2 mirrors + # gen_subpaths_from_points_2d. The new method must agree with both. + for n_dims, reference in ( + (3, mob.get_subpaths_from_points(points)), + (2, list(mob.gen_subpaths_from_points_2d(points))), + ): + split_indices = mob.get_subpath_split_indices_from_points( + points, n_dims=n_dims + ) + assert split_indices.ndim == 2 + assert split_indices.shape[1] == 2 + # Rebuild subpaths, dropping incomplete trailing runs the way the + # reference getters do, then compare element-for-element. + rebuilt = [ + points[start:end] + for start, end in split_indices + if end - start >= nppcc + ] + assert len(rebuilt) == len(reference) + for got, expected in zip(rebuilt, reference, strict=True): + np.testing.assert_array_equal(got, expected) + + +def test_get_subpath_split_indices_from_points_edge_cases(): + v = VMobject() + nppcc = v.n_points_per_cubic_curve + # No points -> no subpaths. + assert v.get_subpath_split_indices_from_points(np.zeros((0, 3))).shape == (0, 2) + # Fewer points than a single curve -> no subpaths. + too_few = v.get_subpath_split_indices_from_points(np.zeros((nppcc - 1, 3))) + assert too_few.shape == (0, 2) + # Exactly one curve -> a single [0, nppcc] range. + one_curve = v.get_subpath_split_indices_from_points(np.zeros((nppcc, 3))) + np.testing.assert_array_equal(one_curve, [[0, nppcc]]) + + def test_bounded_become(): """Tests that align_points generates a bounded number of points. https://github.com/ManimCommunity/manim/issues/1959