Skip to content

Move mobject positioning methods into separate class - #4994

Open
GniLudio wants to merge 18 commits into
ManimCommunity:mainfrom
GniLudio:rework-positioning
Open

Move mobject positioning methods into separate class#4994
GniLudio wants to merge 18 commits into
ManimCommunity:mainfrom
GniLudio:rework-positioning

Conversation

@GniLudio

@GniLudio GniLudio commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Overview: What does this pull request change?

Moves the Mobject positioning methods into their own class.

Motivation and Explanation: Why and how do your changes improve the library?

  • Better separation of concerns
  • Cleans up implementations
  • Easier extensibility and maintainability
  • Performance improvements

Changes

  • Creates new class just for handling positioning
  • Streamlines structure
  • Improves performance
  • Adds setter-methods for all parameters
  • Adds match-methods for all parameters
  • Adds docstrings for all methods
  • Adjusts positionanal/keyword arguments (breaking)
  • Deprecates unnecessary methods
  • All operations use the same points (behavioral change)
  • Always uses 3D (behavioral change)
  • Removes coor_mask parameter from move_to and next_to (breaking)
  • Removes submobject_to_align and index_of_submobject_to_align from next_to (breaking)
  • Adds pass-through **kwargs to all setters

Discussion Points

  • How should the user match the parameter of another object?
    • Allow passing another object to the setter - set_x(other)
      • Advantage: No cluttering, Disadvantage: Not self-explanatory / Can be confusing
    • Provide separate match-methods - match_x(other) (current choice)
      • Advantage: Most straightforward for the user, Disadvantage: Adds cluttering
    • The user passes the value to the setter - set_x(other.get_x())
      • Advantage: No cluttering, Disadvantage: Less convenience for users
  • How should the "get position in bounding box" be called?
    • Ideas: get_anchor, get_critical_point, get_position
    • Current choice: get_anchor
  • Should all attributes have a setter/getter method?
    • Current choice: Yes
  • Which methods should be deprecated?
  • Should setter methods have pass-through **kwargs?
    • Trades static safety for flexibility when overriding methods.
    • Isn't self-explanatory to users.
    • Leads to to worse intellisense/autocomplete.
      • Suppresses passing illegal parameters.
      • Suppresses warnings when having typos in keyword-names.
  • Should operations use get_all_points or get_points_defining_boundary?
    • Current choice: get_all_points
  • Should the properties be kept?
    • e.g. the width, height and depth properties for the (set|get)_(width|height|depth) methods
    • current choice: Yes

Structure

Here is the full structure
  • Points
    • points, get_all_points
    • set_points, match_points, reset_points, reverse_points, repeat
    • get_num_points, has_points, has_no_points
  • Applying Functions
    • get_family
    • apply_(to_family|points_function|function|complex_function)
  • Transformations
    • translate, scale, stretch, rotate, apply_matrix
  • Position
    • (get|set|match)_anchor
      • (get|set|match)_(center|top|bottom|right|left|zenith|nadir)
        • center
    • (get|set|match)_coordinate
      • (get|set|match)_(x|y|z)
    • align_on_border, align_to, next_to
      • shift_onto_screen
    • arrange, arrange_in_grid
    • apply_function_to_position, apply_function_to_submobject_positions
    • is_off_screen, get_center_of_mass, get_boundary_point
  • Size
    • (get|set|match)_dim_size
      • (get|set|match)_(width|height|depth)
      • (scale|stretch)_to_fit_dim
        • (scale|stretch)to_fit(width|height|depth)
  • Misc
    • flip, pose_at_angle, replace, surround
  • Aliases
    • shift -> translate
    • length_over_dim -> get_dim_size
    • get_critical_point, get_edge_center, get_corner -> get_position
    • move_to -> (set|match)_position
    • (get|set|match)_coord -> (get|set|match)_coordinate
    • to_corner, to_edge -> align_on_border
    • (width|height|depth) -> (get|set)_(width|height|depth)
  • Deprecated
    • apply_points_function_about_point -> apply_points_function
    • rescale_to_fit -> set_dim_size
    • stretch_about_point -> stretch
    • get_extremum_along_dim -> get_coordinate
    • rotate_about_origin -> rotate
    • reduce_across_dimension
    • get_points_defining_boundary
    • arrange_submobjects

Benchmark

I used randomized testing with 10-100k points (without submobjects) to benchmark and validate the changes.

