Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 149 additions & 0 deletions manim/mobject/types/vectorized_mobject.py
Original file line number Diff line number Diff line change
Expand Up @@ -1796,6 +1796,155 @@ def get_points_defining_boundary(self) -> Point3D_Array:
tuple(it.chain(*(sm.get_anchors() for sm in self.get_family())))
)

def get_bezier_bounding_box(self) -> Point3D_Array:
"""Return the exact axis-aligned bounding box of the curves defining
this :class:`VMobject`, rather than the bounding box of their control
points.

The points of a Bézier curve other than its anchors act as handles;
they generally do not lie on the curve itself. Computing bounds from
``self.points`` directly therefore yields ``width``/``height`` values
that can differ from the physical extent of the rendered curve. This
method accounts for the interior extrema of every curve and returns
the true bounds as ``array([[xmin, ymin, zmin], [xmax, ymax, zmax]])``.

Returns
-------
Point3D_Array
The lower-left and upper-right corners of the bounding box, or
``None`` if this :class:`VMobject` has no points.

Examples
--------
.. manim:: BezierBoundingBoxExample
:save_last_frame:

class BezierBoundingBoxExample(Scene):
def construct(self):
c = Circle(radius=3).rotate(30 * DEGREES)
rect = Rectangle(width=c.width, height=c.height).move_to(c).set_stroke(BLUE)
self.add(c, rect)
"""
pts = self.points
if len(pts) == 0:
return None
nppcc = self.n_points_per_cubic_curve
if nppcc not in (3, 4) or len(pts) % nppcc != 0:
return np.array([pts.min(axis=0), pts.max(axis=0)])

lower = np.zeros(3)
upper = np.zeros(3)
for dim in range(3):
vals = self._get_curve_extrema(dim, pts, nppcc)
lower[dim] = np.min(vals[:, 0])
upper[dim] = np.max(vals[:, 1])
return np.array([lower, upper])

def _get_curve_extrema(
self, dim: int, pts: Point3D_Array, nppcc: int
) -> npt.NDArray[np.float64]:
"""Return, for every curve in ``pts``, the exact minimum and maximum
value of its ``dim``-th coordinate (anchors and interior extrema).

Returns an array of shape ``(n_curves, 2)``. A single point yields a
degenerate curve whose extent in every dimension is that point.
"""
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8098b63: the unused anchors assignment was removed, and the redundant t = np.zeros(n_curves) initialization was dropped (the subsequent np.where always overwrites it).

n_curves = len(pts) // nppcc
if nppcc == 3:
# Quadratic Bézier: P'(t) = 2*((p1-p0) + t*(p0-2p1+p2)).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume this is in preparation for adding the same feature to OpenGLVMobject since AFAIK VMobject always has npcc == 4. Could maybe be left out for now but I guess it doesn't hurt anything.

p0, p1, p2 = (pts[i::nppcc, dim] for i in range(nppcc))
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
denom = p0 - 2 * p1 + p2
with np.errstate(divide="ignore", invalid="ignore"):
t = np.where(np.abs(denom) > 1e-12, (p0 - p1) / denom, np.nan)
t_valid = (t > 1e-12) & (t < 1 - 1e-12)
starts = pts[::nppcc, dim]
ends = pts[nppcc - 1 :: nppcc, dim]
mins = np.minimum(starts, ends).astype(np.float64)
maxs = np.maximum(starts, ends).astype(np.float64)
if np.any(t_valid):
tv = t[t_valid]
p0v, p1v, p2v = (pts[i::nppcc, dim][t_valid] for i in range(nppcc))
ev = (1 - tv) ** 2 * p0v + 2 * (1 - tv) * tv * p1v + tv**2 * p2v
mins[t_valid] = np.minimum(mins[t_valid], ev)
maxs[t_valid] = np.maximum(maxs[t_valid], ev)
return np.column_stack([mins, maxs])

