X Tutup
Skip to content

mplot3d: make Axes3D.annotate accept (x, y, z) and reproject on draw#31267

Open
sanrishi wants to merge 2 commits intomatplotlib:mainfrom
sanrishi:fix-issue-7927-3d-annotations
Open

mplot3d: make Axes3D.annotate accept (x, y, z) and reproject on draw#31267
sanrishi wants to merge 2 commits intomatplotlib:mainfrom
sanrishi:fix-issue-7927-3d-annotations

Conversation

@sanrishi
Copy link

@sanrishi sanrishi commented Mar 10, 2026

PR summary

AI Disclosure

I used an AI coding assistant to help navigate the Matplotlib codebase (specifically to quickly locate the clipping logic inside text.py and art3d.py) and to help draft the initial Python code for the Annotation3D class based on the architectural plan approved in Issue #7927.

All AI-generated code was heavily reviewed, manually debugged, and verified by me. I personally ran the pytest suite locally and performed manual visual testing with 3D rotations to ensure the math and clipping behaviors perfectly matched standard 2D Matplotlib semantics.

Summary

Fixes mplot3d annotations being anchored in projected 2D snapshot space instead of 3D data space (#7927). Today Axes3D inherits the 2D Axes.annotate, so annotations do not reproject when the 3D view changes (rotate/zoom), even though 3D artists update using Axes3D.M.

What this PR changes

  • Adds art3d.Annotation3D that stores a 3D anchor and reprojects it on every draw using the current 3D projection (Axes3D.M via proj3d._scale_proj_transform_clip).
  • Overrides Axes3D.annotate so that when xycoords='data', xy may be a 3-tuple (x, y, z). In that case we create a normal 2D Annotation via super().annotate(...) and then convert it to Annotation3D (mirrors the existing text_2d_to_3d /
    Text3D pattern).
  • Performs the 3D→2D projection at the start of Annotation3D.draw() (not only in update_positions) so 2D annotation_clip and the clip_on special-casing behave correctly (avoids the “timing trap” where _check_xy() ran before
    projection).

Clipping / layout behavior

  • clip_on=True behavior matches 2D Axes.annotate special-casing (clip to ax.patch, which optimizes to a clip box for a Rectangle patch).
  • Adds axlim_clip kwarg (default False) for Annotation3D, matching Text3D’s view-limit culling.
  • Annotation3D.get_tightbbox() returns None (same rationale as Text3D: projected positions aren’t meaningful for the layout engine).

Visual Proof & Minimal Reproducible Example

Here is an MRE demonstrating the fix. The black arrow uses the new 3D tracking, while the orange arrow shows the old 2D snapshot drifting bug.

import numpy as np
import matplotlib.pyplot as plt

fig = plt.figure()
ax = fig.add_subplot(projection="3d")

xs = ys = zs = np.arange(10)
sc = ax.scatter(xs, ys, zs, depthshade=True)

x0, y0, z0 = xs[5], ys[5], zs[5]

# NEW BEHAVIOR: 3D anchor follows camera rotation
ax.annotate(
    "3D annotate anchor",
    (x0, y0, z0),
    xytext=(0, 12),
    textcoords="offset points",
    arrowprops=dict(arrowstyle="->", lw=1),
    clip_on=True,
    axlim_clip=False,
)

# OLD BEHAVIOR: 2D snapshot drifts
fig.canvas.draw()
snap = sc.get_offsets()[5]
ax.annotate(
    "2D snapshot",
    snap,
    xytext=(0, -15),
    textcoords="offset points",
    arrowprops=dict(arrowstyle="->", lw=1, color="C1"),
    color="C1",
)

ax.set_title("Rotate/zoom: 3D annotation follows; 2D snapshot drifts")
plt.show()

Screenshot

image

PR checklist

Copy link
Contributor

@scottshambaugh scottshambaugh left a comment

Choose a reason for hiding this comment

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

I tried this out locally and it works well! A number of comments here, but I agree on the overall direction.

Needed docs updates:

``'data'``, the annotated position *xy* may be a 3-tuple *(x, y, z)* in
3D data coordinates; in that case, the annotation will reproject during
draws (e.g. on rotation / zoom).
"""
Copy link
Contributor

Choose a reason for hiding this comment

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

This docstring needs to be filled out. Describe the difference in behavior between len 2 and len 3 inputs for xy and xytext, and add detailed Parameters descriptions like the other functions here do.

text3D = text
text2D = Axes.text

def annotate(self, text, xy, xytext=None, xycoords='data', textcoords=None,
Copy link
Contributor

Choose a reason for hiding this comment

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

I think keeping these as "xy" args rather than "xyz" args is the right move. It means 3D has the same API as 2D, but with the special behavior of being able to pass in 3D coordinates.

There is the option of renaming these and making the "xy" args keyword only with a deprecation notice, but I don't think that's worth it.

assert clip_box is not None
assert np.all(clip_box.extents == ax.bbox.extents)


Copy link
Contributor

Choose a reason for hiding this comment

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

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

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)
Copy link
Contributor

@scottshambaugh scottshambaugh Mar 10, 2026

Choose a reason for hiding this comment

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

Probably want to issue a warning for the all-2D case if axlim_clip is supplied, since it's not used.

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)
Copy link
Contributor

Choose a reason for hiding this comment

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

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)

text, xy, xytext=xytext, xycoords=xycoords, textcoords=textcoords,
arrowprops=arrowprops, annotation_clip=annotation_clip, **kwargs)

if xyz is not None:
Copy link
Contributor

Choose a reason for hiding this comment

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

There should also be a path that allows xyztext is not None and xyz is None (2D data location, 3D text location).

return None


class Annotation3D(mtext.Annotation):
Copy link
Contributor

@scottshambaugh scottshambaugh Mar 10, 2026

Choose a reason for hiding this comment

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

Annotation inherits from Text. How much effort would it be to also inherit from Text3D so we get zdir handling, etc? I think not strictly necessary since we can add later.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

mplot3d annotate uses the 2D projection coordinates system

2 participants

X Tutup