Skip to content

Fix VMobject dimensions and critical points using Bézier extrema - #4943

Open
kyoai-zhao wants to merge 15 commits into
ManimCommunity:mainfrom
kyoai-zhao:fix/vmobject-bezier-width-height
Open

Fix VMobject dimensions and critical points using Bézier extrema#4943
kyoai-zhao wants to merge 15 commits into
ManimCommunity:mainfrom
kyoai-zhao:fix/vmobject-bezier-width-height

Conversation

@kyoai-zhao

@kyoai-zhao kyoai-zhao commented Aug 18, 2026

Copy link
Copy Markdown

Motivation

VMobject.width/height/depth are computed from the raw control points of the Bézier curves. Control points (handles) generally do not lie on the rendered curve, so dimensions can be wrong even for simple shapes:

  • Circle(radius=3).rotate(30 * DEGREES) reports width == 6.2074 instead of 6.0 (the diameter).
  • For a 70% arc, the anchor-only x-span is 3.562774, the exact rendered x-span is 3.618034, and the raw control-point x-span is 3.648879; a semicircle's extrema land on subdivision anchors, so it does not distinguish these semantics.

Change

  • Add VMobject.get_bezier_bounding_box(): exact axis-aligned bounding box of the cubic Bézier curves, found analytically (derivative roots for interior extrema), with a control-point fallback for unsupported or incomplete point layouts. Root tolerances are relative to the coefficient scale, so uniformly scaling a curve does not change which roots count as interior extrema.
  • Add VMobject._get_bezier_family_bounding_box(): family-level box computed in a single kernel over the merged points of all submobjects (with a per-member fallback for non-cubic subclasses).
  • Override VMobject.length_over_dim() (backs width/height/depth) so these reflect the physical extent of the rendered curves, recursively through submobjects (VGroup, Text, ...).
  • Override VMobject.get_critical_point() to use the same exact curve box, so get_right()[0] - get_left()[0] == width holds as an invariant (likewise height/depth). Only VMobjects whose curve extrema fall between anchors change behavior; polygonal/anchor-extrema shapes are unchanged.
  • Override VMobject.get_extremum_along_dim() so the planning API (get_x, get_coord, set_x, match_x, align_to) is consistent with the exact critical points: explicit-point calls keep the base-class semantics.

Control frames updated

SVGMobject is scaled to its target height using the exact extent, and the unified critical-point semantics shifts a few scenes' layout slightly. Control frames for QuadraticPath, SmoothCurves, HalfEllipse, Heart, WeightSVG, three_points_Angle were regenerated with pytest --set_test.

Tests

  • rotated circle (interior extrema)
  • a "wild" cubic whose extrema are far outside the anchor range
  • a 70% arc distinguishing control-point / anchor-only / exact x-spans (3.648879 / 3.562774 / 3.618034)
  • depth via a 3D cubic: z handles span [-5, 5], curve reaches [-0.985, 1.453]
  • parametrized width/height/depth setter round-trips on a hand-built VMobject
  • critical points agree with width/height/depth
  • coord planning consistency: get_x() == get_center()[0], get_x(RIGHT) == get_right()[0], set_x(0) centers the rendered extent, align_to(..., RIGHT) aligns rendered right edges
  • scale invariance: width is exact from scale 1e-16 to 1e9, and the setter round-trip holds after scale(1e-14)
  • family aggregation: a VGroup whose members supply the extrema in different directions reports the exact union of the per-member bounds

Verified: analytic extrema agree with dense sampling across Circle, Arc, Line, Triangle, Star, Dot, Polygon, cubic curves, ParametricFunction, Text, VGroup. tests/module (523 passed) and graphical unit suites show no regression vs. main beyond pre-existing typst/ffmpeg environment failures. Microbenchmarks vs. main: Text(...).width +0.3 ms (+17%), Text(...).get_center() is ~30% faster, VGroup with 20 submobjects ~0.15 ms.

Note: the OpenGL backend (OpenGLMobject bounding boxes) still uses raw control points; separate class hierarchy, left for a follow-up.

Closes #3619

- Add VMobject.get_bezier_bounding_box, computing the exact axis-aligned
  bounding box of the quadratic/cubic bezier curves by finding interior
  extrema analytically (roots of the derivative).
- Override reduce_across_dimension so width/height reflect the rendered
  curve extent, not the control-point extent.

Closes ManimCommunity#3619
Comment thread manim/mobject/types/vectorized_mobject.py Fixed
Comment thread manim/mobject/types/vectorized_mobject.py Fixed
Replace the reduce_across_dimension override (which changed critical-point
semantics and shifted rendering) with a length_over_dim override, so only
width/height/depth become exact while centering and edges keep their
control-point behavior.
@kyoai-zhao
kyoai-zhao force-pushed the fix/vmobject-bezier-width-height branch from 4af8809 to d497260 Compare August 18, 2026 08:08
Width/height (and depth) now reflect the exact extent of the rendered
Bézier curves, including interior extrema, instead of the bounding box of
their control points. Critical points (get_left, get_center, ...) keep
their control-point semantics, so only dimensional reads change.

SVGMobject imports are scaled to their target height using the exact
curve extent; the control frames of the five affected SVG tests are
updated accordingly.
@kyoai-zhao
kyoai-zhao force-pushed the fix/vmobject-bezier-width-height branch from 86a476e to ceb944e Compare August 18, 2026 08:34

