From 8bd12f7301c064846cc368116ce0714399112776 Mon Sep 17 00:00:00 2001 From: Hamdi Barkous Date: Thu, 9 Apr 2026 02:05:53 +0100 Subject: [PATCH 1/8] Optimize set_cairo_context_path: vectorize subpath splitting and use flat array indexing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace Python generators and tuple unpacking with numpy-based subpath splitting and direct flat-array indexing for bezier point lookups. Same Cairo calls, same output, ~2-7x faster path building. - Replace gen_subpaths_from_points_2d generator with vectorized numpy boundary detection using np.arange + boolean masking - Replace gen_cubic_bezier_tuples_from_points generator with direct integer-range iteration over pre-flattened xy array - Eliminate per-curve numpy slice creation (*p[:2] splat) - Cache method references (ctx.curve_to → local) to avoid attribute lookup per call Benchmarks (1920x1080 @ 60fps): - set_path: 2-7x faster across scene types - Overall: up to 1.5x faster on shape/text-heavy scenes Co-Authored-By: Claude Opus 4.6 (1M context) --- manim/camera/camera.py | 80 +++++++++++++++++++++++++++++++++++------- 1 file changed, 68 insertions(+), 12 deletions(-) diff --git a/manim/camera/camera.py b/manim/camera/camera.py index 2ee433d28b..408ab40841 100644 --- a/manim/camera/camera.py +++ b/manim/camera/camera.py @@ -711,22 +711,78 @@ 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() + + # Find subpath split points using vectorized comparison. + # A split occurs where consecutive anchors (at nppcc boundaries) + # are NOT close — i.e., there's a gap between subpaths. + n_pts = len(points) + if n_pts < nppcc: + return self + + # Indices where a new cubic curve starts + boundary_indices = np.arange(nppcc, n_pts, nppcc) + if len(boundary_indices) == 0: + return self + + # Check which boundaries are splits (points NOT equal) + ends = points[boundary_indices - 1, :2] # end of previous curve + starts = points[boundary_indices, :2] # start of next curve + diffs = np.abs(ends - starts) + thresholds = atol + rtol * np.abs(starts) + is_split = np.any(diffs > thresholds, axis=1) + + # Build split indices: [0, split1, split2, ..., n_pts] + split_indices = np.concatenate( + [[0], boundary_indices[is_split], [n_pts]] + ) + + # 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 si in range(len(split_indices) - 1): + start_idx = int(split_indices[si]) + end_idx = int(split_indices[si + 1]) + 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( From 7a9b462ce560daeb4df68f169596974bcba9f795 Mon Sep 17 00:00:00 2001 From: Hamdi Barkous Date: Thu, 9 Apr 2026 02:15:05 +0100 Subject: [PATCH 2/8] Eliminate redundant numpy copies in camera reset and frame retrieval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - camera.reset(): Replace set_pixel_array() → convert_pixel_array() → np.array() (copy) → slice assignment (second copy) with a single np.copyto() call. Removes one full-frame copy per frame. - set_frame_to_background(): Same optimization for static frame restore. - renderer.get_frame(): Replace np.array() with .copy() — avoids dtype inference overhead on an already-typed array. Benchmarks (1920x1080 @ 60fps): - camera_reset: 3-10x faster (e.g. 390ms → 120ms on AnimatedTransforms) - Overall: ~2x faster across scene types when combined with set_path opt Co-Authored-By: Claude Opus 4.6 (1M context) --- manim/camera/camera.py | 10 ++++++++-- manim/renderer/cairo_renderer.py | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/manim/camera/camera.py b/manim/camera/camera.py index 408ab40841..99eec8d54a 100644 --- a/manim/camera/camera.py +++ b/manim/camera/camera.py @@ -437,11 +437,17 @@ def reset(self) -> Self: Camera The camera object after setting the pixel array. """ - self.set_pixel_array(self.background) + if hasattr(self, "pixel_array") and self.pixel_array.shape == self.background.shape: + np.copyto(self.pixel_array, self.background) + else: + self.pixel_array = self.background.copy() return self def set_frame_to_background(self, background: PixelArray) -> None: - self.set_pixel_array(background) + if hasattr(self, "pixel_array") and self.pixel_array.shape == background.shape: + np.copyto(self.pixel_array, background) + else: + self.pixel_array = background.copy() #### diff --git a/manim/renderer/cairo_renderer.py b/manim/renderer/cairo_renderer.py index bcbe3a4fc7..ace1325ddf 100644 --- a/manim/renderer/cairo_renderer.py +++ b/manim/renderer/cairo_renderer.py @@ -178,7 +178,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 From a134be6245e9aaca164d72037957131731305d02 Mon Sep 17 00:00:00 2001 From: Hamdi Barkous Date: Wed, 15 Apr 2026 04:54:57 +0100 Subject: [PATCH 3/8] Add Lissajous Table benchmark for measuring render performance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds benchmarks/bench_lissajous.py — a heavy real-world animation workload with grid-of-circles updaters tracing Lissajous curves. Stresses the per-frame render path far more than static gallery scenes, making rendering optimizations visible end-to-end. Co-Authored-By: Claude Opus 4.6 (1M context) --- benchmarks/bench_lissajous.py | 387 ++++++++++++++++++++++++++++++++++ 1 file changed, 387 insertions(+) create mode 100644 benchmarks/bench_lissajous.py diff --git a/benchmarks/bench_lissajous.py b/benchmarks/bench_lissajous.py new file mode 100644 index 0000000000..3c509d75c5 --- /dev/null +++ b/benchmarks/bench_lissajous.py @@ -0,0 +1,387 @@ +"""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(f"-" * 75) + print(f" {'TOTAL':<30s} {grand_total*1000:>8.0f}ms") From 2518c292d24e3eeb5cd6516c547cb84086937f56 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 04:07:33 +0000 Subject: [PATCH 4/8] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- benchmarks/bench_lissajous.py | 43 +++++++++++++++++++++----------- manim/camera/camera.py | 25 +++++++++++-------- manim/renderer/cairo_renderer.py | 2 -- 3 files changed, 43 insertions(+), 27 deletions(-) diff --git a/benchmarks/bench_lissajous.py b/benchmarks/bench_lissajous.py index 3c509d75c5..0d072a15cc 100644 --- a/benchmarks/bench_lissajous.py +++ b/benchmarks/bench_lissajous.py @@ -15,11 +15,12 @@ import time import numpy as np -from manim import * +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: @@ -47,6 +48,7 @@ def get_intersection_point(row_circ, column_circ): # ─── Custom mobject (from mobjects.py) ──────────────────────────────────────── + class LissajousCircle(Circle): def __init__( self, @@ -57,7 +59,7 @@ def __init__( include_radius_line=True, radius_line_kwargs=None, start_angle=0, - **circle_kwargs + **circle_kwargs, ): if point_kwargs is None: point_kwargs = {} @@ -113,7 +115,7 @@ def __init__( include_radius_line=True, row_circle_speeds_range=(1, 3), column_circle_speeds_range=(1, 3), - **kwargs + **kwargs, ): if circle_kwargs is None: circle_kwargs = {} @@ -285,11 +287,11 @@ def construct(self): AnimationGroup( LaggedStart( *[FadeIn(c, shift=0.25 * LEFT) for c in self.row_circles], - lag_ratio=0.25 + lag_ratio=0.25, ), LaggedStart( *[FadeIn(c, shift=0.25 * UP) for c in self.column_circles], - lag_ratio=0.25 + lag_ratio=0.25, ), lag_ratio=1, run_time=4, @@ -319,24 +321,33 @@ def construct(self): 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, + 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, + 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, + radius=0.75, + row_buff=0.25, + column_buff=0.25, + top_edge_buff=0.5, + left_edge_buff=0.5, ) @@ -378,10 +389,12 @@ def bench_scene(scene_cls): 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)") + 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(f"-" * 75) - print(f" {'TOTAL':<30s} {grand_total*1000:>8.0f}ms") + print("-" * 75) + print(f" {'TOTAL':<30s} {grand_total * 1000:>8.0f}ms") diff --git a/manim/camera/camera.py b/manim/camera/camera.py index 99eec8d54a..40923e3677 100644 --- a/manim/camera/camera.py +++ b/manim/camera/camera.py @@ -437,7 +437,10 @@ def reset(self) -> Self: Camera The camera object after setting the pixel array. """ - if hasattr(self, "pixel_array") and self.pixel_array.shape == self.background.shape: + if ( + hasattr(self, "pixel_array") + and self.pixel_array.shape == self.background.shape + ): np.copyto(self.pixel_array, self.background) else: self.pixel_array = self.background.copy() @@ -740,15 +743,13 @@ def set_cairo_context_path(self, ctx: cairo.Context, vmobject: VMobject) -> Self # Check which boundaries are splits (points NOT equal) ends = points[boundary_indices - 1, :2] # end of previous curve - starts = points[boundary_indices, :2] # start of next curve + starts = points[boundary_indices, :2] # start of next curve diffs = np.abs(ends - starts) thresholds = atol + rtol * np.abs(starts) is_split = np.any(diffs > thresholds, axis=1) # Build split indices: [0, split1, split2, ..., n_pts] - split_indices = np.concatenate( - [[0], boundary_indices[is_split], [n_pts]] - ) + split_indices = np.concatenate([[0], boundary_indices[is_split], [n_pts]]) # Precompute flat xy array for fast indexing pts_xy = points[:, :2].ravel() # [x0, y0, x1, y1, ...] @@ -776,17 +777,21 @@ def set_cairo_context_path(self, ctx: cairo.Context, vmobject: VMobject) -> Self 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], + 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])): + if dx <= atol + rtol * abs(pts_xy[last_base]) and dy <= atol + rtol * abs( + pts_xy[last_base + 1] + ): _close_path() return self diff --git a/manim/renderer/cairo_renderer.py b/manim/renderer/cairo_renderer.py index ace1325ddf..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 From ab496adc1ba9ed659c9cb9d38b973cab4807f900 Mon Sep 17 00:00:00 2001 From: Hamdi Barkous Date: Wed, 15 Apr 2026 05:12:11 +0100 Subject: [PATCH 5/8] Fix mypy union-attr error in Camera.reset self.background is typed as PixelArray | None (from __init__ param) but is guaranteed non-None after init_background() runs during construction. Add an assert to satisfy mypy. Co-Authored-By: Claude Opus 4.6 (1M context) --- manim/camera/camera.py | 1 + 1 file changed, 1 insertion(+) diff --git a/manim/camera/camera.py b/manim/camera/camera.py index 40923e3677..023f51517b 100644 --- a/manim/camera/camera.py +++ b/manim/camera/camera.py @@ -437,6 +437,7 @@ def reset(self) -> Self: Camera The camera object after setting the pixel array. """ + assert self.background is not None if ( hasattr(self, "pixel_array") and self.pixel_array.shape == self.background.shape From cae5f48daaa69e7971cfc625b4ee038c87e78ecb Mon Sep 17 00:00:00 2001 From: Hamdi Barkous Date: Wed, 15 Apr 2026 12:03:02 +0100 Subject: [PATCH 6/8] Fix single-curve VMobject skipped in set_cairo_context_path When a VMobject had exactly nppcc points (one cubic curve, e.g. Line), np.arange(nppcc, n_pts, nppcc) returned an empty array and the function exited before drawing. The original code handled this via split_indices of [0, n_pts], yielding one subpath of all 4 points. Handle the empty-boundary case explicitly as a single subpath. Verified pixel-identical vs main across 12 scenes (Line, Dot, Square, Circle, Arrow, Text, MathTex, Polyline, DashedLine, OpenPath, mixed, animated). Co-Authored-By: Claude Opus 4.6 (1M context) --- manim/camera/camera.py | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/manim/camera/camera.py b/manim/camera/camera.py index 023f51517b..b91c117e1c 100644 --- a/manim/camera/camera.py +++ b/manim/camera/camera.py @@ -740,17 +740,20 @@ def set_cairo_context_path(self, ctx: cairo.Context, vmobject: VMobject) -> Self # Indices where a new cubic curve starts boundary_indices = np.arange(nppcc, n_pts, nppcc) if len(boundary_indices) == 0: - return self - - # Check which boundaries are splits (points NOT equal) - ends = points[boundary_indices - 1, :2] # end of previous curve - starts = points[boundary_indices, :2] # start of next curve - diffs = np.abs(ends - starts) - thresholds = atol + rtol * np.abs(starts) - is_split = np.any(diffs > thresholds, axis=1) - - # Build split indices: [0, split1, split2, ..., n_pts] - split_indices = np.concatenate([[0], boundary_indices[is_split], [n_pts]]) + # Single cubic curve — no internal boundaries to split on. + split_indices = np.array([0, n_pts]) + else: + # Check which boundaries are splits (points NOT equal) + ends = points[boundary_indices - 1, :2] # end of previous curve + starts = points[boundary_indices, :2] # start of next curve + diffs = np.abs(ends - starts) + thresholds = atol + rtol * np.abs(starts) + is_split = np.any(diffs > thresholds, axis=1) + + # Build split indices: [0, split1, split2, ..., n_pts] + split_indices = np.concatenate( + [[0], boundary_indices[is_split], [n_pts]] + ) # Precompute flat xy array for fast indexing pts_xy = points[:, :2].ravel() # [x0, y0, x1, y1, ...] From 9be3ff814e63f5754cdaf9a6c1daf01a306ab215 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 12:45:33 +0000 Subject: [PATCH 7/8] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- manim/camera/camera.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/manim/camera/camera.py b/manim/camera/camera.py index b91c117e1c..97e158578b 100644 --- a/manim/camera/camera.py +++ b/manim/camera/camera.py @@ -751,9 +751,7 @@ def set_cairo_context_path(self, ctx: cairo.Context, vmobject: VMobject) -> Self is_split = np.any(diffs > thresholds, axis=1) # Build split indices: [0, split1, split2, ..., n_pts] - split_indices = np.concatenate( - [[0], boundary_indices[is_split], [n_pts]] - ) + split_indices = np.concatenate([[0], boundary_indices[is_split], [n_pts]]) # Precompute flat xy array for fast indexing pts_xy = points[:, :2].ravel() # [x0, y0, x1, y1, ...] From 826fd984dd213192d29bb72eb0a795d7f5c8cb65 Mon Sep 17 00:00:00 2001 From: Hamdi Barkous Date: Fri, 17 Apr 2026 00:07:50 +0100 Subject: [PATCH 8/8] Accept list-typed VMobject.points in set_cairo_context_path The vectorized path builder uses numpy fancy indexing (e.g. points[boundary_indices - 1, :2]), which fails when vmobject.points is a plain Python list. The documented VMobjectDemo example sets points this way, which broke the docs build. np.asarray the points array once on entry; it's a no-op when the input is already an ndarray. Co-Authored-By: Claude Opus 4.6 (1M context) --- manim/camera/camera.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/manim/camera/camera.py b/manim/camera/camera.py index 97e158578b..f356ee202d 100644 --- a/manim/camera/camera.py +++ b/manim/camera/camera.py @@ -723,6 +723,9 @@ def set_cairo_context_path(self, ctx: cairo.Context, vmobject: VMobject) -> Self points = self.transform_points_pre_display(vmobject, vmobject.points) if len(points) == 0: return self + # vmobject.points may be a Python list (see VMobjectDemo in the docs); + # the vectorized path-building below needs an ndarray. + points = np.asarray(points) nppcc = vmobject.n_points_per_cubic_curve # 4 for cubic bezier atol = vmobject.tolerance_for_point_equality