Skip to content

Commit 1df1709

Browse files
authored
Rewrite manim.utils.bezier.get_quadratic_approximation_of_cubic() to produce curves which can be animated smoothly (#3829)
* Rewrite get_quadratic_approximation_of_cubic() and add test * Move test_get... to end of file * Complete docstring for get_quadratic...()
1 parent 628a545 commit 1df1709

File tree

2 files changed

+187
-66
lines changed

2 files changed

+187
-66
lines changed

manim/utils/bezier.py

+139-66
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@
2828

2929
from manim.typing import PointDType
3030
from manim.utils.simple_functions import choose
31-
from manim.utils.space_ops import cross2d, find_intersection
3231

3332
if TYPE_CHECKING:
3433
import numpy.typing as npt
@@ -39,6 +38,7 @@
3938
MatrixMN,
4039
Point3D,
4140
Point3D_Array,
41+
QuadraticBezierPoints_Array,
4242
)
4343

4444
# l is a commonly used name in linear algebra
@@ -1610,74 +1610,147 @@ def get_smooth_open_cubic_bezier_handle_points(
16101610
return H1, H2
16111611

16121612

1613-
# Given 4 control points for a cubic bezier curve (or arrays of such)
1614-
# return control points for 2 quadratics (or 2n quadratics) approximating them.
1613+
@overload
16151614
def get_quadratic_approximation_of_cubic(
16161615
a0: Point3D, h0: Point3D, h1: Point3D, a1: Point3D
1617-
) -> BezierPoints:
1618-
a0 = np.array(a0, ndmin=2)
1619-
h0 = np.array(h0, ndmin=2)
1620-
h1 = np.array(h1, ndmin=2)
1621-
a1 = np.array(a1, ndmin=2)
1622-
# Tangent vectors at the start and end.
1623-
T0 = h0 - a0
1624-
T1 = a1 - h1
1625-
1626-
# Search for inflection points. If none are found, use the
1627-
# midpoint as a cut point.
1628-
# Based on http://www.caffeineowl.com/graphics/2d/vectorial/cubic-inflexion.html
1629-
has_infl = np.ones(len(a0), dtype=bool)
1630-
1631-
p = h0 - a0
1632-
q = h1 - 2 * h0 + a0
1633-
r = a1 - 3 * h1 + 3 * h0 - a0
1634-
1635-
a = cross2d(q, r)
1636-
b = cross2d(p, r)
1637-
c = cross2d(p, q)
1638-
1639-
disc = b * b - 4 * a * c
1640-
has_infl &= disc > 0
1641-
sqrt_disc = np.sqrt(np.abs(disc))
1642-
settings = np.seterr(all="ignore")
1643-
ti_bounds = []
1644-
for sgn in [-1, +1]:
1645-
ti = (-b + sgn * sqrt_disc) / (2 * a)
1646-
ti[a == 0] = (-c / b)[a == 0]
1647-
ti[(a == 0) & (b == 0)] = 0
1648-
ti_bounds.append(ti)
1649-
ti_min, ti_max = ti_bounds
1650-
np.seterr(**settings)
1651-
ti_min_in_range = has_infl & (0 < ti_min) & (ti_min < 1)
1652-
ti_max_in_range = has_infl & (0 < ti_max) & (ti_max < 1)
1653-
1654-
# Choose a value of t which starts at 0.5,
1655-
# but is updated to one of the inflection points
1656-
# if they lie between 0 and 1
1657-
1658-
t_mid = 0.5 * np.ones(len(a0))
1659-
t_mid[ti_min_in_range] = ti_min[ti_min_in_range]
1660-
t_mid[ti_max_in_range] = ti_max[ti_max_in_range]
1661-
1662-
m, n = a0.shape
1663-
t_mid = t_mid.repeat(n).reshape((m, n))
1664-
1665-
# Compute bezier point and tangent at the chosen value of t (these are vectorized)
1666-
mid = bezier([a0, h0, h1, a1])(t_mid) # type: ignore
1667-
Tm = bezier([h0 - a0, h1 - h0, a1 - h1])(t_mid) # type: ignore
1668-
1669-
# Intersection between tangent lines at end points
1670-
# and tangent in the middle
1671-
i0 = find_intersection(a0, T0, mid, Tm)
1672-
i1 = find_intersection(a1, T1, mid, Tm)
1673-
1674-
m, n = np.shape(a0)
1675-
result = np.zeros((6 * m, n))
1616+
) -> QuadraticBezierPoints_Array: ...
1617+
1618+
1619+
@overload
1620+
def get_quadratic_approximation_of_cubic(
1621+
a0: Point3D_Array,
1622+
h0: Point3D_Array,
1623+
h1: Point3D_Array,
1624+
a1: Point3D_Array,
1625+
) -> QuadraticBezierPoints_Array: ...
1626+
1627+
1628+
def get_quadratic_approximation_of_cubic(a0, h0, h1, a1):
1629+
r"""If ``a0``, ``h0``, ``h1`` and ``a1`` are the control points of a cubic
1630+
Bézier curve, approximate the curve with two quadratic Bézier curves and
1631+
return an array of 6 points, where the first 3 points represent the first
1632+
quadratic curve and the last 3 represent the second one.
1633+
1634+
Otherwise, if ``a0``, ``h0``, ``h1`` and ``a1`` are _arrays_ of :math:`N`
1635+
points representing :math:`N` cubic Bézier curves, return an array of
1636+
:math:`6N` points where each group of :math:`6` consecutive points
1637+
approximates each of the :math:`N` curves in a similar way as above.
1638+
1639+
.. note::
1640+
If the cubic spline given by the original cubic Bézier curves is
1641+
smooth, this algorithm will generate a quadratic spline which is also
1642+
smooth.
1643+
1644+
If a cubic Bézier is given by
1645+
1646+
.. math::
1647+
C(t) = (1-t)^3 A_0 + 3(1-t)^2 t H_0 + 3(1-t)t^2 H_1 + t^3 A_1
1648+
1649+
where :math:`A_0`, :math:`H_0`, :math:`H_1` and :math:`A_1` are its
1650+
control points, then this algorithm should generate two quadratic
1651+
Béziers given by
1652+
1653+
.. math::
1654+
Q_0(t) &= (1-t)^2 A_0 + 2(1-t)t M_0 + t^2 K \\
1655+
Q_1(t) &= (1-t)^2 K + 2(1-t)t M_1 + t^2 A_1
1656+
1657+
where :math:`M_0` and :math:`M_1` are the respective handles to be
1658+
found for both curves, and :math:`K` is the end anchor of the 1st curve
1659+
and the start anchor of the 2nd, which must also be found.
1660+
1661+
To solve for :math:`M_0`, :math:`M_1` and :math:`K`, three conditions
1662+
can be imposed:
1663+
1664+
1. :math:`Q_0'(0) = \frac{1}{2}C'(0)`. The derivative of the first
1665+
quadratic curve at :math:`t = 0` should be proportional to that of
1666+
the original cubic curve, also at :math:`t = 0`. Because the cubic
1667+
curve is split into two parts, it is necessary to divide this by
1668+
two: the speed of a point travelling through the curve should be
1669+
half of the original. This gives:
1670+
1671+
.. math::
1672+
Q_0'(0) &= \frac{1}{2}C'(0) \\
1673+
2(M_0 - A_0) &= \frac{3}{2}(H_0 - A_0) \\
1674+
2M_0 - 2A_0 &= \frac{3}{2}H_0 - \frac{3}{2}A_0 \\
1675+
2M_0 &= \frac{3}{2}H_0 + \frac{1}{2}A_0 \\
1676+
M_0 &= \frac{1}{4}(3H_0 + A_0)
1677+
1678+
2. :math:`Q_1'(1) = \frac{1}{2}C'(1)`. The derivative of the second
1679+
quadratic curve at :math:`t = 1` should be half of that of the
1680+
original cubic curve for the same reasons as above, also at
1681+
:math:`t = 1`. This gives:
1682+
1683+
.. math::
1684+
Q_1'(1) &= \frac{1}{2}C'(1) \\
1685+
2(A_1 - M_1) &= \frac{3}{2}(A_1 - H_1) \\
1686+
2A_1 - 2M_1 &= \frac{3}{2}A_1 - \frac{3}{2}H_1 \\
1687+
-2M_1 &= -\frac{1}{2}A_1 - \frac{3}{2}H_1 \\
1688+
M_1 &= \frac{1}{4}(3H_1 + A_1)
1689+
1690+
3. :math:`Q_0'(1) = Q_1'(0)`. The derivatives of both quadratic curves
1691+
should match at the point :math:`K`, in order for the final spline
1692+
to be smooth. This gives:
1693+
1694+
.. math::
1695+
Q_0'(1) &= Q_1'(0) \\
1696+
2(K - M_0) &= 2(M_1 - K) \\
1697+
2K - 2M_0 &= 2M_1 - 2K \\
1698+
4K &= 2M_0 + 2M_1 \\
1699+
K &= \frac{1}{2}(M_0 + M_1)
1700+
1701+
This is sufficient to find proper control points for the quadratic
1702+
Bézier curves.
1703+
1704+
Parameters
1705+
----------
1706+
a0
1707+
The start anchor of a single cubic Bézier curve, or an array of
1708+
:math:`N` start anchors for :math:`N` curves.
1709+
h0
1710+
The first handle of a single cubic Bézier curve, or an array of
1711+
:math:`N` first handles for :math:`N` curves.
1712+
h1
1713+
The second handle of a single cubic Bézier curve, or an array of
1714+
:math:`N` second handles for :math:`N` curves.
1715+
a1
1716+
The end anchor of a single cubic Bézier curve, or an array of
1717+
:math:`N` end anchors for :math:`N` curves.
1718+
1719+
Returns
1720+
-------
1721+
result
1722+
An array containing either 6 points for 2 quadratic Bézier curves
1723+
approximating the original cubic curve, or :math:`6N` points for
1724+
:math:`2N` quadratic curves approximating :math:`N` cubic curves.
1725+
1726+
Raises
1727+
------
1728+
ValueError
1729+
If ``a0``, ``h0``, ``h1`` and ``a1`` have different dimensions, or
1730+
if their number of dimensions is not 1 or 2.
1731+
"""
1732+
a0 = np.asarray(a0)
1733+
h0 = np.asarray(h0)
1734+
h1 = np.asarray(h1)
1735+
a1 = np.asarray(a1)
1736+
1737+
if all(arr.ndim == 1 for arr in (a0, h0, h1, a1)):
1738+
num_curves, dim = 1, a0.shape[0]
1739+
elif all(arr.ndim == 2 for arr in (a0, h0, h1, a1)):
1740+
num_curves, dim = a0.shape
1741+
else:
1742+
raise ValueError("All arguments must be Point3D or Point3D_Array.")
1743+
1744+
m0 = 0.25 * (3 * h0 + a0)
1745+
m1 = 0.25 * (3 * h1 + a1)
1746+
k = 0.5 * (m0 + m1)
1747+
1748+
result = np.empty((6 * num_curves, dim))
16761749
result[0::6] = a0
1677-
result[1::6] = i0
1678-
result[2::6] = mid
1679-
result[3::6] = mid
1680-
result[4::6] = i1
1750+
result[1::6] = m0
1751+
result[2::6] = k
1752+
result[3::6] = k
1753+
result[4::6] = m1
16811754
result[5::6] = a1
16821755
return result
16831756