Here are the benchmark results
10 100 1_000 10_000 100_000 Total Duration
align_on_border 1.6x 1.6x 1.5x 1.2x 1.7x 145 -> 89ms
align_to 2.0x 2.2x 2.4x 2.4x 3.7x 284 -> 80ms
center 1.7x 1.8x 1.6x 1.3x 1.5x 164 -> 110ms
depth (get) 0.62x 1.8x 7.2x 29.2x 18.6x 630 -> 34ms
depth (set) 1.3x 1.6x 3.0x 5.3x 5.1x 803 -> 162ms
flip 0.94x 1.0x 1.0x 1.00x 0.99x 218 -> 220ms
get_all_points 0.98x 0.56x 0.54x 0.55x 0.67x 0 -> 1ms
get_bottom 2.0x 1.9x 1.6x 1.3x 1.1x 58 -> 52ms
get_boundary_point 0.82x 0.92x 0.96x 1.00x 1.0x 30 -> 29ms
get_center 1.8x 1.9x 1.8x 1.3x 1.1x 69 -> 61ms
get_center_of_mass 2.4x 2.1x 2.2x 1.7x 1.1x 61 -> 49ms
get_coord 1.2x 1.3x 1.4x 1.2x 1.0x 25 -> 24ms
get_corner 1.9x 1.8x 1.9x 1.2x 1.0x 50 -> 45ms
get_critical_point 1.9x 1.8x 1.7x 1.3x 1.1x 48 -> 44ms
get_edge_center 1.9x 1.9x 1.6x 1.3x 1.1x 48 -> 42ms
get_extremum_along_dim 1.00x 1.0x 1.0x 0.92x 0.97x 26 -> 27ms
get_left 1.9x 1.7x 1.6x 1.2x 1.1x 64 -> 57ms
get_nadir 1.8x 1.8x 1.7x 1.2x 1.1x 63 -> 55ms
get_num_points 0.79x 0.80x 0.82x 0.84x 0.68x 0 -> 0ms
get_points_defining_boundary 0.60x 0.54x 0.52x 0.59x 0.70x 0 -> 1ms
get_right 1.9x 1.9x 1.9x 1.3x 1.1x 60 -> 51ms
get_top 2.0x 1.7x 1.8x 1.3x 1.1x 59 -> 53ms
get_x 1.4x 1.5x 1.4x 1.1x 1.0x 34 -> 33ms
get_y 1.4x 1.5x 1.3x 1.2x 1.00x 33 -> 32ms
get_z 1.1x 1.3x 1.4x 1.3x 1.1x 34 -> 31ms
get_zenith 2.0x 2.0x 1.7x 1.3x 1.1x 59 -> 52ms
has_no_points 1.2x 1.1x 1.1x 0.59x 1.0x 0 -> 0ms
has_points 0.86x 0.91x 0.82x 0.79x 0.78x 0 -> 0ms
height (get) 0.69x 1.6x 6.5x 26.6x 20.1x 686 -> 36ms
height (set) 1.5x 1.7x 2.8x 4.7x 5.1x 923 -> 190ms
is_off_screen 9.4x 9.6x 7.8x 6.1x 3.5x 187 -> 47ms
length_over_dim 0.65x 1.6x 7.5x 21.8x 19.5x 671 -> 36ms
match_coord 1.8x 1.8x 1.8x 1.3x 1.8x 129 -> 73ms
match_depth 1.3x 1.5x 3.2x 6.2x 6.1x 833 -> 142ms
match_dim_size 1.3x 1.6x 3.1x 5.9x 5.9x 881 -> 153ms
match_height 1.2x 1.6x 3.1x 5.8x 5.8x 858 -> 152ms
match_points 3.7x 3.7x 3.8x 3.9x 1.2x 14 -> 10ms
match_width 1.3x 1.5x 3.0x 6.2x 6.3x 836 -> 138ms
match_x 1.8x 1.9x 1.6x 1.3x 1.8x 133 -> 75ms
match_y 1.8x 1.8x 1.6x 1.3x 1.9x 138 -> 76ms
match_z 1.8x 1.8x 1.7x 1.3x 1.9x 137 -> 75ms
move_to 1.7x 1.9x 1.7x 1.3x 1.7x 157 -> 96ms
next_to 1.7x 1.7x 1.5x 1.2x 1.7x 152 -> 93ms
pose_at_angle 1.1x 1.0x 1.0x 0.98x 1.0x 222 -> 220ms
repeat 1.7x 2.1x 2.4x 3.7x 4.7x 1078 -> 234ms
replace 1.7x 1.8x 2.4x 3.9x 4.3x 1330 -> 327ms
rescale_to_fit 0.89x 1.3x 3.1x 6.6x 6.1x 750 -> 126ms
reset_points 0.37x 0.39x 0.43x 0.41x 1.0x 8 -> 8ms
reverse_points 2.6x 9.6x 21.9x 38.6x 25.8x 2540 -> 96ms
rotate 1.1x 0.97x 1.1x 1.0x 0.99x 222 -> 223ms
rotate_about_origin 0.91x 1.0x 1.0x 0.98x 1.00x 218 -> 219ms
scale 1.3x 1.1x 1.2x 1.1x 1.5x 167 -> 115ms
scale_to_fit_depth 1.0x 1.5x 3.6x 6.5x 6.3x 910 -> 148ms
scale_to_fit_height 1.1x 1.4x 2.9x 6.4x 6.2x 896 -> 149ms
scale_to_fit_width 0.98x 1.4x 3.4x 6.5x 6.4x 864 -> 138ms
set_coord 1.4x 1.3x 1.2x 1.2x 1.8x 125 -> 73ms
set_x 1.1x 1.3x 1.4x 1.2x 1.8x 134 -> 80ms
set_y 1.5x 1.4x 1.3x 1.2x 1.8x 120 -> 67ms
set_z 1.3x 1.3x 1.4x 1.1x 1.9x 118 -> 65ms
shift 1.3x 1.4x 1.5x 1.3x 2.2x 95 -> 46ms
shift_onto_screen 1.9x 2.0x 1.7x 1.3x 1.6x 560 -> 351ms
stretch 1.2x 1.2x 1.0x 0.97x 1.0x 100 -> 98ms
stretch_about_point 0.94x 1.0x 1.0x 1.0x 1.00x 95 -> 96ms
stretch_to_fit_depth 1.0x 1.4x 4.0x 6.9x 6.0x 706 -> 118ms
stretch_to_fit_height 0.98x 1.3x 3.9x 7.1x 6.2x 709 -> 116ms
stretch_to_fit_width 0.86x 1.5x 3.7x 7.2x 5.9x 707 -> 120ms
surround 1.8x 1.8x 2.2x 3.1x 3.4x 1402 -> 425ms
to_corner 1.5x 1.4x 1.4x 1.2x 1.6x 139 -> 87ms
to_edge 1.5x 1.6x 1.5x 1.2x 1.7x 142 -> 87ms
width (get) 0.73x 1.9x 8.8x 28.1x 19.0x 601 -> 32ms
width (set) 1.5x 1.6x 2.9x 5.3x 5.3x 782 -> 153ms