Returns an array of shape ``(n_curves, 2)``. A single point yields a
degenerate curve whose extent in every dimension is that point.
"""

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).

@nikolajmunk nikolajmunk left a comment

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 a welcome change! #3625 never really got off the ground so it's nice to see it picked up again.

I haven't studied the math in detail (I assume it's roughly the same as this with some extra numpy steps), but I'll trust that it's correct! Some of your tests seem unnecessary or straightforwardly wrong, in particular the one for your "180° arc underestimation case". I also note that you aren't checking correctness for depth.

I think it's worth having a conversation about the intended behavior of get_critical_point. IMO the purpose of get_critical_point is precisely to return a point on the actual AABB of the mobject rather than use the control points, even if this is a breaking change for some users (though I can't imagine it's very many!). It seems weird to deliberately introduce a difference between, say mob.width and mob.get_right()[0] - mob.get_left()[0] for the sake of backwards compatibility.

Am I correct in suspecting that this code and PR are heavily LLM-generated and that you perhaps aren't that familiar with the Manim library? That's not disqualifying at all, but I think this change would benefit a lot from having a human take a second pass over this code.

Comment on lines +776 to +780
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).

Comment on lines +759 to +764
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

Comment on lines +767 to +773
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.

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?

"""
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.

KYOAI Zhao and others added 4 commits August 19, 2026 17:22
…animCommunity#3619)

get_left/get_right/get_top/get_bottom/get_center (and get_critical_point
generally) now use the exact bounding box of the rendered Bezier curves
instead of the raw control points, so they always agree with width,
height and depth.  Previously the control-point semantics could inflate
the box (the curve lies inside its control hull by de Casteljau's
convex-hull property), and the 180-degree arc test could not distinguish
the semantics because its subdivided anchors happen to reach the
extrema.  Replace that test with a 70% arc whose interior extrema are
not anchored, and add a depth (z) coverage test.

As a consequence this is a behavior change for users who align mobjects
by their critical points: alignment now tracks the rendered shape.
…tic case

Address reviewer feedback (nikolajmunk): setters are now verified against
an explicitly constructed VMobject (not Circle) and parametrized over each
dimension singly; the quadratic case is kept and reframed as a direct test
of the quadratic curve-data path (SVG Q/T commands are quadratic).  A note
documents why combined setter sequences are not meaningful round-trips
(uniform scaling rescales dimensions set earlier).
…l frames

Reviewer and author check: default VMobjects are cubic-only - the SVG
path code degree-elevates Q/T segments to cubics (add_quad -> add_cubic),
so a three-point VMobject never occurs on the default path and the
quadratic test only exercised the degenerate fallback.  Remove the
nppcc=3 branch of the extrema computation and its test.

Comment on the 70% arc test now distinguishes all three boxes: control
points span 3.648879 in x, the exact curve 3.618034, anchors only
3.562774.  Regenerate graphical control frames rendered under the
unified critical-point semantics.
@kyoai-zhao

Copy link
Copy Markdown
Author

Thanks for the review — this was really helpful.

On the arc test, you're right, and my comment there was wrong too. There were actually two different old bounds involved: width/height used all control points, whose AABB can only overestimate the Bézier extent because of the convex-hull property; get_critical_point, on the other hand, used anchors only and can underestimate it. The 180° arc was a bad test because its extrema happen to land exactly on subdivision anchors, so it doesn't distinguish the behaviors.

I replaced it with Arc(radius=2, start_angle=PI/5, angle=TAU*0.7). Its x-span is ~3.648879 from the raw control points, ~3.618034 for the actual curve, and ~3.562774 from anchors only, so the test now distinguishes all three.

Depth is covered now as well, using a 3D cubic whose z handles span [-5, 5] while the curve itself only reaches approximately [-0.985, 1.453].

On get_critical_point, I ended up agreeing with you. Width/height/depth and critical points now use the same exact curve AABB, so get_right()[0] - get_left()[0] == width holds as an invariant. This changes behavior for VMobjects whose extrema occur between anchors; polygonal/other anchor-extrema shapes are unchanged. I rewrote the tests around that.

The setter tests are now parametrized over width/height/depth on a hand-built VMobject.

I also dropped the quadratic test. I realized that the default Cairo SVG path code degree-elevates Q/T segments to cubics anyway, so the previous three-point VMobject() test wasn't actually exercising the quadratic extrema path.

And yes — much of the first draft of this PR was written with LLM assistance. Since your review I went through the code myself, re-derived the extrema calculations, checked the root/degenerate branches, and ran the full suite: tests/module 523 passed; the remaining 32 failures are the pre-existing Typst environment failures; graphical unit and scene-rendering suites pass apart from the two pre-existing ffmpeg vp9 codec environment failures. I'm happy to keep iterating on anything that still looks off.

@kyoai-zhao kyoai-zhao changed the title Fix width/height of VMobjects to account for bezier interior extrema Fix VMobject dimensions and critical points using Bézier extrema Aug 20, 2026
KYOAI Zhao and others added 4 commits August 20, 2026 13:38
…act Bézier bounds; scale-relative tolerance; fold family extrema into one kernel

- Override VMobject.get_extremum_along_dim so get_x/get_coord/set_x/
  match_x/align_to use the same exact curve bounds as get_critical_point
  and the width/height/depth setters; explicit-point calls keep the base
  class semantics. set_x(0) and align_to now move to the rendered extent.
- Add _get_bezier_family_bounding_box helper (single kernel over merged
  family points; per-member fallback for non-cubic subclasses) and use it
  from length_over_dim and get_critical_point, removing duplicated loops.
- Make extrema tolerances relative to the coefficient scale so that
  uniformly scaling a curve does not change root classification
  (e.g. width now exact at scale 1e-14, setter round-trips hold).
- Tests: coord planning consistency (get_x/set_x/align_to),
  scale invariance across 1e-16..1e9.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

VMobject inaccurately attributes width/height attrs to its bézier control points

3 participants