-
Notifications
You must be signed in to change notification settings - Fork 3.1k
Fix VMobject dimensions and critical points using Bézier extrema #4943
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 6 commits
17ddd26
d497260
ceb944e
3fd6c7e
56cce75
8098b63
1038de6
5497296
c376eb6
403fb98
071ac1b
7f6d920
09e3620
c648241
f8caf32
c60d3ea
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. | ||
| """ | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 8098b63: the unused |
||
| n_curves = len(pts) // nppcc | ||
| if nppcc == 3: | ||
| # Quadratic Bézier: P'(t) = 2*((p1-p0) + t*(p0-2p1+p2)). | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I assume this is in preparation for adding the same feature to |
||
| p0, p1, p2 = (pts[i::nppcc, dim] for i in range(nppcc)) | ||
|
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. | ||
|
|
||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I see no tests for |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,7 @@ | |
| import pytest | ||
|
|
||
| from manim import ( | ||
| Arc, | ||
| Circle, | ||
| CurvesAsSubmobjects, | ||
| Line, | ||
|
|
@@ -15,7 +16,7 @@ | |
| VGroup, | ||
| VMobject, | ||
| ) | ||
| from manim.constants import PI | ||
| from manim.constants import DEGREES, PI | ||
|
|
||
|
|
||
| def test_vmobject_add(): | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Am I misunderstanding your point/comment here? This In fact, a 180° arc should span only 1x its radius in height. Here's a render from the current main branch:
|
||
|
|
||
|
|
||
| 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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
|
|
||
|
|
||
| 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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would turn this into a general test that confirms that setting It might also be nice to explicitly construct a |
||
|
|
||
|
|
||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
|
|
||
|
|
||
| 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) | ||

Uh oh!
There was an error while loading. Please reload this page.