To run the tests yourself, switch to the main branch and execute the following commands.

uv add pandas[output-formatting]
curl -o positionable.py "https://raw.githubusercontent.com/GniLudio/manim/refs/heads/rework-positioning/manim/mobject/abstract/positionable.py"
curl -o test.py "https://github.com/user-attachments/files/31893333/test.py"
python test.py

test.py

Future

Here are a few things that aren't addressed with this PR, but should definitely be discussed and/or changed in the future.

  • implement methods that require submobject access
    • apply_function_to_submobject_positions, space_out_submobjects, arrange, arrange_in_grid
  • bounding box caching
  • calculating bounding box based on bezier curves
  • use Positionable for opengl
  • rename parameters
    • @deprecated_params only supports renaming keyword-only parameters

Links to added or changed documentation pages

Positionable.html, Mobject.html

Reviewer Checklist

  • The PR title is descriptive enough for the changelog, and the PR is labeled correctly
  • If applicable: newly added non-private functions and classes have a docstring including a short summary and a PARAMETERS section
  • If applicable: newly added functions and classes are tested

Comment thread manim/mobject/abstract/positionable.py Fixed
Comment thread manim/mobject/abstract/positionable.py Fixed
Comment thread manim/mobject/abstract/positionable.py Fixed
@GniLudio GniLudio added documentation Improvements or additions to documentation enhancement Additions and improvements in general refactor Refactor or redesign of existing code maintenance refactoring, typos, removing clutter/dead code, and other code quality improvements typehints For adding/discussing typehints performance labels Sep 6, 2026
@GniLudio

GniLudio commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

The docs include the new class now: Positionable.html

@nikolajmunk

Copy link
Copy Markdown
Contributor

I spotted a regression that the library doesn't currently test for:

On the main branch, rescale_to_fit calls self.scale with any kwargs it is given, meaning that when you call VMobject.rescale_to_fit(length, dim, scale_stroke=True), the scale_stroke=True keyword argument gets passed on to the VMobject.scale override and the stroke gets scaled along with the vmobject.

But in this PR, rescale_to_fit now delegates to Positionable.set_dim_size which doesn't accept any keyword arguments and so calling VMobject.rescale_to_fit(length, dim, scale_stroke=True) throws a TypeError. I'm guessing this could be fixed with a few strategic overrides to scale-modifying Positionable methods like the one that's already been written for VMobject.scale. Probably it would also be possible to permit **kwargs in a few places in Positionable to handle this - I'm not personally convinced that making all kwargs explicit is the ideal move here.

