Fix VMobject dimensions and critical points using Bézier extrema - #4943
Fix VMobject dimensions and critical points using Bézier extrema#4943kyoai-zhao wants to merge 15 commits into
Conversation
- 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
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.
4af8809 to
d497260
Compare
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.
86a476e to
ceb944e
Compare
|
|
||
| Returns an array of shape ``(n_curves, 2)``. A single point yields a | ||
| degenerate curve whose extent in every dimension is that point. | ||
| """ |
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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.
| 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) |
There was a problem hiding this comment.
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_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) |
There was a problem hiding this comment.
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:
| 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) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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)). |
There was a problem hiding this comment.
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.
…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.
for more information, see https://pre-commit.ci
|
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; I replaced it with Depth is covered now as well, using a 3D cubic whose z handles span On 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 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. |
…cstrings per review
…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.
for more information, see https://pre-commit.ci
Motivation
VMobject.width/height/depthare 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).Change
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.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).VMobject.length_over_dim()(backs width/height/depth) so these reflect the physical extent of the rendered curves, recursively through submobjects (VGroup, Text, ...).VMobject.get_critical_point()to use the same exact curve box, soget_right()[0] - get_left()[0] == widthholds as an invariant (likewise height/depth). Only VMobjects whose curve extrema fall between anchors change behavior; polygonal/anchor-extrema shapes are unchanged.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
get_x()==get_center()[0],get_x(RIGHT)==get_right()[0],set_x(0)centers the rendered extent,align_to(..., RIGHT)aligns rendered right edgesscale(1e-14)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,VGroupwith 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