mplot3d: make Axes3D.annotate accept (x, y, z) and reproject on draw#31267
mplot3d: make Axes3D.annotate accept (x, y, z) and reproject on draw#31267sanrishi wants to merge 26 commits intomatplotlib:mainfrom
Conversation
scottshambaugh
left a comment
There was a problem hiding this comment.
I tried this out locally and it works well! A number of comments here, but I agree on the overall direction.
Needed docs updates:
- A What's New entry
- A new gallery example for annotations in 3D (somewhat like https://matplotlib.org/devdocs/gallery/mplot3d/text3d.html, though I think this example is messy)
| assert clip_box is not None | ||
| assert np.all(clip_box.extents == ax.bbox.extents) | ||
|
|
||
|
|
There was a problem hiding this comment.
We will need at least:
- Image comparison tests that compare the 2D xytext cases here (both 2D and 3D point positions) with a direct call of
Axes.annotate. This ensures equivalency with the new method. - Image comparison tests for axlim_clip cases.
- A new baseline image test with different cases. One of the axes should be log scale.
- Take a look at the tests we have for annotate in the 2D case to see what else might be needed
There was a problem hiding this comment.
Thanks for clarification!
Added test coverage!
There was a problem hiding this comment.
Could you update the view angle of the baseline images so that annotations are not offscreen or on top of each other?
There was a problem hiding this comment.
Oh yeahI i was also looking that and angle is not good
DONE!
There was a problem hiding this comment.
Some of the annotations don't look like they're behaving correctly. Make sure you're using the images as a diagnostic tool
There was a problem hiding this comment.
Yea i noticed that camera plane inversion is happening in images generated
I will respond you after fixing it!
| arrowprops=arrowprops, annotation_clip=annotation_clip, **kwargs) | ||
|
|
||
| if xyz is not None: | ||
| art3d.annotation_2d_to_3d(a, xyz, xyztext=xyztext, axlim_clip=axlim_clip) |
There was a problem hiding this comment.
When axlim_clip = True, I see the following behavior:
- xyz coordinate is clipped, the text and arrow disappear (I think this is correct)
- The text coordinate is clipped, matplotlib crashes (needs to be fixed)
lib/mpl_toolkits/mplot3d/axes3d.py
Outdated
| text, xy, xytext=xytext, xycoords=xycoords, textcoords=textcoords, | ||
| arrowprops=arrowprops, annotation_clip=annotation_clip, **kwargs) | ||
|
|
||
| if xyz is not None: |
There was a problem hiding this comment.
There should also be a path that allows xyztext is not None and xyz is None (2D data location, 3D text location).
There was a problem hiding this comment.
Excellent point, @scottshambaugh. I've updated the logic to upgrade to Annotation3D if xyz OR xyztext is a 3-tuple, and added a safeguard in art3d.py so it doesn't try to re-project a None anchor. Also added a quick unit test to ensure the text continues to follow camera rotations in this hybrid state!
| @@ -0,0 +1,11 @@ | |||
| 3D annotations follow the view | |||
There was a problem hiding this comment.
I think something like "3D annotations can anchor to 3D coordinates" is clearer
| :meth:`~mpl_toolkits.mplot3d.axes3d.Axes3D.annotate` supports anchoring annotations in | ||
| 3D data coordinates by passing ``(x, y, z)`` for *xy* (with ``xycoords='data'``, | ||
| the default). The annotation is projected during draws, so it stays attached | ||
| to the point as the view changes. |
There was a problem hiding this comment.
This should explain a bit more the different annotation modes (data, etc) and also how/why you would want to use 2D annotations
There was a problem hiding this comment.
Thanks for clarification
DONE!
| draws, so it stays attached to the intended point when rotating or zooming the | ||
| 3D view. | ||
|
|
||
| The new keyword-only parameter ``axlim_clip`` can be used to hide annotations |
There was a problem hiding this comment.
Probably don't need this detail, as axlim_clip is a param on all 3D artists
| assert_delta(ann_fs) | ||
|
|
||
|
|
||
| @mpl3d_image_comparison(['annotate3d_equivalent_to_axes_annotate.png'], |
There was a problem hiding this comment.
For this, please use the fig_ref, fig_test type of image comparison test. That ensures both are giving the pixel-equivalent output, and saves us from having to add a baseline image (which takes up space in the git history)
|
|
||
| This intentionally opts out of layout (like `Text3D`) by returning None from | ||
| `get_tightbbox`. | ||
| """ |
There was a problem hiding this comment.
I believe this should have an __init__ that calls set_3d_properties, like other functions here. And the docstring should include the parameters for that init.
| If a 3-tuple *xy* is passed with *xycoords* not equal to ``'data'``, | ||
| or if a 3-tuple *xytext* is passed with the text position not in | ||
| data coordinates. | ||
| """ |
There was a problem hiding this comment.
There should be an _api.check_in_list call for xycoords and anything else that's a string key
| ax.set_visible(False) | ||
|
|
||
|
|
||
| def test_annotate_3d_follows_view(): |
There was a problem hiding this comment.
I don't think this is necessary with the image test.
There was a problem hiding this comment.
Good point the old p0 != p1 check was a weak proxy. I rewrote test_annotate_3d_follows_view as a @check_figures_equal test: fig_test draws the 3D annotation, rotates the view, and redraws; fig_ref sets the final view up front and uses a pre-projected 2D snapshot with a direct matplotlib.axes.Axes.annotate call.
This gives a pixel-level equivalence check without adding a baseline image. is this logic looking good?
|
|
||
| @mpl3d_image_comparison(['annotate3d_cases.png'], | ||
| remove_text=False, style='mpl20') | ||
| def test_annotate_3d_cases(): |
There was a problem hiding this comment.
You can make this just one subplot with 2 linear and 1 log axes. But please test all four permutations of 2D & 3D coordinates for data and text.
There was a problem hiding this comment.
Updated test_annotate_3d_cases per your suggestion: it’s now a single 3D subplot with zscale='log' and four annotations covering all xy/xytext 2D/3D permutations.
The “xy=2D” cases use a pre-projected 2D snapshot from get_offsets() after a draw (legacy behavior), so those permutations are well-defined. Baseline annotate3d_cases.png was regenerated accordingly.
| assert not np.allclose(p0, p1) | ||
|
|
||
|
|
||
| def test_annotate_3d_text_position_clipped_does_not_crash(): |
There was a problem hiding this comment.
You don't need a special test for this, just the image test with all the permutations of axlim_clip is fine.
There was a problem hiding this comment.
Thanks for clarification
DONE!
| assert np.all(clip_box.extents == ax.bbox.extents) | ||
|
|
||
|
|
||
| def test_annotate_3d_text_position_with_2d_anchor(): |
There was a problem hiding this comment.
This should already be covered by the baseline image test.
36eacc9 to
3164685
Compare
3164685 to
a42cedc
Compare
PR Description
AI Disclosure
I used an AI assistant for code navigation and prototyping; all changes were heavily reviewed, tested locally, and manually verified by me to ensure they match Matplotlib's internal standards.
Summary
Fixes mplot3d annotations being anchored in projected 2D snapshot space instead of 3D data space (#7927). Currently, Axes3D inherits the 2D Axes.annotate, meaning annotations do not reproject when the 3D view changes (rotate/zoom), even though other 3D artists update using Axes3D.M.
What this PR changes
New 3D Artist: Adds art3d.Annotation3D which stores a 3D anchor and reprojects it on every draw using the current 3D projection (Axes3D.M via proj3d._proj_transform_clip).
API Override & Validation: Axes3D.annotate now automatically upgrades to Annotation3D when either xy or xytext is passed as a 3-tuple, seamlessly supporting hybrid 2D/3D coordinate inputs.
Strict Coordinate Enforcement: Uses _api.check_in_list to enforce that 3D tuples are only accepted when xycoords='data' (or textcoords='data').
Draw-Time Projection: Performs the 3D→2D projection at the start of Annotation3D.draw() so 2D annotation_clip and clip_on special-casing behave correctly without timing traps.
Clipping / layout behavior
clip_on=True behavior matches 2D Axes.annotate special-casing (clip to ax.patch).
Adds axlim_clip kwarg (default False) for Annotation3D to cull annotations outside the view limits.
Annotation3D.get_tightbbox() returns None since projected positions aren't meaningful for the layout engine.
Visual Proof & Minimal Reproducible Example
A full demonstration of this new behavior has been added to the docs in the new gallery example: galleries/examples/mplot3d/annotations3d.py.
Below is a minimal reproducible example demonstrating the fix. The black arrow uses the new 3D tracking, while the orange arrow shows the old 2D snapshot drifting bug.
PR checklist