But more generally, I'm now concerned that this PR breaks other behavior elsewhere without anyone knowing because of insufficient regression tests. I would strongly suggest that before this PR is merged, proper tests should be created for all the methods touched by this PR (and probably some others which depend on them).

@GniLudio

GniLudio commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

I spotted a regression that the library doesn't currently test for:

On the main branch, rescale_to_fit calls self.scale with any kwargs it is given, meaning that when you call VMobject.rescale_to_fit(length, dim, scale_stroke=True), the scale_stroke=True keyword argument gets passed on to the VMobject.scale override and the stroke gets scaled along with the vmobject.

But in this PR, rescale_to_fit now delegates to Positionable.set_dim_size which doesn't accept any keyword arguments and so calling VMobject.rescale_to_fit(length, dim, scale_stroke=True) throws a TypeError. I'm guessing this could be fixed with a few strategic overrides to scale-modifying Positionable methods like the one that's already been written for VMobject.scale. Probably it would also be possible to permit **kwargs in a few places in Positionable to handle this - I'm not personally convinced that making all kwargs explicit is the ideal move here.

But more generally, I'm now concerned that this PR breaks other behavior elsewhere without anyone knowing because of insufficient regression tests. I would strongly suggest that before this PR is merged, proper tests should be created for all the methods touched by this PR (and probably some others which depend on them).

I think the known kwargs should definitely be delcared specificly. For unknown kwargs it's probably a good idea to have a **kwargs that just get's passed through. Should I do that for all methods? Or only for specific ones?

@nikolajmunk

Copy link
Copy Markdown
Contributor

Should I do that for all methods? Or only for specific ones?

I think there are many valid choices here. One way to start might be to look at which methods shouldn't receive a **kwargs parameter and then see what's left. Probably any get_(something) method doesn't need it. It also seems like apply_to_family doesn't need it, and therefore any method which is a "wrapper" for apply_to_family probably doesn't need it either? The same logic maybe applies for set_points.

@nikolajmunk

nikolajmunk commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Question: upon closer review of the new class, I've got some concerns that I'd love to hear your thoughts on.

Some of the major changes introduced by this PR are:

  1. As much as possible, all methods delegate to the same handful of "core methods".
  2. Method signatures are now as similar as possible; for example, many methods now accept both a number/vector and a Positionable for their first argument.
  3. Basically anything that had a getter now also has a setter (for example, get_top now has a corresponding set_top).

Overall, I think this is a good change - everything is much more generalized now and it's clear that many of the methods are variations on the same operation. However, IMO this increase in abstraction leads to some method semantics that don't make that much sense. A few examples:

  • Here's a common style of signature:
    def set_width(
          self,
          width: float | Positionable,
          stretch: bool = False,
          *,
          about_point: Point3DLike | None = None,
          about_edge: Vector3DLike | None = None,
      ) -> Self:
    It feels weird to me that the value argument to a setter method can be of a type that is not (immediately) convertible to the type of the attribute being changed. The signature suggests to me that if I call mob.set_width(other_mob), then the width of mob will be set to... another mobject? Some float value that this mobject represents? I can maybe make a guess that it will fetch the width of other_mob and use that, but even looking at the implementation, it seems that this method is calling self.set_dim_size with size=width so it's not immediately clear.
  • Some of the new setters don't have entirely clear semantics in my view. Take the set_top method. It's clear that once I call this method, my mobject will have its top at the target position. But it's less clear to me - especially putting myself in the shoes of a newer user who is unlikely to inspect the implementation - how that is going to be achieved. Will my object be shifted? Will it maybe be stretched so the top point gets to the target? If I pass another Positionable as the top argument, will anything happen to that object? IMO this is much less intuitive than the corresponding get_top method.
  • The new get_position and set_position methods are effectively renamed versions of get_critical_point and move_to. But in my opinion, the renamed versions (and any renamed params) are much less clear. I think mob.get_critical_point(direction=LEFT) is very clear, but it's not immediately clear what it means to get an object's position in a direction. Similarly, mob.move_to(pos, aligned_edge=LEFT) more or less explains to me what will happen, but I don't think mob.set_position(direction=LEFT) has the same clarity.

I don't think any of these are huge blockers, but it's always easier to add more functionality than to remove it later once people have started using it, so I think it's at least worth mentioning.