# Cubic Bézier: derivative roots of P'(t) = A t^2 + B t + C.
p0, p1, p2, p3 = (pts[i::nppcc, dim] for i in range(nppcc))
u = p1 - p0
v = p2 - p1
w = p3 - p2
aa = u - 2 * v + w
bb = 2 * (v - u)
cc = u
disc = bb * bb - 4 * aa * cc
disc_pos = disc > 1e-24
aa_zero = np.abs(aa) < 1e-12
t = np.full((n_curves, 2), np.nan)
with np.errstate(divide="ignore", invalid="ignore"):
sq = np.sqrt(np.maximum(disc, 0))
# A == 0: degenerate quadratic, single root -C/B (B != 0).
t[:, 0] = np.where(
aa_zero & ~np.isclose(bb, 0, atol=1e-12),
-cc / bb,
(-bb + sq) / (2 * aa),
)
t[:, 1] = np.where(aa_zero, t[:, 0], (-bb - sq) / (2 * aa))
start = p0
end = p3
mins = np.minimum(start, end).astype(np.float64)
maxs = np.maximum(start, end).astype(np.float64)
for j in range(2):
tv = t[:, j]
quadratic_root = aa_zero & ~np.isclose(bb, 0, atol=1e-12)
valid = (
(disc_pos & ~aa_zero | quadratic_root) & (tv > 1e-12) & (tv < 1 - 1e-12)
)
if np.any(valid):
tvv = tv[valid]
omt = 1 - tvv
ev = (
omt**3 * p0[valid]
+ 3 * omt**2 * tvv * p1[valid]
+ 3 * omt * tvv**2 * p2[valid]
+ tvv**3 * p3[valid]
)
mins[valid] = np.minimum(mins[valid], ev)
maxs[valid] = np.maximum(maxs[valid], ev)
return np.column_stack([mins, maxs])

def length_over_dim(self, dim: int) -> float:
"""Find the length of this :class:`VMobject` in a certain direction.

Like :meth:`~.Mobject.length_over_dim`, this covers every point in
this :class:`VMobject` and its submobjects, but the length is
computed from the actual Bézier curves (including their interior
extrema) rather than from the raw control points. Dimensions such
as :attr:`~Mobject.width` and :attr:`~Mobject.height` therefore
correspond to the physical extent of the rendered shape, while
critical points such as :meth:`~Mobject.get_left` keep their
control-point semantics.
"""
if len(self.submobjects) == 0:
bbox = self.get_bezier_bounding_box()
if bbox is None:
return 0.0
return bbox[1][dim] - bbox[0][dim]

lower = float("inf")
upper = float("-inf")
for mob in self.get_family():
if len(mob.points) == 0:
continue
bbox = mob.get_bezier_bounding_box()
if bbox is None:
continue
lower = min(lower, bbox[0][dim])
upper = max(upper, bbox[1][dim])
if upper == float("-inf"):
return 0.0
return upper - lower