tests/module/utils/test_bezier.py

+48
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from manim.typing import ManimFloat
99
from manim.utils.bezier import (
1010
_get_subdivision_matrix,
11+
get_quadratic_approximation_of_cubic,
1112
get_smooth_cubic_bezier_handle_points,
1213
partial_bezier_points,
1314
split_bezier,
@@ -167,3 +168,50 @@ def test_get_smooth_cubic_bezier_handle_points() -> None:
167168
]
168169
),
169170
)
171+
172+
173+
def test_get_quadratic_approximation_of_cubic() -> None:
174+
C = np.array(
175+
[
176+
[-5, 2, 0],
177+
[-4, 2, 0],
178+
[-3, 2, 0],
179+
[-2, 2, 0],
180+
[-2, 2, 0],
181+
[-7 / 3, 4 / 3, 0],
182+
[-8 / 3, 2 / 3, 0],
183+
[-3, 0, 0],
184+
[-3, 0, 0],
185+
[-1 / 3, -1, 0],
186+
[7 / 3, -2, 0],
187+
[5, -3, 0],
188+
]
189+
)
190+
a0, h0, h1, a1 = C[::4], C[1::4], C[2::4], C[3::4]
191+
192+
Q = get_quadratic_approximation_of_cubic(a0, h0, h1, a1)
193+
assert np.allclose(
194+
Q,
195+
np.array(
196+
[
197+
[-5, 2, 0],
198+
[-17 / 4, 2, 0],
199+
[-7 / 2, 2, 0],
200+
[-7 / 2, 2, 0],
201+
[-11 / 4, 2, 0],
202+
[-2, 2, 0],
203+
[-2, 2, 0],
204+
[-9 / 4, 3 / 2, 0],
205+
[-5 / 2, 1, 0],
206+
[-5 / 2, 1, 0],
207+
[-11 / 4, 1 / 2, 0],
208+
[-3, 0, 0],
209+
[-3, 0, 0],
210+
[-1, -3 / 4, 0],
211+
[1, -3 / 2, 0],
212+
[1, -3 / 2, 0],
213+
[3, -9 / 4, 0],
214+
[5, -3, 0],
215+
]
216+
),
217+
)

0 commit comments

Comments
 (0)