I have suggestions for all of these, but before I do that, I just wanted to hear your thoughts on these things. Did you have any particular design goal or considerations in mind when making these changes? I'm entirely willing to be wrong on this :)

@GniLudio

GniLudio commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

It feels weird to me that the value argument to a setter method can be of a type that is not (immediately) convertible to the type of the attribute being changed. The signature suggests to me that if I call mob.set_width(other_mob), then the width of mob will be set to... another mobject? Some float value that this mobject represents? I can maybe make a guess that it will fetch the width of other_mob and use that, but even looking at the implementation, it seems that this method is calling self.set_dim_size with size=width so it's not immediately clear.

That was already the case with move_to before.

When passing another object to the value of a setter method, it always uses the property of that object as the value. So set_*(other) is equivalent to set_*(other.get_*()). Do you think that's something that should be mentioned in the descriptions of setter methods?

Alternatively, having separate match-methods for passing another object could also be an option. (I liked the idea of having just one setter method per attribute)

Some of the new setters don't have entirely clear semantics in my view. Take the set_top method. It's clear that once I call this method, my mobject will have its top at the target position. But it's less clear to me - especially putting myself in the shoes of a newer user who is unlikely to inspect the implementation - how that is going to be achieved. Will my object be shifted? Will it maybe be stretched so the top point gets to the target? If I pass another Positionable as the top argument, will anything happen to that object? IMO this is much less intuitive than the corresponding get_top method.

I think the base assumption should be that the shape of an object isn't modified unless specified. Otherwise, even move_to would need to explain that all points are shifted equally under the hood.

The new get_position and set_position methods are effectively renamed versions of get_critical_point and move_to. But in my opinion, the renamed versions (and any renamed params) are much less clear. I think mob.get_critical_point(direction=LEFT) is very clear, but it's not immediately clear what it means to get an object's position in a direction. Similarly, mob.move_to(pos, aligned_edge=LEFT) more or less explains to me what will happen, but I don't think mob.set_position(direction=LEFT) has the same clarity.

I renamed the get_critical_point and move_to methods due to the following reasons:

  • it should be obvious which setter and getter are related to each other
    • it isn't obvious that get_critical_point and move_to are related to each other
  • a position is a lot more common characteristic than a critical point
  • I wanted to use naming that is common for 3D software/theory/etc.

I know that direction isn't a perfect parameter name and already thought about changing it to at_edge (or something else). What do you think of that?

@GniLudio

GniLudio commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Additionally, the old method names (e.g. move_to and get_critical_point) aren't removed and can still be used

Comment thread manim/mobject/abstract/positionable.py Fixed
@nikolajmunk

Copy link
Copy Markdown
Contributor

@GniLudio:

That was already the case with move_to before.

This I don't agree with - mob.move_to(other_mob) has an IMO extremely clear mapping to a plain-language sentence: "move mob to other_mob's position". It's explicitly not a setter, even if it mimics one under the hood.

When passing another object to the value of a setter method, it always uses the property of that object as the value.

I think that's a wonderful rule in theory, but in my view it clashes too heavily with the existing conventions for getters/setters in Python and other languages. Using set_ is dangerous language IMO 😉 See below for an alternate idea.

I think the base assumption should be that the shape of an object isn't modified unless specified. Otherwise, even move_to would need to explain that all points are shifted equally under the hood.

Disagree for the same reason as my first point! "Move" is basically always analogous to "shift" or "translate".

it should be obvious which setter and getter are related to each other; it isn't obvious that get_critical_point and move_to are related to each other

Generally agree! See below for my naming ideas.

a position is a lot more common characteristic than a critical point; I wanted to use naming that is common for 3D software/theory/etc.

100000% agree, I think "critical point" is a horrible term. But I would also challenge the notion of a "position" here; given that a mobject currently doesn't have a single origin point that its points are defined relative to, I find it difficult to say for sure that a mobject has a single position. You could maybe make the argument that its position is the center of its bounding box?
If/when we ever end up implementing the 3D software-esque scene graph + transform system we've discussed, I think it would be very natural to introduce "position" as a canonical term, but currently it would be hard for me to provide a single clear definition of a mobject's position.

thought about changing it to at_edge (or something else).

