ManimCommunity / manim

A community-maintained Python framework for creating mathematical animations.
https://www.manim.community
MIT License
19.94k stars 1.48k forks source link

Rewrite `manim.utils.bezier.get_quadratic_approximation_of_cubic()` to produce curves which can be animated smoothly #3829

Open chopan050 opened 1 week ago

chopan050 commented 1 week ago

Overview: What does this pull request change?

Motivation and Explanation: Why and how do your changes improve the library?

The current implementation splits the cubic Bézier at its inflection point (if it exists, otherwise simply t = 0.5). This sounds quite right: the curvature changes at the inflection point, something which can't be captured by a single quadratic Bézier, so we split the curve into two at that point in order to approximate each curvature with different curves.

However:

https://github.com/ManimCommunity/manim/assets/49853152/6fc5b352-92a7-4787-9bf6-57f002547114

As you can see, the speed of the point (1st derivative of the Béziers) is discontinuous when transitioning from one quadratic to the other.

This is problematic for 2 reasons:

Therefore I propose an alternate implementation: instead of forcing a split at a specific point on the curve, I explicitly require that the curves and their 1st derivatives are continuous. (See the docstring of get_quadratic...() for more details on the mathematical process)

With this implementation, the result is as follows:

https://github.com/ManimCommunity/manim/assets/49853152/074a82b5-a20b-4440-a32a-0434a58a01b6

Another example with more exotic curves (the spline is, however, not smooth, so the speeds at the green points aren't continuous:

https://github.com/ManimCommunity/manim/assets/49853152/2ed4f09a-6731-402d-b1f3-08d35ffca6e6

https://github.com/ManimCommunity/manim/assets/49853152/d2b18322-f2d4-4118-b0c8-ccabab852f6e

Notice that the resultant curve might be slightly more off than the original approximation. The original seems more proper for static images where it's enough that the tangents are continuous (the speed directions are the same, rather than the speeds themselves). However, Manim is mainly an animation library, so it's necessary that the speeds are also continuous in this case.

Links to added or changed documentation pages

https://manimce--3829.org.readthedocs.build/en/3829/reference/manim.utils.bezier.html#manim.utils.bezier.get_quadratic_approximation_of_cubic

Further Information and Comments

The code I used:

from manim import *
from manim.utils.bezier import get_quadratic_approximation_of_cubic
from manim.typing import CubicBezierPoints

class BezierScene(Scene):
    def construct(self):
        base = VMobject().set_points_as_corners([
            [-5, 2, 0],
            [-2, 2, 0],
            [-3, 0, 0],
            [5, -3, 0],
        ]).make_smooth()

        # Change the contents to whatever CubicBezierPoints you want
        cubic_bezier_points: list[CubicBezierPoints] = [
            base.points[i:i+4]
            for i in range(0, len(base.points), 4)
            # COMMENT THE TWO LINES ABOVE AND UNCOMMENT THE ARRAYS BELOW FOR THE 2ND EXAMPLE
            # np.array([
            #     [-5, -1, 0],
            #     [-5, 2, 0],
            #     [-3, 2, 0],
            #     [-2, -2, 0],
            # ]),
            # np.array([
            #     [-2, -2, 0],
            #     [2, -2, 0],
            #     [-2, 4, 0],
            #     [2, 2, 0],
            # ]),
            # np.array([
            #     [2, 2, 0],
            #     [6, -2, 0],
            #     [0, -2, 0],
            #     [5, 3, 0],
            # ]),
        ]
        cubic_beziers = [bezier(p) for p in cubic_bezier_points]
        cubic_derivative_points = [3 * (p[1:] - p[:-1]) for p in cubic_bezier_points]
        cubic_derivatives = [bezier(d) for d in cubic_derivative_points]

        quadratic_bezier_points = []

        # Build VMobjects curve by curve, adding separating dots
        cubic_vmob = VMobject(stroke_color=RED)
        quadratic_vmob = VMobject(stroke_color=YELLOW)
        for c in cubic_bezier_points:
            (
                cubic_vmob
                .start_new_path(c[0])
                .add_cubic_bezier_curve_to(*c[1:])
                .add(Dot(c[0], color=GREEN))
            )
            Q = get_quadratic_approximation_of_cubic(*c)
            q0, q1 = Q[:3], Q[3:]
            quadratic_bezier_points.append(q0)
            quadratic_bezier_points.append(q1)
            (
                quadratic_vmob
                .start_new_path(q0[0])
                .add_quadratic_bezier_curve_to(*q0[1:])
                .add_quadratic_bezier_curve_to(*q1[1:])
                .add(Dot(q0[0], color=GREEN), Dot(q1[0], color=BLUE))
            )
        cubic_vmob.add(Dot(cubic_bezier_points[-1][-1], color=GREEN))
        quadratic_vmob.add(Dot(cubic_bezier_points[-1][-1], color=GREEN))

        quadratic_beziers = [bezier(p) for p in quadratic_bezier_points]
        quadratic_derivative_points = [2 * (p[1:] - p[:-1]) for p in quadratic_bezier_points]
        quadratic_derivatives = [bezier(d) for d in quadratic_derivative_points]

        t = ValueTracker(0) # between 0 and 3

        def dot_cubic_updater(dot: Dot):
            val = t.get_value()
            i, alpha = integer_interpolate(0, 3, val) if val < 3 else (2, 1.0)
            B = cubic_beziers[i]
            dot.move_to(B(alpha))

        def arrow_cubic_updater(arrow: Arrow) -> Arrow:
            val = t.get_value()
            i, alpha = integer_interpolate(0, 3, val) if val < 3 else (2, 1.0)
            B = cubic_beziers[i]
            dB = cubic_derivatives[i]
            arrow.put_start_and_end_on(B(alpha), B(alpha) + dB(alpha)/2)

        dot = Dot(color=WHITE, radius=0.15).add_updater(dot_cubic_updater)
        arrow = Arrow(color=WHITE, buff=0.0).add_updater(arrow_cubic_updater)

        self.wait(0.5)
        self.play(FadeIn(cubic_vmob))

        self.play(FadeIn(dot, arrow))
        self.play(t.animate.set_value(1), run_time=6, rate_func=linear)
        self.play(FadeOut(dot, arrow))

        dot.clear_updaters()
        arrow.clear_updaters()

        def dot_quadratic_updater(dot: Dot):
            val = t.get_value()
            i, alpha = integer_interpolate(0, 6, val) if val < 6 else (5, 1.0)
            B = quadratic_beziers[i]
            dot.move_to(B(alpha))

        def arrow_quadratic_updater(arrow: Arrow) -> Arrow:
            val = t.get_value()
            i, alpha = integer_interpolate(0, 6, val) if val < 6 else (5, 1.0)
            B = quadratic_beziers[i]
            dB = quadratic_derivatives[i]
            arrow.put_start_and_end_on(B(alpha), B(alpha) + dB(alpha)/2)

        dot.add_updater(dot_quadratic_updater)
        arrow.add_updater(arrow_quadratic_updater)

        t.set_value(0)
        self.play(FadeIn(quadratic_vmob))

        self.play(FadeIn(dot, arrow))
        self.play(t.animate.set_value(1), run_time=12, rate_func=linear)
        self.play(FadeOut(dot, arrow))

Reviewer Checklist