def get_arc_length(self, sample_points_per_curve: int | None = None) -> float:
"""Return the approximated length of the whole curve.

Expand Down

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see no tests for depth, is this intentional?

Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import pytest

from manim import (
Arc,
Circle,
CurvesAsSubmobjects,
Line,
Expand All @@ -15,7 +16,7 @@
VGroup,
VMobject,
)
from manim.constants import PI
from manim.constants import DEGREES, PI


def test_vmobject_add():
Expand Down Expand Up @@ -735,3 +736,65 @@ def test_pointwise_become_partial_where_vmobject_is_self():
]
)
np.testing.assert_allclose(sq.points, expected_points)


def test_width_height_account_for_curve_interior_extrema():
"""Handles (control points) must not inflate width/height (#3619)."""
c = Circle(radius=3).rotate(30 * DEGREES)
assert c.width == pytest.approx(6.0, abs=1e-3)
assert c.height == pytest.approx(6.0, abs=1e-3)


def test_width_height_include_interior_extrema_of_wild_cubic():
# Handles at x=5 and x=-5 with anchors x=0 and x=1: the control-point
# bounding box would give width 10, but the curve itself peaks at
# x ~= 1.45299 and dips to x ~= -0.98473, so the exact width is ~2.4377.
vmob = VMobject().set_points(
np.array([[0.0, 0.0, 0.0], [5.0, 3.0, 0.0], [-5.0, 3.0, 0.0], [1.0, 0.0, 0.0]])
)
Comment on lines +791 to +793

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To properly test that this works, I would like to see a wild cubic that has three dimensions (currently the z dimension is 0). That way we know that the formula correctly calculates a 3D bounding box.

assert vmob.width == pytest.approx(2.4377191199218955, abs=1e-4)
assert vmob.height == pytest.approx(2.25, abs=1e-4)


def test_arc_height_is_not_underestimated():
# A 180-degree arc with near-vertical handles: the raw control points
# span only ~1x the radius in height, while the rendered arc spans 2x.
arc = Arc(radius=2, angle=PI)
assert arc.height == pytest.approx(2.0)
assert arc.width == pytest.approx(4.0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Am I misunderstanding your point/comment here?

ThisArc consists of 8 subcurves, and the middle anchor point is already located at (0, 2, 0) so the width and height are already correctly computed.

In fact, a 180° arc should span only 1x its radius in height. Here's a render from the current main branch:

Image



def test_quadratic_width_height_use_curve_extrema():
# Quadratic counterpart: interior extrema must be included too.
vmob = VMobject().set_points(
np.array([[0.0, 0.0, 0.0], [2.0, 4.0, 0.0], [4.0, 0.0, 0.0]])
)
assert vmob.width == pytest.approx(4.0)
assert vmob.height == pytest.approx(4.0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is probably a good test, but it feels weird to test that width/height computation is correct on an invalid VMobject? Standard VMobjects always use cubic beziers so I don't think a three-point VMobject would be rendered. There might be some utility for this I'm unaware of, happy to be wrong.



def test_width_height_setters_round_trip_on_rotated_circle():
c = Circle(radius=3).rotate(30 * DEGREES)
c.width = 5.0
assert c.width == pytest.approx(5.0)
assert c.height == pytest.approx(5.0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would turn this into a general test that confirms that setting width/height/depth to val results in a mobject of size val in that dimension. Maybe parametrize the test to try out both single dimensions as well as combinations.

It might also be nice to explicitly construct a VMobject for this, just in case Circle changes the ways its points are drawn down the line (since we're not testing the implementation of Circle).



def test_width_height_of_single_point_vmobject():
vmob = VMobject().set_points(np.array([[3.0, 4.0, 0.0]]))
assert vmob.width == pytest.approx(0.0)
assert vmob.height == pytest.approx(0.0)
Comment on lines +827 to +830

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also needs depth. It's also unclear if this will ever be relevant since VMobject expects at least 4 points, but I guess it doesn't hurt?



def test_critical_points_keep_control_point_semantics():
# #3619: width/height are exact, but critical points (get_left,
# get_center, ...) keep their control-point semantics so that existing
# renderings, which rely on those values for centering, do not change.
# get_left/get_right are computed from the anchor points only (the
# "boundary points"), so handles outside the anchors must not move them.
vmob = VMobject().set_points(
np.array([[0.0, 0.0, 0.0], [5.0, 3.0, 0.0], [-5.0, 3.0, 0.0], [1.0, 0.0, 0.0]])
)
assert vmob.width == pytest.approx(2.4377191199218955, abs=1e-4)
assert vmob.get_left()[0] == pytest.approx(0.0)
assert vmob.get_right()[0] == pytest.approx(1.0)
Binary file not shown.
Binary file modified tests/test_graphical_units/control_data/img_and_svg/Heart.npz
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.