Definitely open to this (though I think "edge" is also tricky language when more than half of the critical points aren't really edges, haha)! In the suggestions below I float the idea of the term "anchor", maybe that could work?

Suggestions

If I was king of Manim, here's what I would do:

  1. Drop the principle that a getter must have a corresponding setter; some values simply work better as "read-only". I would remove setters for at least the following: top, bottom, left, right, zenith, nadir, center. These can all be replicated using next_to or move_to.

  2. As a general rule, use only the actual value type in setter methods instead of the float/vector | Positionable type union. We can instead use methods with more descriptive names such as the existing move_to or (scale/stretch)_to_fit_(dim), for which both a numeric value and another Positionable would make sense as input IMO.

    I assume some of the reasoning behind this change was to get rid of the match_(something) methods, and I'm fine leaving them out, but in the vast majority of cases I think it's much clearer for the user to have to write mob.set_width(other_mob.width) instead of mob.set_width(other_mob) or mob.set_y(other_mob.get_y(UP), aligned_edge=UP) instead of mob.set_y(mob, UP).

  3. Instead of get_position and set_position for the core position getter/setter, use names which more accurately describe what happens.

    For get_position, like I said I really don't like get_critical_point, so I'm fine working under the assumption that this will change down the line. We could of course use get_bounding_box_point like in OpenGLMobject. I've also been considering the idea of adopting the canonical term "anchor point" as a name for the nine points on the bounding box (UP, DOWN, LEFT, RIGHT, UL, UR, DL, DR, ORIGIN and I guess there are six more if we consider the 3D bounding box...). In that case, we could call the method get_anchor_point(self, direction: Vector3D).

    For set_position, we of course already have move_to and I think that would be a fine name for the core setter, but adopting the above nomenclature, we could also use move_anchor_point_to or (place/put)_anchor_point_on. This would also align with the current very loose convention of having methods like next_to and align_on which end in a preposition to indicate a relative operation. IMO these names all work with the combined float/vector | Positionable argument type. This could also work as a global replacement for the set_top, set_right, etc. methods.

I look forward to hearing your thoughts on all this! It might also be relevant to hear the opinion of some additional core developers since this is a reasonably big design choice.

Comment thread manim/mobject/abstract/positionable.py
@GniLudio

GniLudio commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

That was already the case with move_to before.

This I don't agree with - mob.move_to(other_mob) has an IMO extremely clear mapping to a plain-language sentence: "move mob to other_mob's position". It's explicitly not a setter, even if it mimics one under the hood.

I meant that move_to already was a "setter" method which allowed passing both a value and another object. I agree that "move to object" is easier to comprehend than "sets the top position to the top position of another object", but I wouldn't say it hard either...

When passing another object to the value of a setter method, it always uses the property of that object as the value.

I think that's a wonderful rule in theory, but in my view it clashes too heavily with the existing conventions for getters/setters in Python and other languages. Using set_ is dangerous language IMO 😉 See below for an alternate idea.

Agree.

I think the base assumption should be that the shape of an object isn't modified unless specified. Otherwise, even move_to would need to explain that all points are shifted equally under the hood.

Disagree for the same reason as my first point! "Move" is basically always analogous to "shift" or "translate".

I just wanted to make the point that it isn't hard to understand that other operations like set_top(some_position) are just moving the object like move_to. So set_top would also be analogous to move_to.

  1. Drop the principle that a getter must have a corresponding setter; some values simply work better as "read-only". I would remove setters for at least the following: top, bottom, left, right, zenith, nadir, center. These can all be replicated using next_to or move_to.

I wouldn't be against it. The same argument could be used for removing the getter methods as well: They can all be replicated using get_position/get_critical_point.

  1. As a general rule, use only the actual value type in setter methods instead of the float/vector | Positionable type union. We can instead use methods with more descriptive names such as the existing move_to or (scale/stretch)to_fit(dim), for which both a numeric value and another Positionable would make sense as input IMO. I assume some of the reasoning behind this change was to get rid of the match_(something) methods, and I'm fine leaving them out, but in the vast majority of cases I think it's much clearer for the user to have to write mob.set_width(other_mob.width) instead of mob.set_width(other_mob) or mob.set_y(other_mob.get_y(UP), aligned_edge=UP) instead of mob.set_y(mob, UP).

Your assumption is correct. I didn't like having multiple setter methods for the same attribute. That just leads to more methods (and I would say there are already quite a lot to remember).

I would favor dropping them, but that would be quite a breaking change.

  1. For get_position, like I said I really don't like get_critical_point, so I'm fine working under the assumption that this will change down the line. We could of course use get_bounding_box_point like in OpenGLMobject. I've also been considering the idea of adopting the canonical term "anchor point" as a name for the nine points on the bounding box (UP, DOWN, LEFT, RIGHT, UL, UR, DL, DR, ORIGIN and I guess there are six more if we consider the 3D bounding box...). In that case, we could call the method get_anchor_point(self, direction: Vector3D).

I can understand that, but just as a note: Currently, the get_critical_point/get_position is restricted to anchor points, but I don't see a point to keep that arbitrary restriction. I kept the behavior the same in this PR, but I see why it should be kept in the long term. So get_anchor_point shouldn't be a perfect name either.

@nikolajmunk

nikolajmunk commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Sounds like we're roughly aligned! I've only got a few comments.

The same argument could be used for removing the getter methods as well: They can all be replicated using get_position/get_critical_point.

I actually think it's completely fine to keep get_(left/right/top/etc). In my view, it should be possible to read a value with very little consideration or difficulty and somewhat harder to write to a value without thinking about it first. I like convenience getters :)

I would favor dropping them, but that would be quite a breaking change.

Here I'm assuming you're referring to the various match_... methods? I'm okay either way, but it might be worth taking a separate look at this class of methods. I see match methods as completely reasonable helper wrappers around a matched getter/setter pair, but they do seem slightly arbitrary in their current form. I could easily see (V)Mobject implementing a general match_attribute(mob: Mobject, attr: str) method which might save some space.

I can understand that, but just as a note: Currently, the get_critical_point/get_position is restricted to anchor points, but I don't see a point to keep that arbitrary restriction.

Yeah, I totally see how it could for example be useful to express any point inside the bounding box of a mobject with a tuple (x, y, z) where each value is in the range [0,1] so that

point = interpolate(minimum_corner, maximum_corner, (x, y, z))

or similar. This would effectively be some Positionable-specific version of coord2point, now that I think about it.
But "anchor point" is a common enough idea in games and graphics software that it isn't entirely arbitrary (we can also consider that the set of anchors have the unique property that they are represented by the set $$\{-1,0,1\}^3$$), and IMO it might just be worth it to keep as the main way to think about where to "grab" a mobject, even if we introduce other "point in/on the bbox" methods. But I'm happy to listen to alternatives!!

@GniLudio

GniLudio commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Added the discussion points to the PR description.

@GniLudio

GniLudio commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

I just realized that returning references to ORIGIN and self.points can lead to unexpected behavior when the user modifies the returned reference(s) in place.

Here an example:

def construct(self) -> None:
        circle = Circle()
        all_points = circle.get_all_points()
        all_points += 3 * RIGHT
        self.add(circle)

All operations should thus return refresh copies. Also it could make sense to make constants read-only to avoid unexpected behavior like this.

class TestScene(Scene):
    def construct(self):
        point = ORIGIN
        point += RIGHT + UP

        circle = Circle().move_to(ORIGIN)
        self.add(circle)

@nikolajmunk

Copy link
Copy Markdown
Contributor

I'm fine with always returning copies, but I don't necessarily think it's wrong for Manim to not do so. Sometimes a live view of the returned points is even what you want! This example:

def construct(self) -> None:
    circle = Circle()
    all_points = circle.get_all_points()
    all_points += 3 * RIGHT
    self.add(circle)

actually seems to be a perfectly legitimate use case for the user (who is after all perfectly capable of calling all_points.copy() themselves). It's obviously undefined behavior on Manim's part, but I don't think the library has a responsibility to prevent the user from doing this. As a user, I would probably even expect that if I call e.g. mobject.get_points(), I receive the actual points array of mobject and am able to operate on it myself.

In many cases, it's also possibly an unnecessary performance hit. Take for example Mobject.get_center_of_mass:

def get_center_of_mass(self) -> Point3D:
        return np.apply_along_axis(np.mean, 0, self.get_all_points())

We're just iterating over get_all_points, so having to first copy the list is just wasted time in this case, and I have a feeling the speed decrease might easily add up if we're calling get_center_of_mass inside an updater.

I'm completely on board with your new change of returning ORIGIN.copy() instead of ORIGIN - really good idea - but I'm not sure that it would be a good idea to make Manim's constants read-only (though it might be possible to make them immutable so you can only do ORIGIN = UP and not ORIGIN += UP...). As far as I'm concerned, the user should absolutely be allowed to change the values of Manim's constants at runtime.

@GniLudio

GniLudio commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

I'm fine with always returning copies, but I don't necessarily think it's wrong for Manim to not do so. Sometimes a live view of the returned points is even what you want! This example:

def construct(self) -> None:
    circle = Circle()
    all_points = circle.get_all_points()
    all_points += 3 * RIGHT
    self.add(circle)

actually seems to be a perfectly legitimate use case for the user (who is after all perfectly capable of calling all_points.copy() themselves). It's obviously undefined behavior on Manim's part, but I don't think the library has a responsibility to prevent the user from doing this. As a user, I would probably even expect that if I call e.g. mobject.get_points(), I receive the actual points array of mobject and am able to operate on it myself.

In many cases, it's also possibly an unnecessary performance hit. Take for example Mobject.get_center_of_mass:

def get_center_of_mass(self) -> Point3D:
        return np.apply_along_axis(np.mean, 0, self.get_all_points())

We're just iterating over get_all_points, so having to first copy the list is just wasted time in this case, and I have a feeling the speed decrease might easily add up if we're calling get_center_of_mass inside an updater.

I'm completely on board with your new change of returning ORIGIN.copy() instead of ORIGIN - really good idea - but I'm not sure that it would be a good idea to make Manim's constants read-only (though it might be possible to make them immutable so you can only do ORIGIN = UP and not ORIGIN += UP...). As far as I'm concerned, the user should absolutely be allowed to change the values of Manim's constants at runtime.

You are right. It's quite a performance difference for mobject with only 1 member which has points. And I noticed that the old behavior also returned a reference in that case. So I reverted it for get_all_points.

@GniLudio

GniLudio commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

I'm completely on board with your new change of returning ORIGIN.copy() instead of ORIGIN - really good idea - but I'm not sure that it would be a good idea to make Manim's constants read-only (though it might be possible to make them immutable so you can only do ORIGIN = UP and not ORIGIN += UP...). As far as I'm concerned, the user should absolutely be allowed to change the values of Manim's constants at runtime.

There are two variants of what read-only could mean.

Making the variable final

ORIGIN: Final = np.array([0.0, 0.0, 0.0])
ORIGIN = np.array([1, 2, 3])

The second assignment would result in just a warnings (e.g. from mypy).

Making the numpy array non-writable

ORIGIN: Vector3D = np.array((0.0, 0.0, 0.0))
ORIGIN.flags.writable = False
ORIGIN += RIGHT

The third line would result in an error during runtime.

@nikolajmunk

Copy link
Copy Markdown
Contributor

I'm not particularly in love with either option; in my mind, the risk (to the extent there is any) is not actual overwriting of the constant, but rather unintentional in-place modification a la your example in the previous comment. I suppose the Final type might work for internal use so we catch any accidental modification? That might be something.

For what it's worth, when the user writes from manim import ORIGIN or from manim import * at the top of a file, then the value of ORIGIN is copied into a new ORIGIN variable in the global namespace. So if the user writes ORIGIN = "foo", it only affects the new copy and not the one which Manim uses internally. And if the user happens to write manim.utils.constants.ORIGIN = "foo", I would argue that they are clearly doing it very intentionally and we should let them shoot themselves in the foot 🙂

Comment thread manim/mobject/abstract/positionable.py Outdated
@GniLudio

GniLudio commented Sep 12, 2026

Copy link
Copy Markdown
Contributor Author

I renamed (get|set)_position to (get|set)_anchor. Additionally, I added match-methods for all parameters (the setter-methods don't allow passing another object any more).

Comment thread manim/mobject/abstract/positionable.py Outdated

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.

General notes on testing (I realize much of this is currently left out on purpose, so see it more as a wishlist!):

  • I think a helper method to build a Positionable would be very useful. Something simple like:
    def positionable_with_points(points):
        return Positionable().set_points(points)
  • For any method which takes a direction or about_edge or similar as parameter (meaning a vector representing an anchor point), the test should be parametrized to try at least all the constant-valued directions such as UP, DR, OUT, ORIGIN, etc. Might be useful to save these as a direction_constants list at the top of the file. It could also be built as list(itertools.product([-1, 0, 1], repeat=3)).
    • A similar thing goes for methods with a dim parameter; we should take care to test all three when it makes sense.
  • Methods with optional parameters should be tested with and without arguments for those parameters when it makes sense.
  • Some methods have mutually exclusive parameters. For example, apply_points_function will choose its pivot point in this order:
    if about_point is not None:
        choose about_point
    if about_edge is not None:
        choose self.get_critical_point(about_edge)
    choose self.get_critical_point(ORIGIN)
    
    This means that a) tests should take care to actually test all those branches and b) there should probably be tests to confirm that it is possible to pass mutually exclusive arguments but that behavior stays as expected.
  • Most of the methods which modify a Positionable could use a test for the case where the operation does nothing (shifting by 0, calling apply_function with lambda x: x, etc.) Similarly, we could probably test for operations where we pass in empty lists of points and so on.
  • Standard boundary-case stuff. Can we pass a buff=0? Does a negative buff behave the way we expect?

I'll add more if I think of them :)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

  • Getter methods should always be tested with 0 points.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation enhancement Additions and improvements in general maintenance refactoring, typos, removing clutter/dead code, and other code quality improvements performance refactor Refactor or redesign of existing code typehints For adding/discussing typehints

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants