')
+
+
+def test_multivariate_repr_png():
+ cmap = mpl.multivar_colormaps['3VarAddA']
+ png = cmap._repr_png_()
+ assert len(png) > 0
+ img = Image.open(BytesIO(png))
+ assert img.width > 0
+ assert img.height > 0
+ assert 'Title' in img.text
+ assert 'Description' in img.text
+ assert 'Author' in img.text
+ assert 'Software' in img.text
+
+
+def test_multivariate_repr_html():
+ cmap = mpl.multivar_colormaps['3VarAddA']
+ html = cmap._repr_html_()
+ assert len(html) > 0
+ for c in cmap:
+ png = c._repr_png_()
+ assert base64.b64encode(png).decode('ascii') in html
+ assert cmap.name in html
+ assert html.startswith('
')
+
+
+def test_bivar_eq():
+ """
+ Tests equality between multivariate colormaps
+ """
+ cmap_0 = mpl.bivar_colormaps['BiPeak']
+
+ cmap_1 = mpl.bivar_colormaps['BiPeak']
+ assert (cmap_0 == cmap_1) is True
+
+ cmap_1 = mpl.multivar_colormaps['2VarAddA']
+ assert (cmap_0 == cmap_1) is False
+
+ cmap_1 = mpl.bivar_colormaps['BiCone']
+ assert (cmap_0 == cmap_1) is False
+
+ cmap_1 = mpl.bivar_colormaps['BiPeak']
+ cmap_1 = cmap_1.with_extremes(bad='k')
+ assert (cmap_0 == cmap_1) is False
+
+ cmap_1 = mpl.bivar_colormaps['BiPeak']
+ cmap_1 = cmap_1.with_extremes(outside='k')
+ assert (cmap_0 == cmap_1) is False
+
+ cmap_1 = mpl.bivar_colormaps['BiPeak']
+ cmap_1._init()
+ cmap_1._lut *= 0.5
+ assert (cmap_0 == cmap_1) is False
+
+ cmap_1 = mpl.bivar_colormaps['BiPeak']
+ cmap_1 = cmap_1.with_extremes(shape='ignore')
+ assert (cmap_0 == cmap_1) is False
+
+
+def test_multivar_eq():
+ """
+ Tests equality between multivariate colormaps
+ """
+ cmap_0 = mpl.multivar_colormaps['2VarAddA']
+
+ cmap_1 = mpl.multivar_colormaps['2VarAddA']
+ assert (cmap_0 == cmap_1) is True
+
+ cmap_1 = mpl.bivar_colormaps['BiPeak']
+ assert (cmap_0 == cmap_1) is False
+
+ cmap_1 = mpl.colors.MultivarColormap([cmap_0[0]]*2,
+ 'sRGB_add')
+ assert (cmap_0 == cmap_1) is False
+
+ cmap_1 = mpl.multivar_colormaps['3VarAddA']
+ assert (cmap_0 == cmap_1) is False
+
+ cmap_1 = mpl.multivar_colormaps['2VarAddA']
+ cmap_1 = cmap_1.with_extremes(bad='k')
+ assert (cmap_0 == cmap_1) is False
+
+ cmap_1 = mpl.multivar_colormaps['2VarAddA']
+ cmap_1 = mpl.colors.MultivarColormap(cmap_1[:], 'sRGB_sub')
+ assert (cmap_0 == cmap_1) is False
diff --git a/lib/matplotlib/tests/test_offsetbox.py b/lib/matplotlib/tests/test_offsetbox.py
index f18fa7c777d1..f126b1cbb466 100644
--- a/lib/matplotlib/tests/test_offsetbox.py
+++ b/lib/matplotlib/tests/test_offsetbox.py
@@ -48,8 +48,8 @@ def test_offsetbox_clipping():
da.add_artist(bg)
da.add_artist(line)
ax.add_artist(anchored_box)
- ax.set_xlim((0, 1))
- ax.set_ylim((0, 1))
+ ax.set_xlim(0, 1)
+ ax.set_ylim(0, 1)
def test_offsetbox_clip_children():
@@ -455,8 +455,55 @@ def test_remove_draggable():
def test_draggable_in_subfigure():
fig = plt.figure()
# Put annotation at lower left corner to make it easily pickable below.
- ann = fig.subfigures().add_axes([0, 0, 1, 1]).annotate("foo", (0, 0))
+ ann = fig.subfigures().add_axes((0, 0, 1, 1)).annotate("foo", (0, 0))
ann.draggable(True)
fig.canvas.draw() # Texts are non-pickable until the first draw.
MouseEvent("button_press_event", fig.canvas, 1, 1)._process()
assert ann._draggable.got_artist
+ # Stop dragging the annotation.
+ MouseEvent("button_release_event", fig.canvas, 1, 1)._process()
+ assert not ann._draggable.got_artist
+ # A scroll event should not initiate a drag.
+ MouseEvent("scroll_event", fig.canvas, 1, 1)._process()
+ assert not ann._draggable.got_artist
+ # An event outside the annotation should not initiate a drag.
+ bbox = ann.get_window_extent()
+ MouseEvent("button_press_event", fig.canvas, bbox.x1+2, bbox.y1+2)._process()
+ assert not ann._draggable.got_artist
+
+
+def test_anchored_offsetbox_tuple_and_float_borderpad():
+ """
+ Test AnchoredOffsetbox correctly handles both float and tuple for borderpad.
+ """
+
+ fig, ax = plt.subplots()
+
+ # Case 1: Establish a baseline with float value
+ text_float = AnchoredText("float", loc='lower left', borderpad=5)
+ ax.add_artist(text_float)
+
+ # Case 2: Test that a symmetric tuple gives the exact same result.
+ text_tuple_equal = AnchoredText("tuple", loc='lower left', borderpad=(5, 5))
+ ax.add_artist(text_tuple_equal)
+
+ # Case 3: Test that an asymmetric tuple with different values works as expected.
+ text_tuple_asym = AnchoredText("tuple_asym", loc='lower left', borderpad=(10, 4))
+ ax.add_artist(text_tuple_asym)
+
+ # Draw the canvas to calculate final positions
+ fig.canvas.draw()
+
+ pos_float = text_float.get_window_extent()
+ pos_tuple_equal = text_tuple_equal.get_window_extent()
+ pos_tuple_asym = text_tuple_asym.get_window_extent()
+
+ # Assertion 1: Prove that borderpad=5 is identical to borderpad=(5, 5).
+ assert pos_tuple_equal.x0 == pos_float.x0
+ assert pos_tuple_equal.y0 == pos_float.y0
+
+ # Assertion 2: Prove that the asymmetric padding moved the box
+ # further from the origin than the baseline in the x-direction and less far
+ # in the y-direction.
+ assert pos_tuple_asym.x0 > pos_float.x0
+ assert pos_tuple_asym.y0 < pos_float.y0
diff --git a/lib/matplotlib/tests/test_patches.py b/lib/matplotlib/tests/test_patches.py
index 3544ce8cb10c..ed608eebb6a7 100644
--- a/lib/matplotlib/tests/test_patches.py
+++ b/lib/matplotlib/tests/test_patches.py
@@ -178,7 +178,7 @@ def test_rotate_rect():
assert_almost_equal(rect1.get_verts(), new_verts)
-@check_figures_equal(extensions=['png'])
+@check_figures_equal()
def test_rotate_rect_draw(fig_test, fig_ref):
ax_test = fig_test.add_subplot()
ax_ref = fig_ref.add_subplot()
@@ -199,7 +199,7 @@ def test_rotate_rect_draw(fig_test, fig_ref):
assert rect_test.get_angle() == angle
-@check_figures_equal(extensions=['png'])
+@check_figures_equal()
def test_dash_offset_patch_draw(fig_test, fig_ref):
ax_test = fig_test.add_subplot()
ax_ref = fig_ref.add_subplot()
@@ -241,7 +241,7 @@ def test_negative_rect():
assert_array_equal(np.roll(neg_vertices, 2, 0), pos_vertices)
-@image_comparison(['clip_to_bbox'])
+@image_comparison(['clip_to_bbox.png'])
def test_clip_to_bbox():
fig, ax = plt.subplots()
ax.set_xlim([-18, 20])
@@ -395,7 +395,7 @@ def test_patch_linestyle_accents():
fig.canvas.draw()
-@check_figures_equal(extensions=['png'])
+@check_figures_equal()
def test_patch_linestyle_none(fig_test, fig_ref):
circle = mpath.Path.unit_circle()
@@ -438,7 +438,7 @@ def test_wedge_movement():
@image_comparison(['wedge_range'], remove_text=True,
- tol=0.009 if platform.machine() == 'arm64' else 0)
+ tol=0 if platform.machine() == 'x86_64' else 0.009)
def test_wedge_range():
ax = plt.axes()
@@ -564,7 +564,7 @@ def test_units_rectangle():
@image_comparison(['connection_patch.png'], style='mpl20', remove_text=True,
- tol=0.024 if platform.machine() == 'arm64' else 0)
+ tol=0 if platform.machine() == 'x86_64' else 0.024)
def test_connection_patch():
fig, (ax1, ax2) = plt.subplots(1, 2)
@@ -583,7 +583,7 @@ def test_connection_patch():
ax2.add_artist(con)
-@check_figures_equal(extensions=["png"])
+@check_figures_equal()
def test_connection_patch_fig(fig_test, fig_ref):
# Test that connection patch can be added as figure artist, and that figure
# pixels count negative values from the top right corner (this API may be
@@ -606,6 +606,28 @@ def test_connection_patch_fig(fig_test, fig_ref):
fig_ref.add_artist(con)
+@check_figures_equal()
+def test_connection_patch_pixel_points(fig_test, fig_ref):
+ xyA_pts = (.3, .2)
+ xyB_pts = (-30, -20)
+
+ ax1, ax2 = fig_test.subplots(1, 2)
+ con = mpatches.ConnectionPatch(xyA=xyA_pts, coordsA="axes points", axesA=ax1,
+ xyB=xyB_pts, coordsB="figure points",
+ arrowstyle="->", shrinkB=5)
+ fig_test.add_artist(con)
+
+ plt.rcParams["savefig.dpi"] = plt.rcParams["figure.dpi"]
+
+ ax1, ax2 = fig_ref.subplots(1, 2)
+ xyA_pix = (xyA_pts[0]*(fig_ref.dpi/72), xyA_pts[1]*(fig_ref.dpi/72))
+ xyB_pix = (xyB_pts[0]*(fig_ref.dpi/72), xyB_pts[1]*(fig_ref.dpi/72))
+ con = mpatches.ConnectionPatch(xyA=xyA_pix, coordsA="axes pixels", axesA=ax1,
+ xyB=xyB_pix, coordsB="figure pixels",
+ arrowstyle="->", shrinkB=5)
+ fig_ref.add_artist(con)
+
+
def test_datetime_rectangle():
# Check that creating a rectangle with timedeltas doesn't fail
from datetime import datetime, timedelta
@@ -656,7 +678,7 @@ def test_contains_points():
# Currently fails with pdf/svg, probably because some parts assume a dpi of 72.
-@check_figures_equal(extensions=["png"])
+@check_figures_equal()
def test_shadow(fig_test, fig_ref):
xy = np.array([.2, .3])
dxy = np.array([.1, .2])
@@ -919,7 +941,9 @@ def test_arc_in_collection(fig_test, fig_ref):
arc2 = Arc([.5, .5], .5, 1, theta1=0, theta2=60, angle=20)
col = mcollections.PatchCollection(patches=[arc2], facecolors='none',
edgecolors='k')
- fig_ref.subplots().add_patch(arc1)
+ ax_ref = fig_ref.subplots()
+ ax_ref.add_patch(arc1)
+ ax_ref.autoscale_view()
fig_test.subplots().add_collection(col)
@@ -960,3 +984,120 @@ def test_arrow_set_data():
)
arrow.set_data(x=.5, dx=3, dy=8, width=1.2)
assert np.allclose(expected2, np.round(arrow.get_verts(), 2))
+
+
+@check_figures_equal(extensions=["png", "pdf", "svg", "eps"])
+def test_set_and_get_hatch_linewidth(fig_test, fig_ref):
+ ax_test = fig_test.add_subplot()
+ ax_ref = fig_ref.add_subplot()
+
+ lw = 2.0
+
+ with plt.rc_context({"hatch.linewidth": lw}):
+ ax_ref.add_patch(mpatches.Rectangle((0, 0), 1, 1, hatch="x"))
+
+ ax_test.add_patch(mpatches.Rectangle((0, 0), 1, 1, hatch="x"))
+ ax_test.patches[0].set_hatch_linewidth(lw)
+
+ assert ax_ref.patches[0].get_hatch_linewidth() == lw
+ assert ax_test.patches[0].get_hatch_linewidth() == lw
+
+
+def test_patch_hatchcolor_inherit_logic():
+ with mpl.rc_context({'hatch.color': 'edge'}):
+ # Test for when edgecolor and hatchcolor is set
+ rect = Rectangle((0, 0), 1, 1, hatch='//', ec='red',
+ hatchcolor='yellow')
+ assert mcolors.same_color(rect.get_edgecolor(), 'red')
+ assert mcolors.same_color(rect.get_hatchcolor(), 'yellow')
+
+ # Test for explicitly setting edgecolor and then hatchcolor
+ rect = Rectangle((0, 0), 1, 1, hatch='//')
+ rect.set_edgecolor('orange')
+ assert mcolors.same_color(rect.get_hatchcolor(), 'orange')
+ rect.set_hatchcolor('cyan')
+ assert mcolors.same_color(rect.get_hatchcolor(), 'cyan')
+
+ # Test for explicitly setting hatchcolor and then edgecolor
+ rect = Rectangle((0, 0), 1, 1, hatch='//')
+ rect.set_hatchcolor('purple')
+ assert mcolors.same_color(rect.get_hatchcolor(), 'purple')
+ rect.set_edgecolor('green')
+ assert mcolors.same_color(rect.get_hatchcolor(), 'purple')
+
+ # Smoke test for setting with numpy array
+ rect.set_hatchcolor(np.ones(3))
+
+
+def test_patch_hatchcolor_fallback_logic():
+ # Test for when hatchcolor parameter is passed
+ rect = Rectangle((0, 0), 1, 1, hatch='//', hatchcolor='green')
+ assert mcolors.same_color(rect.get_hatchcolor(), 'green')
+
+ # Test that hatchcolor parameter takes precedence over rcParam
+ # When edgecolor is not set
+ with mpl.rc_context({'hatch.color': 'blue'}):
+ rect = Rectangle((0, 0), 1, 1, hatch='//', hatchcolor='green')
+ assert mcolors.same_color(rect.get_hatchcolor(), 'green')
+ # When edgecolor is set
+ with mpl.rc_context({'hatch.color': 'yellow'}):
+ rect = Rectangle((0, 0), 1, 1, hatch='//', hatchcolor='green', edgecolor='red')
+ assert mcolors.same_color(rect.get_hatchcolor(), 'green')
+
+ # Test that hatchcolor is not overridden by edgecolor when
+ # hatchcolor parameter is not passed and hatch.color rcParam is set to a color
+ # When edgecolor is not set
+ with mpl.rc_context({'hatch.color': 'blue'}):
+ rect = Rectangle((0, 0), 1, 1, hatch='//')
+ assert mcolors.same_color(rect.get_hatchcolor(), 'blue')
+ # When edgecolor is set
+ with mpl.rc_context({'hatch.color': 'blue'}):
+ rect = Rectangle((0, 0), 1, 1, hatch='//', edgecolor='red')
+ assert mcolors.same_color(rect.get_hatchcolor(), 'blue')
+
+ # Test that hatchcolor matches edgecolor when
+ # hatchcolor parameter is not passed and hatch.color rcParam is set to 'edge'
+ with mpl.rc_context({'hatch.color': 'edge'}):
+ rect = Rectangle((0, 0), 1, 1, hatch='//', edgecolor='red')
+ assert mcolors.same_color(rect.get_hatchcolor(), 'red')
+ # hatchcolor parameter is set to 'edge'
+ rect = Rectangle((0, 0), 1, 1, hatch='//', hatchcolor='edge', edgecolor='orange')
+ assert mcolors.same_color(rect.get_hatchcolor(), 'orange')
+
+ # Test for default hatchcolor when hatchcolor parameter is not passed and
+ # hatch.color rcParam is set to 'edge' and edgecolor is not set
+ rect = Rectangle((0, 0), 1, 1, hatch='//')
+ assert mcolors.same_color(rect.get_hatchcolor(), mpl.rcParams['patch.edgecolor'])
+
+
+def test_facecolor_none_force_edgecolor_false():
+ rcParams['patch.force_edgecolor'] = False # default value
+ rect = Rectangle((0, 0), 1, 1, facecolor="none")
+ assert rect.get_edgecolor() == (0.0, 0.0, 0.0, 0.0)
+
+
+def test_facecolor_none_force_edgecolor_true():
+ rcParams['patch.force_edgecolor'] = True
+ rect = Rectangle((0, 0), 1, 1, facecolor="none")
+ assert rect.get_edgecolor() == (0.0, 0.0, 0.0, 1)
+
+
+def test_facecolor_none_edgecolor_force_edgecolor():
+
+ # Case 1:force_edgecolor =False -> rcParams['patch.edgecolor'] should NOT be applied
+ rcParams['patch.force_edgecolor'] = False
+ rcParams['patch.edgecolor'] = 'red'
+ rect = Rectangle((0, 0), 1, 1, facecolor="none")
+ assert not mcolors.same_color(rect.get_edgecolor(), rcParams['patch.edgecolor'])
+
+ # Case 2:force_edgecolor =True -> rcParams['patch.edgecolor'] SHOULD be applied
+ rcParams['patch.force_edgecolor'] = True
+ rcParams['patch.edgecolor'] = 'red'
+ rect = Rectangle((0, 0), 1, 1, facecolor="none")
+ assert mcolors.same_color(rect.get_edgecolor(), rcParams['patch.edgecolor'])
+
+
+def test_empty_fancyarrow():
+ fig, ax = plt.subplots()
+ arrow = ax.arrow([], [], [], [])
+ assert arrow is not None
diff --git a/lib/matplotlib/tests/test_path.py b/lib/matplotlib/tests/test_path.py
index 2c4df6ea3b39..a61f01c0d48a 100644
--- a/lib/matplotlib/tests/test_path.py
+++ b/lib/matplotlib/tests/test_path.py
@@ -151,12 +151,12 @@ def test_nonlinear_containment():
@image_comparison(['arrow_contains_point.png'], remove_text=True, style='mpl20',
- tol=0.027 if platform.machine() == 'arm64' else 0)
+ tol=0 if platform.machine() == 'x86_64' else 0.027)
def test_arrow_contains_point():
# fix bug (#8384)
fig, ax = plt.subplots()
- ax.set_xlim((0, 2))
- ax.set_ylim((0, 2))
+ ax.set_xlim(0, 2)
+ ax.set_ylim(0, 2)
# create an arrow with Curve style
arrow = patches.FancyArrowPatch((0.5, 0.25), (1.5, 0.75),
@@ -283,7 +283,7 @@ def test_marker_paths_pdf():
@image_comparison(['nan_path'], style='default', remove_text=True,
extensions=['pdf', 'svg', 'eps', 'png'],
- tol=0.009 if platform.machine() == 'arm64' else 0)
+ tol=0 if platform.machine() == 'x86_64' else 0.009)
def test_nan_isolated_points():
y0 = [0, np.nan, 2, np.nan, 4, 5, 6]
@@ -355,15 +355,49 @@ def test_path_deepcopy():
# Should not raise any error
verts = [[0, 0], [1, 1]]
codes = [Path.MOVETO, Path.LINETO]
- path1 = Path(verts)
- path2 = Path(verts, codes)
+ path1 = Path(verts, readonly=True)
+ path2 = Path(verts, codes, readonly=True)
path1_copy = path1.deepcopy()
path2_copy = path2.deepcopy()
assert path1 is not path1_copy
assert path1.vertices is not path1_copy.vertices
+ assert_array_equal(path1.vertices, path1_copy.vertices)
+ assert path1.readonly
+ assert not path1_copy.readonly
assert path2 is not path2_copy
assert path2.vertices is not path2_copy.vertices
+ assert_array_equal(path2.vertices, path2_copy.vertices)
assert path2.codes is not path2_copy.codes
+ assert_array_equal(path2.codes, path2_copy.codes)
+ assert path2.readonly
+ assert not path2_copy.readonly
+
+
+def test_path_deepcopy_cycle():
+ class PathWithCycle(Path):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.x = self
+
+ p = PathWithCycle([[0, 0], [1, 1]], readonly=True)
+ p_copy = p.deepcopy()
+ assert p_copy is not p
+ assert p.readonly
+ assert not p_copy.readonly
+ assert p_copy.x is p_copy
+
+ class PathWithCycle2(Path):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.x = [self] * 2
+
+ p2 = PathWithCycle2([[0, 0], [1, 1]], readonly=True)
+ p2_copy = p2.deepcopy()
+ assert p2_copy is not p2
+ assert p2.readonly
+ assert not p2_copy.readonly
+ assert p2_copy.x[0] is p2_copy
+ assert p2_copy.x[1] is p2_copy
def test_path_shallowcopy():
@@ -541,3 +575,84 @@ def test_cleanup_closepoly():
cleaned = p.cleaned(remove_nans=True)
assert len(cleaned) == 1
assert cleaned.codes[0] == Path.STOP
+
+
+def test_interpolated_moveto():
+ # Initial path has two subpaths with two LINETOs each
+ vertices = np.array([[0, 0],
+ [0, 1],
+ [1, 2],
+ [4, 4],
+ [4, 5],
+ [5, 5]])
+ codes = [Path.MOVETO, Path.LINETO, Path.LINETO] * 2
+
+ path = Path(vertices, codes)
+ result = path.interpolated(3)
+
+ # Result should have two subpaths with six LINETOs each
+ expected_subpath_codes = [Path.MOVETO] + [Path.LINETO] * 6
+ np.testing.assert_array_equal(result.codes, expected_subpath_codes * 2)
+
+
+def test_interpolated_closepoly():
+ codes = [Path.MOVETO] + [Path.LINETO]*2 + [Path.CLOSEPOLY]
+ vertices = [(4, 3), (5, 4), (5, 3), (0, 0)]
+
+ path = Path(vertices, codes)
+ result = path.interpolated(2)
+
+ expected_vertices = np.array([[4, 3],
+ [4.5, 3.5],
+ [5, 4],
+ [5, 3.5],
+ [5, 3],
+ [4.5, 3],
+ [4, 3]])
+ expected_codes = [Path.MOVETO] + [Path.LINETO]*5 + [Path.CLOSEPOLY]
+
+ np.testing.assert_allclose(result.vertices, expected_vertices)
+ np.testing.assert_array_equal(result.codes, expected_codes)
+
+ # Usually closepoly is the last vertex but does not have to be.
+ codes += [Path.LINETO]
+ vertices += [(2, 1)]
+
+ path = Path(vertices, codes)
+ result = path.interpolated(2)
+
+ extra_expected_vertices = np.array([[3, 2],
+ [2, 1]])
+ expected_vertices = np.concatenate([expected_vertices, extra_expected_vertices])
+
+ expected_codes += [Path.LINETO] * 2
+
+ np.testing.assert_allclose(result.vertices, expected_vertices)
+ np.testing.assert_array_equal(result.codes, expected_codes)
+
+
+def test_interpolated_moveto_closepoly():
+ # Initial path has two closed subpaths
+ codes = ([Path.MOVETO] + [Path.LINETO]*2 + [Path.CLOSEPOLY]) * 2
+ vertices = [(4, 3), (5, 4), (5, 3), (0, 0), (8, 6), (10, 8), (10, 6), (0, 0)]
+
+ path = Path(vertices, codes)
+ result = path.interpolated(2)
+
+ expected_vertices1 = np.array([[4, 3],
+ [4.5, 3.5],
+ [5, 4],
+ [5, 3.5],
+ [5, 3],
+ [4.5, 3],
+ [4, 3]])
+ expected_vertices = np.concatenate([expected_vertices1, expected_vertices1 * 2])
+ expected_codes = ([Path.MOVETO] + [Path.LINETO]*5 + [Path.CLOSEPOLY]) * 2
+
+ np.testing.assert_allclose(result.vertices, expected_vertices)
+ np.testing.assert_array_equal(result.codes, expected_codes)
+
+
+def test_interpolated_empty_path():
+ path = Path(np.zeros((0, 2)))
+ assert path.interpolated(42) is path
diff --git a/lib/matplotlib/tests/test_patheffects.py b/lib/matplotlib/tests/test_patheffects.py
index bf067b2abbfd..466754aae383 100644
--- a/lib/matplotlib/tests/test_patheffects.py
+++ b/lib/matplotlib/tests/test_patheffects.py
@@ -30,7 +30,7 @@ def test_patheffect1():
@image_comparison(['patheffect2'], remove_text=True, style='mpl20',
- tol=0.06 if platform.machine() == 'arm64' else 0)
+ tol=0 if platform.machine() == 'x86_64' else 0.06)
def test_patheffect2():
ax2 = plt.subplot()
@@ -45,7 +45,8 @@ def test_patheffect2():
foreground="w")])
-@image_comparison(['patheffect3'], tol=0.019 if platform.machine() == 'arm64' else 0)
+@image_comparison(['patheffect3'],
+ tol=0 if platform.machine() == 'x86_64' else 0.019)
def test_patheffect3():
p1, = plt.plot([1, 3, 5, 4, 3], 'o-b', lw=4)
p1.set_path_effects([path_effects.SimpleLineShadow(),
@@ -134,9 +135,8 @@ def test_collection():
'edgecolor': 'blue'})
-@image_comparison(['tickedstroke'], remove_text=True, extensions=['png'],
- tol=0.22) # Increased tolerance due to fixed clipping.
-def test_tickedstroke():
+@image_comparison(['tickedstroke.png'], remove_text=True, style='mpl20')
+def test_tickedstroke(text_placeholders):
fig, (ax1, ax2, ax3) = plt.subplots(1, 3, figsize=(12, 4))
path = Path.unit_circle()
patch = patches.PathPatch(path, facecolor='none', lw=2, path_effects=[
@@ -148,13 +148,13 @@ def test_tickedstroke():
ax1.set_xlim(-2, 2)
ax1.set_ylim(-2, 2)
- ax2.plot([0, 1], [0, 1], label=' ',
+ ax2.plot([0, 1], [0, 1], label='C0',
path_effects=[path_effects.withTickedStroke(spacing=7,
angle=135)])
nx = 101
x = np.linspace(0.0, 1.0, nx)
y = 0.3 * np.sin(x * 8) + 0.4
- ax2.plot(x, y, label=' ', path_effects=[path_effects.withTickedStroke()])
+ ax2.plot(x, y, label='C1', path_effects=[path_effects.withTickedStroke()])
ax2.legend()
diff --git a/lib/matplotlib/tests/test_pickle.py b/lib/matplotlib/tests/test_pickle.py
index 1474a67d28aa..82fc60e186c7 100644
--- a/lib/matplotlib/tests/test_pickle.py
+++ b/lib/matplotlib/tests/test_pickle.py
@@ -17,7 +17,7 @@
import matplotlib.pyplot as plt
import matplotlib.transforms as mtransforms
import matplotlib.figure as mfigure
-from mpl_toolkits.axes_grid1 import axes_divider, parasite_axes # type: ignore
+from mpl_toolkits.axes_grid1 import axes_divider, parasite_axes # type: ignore[import]
def test_simple():
@@ -104,7 +104,7 @@ def _generate_complete_test_figure(fig_ref):
@mpl.style.context("default")
-@check_figures_equal(extensions=["png"])
+@check_figures_equal()
def test_complete(fig_test, fig_ref):
_generate_complete_test_figure(fig_ref)
# plotting is done, now test its pickle-ability
@@ -136,7 +136,7 @@ def _pickle_load_subprocess():
@mpl.style.context("default")
-@check_figures_equal(extensions=['png'])
+@check_figures_equal()
def test_pickle_load_from_subprocess(fig_test, fig_ref, tmp_path):
_generate_complete_test_figure(fig_ref)
@@ -150,7 +150,15 @@ def test_pickle_load_from_subprocess(fig_test, fig_ref, tmp_path):
proc = subprocess_run_helper(
_pickle_load_subprocess,
timeout=60,
- extra_env={'PICKLE_FILE_PATH': str(fp), 'MPLBACKEND': 'Agg'}
+ extra_env={
+ "PICKLE_FILE_PATH": str(fp),
+ "MPLBACKEND": "Agg",
+ # subprocess_run_helper will set SOURCE_DATE_EPOCH=0, so for a dirty tree,
+ # the version will have the date 19700101. As we aren't trying to test the
+ # version compatibility warning, force setuptools-scm to use the same
+ # version as us.
+ "SETUPTOOLS_SCM_PRETEND_VERSION_FOR_MATPLOTLIB": mpl.__version__,
+ },
)
loaded_fig = pickle.loads(ast.literal_eval(proc.stdout))
diff --git a/lib/matplotlib/tests/test_png.py b/lib/matplotlib/tests/test_png.py
index 066eb01c3ae6..a7677b0d05ac 100644
--- a/lib/matplotlib/tests/test_png.py
+++ b/lib/matplotlib/tests/test_png.py
@@ -7,7 +7,7 @@
from matplotlib import cm, pyplot as plt
-@image_comparison(['pngsuite.png'], tol=0.03)
+@image_comparison(['pngsuite.png'], tol=0.09)
def test_pngsuite():
files = sorted(
(Path(__file__).parent / "baseline_images/pngsuite").glob("basn*.png"))
@@ -20,7 +20,10 @@ def test_pngsuite():
if data.ndim == 2:
# keep grayscale images gray
cmap = cm.gray
- plt.imshow(data, extent=(i, i + 1, 0, 1), cmap=cmap)
+ # Using the old default data interpolation stage lets us
+ # continue to use the existing reference image
+ plt.imshow(data, extent=(i, i + 1, 0, 1), cmap=cmap,
+ interpolation_stage='data')
plt.gca().patch.set_facecolor("#ddffff")
plt.gca().set_xlim(0, len(files))
diff --git a/lib/matplotlib/tests/test_polar.py b/lib/matplotlib/tests/test_polar.py
index 6b3c08d2eb3f..4f9e63380490 100644
--- a/lib/matplotlib/tests/test_polar.py
+++ b/lib/matplotlib/tests/test_polar.py
@@ -3,11 +3,13 @@
import pytest
import matplotlib as mpl
+from matplotlib.projections.polar import RadialLocator
from matplotlib import pyplot as plt
from matplotlib.testing.decorators import image_comparison, check_figures_equal
+import matplotlib.ticker as mticker
-@image_comparison(['polar_axes'], style='default', tol=0.012)
+@image_comparison(['polar_axes.png'], style='default', tol=0.012)
def test_polar_annotations():
# You can specify the xypoint and the xytext in different positions and
# coordinate systems, and optionally turn on a connecting line and mark the
@@ -41,7 +43,7 @@ def test_polar_annotations():
ax.tick_params(axis='x', tick1On=True, tick2On=True, direction='out')
-@image_comparison(['polar_coords'], style='default', remove_text=True,
+@image_comparison(['polar_coords.png'], style='default', remove_text=True,
tol=0.014)
def test_polar_coord_annotations():
# You can also use polar notation on a cartesian axes. Here the native
@@ -144,37 +146,37 @@ def test_polar_units_2(fig_test, fig_ref):
ax.set(xlabel="rad", ylabel="km")
-@image_comparison(['polar_rmin'], style='default')
+@image_comparison(['polar_rmin.png'], style='default')
def test_polar_rmin():
r = np.arange(0, 3.0, 0.01)
theta = 2*np.pi*r
fig = plt.figure()
- ax = fig.add_axes([0.1, 0.1, 0.8, 0.8], polar=True)
+ ax = fig.add_axes((0.1, 0.1, 0.8, 0.8), polar=True)
ax.plot(theta, r)
ax.set_rmax(2.0)
ax.set_rmin(0.5)
-@image_comparison(['polar_negative_rmin'], style='default')
+@image_comparison(['polar_negative_rmin.png'], style='default')
def test_polar_negative_rmin():
r = np.arange(-3.0, 0.0, 0.01)
theta = 2*np.pi*r
fig = plt.figure()
- ax = fig.add_axes([0.1, 0.1, 0.8, 0.8], polar=True)
+ ax = fig.add_axes((0.1, 0.1, 0.8, 0.8), polar=True)
ax.plot(theta, r)
ax.set_rmax(0.0)
ax.set_rmin(-3.0)
-@image_comparison(['polar_rorigin'], style='default')
+@image_comparison(['polar_rorigin.png'], style='default')
def test_polar_rorigin():
r = np.arange(0, 3.0, 0.01)
theta = 2*np.pi*r
fig = plt.figure()
- ax = fig.add_axes([0.1, 0.1, 0.8, 0.8], polar=True)
+ ax = fig.add_axes((0.1, 0.1, 0.8, 0.8), polar=True)
ax.plot(theta, r)
ax.set_rmax(2.0)
ax.set_rmin(0.5)
@@ -184,14 +186,14 @@ def test_polar_rorigin():
@image_comparison(['polar_invertedylim.png'], style='default')
def test_polar_invertedylim():
fig = plt.figure()
- ax = fig.add_axes([0.1, 0.1, 0.8, 0.8], polar=True)
+ ax = fig.add_axes((0.1, 0.1, 0.8, 0.8), polar=True)
ax.set_ylim(2, 0)
@image_comparison(['polar_invertedylim_rorigin.png'], style='default')
def test_polar_invertedylim_rorigin():
fig = plt.figure()
- ax = fig.add_axes([0.1, 0.1, 0.8, 0.8], polar=True)
+ ax = fig.add_axes((0.1, 0.1, 0.8, 0.8), polar=True)
ax.yaxis.set_inverted(True)
# Set the rlims to inverted (2, 0) without calling set_rlim, to check that
# viewlims are correctly unstaled before draw()ing.
@@ -200,19 +202,19 @@ def test_polar_invertedylim_rorigin():
ax.set_rorigin(3)
-@image_comparison(['polar_theta_position'], style='default')
+@image_comparison(['polar_theta_position.png'], style='default')
def test_polar_theta_position():
r = np.arange(0, 3.0, 0.01)
theta = 2*np.pi*r
fig = plt.figure()
- ax = fig.add_axes([0.1, 0.1, 0.8, 0.8], polar=True)
+ ax = fig.add_axes((0.1, 0.1, 0.8, 0.8), polar=True)
ax.plot(theta, r)
ax.set_theta_zero_location("NW", 30)
ax.set_theta_direction('clockwise')
-@image_comparison(['polar_rlabel_position'], style='default')
+@image_comparison(['polar_rlabel_position.png'], style='default')
def test_polar_rlabel_position():
fig = plt.figure()
ax = fig.add_subplot(projection='polar')
@@ -220,7 +222,14 @@ def test_polar_rlabel_position():
ax.tick_params(rotation='auto')
-@image_comparison(['polar_theta_wedge'], style='default')
+@image_comparison(['polar_title_position.png'], style='mpl20')
+def test_polar_title_position():
+ fig = plt.figure()
+ ax = fig.add_subplot(projection='polar')
+ ax.set_title('foo')
+
+
+@image_comparison(['polar_theta_wedge.png'], style='default')
def test_polar_theta_limits():
r = np.arange(0, 3.0, 0.01)
theta = 2*np.pi*r
@@ -253,7 +262,7 @@ def test_polar_theta_limits():
steps=[1, 2, 2.5, 5, 10])
-@check_figures_equal(extensions=["png"])
+@check_figures_equal()
def test_polar_rlim(fig_test, fig_ref):
ax = fig_test.subplots(subplot_kw={'polar': True})
ax.set_rlim(top=10)
@@ -264,7 +273,7 @@ def test_polar_rlim(fig_test, fig_ref):
ax.set_rmin(.5)
-@check_figures_equal(extensions=["png"])
+@check_figures_equal()
def test_polar_rlim_bottom(fig_test, fig_ref):
ax = fig_test.subplots(subplot_kw={'polar': True})
ax.set_rlim(bottom=[.5, 10])
@@ -324,7 +333,7 @@ def test_get_tightbbox_polar():
bb.extents, [107.7778, 29.2778, 539.7847, 450.7222], rtol=1e-03)
-@check_figures_equal(extensions=["png"])
+@check_figures_equal()
def test_polar_interpolation_steps_constant_r(fig_test, fig_ref):
# Check that an extra half-turn doesn't make any difference -- modulo
# antialiasing, which we disable here.
@@ -338,7 +347,7 @@ def test_polar_interpolation_steps_constant_r(fig_test, fig_ref):
.bar([0], [1], -2*np.pi, edgecolor="none", antialiased=False))
-@check_figures_equal(extensions=["png"])
+@check_figures_equal()
def test_polar_interpolation_steps_variable_r(fig_test, fig_ref):
l, = fig_test.add_subplot(projection="polar").plot([0, np.pi/2], [1, 2])
l.get_path()._interpolation_steps = 100
@@ -386,7 +395,7 @@ def test_axvspan():
assert span.get_path()._interpolation_steps > 1
-@check_figures_equal(extensions=["png"])
+@check_figures_equal()
def test_remove_shared_polar(fig_ref, fig_test):
# Removing shared polar axes used to crash. Test removing them, keeping in
# both cases just the lower left axes of a grid to avoid running into a
@@ -436,6 +445,33 @@ def test_cursor_precision():
assert ax.format_coord(2, 1) == "θ=0.637π (114.6°), r=1.000"
+def test_custom_fmt_data():
+ ax = plt.subplot(projection="polar")
+ def millions(x):
+ return '$%1.1fM' % (x*1e-6)
+
+ # Test only x formatter
+ ax.fmt_xdata = None
+ ax.fmt_ydata = millions
+ assert ax.format_coord(12, 2e7) == "θ=3.8197186342π (687.54935416°), r=$20.0M"
+ assert ax.format_coord(1234, 2e6) == "θ=392.794399551π (70702.9919191°), r=$2.0M"
+ assert ax.format_coord(3, 100) == "θ=0.95493π (171.887°), r=$0.0M"
+
+ # Test only y formatter
+ ax.fmt_xdata = millions
+ ax.fmt_ydata = None
+ assert ax.format_coord(2e5, 1) == "θ=$0.2M, r=1.000"
+ assert ax.format_coord(1, .1) == "θ=$0.0M, r=0.100"
+ assert ax.format_coord(1e6, 0.005) == "θ=$1.0M, r=0.005"
+
+ # Test both x and y formatters
+ ax.fmt_xdata = millions
+ ax.fmt_ydata = millions
+ assert ax.format_coord(2e6, 2e4*3e5) == "θ=$2.0M, r=$6000.0M"
+ assert ax.format_coord(1e18, 12891328123) == "θ=$1000000000000.0M, r=$12891.3M"
+ assert ax.format_coord(63**7, 1081968*1024) == "θ=$3938980.6M, r=$1107.9M"
+
+
@image_comparison(['polar_log.png'], style='default')
def test_polar_log():
fig = plt.figure()
@@ -448,9 +484,107 @@ def test_polar_log():
ax.plot(np.linspace(0, 2 * np.pi, n), np.logspace(0, 2, n))
+@check_figures_equal()
+def test_polar_log_rorigin(fig_ref, fig_test):
+ # Test that equivalent linear and log radial settings give the same axes patch
+ # and spines.
+ ax_ref = fig_ref.add_subplot(projection='polar', facecolor='red')
+ ax_ref.set_rlim(0, 2)
+ ax_ref.set_rorigin(-3)
+ ax_ref.set_rticks(np.linspace(0, 2, 5))
+
+ ax_test = fig_test.add_subplot(projection='polar', facecolor='red')
+ ax_test.set_rscale('log')
+ ax_test.set_rlim(1, 100)
+ ax_test.set_rorigin(10**-3)
+ ax_test.set_rticks(np.logspace(0, 2, 5))
+
+ for ax in ax_ref, ax_test:
+ # Radial tick labels should be the only difference, so turn them off.
+ ax.tick_params(labelleft=False)
+
+
def test_polar_neg_theta_lims():
fig = plt.figure()
ax = fig.add_subplot(projection='polar')
ax.set_thetalim(-np.pi, np.pi)
labels = [l.get_text() for l in ax.xaxis.get_ticklabels()]
assert labels == ['-180°', '-135°', '-90°', '-45°', '0°', '45°', '90°', '135°']
+
+
+@pytest.mark.parametrize("order", ["before", "after"])
+@image_comparison(baseline_images=['polar_errorbar.png'], remove_text=True,
+ style='mpl20')
+def test_polar_errorbar(order):
+ theta = np.arange(0, 2 * np.pi, np.pi / 8)
+ r = theta / np.pi / 2 + 0.5
+ fig = plt.figure(figsize=(5, 5))
+ ax = fig.add_subplot(projection='polar')
+ if order == "before":
+ ax.set_theta_zero_location("N")
+ ax.set_theta_direction(-1)
+ ax.errorbar(theta, r, xerr=0.1, yerr=0.1, capsize=7, fmt="o", c="seagreen")
+ else:
+ ax.errorbar(theta, r, xerr=0.1, yerr=0.1, capsize=7, fmt="o", c="seagreen")
+ ax.set_theta_zero_location("N")
+ ax.set_theta_direction(-1)
+
+
+def test_radial_limits_behavior():
+ # r=0 is kept as limit if positive data and ticks are used
+ # negative ticks or data result in negative limits
+ fig = plt.figure()
+ ax = fig.add_subplot(projection='polar')
+ assert ax.get_ylim() == (0, 1)
+ # upper limit is expanded to include the ticks, but lower limit stays at 0
+ ax.set_rticks([1, 2, 3, 4])
+ assert ax.get_ylim() == (0, 4)
+ # upper limit is autoscaled to data, but lower limit limit stays 0
+ ax.plot([1, 2], [1, 2])
+ assert ax.get_ylim() == (0, 2)
+ # negative ticks also expand the negative limit
+ ax.set_rticks([-1, 0, 1, 2])
+ assert ax.get_ylim() == (-1, 2)
+ # negative data also autoscales to negative limits
+ ax.plot([1, 2], [-1, -2])
+ assert ax.get_ylim() == (-2, 2)
+
+
+def test_radial_locator_wrapping():
+ # Check that the locator is always wrapped inside a RadialLocator
+ # and that RaidialAxis.isDefault_majloc is set correctly.
+ fig, ax = plt.subplots(subplot_kw={'projection': 'polar'})
+ assert ax.yaxis.isDefault_majloc
+ assert isinstance(ax.yaxis.get_major_locator(), RadialLocator)
+
+ # set an explicit locator
+ locator = mticker.MaxNLocator(3)
+ ax.yaxis.set_major_locator(locator)
+ assert not ax.yaxis.isDefault_majloc
+ assert isinstance(ax.yaxis.get_major_locator(), RadialLocator)
+ assert ax.yaxis.get_major_locator().base is locator
+
+ ax.clear() # reset to the default locator
+ assert ax.yaxis.isDefault_majloc
+ assert isinstance(ax.yaxis.get_major_locator(), RadialLocator)
+
+ ax.set_rticks([0, 1, 2, 3]) # implicitly sets a FixedLocator
+ assert not ax.yaxis.isDefault_majloc # because of the fixed ticks
+ assert isinstance(ax.yaxis.get_major_locator(), RadialLocator)
+ assert isinstance(ax.yaxis.get_major_locator().base, mticker.FixedLocator)
+
+ ax.clear()
+
+ ax.set_rgrids([0, 1, 2, 3]) # implicitly sets a FixedLocator
+ assert not ax.yaxis.isDefault_majloc # because of the fixed ticks
+ assert isinstance(ax.yaxis.get_major_locator(), RadialLocator)
+ assert isinstance(ax.yaxis.get_major_locator().base, mticker.FixedLocator)
+
+ ax.clear()
+
+ ax.set_yscale("log") # implicitly sets a LogLocator
+ # Note that the LogLocator is still considered the default locator
+ # for the log scale
+ assert ax.yaxis.isDefault_majloc
+ assert isinstance(ax.yaxis.get_major_locator(), RadialLocator)
+ assert isinstance(ax.yaxis.get_major_locator().base, mticker.LogLocator)
diff --git a/lib/matplotlib/tests/test_preprocess_data.py b/lib/matplotlib/tests/test_preprocess_data.py
index 0684f0dbb9ae..c983d78786e1 100644
--- a/lib/matplotlib/tests/test_preprocess_data.py
+++ b/lib/matplotlib/tests/test_preprocess_data.py
@@ -267,7 +267,7 @@ class TestPlotTypes:
plotters = [Axes.scatter, Axes.bar, Axes.plot]
@pytest.mark.parametrize('plotter', plotters)
- @check_figures_equal(extensions=['png'])
+ @check_figures_equal()
def test_dict_unpack(self, plotter, fig_test, fig_ref):
x = [1, 2, 3]
y = [4, 5, 6]
@@ -278,7 +278,7 @@ def test_dict_unpack(self, plotter, fig_test, fig_ref):
plotter(fig_ref.subplots(), x, y)
@pytest.mark.parametrize('plotter', plotters)
- @check_figures_equal(extensions=['png'])
+ @check_figures_equal()
def test_data_kwarg(self, plotter, fig_test, fig_ref):
x = [1, 2, 3]
y = [4, 5, 6]
diff --git a/lib/matplotlib/tests/test_pyplot.py b/lib/matplotlib/tests/test_pyplot.py
index a077aede8f8b..55f7c33cb52e 100644
--- a/lib/matplotlib/tests/test_pyplot.py
+++ b/lib/matplotlib/tests/test_pyplot.py
@@ -1,4 +1,5 @@
import difflib
+import inspect
import numpy as np
import sys
@@ -163,8 +164,9 @@ def test_close():
try:
plt.close(1.1)
except TypeError as e:
- assert str(e) == "close() argument must be a Figure, an int, " \
- "a string, or None, not
"
+ assert str(e) == (
+ "'fig' must be an instance of matplotlib.figure.Figure, int, str "
+ "or None, not a float")
def test_subplot_reuse():
@@ -380,7 +382,7 @@ def extract_documented_functions(lines):
:nosignatures:
plot
- plot_date
+ errorbar
"""
functions = []
@@ -439,9 +441,8 @@ def test_switch_backend_no_close():
assert len(plt.get_fignums()) == 2
plt.switch_backend('agg')
assert len(plt.get_fignums()) == 2
- with pytest.warns(mpl.MatplotlibDeprecationWarning):
- plt.switch_backend('svg')
- assert len(plt.get_fignums()) == 0
+ plt.switch_backend('svg')
+ assert len(plt.get_fignums()) == 2
def figure_hook_example(figure):
@@ -457,3 +458,53 @@ def test_figure_hook():
fig = plt.figure()
assert fig._test_was_here
+
+
+def test_multiple_same_figure_calls():
+ fig = plt.figure(1, figsize=(1, 2))
+ with pytest.warns(UserWarning, match="Ignoring specified arguments in this call"):
+ fig2 = plt.figure(1, figsize=np.array([3, 4]))
+ with pytest.warns(UserWarning, match="Ignoring specified arguments in this call"):
+ plt.figure(fig, figsize=np.array([5, 6]))
+ assert fig is fig2
+ fig3 = plt.figure(1) # Checks for false warnings
+ assert fig is fig3
+
+
+def test_close_all_warning():
+ fig1 = plt.figure()
+
+ # Check that the warning is issued when 'all' is passed to plt.figure
+ with pytest.warns(UserWarning, match="closes all existing figures"):
+ fig2 = plt.figure("all")
+
+
+def test_matshow():
+ fig = plt.figure()
+ arr = [[0, 1], [1, 2]]
+
+ # Smoke test that matshow does not ask for a new figsize on the existing figure
+ plt.matshow(arr, fignum=fig.number)
+
+
+def assert_same_signature(func1, func2):
+ """
+ Assert that `func1` and `func2` have the same arguments,
+ i.e. same parameter count, names and kinds.
+
+ :param func1: First function to check
+ :param func2: Second function to check
+ """
+ params1 = inspect.signature(func1).parameters
+ params2 = inspect.signature(func2).parameters
+
+ assert len(params1) == len(params2)
+ assert all([
+ params1[p].name == params2[p].name and
+ params1[p].kind == params2[p].kind
+ for p in params1
+ ])
+
+
+def test_setloglevel_signature():
+ assert_same_signature(plt.set_loglevel, mpl.set_loglevel)
diff --git a/lib/matplotlib/tests/test_quiver.py b/lib/matplotlib/tests/test_quiver.py
index 7c5a9d343530..1205487cfe94 100644
--- a/lib/matplotlib/tests/test_quiver.py
+++ b/lib/matplotlib/tests/test_quiver.py
@@ -6,6 +6,7 @@
from matplotlib import pyplot as plt
from matplotlib.testing.decorators import image_comparison
+from matplotlib.testing.decorators import check_figures_equal
def draw_quiver(ax, **kwargs):
@@ -25,11 +26,12 @@ def test_quiver_memory_leak():
Q = draw_quiver(ax)
ttX = Q.X
+ orig_refcount = sys.getrefcount(ttX)
Q.remove()
del Q
- assert sys.getrefcount(ttX) == 2
+ assert sys.getrefcount(ttX) < orig_refcount
@pytest.mark.skipif(platform.python_implementation() != 'CPython',
@@ -42,9 +44,9 @@ def test_quiver_key_memory_leak():
qk = ax.quiverkey(Q, 0.5, 0.92, 2, r'$2 \frac{m}{s}$',
labelpos='W',
fontproperties={'weight': 'bold'})
- assert sys.getrefcount(qk) == 3
+ orig_refcount = sys.getrefcount(qk)
qk.remove()
- assert sys.getrefcount(qk) == 2
+ assert sys.getrefcount(qk) < orig_refcount
def test_quiver_number_of_args():
@@ -333,3 +335,53 @@ def test_quiver_setuvc_numbers():
q = ax.quiver(X, Y, U, V)
q.set_UVC(0, 1)
+
+
+def draw_quiverkey_zorder_argument(fig, zorder=None):
+ """Draw Quiver and QuiverKey using zorder argument"""
+ x = np.arange(1, 6, 1)
+ y = np.arange(1, 6, 1)
+ X, Y = np.meshgrid(x, y)
+ U, V = 2, 2
+
+ ax = fig.subplots()
+ q = ax.quiver(X, Y, U, V, pivot='middle')
+ ax.set_xlim(0.5, 5.5)
+ ax.set_ylim(0.5, 5.5)
+ if zorder is None:
+ ax.quiverkey(q, 4, 4, 25, coordinates='data',
+ label='U', color='blue')
+ ax.quiverkey(q, 5.5, 2, 20, coordinates='data',
+ label='V', color='blue', angle=90)
+ else:
+ ax.quiverkey(q, 4, 4, 25, coordinates='data',
+ label='U', color='blue', zorder=zorder)
+ ax.quiverkey(q, 5.5, 2, 20, coordinates='data',
+ label='V', color='blue', angle=90, zorder=zorder)
+
+
+def draw_quiverkey_setzorder(fig, zorder=None):
+ """Draw Quiver and QuiverKey using set_zorder"""
+ x = np.arange(1, 6, 1)
+ y = np.arange(1, 6, 1)
+ X, Y = np.meshgrid(x, y)
+ U, V = 2, 2
+
+ ax = fig.subplots()
+ q = ax.quiver(X, Y, U, V, pivot='middle')
+ ax.set_xlim(0.5, 5.5)
+ ax.set_ylim(0.5, 5.5)
+ qk1 = ax.quiverkey(q, 4, 4, 25, coordinates='data',
+ label='U', color='blue')
+ qk2 = ax.quiverkey(q, 5.5, 2, 20, coordinates='data',
+ label='V', color='blue', angle=90)
+ if zorder is not None:
+ qk1.set_zorder(zorder)
+ qk2.set_zorder(zorder)
+
+
+@pytest.mark.parametrize('zorder', [0, 2, 5, None])
+@check_figures_equal()
+def test_quiverkey_zorder(fig_test, fig_ref, zorder):
+ draw_quiverkey_zorder_argument(fig_test, zorder=zorder)
+ draw_quiverkey_setzorder(fig_ref, zorder=zorder)
diff --git a/lib/matplotlib/tests/test_rcparams.py b/lib/matplotlib/tests/test_rcparams.py
index 4823df0ce250..2235f98b720f 100644
--- a/lib/matplotlib/tests/test_rcparams.py
+++ b/lib/matplotlib/tests/test_rcparams.py
@@ -5,6 +5,7 @@
from unittest import mock
from cycler import cycler, Cycler
+from packaging.version import parse as parse_version
import pytest
import matplotlib as mpl
@@ -256,6 +257,8 @@ def generate_validator_testcases(valid):
{'validator': validate_cycler,
'success': (('cycler("color", "rgb")',
cycler("color", 'rgb')),
+ ('cycler("color", "Dark2")',
+ cycler("color", mpl.color_sequences["Dark2"])),
(cycler('linestyle', ['-', '--']),
cycler('linestyle', ['-', '--'])),
("""(cycler("color", ["r", "g", "b"]) +
@@ -454,6 +457,12 @@ def test_validator_invalid(validator, arg, exception_type):
validator(arg)
+def test_validate_cycler_bad_color_string():
+ msg = "'foo' is neither a color sequence name nor can it be interpreted as a list"
+ with pytest.raises(ValueError, match=msg):
+ validate_cycler("cycler('color', 'foo')")
+
+
@pytest.mark.parametrize('weight, parsed_weight', [
('bold', 'bold'),
('BOLD', ValueError), # weight is case-sensitive
@@ -520,10 +529,11 @@ def test_rcparams_reset_after_fail():
@pytest.mark.skipif(sys.platform != "linux", reason="Linux only")
-def test_backend_fallback_headless(tmp_path):
+def test_backend_fallback_headless_invalid_backend(tmp_path):
env = {**os.environ,
"DISPLAY": "", "WAYLAND_DISPLAY": "",
"MPLBACKEND": "", "MPLCONFIGDIR": str(tmp_path)}
+ # plotting should fail with the tkagg backend selected in a headless environment
with pytest.raises(subprocess.CalledProcessError):
subprocess_run_for_testing(
[sys.executable, "-c",
@@ -535,11 +545,38 @@ def test_backend_fallback_headless(tmp_path):
env=env, check=True, stderr=subprocess.DEVNULL)
+@pytest.mark.skipif(sys.platform != "linux", reason="Linux only")
+def test_backend_fallback_headless_auto_backend(tmp_path):
+ # specify a headless mpl environment, but request a graphical (tk) backend
+ env = {**os.environ,
+ "DISPLAY": "", "WAYLAND_DISPLAY": "",
+ "MPLBACKEND": "TkAgg", "MPLCONFIGDIR": str(tmp_path)}
+
+ # allow fallback to an available interactive backend explicitly in configuration
+ rc_path = tmp_path / "matplotlibrc"
+ rc_path.write_text("backend_fallback: true")
+
+ # plotting should succeed, by falling back to use the generic agg backend
+ backend = subprocess_run_for_testing(
+ [sys.executable, "-c",
+ "import matplotlib.pyplot;"
+ "matplotlib.pyplot.plot(42);"
+ "print(matplotlib.get_backend());"
+ ],
+ env=env, text=True, check=True, capture_output=True).stdout
+ assert backend.strip().lower() == "agg"
+
+
@pytest.mark.skipif(
- sys.platform == "linux" and not _c_internal_utils.display_is_valid(),
+ sys.platform == "linux" and not _c_internal_utils.xdisplay_is_valid(),
reason="headless")
def test_backend_fallback_headful(tmp_path):
- pytest.importorskip("tkinter")
+ if parse_version(pytest.__version__) >= parse_version('8.2.0'):
+ pytest_kwargs = dict(exc_type=ImportError)
+ else:
+ pytest_kwargs = {}
+
+ pytest.importorskip("tkinter", **pytest_kwargs)
env = {**os.environ, "MPLBACKEND": "", "MPLCONFIGDIR": str(tmp_path)}
backend = subprocess_run_for_testing(
[sys.executable, "-c",
@@ -548,6 +585,7 @@ def test_backend_fallback_headful(tmp_path):
# Check that access on another instance does not resolve the sentinel.
"assert mpl.RcParams({'backend': sentinel})['backend'] == sentinel; "
"assert mpl.rcParams._get('backend') == sentinel; "
+ "assert mpl.get_backend(auto_select=False) is None; "
"import matplotlib.pyplot; "
"print(matplotlib.get_backend())"],
env=env, text=True, check=True, capture_output=True).stdout
@@ -557,40 +595,6 @@ def test_backend_fallback_headful(tmp_path):
def test_deprecation(monkeypatch):
- monkeypatch.setitem(
- mpl._deprecated_map, "patch.linewidth",
- ("0.0", "axes.linewidth", lambda old: 2 * old, lambda new: new / 2))
- with pytest.warns(mpl.MatplotlibDeprecationWarning):
- assert mpl.rcParams["patch.linewidth"] \
- == mpl.rcParams["axes.linewidth"] / 2
- with pytest.warns(mpl.MatplotlibDeprecationWarning):
- mpl.rcParams["patch.linewidth"] = 1
- assert mpl.rcParams["axes.linewidth"] == 2
-
- monkeypatch.setitem(
- mpl._deprecated_ignore_map, "patch.edgecolor",
- ("0.0", "axes.edgecolor"))
- with pytest.warns(mpl.MatplotlibDeprecationWarning):
- assert mpl.rcParams["patch.edgecolor"] \
- == mpl.rcParams["axes.edgecolor"]
- with pytest.warns(mpl.MatplotlibDeprecationWarning):
- mpl.rcParams["patch.edgecolor"] = "#abcd"
- assert mpl.rcParams["axes.edgecolor"] != "#abcd"
-
- monkeypatch.setitem(
- mpl._deprecated_ignore_map, "patch.force_edgecolor",
- ("0.0", None))
- with pytest.warns(mpl.MatplotlibDeprecationWarning):
- assert mpl.rcParams["patch.force_edgecolor"] is None
-
- monkeypatch.setitem(
- mpl._deprecated_remain_as_none, "svg.hashsalt",
- ("0.0",))
- with pytest.warns(mpl.MatplotlibDeprecationWarning):
- mpl.rcParams["svg.hashsalt"] = "foobar"
- assert mpl.rcParams["svg.hashsalt"] == "foobar" # Doesn't warn.
- mpl.rcParams["svg.hashsalt"] = None # Doesn't warn.
-
mpl.rcParams.update(mpl.rcParams.copy()) # Doesn't warn.
# Note that the warning suppression actually arises from the
# iteration over the updater rcParams being protected by
@@ -650,3 +654,21 @@ def test_rcparams_path_sketch_from_file(tmp_path, value):
rc_path.write_text(f"path.sketch: {value}")
with mpl.rc_context(fname=rc_path):
assert mpl.rcParams["path.sketch"] == (1, 2, 3)
+
+
+@pytest.mark.parametrize('group, option, alias, value', [
+ ('lines', 'linewidth', 'lw', 3),
+ ('lines', 'linestyle', 'ls', 'dashed'),
+ ('lines', 'color', 'c', 'white'),
+ ('axes', 'facecolor', 'fc', 'black'),
+ ('figure', 'edgecolor', 'ec', 'magenta'),
+ ('lines', 'markeredgewidth', 'mew', 1.5),
+ ('patch', 'antialiased', 'aa', False),
+ ('font', 'sans-serif', 'sans', ["Verdana"])
+])
+def test_rc_aliases(group, option, alias, value):
+ rc_kwargs = {alias: value,}
+ mpl.rc(group, **rc_kwargs)
+
+ rcParams_key = f"{group}.{option}"
+ assert mpl.rcParams[rcParams_key] == value
diff --git a/lib/matplotlib/tests/test_sankey.py b/lib/matplotlib/tests/test_sankey.py
index cbb7f516a65c..253bfa4fa093 100644
--- a/lib/matplotlib/tests/test_sankey.py
+++ b/lib/matplotlib/tests/test_sankey.py
@@ -91,7 +91,7 @@ def test_sankey2():
(0.75, -0.8599479)])
-@check_figures_equal(extensions=['png'])
+@check_figures_equal()
def test_sankey3(fig_test, fig_ref):
ax_test = fig_test.gca()
s_test = Sankey(ax=ax_test, flows=[0.25, -0.25, -0.25, 0.25, 0.5, -0.5],
diff --git a/lib/matplotlib/tests/test_scale.py b/lib/matplotlib/tests/test_scale.py
index 727397367762..f98e083d84a0 100644
--- a/lib/matplotlib/tests/test_scale.py
+++ b/lib/matplotlib/tests/test_scale.py
@@ -6,8 +6,12 @@
LogTransform, InvertedLogTransform,
SymmetricalLogTransform)
import matplotlib.scale as mscale
-from matplotlib.ticker import AsinhLocator, LogFormatterSciNotation
+from matplotlib.ticker import (
+ AsinhLocator, AutoLocator, LogFormatterSciNotation,
+ NullFormatter, NullLocator, ScalarFormatter
+)
from matplotlib.testing.decorators import check_figures_equal, image_comparison
+from matplotlib.transforms import IdentityTransform
import numpy as np
from numpy.testing import assert_allclose
@@ -107,7 +111,8 @@ def test_logscale_mask():
fig, ax = plt.subplots()
ax.plot(np.exp(-xs**2))
fig.canvas.draw()
- ax.set(yscale="log")
+ ax.set(yscale="log",
+ yticks=10.**np.arange(-300, 0, 24)) # Backcompat tick selection.
def test_extra_kwargs_raise():
@@ -162,6 +167,7 @@ def test_logscale_nonpos_values():
ax4.set_yscale('log')
ax4.set_xscale('log')
+ ax4.set_yticks([1e-2, 1, 1e+2]) # Backcompat tick selection.
def test_invalid_log_lims():
@@ -293,3 +299,75 @@ def test_bad_scale(self):
AsinhScale(axis=None, linear_width=-1)
s0 = AsinhScale(axis=None, )
s1 = AsinhScale(axis=None, linear_width=3.0)
+
+
+def test_custom_scale_without_axis():
+ """
+ Test that one can register and use custom scales that don't take an *axis* param.
+ """
+ class CustomTransform(IdentityTransform):
+ pass
+
+ class CustomScale(mscale.ScaleBase):
+ name = "custom"
+
+ # Important: __init__ has no *axis* parameter
+ def __init__(self):
+ self._transform = CustomTransform()
+
+ def get_transform(self):
+ return self._transform
+
+ def set_default_locators_and_formatters(self, axis):
+ axis.set_major_locator(AutoLocator())
+ axis.set_major_formatter(ScalarFormatter())
+ axis.set_minor_locator(NullLocator())
+ axis.set_minor_formatter(NullFormatter())
+
+ try:
+ mscale.register_scale(CustomScale)
+ fig, ax = plt.subplots()
+ ax.set_xscale('custom')
+ assert isinstance(ax.xaxis.get_transform(), CustomTransform)
+ finally:
+ # cleanup - there's no public unregister_scale()
+ del mscale._scale_mapping["custom"]
+ del mscale._scale_has_axis_parameter["custom"]
+
+
+def test_custom_scale_with_axis():
+ """
+ Test that one can still register and use custom scales with an *axis*
+ parameter, but that registering issues a pending-deprecation warning.
+ """
+ class CustomTransform(IdentityTransform):
+ pass
+
+ class CustomScale(mscale.ScaleBase):
+ name = "custom"
+
+ # Important: __init__ still has the *axis* parameter
+ def __init__(self, axis):
+ self._transform = CustomTransform()
+
+ def get_transform(self):
+ return self._transform
+
+ def set_default_locators_and_formatters(self, axis):
+ axis.set_major_locator(AutoLocator())
+ axis.set_major_formatter(ScalarFormatter())
+ axis.set_minor_locator(NullLocator())
+ axis.set_minor_formatter(NullFormatter())
+
+ try:
+ with pytest.warns(
+ PendingDeprecationWarning,
+ match=r"'axis' parameter .* is pending-deprecated"):
+ mscale.register_scale(CustomScale)
+ fig, ax = plt.subplots()
+ ax.set_xscale('custom')
+ assert isinstance(ax.xaxis.get_transform(), CustomTransform)
+ finally:
+ # cleanup - there's no public unregister_scale()
+ del mscale._scale_mapping["custom"]
+ del mscale._scale_has_axis_parameter["custom"]
diff --git a/lib/matplotlib/tests/test_simplification.py b/lib/matplotlib/tests/test_simplification.py
index 0a5c215eff30..41d01addd622 100644
--- a/lib/matplotlib/tests/test_simplification.py
+++ b/lib/matplotlib/tests/test_simplification.py
@@ -25,11 +25,11 @@ def test_clipping():
fig, ax = plt.subplots()
ax.plot(t, s, linewidth=1.0)
- ax.set_ylim((-0.20, -0.28))
+ ax.set_ylim(-0.20, -0.28)
@image_comparison(['overflow'], remove_text=True,
- tol=0.007 if platform.machine() == 'arm64' else 0)
+ tol=0 if platform.machine() == 'x86_64' else 0.007)
def test_overflow():
x = np.array([1.0, 2.0, 3.0, 2.0e5])
y = np.arange(len(x))
@@ -244,11 +244,11 @@ def test_simplify_curve():
fig, ax = plt.subplots()
ax.add_patch(pp1)
- ax.set_xlim((0, 2))
- ax.set_ylim((0, 2))
+ ax.set_xlim(0, 2)
+ ax.set_ylim(0, 2)
-@check_figures_equal()
+@check_figures_equal(extensions=['png', 'pdf', 'svg'])
def test_closed_path_nan_removal(fig_test, fig_ref):
ax_test = fig_test.subplots(2, 2).flatten()
ax_ref = fig_ref.subplots(2, 2).flatten()
@@ -356,7 +356,7 @@ def test_closed_path_nan_removal(fig_test, fig_ref):
remove_ticks_and_titles(fig_ref)
-@check_figures_equal()
+@check_figures_equal(extensions=['png', 'pdf', 'svg'])
def test_closed_path_clipping(fig_test, fig_ref):
vertices = []
for roll in range(8):
@@ -401,8 +401,8 @@ def test_closed_path_clipping(fig_test, fig_ref):
def test_hatch():
fig, ax = plt.subplots()
ax.add_patch(plt.Rectangle((0, 0), 1, 1, fill=False, hatch="/"))
- ax.set_xlim((0.45, 0.55))
- ax.set_ylim((0.45, 0.55))
+ ax.set_xlim(0.45, 0.55)
+ ax.set_ylim(0.45, 0.55)
@image_comparison(['fft_peaks'], remove_text=True)
@@ -518,3 +518,54 @@ def test_clipping_full():
simplified = list(p.iter_segments(clip=[0, 0, 100, 100]))
assert ([(list(x), y) for x, y in simplified] ==
[([50, 40], 1)])
+
+
+def test_simplify_closepoly():
+ # The values of the vertices in a CLOSEPOLY should always be ignored,
+ # in favor of the most recent MOVETO's vertex values
+ paths = [Path([(1, 1), (2, 1), (2, 2), (np.nan, np.nan)],
+ [Path.MOVETO, Path.LINETO, Path.LINETO, Path.CLOSEPOLY]),
+ Path([(1, 1), (2, 1), (2, 2), (40, 50)],
+ [Path.MOVETO, Path.LINETO, Path.LINETO, Path.CLOSEPOLY])]
+ expected_path = Path([(1, 1), (2, 1), (2, 2), (1, 1), (1, 1), (0, 0)],
+ [Path.MOVETO, Path.LINETO, Path.LINETO, Path.LINETO,
+ Path.LINETO, Path.STOP])
+
+ for path in paths:
+ simplified_path = path.cleaned(simplify=True)
+ assert_array_equal(expected_path.vertices, simplified_path.vertices)
+ assert_array_equal(expected_path.codes, simplified_path.codes)
+
+ # test that a compound path also works
+ path = Path([(1, 1), (2, 1), (2, 2), (np.nan, np.nan),
+ (-1, 0), (-2, 0), (-2, 1), (np.nan, np.nan)],
+ [Path.MOVETO, Path.LINETO, Path.LINETO, Path.CLOSEPOLY,
+ Path.MOVETO, Path.LINETO, Path.LINETO, Path.CLOSEPOLY])
+ expected_path = Path([(1, 1), (2, 1), (2, 2), (1, 1),
+ (-1, 0), (-2, 0), (-2, 1), (-1, 0), (-1, 0), (0, 0)],
+ [Path.MOVETO, Path.LINETO, Path.LINETO, Path.LINETO,
+ Path.MOVETO, Path.LINETO, Path.LINETO, Path.LINETO,
+ Path.LINETO, Path.STOP])
+
+ simplified_path = path.cleaned(simplify=True)
+ assert_array_equal(expected_path.vertices, simplified_path.vertices)
+ assert_array_equal(expected_path.codes, simplified_path.codes)
+
+ # test for a path with an invalid MOVETO
+ # CLOSEPOLY with an invalid MOVETO should be ignored
+ path = Path([(1, 0), (1, -1), (2, -1),
+ (np.nan, np.nan), (-1, -1), (-2, 1), (-1, 1),
+ (2, 2), (0, -1)],
+ [Path.MOVETO, Path.LINETO, Path.LINETO,
+ Path.MOVETO, Path.LINETO, Path.LINETO, Path.LINETO,
+ Path.CLOSEPOLY, Path.LINETO])
+ expected_path = Path([(1, 0), (1, -1), (2, -1),
+ (np.nan, np.nan), (-1, -1), (-2, 1), (-1, 1),
+ (0, -1), (0, -1), (0, 0)],
+ [Path.MOVETO, Path.LINETO, Path.LINETO,
+ Path.MOVETO, Path.LINETO, Path.LINETO, Path.LINETO,
+ Path.LINETO, Path.LINETO, Path.STOP])
+
+ simplified_path = path.cleaned(simplify=True)
+ assert_array_equal(expected_path.vertices, simplified_path.vertices)
+ assert_array_equal(expected_path.codes, simplified_path.codes)
diff --git a/lib/matplotlib/tests/test_skew.py b/lib/matplotlib/tests/test_skew.py
index fd7e7cebfacb..8527e474fa21 100644
--- a/lib/matplotlib/tests/test_skew.py
+++ b/lib/matplotlib/tests/test_skew.py
@@ -133,7 +133,7 @@ def upper_xlim(self):
register_projection(SkewXAxes)
-@image_comparison(['skew_axes'], remove_text=True)
+@image_comparison(['skew_axes.png'], remove_text=True)
def test_set_line_coll_dash_image():
fig = plt.figure()
ax = fig.add_subplot(1, 1, 1, projection='skewx')
@@ -145,8 +145,8 @@ def test_set_line_coll_dash_image():
ax.axvline(0, color='b')
-@image_comparison(['skew_rects'], remove_text=True,
- tol=0.009 if platform.machine() == 'arm64' else 0)
+@image_comparison(['skew_rects.png'], remove_text=True,
+ tol=0 if platform.machine() == 'x86_64' else 0.009)
def test_skew_rectangle():
fix, axes = plt.subplots(5, 5, sharex=True, sharey=True, figsize=(8, 8))
diff --git a/lib/matplotlib/tests/test_sphinxext.py b/lib/matplotlib/tests/test_sphinxext.py
index 6624e3b17ba5..ede3166a2e1b 100644
--- a/lib/matplotlib/tests/test_sphinxext.py
+++ b/lib/matplotlib/tests/test_sphinxext.py
@@ -10,8 +10,10 @@
import pytest
-pytest.importorskip('sphinx',
- minversion=None if sys.version_info < (3, 10) else '4.1.3')
+pytest.importorskip('sphinx', minversion='4.1.3')
+
+
+tinypages = Path(__file__).parent / 'data/tinypages'
def build_sphinx_html(source_dir, doctree_dir, html_dir, extra_args=None):
@@ -19,9 +21,13 @@ def build_sphinx_html(source_dir, doctree_dir, html_dir, extra_args=None):
extra_args = [] if extra_args is None else extra_args
cmd = [sys.executable, '-msphinx', '-W', '-b', 'html',
'-d', str(doctree_dir), str(source_dir), str(html_dir), *extra_args]
+ # On CI, gcov emits warnings (due to agg headers being included with the
+ # same name in multiple extension modules -- but we don't care about their
+ # coverage anyways); hide them using GCOV_ERROR_FILE.
proc = subprocess_run_for_testing(
cmd, capture_output=True, text=True,
- env={**os.environ, "MPLBACKEND": ""})
+ env={**os.environ, "MPLBACKEND": "", "GCOV_ERROR_FILE": os.devnull}
+ )
out = proc.stdout
err = proc.stderr
@@ -34,24 +40,12 @@ def build_sphinx_html(source_dir, doctree_dir, html_dir, extra_args=None):
def test_tinypages(tmp_path):
- shutil.copytree(Path(__file__).parent / 'tinypages', tmp_path,
- dirs_exist_ok=True)
+ shutil.copytree(tinypages, tmp_path, dirs_exist_ok=True,
+ ignore=shutil.ignore_patterns('_build', 'doctrees',
+ 'plot_directive'))
html_dir = tmp_path / '_build' / 'html'
img_dir = html_dir / '_images'
doctree_dir = tmp_path / 'doctrees'
- # Build the pages with warnings turned into errors
- cmd = [sys.executable, '-msphinx', '-W', '-b', 'html',
- '-d', str(doctree_dir),
- str(Path(__file__).parent / 'tinypages'), str(html_dir)]
- # On CI, gcov emits warnings (due to agg headers being included with the
- # same name in multiple extension modules -- but we don't care about their
- # coverage anyways); hide them using GCOV_ERROR_FILE.
- proc = subprocess_run_for_testing(
- cmd, capture_output=True, text=True,
- env={**os.environ, "MPLBACKEND": "", "GCOV_ERROR_FILE": os.devnull}
- )
- out = proc.stdout
- err = proc.stderr
# Build the pages with warnings turned into errors
build_sphinx_html(tmp_path, doctree_dir, html_dir)
@@ -63,7 +57,7 @@ def plot_directive_file(num):
# This is always next to the doctree dir.
return doctree_dir.parent / 'plot_directive' / f'some_plots-{num}.png'
- range_10, range_6, range_4 = [plot_file(i) for i in range(1, 4)]
+ range_10, range_6, range_4 = (plot_file(i) for i in range(1, 4))
# Plot 5 is range(6) plot
assert filecmp.cmp(range_6, plot_file(5))
# Plot 7 is range(4) plot
@@ -76,27 +70,35 @@ def plot_directive_file(num):
# Plot 13 shows close-figs in action
assert filecmp.cmp(range_4, plot_file(13))
# Plot 14 has included source
- html_contents = (html_dir / 'some_plots.html').read_bytes()
+ html_contents = (html_dir / 'some_plots.html').read_text(encoding='utf-8')
- assert b'# Only a comment' in html_contents
+ assert '# Only a comment' in html_contents
# check plot defined in external file.
assert filecmp.cmp(range_4, img_dir / 'range4.png')
assert filecmp.cmp(range_6, img_dir / 'range6_range6.png')
# check if figure caption made it into html file
- assert b'This is the caption for plot 15.' in html_contents
- # check if figure caption using :caption: made it into html file
- assert b'Plot 17 uses the caption option.' in html_contents
+ assert 'This is the caption for plot 15.' in html_contents
+ # check if figure caption using :caption: made it into html file (because this plot
+ # doesn't use srcset, the caption preserves newlines in the output.)
+ assert 'Plot 17 uses the caption option,\nwith multi-line input.' in html_contents
+ # check if figure alt text using :alt: made it into html file
+ assert 'Plot 17 uses the alt option, with multi-line input.' in html_contents
# check if figure caption made it into html file
- assert b'This is the caption for plot 18.' in html_contents
+ assert 'This is the caption for plot 18.' in html_contents
# check if the custom classes made it into the html file
- assert b'plot-directive my-class my-other-class' in html_contents
+ assert 'plot-directive my-class my-other-class' in html_contents
# check that the multi-image caption is applied twice
- assert html_contents.count(b'This caption applies to both plots.') == 2
+ assert html_contents.count('This caption applies to both plots.') == 2
# Plot 21 is range(6) plot via an include directive. But because some of
# the previous plots are repeated, the argument to plot_file() is only 17.
assert filecmp.cmp(range_6, plot_file(17))
# plot 22 is from the range6.py file again, but a different function
assert filecmp.cmp(range_10, img_dir / 'range6_range10.png')
+ # plots 23--25 use a custom basename
+ assert filecmp.cmp(range_6, img_dir / 'custom-basename-6.png')
+ assert filecmp.cmp(range_4, img_dir / 'custom-basename-4.png')
+ assert filecmp.cmp(range_4, img_dir / 'custom-basename-4-6_00.png')
+ assert filecmp.cmp(range_6, img_dir / 'custom-basename-4-6_01.png')
# Modify the included plot
contents = (tmp_path / 'included_plot_21.rst').read_bytes()
@@ -123,9 +125,8 @@ def plot_directive_file(num):
def test_plot_html_show_source_link(tmp_path):
- parent = Path(__file__).parent
- shutil.copyfile(parent / 'tinypages/conf.py', tmp_path / 'conf.py')
- shutil.copytree(parent / 'tinypages/_static', tmp_path / '_static')
+ shutil.copyfile(tinypages / 'conf.py', tmp_path / 'conf.py')
+ shutil.copytree(tinypages / '_static', tmp_path / '_static')
doctree_dir = tmp_path / 'doctrees'
(tmp_path / 'index.rst').write_text("""
.. plot::
@@ -148,9 +149,8 @@ def test_plot_html_show_source_link(tmp_path):
def test_show_source_link_true(tmp_path, plot_html_show_source_link):
# Test that a source link is generated if :show-source-link: is true,
# whether or not plot_html_show_source_link is true.
- parent = Path(__file__).parent
- shutil.copyfile(parent / 'tinypages/conf.py', tmp_path / 'conf.py')
- shutil.copytree(parent / 'tinypages/_static', tmp_path / '_static')
+ shutil.copyfile(tinypages / 'conf.py', tmp_path / 'conf.py')
+ shutil.copytree(tinypages / '_static', tmp_path / '_static')
doctree_dir = tmp_path / 'doctrees'
(tmp_path / 'index.rst').write_text("""
.. plot::
@@ -168,9 +168,8 @@ def test_show_source_link_true(tmp_path, plot_html_show_source_link):
def test_show_source_link_false(tmp_path, plot_html_show_source_link):
# Test that a source link is NOT generated if :show-source-link: is false,
# whether or not plot_html_show_source_link is true.
- parent = Path(__file__).parent
- shutil.copyfile(parent / 'tinypages/conf.py', tmp_path / 'conf.py')
- shutil.copytree(parent / 'tinypages/_static', tmp_path / '_static')
+ shutil.copyfile(tinypages / 'conf.py', tmp_path / 'conf.py')
+ shutil.copytree(tinypages / '_static', tmp_path / '_static')
doctree_dir = tmp_path / 'doctrees'
(tmp_path / 'index.rst').write_text("""
.. plot::
@@ -184,15 +183,38 @@ def test_show_source_link_false(tmp_path, plot_html_show_source_link):
assert len(list(html_dir.glob("**/index-1.py"))) == 0
+def test_plot_html_show_source_link_custom_basename(tmp_path):
+ # Test that source link filename includes .py extension when using custom basename
+ shutil.copyfile(tinypages / 'conf.py', tmp_path / 'conf.py')
+ shutil.copytree(tinypages / '_static', tmp_path / '_static')
+ doctree_dir = tmp_path / 'doctrees'
+ (tmp_path / 'index.rst').write_text("""
+.. plot::
+ :filename-prefix: custom-name
+
+ plt.plot(range(2))
+""")
+ html_dir = tmp_path / '_build' / 'html'
+ build_sphinx_html(tmp_path, doctree_dir, html_dir)
+
+ # Check that source file with .py extension is generated
+ assert len(list(html_dir.glob("**/custom-name.py"))) == 1
+
+ # Check that the HTML contains the correct link with .py extension
+ html_content = (html_dir / 'index.html').read_text()
+ assert 'custom-name.py' in html_content
+
+
def test_srcset_version(tmp_path):
- shutil.copytree(Path(__file__).parent / 'tinypages', tmp_path,
- dirs_exist_ok=True)
+ shutil.copytree(tinypages, tmp_path, dirs_exist_ok=True,
+ ignore=shutil.ignore_patterns('_build', 'doctrees',
+ 'plot_directive'))
html_dir = tmp_path / '_build' / 'html'
img_dir = html_dir / '_images'
doctree_dir = tmp_path / 'doctrees'
- build_sphinx_html(tmp_path, doctree_dir, html_dir, extra_args=[
- '-D', 'plot_srcset=2x'])
+ build_sphinx_html(tmp_path, doctree_dir, html_dir,
+ extra_args=['-D', 'plot_srcset=2x'])
def plot_file(num, suff=''):
return img_dir / f'some_plots-{num}{suff}.png'
diff --git a/lib/matplotlib/tests/test_spines.py b/lib/matplotlib/tests/test_spines.py
index 9ce16fb39227..5aecf6c2ad55 100644
--- a/lib/matplotlib/tests/test_spines.py
+++ b/lib/matplotlib/tests/test_spines.py
@@ -55,7 +55,7 @@ def set_val(self, val):
spines['top':]
-@image_comparison(['spines_axes_positions'])
+@image_comparison(['spines_axes_positions.png'])
def test_spines_axes_positions():
# SF bug 2852168
fig = plt.figure()
@@ -72,7 +72,7 @@ def test_spines_axes_positions():
ax.spines.bottom.set_color('none')
-@image_comparison(['spines_data_positions'])
+@image_comparison(['spines_data_positions.png'])
def test_spines_data_positions():
fig, ax = plt.subplots()
ax.spines.left.set_position(('data', -1.5))
@@ -83,7 +83,7 @@ def test_spines_data_positions():
ax.set_ylim([-2, 2])
-@check_figures_equal(extensions=["png"])
+@check_figures_equal()
def test_spine_nonlinear_data_positions(fig_test, fig_ref):
plt.style.use("default")
@@ -104,7 +104,7 @@ def test_spine_nonlinear_data_positions(fig_test, fig_ref):
ax.tick_params(axis="y", labelleft=False, left=False, right=True)
-@image_comparison(['spines_capstyle'])
+@image_comparison(['spines_capstyle.png'])
def test_spines_capstyle():
# issue 2542
plt.rc('axes', linewidth=20)
@@ -142,7 +142,7 @@ def test_label_without_ticks():
"X-Axis label not below the spine"
-@image_comparison(['black_axes'])
+@image_comparison(['black_axes.png'])
def test_spines_black_axes():
# GitHub #18804
plt.rcParams["savefig.pad_inches"] = 0
@@ -154,3 +154,44 @@ def test_spines_black_axes():
ax.set_xticks([])
ax.set_yticks([])
ax.set_facecolor((0, 0, 0))
+
+
+def test_arc_spine_inner_no_axis():
+ # Backcompat: smoke test that inner arc spine does not need a registered
+ # axis in order to be drawn
+ fig = plt.figure()
+ ax = fig.add_subplot(projection="polar")
+ inner_spine = ax.spines["inner"]
+ inner_spine.register_axis(None)
+ assert ax.spines["inner"].axis is None
+
+ fig.draw_without_rendering()
+
+
+def test_spine_set_bounds_with_none():
+ """Test that set_bounds(None, ...) uses original axis view limits."""
+ fig, ax = plt.subplots()
+
+ # Plot some data to set axis limits
+ x = np.linspace(0, 10, 100)
+ y = np.sin(x)
+ ax.plot(x, y)
+
+ xlim = ax.viewLim.intervalx
+ ylim = ax.viewLim.intervaly
+ # Use modified set_bounds with None
+ ax.spines['bottom'].set_bounds(2, None)
+ ax.spines['left'].set_bounds(None, None)
+
+ # Check that get_bounds returns correct numeric values
+ bottom_bound = ax.spines['bottom'].get_bounds()
+ assert bottom_bound[1] is not None, "Higher bound should be numeric"
+ assert np.isclose(bottom_bound[0], 2), "Lower bound should match provided value"
+ assert np.isclose(bottom_bound[1],
+ xlim[1]), "Upper bound should match original value"
+
+ left_bound = ax.spines['left'].get_bounds()
+ assert (left_bound[0] is not None) and (left_bound[1] is not None), \
+ "left bound should be numeric"
+ assert np.isclose(left_bound[0], ylim[0]), "Lower bound should match original value"
+ assert np.isclose(left_bound[1], ylim[1]), "Upper bound should match original value"
diff --git a/lib/matplotlib/tests/test_streamplot.py b/lib/matplotlib/tests/test_streamplot.py
index ed8b77d7996d..697ee527f253 100644
--- a/lib/matplotlib/tests/test_streamplot.py
+++ b/lib/matplotlib/tests/test_streamplot.py
@@ -23,37 +23,38 @@ def swirl_velocity_field():
return x, y, U, V
-@image_comparison(['streamplot_startpoints'], remove_text=True, style='mpl20',
- extensions=['png'])
+@image_comparison(['streamplot_startpoints.png'], remove_text=True, style='mpl20',
+ tol=0.003)
def test_startpoints():
+ # Test varying startpoints. Also tests a non-default num_arrows argument.
X, Y, U, V = velocity_field()
start_x, start_y = np.meshgrid(np.linspace(X.min(), X.max(), 5),
np.linspace(Y.min(), Y.max(), 5))
start_points = np.column_stack([start_x.ravel(), start_y.ravel()])
- plt.streamplot(X, Y, U, V, start_points=start_points)
+ plt.streamplot(X, Y, U, V, start_points=start_points, num_arrows=4)
plt.plot(start_x, start_y, 'ok')
-@image_comparison(['streamplot_colormap'], remove_text=True, style='mpl20',
+@image_comparison(['streamplot_colormap.png'], remove_text=True, style='mpl20',
tol=0.022)
def test_colormap():
X, Y, U, V = velocity_field()
plt.streamplot(X, Y, U, V, color=U, density=0.6, linewidth=2,
- cmap=plt.cm.autumn)
+ cmap="autumn")
plt.colorbar()
-@image_comparison(['streamplot_linewidth'], remove_text=True, style='mpl20',
- tol=0.004)
+@image_comparison(['streamplot_linewidth.png'], remove_text=True, style='mpl20',
+ tol=0.03)
def test_linewidth():
X, Y, U, V = velocity_field()
speed = np.hypot(U, V)
lw = 5 * speed / speed.max()
ax = plt.figure().subplots()
- ax.streamplot(X, Y, U, V, density=[0.5, 1], color='k', linewidth=lw)
+ ax.streamplot(X, Y, U, V, density=[0.5, 1], color='k', linewidth=lw, num_arrows=2)
-@image_comparison(['streamplot_masks_and_nans'],
+@image_comparison(['streamplot_masks_and_nans.png'],
remove_text=True, style='mpl20')
def test_masks_and_nans():
X, Y, U, V = velocity_field()
@@ -63,7 +64,7 @@ def test_masks_and_nans():
U = np.ma.array(U, mask=mask)
ax = plt.figure().subplots()
with np.errstate(invalid='ignore'):
- ax.streamplot(X, Y, U, V, color=U, cmap=plt.cm.Blues)
+ ax.streamplot(X, Y, U, V, color=U, cmap="Blues")
@image_comparison(['streamplot_maxlength.png'],
@@ -99,6 +100,66 @@ def test_direction():
linewidth=2, density=2)
+@image_comparison(['streamplot_integration.png'], style='mpl20', tol=0.05)
+def test_integration_options():
+ # Linear potential flow over a lifting cylinder
+ n = 50
+ x, y = np.meshgrid(np.linspace(-2, 2, n), np.linspace(-3, 3, n))
+ th = np.arctan2(y, x)
+ r = np.sqrt(x**2 + y**2)
+ vr = -np.cos(th) / r**2
+ vt = -np.sin(th) / r**2 - 1 / r
+ vx = vr * np.cos(th) - vt * np.sin(th) + 1.0
+ vy = vr * np.sin(th) + vt * np.cos(th)
+
+ # Seed points
+ n_seed = 50
+ seed_pts = np.column_stack((np.full(n_seed, -1.75), np.linspace(-2, 2, n_seed)))
+
+ fig, axs = plt.subplots(3, 1, figsize=(6, 14))
+ th_circ = np.linspace(0, 2 * np.pi, 100)
+ for ax, max_val in zip(axs, [0.05, 1, 5]):
+ ax_ins = ax.inset_axes([0.0, 0.7, 0.3, 0.35])
+ for ax_curr, is_inset in zip([ax, ax_ins], [False, True]):
+ ax_curr.streamplot(
+ x,
+ y,
+ vx,
+ vy,
+ start_points=seed_pts,
+ broken_streamlines=False,
+ arrowsize=1e-10,
+ linewidth=2 if is_inset else 0.6,
+ color="k",
+ integration_max_step_scale=max_val,
+ integration_max_error_scale=max_val,
+ )
+
+ # Draw the cylinder
+ ax_curr.fill(
+ np.cos(th_circ),
+ np.sin(th_circ),
+ color="w",
+ ec="k",
+ lw=6 if is_inset else 2,
+ )
+
+ # Set axis properties
+ ax_curr.set_aspect("equal")
+
+ # Set axis limits and show zoomed region
+ ax_ins.set_xlim(-1.2, -0.7)
+ ax_ins.set_ylim(-0.8, -0.4)
+ ax_ins.set_yticks(())
+ ax_ins.set_xticks(())
+
+ ax.set_ylim(-1.5, 1.5)
+ ax.axis("off")
+ ax.indicate_inset_zoom(ax_ins, ec="k")
+
+ fig.tight_layout()
+
+
def test_streamplot_limits():
ax = plt.axes()
x = np.linspace(-5, 10, 20)
@@ -155,8 +216,20 @@ def test_streamplot_grid():
x = np.array([0, 20, 40])
y = np.array([0, 20, 10])
- with pytest.raises(ValueError, match="'y' must be strictly increasing"):
- plt.streamplot(x, y, u, v)
+
+def test_streamplot_integration_params():
+ x = np.array([[10, 20], [10, 20]])
+ y = np.array([[10, 10], [20, 20]])
+ u = np.ones((2, 2))
+ v = np.zeros((2, 2))
+
+ err_str = "The value of integration_max_step_scale must be > 0, got -0.5"
+ with pytest.raises(ValueError, match=err_str):
+ plt.streamplot(x, y, u, v, integration_max_step_scale=-0.5)
+
+ err_str = "The value of integration_max_error_scale must be > 0, got 0.0"
+ with pytest.raises(ValueError, match=err_str):
+ plt.streamplot(x, y, u, v, integration_max_error_scale=0.0)
def test_streamplot_inputs(): # test no exception occurs.
diff --git a/lib/matplotlib/tests/test_style.py b/lib/matplotlib/tests/test_style.py
index be038965e33d..14110209fa15 100644
--- a/lib/matplotlib/tests/test_style.py
+++ b/lib/matplotlib/tests/test_style.py
@@ -8,7 +8,6 @@
import matplotlib as mpl
from matplotlib import pyplot as plt, style
-from matplotlib.style.core import USER_LIBRARY_PATHS, STYLE_EXTENSION
PARAM = 'image.cmap'
@@ -21,7 +20,8 @@ def temp_style(style_name, settings=None):
"""Context manager to create a style sheet in a temporary directory."""
if not settings:
settings = DUMMY_SETTINGS
- temp_file = f'{style_name}.{STYLE_EXTENSION}'
+ temp_file = f'{style_name}.mplstyle'
+ orig_library_paths = style.USER_LIBRARY_PATHS
try:
with TemporaryDirectory() as tmpdir:
# Write style settings to file in the tmpdir.
@@ -29,10 +29,11 @@ def temp_style(style_name, settings=None):
"\n".join(f"{k}: {v}" for k, v in settings.items()),
encoding="utf-8")
# Add tmpdir to style path and reload so we can access this style.
- USER_LIBRARY_PATHS.append(tmpdir)
+ style.USER_LIBRARY_PATHS.append(tmpdir)
style.reload_library()
yield
finally:
+ style.USER_LIBRARY_PATHS = orig_library_paths
style.reload_library()
@@ -47,8 +48,17 @@ def test_invalid_rc_warning_includes_filename(caplog):
def test_available():
- with temp_style('_test_', DUMMY_SETTINGS):
- assert '_test_' in style.available
+ # Private name should not be listed in available but still usable.
+ assert '_classic_test_patch' not in style.available
+ assert '_classic_test_patch' in style.library
+
+ with temp_style('_test_', DUMMY_SETTINGS), temp_style('dummy', DUMMY_SETTINGS):
+ assert 'dummy' in style.available
+ assert 'dummy' in style.library
+ assert '_test_' not in style.available
+ assert '_test_' in style.library
+ assert 'dummy' not in style.available
+ assert '_test_' not in style.available
def test_use():
@@ -71,7 +81,7 @@ def test_use_url(tmp_path):
def test_single_path(tmp_path):
mpl.rcParams[PARAM] = 'gray'
- path = tmp_path / f'text.{STYLE_EXTENSION}'
+ path = tmp_path / 'text.mplstyle'
path.write_text(f'{PARAM} : {VALUE}', encoding='utf-8')
with style.context(path):
assert mpl.rcParams[PARAM] == VALUE
@@ -140,7 +150,9 @@ def test_context_with_badparam():
with style.context({PARAM: other_value}):
assert mpl.rcParams[PARAM] == other_value
x = style.context({PARAM: original_value, 'badparam': None})
- with pytest.raises(KeyError):
+ with pytest.raises(
+ KeyError, match="\'badparam\' is not a valid value for rcParam. "
+ ):
with x:
pass
assert mpl.rcParams[PARAM] == other_value
diff --git a/lib/matplotlib/tests/test_subplots.py b/lib/matplotlib/tests/test_subplots.py
index 70215c9531ba..0f00a88aa72d 100644
--- a/lib/matplotlib/tests/test_subplots.py
+++ b/lib/matplotlib/tests/test_subplots.py
@@ -4,6 +4,7 @@
import numpy as np
import pytest
+import matplotlib as mpl
from matplotlib.axes import Axes, SubplotBase
import matplotlib.pyplot as plt
from matplotlib.testing.decorators import check_figures_equal, image_comparison
@@ -111,10 +112,15 @@ def test_shared():
@pytest.mark.parametrize('remove_ticks', [True, False])
-def test_label_outer(remove_ticks):
- f, axs = plt.subplots(2, 2, sharex=True, sharey=True)
+@pytest.mark.parametrize('layout_engine', ['none', 'tight', 'constrained'])
+@pytest.mark.parametrize('with_colorbar', [True, False])
+def test_label_outer(remove_ticks, layout_engine, with_colorbar):
+ fig = plt.figure(layout=layout_engine)
+ axs = fig.subplots(2, 2, sharex=True, sharey=True)
for ax in axs.flat:
ax.set(xlabel="foo", ylabel="bar")
+ if with_colorbar:
+ fig.colorbar(mpl.cm.ScalarMappable(), ax=ax)
ax.label_outer(remove_inner_ticks=remove_ticks)
check_ticklabel_visible(
axs.flat, [False, False, True, True], [True, False, True, False])
@@ -174,8 +180,8 @@ def test_exceptions():
plt.subplots(2, 2, sharey='blah')
-@image_comparison(['subplots_offset_text'],
- tol=0.028 if platform.machine() == 'arm64' else 0)
+@image_comparison(['subplots_offset_text.png'],
+ tol=0 if platform.machine() == 'x86_64' else 0.028)
def test_subplots_offsettext():
x = np.arange(0, 1e10, 1e9)
y = np.arange(0, 100, 10)+1e4
@@ -242,7 +248,7 @@ def test_dont_mutate_kwargs():
@pytest.mark.parametrize("width_ratios", [None, [1, 3, 2]])
@pytest.mark.parametrize("height_ratios", [None, [1, 2]])
-@check_figures_equal(extensions=['png'])
+@check_figures_equal()
def test_width_and_height_ratios(fig_test, fig_ref,
height_ratios, width_ratios):
fig_test.subplots(2, 3, height_ratios=height_ratios,
@@ -254,7 +260,7 @@ def test_width_and_height_ratios(fig_test, fig_ref,
@pytest.mark.parametrize("width_ratios", [None, [1, 3, 2]])
@pytest.mark.parametrize("height_ratios", [None, [1, 2]])
-@check_figures_equal(extensions=['png'])
+@check_figures_equal()
def test_width_and_height_ratios_mosaic(fig_test, fig_ref,
height_ratios, width_ratios):
mosaic_spec = [['A', 'B', 'B'], ['A', 'C', 'D']]
diff --git a/lib/matplotlib/tests/test_table.py b/lib/matplotlib/tests/test_table.py
index ea31ac124e4a..43b8702737a6 100644
--- a/lib/matplotlib/tests/test_table.py
+++ b/lib/matplotlib/tests/test_table.py
@@ -2,10 +2,8 @@
from unittest.mock import Mock
import numpy as np
-import pytest
import matplotlib.pyplot as plt
-import matplotlib as mpl
from matplotlib.path import Path
from matplotlib.table import CustomCell, Table
from matplotlib.testing.decorators import image_comparison, check_figures_equal
@@ -57,7 +55,7 @@ def test_label_colours():
dim = 3
c = np.linspace(0, 1, dim)
- colours = plt.cm.RdYlGn(c)
+ colours = plt.colormaps["RdYlGn"](c)
cellText = [['1'] * dim] * dim
fig = plt.figure()
@@ -89,13 +87,13 @@ def test_label_colours():
loc='best')
-@image_comparison(['table_cell_manipulation.png'], remove_text=True)
-def test_diff_cell_table():
+@image_comparison(['table_cell_manipulation.png'], style='mpl20')
+def test_diff_cell_table(text_placeholders):
cells = ('horizontal', 'vertical', 'open', 'closed', 'T', 'R', 'B', 'L')
cellText = [['1'] * len(cells)] * 2
colWidths = [0.1] * len(cells)
- _, axs = plt.subplots(nrows=len(cells), figsize=(4, len(cells)+1))
+ _, axs = plt.subplots(nrows=len(cells), figsize=(4, len(cells)+1), layout='tight')
for ax, cell in zip(axs, cells):
ax.table(
colWidths=colWidths,
@@ -104,7 +102,6 @@ def test_diff_cell_table():
edges=cell,
)
ax.axis('off')
- plt.tight_layout()
def test_customcell():
@@ -128,10 +125,9 @@ def test_customcell():
@image_comparison(['table_auto_column.png'])
def test_auto_column():
- fig = plt.figure()
+ fig, (ax1, ax2, ax3, ax4) = plt.subplots(4, 1)
# iterable list input
- ax1 = fig.add_subplot(4, 1, 1)
ax1.axis('off')
tb1 = ax1.table(
cellText=[['Fit Text', 2],
@@ -144,7 +140,6 @@ def test_auto_column():
tb1.auto_set_column_width([-1, 0, 1])
# iterable tuple input
- ax2 = fig.add_subplot(4, 1, 2)
ax2.axis('off')
tb2 = ax2.table(
cellText=[['Fit Text', 2],
@@ -157,7 +152,6 @@ def test_auto_column():
tb2.auto_set_column_width((-1, 0, 1))
# 3 single inputs
- ax3 = fig.add_subplot(4, 1, 3)
ax3.axis('off')
tb3 = ax3.table(
cellText=[['Fit Text', 2],
@@ -171,8 +165,8 @@ def test_auto_column():
tb3.auto_set_column_width(0)
tb3.auto_set_column_width(1)
- # 4 non integer iterable input
- ax4 = fig.add_subplot(4, 1, 4)
+ # 4 this used to test non-integer iterable input, which did nothing, but only
+ # remains to avoid re-generating the test image.
ax4.axis('off')
tb4 = ax4.table(
cellText=[['Fit Text', 2],
@@ -182,12 +176,6 @@ def test_auto_column():
loc="center")
tb4.auto_set_font_size(False)
tb4.set_fontsize(12)
- with pytest.warns(mpl.MatplotlibDeprecationWarning,
- match="'col' must be an int or sequence of ints"):
- tb4.auto_set_column_width("-101") # type: ignore [arg-type]
- with pytest.warns(mpl.MatplotlibDeprecationWarning,
- match="'col' must be an int or sequence of ints"):
- tb4.auto_set_column_width(["-101"]) # type: ignore [list-item]
def test_table_cells():
@@ -208,7 +196,7 @@ def test_table_cells():
plt.setp(table)
-@check_figures_equal(extensions=["png"])
+@check_figures_equal()
def test_table_bbox(fig_test, fig_ref):
data = [[2, 3],
[4, 5]]
@@ -235,7 +223,7 @@ def test_table_bbox(fig_test, fig_ref):
)
-@check_figures_equal(extensions=['png'])
+@check_figures_equal()
def test_table_unit(fig_test, fig_ref):
# test that table doesn't participate in unit machinery, instead uses repr/str
@@ -264,3 +252,32 @@ def __repr__(self):
munits.registry.pop(FakeUnit)
assert not munits.registry.get_converter(FakeUnit)
+
+
+def test_table_dataframe(pd):
+ # Test if Pandas Data Frame can be passed in cellText
+
+ data = {
+ 'Letter': ['A', 'B', 'C'],
+ 'Number': [100, 200, 300]
+ }
+
+ df = pd.DataFrame(data)
+ fig, ax = plt.subplots()
+ table = ax.table(df, loc='center')
+
+ for r, (index, row) in enumerate(df.iterrows()):
+ for c, col in enumerate(df.columns if r == 0 else row.values):
+ assert table[r if r == 0 else r+1, c].get_text().get_text() == str(col)
+
+
+def test_table_fontsize():
+ # Test that the passed fontsize propagates to cells
+ tableData = [['a', 1], ['b', 2]]
+ fig, ax = plt.subplots()
+ test_fontsize = 20
+ t = ax.table(cellText=tableData, loc='top', fontsize=test_fontsize)
+ cell_fontsize = t[(0, 0)].get_fontsize()
+ assert cell_fontsize == test_fontsize, f"Actual:{test_fontsize},got:{cell_fontsize}"
+ cell_fontsize = t[(1, 1)].get_fontsize()
+ assert cell_fontsize == test_fontsize, f"Actual:{test_fontsize},got:{cell_fontsize}"
diff --git a/lib/matplotlib/tests/test_testing.py b/lib/matplotlib/tests/test_testing.py
index f13839d6b3b6..c438c54d26fa 100644
--- a/lib/matplotlib/tests/test_testing.py
+++ b/lib/matplotlib/tests/test_testing.py
@@ -14,7 +14,7 @@ def test_warn_to_fail():
@pytest.mark.parametrize("a", [1])
-@check_figures_equal(extensions=["png"])
+@check_figures_equal()
@pytest.mark.parametrize("b", [1])
def test_parametrize_with_check_figure_equal(a, fig_ref, b, fig_test):
assert a == b
diff --git a/lib/matplotlib/tests/test_text.py b/lib/matplotlib/tests/test_text.py
index 8904337f68ba..02cecea1c6c6 100644
--- a/lib/matplotlib/tests/test_text.py
+++ b/lib/matplotlib/tests/test_text.py
@@ -144,8 +144,8 @@ def test_multiline2():
fig, ax = plt.subplots()
- ax.set_xlim([0, 1.4])
- ax.set_ylim([0, 2])
+ ax.set_xlim(0, 1.4)
+ ax.set_ylim(0, 2)
ax.axhline(0.5, color='C2', linewidth=0.3)
sts = ['Line', '2 Lineg\n 2 Lg', '$\\sum_i x $', 'hi $\\sum_i x $\ntest',
'test\n $\\sum_i x $', '$\\sum_i x $\n $\\sum_i x $']
@@ -208,13 +208,6 @@ def test_antialiasing():
mpl.rcParams['text.antialiased'] = False # Should not affect existing text.
-def test_afm_kerning():
- fn = mpl.font_manager.findfont("Helvetica", fontext="afm")
- with open(fn, 'rb') as fh:
- afm = mpl._afm.AFM(fh)
- assert afm.string_width_height('VAVAVAVAVAVA') == (7174.0, 718)
-
-
@image_comparison(['text_contains.png'])
def test_contains():
fig = plt.figure()
@@ -671,7 +664,7 @@ def test_annotation_update():
rtol=1e-6)
-@check_figures_equal(extensions=["png"])
+@check_figures_equal()
def test_annotation_units(fig_test, fig_ref):
ax = fig_test.add_subplot()
ax.plot(datetime.now(), 1, "o") # Implicitly set axes extents.
@@ -761,7 +754,7 @@ def test_wrap_no_wrap():
assert text._get_wrapped_text() == 'non wrapped text'
-@check_figures_equal(extensions=["png"])
+@check_figures_equal()
def test_buffer_size(fig_test, fig_ref):
# On old versions of the Agg renderer, large non-ascii single-character
# strings (here, "€") would be rendered clipped because the rendering
@@ -921,7 +914,7 @@ def test_annotate_offset_fontsize():
fontsize='10',
xycoords='data',
textcoords=text_coords[i]) for i in range(2)]
- points_coords, fontsize_coords = [ann.get_window_extent() for ann in anns]
+ points_coords, fontsize_coords = (ann.get_window_extent() for ann in anns)
fig.canvas.draw()
assert str(points_coords) == str(fontsize_coords)
@@ -958,7 +951,7 @@ def test_annotation_antialiased():
assert annot4._antialiased == mpl.rcParams['text.antialiased']
-@check_figures_equal(extensions=["png"])
+@check_figures_equal()
def test_annotate_and_offsetfrom_copy_input(fig_test, fig_ref):
# Both approaches place the text (10, 0) pixels away from the center of the line.
ax = fig_test.add_subplot()
@@ -974,7 +967,7 @@ def test_annotate_and_offsetfrom_copy_input(fig_test, fig_ref):
an_xy[:] = 2
-@check_figures_equal()
+@check_figures_equal(extensions=['png', 'pdf', 'svg'])
def test_text_antialiased_off_default_vs_manual(fig_test, fig_ref):
fig_test.text(0.5, 0.5, '6 inches x 2 inches',
antialiased=False)
@@ -983,7 +976,7 @@ def test_text_antialiased_off_default_vs_manual(fig_test, fig_ref):
fig_ref.text(0.5, 0.5, '6 inches x 2 inches')
-@check_figures_equal()
+@check_figures_equal(extensions=['png', 'pdf', 'svg'])
def test_text_antialiased_on_default_vs_manual(fig_test, fig_ref):
fig_test.text(0.5, 0.5, '6 inches x 2 inches', antialiased=True)
@@ -1135,3 +1128,58 @@ def test_font_wrap():
plt.text(3, 4, t, family='monospace', ha='right', wrap=True)
plt.text(-1, 0, t, fontsize=14, style='italic', ha='left', rotation=-15,
wrap=True)
+
+
+def test_ha_for_angle():
+ text_instance = Text()
+ angles = np.arange(0, 360.1, 0.1)
+ for angle in angles:
+ alignment = text_instance._ha_for_angle(angle)
+ assert alignment in ['center', 'left', 'right']
+
+
+def test_va_for_angle():
+ text_instance = Text()
+ angles = np.arange(0, 360.1, 0.1)
+ for angle in angles:
+ alignment = text_instance._va_for_angle(angle)
+ assert alignment in ['center', 'top', 'baseline']
+
+
+@image_comparison(baseline_images=['xtick_rotation_mode'],
+ remove_text=False, extensions=['png'], style='mpl20')
+def test_xtick_rotation_mode():
+ fig, ax = plt.subplots(figsize=(12, 1))
+ ax.set_yticks([])
+ ax2 = ax.twiny()
+
+ ax.set_xticks(range(37), ['foo'] * 37, rotation_mode="xtick")
+ ax2.set_xticks(range(37), ['foo'] * 37, rotation_mode="xtick")
+
+ angles = np.linspace(0, 360, 37)
+
+ for tick, angle in zip(ax.get_xticklabels(), angles):
+ tick.set_rotation(angle)
+ for tick, angle in zip(ax2.get_xticklabels(), angles):
+ tick.set_rotation(angle)
+
+ plt.subplots_adjust(left=0.01, right=0.99, top=.6, bottom=.4)
+
+
+@image_comparison(baseline_images=['ytick_rotation_mode'],
+ remove_text=False, extensions=['png'], style='mpl20')
+def test_ytick_rotation_mode():
+ fig, ax = plt.subplots(figsize=(1, 12))
+ ax.set_xticks([])
+ ax2 = ax.twinx()
+
+ ax.set_yticks(range(37), ['foo'] * 37, rotation_mode="ytick")
+ ax2.set_yticks(range(37), ['foo'] * 37, rotation_mode='ytick')
+
+ angles = np.linspace(0, 360, 37)
+ for tick, angle in zip(ax.get_yticklabels(), angles):
+ tick.set_rotation(angle)
+ for tick, angle in zip(ax2.get_yticklabels(), angles):
+ tick.set_rotation(angle)
+
+ plt.subplots_adjust(left=0.4, right=0.6, top=.99, bottom=.01)
diff --git a/lib/matplotlib/tests/test_ticker.py b/lib/matplotlib/tests/test_ticker.py
index ac68a5d90b14..a9104cc1b839 100644
--- a/lib/matplotlib/tests/test_ticker.py
+++ b/lib/matplotlib/tests/test_ticker.py
@@ -332,13 +332,11 @@ def test_basic(self):
with pytest.raises(ValueError):
loc.tick_values(0, 1000)
- test_value = np.array([1.00000000e-05, 1.00000000e-03, 1.00000000e-01,
- 1.00000000e+01, 1.00000000e+03, 1.00000000e+05,
- 1.00000000e+07, 1.000000000e+09])
+ test_value = np.array([1e-5, 1e-3, 1e-1, 1e+1, 1e+3, 1e+5, 1e+7])
assert_almost_equal(loc.tick_values(0.001, 1.1e5), test_value)
loc = mticker.LogLocator(base=2)
- test_value = np.array([0.5, 1., 2., 4., 8., 16., 32., 64., 128., 256.])
+ test_value = np.array([.5, 1., 2., 4., 8., 16., 32., 64., 128.])
assert_almost_equal(loc.tick_values(1, 100), test_value)
def test_polar_axes(self):
@@ -358,19 +356,20 @@ def test_switch_to_autolocator(self):
loc = mticker.LogLocator(subs=np.arange(2, 10))
assert 1.0 not in loc.tick_values(0.9, 20.)
assert 10.0 not in loc.tick_values(0.9, 20.)
+ # don't switch if there's already one major and one minor tick (10 & 20)
+ loc = mticker.LogLocator(subs="auto")
+ tv = loc.tick_values(10, 20)
+ assert_array_equal(tv[(10 <= tv) & (tv <= 20)], [20])
def test_set_params(self):
"""
Create log locator with default value, base=10.0, subs=[1.0],
- numdecs=4, numticks=15 and change it to something else.
+ numticks=15 and change it to something else.
See if change was successful. Should not raise exception.
"""
loc = mticker.LogLocator()
- with pytest.warns(mpl.MatplotlibDeprecationWarning, match="numdecs"):
- loc.set_params(numticks=7, numdecs=8, subs=[2.0], base=4)
+ loc.set_params(numticks=7, subs=[2.0], base=4)
assert loc.numticks == 7
- with pytest.warns(mpl.MatplotlibDeprecationWarning, match="numdecs"):
- assert loc.numdecs == 8
assert loc._base == 4
assert list(loc._subs) == [2.0]
@@ -380,7 +379,7 @@ def test_tick_values_correct(self):
1.e+01, 2.e+01, 5.e+01, 1.e+02, 2.e+02, 5.e+02,
1.e+03, 2.e+03, 5.e+03, 1.e+04, 2.e+04, 5.e+04,
1.e+05, 2.e+05, 5.e+05, 1.e+06, 2.e+06, 5.e+06,
- 1.e+07, 2.e+07, 5.e+07, 1.e+08, 2.e+08, 5.e+08])
+ 1.e+07, 2.e+07, 5.e+07])
assert_almost_equal(ll.tick_values(1, 1e7), test_value)
def test_tick_values_not_empty(self):
@@ -390,8 +389,7 @@ def test_tick_values_not_empty(self):
1.e+01, 2.e+01, 5.e+01, 1.e+02, 2.e+02, 5.e+02,
1.e+03, 2.e+03, 5.e+03, 1.e+04, 2.e+04, 5.e+04,
1.e+05, 2.e+05, 5.e+05, 1.e+06, 2.e+06, 5.e+06,
- 1.e+07, 2.e+07, 5.e+07, 1.e+08, 2.e+08, 5.e+08,
- 1.e+09, 2.e+09, 5.e+09])
+ 1.e+07, 2.e+07, 5.e+07, 1.e+08, 2.e+08, 5.e+08])
assert_almost_equal(ll.tick_values(1, 1e8), test_value)
def test_multiple_shared_axes(self):
@@ -637,7 +635,7 @@ def test_subs(self):
sym = mticker.SymmetricalLogLocator(base=10, linthresh=1, subs=[2.0, 4.0])
sym.create_dummy_axis()
sym.axis.set_view_interval(-10, 10)
- assert (sym() == [-20., -40., -2., -4., 0., 2., 4., 20., 40.]).all()
+ assert_array_equal(sym(), [-20, -40, -2, -4, 0, 2, 4, 20, 40])
def test_extending(self):
sym = mticker.SymmetricalLogLocator(base=10, linthresh=1)
@@ -862,6 +860,22 @@ def test_set_use_offset_float(self):
assert not tmp_form.get_useOffset()
assert tmp_form.offset == 0.5
+ def test_set_use_offset_bool(self):
+ tmp_form = mticker.ScalarFormatter()
+ tmp_form.set_useOffset(True)
+ assert tmp_form.get_useOffset()
+ assert tmp_form.offset == 0
+
+ tmp_form.set_useOffset(False)
+ assert not tmp_form.get_useOffset()
+ assert tmp_form.offset == 0
+
+ def test_set_use_offset_int(self):
+ tmp_form = mticker.ScalarFormatter()
+ tmp_form.set_useOffset(1)
+ assert not tmp_form.get_useOffset()
+ assert tmp_form.offset == 1
+
def test_use_locale(self):
conv = locale.localeconv()
sep = conv['thousands_sep']
@@ -1238,11 +1252,16 @@ def test_sublabel(self):
ax.set_xlim(1, 80)
self._sub_labels(ax.xaxis, subs=[])
- # axis range at 0.4 to 1 decades, label subs 2, 3, 4, 6
+ # axis range slightly more than 1 decade, but spanning a single major
+ # tick, label subs 2, 3, 4, 6
+ ax.set_xlim(.8, 9)
+ self._sub_labels(ax.xaxis, subs=[2, 3, 4, 6])
+
+ # axis range at 0.4 to 1 decade, label subs 2, 3, 4, 6
ax.set_xlim(1, 8)
self._sub_labels(ax.xaxis, subs=[2, 3, 4, 6])
- # axis range at 0 to 0.4 decades, label all
+ # axis range at 0 to 0.4 decade, label all
ax.set_xlim(0.5, 0.9)
self._sub_labels(ax.xaxis, subs=np.arange(2, 10, dtype=int))
@@ -1594,6 +1613,73 @@ def test_engformatter_usetex_useMathText():
assert x_tick_label_text == ['$0$', '$500$', '$1$ k']
+@pytest.mark.parametrize(
+ 'data_offset, noise, oom_center_desired, oom_noise_desired', [
+ (271_490_000_000.0, 10, 9, 0),
+ (27_149_000_000_000.0, 10_000_000, 12, 6),
+ (27.149, 0.01, 0, -3),
+ (2_714.9, 0.01, 3, -3),
+ (271_490.0, 0.001, 3, -3),
+ (271.49, 0.001, 0, -3),
+ # The following sets of parameters demonstrates that when
+ # oom(data_offset)-1 and oom(noise)-2 equal a standard 3*N oom, we get
+ # that oom_noise_desired < oom(noise)
+ (27_149_000_000.0, 100, 9, +3),
+ (27.149, 1e-07, 0, -6),
+ (271.49, 0.0001, 0, -3),
+ (27.149, 0.0001, 0, -3),
+ # Tests where oom(data_offset) <= oom(noise), those are probably
+ # covered by the part where formatter.offset != 0
+ (27_149.0, 10_000, 0, 3),
+ (27.149, 10_000, 0, 3),
+ (27.149, 1_000, 0, 3),
+ (27.149, 100, 0, 0),
+ (27.149, 10, 0, 0),
+ ]
+)
+def test_engformatter_offset_oom(
+ data_offset,
+ noise,
+ oom_center_desired,
+ oom_noise_desired
+):
+ UNIT = "eV"
+ fig, ax = plt.subplots()
+ ydata = data_offset + np.arange(-5, 7, dtype=float)*noise
+ ax.plot(ydata)
+ formatter = mticker.EngFormatter(useOffset=True, unit=UNIT)
+ # So that offset strings will always have the same size
+ formatter.ENG_PREFIXES[0] = "_"
+ ax.yaxis.set_major_formatter(formatter)
+ fig.canvas.draw()
+ offset_got = formatter.get_offset()
+ ticks_got = [labl.get_text() for labl in ax.get_yticklabels()]
+ # Predicting whether offset should be 0 or not is essentially testing
+ # ScalarFormatter._compute_offset . This function is pretty complex and it
+ # would be nice to test it, but this is out of scope for this test which
+ # only makes sure that offset text and the ticks gets the correct unit
+ # prefixes and the ticks.
+ if formatter.offset:
+ prefix_noise_got = offset_got[2]
+ prefix_noise_desired = formatter.ENG_PREFIXES[oom_noise_desired]
+ prefix_center_got = offset_got[-1-len(UNIT)]
+ prefix_center_desired = formatter.ENG_PREFIXES[oom_center_desired]
+ assert prefix_noise_desired == prefix_noise_got
+ assert prefix_center_desired == prefix_center_got
+ # Make sure the ticks didn't get the UNIT
+ for tick in ticks_got:
+ assert UNIT not in tick
+ else:
+ assert oom_center_desired == 0
+ assert offset_got == ""
+ # Make sure the ticks contain now the prefixes
+ for tick in ticks_got:
+ # 0 is zero on all orders of magnitudes, no matter what is
+ # oom_noise_desired
+ prefix_idx = 0 if tick[0] == "0" else oom_noise_desired
+ assert tick.endswith(formatter.ENG_PREFIXES[prefix_idx] + UNIT)
+
+
class TestPercentFormatter:
percent_data = [
# Check explicitly set decimals over different intervals and values
@@ -1828,14 +1914,54 @@ def test_bad_locator_subs(sub):
ll.set_params(subs=sub)
-@pytest.mark.parametrize('numticks', [1, 2, 3, 9])
+@pytest.mark.parametrize("numticks, lims, ticks", [
+ (1, (.5, 5), [.1, 1, 10]),
+ (2, (.5, 5), [.1, 1, 10]),
+ (3, (.5, 5), [.1, 1, 10]),
+ (9, (.5, 5), [.1, 1, 10]),
+ (1, (.5, 50), [.1, 10, 1_000]),
+ (2, (.5, 50), [.1, 1, 10, 100]),
+ (3, (.5, 50), [.1, 1, 10, 100]),
+ (9, (.5, 50), [.1, 1, 10, 100]),
+ (1, (.5, 500), [.1, 10, 1_000]),
+ (2, (.5, 500), [.01, 1, 100, 10_000]),
+ (3, (.5, 500), [.1, 1, 10, 100, 1_000]),
+ (9, (.5, 500), [.1, 1, 10, 100, 1_000]),
+ (1, (.5, 5000), [.1, 100, 100_000]),
+ (2, (.5, 5000), [.001, 1, 1_000, 1_000_000]),
+ (3, (.5, 5000), [.001, 1, 1_000, 1_000_000]),
+ (9, (.5, 5000), [.1, 1, 10, 100, 1_000, 10_000]),
+])
@mpl.style.context('default')
-def test_small_range_loglocator(numticks):
- ll = mticker.LogLocator()
- ll.set_params(numticks=numticks)
- for top in [5, 7, 9, 11, 15, 50, 100, 1000]:
- ticks = ll.tick_values(.5, top)
- assert (np.diff(np.log10(ll.tick_values(6, 150))) == 1).all()
+def test_small_range_loglocator(numticks, lims, ticks):
+ ll = mticker.LogLocator(numticks=numticks)
+ assert_array_equal(ll.tick_values(*lims), ticks)
+
+
+@mpl.style.context('default')
+def test_loglocator_properties():
+ # Test that LogLocator returns ticks satisfying basic desirable properties
+ # for a wide range of inputs.
+ max_numticks = 8
+ pow_end = 20
+ for numticks, (lo, hi) in itertools.product(
+ range(1, max_numticks + 1), itertools.combinations(range(pow_end), 2)):
+ ll = mticker.LogLocator(numticks=numticks)
+ decades = np.log10(ll.tick_values(10**lo, 10**hi)).round().astype(int)
+ # There are no more ticks than the requested number, plus exactly one
+ # tick below and one tick above the limits.
+ assert len(decades) <= numticks + 2
+ assert decades[0] < lo <= decades[1]
+ assert decades[-2] <= hi < decades[-1]
+ stride, = {*np.diff(decades)} # Extract the (constant) stride.
+ # Either the ticks are on integer multiples of the stride...
+ if not (decades % stride == 0).all():
+ # ... or (for this given stride) no offset would be acceptable,
+ # i.e. they would either result in fewer ticks than the selected
+ # solution, or more than the requested number of ticks.
+ for offset in range(0, stride):
+ alt_decades = range(lo + offset, hi + 1, stride)
+ assert len(alt_decades) < len(decades) or len(alt_decades) > numticks
def test_NullFormatter():
diff --git a/lib/matplotlib/tests/test_tightlayout.py b/lib/matplotlib/tests/test_tightlayout.py
index 9c654f4d1f48..98fd5e70cdb9 100644
--- a/lib/matplotlib/tests/test_tightlayout.py
+++ b/lib/matplotlib/tests/test_tightlayout.py
@@ -11,6 +11,11 @@
from matplotlib.patches import Rectangle
+pytestmark = [
+ pytest.mark.usefixtures('text_placeholders')
+]
+
+
def example_plot(ax, fontsize=12):
ax.plot([1, 2])
ax.locator_params(nbins=3)
@@ -19,7 +24,7 @@ def example_plot(ax, fontsize=12):
ax.set_title('Title', fontsize=fontsize)
-@image_comparison(['tight_layout1'], tol=1.9)
+@image_comparison(['tight_layout1'], style='mpl20')
def test_tight_layout1():
"""Test tight_layout for a single subplot."""
fig, ax = plt.subplots()
@@ -27,7 +32,7 @@ def test_tight_layout1():
plt.tight_layout()
-@image_comparison(['tight_layout2'])
+@image_comparison(['tight_layout2'], style='mpl20')
def test_tight_layout2():
"""Test tight_layout for multiple subplots."""
fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(nrows=2, ncols=2)
@@ -38,7 +43,7 @@ def test_tight_layout2():
plt.tight_layout()
-@image_comparison(['tight_layout3'])
+@image_comparison(['tight_layout3'], style='mpl20')
def test_tight_layout3():
"""Test tight_layout for multiple subplots."""
ax1 = plt.subplot(221)
@@ -50,8 +55,7 @@ def test_tight_layout3():
plt.tight_layout()
-@image_comparison(['tight_layout4'], freetype_version=('2.5.5', '2.6.1'),
- tol=0.015)
+@image_comparison(['tight_layout4'], style='mpl20')
def test_tight_layout4():
"""Test tight_layout for subplot2grid."""
ax1 = plt.subplot2grid((3, 3), (0, 0))
@@ -65,7 +69,7 @@ def test_tight_layout4():
plt.tight_layout()
-@image_comparison(['tight_layout5'])
+@image_comparison(['tight_layout5'], style='mpl20')
def test_tight_layout5():
"""Test tight_layout for image."""
ax = plt.subplot()
@@ -74,7 +78,7 @@ def test_tight_layout5():
plt.tight_layout()
-@image_comparison(['tight_layout6'])
+@image_comparison(['tight_layout6'], style='mpl20')
def test_tight_layout6():
"""Test tight_layout for gridspec."""
@@ -116,7 +120,7 @@ def test_tight_layout6():
h_pad=0.45)
-@image_comparison(['tight_layout7'], tol=1.9)
+@image_comparison(['tight_layout7'], style='mpl20')
def test_tight_layout7():
# tight layout with left and right titles
fontsize = 24
@@ -130,7 +134,7 @@ def test_tight_layout7():
plt.tight_layout()
-@image_comparison(['tight_layout8'], tol=0.005)
+@image_comparison(['tight_layout8'], style='mpl20', tol=0.005)
def test_tight_layout8():
"""Test automatic use of tight_layout."""
fig = plt.figure()
@@ -140,7 +144,7 @@ def test_tight_layout8():
fig.draw_without_rendering()
-@image_comparison(['tight_layout9'])
+@image_comparison(['tight_layout9'], style='mpl20')
def test_tight_layout9():
# Test tight_layout for non-visible subplots
# GH 8244
@@ -174,10 +178,10 @@ def test_outward_ticks():
# These values were obtained after visual checking that they correspond
# to a tight layouting that did take the ticks into account.
expected = [
- [[0.091, 0.607], [0.433, 0.933]],
- [[0.579, 0.607], [0.922, 0.933]],
- [[0.091, 0.140], [0.433, 0.466]],
- [[0.579, 0.140], [0.922, 0.466]],
+ [[0.092, 0.605], [0.433, 0.933]],
+ [[0.581, 0.605], [0.922, 0.933]],
+ [[0.092, 0.138], [0.433, 0.466]],
+ [[0.581, 0.138], [0.922, 0.466]],
]
for nn, ax in enumerate(fig.axes):
assert_array_equal(np.round(ax.get_position().get_points(), 3),
@@ -190,8 +194,8 @@ def add_offsetboxes(ax, size=10, margin=.1, color='black'):
"""
m, mp = margin, 1+margin
anchor_points = [(-m, -m), (-m, .5), (-m, mp),
- (mp, .5), (.5, mp), (mp, mp),
- (.5, -m), (mp, -m), (.5, -m)]
+ (.5, mp), (mp, mp), (mp, .5),
+ (mp, -m), (.5, -m)]
for point in anchor_points:
da = DrawingArea(size, size)
background = Rectangle((0, 0), width=size,
@@ -211,47 +215,78 @@ def add_offsetboxes(ax, size=10, margin=.1, color='black'):
bbox_transform=ax.transAxes,
borderpad=0.)
ax.add_artist(anchored_box)
- return anchored_box
-@image_comparison(['tight_layout_offsetboxes1', 'tight_layout_offsetboxes2'])
def test_tight_layout_offsetboxes():
- # 1.
+ # 0.
# - Create 4 subplots
# - Plot a diagonal line on them
+ # - Use tight_layout
+ #
+ # 1.
+ # - Same 4 subplots
# - Surround each plot with 7 boxes
# - Use tight_layout
- # - See that the squares are included in the tight_layout
- # and that the squares in the middle do not overlap
+ # - See that the squares are included in the tight_layout and that the squares do
+ # not overlap
#
# 2.
- # - Make the squares around the right side axes invisible
- # - See that the invisible squares do not affect the
- # tight_layout
+ # - Make the squares around the Axes invisible
+ # - See that the invisible squares do not affect the tight_layout
rows = cols = 2
colors = ['red', 'blue', 'green', 'yellow']
x = y = [0, 1]
- def _subplots():
- _, axs = plt.subplots(rows, cols)
- axs = axs.flat
- for ax, color in zip(axs, colors):
+ def _subplots(with_boxes):
+ fig, axs = plt.subplots(rows, cols)
+ for ax, color in zip(axs.flat, colors):
ax.plot(x, y, color=color)
- add_offsetboxes(ax, 20, color=color)
- return axs
+ if with_boxes:
+ add_offsetboxes(ax, 20, color=color)
+ return fig, axs
+
+ # 0.
+ fig0, axs0 = _subplots(False)
+ fig0.tight_layout()
# 1.
- axs = _subplots()
- plt.tight_layout()
+ fig1, axs1 = _subplots(True)
+ fig1.tight_layout()
+
+ # The AnchoredOffsetbox should be added to the bounding of the Axes, causing them to
+ # be smaller than the plain figure.
+ for ax0, ax1 in zip(axs0.flat, axs1.flat):
+ bbox0 = ax0.get_position()
+ bbox1 = ax1.get_position()
+ assert bbox1.x0 > bbox0.x0
+ assert bbox1.x1 < bbox0.x1
+ assert bbox1.y0 > bbox0.y0
+ assert bbox1.y1 < bbox0.y1
+
+ # No AnchoredOffsetbox should overlap with another.
+ bboxes = []
+ for ax1 in axs1.flat:
+ for child in ax1.get_children():
+ if not isinstance(child, AnchoredOffsetbox):
+ continue
+ bbox = child.get_window_extent()
+ for other_bbox in bboxes:
+ assert not bbox.overlaps(other_bbox)
+ bboxes.append(bbox)
# 2.
- axs = _subplots()
- for ax in (axs[cols-1::rows]):
+ fig2, axs2 = _subplots(True)
+ for ax in axs2.flat:
for child in ax.get_children():
if isinstance(child, AnchoredOffsetbox):
child.set_visible(False)
-
- plt.tight_layout()
+ fig2.tight_layout()
+ # The invisible AnchoredOffsetbox should not count for tight layout, so it should
+ # look the same as when they were never added.
+ for ax0, ax2 in zip(axs0.flat, axs2.flat):
+ bbox0 = ax0.get_position()
+ bbox2 = ax2.get_position()
+ assert_array_equal(bbox2.get_points(), bbox0.get_points())
def test_empty_layout():
@@ -296,8 +331,8 @@ def test_collapsed():
# zero (i.e. margins add up to more than the available width) that a call
# to tight_layout will not get applied:
fig, ax = plt.subplots(tight_layout=True)
- ax.set_xlim([0, 1])
- ax.set_ylim([0, 1])
+ ax.set_xlim(0, 1)
+ ax.set_ylim(0, 1)
ax.annotate('BIG LONG STRING', xy=(1.25, 2), xytext=(10.5, 1.75),
annotation_clip=False)
diff --git a/lib/matplotlib/tests/test_transforms.py b/lib/matplotlib/tests/test_transforms.py
index 3d12b90d5210..b4db34db5a91 100644
--- a/lib/matplotlib/tests/test_transforms.py
+++ b/lib/matplotlib/tests/test_transforms.py
@@ -9,9 +9,10 @@
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import matplotlib.transforms as mtransforms
-from matplotlib.transforms import Affine2D, Bbox, TransformedBbox
+from matplotlib.transforms import Affine2D, Bbox, TransformedBbox, _ScaledRotation
from matplotlib.path import Path
from matplotlib.testing.decorators import image_comparison, check_figures_equal
+from unittest.mock import MagicMock
class TestAffine2D:
@@ -341,6 +342,31 @@ def test_deepcopy(self):
assert_array_equal(s.get_matrix(), a.get_matrix())
+class TestAffineDeltaTransform:
+ def test_invalidate(self):
+ before = np.array([[1.0, 4.0, 0.0],
+ [5.0, 1.0, 0.0],
+ [0.0, 0.0, 1.0]])
+ after = np.array([[1.0, 3.0, 0.0],
+ [5.0, 1.0, 0.0],
+ [0.0, 0.0, 1.0]])
+
+ # Translation and skew present
+ base = mtransforms.Affine2D.from_values(1, 5, 4, 1, 2, 3)
+ t = mtransforms.AffineDeltaTransform(base)
+ assert_array_equal(t.get_matrix(), before)
+
+ # Mess with the internal structure of `base` without invalidating
+ # This should not affect this transform because it's a passthrough:
+ # it's always invalid
+ base.get_matrix()[0, 1:] = 3
+ assert_array_equal(t.get_matrix(), after)
+
+ # Invalidate the base
+ base.invalidate()
+ assert_array_equal(t.get_matrix(), after)
+
+
def test_non_affine_caching():
class AssertingNonAffineTransform(mtransforms.Transform):
"""
@@ -865,8 +891,7 @@ def test_str_transform():
Affine2D().scale(1.0))),
PolarTransform(
PolarAxes(0.125,0.1;0.775x0.8),
- use_rmin=True,
- apply_theta_transforms=False)),
+ use_rmin=True)),
CompositeGenericTransform(
CompositeGenericTransform(
PolarAffine(
@@ -961,12 +986,6 @@ def test_transformed_path():
[(0, 0), (r2, r2), (0, 2 * r2), (-r2, r2)],
atol=1e-15)
- # Changing the path does not change the result (it's cached).
- path.points = [(0, 0)] * 4
- assert_allclose(trans_path.get_fully_transformed_path().vertices,
- [(0, 0), (r2, r2), (0, 2 * r2), (-r2, r2)],
- atol=1e-15)
-
def test_transformed_patch_path():
trans = mtransforms.Affine2D()
@@ -1027,7 +1046,7 @@ def test_transformwrapper():
t.set(scale.LogTransform(10))
-@check_figures_equal(extensions=["png"])
+@check_figures_equal()
def test_scale_swapping(fig_test, fig_ref):
np.random.seed(19680801)
samples = np.random.normal(size=10)
@@ -1079,3 +1098,27 @@ def test_interval_contains_open():
assert not mtransforms.interval_contains_open((0, 1), -1)
assert not mtransforms.interval_contains_open((0, 1), 2)
assert mtransforms.interval_contains_open((1, 0), 0.5)
+
+
+def test_scaledrotation_initialization():
+ """Test that the ScaledRotation object is initialized correctly."""
+ theta = 1.0 # Arbitrary theta value for testing
+ trans_shift = MagicMock() # Mock the trans_shift transformation
+ scaled_rot = _ScaledRotation(theta, trans_shift)
+ assert scaled_rot._theta == theta
+ assert scaled_rot._trans_shift == trans_shift
+ assert scaled_rot._mtx is None
+
+
+def test_scaledrotation_get_matrix_invalid():
+ """Test get_matrix when the matrix is invalid and needs recalculation."""
+ theta = np.pi / 2
+ trans_shift = MagicMock(transform=MagicMock(return_value=[[theta, 0]]))
+ scaled_rot = _ScaledRotation(theta, trans_shift)
+ scaled_rot._invalid = True
+ matrix = scaled_rot.get_matrix()
+ trans_shift.transform.assert_called_once_with([[theta, 0]])
+ expected_rotation = np.array([[0, -1],
+ [1, 0]])
+ assert matrix is not None
+ assert_allclose(matrix[:2, :2], expected_rotation, atol=1e-15)
diff --git a/lib/matplotlib/tests/test_triangulation.py b/lib/matplotlib/tests/test_triangulation.py
index 14c591abd4e5..ae065a231fd9 100644
--- a/lib/matplotlib/tests/test_triangulation.py
+++ b/lib/matplotlib/tests/test_triangulation.py
@@ -612,7 +612,7 @@ def test_triinterpcubic_cg_solver():
# 1) A commonly used test involves a 2d Poisson matrix.
def poisson_sparse_matrix(n, m):
"""
- Return the sparse, (n*m, n*m) matrix in coo format resulting from the
+ Return the sparse, (n*m, n*m) matrix in COO format resulting from the
discretisation of the 2-dimensional Poisson equation according to a
finite difference numerical scheme on a uniform (n, m) grid.
"""
@@ -1181,43 +1181,44 @@ def test_tricontourf_decreasing_levels():
plt.tricontourf(x, y, z, [1.0, 0.0])
-def test_internal_cpp_api():
+def test_internal_cpp_api() -> None:
# Following github issue 8197.
- from matplotlib import _tri # noqa: ensure lazy-loaded module *is* loaded.
+ from matplotlib import _tri # noqa: F401, ensure lazy-loaded module *is* loaded.
# C++ Triangulation.
with pytest.raises(
TypeError,
match=r'__init__\(\): incompatible constructor arguments.'):
- mpl._tri.Triangulation()
+ mpl._tri.Triangulation() # type: ignore[call-arg]
with pytest.raises(
ValueError, match=r'x and y must be 1D arrays of the same length'):
- mpl._tri.Triangulation([], [1], [[]], (), (), (), False)
+ mpl._tri.Triangulation(np.array([]), np.array([1]), np.array([[]]), (), (), (),
+ False)
- x = [0, 1, 1]
- y = [0, 0, 1]
+ x = np.array([0, 1, 1], dtype=np.float64)
+ y = np.array([0, 0, 1], dtype=np.float64)
with pytest.raises(
ValueError,
match=r'triangles must be a 2D array of shape \(\?,3\)'):
- mpl._tri.Triangulation(x, y, [[0, 1]], (), (), (), False)
+ mpl._tri.Triangulation(x, y, np.array([[0, 1]]), (), (), (), False)
- tris = [[0, 1, 2]]
+ tris = np.array([[0, 1, 2]], dtype=np.int_)
with pytest.raises(
ValueError,
match=r'mask must be a 1D array with the same length as the '
r'triangles array'):
- mpl._tri.Triangulation(x, y, tris, [0, 1], (), (), False)
+ mpl._tri.Triangulation(x, y, tris, np.array([0, 1]), (), (), False)
with pytest.raises(
ValueError, match=r'edges must be a 2D array with shape \(\?,2\)'):
- mpl._tri.Triangulation(x, y, tris, (), [[1]], (), False)
+ mpl._tri.Triangulation(x, y, tris, (), np.array([[1]]), (), False)
with pytest.raises(
ValueError,
match=r'neighbors must be a 2D array with the same shape as the '
r'triangles array'):
- mpl._tri.Triangulation(x, y, tris, (), (), [[-1]], False)
+ mpl._tri.Triangulation(x, y, tris, (), (), np.array([[-1]]), False)
triang = mpl._tri.Triangulation(x, y, tris, (), (), (), False)
@@ -1232,9 +1233,9 @@ def test_internal_cpp_api():
ValueError,
match=r'mask must be a 1D array with the same length as the '
r'triangles array'):
- triang.set_mask(mask)
+ triang.set_mask(mask) # type: ignore[arg-type]
- triang.set_mask([True])
+ triang.set_mask(np.array([True]))
assert_array_equal(triang.get_edges(), np.empty((0, 2)))
triang.set_mask(()) # Equivalent to Python Triangulation mask=None
@@ -1244,15 +1245,14 @@ def test_internal_cpp_api():
with pytest.raises(
TypeError,
match=r'__init__\(\): incompatible constructor arguments.'):
- mpl._tri.TriContourGenerator()
+ mpl._tri.TriContourGenerator() # type: ignore[call-arg]
with pytest.raises(
ValueError,
- match=r'z must be a 1D array with the same length as the x and y '
- r'arrays'):
- mpl._tri.TriContourGenerator(triang, [1])
+ match=r'z must be a 1D array with the same length as the x and y arrays'):
+ mpl._tri.TriContourGenerator(triang, np.array([1]))
- z = [0, 1, 2]
+ z = np.array([0, 1, 2])
tcg = mpl._tri.TriContourGenerator(triang, z)
with pytest.raises(
@@ -1263,13 +1263,13 @@ def test_internal_cpp_api():
with pytest.raises(
TypeError,
match=r'__init__\(\): incompatible constructor arguments.'):
- mpl._tri.TrapezoidMapTriFinder()
+ mpl._tri.TrapezoidMapTriFinder() # type: ignore[call-arg]
trifinder = mpl._tri.TrapezoidMapTriFinder(triang)
with pytest.raises(
ValueError, match=r'x and y must be array-like with same shape'):
- trifinder.find_many([0], [0, 1])
+ trifinder.find_many(np.array([0]), np.array([0, 1]))
def test_qhull_large_offset():
diff --git a/lib/matplotlib/tests/test_ttconv.py b/lib/matplotlib/tests/test_ttconv.py
deleted file mode 100644
index 1d839e7094b0..000000000000
--- a/lib/matplotlib/tests/test_ttconv.py
+++ /dev/null
@@ -1,17 +0,0 @@
-from pathlib import Path
-
-import matplotlib
-from matplotlib.testing.decorators import image_comparison
-import matplotlib.pyplot as plt
-
-
-@image_comparison(["truetype-conversion.pdf"])
-# mpltest.ttf does not have "l"/"p" glyphs so we get a warning when trying to
-# get the font extents.
-def test_truetype_conversion(recwarn):
- matplotlib.rcParams['pdf.fonttype'] = 3
- fig, ax = plt.subplots()
- ax.text(0, 0, "ABCDE",
- font=Path(__file__).with_name("mpltest.ttf"), fontsize=80)
- ax.set_xticks([])
- ax.set_yticks([])
diff --git a/lib/matplotlib/tests/test_type1font.py b/lib/matplotlib/tests/test_type1font.py
index 1e173d5ea84d..b2f93ef28a26 100644
--- a/lib/matplotlib/tests/test_type1font.py
+++ b/lib/matplotlib/tests/test_type1font.py
@@ -5,7 +5,7 @@
def test_Type1Font():
- filename = os.path.join(os.path.dirname(__file__), 'cmr10.pfb')
+ filename = os.path.join(os.path.dirname(__file__), 'data', 'cmr10.pfb')
font = t1f.Type1Font(filename)
slanted = font.transform({'slant': 1})
condensed = font.transform({'extend': 0.5})
@@ -78,7 +78,7 @@ def test_Type1Font():
def test_Type1Font_2():
- filename = os.path.join(os.path.dirname(__file__),
+ filename = os.path.join(os.path.dirname(__file__), 'data',
'Courier10PitchBT-Bold.pfb')
font = t1f.Type1Font(filename)
assert font.prop['Weight'] == 'Bold'
@@ -137,15 +137,15 @@ def test_tokenize_errors():
def test_overprecision():
# We used to output too many digits in FontMatrix entries and
# ItalicAngle, which could make Type-1 parsers unhappy.
- filename = os.path.join(os.path.dirname(__file__), 'cmr10.pfb')
+ filename = os.path.join(os.path.dirname(__file__), 'data', 'cmr10.pfb')
font = t1f.Type1Font(filename)
slanted = font.transform({'slant': .167})
lines = slanted.parts[0].decode('ascii').splitlines()
- matrix, = [line[line.index('[')+1:line.index(']')]
- for line in lines if '/FontMatrix' in line]
- angle, = [word
+ matrix, = (line[line.index('[')+1:line.index(']')]
+ for line in lines if '/FontMatrix' in line)
+ angle, = (word
for line in lines if '/ItalicAngle' in line
- for word in line.split() if word[0] in '-0123456789']
+ for word in line.split() if word[0] in '-0123456789')
# the following used to include 0.00016700000000000002
assert matrix == '0.001 0 0.000167 0.001 0 0'
# and here we had -9.48090361795083
diff --git a/lib/matplotlib/tests/test_units.py b/lib/matplotlib/tests/test_units.py
index ae6372fea1e1..d2350667e94f 100644
--- a/lib/matplotlib/tests/test_units.py
+++ b/lib/matplotlib/tests/test_units.py
@@ -4,8 +4,10 @@
import matplotlib.pyplot as plt
from matplotlib.testing.decorators import check_figures_equal, image_comparison
+import matplotlib.patches as mpatches
import matplotlib.units as munits
-from matplotlib.category import UnitData
+from matplotlib.category import StrCategoryConverter, UnitData
+from matplotlib.dates import DateConverter
import numpy as np
import pytest
@@ -189,7 +191,7 @@ def test_errorbar_mixed_units():
fig.canvas.draw()
-@check_figures_equal(extensions=["png"])
+@check_figures_equal()
def test_subclass(fig_test, fig_ref):
class subdate(datetime):
pass
@@ -236,6 +238,39 @@ def test_shared_axis_categorical():
assert "c" in ax2.xaxis.get_units()._mapping.keys()
+def test_explicit_converter():
+ d1 = {"a": 1, "b": 2}
+ str_cat_converter = StrCategoryConverter()
+ str_cat_converter_2 = StrCategoryConverter()
+ date_converter = DateConverter()
+
+ # Explicit is set
+ fig1, ax1 = plt.subplots()
+ ax1.xaxis.set_converter(str_cat_converter)
+ assert ax1.xaxis.get_converter() == str_cat_converter
+ # Explicit not overridden by implicit
+ ax1.plot(d1.keys(), d1.values())
+ assert ax1.xaxis.get_converter() == str_cat_converter
+ # No error when called twice with equivalent input
+ ax1.xaxis.set_converter(str_cat_converter)
+ # Error when explicit called twice
+ with pytest.raises(RuntimeError):
+ ax1.xaxis.set_converter(str_cat_converter_2)
+
+ fig2, ax2 = plt.subplots()
+ ax2.plot(d1.keys(), d1.values())
+
+ # No error when equivalent type is used
+ ax2.xaxis.set_converter(str_cat_converter)
+
+ fig3, ax3 = plt.subplots()
+ ax3.plot(d1.keys(), d1.values())
+
+ # Warn when implicit overridden
+ with pytest.warns():
+ ax3.xaxis.set_converter(date_converter)
+
+
def test_empty_default_limits(quantity_converter):
munits.registry[Quantity] = quantity_converter
fig, ax1 = plt.subplots()
@@ -302,3 +337,17 @@ def test_plot_kernel():
# just a smoketest that fail
kernel = Kernel([1, 2, 3, 4, 5])
plt.plot(kernel)
+
+
+def test_connection_patch_units(pd):
+ # tests that this doesn't raise an error
+ fig, (ax1, ax2) = plt.subplots(nrows=2, figsize=(10, 5))
+ x = pd.Timestamp('2017-01-01T12')
+ ax1.axvline(x)
+ y = "test test"
+ ax2.axhline(y)
+ arr = mpatches.ConnectionPatch((x, 0), (0, y),
+ coordsA='data', coordsB='data',
+ axesA=ax1, axesB=ax2)
+ fig.add_artist(arr)
+ fig.draw_without_rendering()
diff --git a/lib/matplotlib/tests/test_usetex.py b/lib/matplotlib/tests/test_usetex.py
index 342face4504f..cd9f2597361b 100644
--- a/lib/matplotlib/tests/test_usetex.py
+++ b/lib/matplotlib/tests/test_usetex.py
@@ -1,3 +1,4 @@
+import re
from tempfile import TemporaryFile
import numpy as np
@@ -42,13 +43,13 @@ def test_usetex():
ax.set_axis_off()
-@check_figures_equal()
+@check_figures_equal(extensions=['png', 'pdf', 'svg'])
def test_empty(fig_test, fig_ref):
mpl.rcParams['text.usetex'] = True
fig_test.text(.5, .5, "% a comment")
-@check_figures_equal()
+@check_figures_equal(extensions=['png', 'pdf', 'svg'])
def test_unicode_minus(fig_test, fig_ref):
mpl.rcParams['text.usetex'] = True
fig_test.text(.5, .5, "$-$")
@@ -156,6 +157,69 @@ def test_missing_psfont(fmt, monkeypatch):
fig.savefig(tmpfile, format=fmt)
+def test_pdf_type1_font_subsetting():
+ """Test that fonts in PDF output are properly subset."""
+ pikepdf = pytest.importorskip("pikepdf")
+
+ mpl.rcParams["text.usetex"] = True
+ mpl.rcParams["text.latex.preamble"] = r"\usepackage{amssymb}"
+ fig, ax = plt.subplots()
+ ax.text(0.2, 0.7, r"$\int_{-\infty}^{\aleph}\sqrt{\alpha\beta\gamma}\mathrm{d}x$")
+ ax.text(0.2, 0.5, r"$\mathfrak{x}\circledcirc\mathfrak{y}\in\mathbb{R}$")
+
+ with TemporaryFile() as tmpfile:
+ fig.savefig(tmpfile, format="pdf")
+ tmpfile.seek(0)
+ pdf = pikepdf.Pdf.open(tmpfile)
+
+ length = {}
+ page = pdf.pages[0]
+ for font_name, font in page.Resources.Font.items():
+ assert font.Subtype == "/Type1", (
+ f"Font {font_name}={font} is not a Type 1 font"
+ )
+
+ # Subsetted font names have a 6-character tag followed by a '+'
+ base_font = str(font["/BaseFont"]).removeprefix("/")
+ assert re.match(r"^[A-Z]{6}\+", base_font), (
+ f"Font {font_name}={base_font} lacks a subset indicator tag"
+ )
+ assert "/FontFile" in font.FontDescriptor, (
+ f"Type 1 font {font_name}={base_font} is not embedded"
+ )
+ _, original_name = base_font.split("+", 1)
+ length[original_name] = len(bytes(font["/FontDescriptor"]["/FontFile"]))
+
+ print("Embedded font stream lengths:", length)
+ # We should have several fonts, each much smaller than the original.
+ # I get under 10kB on my system for each font, but allow 15kB in case
+ # of differences in the font files.
+ assert {
+ 'CMEX10',
+ 'CMMI12',
+ 'CMR12',
+ 'CMSY10',
+ 'CMSY8',
+ 'EUFM10',
+ 'MSAM10',
+ 'MSBM10',
+ }.issubset(length), "Missing expected fonts in the PDF"
+ for font_name, length in length.items():
+ assert length < 15_000, (
+ f"Font {font_name}={length} is larger than expected"
+ )
+
+ # For comparison, lengths without subsetting on my system:
+ # 'CMEX10': 29686
+ # 'CMMI12': 36176
+ # 'CMR12': 32157
+ # 'CMSY10': 32004
+ # 'CMSY8': 32061
+ # 'EUFM10': 20546
+ # 'MSAM10': 31199
+ # 'MSBM10': 34129
+
+
try:
_old_gs_version = mpl._get_executable_info('gs').version < parse_version('9.55')
except mpl.ExecutableNotFoundError:
@@ -168,8 +232,8 @@ def test_rotation():
mpl.rcParams['text.usetex'] = True
fig = plt.figure()
- ax = fig.add_axes([0, 0, 1, 1])
- ax.set(xlim=[-0.5, 5], xticks=[], ylim=[-0.5, 3], yticks=[], frame_on=False)
+ ax = fig.add_axes((0, 0, 1, 1))
+ ax.set(xlim=(-0.5, 5), xticks=[], ylim=(-0.5, 3), yticks=[], frame_on=False)
text = {val: val[0] for val in ['top', 'center', 'bottom', 'left', 'right']}
text['baseline'] = 'B'
@@ -185,3 +249,10 @@ def test_rotation():
# 'My' checks full height letters plus descenders.
ax.text(x, y, f"$\\mathrm{{My {text[ha]}{text[va]} {angle}}}$",
rotation=angle, horizontalalignment=ha, verticalalignment=va)
+
+
+def test_unicode_sizing():
+ tp = mpl.textpath.TextToPath()
+ scale1 = tp.get_glyphs_tex(mpl.font_manager.FontProperties(), "W")[0][0][3]
+ scale2 = tp.get_glyphs_tex(mpl.font_manager.FontProperties(), r"\textwon")[0][0][3]
+ assert scale1 == scale2
diff --git a/lib/matplotlib/tests/test_widgets.py b/lib/matplotlib/tests/test_widgets.py
index 0f2cc411dbdf..a1b540fb4a28 100644
--- a/lib/matplotlib/tests/test_widgets.py
+++ b/lib/matplotlib/tests/test_widgets.py
@@ -1,15 +1,14 @@
import functools
import io
+import operator
from unittest import mock
-import matplotlib as mpl
-from matplotlib.backend_bases import MouseEvent
+from matplotlib.backend_bases import DrawEvent, KeyEvent, MouseEvent
import matplotlib.colors as mcolors
import matplotlib.widgets as widgets
import matplotlib.pyplot as plt
from matplotlib.testing.decorators import check_figures_equal, image_comparison
-from matplotlib.testing.widgets import (click_and_drag, do_event, get_ax,
- mock_event, noop)
+from matplotlib.testing.widgets import click_and_drag, get_ax, noop
import numpy as np
from numpy.testing import assert_allclose
@@ -70,12 +69,11 @@ def test_save_blitted_widget_as_pdf():
def test_rectangle_selector(ax, kwargs):
onselect = mock.Mock(spec=noop, return_value=None)
- tool = widgets.RectangleSelector(ax, onselect, **kwargs)
- do_event(tool, 'press', xdata=100, ydata=100, button=1)
- do_event(tool, 'onmove', xdata=199, ydata=199, button=1)
-
+ tool = widgets.RectangleSelector(ax, onselect=onselect, **kwargs)
+ MouseEvent._from_ax_coords("button_press_event", ax, (100, 100), 1)._process()
+ MouseEvent._from_ax_coords("motion_notify_event", ax, (199, 199), 1)._process()
# purposely drag outside of axis for release
- do_event(tool, 'release', xdata=250, ydata=250, button=1)
+ MouseEvent._from_ax_coords("button_release_event", ax, (250, 250), 1)._process()
if kwargs.get('drawtype', None) not in ['line', 'none']:
assert_allclose(tool.geometry,
@@ -104,7 +102,7 @@ def test_rectangle_minspan(ax, spancoords, minspanx, x1, minspany, y1):
minspanx, minspany = (ax.transData.transform((x1, y1)) -
ax.transData.transform((x0, y0)))
- tool = widgets.RectangleSelector(ax, onselect, interactive=True,
+ tool = widgets.RectangleSelector(ax, onselect=onselect, interactive=True,
spancoords=spancoords,
minspanx=minspanx, minspany=minspany)
# Too small to create a selector
@@ -130,24 +128,14 @@ def test_rectangle_minspan(ax, spancoords, minspanx, x1, minspany, y1):
assert kwargs == {}
-def test_deprecation_selector_visible_attribute(ax):
- tool = widgets.RectangleSelector(ax, lambda *args: None)
-
- assert tool.get_visible()
-
- with pytest.warns(mpl.MatplotlibDeprecationWarning,
- match="was deprecated in Matplotlib 3.8"):
- tool.visible
-
-
@pytest.mark.parametrize('drag_from_anywhere, new_center',
[[True, (60, 75)],
[False, (30, 20)]])
def test_rectangle_drag(ax, drag_from_anywhere, new_center):
- tool = widgets.RectangleSelector(ax, onselect=noop, interactive=True,
+ tool = widgets.RectangleSelector(ax, interactive=True,
drag_from_anywhere=drag_from_anywhere)
# Create rectangle
- click_and_drag(tool, start=(0, 10), end=(100, 120))
+ click_and_drag(tool, start=(10, 10), end=(90, 120))
assert tool.center == (50, 65)
# Drag inside rectangle, but away from centre handle
#
@@ -165,7 +153,7 @@ def test_rectangle_drag(ax, drag_from_anywhere, new_center):
def test_rectangle_selector_set_props_handle_props(ax):
- tool = widgets.RectangleSelector(ax, onselect=noop, interactive=True,
+ tool = widgets.RectangleSelector(ax, interactive=True,
props=dict(facecolor='b', alpha=0.2),
handle_props=dict(alpha=0.5))
# Create rectangle
@@ -186,10 +174,10 @@ def test_rectangle_selector_set_props_handle_props(ax):
def test_rectangle_resize(ax):
- tool = widgets.RectangleSelector(ax, onselect=noop, interactive=True)
+ tool = widgets.RectangleSelector(ax, interactive=True)
# Create rectangle
- click_and_drag(tool, start=(0, 10), end=(100, 120))
- assert tool.extents == (0.0, 100.0, 10.0, 120.0)
+ click_and_drag(tool, start=(10, 10), end=(100, 120))
+ assert tool.extents == (10.0, 100.0, 10.0, 120.0)
# resize NE handle
extents = tool.extents
@@ -221,7 +209,7 @@ def test_rectangle_resize(ax):
def test_rectangle_add_state(ax):
- tool = widgets.RectangleSelector(ax, onselect=noop, interactive=True)
+ tool = widgets.RectangleSelector(ax, interactive=True)
# Create rectangle
click_and_drag(tool, start=(70, 65), end=(125, 130))
@@ -237,7 +225,7 @@ def test_rectangle_add_state(ax):
@pytest.mark.parametrize('add_state', [True, False])
def test_rectangle_resize_center(ax, add_state):
- tool = widgets.RectangleSelector(ax, onselect=noop, interactive=True)
+ tool = widgets.RectangleSelector(ax, interactive=True)
# Create rectangle
click_and_drag(tool, start=(70, 65), end=(125, 130))
assert tool.extents == (70.0, 125.0, 65.0, 130.0)
@@ -311,7 +299,7 @@ def test_rectangle_resize_center(ax, add_state):
@pytest.mark.parametrize('add_state', [True, False])
def test_rectangle_resize_square(ax, add_state):
- tool = widgets.RectangleSelector(ax, onselect=noop, interactive=True)
+ tool = widgets.RectangleSelector(ax, interactive=True)
# Create rectangle
click_and_drag(tool, start=(70, 65), end=(120, 115))
assert tool.extents == (70.0, 120.0, 65.0, 115.0)
@@ -384,7 +372,7 @@ def test_rectangle_resize_square(ax, add_state):
def test_rectangle_resize_square_center(ax):
- tool = widgets.RectangleSelector(ax, onselect=noop, interactive=True)
+ tool = widgets.RectangleSelector(ax, interactive=True)
# Create rectangle
click_and_drag(tool, start=(70, 65), end=(120, 115))
tool.add_state('square')
@@ -449,18 +437,18 @@ def test_rectangle_resize_square_center(ax):
@pytest.mark.parametrize('selector_class',
[widgets.RectangleSelector, widgets.EllipseSelector])
def test_rectangle_rotate(ax, selector_class):
- tool = selector_class(ax, onselect=noop, interactive=True)
+ tool = selector_class(ax, interactive=True)
# Draw rectangle
click_and_drag(tool, start=(100, 100), end=(130, 140))
assert tool.extents == (100, 130, 100, 140)
assert len(tool._state) == 0
# Rotate anticlockwise using top-right corner
- do_event(tool, 'on_key_press', key='r')
+ KeyEvent("key_press_event", ax.figure.canvas, "r")._process()
assert tool._state == {'rotate'}
assert len(tool._state) == 1
click_and_drag(tool, start=(130, 140), end=(120, 145))
- do_event(tool, 'on_key_press', key='r')
+ KeyEvent("key_press_event", ax.figure.canvas, "r")._process()
assert len(tool._state) == 0
# Extents shouldn't change (as shape of rectangle hasn't changed)
assert tool.extents == (100, 130, 100, 140)
@@ -482,7 +470,7 @@ def test_rectangle_rotate(ax, selector_class):
def test_rectangle_add_remove_set(ax):
- tool = widgets.RectangleSelector(ax, onselect=noop, interactive=True)
+ tool = widgets.RectangleSelector(ax, interactive=True)
# Draw rectangle
click_and_drag(tool, start=(100, 100), end=(130, 140))
assert tool.extents == (100, 130, 100, 140)
@@ -498,7 +486,7 @@ def test_rectangle_add_remove_set(ax):
def test_rectangle_resize_square_center_aspect(ax, use_data_coordinates):
ax.set_aspect(0.8)
- tool = widgets.RectangleSelector(ax, onselect=noop, interactive=True,
+ tool = widgets.RectangleSelector(ax, interactive=True,
use_data_coordinates=use_data_coordinates)
# Create rectangle
click_and_drag(tool, start=(70, 65), end=(120, 115))
@@ -530,8 +518,7 @@ def test_rectangle_resize_square_center_aspect(ax, use_data_coordinates):
def test_ellipse(ax):
"""For ellipse, test out the key modifiers"""
- tool = widgets.EllipseSelector(ax, onselect=noop,
- grab_range=10, interactive=True)
+ tool = widgets.EllipseSelector(ax, grab_range=10, interactive=True)
tool.extents = (100, 150, 100, 150)
# drag the rectangle
@@ -557,9 +544,7 @@ def test_ellipse(ax):
def test_rectangle_handles(ax):
- tool = widgets.RectangleSelector(ax, onselect=noop,
- grab_range=10,
- interactive=True,
+ tool = widgets.RectangleSelector(ax, grab_range=10, interactive=True,
handle_props={'markerfacecolor': 'r',
'markeredgecolor': 'b'})
tool.extents = (100, 150, 100, 150)
@@ -594,7 +579,7 @@ def test_rectangle_selector_onselect(ax, interactive):
# check when press and release events take place at the same position
onselect = mock.Mock(spec=noop, return_value=None)
- tool = widgets.RectangleSelector(ax, onselect, interactive=interactive)
+ tool = widgets.RectangleSelector(ax, onselect=onselect, interactive=interactive)
# move outside of axis
click_and_drag(tool, start=(100, 110), end=(150, 120))
@@ -610,7 +595,7 @@ def test_rectangle_selector_onselect(ax, interactive):
def test_rectangle_selector_ignore_outside(ax, ignore_event_outside):
onselect = mock.Mock(spec=noop, return_value=None)
- tool = widgets.RectangleSelector(ax, onselect,
+ tool = widgets.RectangleSelector(ax, onselect=onselect,
ignore_event_outside=ignore_event_outside)
click_and_drag(tool, start=(100, 110), end=(150, 120))
onselect.assert_called_once()
@@ -649,10 +634,10 @@ def test_span_selector(ax, orientation, onmove_callback, kwargs):
tax = ax.twinx()
tool = widgets.SpanSelector(ax, onselect, orientation, **kwargs)
- do_event(tool, 'press', xdata=100, ydata=100, button=1)
+ MouseEvent._from_ax_coords("button_press_event", ax, (100, 100), 1)._process()
# move outside of axis
- do_event(tool, 'onmove', xdata=199, ydata=199, button=1)
- do_event(tool, 'release', xdata=250, ydata=250, button=1)
+ MouseEvent._from_ax_coords("motion_notify_event", ax, (199, 199), 1)._process()
+ MouseEvent._from_ax_coords("button_release_event", ax, (250, 250), 1)._process()
onselect.assert_called_once_with(100, 199)
if onmove_callback:
@@ -772,10 +757,11 @@ def test_span_selector_set_props_handle_props(ax):
@pytest.mark.parametrize('selector', ['span', 'rectangle'])
def test_selector_clear(ax, selector):
- kwargs = dict(ax=ax, onselect=noop, interactive=True)
+ kwargs = dict(ax=ax, interactive=True)
if selector == 'span':
Selector = widgets.SpanSelector
kwargs['direction'] = 'horizontal'
+ kwargs['onselect'] = noop
else:
Selector = widgets.RectangleSelector
@@ -795,7 +781,7 @@ def test_selector_clear(ax, selector):
click_and_drag(tool, start=(130, 130), end=(130, 130))
assert tool._selection_completed
- do_event(tool, 'on_key_press', key='escape')
+ KeyEvent("key_press_event", ax.figure.canvas, "escape")._process()
assert not tool._selection_completed
@@ -806,7 +792,7 @@ def test_selector_clear_method(ax, selector):
interactive=True,
ignore_event_outside=True)
else:
- tool = widgets.RectangleSelector(ax, onselect=noop, interactive=True)
+ tool = widgets.RectangleSelector(ax, interactive=True)
click_and_drag(tool, start=(10, 10), end=(100, 120))
assert tool._selection_completed
assert tool.get_visible()
@@ -862,7 +848,7 @@ def test_tool_line_handle(ax):
def test_span_selector_bound(direction):
fig, ax = plt.subplots(1, 1)
ax.plot([10, 20], [10, 30])
- ax.figure.canvas.draw()
+ fig.canvas.draw()
x_bound = ax.get_xbound()
y_bound = ax.get_ybound()
@@ -917,10 +903,8 @@ def mean(vmin, vmax):
# Add span selector and check that the line is draw after it was updated
# by the callback
- press_data = [1, 2]
- move_data = [2, 2]
- do_event(span, 'press', xdata=press_data[0], ydata=press_data[1], button=1)
- do_event(span, 'onmove', xdata=move_data[0], ydata=move_data[1], button=1)
+ MouseEvent._from_ax_coords("button_press_event", ax, (1, 2), 1)._process()
+ MouseEvent._from_ax_coords("motion_notify_event", ax, (2, 2), 1)._process()
assert span._get_animated_artists() == (ln, ln2)
assert ln.stale is False
assert ln2.stale
@@ -930,16 +914,12 @@ def mean(vmin, vmax):
# Change span selector and check that the line is drawn/updated after its
# value was updated by the callback
- press_data = [4, 0]
- move_data = [5, 2]
- release_data = [5, 2]
- do_event(span, 'press', xdata=press_data[0], ydata=press_data[1], button=1)
- do_event(span, 'onmove', xdata=move_data[0], ydata=move_data[1], button=1)
+ MouseEvent._from_ax_coords("button_press_event", ax, (4, 0), 1)._process()
+ MouseEvent._from_ax_coords("motion_notify_event", ax, (5, 2), 1)._process()
assert ln.stale is False
assert ln2.stale
assert_allclose(ln2.get_ydata(), -0.9424150707548072)
- do_event(span, 'release', xdata=release_data[0],
- ydata=release_data[1], button=1)
+ MouseEvent._from_ax_coords("button_release_event", ax, (5, 2), 1)._process()
assert ln2.stale is False
@@ -999,10 +979,10 @@ def test_span_selector_extents(ax):
def test_lasso_selector(ax, kwargs):
onselect = mock.Mock(spec=noop, return_value=None)
- tool = widgets.LassoSelector(ax, onselect, **kwargs)
- do_event(tool, 'press', xdata=100, ydata=100, button=1)
- do_event(tool, 'onmove', xdata=125, ydata=125, button=1)
- do_event(tool, 'release', xdata=150, ydata=150, button=1)
+ tool = widgets.LassoSelector(ax, onselect=onselect, **kwargs)
+ MouseEvent._from_ax_coords("button_press_event", ax, (100, 100), 1)._process()
+ MouseEvent._from_ax_coords("motion_notify_event", ax, (125, 125), 1)._process()
+ MouseEvent._from_ax_coords("button_release_event", ax, (150, 150), 1)._process()
onselect.assert_called_once_with([(100, 100), (125, 125), (150, 150)])
@@ -1010,7 +990,8 @@ def test_lasso_selector(ax, kwargs):
def test_lasso_selector_set_props(ax):
onselect = mock.Mock(spec=noop, return_value=None)
- tool = widgets.LassoSelector(ax, onselect, props=dict(color='b', alpha=0.2))
+ tool = widgets.LassoSelector(ax, onselect=onselect,
+ props=dict(color='b', alpha=0.2))
artist = tool._selection_artist
assert mcolors.same_color(artist.get_color(), 'b')
@@ -1077,7 +1058,7 @@ def test_TextBox(ax, toolbar):
assert tool.text == ''
- do_event(tool, '_click')
+ MouseEvent._from_ax_coords("button_press_event", ax, (.5, .5), 1)._process()
tool.set_val('x**2')
@@ -1089,9 +1070,9 @@ def test_TextBox(ax, toolbar):
assert submit_event.call_count == 2
- do_event(tool, '_click', xdata=.5, ydata=.5) # Ensure the click is in the axes.
- do_event(tool, '_keypress', key='+')
- do_event(tool, '_keypress', key='5')
+ MouseEvent._from_ax_coords("button_press_event", ax, (.5, .5), 1)._process()
+ KeyEvent("key_press_event", ax.figure.canvas, "+")._process()
+ KeyEvent("key_press_event", ax.figure.canvas, "5")._process()
assert text_change_event.call_count == 3
@@ -1109,7 +1090,7 @@ def test_RadioButtons(ax):
@image_comparison(['check_radio_buttons.png'], style='mpl20', remove_text=True)
def test_check_radio_buttons_image():
ax = get_ax()
- fig = ax.figure
+ fig = ax.get_figure(root=False)
fig.subplots_adjust(left=0.3)
rax1 = fig.add_axes((0.05, 0.7, 0.2, 0.15))
@@ -1137,7 +1118,7 @@ def test_check_radio_buttons_image():
check_props={'color': ['red', 'green', 'blue']})
-@check_figures_equal(extensions=["png"])
+@check_figures_equal()
def test_radio_buttons(fig_test, fig_ref):
widgets.RadioButtons(fig_test.subplots(), ["tea", "coffee"])
ax = fig_ref.add_subplot(xticks=[], yticks=[])
@@ -1147,7 +1128,7 @@ def test_radio_buttons(fig_test, fig_ref):
ax.text(.25, 1/3, "coffee", transform=ax.transAxes, va="center")
-@check_figures_equal(extensions=['png'])
+@check_figures_equal()
def test_radio_buttons_props(fig_test, fig_ref):
label_props = {'color': ['red'], 'fontsize': [24]}
radio_props = {'facecolor': 'green', 'edgecolor': 'blue', 'linewidth': 2}
@@ -1171,7 +1152,7 @@ def test_radio_button_active_conflict(ax):
assert mcolors.same_color(rb._buttons.get_facecolor(), ['green', 'none'])
-@check_figures_equal(extensions=['png'])
+@check_figures_equal()
def test_radio_buttons_activecolor_change(fig_test, fig_ref):
widgets.RadioButtons(fig_ref.subplots(), ['tea', 'coffee'],
activecolor='green')
@@ -1182,7 +1163,7 @@ def test_radio_buttons_activecolor_change(fig_test, fig_ref):
cb.activecolor = 'green'
-@check_figures_equal(extensions=["png"])
+@check_figures_equal()
def test_check_buttons(fig_test, fig_ref):
widgets.CheckButtons(fig_test.subplots(), ["tea", "coffee"], [True, True])
ax = fig_ref.add_subplot(xticks=[], yticks=[])
@@ -1194,7 +1175,7 @@ def test_check_buttons(fig_test, fig_ref):
ax.text(.25, 1/3, "coffee", transform=ax.transAxes, va="center")
-@check_figures_equal(extensions=['png'])
+@check_figures_equal()
def test_check_button_props(fig_test, fig_ref):
label_props = {'color': ['red'], 'fontsize': [24]}
frame_props = {'facecolor': 'green', 'edgecolor': 'blue', 'linewidth': 2}
@@ -1354,182 +1335,178 @@ def test_range_slider_same_init_values(orientation):
assert_allclose(box.get_points().flatten()[idx], [0, 0.25, 0, 0.75])
-def check_polygon_selector(event_sequence, expected_result, selections_count,
- **kwargs):
+def check_polygon_selector(events, expected, selections_count, **kwargs):
"""
Helper function to test Polygon Selector.
Parameters
----------
- event_sequence : list of tuples (etype, dict())
- A sequence of events to perform. The sequence is a list of tuples
- where the first element of the tuple is an etype (e.g., 'onmove',
- 'press', etc.), and the second element of the tuple is a dictionary of
- the arguments for the event (e.g., xdata=5, key='shift', etc.).
- expected_result : list of vertices (xdata, ydata)
- The list of vertices that are expected to result from the event
- sequence.
+ events : list[MouseEvent]
+ A sequence of events to perform.
+ expected : list of vertices (xdata, ydata)
+ The list of vertices expected to result from the event sequence.
selections_count : int
Wait for the tool to call its `onselect` function `selections_count`
- times, before comparing the result to the `expected_result`
+ times, before comparing the result to the `expected`
**kwargs
Keyword arguments are passed to PolygonSelector.
"""
- ax = get_ax()
-
onselect = mock.Mock(spec=noop, return_value=None)
- tool = widgets.PolygonSelector(ax, onselect, **kwargs)
+ ax = events[0].canvas.figure.axes[0]
+ tool = widgets.PolygonSelector(ax, onselect=onselect, **kwargs)
- for (etype, event_args) in event_sequence:
- do_event(tool, etype, **event_args)
+ for event in events:
+ event._process()
assert onselect.call_count == selections_count
- assert onselect.call_args == ((expected_result, ), {})
+ assert onselect.call_args == ((expected, ), {})
-def polygon_place_vertex(xdata, ydata):
- return [('onmove', dict(xdata=xdata, ydata=ydata)),
- ('press', dict(xdata=xdata, ydata=ydata)),
- ('release', dict(xdata=xdata, ydata=ydata))]
+def polygon_place_vertex(ax, xy):
+ return [
+ MouseEvent._from_ax_coords("motion_notify_event", ax, xy),
+ MouseEvent._from_ax_coords("button_press_event", ax, xy, 1),
+ MouseEvent._from_ax_coords("button_release_event", ax, xy, 1),
+ ]
-def polygon_remove_vertex(xdata, ydata):
- return [('onmove', dict(xdata=xdata, ydata=ydata)),
- ('press', dict(xdata=xdata, ydata=ydata, button=3)),
- ('release', dict(xdata=xdata, ydata=ydata, button=3))]
+def polygon_remove_vertex(ax, xy):
+ return [
+ MouseEvent._from_ax_coords("motion_notify_event", ax, xy),
+ MouseEvent._from_ax_coords("button_press_event", ax, xy, 3),
+ MouseEvent._from_ax_coords("button_release_event", ax, xy, 3),
+ ]
@pytest.mark.parametrize('draw_bounding_box', [False, True])
-def test_polygon_selector(draw_bounding_box):
+def test_polygon_selector(ax, draw_bounding_box):
check_selector = functools.partial(
check_polygon_selector, draw_bounding_box=draw_bounding_box)
# Simple polygon
expected_result = [(50, 50), (150, 50), (50, 150)]
event_sequence = [
- *polygon_place_vertex(50, 50),
- *polygon_place_vertex(150, 50),
- *polygon_place_vertex(50, 150),
- *polygon_place_vertex(50, 50),
+ *polygon_place_vertex(ax, (50, 50)),
+ *polygon_place_vertex(ax, (150, 50)),
+ *polygon_place_vertex(ax, (50, 150)),
+ *polygon_place_vertex(ax, (50, 50)),
]
check_selector(event_sequence, expected_result, 1)
# Move first vertex before completing the polygon.
expected_result = [(75, 50), (150, 50), (50, 150)]
event_sequence = [
- *polygon_place_vertex(50, 50),
- *polygon_place_vertex(150, 50),
- ('on_key_press', dict(key='control')),
- ('onmove', dict(xdata=50, ydata=50)),
- ('press', dict(xdata=50, ydata=50)),
- ('onmove', dict(xdata=75, ydata=50)),
- ('release', dict(xdata=75, ydata=50)),
- ('on_key_release', dict(key='control')),
- *polygon_place_vertex(50, 150),
- *polygon_place_vertex(75, 50),
+ *polygon_place_vertex(ax, (50, 50)),
+ *polygon_place_vertex(ax, (150, 50)),
+ KeyEvent("key_press_event", ax.figure.canvas, "control"),
+ MouseEvent._from_ax_coords("motion_notify_event", ax, (50, 50)),
+ MouseEvent._from_ax_coords("button_press_event", ax, (50, 50), 1),
+ MouseEvent._from_ax_coords("motion_notify_event", ax, (75, 50)),
+ MouseEvent._from_ax_coords("button_release_event", ax, (75, 50), 1),
+ KeyEvent("key_release_event", ax.figure.canvas, "control"),
+ *polygon_place_vertex(ax, (50, 150)),
+ *polygon_place_vertex(ax, (75, 50)),
]
check_selector(event_sequence, expected_result, 1)
# Move first two vertices at once before completing the polygon.
expected_result = [(50, 75), (150, 75), (50, 150)]
event_sequence = [
- *polygon_place_vertex(50, 50),
- *polygon_place_vertex(150, 50),
- ('on_key_press', dict(key='shift')),
- ('onmove', dict(xdata=100, ydata=100)),
- ('press', dict(xdata=100, ydata=100)),
- ('onmove', dict(xdata=100, ydata=125)),
- ('release', dict(xdata=100, ydata=125)),
- ('on_key_release', dict(key='shift')),
- *polygon_place_vertex(50, 150),
- *polygon_place_vertex(50, 75),
+ *polygon_place_vertex(ax, (50, 50)),
+ *polygon_place_vertex(ax, (150, 50)),
+ KeyEvent("key_press_event", ax.figure.canvas, "shift"),
+ MouseEvent._from_ax_coords("motion_notify_event", ax, (100, 100)),
+ MouseEvent._from_ax_coords("button_press_event", ax, (100, 100), 1),
+ MouseEvent._from_ax_coords("motion_notify_event", ax, (100, 125)),
+ MouseEvent._from_ax_coords("button_release_event", ax, (100, 125), 1),
+ KeyEvent("key_release_event", ax.figure.canvas, "shift"),
+ *polygon_place_vertex(ax, (50, 150)),
+ *polygon_place_vertex(ax, (50, 75)),
]
check_selector(event_sequence, expected_result, 1)
# Move first vertex after completing the polygon.
- expected_result = [(75, 50), (150, 50), (50, 150)]
+ expected_result = [(85, 50), (150, 50), (50, 150)]
event_sequence = [
- *polygon_place_vertex(50, 50),
- *polygon_place_vertex(150, 50),
- *polygon_place_vertex(50, 150),
- *polygon_place_vertex(50, 50),
- ('onmove', dict(xdata=50, ydata=50)),
- ('press', dict(xdata=50, ydata=50)),
- ('onmove', dict(xdata=75, ydata=50)),
- ('release', dict(xdata=75, ydata=50)),
+ *polygon_place_vertex(ax, (60, 50)),
+ *polygon_place_vertex(ax, (150, 50)),
+ *polygon_place_vertex(ax, (50, 150)),
+ *polygon_place_vertex(ax, (60, 50)),
+ MouseEvent._from_ax_coords("motion_notify_event", ax, (60, 50)),
+ MouseEvent._from_ax_coords("button_press_event", ax, (60, 50), 1),
+ MouseEvent._from_ax_coords("motion_notify_event", ax, (85, 50)),
+ MouseEvent._from_ax_coords("button_release_event", ax, (85, 50), 1),
]
check_selector(event_sequence, expected_result, 2)
# Move all vertices after completing the polygon.
expected_result = [(75, 75), (175, 75), (75, 175)]
event_sequence = [
- *polygon_place_vertex(50, 50),
- *polygon_place_vertex(150, 50),
- *polygon_place_vertex(50, 150),
- *polygon_place_vertex(50, 50),
- ('on_key_press', dict(key='shift')),
- ('onmove', dict(xdata=100, ydata=100)),
- ('press', dict(xdata=100, ydata=100)),
- ('onmove', dict(xdata=125, ydata=125)),
- ('release', dict(xdata=125, ydata=125)),
- ('on_key_release', dict(key='shift')),
+ *polygon_place_vertex(ax, (50, 50)),
+ *polygon_place_vertex(ax, (150, 50)),
+ *polygon_place_vertex(ax, (50, 150)),
+ *polygon_place_vertex(ax, (50, 50)),
+ KeyEvent("key_press_event", ax.figure.canvas, "shift"),
+ MouseEvent._from_ax_coords("motion_notify_event", ax, (100, 100)),
+ MouseEvent._from_ax_coords("button_press_event", ax, (100, 100), 1),
+ MouseEvent._from_ax_coords("motion_notify_event", ax, (125, 125)),
+ MouseEvent._from_ax_coords("button_release_event", ax, (125, 125), 1),
+ KeyEvent("key_release_event", ax.figure.canvas, "shift"),
]
check_selector(event_sequence, expected_result, 2)
# Try to move a vertex and move all before placing any vertices.
expected_result = [(50, 50), (150, 50), (50, 150)]
event_sequence = [
- ('on_key_press', dict(key='control')),
- ('onmove', dict(xdata=100, ydata=100)),
- ('press', dict(xdata=100, ydata=100)),
- ('onmove', dict(xdata=125, ydata=125)),
- ('release', dict(xdata=125, ydata=125)),
- ('on_key_release', dict(key='control')),
- ('on_key_press', dict(key='shift')),
- ('onmove', dict(xdata=100, ydata=100)),
- ('press', dict(xdata=100, ydata=100)),
- ('onmove', dict(xdata=125, ydata=125)),
- ('release', dict(xdata=125, ydata=125)),
- ('on_key_release', dict(key='shift')),
- *polygon_place_vertex(50, 50),
- *polygon_place_vertex(150, 50),
- *polygon_place_vertex(50, 150),
- *polygon_place_vertex(50, 50),
+ KeyEvent("key_press_event", ax.figure.canvas, "control"),
+ MouseEvent._from_ax_coords("motion_notify_event", ax, (100, 100)),
+ MouseEvent._from_ax_coords("button_press_event", ax, (100, 100), 1),
+ MouseEvent._from_ax_coords("motion_notify_event", ax, (125, 125)),
+ MouseEvent._from_ax_coords("button_release_event", ax, (125, 125), 1),
+ KeyEvent("key_release_event", ax.figure.canvas, "control"),
+ KeyEvent("key_press_event", ax.figure.canvas, "shift"),
+ MouseEvent._from_ax_coords("motion_notify_event", ax, (100, 100)),
+ MouseEvent._from_ax_coords("button_press_event", ax, (100, 100), 1),
+ MouseEvent._from_ax_coords("motion_notify_event", ax, (125, 125)),
+ MouseEvent._from_ax_coords("button_release_event", ax, (125, 125), 1),
+ KeyEvent("key_release_event", ax.figure.canvas, "shift"),
+ *polygon_place_vertex(ax, (50, 50)),
+ *polygon_place_vertex(ax, (150, 50)),
+ *polygon_place_vertex(ax, (50, 150)),
+ *polygon_place_vertex(ax, (50, 50)),
]
check_selector(event_sequence, expected_result, 1)
# Try to place vertex out-of-bounds, then reset, and start a new polygon.
expected_result = [(50, 50), (150, 50), (50, 150)]
event_sequence = [
- *polygon_place_vertex(50, 50),
- *polygon_place_vertex(250, 50),
- ('on_key_press', dict(key='escape')),
- ('on_key_release', dict(key='escape')),
- *polygon_place_vertex(50, 50),
- *polygon_place_vertex(150, 50),
- *polygon_place_vertex(50, 150),
- *polygon_place_vertex(50, 50),
+ *polygon_place_vertex(ax, (50, 50)),
+ *polygon_place_vertex(ax, (250, 50)),
+ KeyEvent("key_press_event", ax.figure.canvas, "escape"),
+ KeyEvent("key_release_event", ax.figure.canvas, "escape"),
+ *polygon_place_vertex(ax, (50, 50)),
+ *polygon_place_vertex(ax, (150, 50)),
+ *polygon_place_vertex(ax, (50, 150)),
+ *polygon_place_vertex(ax, (50, 50)),
]
check_selector(event_sequence, expected_result, 1)
@pytest.mark.parametrize('draw_bounding_box', [False, True])
def test_polygon_selector_set_props_handle_props(ax, draw_bounding_box):
- tool = widgets.PolygonSelector(ax, onselect=noop,
+ tool = widgets.PolygonSelector(ax,
props=dict(color='b', alpha=0.2),
handle_props=dict(alpha=0.5),
draw_bounding_box=draw_bounding_box)
- event_sequence = [
- *polygon_place_vertex(50, 50),
- *polygon_place_vertex(150, 50),
- *polygon_place_vertex(50, 150),
- *polygon_place_vertex(50, 50),
- ]
-
- for (etype, event_args) in event_sequence:
- do_event(tool, etype, **event_args)
+ for event in [
+ *polygon_place_vertex(ax, (50, 50)),
+ *polygon_place_vertex(ax, (150, 50)),
+ *polygon_place_vertex(ax, (50, 150)),
+ *polygon_place_vertex(ax, (50, 50)),
+ ]:
+ event._process()
artist = tool._selection_artist
assert artist.get_color() == 'b'
@@ -1553,40 +1530,39 @@ def test_rect_visibility(fig_test, fig_ref):
ax_test = fig_test.subplots()
_ = fig_ref.subplots()
- tool = widgets.RectangleSelector(ax_test, onselect=noop,
- props={'visible': False})
+ tool = widgets.RectangleSelector(ax_test, props={'visible': False})
tool.extents = (0.2, 0.8, 0.3, 0.7)
# Change the order that the extra point is inserted in
@pytest.mark.parametrize('idx', [1, 2, 3])
@pytest.mark.parametrize('draw_bounding_box', [False, True])
-def test_polygon_selector_remove(idx, draw_bounding_box):
+def test_polygon_selector_remove(ax, idx, draw_bounding_box):
verts = [(50, 50), (150, 50), (50, 150)]
- event_sequence = [polygon_place_vertex(*verts[0]),
- polygon_place_vertex(*verts[1]),
- polygon_place_vertex(*verts[2]),
+ event_sequence = [polygon_place_vertex(ax, verts[0]),
+ polygon_place_vertex(ax, verts[1]),
+ polygon_place_vertex(ax, verts[2]),
# Finish the polygon
- polygon_place_vertex(*verts[0])]
+ polygon_place_vertex(ax, verts[0])]
# Add an extra point
- event_sequence.insert(idx, polygon_place_vertex(200, 200))
+ event_sequence.insert(idx, polygon_place_vertex(ax, (200, 200)))
# Remove the extra point
- event_sequence.append(polygon_remove_vertex(200, 200))
+ event_sequence.append(polygon_remove_vertex(ax, (200, 200)))
# Flatten list of lists
- event_sequence = sum(event_sequence, [])
+ event_sequence = functools.reduce(operator.iadd, event_sequence, [])
check_polygon_selector(event_sequence, verts, 2,
draw_bounding_box=draw_bounding_box)
@pytest.mark.parametrize('draw_bounding_box', [False, True])
-def test_polygon_selector_remove_first_point(draw_bounding_box):
+def test_polygon_selector_remove_first_point(ax, draw_bounding_box):
verts = [(50, 50), (150, 50), (50, 150)]
event_sequence = [
- *polygon_place_vertex(*verts[0]),
- *polygon_place_vertex(*verts[1]),
- *polygon_place_vertex(*verts[2]),
- *polygon_place_vertex(*verts[0]),
- *polygon_remove_vertex(*verts[0]),
+ *polygon_place_vertex(ax, verts[0]),
+ *polygon_place_vertex(ax, verts[1]),
+ *polygon_place_vertex(ax, verts[2]),
+ *polygon_place_vertex(ax, verts[0]),
+ *polygon_remove_vertex(ax, verts[0]),
]
check_polygon_selector(event_sequence, verts[1:], 2,
draw_bounding_box=draw_bounding_box)
@@ -1596,48 +1572,44 @@ def test_polygon_selector_remove_first_point(draw_bounding_box):
def test_polygon_selector_redraw(ax, draw_bounding_box):
verts = [(50, 50), (150, 50), (50, 150)]
event_sequence = [
- *polygon_place_vertex(*verts[0]),
- *polygon_place_vertex(*verts[1]),
- *polygon_place_vertex(*verts[2]),
- *polygon_place_vertex(*verts[0]),
+ *polygon_place_vertex(ax, verts[0]),
+ *polygon_place_vertex(ax, verts[1]),
+ *polygon_place_vertex(ax, verts[2]),
+ *polygon_place_vertex(ax, verts[0]),
# Polygon completed, now remove first two verts.
- *polygon_remove_vertex(*verts[1]),
- *polygon_remove_vertex(*verts[2]),
+ *polygon_remove_vertex(ax, verts[1]),
+ *polygon_remove_vertex(ax, verts[2]),
# At this point the tool should be reset so we can add more vertices.
- *polygon_place_vertex(*verts[1]),
+ *polygon_place_vertex(ax, verts[1]),
]
- tool = widgets.PolygonSelector(ax, onselect=noop,
- draw_bounding_box=draw_bounding_box)
- for (etype, event_args) in event_sequence:
- do_event(tool, etype, **event_args)
+ tool = widgets.PolygonSelector(ax, draw_bounding_box=draw_bounding_box)
+ for event in event_sequence:
+ event._process()
# After removing two verts, only one remains, and the
- # selector should be automatically resete
+ # selector should be automatically reset
assert tool.verts == verts[0:2]
@pytest.mark.parametrize('draw_bounding_box', [False, True])
-@check_figures_equal(extensions=['png'])
+@check_figures_equal()
def test_polygon_selector_verts_setter(fig_test, fig_ref, draw_bounding_box):
verts = [(0.1, 0.4), (0.5, 0.9), (0.3, 0.2)]
ax_test = fig_test.add_subplot()
- tool_test = widgets.PolygonSelector(
- ax_test, onselect=noop, draw_bounding_box=draw_bounding_box)
+ tool_test = widgets.PolygonSelector(ax_test, draw_bounding_box=draw_bounding_box)
tool_test.verts = verts
assert tool_test.verts == verts
ax_ref = fig_ref.add_subplot()
- tool_ref = widgets.PolygonSelector(
- ax_ref, onselect=noop, draw_bounding_box=draw_bounding_box)
- event_sequence = [
- *polygon_place_vertex(*verts[0]),
- *polygon_place_vertex(*verts[1]),
- *polygon_place_vertex(*verts[2]),
- *polygon_place_vertex(*verts[0]),
- ]
- for (etype, event_args) in event_sequence:
- do_event(tool_ref, etype, **event_args)
+ tool_ref = widgets.PolygonSelector(ax_ref, draw_bounding_box=draw_bounding_box)
+ for event in [
+ *polygon_place_vertex(ax_ref, verts[0]),
+ *polygon_place_vertex(ax_ref, verts[1]),
+ *polygon_place_vertex(ax_ref, verts[2]),
+ *polygon_place_vertex(ax_ref, verts[0]),
+ ]:
+ event._process()
def test_polygon_selector_box(ax):
@@ -1645,40 +1617,29 @@ def test_polygon_selector_box(ax):
ax.set(xlim=(-10, 50), ylim=(-10, 50))
verts = [(20, 0), (0, 20), (20, 40), (40, 20)]
event_sequence = [
- *polygon_place_vertex(*verts[0]),
- *polygon_place_vertex(*verts[1]),
- *polygon_place_vertex(*verts[2]),
- *polygon_place_vertex(*verts[3]),
- *polygon_place_vertex(*verts[0]),
+ *polygon_place_vertex(ax, verts[0]),
+ *polygon_place_vertex(ax, verts[1]),
+ *polygon_place_vertex(ax, verts[2]),
+ *polygon_place_vertex(ax, verts[3]),
+ *polygon_place_vertex(ax, verts[0]),
]
# Create selector
- tool = widgets.PolygonSelector(ax, onselect=noop, draw_bounding_box=True)
- for (etype, event_args) in event_sequence:
- do_event(tool, etype, **event_args)
-
- # In order to trigger the correct callbacks, trigger events on the canvas
- # instead of the individual tools
- t = ax.transData
- canvas = ax.figure.canvas
+ tool = widgets.PolygonSelector(ax, draw_bounding_box=True)
+ for event in event_sequence:
+ event._process()
# Scale to half size using the top right corner of the bounding box
- MouseEvent(
- "button_press_event", canvas, *t.transform((40, 40)), 1)._process()
- MouseEvent(
- "motion_notify_event", canvas, *t.transform((20, 20)))._process()
- MouseEvent(
- "button_release_event", canvas, *t.transform((20, 20)), 1)._process()
+ MouseEvent._from_ax_coords("button_press_event", ax, (40, 40), 1)._process()
+ MouseEvent._from_ax_coords("motion_notify_event", ax, (20, 20))._process()
+ MouseEvent._from_ax_coords("button_release_event", ax, (20, 20), 1)._process()
np.testing.assert_allclose(
tool.verts, [(10, 0), (0, 10), (10, 20), (20, 10)])
# Move using the center of the bounding box
- MouseEvent(
- "button_press_event", canvas, *t.transform((10, 10)), 1)._process()
- MouseEvent(
- "motion_notify_event", canvas, *t.transform((30, 30)))._process()
- MouseEvent(
- "button_release_event", canvas, *t.transform((30, 30)), 1)._process()
+ MouseEvent._from_ax_coords("button_press_event", ax, (10, 10), 1)._process()
+ MouseEvent._from_ax_coords("motion_notify_event", ax, (30, 30))._process()
+ MouseEvent._from_ax_coords("button_release_event", ax, (30, 30), 1)._process()
np.testing.assert_allclose(
tool.verts, [(30, 20), (20, 30), (30, 40), (40, 30)])
@@ -1686,10 +1647,8 @@ def test_polygon_selector_box(ax):
np.testing.assert_allclose(
tool._box.extents, (20.0, 40.0, 20.0, 40.0))
- MouseEvent(
- "button_press_event", canvas, *t.transform((30, 20)), 3)._process()
- MouseEvent(
- "button_release_event", canvas, *t.transform((30, 20)), 3)._process()
+ MouseEvent._from_ax_coords("button_press_event", ax, (30, 20), 3)._process()
+ MouseEvent._from_ax_coords("button_release_event", ax, (30, 20), 3)._process()
np.testing.assert_allclose(
tool.verts, [(20, 30), (30, 40), (40, 30)])
np.testing.assert_allclose(
@@ -1702,9 +1661,9 @@ def test_polygon_selector_clear_method(ax):
for result in ([(50, 50), (150, 50), (50, 150), (50, 50)],
[(50, 50), (100, 50), (50, 150), (50, 50)]):
- for x, y in result:
- for etype, event_args in polygon_place_vertex(x, y):
- do_event(tool, etype, **event_args)
+ for xy in result:
+ for event in polygon_place_vertex(ax, xy):
+ event._process()
artist = tool._selection_artist
@@ -1722,7 +1681,8 @@ def test_polygon_selector_clear_method(ax):
@pytest.mark.parametrize("horizOn", [False, True])
@pytest.mark.parametrize("vertOn", [False, True])
def test_MultiCursor(horizOn, vertOn):
- (ax1, ax3) = plt.figure().subplots(2, sharex=True)
+ fig = plt.figure()
+ (ax1, ax3) = fig.subplots(2, sharex=True)
ax2 = plt.figure().subplots()
# useblit=false to avoid having to draw the figure to cache the renderer
@@ -1734,13 +1694,9 @@ def test_MultiCursor(horizOn, vertOn):
assert len(multi.vlines) == 2
assert len(multi.hlines) == 2
- # mock a motion_notify_event
- # Can't use `do_event` as that helper requires the widget
- # to have a single .ax attribute.
- event = mock_event(ax1, xdata=.5, ydata=.25)
- multi.onmove(event)
+ MouseEvent._from_ax_coords("motion_notify_event", ax1, (.5, .25))._process()
# force a draw + draw event to exercise clear
- ax1.figure.canvas.draw()
+ fig.canvas.draw()
# the lines in the first two ax should both move
for l in multi.vlines:
@@ -1756,8 +1712,7 @@ def test_MultiCursor(horizOn, vertOn):
# After toggling settings, the opposite lines should be visible after move.
multi.horizOn = not multi.horizOn
multi.vertOn = not multi.vertOn
- event = mock_event(ax1, xdata=.5, ydata=.25)
- multi.onmove(event)
+ MouseEvent._from_ax_coords("motion_notify_event", ax1, (.5, .25))._process()
assert len([line for line in multi.vlines if line.get_visible()]) == (
0 if vertOn else 2)
assert len([line for line in multi.hlines if line.get_visible()]) == (
@@ -1765,9 +1720,31 @@ def test_MultiCursor(horizOn, vertOn):
# test a move event in an Axes not part of the MultiCursor
# the lines in ax1 and ax2 should not have moved.
- event = mock_event(ax3, xdata=.75, ydata=.75)
- multi.onmove(event)
+ MouseEvent._from_ax_coords("motion_notify_event", ax3, (.75, .75))._process()
for l in multi.vlines:
assert l.get_xdata() == (.5, .5)
for l in multi.hlines:
assert l.get_ydata() == (.25, .25)
+
+
+def test_parent_axes_removal():
+
+ fig, (ax_radio, ax_checks) = plt.subplots(1, 2)
+
+ radio = widgets.RadioButtons(ax_radio, ['1', '2'], 0)
+ checks = widgets.CheckButtons(ax_checks, ['1', '2'], [True, False])
+
+ ax_checks.remove()
+ ax_radio.remove()
+ with io.BytesIO() as out:
+ # verify that saving does not raise
+ fig.savefig(out, format='raw')
+
+ # verify that this method which is triggered by a draw_event callback when
+ # blitting is enabled does not raise. Calling private methods is simpler
+ # than trying to force blitting to be enabled with Agg or use a GUI
+ # framework.
+ renderer = fig._get_renderer()
+ evt = DrawEvent('draw_event', fig.canvas, renderer)
+ radio._clear(evt)
+ checks._clear(evt)
diff --git a/lib/matplotlib/tests/tinypages/.gitignore b/lib/matplotlib/tests/tinypages/.gitignore
deleted file mode 100644
index 69fa449dd96e..000000000000
--- a/lib/matplotlib/tests/tinypages/.gitignore
+++ /dev/null
@@ -1 +0,0 @@
-_build/
diff --git a/lib/matplotlib/texmanager.py b/lib/matplotlib/texmanager.py
index 812eab58b877..35651a94aa85 100644
--- a/lib/matplotlib/texmanager.py
+++ b/lib/matplotlib/texmanager.py
@@ -23,7 +23,6 @@
import functools
import hashlib
import logging
-import os
from pathlib import Path
import subprocess
from tempfile import TemporaryDirectory
@@ -31,7 +30,7 @@
import numpy as np
import matplotlib as mpl
-from matplotlib import _api, cbook, dviread
+from matplotlib import cbook, dviread
_log = logging.getLogger(__name__)
@@ -63,11 +62,17 @@ class TexManager:
Repeated calls to this constructor always return the same instance.
"""
- texcache = _api.deprecate_privatize_attribute("3.8")
- _texcache = os.path.join(mpl.get_cachedir(), 'tex.cache')
+ _cache_dir = Path(mpl.get_cachedir(), 'tex.cache')
_grey_arrayd = {}
_font_families = ('serif', 'sans-serif', 'cursive', 'monospace')
+ # Check for the cm-super package (which registers unicode computer modern
+ # support just by being installed) without actually loading any package
+ # (because we already load the incompatible fix-cm).
+ _check_cmsuper_installed = (
+ r'\IfFileExists{type1ec.sty}{}{\PackageError{matplotlib-support}{'
+ r'Missing cm-super package, required by Matplotlib}{}}'
+ )
_font_preambles = {
'new century schoolbook': r'\renewcommand{\rmdefault}{pnc}',
'bookman': r'\renewcommand{\rmdefault}{pbk}',
@@ -81,13 +86,10 @@ class TexManager:
'helvetica': r'\usepackage{helvet}',
'avant garde': r'\usepackage{avant}',
'courier': r'\usepackage{courier}',
- # Loading the type1ec package ensures that cm-super is installed, which
- # is necessary for Unicode computer modern. (It also allows the use of
- # computer modern at arbitrary sizes, but that's just a side effect.)
- 'monospace': r'\usepackage{type1ec}',
- 'computer modern roman': r'\usepackage{type1ec}',
- 'computer modern sans serif': r'\usepackage{type1ec}',
- 'computer modern typewriter': r'\usepackage{type1ec}',
+ 'monospace': _check_cmsuper_installed,
+ 'computer modern roman': _check_cmsuper_installed,
+ 'computer modern sans serif': _check_cmsuper_installed,
+ 'computer modern typewriter': _check_cmsuper_installed,
}
_font_types = {
'new century schoolbook': 'serif',
@@ -106,7 +108,7 @@ class TexManager:
@functools.lru_cache # Always return the same instance.
def __new__(cls):
- Path(cls._texcache).mkdir(parents=True, exist_ok=True)
+ cls._cache_dir.mkdir(parents=True, exist_ok=True)
return object.__new__(cls)
@classmethod
@@ -134,18 +136,16 @@ def _get_font_preamble_and_command(cls):
preambles[font_family] = cls._font_preambles[
mpl.rcParams['font.family'][0].lower()]
else:
- for font in mpl.rcParams['font.' + font_family]:
- if font.lower() in cls._font_preambles:
- preambles[font_family] = \
- cls._font_preambles[font.lower()]
+ rcfonts = mpl.rcParams[f"font.{font_family}"]
+ for i, font in enumerate(map(str.lower, rcfonts)):
+ if font in cls._font_preambles:
+ preambles[font_family] = cls._font_preambles[font]
_log.debug(
- 'family: %s, font: %s, info: %s',
- font_family, font,
- cls._font_preambles[font.lower()])
+ 'family: %s, package: %s, font: %s, skipped: %s',
+ font_family, cls._font_preambles[font], rcfonts[i],
+ ', '.join(rcfonts[:i]),
+ )
break
- else:
- _log.debug('%s font is not compatible with usetex.',
- font)
else:
_log.info('No LaTeX-compatible font found for the %s font'
'family in rcParams. Using default.',
@@ -166,20 +166,30 @@ def _get_font_preamble_and_command(cls):
return preamble, fontcmd
@classmethod
- def get_basefile(cls, tex, fontsize, dpi=None):
+ def _get_base_path(cls, tex, fontsize, dpi=None):
"""
- Return a filename based on a hash of the string, fontsize, and dpi.
+ Return a file path based on a hash of the string, fontsize, and dpi.
"""
src = cls._get_tex_source(tex, fontsize) + str(dpi)
- filehash = hashlib.md5(src.encode('utf-8')).hexdigest()
- filepath = Path(cls._texcache)
+ filehash = hashlib.sha256(
+ src.encode('utf-8'),
+ usedforsecurity=False
+ ).hexdigest()
+ filepath = cls._cache_dir
num_letters, num_levels = 2, 2
for i in range(0, num_letters*num_levels, num_letters):
- filepath = filepath / Path(filehash[i:i+2])
+ filepath = filepath / filehash[i:i+2]
filepath.mkdir(parents=True, exist_ok=True)
- return os.path.join(filepath, filehash)
+ return filepath / filehash
+
+ @classmethod
+ def get_basefile(cls, tex, fontsize, dpi=None): # Kept for backcompat.
+ """
+ Return a filename based on a hash of the string, fontsize, and dpi.
+ """
+ return str(cls._get_base_path(tex, fontsize, dpi))
@classmethod
def get_font_preamble(cls):
@@ -200,6 +210,7 @@ def _get_tex_source(cls, tex, fontsize):
font_preamble, fontcmd = cls._get_font_preamble_and_command()
baselineskip = 1.25 * fontsize
return "\n".join([
+ r"\RequirePackage{fix-cm}",
r"\documentclass{article}",
r"% Pass-through \mathdefault, which is used in non-usetex mode",
r"% to use the default text font but was historically suppressed",
@@ -223,8 +234,6 @@ def _get_tex_source(cls, tex, fontsize):
r"\begin{document}",
r"% The empty hbox ensures that a page is printed even for empty",
r"% inputs, except when using psfrag which gets confused by it.",
- r"% matplotlibbaselinemarker is used by dviread to detect the",
- r"% last line's baseline.",
rf"\fontsize{{{fontsize}}}{{{baselineskip}}}%",
r"\ifdefined\psfrag\else\hbox{}\fi%",
rf"{{{fontcmd} {tex}}}%",
@@ -238,17 +247,16 @@ def make_tex(cls, tex, fontsize):
Return the file name.
"""
- texfile = cls.get_basefile(tex, fontsize) + ".tex"
- Path(texfile).write_text(cls._get_tex_source(tex, fontsize),
- encoding='utf-8')
- return texfile
+ texpath = cls._get_base_path(tex, fontsize).with_suffix(".tex")
+ texpath.write_text(cls._get_tex_source(tex, fontsize), encoding='utf-8')
+ return str(texpath)
@classmethod
def _run_checked_subprocess(cls, command, tex, *, cwd=None):
_log.debug(cbook._pformat_subprocess(command))
try:
report = subprocess.check_output(
- command, cwd=cwd if cwd is not None else cls._texcache,
+ command, cwd=cwd if cwd is not None else cls._cache_dir,
stderr=subprocess.STDOUT)
except FileNotFoundError as exc:
raise RuntimeError(
@@ -276,11 +284,9 @@ def make_dvi(cls, tex, fontsize):
Return the file name.
"""
- basefile = cls.get_basefile(tex, fontsize)
- dvifile = '%s.dvi' % basefile
- if not os.path.exists(dvifile):
- texfile = Path(cls.make_tex(tex, fontsize))
- # Generate the dvi in a temporary directory to avoid race
+ dvipath = cls._get_base_path(tex, fontsize).with_suffix(".dvi")
+ if not dvipath.exists():
+ # Generate the tex and dvi in a temporary directory to avoid race
# conditions e.g. if multiple processes try to process the same tex
# string at the same time. Having tmpdir be a subdirectory of the
# final output dir ensures that they are on the same filesystem,
@@ -289,15 +295,17 @@ def make_dvi(cls, tex, fontsize):
# the absolute path may contain characters (e.g. ~) that TeX does
# not support; n.b. relative paths cannot traverse parents, or it
# will be blocked when `openin_any = p` in texmf.cnf).
- cwd = Path(dvifile).parent
- with TemporaryDirectory(dir=cwd) as tmpdir:
- tmppath = Path(tmpdir)
+ with TemporaryDirectory(dir=dvipath.parent) as tmpdir:
+ Path(tmpdir, "file.tex").write_text(
+ cls._get_tex_source(tex, fontsize), encoding='utf-8')
cls._run_checked_subprocess(
["latex", "-interaction=nonstopmode", "--halt-on-error",
- f"--output-directory={tmppath.name}",
- f"{texfile.name}"], tex, cwd=cwd)
- (tmppath / Path(dvifile).name).replace(dvifile)
- return dvifile
+ "file.tex"], tex, cwd=tmpdir)
+ Path(tmpdir, "file.dvi").replace(dvipath)
+ # Also move the tex source to the main cache directory, but
+ # only for backcompat.
+ Path(tmpdir, "file.tex").replace(dvipath.with_suffix(".tex"))
+ return str(dvipath)
@classmethod
def make_png(cls, tex, fontsize, dpi):
@@ -306,35 +314,33 @@ def make_png(cls, tex, fontsize, dpi):
Return the file name.
"""
- basefile = cls.get_basefile(tex, fontsize, dpi)
- pngfile = '%s.png' % basefile
- # see get_rgba for a discussion of the background
- if not os.path.exists(pngfile):
- dvifile = cls.make_dvi(tex, fontsize)
- cmd = ["dvipng", "-bg", "Transparent", "-D", str(dpi),
- "-T", "tight", "-o", pngfile, dvifile]
- # When testing, disable FreeType rendering for reproducibility; but
- # dvipng 1.16 has a bug (fixed in f3ff241) that breaks --freetype0
- # mode, so for it we keep FreeType enabled; the image will be
- # slightly off.
- if (getattr(mpl, "_called_from_pytest", False) and
- mpl._get_executable_info("dvipng").raw_version != "1.16"):
- cmd.insert(1, "--freetype0")
- cls._run_checked_subprocess(cmd, tex)
- return pngfile
+ pngpath = cls._get_base_path(tex, fontsize, dpi).with_suffix(".png")
+ if not pngpath.exists():
+ dvipath = cls.make_dvi(tex, fontsize)
+ with TemporaryDirectory(dir=pngpath.parent) as tmpdir:
+ cmd = ["dvipng", "-bg", "Transparent", "-D", str(dpi),
+ "-T", "tight", "-o", "file.png", dvipath]
+ # When testing, disable FreeType rendering for reproducibility;
+ # but dvipng 1.16 has a bug (fixed in f3ff241) that breaks
+ # --freetype0 mode, so for it we keep FreeType enabled; the
+ # image will be slightly off.
+ if (getattr(mpl, "_called_from_pytest", False) and
+ mpl._get_executable_info("dvipng").raw_version != "1.16"):
+ cmd.insert(1, "--freetype0")
+ cls._run_checked_subprocess(cmd, tex, cwd=tmpdir)
+ Path(tmpdir, "file.png").replace(pngpath)
+ return str(pngpath)
@classmethod
def get_grey(cls, tex, fontsize=None, dpi=None):
"""Return the alpha channel."""
- if not fontsize:
- fontsize = mpl.rcParams['font.size']
- if not dpi:
- dpi = mpl.rcParams['savefig.dpi']
+ fontsize = mpl._val_or_rc(fontsize, 'font.size')
+ dpi = mpl._val_or_rc(dpi, 'savefig.dpi')
key = cls._get_tex_source(tex, fontsize), dpi
alpha = cls._grey_arrayd.get(key)
if alpha is None:
pngfile = cls.make_png(tex, fontsize, dpi)
- rgba = mpl.image.imread(os.path.join(cls._texcache, pngfile))
+ rgba = mpl.image.imread(pngfile)
cls._grey_arrayd[key] = alpha = rgba[:, :, -1]
return alpha
@@ -360,9 +366,9 @@ def get_text_width_height_descent(cls, tex, fontsize, renderer=None):
"""Return width, height and descent of the text."""
if tex.strip() == '':
return 0, 0, 0
- dvifile = cls.make_dvi(tex, fontsize)
+ dvipath = cls.make_dvi(tex, fontsize)
dpi_fraction = renderer.points_to_pixels(1.) if renderer else 1
- with dviread.Dvi(dvifile, 72 * dpi_fraction) as dvi:
+ with dviread.Dvi(dvipath, 72 * dpi_fraction) as dvi:
page, = dvi
# A total height (including the descent) needs to be returned.
return page.width, page.height + page.descent, page.descent
diff --git a/lib/matplotlib/text.py b/lib/matplotlib/text.py
index af990ec1bf9f..acde4fb179a2 100644
--- a/lib/matplotlib/text.py
+++ b/lib/matplotlib/text.py
@@ -188,8 +188,7 @@ def _reset_visual_defaults(
linespacing = 1.2 # Maybe use rcParam later.
self.set_linespacing(linespacing)
self.set_rotation_mode(rotation_mode)
- self.set_antialiased(antialiased if antialiased is not None else
- mpl.rcParams['text.antialiased'])
+ self.set_antialiased(mpl._val_or_rc(antialiased, 'text.antialiased'))
def update(self, kwargs):
# docstring inherited
@@ -301,16 +300,19 @@ def set_rotation_mode(self, m):
Parameters
----------
- m : {None, 'default', 'anchor'}
+ m : {None, 'default', 'anchor', 'xtick', 'ytick'}
If ``"default"``, the text will be first rotated, then aligned according
to their horizontal and vertical alignments. If ``"anchor"``, then
- alignment occurs before rotation. Passing ``None`` will set the rotation
- mode to ``"default"``.
+ alignment occurs before rotation. "xtick" and "ytick" adjust the
+ horizontal/vertical alignment so that the text is visually pointing
+ towards its anchor point. This is primarily used for rotated tick
+ labels and positions them nicely towards their ticks. Passing
+ ``None`` will set the rotation mode to ``"default"``.
"""
if m is None:
m = "default"
else:
- _api.check_in_list(("anchor", "default"), rotation_mode=m)
+ _api.check_in_list(("anchor", "default", "xtick", "ytick"), rotation_mode=m)
self._rotation_mode = m
self.stale = True
@@ -372,7 +374,8 @@ def _get_layout(self, renderer):
# Full vertical extent of font, including ascenders and descenders:
_, lp_h, lp_d = _get_text_metrics_with_cache(
renderer, "lp", self._fontproperties,
- ismath="TeX" if self.get_usetex() else False, dpi=self.figure.dpi)
+ ismath="TeX" if self.get_usetex() else False,
+ dpi=self.get_figure(root=True).dpi)
min_dy = (lp_h - lp_d) * self._linespacing
for i, line in enumerate(lines):
@@ -380,7 +383,7 @@ def _get_layout(self, renderer):
if clean_line:
w, h, d = _get_text_metrics_with_cache(
renderer, clean_line, self._fontproperties,
- ismath=ismath, dpi=self.figure.dpi)
+ ismath=ismath, dpi=self.get_figure(root=True).dpi)
else:
w = h = d = 0
@@ -453,6 +456,11 @@ def _get_layout(self, renderer):
rotation_mode = self.get_rotation_mode()
if rotation_mode != "anchor":
+ angle = self.get_rotation()
+ if rotation_mode == 'xtick':
+ halign = self._ha_for_angle(angle)
+ elif rotation_mode == 'ytick':
+ valign = self._va_for_angle(angle)
# compute the text location in display coords and the offsets
# necessary to align the bbox with that location
if halign == 'center':
@@ -508,14 +516,21 @@ def _get_layout(self, renderer):
def set_bbox(self, rectprops):
"""
- Draw a bounding box around self.
+ Draw a box behind/around the text.
+
+ This can be used to set a background and/or a frame around the text.
+ It's realized through a `.FancyBboxPatch` behind the text (see also
+ `.Text.get_bbox_patch`). The bbox patch is None by default and only
+ created when needed.
Parameters
----------
- rectprops : dict with properties for `.patches.FancyBboxPatch`
+ rectprops : dict with properties for `.FancyBboxPatch` or None
The default boxstyle is 'square'. The mutation
scale of the `.patches.FancyBboxPatch` is set to the fontsize.
+ Pass ``None`` to remove the bbox patch completely.
+
Examples
--------
::
@@ -550,6 +565,8 @@ def get_bbox_patch(self):
"""
Return the bbox Patch, or None if the `.patches.FancyBboxPatch`
is not made.
+
+ For more details see `.Text.set_bbox`.
"""
return self._bbox_patch
@@ -677,10 +694,10 @@ def _get_rendered_text_width(self, text):
Return the width of a given text string, in pixels.
"""
- w, h, d = self._renderer.get_text_width_height_descent(
- text,
- self.get_fontproperties(),
- cbook.is_math_text(text))
+ w, h, d = _get_text_metrics_with_cache(
+ self._renderer, text, self.get_fontproperties(),
+ cbook.is_math_text(text),
+ self.get_figure(root=True).dpi)
return math.ceil(w)
def _get_wrapped_text(self):
@@ -753,9 +770,16 @@ def draw(self, renderer):
# don't use self.get_position here, which refers to text
# position in Text:
- posx = float(self.convert_xunits(self._x))
- posy = float(self.convert_yunits(self._y))
+ x, y = self._x, self._y
+ if np.ma.is_masked(x):
+ x = np.nan
+ if np.ma.is_masked(y):
+ y = np.nan
+ posx = float(self.convert_xunits(x))
+ posy = float(self.convert_yunits(y))
posx, posy = trans.transform((posx, posy))
+ if np.isnan(posx) or np.isnan(posy):
+ return # don't throw a warning here
if not np.isfinite(posx) or not np.isfinite(posy):
_log.warning("posx and posy should be finite values")
return
@@ -934,28 +958,30 @@ def get_window_extent(self, renderer=None, dpi=None):
dpi : float, optional
The dpi value for computing the bbox, defaults to
- ``self.figure.dpi`` (*not* the renderer dpi); should be set e.g. if
- to match regions with a figure saved with a custom dpi value.
+ ``self.get_figure(root=True).dpi`` (*not* the renderer dpi); should be set
+ e.g. if to match regions with a figure saved with a custom dpi value.
"""
if not self.get_visible():
return Bbox.unit()
+
+ fig = self.get_figure(root=True)
if dpi is None:
- dpi = self.figure.dpi
+ dpi = fig.dpi
if self.get_text() == '':
- with cbook._setattr_cm(self.figure, dpi=dpi):
+ with cbook._setattr_cm(fig, dpi=dpi):
tx, ty = self._get_xy_display()
return Bbox.from_bounds(tx, ty, 0, 0)
if renderer is not None:
self._renderer = renderer
if self._renderer is None:
- self._renderer = self.figure._get_renderer()
+ self._renderer = fig._get_renderer()
if self._renderer is None:
raise RuntimeError(
"Cannot get window extent of text w/o renderer. You likely "
"want to call 'figure.draw_without_rendering()' first.")
- with cbook._setattr_cm(self.figure, dpi=dpi):
+ with cbook._setattr_cm(fig, dpi=dpi):
bbox, info, descent = self._get_layout(self._renderer)
x, y = self.get_unitless_position()
x, y = self.get_transform().transform((x, y))
@@ -964,7 +990,10 @@ def get_window_extent(self, renderer=None, dpi=None):
def set_backgroundcolor(self, color):
"""
- Set the background color of the text by updating the bbox.
+ Set the background color of the text.
+
+ This is realized through the bbox (see `.set_bbox`),
+ creating the bbox patch if needed.
Parameters
----------
@@ -1326,10 +1355,7 @@ def set_usetex(self, usetex):
Whether to render using TeX, ``None`` means to use
:rc:`text.usetex`.
"""
- if usetex is None:
- self._usetex = mpl.rcParams['text.usetex']
- else:
- self._usetex = bool(usetex)
+ self._usetex = bool(mpl._val_or_rc(usetex, 'text.usetex'))
self.stale = True
def get_usetex(self):
@@ -1370,6 +1396,32 @@ def set_fontname(self, fontname):
"""
self.set_fontfamily(fontname)
+ def _ha_for_angle(self, angle):
+ """
+ Determines horizontal alignment ('ha') for rotation_mode "xtick" based on
+ the angle of rotation in degrees and the vertical alignment.
+ """
+ anchor_at_bottom = self.get_verticalalignment() == 'bottom'
+ if (angle <= 10 or 85 <= angle <= 95 or 350 <= angle or
+ 170 <= angle <= 190 or 265 <= angle <= 275):
+ return 'center'
+ elif 10 < angle < 85 or 190 < angle < 265:
+ return 'left' if anchor_at_bottom else 'right'
+ return 'right' if anchor_at_bottom else 'left'
+
+ def _va_for_angle(self, angle):
+ """
+ Determines vertical alignment ('va') for rotation_mode "ytick" based on
+ the angle of rotation in degrees and the horizontal alignment.
+ """
+ anchor_at_left = self.get_horizontalalignment() == 'left'
+ if (angle <= 10 or 350 <= angle or 170 <= angle <= 190
+ or 80 <= angle <= 100 or 260 <= angle <= 280):
+ return 'center'
+ elif 190 < angle < 260 or 10 < angle < 80:
+ return 'baseline' if anchor_at_left else 'top'
+ return 'top' if anchor_at_left else 'baseline'
+
class OffsetFrom:
"""Callable helper class for working with `Annotation`."""
@@ -1501,9 +1553,7 @@ def _get_xy_transform(self, renderer, coords):
return self.axes.transData
elif coords == 'polar':
from matplotlib.projections import PolarAxes
- tr = PolarAxes.PolarTransform(apply_theta_transforms=False)
- trans = tr + self.axes.transData
- return trans
+ return PolarAxes.PolarTransform() + self.axes.transData
try:
bbox_name, unit = coords.split()
@@ -1514,9 +1564,9 @@ def _get_xy_transform(self, renderer, coords):
# if unit is offset-like
if bbox_name == "figure":
- bbox0 = self.figure.figbbox
+ bbox0 = self.get_figure(root=False).figbbox
elif bbox_name == "subfigure":
- bbox0 = self.figure.bbox
+ bbox0 = self.get_figure(root=False).bbox
elif bbox_name == "axes":
bbox0 = self.axes.bbox
@@ -1529,11 +1579,13 @@ def _get_xy_transform(self, renderer, coords):
raise ValueError(f"{coords!r} is not a valid coordinate")
if unit == "points":
- tr = Affine2D().scale(self.figure.dpi / 72) # dpi/72 dots per point
+ tr = Affine2D().scale(
+ self.get_figure(root=True).dpi / 72) # dpi/72 dots per point
elif unit == "pixels":
tr = Affine2D()
elif unit == "fontsize":
- tr = Affine2D().scale(self.get_size() * self.figure.dpi / 72)
+ tr = Affine2D().scale(
+ self.get_size() * self.get_figure(root=True).dpi / 72)
elif unit == "fraction":
tr = Affine2D().scale(*bbox0.size)
else:
@@ -1571,7 +1623,7 @@ def _get_position_xy(self, renderer):
def _check_xy(self, renderer=None):
"""Check whether the annotation at *xy_pixel* should be drawn."""
if renderer is None:
- renderer = self.figure._get_renderer()
+ renderer = self.get_figure(root=True)._get_renderer()
b = self.get_annotation_clip()
if b or (b is None and self.xycoords == "data"):
# check if self.xy is inside the Axes.
@@ -1837,10 +1889,6 @@ def transform(renderer) -> Transform
# modified YAArrow API to be used with FancyArrowPatch
for key in ['width', 'headwidth', 'headlength', 'shrink']:
arrowprops.pop(key, None)
- if 'frac' in arrowprops:
- _api.warn_deprecated(
- "3.8", name="the (unused) 'frac' key in 'arrowprops'")
- arrowprops.pop("frac")
self.arrow_patch = FancyArrowPatch((0, 0), (1, 1), **arrowprops)
else:
self.arrow_patch = None
@@ -1848,7 +1896,6 @@ def transform(renderer) -> Transform
# Must come last, as some kwargs may be propagated to arrow_patch.
Text.__init__(self, x, y, text, **kwargs)
- @_api.rename_parameter("3.8", "event", "mouseevent")
def contains(self, mouseevent):
if self._different_canvas(mouseevent):
return False, {}
@@ -1987,8 +2034,9 @@ def draw(self, renderer):
self.update_positions(renderer)
self.update_bbox_position_size(renderer)
if self.arrow_patch is not None: # FancyArrowPatch
- if self.arrow_patch.figure is None and self.figure is not None:
- self.arrow_patch.figure = self.figure
+ if (self.arrow_patch.get_figure(root=False) is None and
+ (fig := self.get_figure(root=False)) is not None):
+ self.arrow_patch.set_figure(fig)
self.arrow_patch.draw(renderer)
# Draw text, including FancyBboxPatch, after FancyArrowPatch.
# Otherwise, a wedge arrowstyle can land partly on top of the Bbox.
@@ -2003,7 +2051,7 @@ def get_window_extent(self, renderer=None):
if renderer is not None:
self._renderer = renderer
if self._renderer is None:
- self._renderer = self.figure._get_renderer()
+ self._renderer = self.get_figure(root=True)._get_renderer()
if self._renderer is None:
raise RuntimeError('Cannot get window extent without renderer')
@@ -2024,4 +2072,4 @@ def get_tightbbox(self, renderer=None):
return super().get_tightbbox(renderer)
-_docstring.interpd.update(Annotation=Annotation.__init__.__doc__)
+_docstring.interpd.register(Annotation=Annotation.__init__.__doc__)
diff --git a/lib/matplotlib/text.pyi b/lib/matplotlib/text.pyi
index 6a83b1bbbed9..41c7b761ae32 100644
--- a/lib/matplotlib/text.pyi
+++ b/lib/matplotlib/text.pyi
@@ -4,7 +4,7 @@ from .font_manager import FontProperties
from .offsetbox import DraggableAnnotation
from .path import Path
from .patches import FancyArrowPatch, FancyBboxPatch
-from .textpath import ( # noqa: reexported API
+from .textpath import ( # noqa: F401, reexported API
TextPath as TextPath,
TextToPath as TextToPath,
)
@@ -14,9 +14,9 @@ from .transforms import (
Transform,
)
-from collections.abc import Callable, Iterable
+from collections.abc import Iterable
from typing import Any, Literal
-from .typing import ColorType
+from .typing import ColorType, CoordsType
class Text(Artist):
zorder: float
@@ -46,9 +46,9 @@ class Text(Artist):
def update(self, kwargs: dict[str, Any]) -> list[Any]: ...
def get_rotation(self) -> float: ...
def get_transform_rotates_text(self) -> bool: ...
- def set_rotation_mode(self, m: None | Literal["default", "anchor"]) -> None: ...
- def get_rotation_mode(self) -> Literal["default", "anchor"]: ...
- def set_bbox(self, rectprops: dict[str, Any]) -> None: ...
+ def set_rotation_mode(self, m: None | Literal["default", "anchor", "xtick", "ytick"]) -> None: ...
+ def get_rotation_mode(self) -> Literal["default", "anchor", "xtick", "ytick"]: ...
+ def set_bbox(self, rectprops: dict[str, Any] | None) -> None: ...
def get_bbox_patch(self) -> None | FancyBboxPatch: ...
def update_bbox_position_size(self, renderer: RendererBase) -> None: ...
def get_wrap(self) -> bool: ...
@@ -106,6 +106,8 @@ class Text(Artist):
def set_fontname(self, fontname: str | Iterable[str]) -> None: ...
def get_antialiased(self) -> bool: ...
def set_antialiased(self, antialiased: bool) -> None: ...
+ def _ha_for_angle(self, angle: Any) -> Literal['center', 'right', 'left'] | None: ...
+ def _va_for_angle(self, angle: Any) -> Literal['center', 'top', 'baseline'] | None: ...
class OffsetFrom:
def __init__(
@@ -120,17 +122,11 @@ class OffsetFrom:
class _AnnotationBase:
xy: tuple[float, float]
- xycoords: str | tuple[str, str] | Artist | Transform | Callable[
- [RendererBase], Bbox | Transform
- ]
+ xycoords: CoordsType
def __init__(
self,
xy,
- xycoords: str
- | tuple[str, str]
- | Artist
- | Transform
- | Callable[[RendererBase], Bbox | Transform] = ...,
+ xycoords: CoordsType = ...,
annotation_clip: bool | None = ...,
) -> None: ...
def set_annotation_clip(self, b: bool | None) -> None: ...
@@ -147,17 +143,8 @@ class Annotation(Text, _AnnotationBase):
text: str,
xy: tuple[float, float],
xytext: tuple[float, float] | None = ...,
- xycoords: str
- | tuple[str, str]
- | Artist
- | Transform
- | Callable[[RendererBase], Bbox | Transform] = ...,
- textcoords: str
- | tuple[str, str]
- | Artist
- | Transform
- | Callable[[RendererBase], Bbox | Transform]
- | None = ...,
+ xycoords: CoordsType = ...,
+ textcoords: CoordsType | None = ...,
arrowprops: dict[str, Any] | None = ...,
annotation_clip: bool | None = ...,
**kwargs
@@ -165,17 +152,11 @@ class Annotation(Text, _AnnotationBase):
@property
def xycoords(
self,
- ) -> str | tuple[str, str] | Artist | Transform | Callable[
- [RendererBase], Bbox | Transform
- ]: ...
+ ) -> CoordsType: ...
@xycoords.setter
def xycoords(
self,
- xycoords: str
- | tuple[str, str]
- | Artist
- | Transform
- | Callable[[RendererBase], Bbox | Transform],
+ xycoords: CoordsType,
) -> None: ...
@property
def xyann(self) -> tuple[float, float]: ...
@@ -183,31 +164,19 @@ class Annotation(Text, _AnnotationBase):
def xyann(self, xytext: tuple[float, float]) -> None: ...
def get_anncoords(
self,
- ) -> str | tuple[str, str] | Artist | Transform | Callable[
- [RendererBase], Bbox | Transform
- ]: ...
+ ) -> CoordsType: ...
def set_anncoords(
self,
- coords: str
- | tuple[str, str]
- | Artist
- | Transform
- | Callable[[RendererBase], Bbox | Transform],
+ coords: CoordsType,
) -> None: ...
@property
def anncoords(
self,
- ) -> str | tuple[str, str] | Artist | Transform | Callable[
- [RendererBase], Bbox | Transform
- ]: ...
+ ) -> CoordsType: ...
@anncoords.setter
def anncoords(
self,
- coords: str
- | tuple[str, str]
- | Artist
- | Transform
- | Callable[[RendererBase], Bbox | Transform],
+ coords: CoordsType,
) -> None: ...
def update_positions(self, renderer: RendererBase) -> None: ...
# Drops `dpi` parameter from superclass
diff --git a/lib/matplotlib/textpath.py b/lib/matplotlib/textpath.py
index c00966d6e6c3..b57597ded363 100644
--- a/lib/matplotlib/textpath.py
+++ b/lib/matplotlib/textpath.py
@@ -8,7 +8,7 @@
from matplotlib.font_manager import (
FontProperties, get_font, fontManager as _fontManager
)
-from matplotlib.ft2font import LOAD_NO_HINTING, LOAD_TARGET_LIGHT
+from matplotlib.ft2font import LoadFlags
from matplotlib.mathtext import MathTextParser
from matplotlib.path import Path
from matplotlib.texmanager import TexManager
@@ -37,7 +37,7 @@ def _get_font(self, prop):
return font
def _get_hinting_flag(self):
- return LOAD_NO_HINTING
+ return LoadFlags.NO_HINTING
def _get_char_id(self, font, ccode):
"""
@@ -61,7 +61,7 @@ def get_text_width_height_descent(self, s, prop, ismath):
return width * scale, height * scale, descent * scale
font = self._get_font(prop)
- font.set_text(s, 0.0, flags=LOAD_NO_HINTING)
+ font.set_text(s, 0.0, flags=LoadFlags.NO_HINTING)
w, h = font.get_width_height()
w /= 64.0 # convert from subpixels
h /= 64.0
@@ -190,7 +190,7 @@ def get_glyphs_mathtext(self, prop, s, glyph_map=None,
if char_id not in glyph_map:
font.clear()
font.set_size(self.FONT_SCALE, self.DPI)
- font.load_char(ccode, flags=LOAD_NO_HINTING)
+ font.load_char(ccode, flags=LoadFlags.NO_HINTING)
glyph_map_new[char_id] = font.get_path()
xpositions.append(ox)
@@ -232,23 +232,14 @@ def get_glyphs_tex(self, prop, s, glyph_map=None,
# Gather font information and do some setup for combining
# characters into strings.
+ t1_encodings = {}
for text in page.text:
font = get_font(text.font_path)
char_id = self._get_char_id(font, text.glyph)
if char_id not in glyph_map:
font.clear()
font.set_size(self.FONT_SCALE, self.DPI)
- glyph_name_or_index = text.glyph_name_or_index
- if isinstance(glyph_name_or_index, str):
- index = font.get_name_index(glyph_name_or_index)
- font.load_glyph(index, flags=LOAD_TARGET_LIGHT)
- elif isinstance(glyph_name_or_index, int):
- self._select_native_charmap(font)
- font.load_char(
- glyph_name_or_index, flags=LOAD_TARGET_LIGHT)
- else: # Should not occur.
- raise TypeError(f"Glyph spec of unexpected type: "
- f"{glyph_name_or_index!r}")
+ font.load_glyph(text.index, flags=LoadFlags.TARGET_LIGHT)
glyph_map_new[char_id] = font.get_path()
glyph_ids.append(char_id)
@@ -269,23 +260,6 @@ def get_glyphs_tex(self, prop, s, glyph_map=None,
return (list(zip(glyph_ids, xpositions, ypositions, sizes)),
glyph_map_new, myrects)
- @staticmethod
- def _select_native_charmap(font):
- # Select the native charmap. (we can't directly identify it but it's
- # typically an Adobe charmap).
- for charmap_code in [
- 1094992451, # ADOBE_CUSTOM.
- 1094995778, # ADOBE_STANDARD.
- ]:
- try:
- font.select_charmap(charmap_code)
- except (ValueError, RuntimeError):
- pass
- else:
- break
- else:
- _log.warning("No supported encoding in font (%s).", font.fname)
-
text_to_path = TextToPath()
diff --git a/lib/matplotlib/ticker.py b/lib/matplotlib/ticker.py
index 2b00937f9e29..f82eeedc8918 100644
--- a/lib/matplotlib/ticker.py
+++ b/lib/matplotlib/ticker.py
@@ -407,6 +407,11 @@ class ScalarFormatter(Formatter):
useLocale : bool, default: :rc:`axes.formatter.use_locale`.
Whether to use locale settings for decimal sign and positive sign.
See `.set_useLocale`.
+ usetex : bool, default: :rc:`text.usetex`
+ To enable/disable the use of TeX's math mode for rendering the
+ numbers in the formatter.
+
+ .. versionadded:: 3.10
Notes
-----
@@ -435,22 +440,21 @@ class ScalarFormatter(Formatter):
lim = (1_000_000, 1_000_010)
fig, (ax1, ax2, ax3) = plt.subplots(3, 1, gridspec_kw={'hspace': 2})
- ax1.set(title='offset_notation', xlim=lim)
+ ax1.set(title='offset notation', xlim=lim)
ax2.set(title='scientific notation', xlim=lim)
ax2.xaxis.get_major_formatter().set_useOffset(False)
- ax3.set(title='floating point notation', xlim=lim)
+ ax3.set(title='floating-point notation', xlim=lim)
ax3.xaxis.get_major_formatter().set_useOffset(False)
ax3.xaxis.get_major_formatter().set_scientific(False)
"""
- def __init__(self, useOffset=None, useMathText=None, useLocale=None):
- if useOffset is None:
- useOffset = mpl.rcParams['axes.formatter.useoffset']
- self._offset_threshold = \
- mpl.rcParams['axes.formatter.offset_threshold']
+ def __init__(self, useOffset=None, useMathText=None, useLocale=None, *,
+ usetex=None):
+ useOffset = mpl._val_or_rc(useOffset, 'axes.formatter.useoffset')
+ self._offset_threshold = mpl.rcParams['axes.formatter.offset_threshold']
self.set_useOffset(useOffset)
- self._usetex = mpl.rcParams['text.usetex']
+ self.set_usetex(usetex)
self.set_useMathText(useMathText)
self.orderOfMagnitude = 0
self.format = ''
@@ -458,6 +462,16 @@ def __init__(self, useOffset=None, useMathText=None, useLocale=None):
self._powerlimits = mpl.rcParams['axes.formatter.limits']
self.set_useLocale(useLocale)
+ def get_usetex(self):
+ """Return whether TeX's math mode is enabled for rendering."""
+ return self._usetex
+
+ def set_usetex(self, val):
+ """Set whether to use TeX's math mode for rendering numbers in the formatter."""
+ self._usetex = mpl._val_or_rc(val, 'text.usetex')
+
+ usetex = property(fget=get_usetex, fset=set_usetex)
+
def get_useOffset(self):
"""
Return whether automatic mode for offset notation is active.
@@ -498,7 +512,7 @@ def set_useOffset(self, val):
will be formatted as ``0, 2, 4, 6, 8`` plus an offset ``+1e5``, which
is written to the edge of the axis.
"""
- if val in [True, False]:
+ if isinstance(val, bool):
self.offset = 0
self._useOffset = val
else:
@@ -526,10 +540,7 @@ def set_useLocale(self, val):
val : bool or None
*None* resets to :rc:`axes.formatter.use_locale`.
"""
- if val is None:
- self._useLocale = mpl.rcParams['axes.formatter.use_locale']
- else:
- self._useLocale = val
+ self._useLocale = mpl._val_or_rc(val, 'axes.formatter.use_locale')
useLocale = property(fget=get_useLocale, fset=set_useLocale)
@@ -574,7 +585,7 @@ def set_useMathText(self, val):
from matplotlib import font_manager
ufont = font_manager.findfont(
font_manager.FontProperties(
- mpl.rcParams["font.family"]
+ family=mpl.rcParams["font.family"]
),
fallback_to_default=False,
)
@@ -847,20 +858,23 @@ class LogFormatter(Formatter):
labelOnlyBase : bool, default: False
If True, label ticks only at integer powers of base.
- This is normally True for major ticks and False for
- minor ticks.
+ This is normally True for major ticks and False for minor ticks.
minor_thresholds : (subset, all), default: (1, 0.4)
If labelOnlyBase is False, these two numbers control
the labeling of ticks that are not at integer powers of
- base; normally these are the minor ticks. The controlling
- parameter is the log of the axis data range. In the typical
- case where base is 10 it is the number of decades spanned
- by the axis, so we can call it 'numdec'. If ``numdec <= all``,
- all minor ticks will be labeled. If ``all < numdec <= subset``,
- then only a subset of minor ticks will be labeled, so as to
- avoid crowding. If ``numdec > subset`` then no minor ticks will
- be labeled.
+ base; normally these are the minor ticks.
+
+ The first number (*subset*) is the largest number of major ticks for
+ which minor ticks are labeled; e.g., the default, 1, means that minor
+ ticks are labeled as long as there is no more than 1 major tick. (It
+ is assumed that major ticks are at integer powers of *base*.)
+
+ The second number (*all*) is a threshold, in log-units of the axis
+ limit range, over which only a subset of the minor ticks are labeled,
+ so as to avoid crowding; e.g., with the default value (0.4) and the
+ usual ``base=10``, all minor ticks are shown only if the axis limit
+ range spans less than 0.4 decades.
linthresh : None or float, default: None
If a symmetric log scale is in use, its ``linthresh``
@@ -884,12 +898,9 @@ class LogFormatter(Formatter):
Examples
--------
- To label a subset of minor ticks when the view limits span up
- to 2 decades, and all of the ticks when zoomed in to 0.5 decades
- or less, use ``minor_thresholds=(2, 0.5)``.
-
- To label all minor ticks when the view limits span up to 1.5
- decades, use ``minor_thresholds=(1.5, 1.5)``.
+ To label a subset of minor ticks when there are up to 2 major ticks,
+ and all of the ticks when zoomed in to 0.5 decades or less, use
+ ``minor_thresholds=(2, 0.5)``.
"""
def __init__(self, base=10.0, labelOnlyBase=False,
@@ -957,22 +968,32 @@ def set_locs(self, locs=None):
return
b = self._base
+
if linthresh is not None: # symlog
- # Only compute the number of decades in the logarithmic part of the
- # axis
- numdec = 0
+ # Only count ticks and decades in the logarithmic part of the axis.
+ numdec = numticks = 0
if vmin < -linthresh:
rhs = min(vmax, -linthresh)
- numdec += math.log(vmin / rhs) / math.log(b)
+ numticks += (
+ math.floor(math.log(abs(rhs), b))
+ - math.floor(math.nextafter(math.log(abs(vmin), b), -math.inf)))
+ numdec += math.log(vmin / rhs, b)
if vmax > linthresh:
lhs = max(vmin, linthresh)
- numdec += math.log(vmax / lhs) / math.log(b)
+ numticks += (
+ math.floor(math.log(vmax, b))
+ - math.floor(math.nextafter(math.log(lhs, b), -math.inf)))
+ numdec += math.log(vmax / lhs, b)
else:
- vmin = math.log(vmin) / math.log(b)
- vmax = math.log(vmax) / math.log(b)
- numdec = abs(vmax - vmin)
-
- if numdec > self.minor_thresholds[0]:
+ lmin = math.log(vmin, b)
+ lmax = math.log(vmax, b)
+ # The nextafter call handles the case where vmin is exactly at a
+ # decade (e.g. there's one major tick between 1 and 5).
+ numticks = (math.floor(lmax)
+ - math.floor(math.nextafter(lmin, -math.inf)))
+ numdec = abs(lmax - lmin)
+
+ if numticks > self.minor_thresholds[0]:
# Label only bases
self._sublabels = {1}
elif numdec > self.minor_thresholds[1]:
@@ -987,13 +1008,7 @@ def set_locs(self, locs=None):
self._sublabels = set(np.arange(1, b + 1))
def _num_to_string(self, x, vmin, vmax):
- if x > 10000:
- s = '%1.0e' % x
- elif x < 1:
- s = '%1.0e' % x
- else:
- s = self._pprint_val(x, vmax - vmin)
- return s
+ return self._pprint_val(x, vmax - vmin) if 1 <= x <= 10000 else f"{x:1.0e}"
def __call__(self, x, pos=None):
# docstring inherited
@@ -1053,15 +1068,14 @@ class LogFormatterExponent(LogFormatter):
"""
Format values for log axis using ``exponent = log_base(value)``.
"""
+
def _num_to_string(self, x, vmin, vmax):
fx = math.log(x) / math.log(self._base)
- if abs(fx) > 10000:
- s = '%1.0g' % fx
- elif abs(fx) < 1:
- s = '%1.0g' % fx
- else:
+ if 1 <= abs(fx) <= 10000:
fd = math.log(vmax - vmin) / math.log(self._base)
s = self._pprint_val(fx, fd)
+ else:
+ s = f"{fx:1.0g}"
return s
@@ -1146,10 +1160,10 @@ def __init__(
Parameters
----------
use_overline : bool, default: False
- If x > 1/2, with x = 1-v, indicate if x should be displayed as
- $\overline{v}$. The default is to display $1-v$.
+ If x > 1/2, with x = 1 - v, indicate if x should be displayed as
+ $\overline{v}$. The default is to display $1 - v$.
- one_half : str, default: r"\frac{1}{2}"
+ one_half : str, default: r"\\frac{1}{2}"
The string used to represent 1/2.
minor : bool, default: False
@@ -1179,9 +1193,9 @@ def use_overline(self, use_overline):
Parameters
----------
- use_overline : bool, default: False
- If x > 1/2, with x = 1-v, indicate if x should be displayed as
- $\overline{v}$. The default is to display $1-v$.
+ use_overline : bool
+ If x > 1/2, with x = 1 - v, indicate if x should be displayed as
+ $\overline{v}$. The default is to display $1 - v$.
"""
self._use_overline = use_overline
@@ -1189,7 +1203,7 @@ def set_one_half(self, one_half):
r"""
Set the way one half is displayed.
- one_half : str, default: r"\frac{1}{2}"
+ one_half : str
The string used to represent 1/2.
"""
self._one_half = one_half
@@ -1324,7 +1338,7 @@ def format_data_short(self, value):
return f"1-{1 - value:e}"
-class EngFormatter(Formatter):
+class EngFormatter(ScalarFormatter):
"""
Format axis values using engineering prefixes to represent powers
of 1000, plus a specified unit, e.g., 10 MHz instead of 1e7.
@@ -1356,7 +1370,7 @@ class EngFormatter(Formatter):
}
def __init__(self, unit="", places=None, sep=" ", *, usetex=None,
- useMathText=None):
+ useMathText=None, useOffset=False):
r"""
Parameters
----------
@@ -1390,76 +1404,124 @@ def __init__(self, unit="", places=None, sep=" ", *, usetex=None,
useMathText : bool, default: :rc:`axes.formatter.use_mathtext`
To enable/disable the use mathtext for rendering the numbers in
the formatter.
+ useOffset : bool or float, default: False
+ Whether to use offset notation with :math:`10^{3*N}` based prefixes.
+ This features allows showing an offset with standard SI order of
+ magnitude prefix near the axis. Offset is computed similarly to
+ how `ScalarFormatter` computes it internally, but here you are
+ guaranteed to get an offset which will make the tick labels exceed
+ 3 digits. See also `.set_useOffset`.
+
+ .. versionadded:: 3.10
"""
self.unit = unit
self.places = places
self.sep = sep
- self.set_usetex(usetex)
- self.set_useMathText(useMathText)
-
- def get_usetex(self):
- return self._usetex
-
- def set_usetex(self, val):
- if val is None:
- self._usetex = mpl.rcParams['text.usetex']
- else:
- self._usetex = val
-
- usetex = property(fget=get_usetex, fset=set_usetex)
+ super().__init__(
+ useOffset=useOffset,
+ useMathText=useMathText,
+ useLocale=False,
+ usetex=usetex,
+ )
- def get_useMathText(self):
- return self._useMathText
+ def __call__(self, x, pos=None):
+ """
+ Return the format for tick value *x* at position *pos*.
- def set_useMathText(self, val):
- if val is None:
- self._useMathText = mpl.rcParams['axes.formatter.use_mathtext']
+ If there is no currently offset in the data, it returns the best
+ engineering formatting that fits the given argument, independently.
+ """
+ if len(self.locs) == 0 or self.offset == 0:
+ return self.fix_minus(self.format_data(x))
else:
- self._useMathText = val
+ xp = (x - self.offset) / (10. ** self.orderOfMagnitude)
+ if abs(xp) < 1e-8:
+ xp = 0
+ return self._format_maybe_minus_and_locale(self.format, xp)
- useMathText = property(fget=get_useMathText, fset=set_useMathText)
+ def set_locs(self, locs):
+ # docstring inherited
+ self.locs = locs
+ if len(self.locs) > 0:
+ vmin, vmax = sorted(self.axis.get_view_interval())
+ if self._useOffset:
+ self._compute_offset()
+ if self.offset != 0:
+ # We don't want to use the offset computed by
+ # self._compute_offset because it rounds the offset unaware
+ # of our engineering prefixes preference, and this can
+ # cause ticks with 4+ digits to appear. These ticks are
+ # slightly less readable, so if offset is justified
+ # (decided by self._compute_offset) we set it to better
+ # value:
+ self.offset = round((vmin + vmax)/2, 3)
+ # Use log1000 to use engineers' oom standards
+ self.orderOfMagnitude = math.floor(math.log(vmax - vmin, 1000))*3
+ self._set_format()
- def __call__(self, x, pos=None):
- s = f"{self.format_eng(x)}{self.unit}"
- # Remove the trailing separator when there is neither prefix nor unit
- if self.sep and s.endswith(self.sep):
- s = s[:-len(self.sep)]
- return self.fix_minus(s)
+ # Simplify a bit ScalarFormatter.get_offset: We always want to use
+ # self.format_data. Also we want to return a non-empty string only if there
+ # is an offset, no matter what is self.orderOfMagnitude. If there _is_ an
+ # offset, self.orderOfMagnitude is consulted. This behavior is verified
+ # in `test_ticker.py`.
+ def get_offset(self):
+ # docstring inherited
+ if len(self.locs) == 0:
+ return ''
+ if self.offset:
+ offsetStr = ''
+ if self.offset:
+ offsetStr = self.format_data(self.offset)
+ if self.offset > 0:
+ offsetStr = '+' + offsetStr
+ sciNotStr = self.format_data(10 ** self.orderOfMagnitude)
+ if self._useMathText or self._usetex:
+ if sciNotStr != '':
+ sciNotStr = r'\times%s' % sciNotStr
+ s = f'${sciNotStr}{offsetStr}$'
+ else:
+ s = sciNotStr + offsetStr
+ return self.fix_minus(s)
+ return ''
def format_eng(self, num):
+ """Alias to EngFormatter.format_data"""
+ return self.format_data(num)
+
+ def format_data(self, value):
"""
Format a number in engineering notation, appending a letter
representing the power of 1000 of the original number.
Some examples:
- >>> format_eng(0) # for self.places = 0
+ >>> format_data(0) # for self.places = 0
'0'
- >>> format_eng(1000000) # for self.places = 1
+ >>> format_data(1000000) # for self.places = 1
'1.0 M'
- >>> format_eng(-1e-6) # for self.places = 2
+ >>> format_data(-1e-6) # for self.places = 2
'-1.00 \N{MICRO SIGN}'
"""
sign = 1
fmt = "g" if self.places is None else f".{self.places:d}f"
- if num < 0:
+ if value < 0:
sign = -1
- num = -num
+ value = -value
- if num != 0:
- pow10 = int(math.floor(math.log10(num) / 3) * 3)
+ if value != 0:
+ pow10 = int(math.floor(math.log10(value) / 3) * 3)
else:
pow10 = 0
- # Force num to zero, to avoid inconsistencies like
+ # Force value to zero, to avoid inconsistencies like
# format_eng(-0) = "0" and format_eng(0.0) = "0"
# but format_eng(-0.0) = "-0.0"
- num = 0.0
+ value = 0.0
pow10 = np.clip(pow10, min(self.ENG_PREFIXES), max(self.ENG_PREFIXES))
- mant = sign * num / (10.0 ** pow10)
+ mant = sign * value / (10.0 ** pow10)
# Taking care of the cases like 999.9..., which may be rounded to 1000
# instead of 1 k. Beware of the corner case of values that are beyond
# the range of SI prefixes (i.e. > 'Y').
@@ -1468,13 +1530,15 @@ def format_eng(self, num):
mant /= 1000
pow10 += 3
- prefix = self.ENG_PREFIXES[int(pow10)]
+ unit_prefix = self.ENG_PREFIXES[int(pow10)]
+ if self.unit or unit_prefix:
+ suffix = f"{self.sep}{unit_prefix}{self.unit}"
+ else:
+ suffix = ""
if self._usetex or self._useMathText:
- formatted = f"${mant:{fmt}}${self.sep}{prefix}"
+ return f"${mant:{fmt}}${suffix}"
else:
- formatted = f"{mant:{fmt}}{self.sep}{prefix}"
-
- return formatted
+ return f"{mant:{fmt}}{suffix}"
class PercentFormatter(Formatter):
@@ -1684,6 +1748,7 @@ class IndexLocator(Locator):
IndexLocator assumes index plotting; i.e., that the ticks are placed at integer
values in the range between 0 and len(data) inclusive.
"""
+
def __init__(self, base, offset):
"""Place ticks every *base* data point, starting at *offset*."""
self._base = base
@@ -1707,14 +1772,14 @@ def tick_values(self, vmin, vmax):
class FixedLocator(Locator):
- """
+ r"""
Place ticks at a set of fixed values.
If *nbins* is None ticks are placed at all values. Otherwise, the *locs* array of
- possible positions will be subsampled to keep the number of ticks <=
- :math:`nbins* +1`. The subsampling will be done to include the smallest absolute
- value; for example, if zero is included in the array of possibilities, then it of
- the chosen ticks.
+ possible positions will be subsampled to keep the number of ticks
+ :math:`\leq nbins + 1`. The subsampling will be done to include the smallest
+ absolute value; for example, if zero is included in the array of possibilities, then
+ it will be included in the chosen ticks.
"""
def __init__(self, locs, nbins=None):
@@ -1736,9 +1801,7 @@ def tick_values(self, vmin, vmax):
.. note::
- Because the values are fixed, vmin and vmax are not used in this
- method.
-
+ Because the values are fixed, *vmin* and *vmax* are not used.
"""
if self.nbins is None:
return self.locs
@@ -1753,7 +1816,7 @@ def tick_values(self, vmin, vmax):
class NullLocator(Locator):
"""
- No ticks
+ Place no ticks.
"""
def __call__(self):
@@ -1765,8 +1828,7 @@ def tick_values(self, vmin, vmax):
.. note::
- Because the values are Null, vmin and vmax are not used in this
- method.
+ Because there are no ticks, *vmin* and *vmax* are not used.
"""
return []
@@ -1775,12 +1837,11 @@ class LinearLocator(Locator):
"""
Place ticks at evenly spaced values.
- The first time this function is called it will try to set the
- number of ticks to make a nice tick partitioning. Thereafter, the
- number of ticks will be fixed so that interactive navigation will
- be nice
-
+ The first time this function is called, it will try to set the number of
+ ticks to make a nice tick partitioning. Thereafter, the number of ticks
+ will be fixed to avoid jumping during interactive navigation.
"""
+
def __init__(self, numticks=None, presets=None):
"""
Parameters
@@ -1861,9 +1922,9 @@ def __init__(self, base=1.0, offset=0.0):
"""
Parameters
----------
- base : float > 0
+ base : float > 0, default: 1.0
Interval between ticks.
- offset : float
+ offset : float, default: 0.0
Value added to each multiple of *base*.
.. versionadded:: 3.8
@@ -1877,9 +1938,9 @@ def set_params(self, base=None, offset=None):
Parameters
----------
- base : float > 0
+ base : float > 0, optional
Interval between ticks.
- offset : float
+ offset : float, optional
Value added to each multiple of *base*.
.. versionadded:: 3.8
@@ -1940,6 +2001,7 @@ class _Edge_integer:
Take floating-point precision limitations into account when calculating
tick locations as integer multiples of a step.
"""
+
def __init__(self, step, offset):
"""
Parameters
@@ -2275,8 +2337,7 @@ class LogLocator(Locator):
Places ticks at the values ``subs[j] * base**i``.
"""
- @_api.delete_parameter("3.8", "numdecs")
- def __init__(self, base=10.0, subs=(1.0,), numdecs=4, numticks=None):
+ def __init__(self, base=10.0, subs=(1.0,), *, numticks=None):
"""
Parameters
----------
@@ -2305,24 +2366,17 @@ def __init__(self, base=10.0, subs=(1.0,), numdecs=4, numticks=None):
numticks = 'auto'
self._base = float(base)
self._set_subs(subs)
- self._numdecs = numdecs
self.numticks = numticks
- @_api.delete_parameter("3.8", "numdecs")
- def set_params(self, base=None, subs=None, numdecs=None, numticks=None):
+ def set_params(self, base=None, subs=None, *, numticks=None):
"""Set parameters within this locator."""
if base is not None:
self._base = float(base)
if subs is not None:
self._set_subs(subs)
- if numdecs is not None:
- self._numdecs = numdecs
if numticks is not None:
self.numticks = numticks
- numdecs = _api.deprecate_privatize_attribute(
- "3.8", addendum="This attribute has no effect.")
-
def _set_subs(self, subs):
"""
Set the minor ticks for the log scaling every ``base**i*subs[j]``.
@@ -2349,14 +2403,19 @@ def __call__(self):
vmin, vmax = self.axis.get_view_interval()
return self.tick_values(vmin, vmax)
+ def _log_b(self, x):
+ # Use specialized logs if possible, as they can be more accurate; e.g.
+ # log(.001) / log(10) = -2.999... (whether math.log or np.log) due to
+ # floating point error.
+ return (np.log10(x) if self._base == 10 else
+ np.log2(x) if self._base == 2 else
+ np.log(x) / np.log(self._base))
+
def tick_values(self, vmin, vmax):
- if self.numticks == 'auto':
- if self.axis is not None:
- numticks = np.clip(self.axis.get_tick_space(), 2, 9)
- else:
- numticks = 9
- else:
- numticks = self.numticks
+ n_request = (
+ self.numticks if self.numticks != "auto" else
+ np.clip(self.axis.get_tick_space(), 2, 9) if self.axis is not None else
+ 9)
b = self._base
if vmin <= 0.0:
@@ -2367,17 +2426,17 @@ def tick_values(self, vmin, vmax):
raise ValueError(
"Data has no positive values, and therefore cannot be log-scaled.")
- _log.debug('vmin %s vmax %s', vmin, vmax)
-
if vmax < vmin:
vmin, vmax = vmax, vmin
- log_vmin = math.log(vmin) / math.log(b)
- log_vmax = math.log(vmax) / math.log(b)
-
- numdec = math.floor(log_vmax) - math.ceil(log_vmin)
+ # Min and max exponents, float and int versions; e.g., if vmin=10^0.3,
+ # vmax=10^6.9, then efmin=0.3, emin=1, emax=6, efmax=6.9, n_avail=6.
+ efmin, efmax = self._log_b([vmin, vmax])
+ emin = math.ceil(efmin)
+ emax = math.floor(efmax)
+ n_avail = emax - emin + 1 # Total number of decade ticks available.
if isinstance(self._subs, str):
- if numdec > 10 or b < 3:
+ if n_avail >= 10 or b < 3:
if self._subs == 'auto':
return np.array([]) # no minor or major ticks
else:
@@ -2388,41 +2447,87 @@ def tick_values(self, vmin, vmax):
else:
subs = self._subs
- # Get decades between major ticks.
- stride = (max(math.ceil(numdec / (numticks - 1)), 1)
- if mpl.rcParams['_internal.classic_mode'] else
- numdec // numticks + 1)
-
- # if we have decided that the stride is as big or bigger than
- # the range, clip the stride back to the available range - 1
- # with a floor of 1. This prevents getting axis with only 1 tick
- # visible.
- if stride >= numdec:
- stride = max(1, numdec - 1)
-
- # Does subs include anything other than 1? Essentially a hack to know
- # whether we're a major or a minor locator.
- have_subs = len(subs) > 1 or (len(subs) == 1 and subs[0] != 1.0)
-
- decades = np.arange(math.floor(log_vmin) - stride,
- math.ceil(log_vmax) + 2 * stride, stride)
-
- if have_subs:
- if stride == 1:
- ticklocs = np.concatenate(
- [subs * decade_start for decade_start in b ** decades])
+ # Get decades between major ticks. Include an extra tick outside the
+ # lower and the upper limit: QuadContourSet._autolev relies on this.
+ if mpl.rcParams["_internal.classic_mode"]: # keep historic formulas
+ stride = max(math.ceil((n_avail - 1) / (n_request - 1)), 1)
+ decades = np.arange(emin - stride, emax + stride + 1, stride)
+ else:
+ # *Determine the actual number of ticks*: Find the largest number
+ # of ticks, no more than the requested number, that can actually
+ # be drawn (e.g., with 9 decades ticks, no stride yields 7
+ # ticks). For a given value of the stride *s*, there are either
+ # floor(n_avail/s) or ceil(n_avail/s) ticks depending on the
+ # offset. Pick the smallest stride such that floor(n_avail/s) <
+ # n_request, i.e. n_avail/s < n_request+1, then re-set n_request
+ # to ceil(...) if acceptable, else to floor(...) (which must then
+ # equal the original n_request, i.e. n_request is kept unchanged).
+ stride = n_avail // (n_request + 1) + 1
+ nr = math.ceil(n_avail / stride)
+ if nr <= n_request:
+ n_request = nr
+ else:
+ assert nr == n_request + 1
+ if n_request == 0: # No tick in bounds; two ticks just outside.
+ decades = [emin - 1, emax + 1]
+ stride = decades[1] - decades[0]
+ elif n_request == 1: # A single tick close to center.
+ mid = round((efmin + efmax) / 2)
+ stride = max(mid - (emin - 1), (emax + 1) - mid)
+ decades = [mid - stride, mid, mid + stride]
+ else:
+ # *Determine the stride*: Pick the largest stride that yields
+ # this actual n_request (e.g., with 15 decades, strides of
+ # 5, 6, or 7 *can* yield 3 ticks; picking a larger stride
+ # minimizes unticked space at the ends). First try for
+ # ceil(n_avail/stride) == n_request
+ # i.e.
+ # n_avail/n_request <= stride < n_avail/(n_request-1)
+ # else fallback to
+ # floor(n_avail/stride) == n_request
+ # i.e.
+ # n_avail/(n_request+1) < stride <= n_avail/n_request
+ # One of these cases must have an integer solution (given the
+ # choice of n_request above).
+ stride = (n_avail - 1) // (n_request - 1)
+ if stride < n_avail / n_request: # fallback to second case
+ stride = n_avail // n_request
+ # *Determine the offset*: For a given stride *and offset*
+ # (0 <= offset < stride), the actual number of ticks is
+ # ceil((n_avail - offset) / stride), which must be equal to
+ # n_request. This leads to olo <= offset < ohi, with the
+ # values defined below.
+ olo = max(n_avail - stride * n_request, 0)
+ ohi = min(n_avail - stride * (n_request - 1), stride)
+ # Try to see if we can pick an offset so that ticks are at
+ # integer multiples of the stride while satisfying the bounds
+ # above; if not, fallback to the smallest acceptable offset.
+ offset = (-emin) % stride
+ if not olo <= offset < ohi:
+ offset = olo
+ decades = range(emin + offset - stride, emax + stride + 1, stride)
+
+ # Guess whether we're a minor locator, based on whether subs include
+ # anything other than 1.
+ is_minor = len(subs) > 1 or (len(subs) == 1 and subs[0] != 1.0)
+ if is_minor:
+ if stride == 1 or n_avail <= 1:
+ # Minor ticks start in the decade preceding the first major tick.
+ ticklocs = np.concatenate([
+ subs * b**decade for decade in range(emin - 1, emax + 1)])
else:
ticklocs = np.array([])
else:
- ticklocs = b ** decades
+ ticklocs = b ** np.array(decades)
- _log.debug('ticklocs %r', ticklocs)
if (len(subs) > 1
and stride == 1
- and ((vmin <= ticklocs) & (ticklocs <= vmax)).sum() <= 1):
+ and (len(decades) - 2 # major
+ + ((vmin <= ticklocs) & (ticklocs <= vmax)).sum()) # minor
+ <= 1):
# If we're a minor locator *that expects at least two ticks per
# decade* and the major locator stride is 1 and there's no more
- # than one minor tick, switch to AutoLocator.
+ # than one major or minor tick, switch to AutoLocator.
return AutoLocator().tick_values(vmin, vmax)
else:
return self.raise_if_exceeds(ticklocs)
@@ -2881,20 +2986,21 @@ class AutoMinorLocator(Locator):
Place evenly spaced minor ticks, with the step size and maximum number of ticks
chosen automatically.
- The Axis scale must be linear with evenly spaced major ticks .
+ The Axis must use a linear scale and have evenly spaced major ticks.
"""
def __init__(self, n=None):
"""
- *n* is the number of subdivisions of the interval between
- major ticks; e.g., n=2 will place a single minor tick midway
- between major ticks.
-
- If *n* is omitted or None, the value stored in rcParams will be used.
- In case *n* is set to 'auto', it will be set to 4 or 5. If the distance
- between the major ticks equals 1, 2.5, 5 or 10 it can be perfectly
- divided in 5 equidistant sub-intervals with a length multiple of
- 0.05. Otherwise it is divided in 4 sub-intervals.
+ Parameters
+ ----------
+ n : int or 'auto', default: :rc:`xtick.minor.ndivs` or :rc:`ytick.minor.ndivs`
+ The number of subdivisions of the interval between major ticks;
+ e.g., n=2 will place a single minor tick midway between major ticks.
+
+ If *n* is 'auto', it will be set to 4 or 5: if the distance
+ between the major ticks equals 1, 2.5, 5 or 10 it can be perfectly
+ divided in 5 equidistant sub-intervals with a length multiple of
+ 0.05; otherwise, it is divided in 4 sub-intervals.
"""
self.ndivs = n
diff --git a/lib/matplotlib/ticker.pyi b/lib/matplotlib/ticker.pyi
index f026b4943c94..bed288658909 100644
--- a/lib/matplotlib/ticker.pyi
+++ b/lib/matplotlib/ticker.pyi
@@ -64,8 +64,16 @@ class ScalarFormatter(Formatter):
useOffset: bool | float | None = ...,
useMathText: bool | None = ...,
useLocale: bool | None = ...,
+ *,
+ usetex: bool | None = ...,
) -> None: ...
offset: float
+ def get_usetex(self) -> bool: ...
+ def set_usetex(self, val: bool) -> None: ...
+ @property
+ def usetex(self) -> bool: ...
+ @usetex.setter
+ def usetex(self, val: bool) -> None: ...
def get_useOffset(self) -> bool: ...
def set_useOffset(self, val: bool | float) -> None: ...
@property
@@ -125,7 +133,7 @@ class LogitFormatter(Formatter):
def set_minor_number(self, minor_number: int) -> None: ...
def format_data_short(self, value: float) -> str: ...
-class EngFormatter(Formatter):
+class EngFormatter(ScalarFormatter):
ENG_PREFIXES: dict[int, str]
unit: str
places: int | None
@@ -137,20 +145,9 @@ class EngFormatter(Formatter):
sep: str = ...,
*,
usetex: bool | None = ...,
- useMathText: bool | None = ...
+ useMathText: bool | None = ...,
+ useOffset: bool | float | None = ...,
) -> None: ...
- def get_usetex(self) -> bool: ...
- def set_usetex(self, val: bool | None) -> None: ...
- @property
- def usetex(self) -> bool: ...
- @usetex.setter
- def usetex(self, val: bool | None) -> None: ...
- def get_useMathText(self) -> bool: ...
- def set_useMathText(self, val: bool | None) -> None: ...
- @property
- def useMathText(self) -> bool: ...
- @useMathText.setter
- def useMathText(self, val: bool | None) -> None: ...
def format_eng(self, num: float) -> str: ...
class PercentFormatter(Formatter):
@@ -231,20 +228,19 @@ class MaxNLocator(Locator):
def view_limits(self, dmin: float, dmax: float) -> tuple[float, float]: ...
class LogLocator(Locator):
- numdecs: float
numticks: int | None
def __init__(
self,
base: float = ...,
subs: None | Literal["auto", "all"] | Sequence[float] = ...,
- numdecs: float = ...,
+ *,
numticks: int | None = ...,
) -> None: ...
def set_params(
self,
base: float | None = ...,
subs: Literal["auto", "all"] | Sequence[float] | None = ...,
- numdecs: float | None = ...,
+ *,
numticks: int | None = ...,
) -> None: ...
@@ -299,3 +295,14 @@ class AutoLocator(MaxNLocator):
class AutoMinorLocator(Locator):
ndivs: int
def __init__(self, n: int | None = ...) -> None: ...
+
+__all__ = ('TickHelper', 'Formatter', 'FixedFormatter',
+ 'NullFormatter', 'FuncFormatter', 'FormatStrFormatter',
+ 'StrMethodFormatter', 'ScalarFormatter', 'LogFormatter',
+ 'LogFormatterExponent', 'LogFormatterMathtext',
+ 'LogFormatterSciNotation',
+ 'LogitFormatter', 'EngFormatter', 'PercentFormatter',
+ 'Locator', 'IndexLocator', 'FixedLocator', 'NullLocator',
+ 'LinearLocator', 'LogLocator', 'AutoLocator',
+ 'MultipleLocator', 'MaxNLocator', 'AutoMinorLocator',
+ 'SymmetricalLogLocator', 'AsinhLocator', 'LogitLocator')
diff --git a/lib/matplotlib/transforms.py b/lib/matplotlib/transforms.py
index 3575bd1fc14d..350113c56170 100644
--- a/lib/matplotlib/transforms.py
+++ b/lib/matplotlib/transforms.py
@@ -1,7 +1,6 @@
"""
-Matplotlib includes a framework for arbitrary geometric
-transformations that is used determine the final position of all
-elements drawn on the canvas.
+Matplotlib includes a framework for arbitrary geometric transformations that is used to
+determine the final position of all elements drawn on the canvas.
Transforms are composed into trees of `TransformNode` objects
whose actual value depends on their children. When the contents of
@@ -11,10 +10,10 @@
unnecessary recomputations of transforms, and contributes to better
interactive performance.
-For example, here is a graph of the transform tree used to plot data
-to the graph:
+For example, here is a graph of the transform tree used to plot data to the figure:
-.. image:: ../_static/transforms.png
+.. graphviz:: /api/transforms.dot
+ :alt: Diagram of transform tree from data to figure coordinates.
The framework can be used for both affine and non-affine
transformations. However, for speed, we want to use the backend
@@ -36,8 +35,8 @@
# `np.minimum` instead of the builtin `min`, and likewise for `max`. This is
# done so that `nan`s are propagated, instead of being silently dropped.
-import copy
import functools
+import itertools
import textwrap
import weakref
import math
@@ -46,8 +45,7 @@
from numpy.linalg import inv
from matplotlib import _api
-from matplotlib._path import (
- affine_transform, count_bboxes_overlapping_bbox, update_path_extents)
+from matplotlib._path import affine_transform, count_bboxes_overlapping_bbox
from .path import Path
DEBUG = False
@@ -92,9 +90,6 @@ class TransformNode:
# Invalidation may affect only the affine part. If the
# invalidation was "affine-only", the _invalid member is set to
# INVALID_AFFINE_ONLY
- INVALID_NON_AFFINE = _api.deprecated("3.8")(_api.classproperty(lambda cls: 1))
- INVALID_AFFINE = _api.deprecated("3.8")(_api.classproperty(lambda cls: 2))
- INVALID = _api.deprecated("3.8")(_api.classproperty(lambda cls: 3))
# Possible values for the _invalid attribute.
_VALID, _INVALID_AFFINE_ONLY, _INVALID_FULL = range(3)
@@ -102,7 +97,6 @@ class TransformNode:
# Some metadata about the transform, used to determine whether an
# invalidation is affine-only
is_affine = False
- is_bbox = _api.deprecated("3.9")(_api.classproperty(lambda cls: False))
pass_through = False
"""
@@ -144,7 +138,9 @@ def __setstate__(self, data_dict):
for k, v in self._parents.items() if v is not None}
def __copy__(self):
- other = copy.copy(super())
+ cls = type(self)
+ other = cls.__new__(cls)
+ other.__dict__.update(self.__dict__)
# If `c = a + b; a1 = copy(a)`, then modifications to `a1` do not
# propagate back to `c`, i.e. we need to clear the parents of `a1`.
other._parents = {}
@@ -220,7 +216,6 @@ class BboxBase(TransformNode):
and height, but these are not stored explicitly.
"""
- is_bbox = _api.deprecated("3.9")(_api.classproperty(lambda cls: True))
is_affine = True
if DEBUG:
@@ -245,7 +240,7 @@ def x0(self):
The first of the pair of *x* coordinates that define the bounding box.
This is not guaranteed to be less than :attr:`x1` (for that, use
- :attr:`xmin`).
+ :attr:`~BboxBase.xmin`).
"""
return self.get_points()[0, 0]
@@ -255,7 +250,7 @@ def y0(self):
The first of the pair of *y* coordinates that define the bounding box.
This is not guaranteed to be less than :attr:`y1` (for that, use
- :attr:`ymin`).
+ :attr:`~BboxBase.ymin`).
"""
return self.get_points()[0, 1]
@@ -265,7 +260,7 @@ def x1(self):
The second of the pair of *x* coordinates that define the bounding box.
This is not guaranteed to be greater than :attr:`x0` (for that, use
- :attr:`xmax`).
+ :attr:`~BboxBase.xmax`).
"""
return self.get_points()[1, 0]
@@ -275,7 +270,7 @@ def y1(self):
The second of the pair of *y* coordinates that define the bounding box.
This is not guaranteed to be greater than :attr:`y0` (for that, use
- :attr:`ymax`).
+ :attr:`~BboxBase.ymax`).
"""
return self.get_points()[1, 1]
@@ -285,7 +280,7 @@ def p0(self):
The first pair of (*x*, *y*) coordinates that define the bounding box.
This is not guaranteed to be the bottom-left corner (for that, use
- :attr:`min`).
+ :attr:`~BboxBase.min`).
"""
return self.get_points()[0]
@@ -295,7 +290,7 @@ def p1(self):
The second pair of (*x*, *y*) coordinates that define the bounding box.
This is not guaranteed to be the top-right corner (for that, use
- :attr:`max`).
+ :attr:`~BboxBase.max`).
"""
return self.get_points()[1]
@@ -367,7 +362,10 @@ def size(self):
@property
def bounds(self):
- """Return (:attr:`x0`, :attr:`y0`, :attr:`width`, :attr:`height`)."""
+ """
+ Return (:attr:`x0`, :attr:`y0`, :attr:`~BboxBase.width`,
+ :attr:`~BboxBase.height`).
+ """
(x0, y0), (x1, y1) = self.get_points()
return (x0, y0, x1 - x0, y1 - y0)
@@ -479,7 +477,7 @@ def transformed(self, transform):
'NW': (0, 1.0),
'W': (0, 0.5)}
- def anchored(self, c, container=None):
+ def anchored(self, c, container):
"""
Return a copy of the `Bbox` anchored to *c* within *container*.
@@ -489,19 +487,13 @@ def anchored(self, c, container=None):
Either an (*x*, *y*) pair of relative coordinates (0 is left or
bottom, 1 is right or top), 'C' (center), or a cardinal direction
('SW', southwest, is bottom left, etc.).
- container : `Bbox`, optional
+ container : `Bbox`
The box within which the `Bbox` is positioned.
See Also
--------
.Axes.set_anchor
"""
- if container is None:
- _api.warn_deprecated(
- "3.8", message="Calling anchored() with no container bbox "
- "returns a frozen copy of the original bbox and is deprecated "
- "since %(since)s.")
- container = self
l, b, w, h = container.bounds
L, B, W, H = self.bounds
cx, cy = self.coefs[c] if isinstance(c, str) else c
@@ -553,7 +545,7 @@ def splitx(self, *args):
x0, y0, x1, y1 = self.extents
w = x1 - x0
return [Bbox([[x0 + xf0 * w, y0], [x0 + xf1 * w, y1]])
- for xf0, xf1 in zip(xf[:-1], xf[1:])]
+ for xf0, xf1 in itertools.pairwise(xf)]
def splity(self, *args):
"""
@@ -564,7 +556,7 @@ def splity(self, *args):
x0, y0, x1, y1 = self.extents
h = y1 - y0
return [Bbox([[x0, y0 + yf0 * h], [x1, y0 + yf1 * h]])
- for yf0, yf1 in zip(yf[:-1], yf[1:])]
+ for yf0, yf1 in itertools.pairwise(yf)]
def count_contains(self, vertices):
"""
@@ -605,7 +597,6 @@ def expanded(self, sw, sh):
a = np.array([[-deltaw, -deltah], [deltaw, deltah]])
return Bbox(self._points + a)
- @_api.rename_parameter("3.8", "p", "w_pad")
def padded(self, w_pad, h_pad=None):
"""
Construct a `Bbox` by padding this one on all four sides.
@@ -875,13 +866,31 @@ def update_from_path(self, path, ignore=None, updatex=True, updatey=True):
if ignore is None:
ignore = self._ignore
- if path.vertices.size == 0:
+ if path.vertices.size == 0 or not (updatex or updatey):
return
- points, minpos, changed = update_path_extents(
- path, None, self._points, self._minpos, ignore)
-
- if changed:
+ if ignore:
+ points = np.array([[np.inf, np.inf], [-np.inf, -np.inf]])
+ minpos = np.array([np.inf, np.inf])
+ else:
+ points = self._points.copy()
+ minpos = self._minpos.copy()
+
+ valid_points = (np.isfinite(path.vertices[..., 0])
+ & np.isfinite(path.vertices[..., 1]))
+
+ if updatex:
+ x = path.vertices[..., 0][valid_points]
+ points[0, 0] = min(points[0, 0], np.min(x, initial=np.inf))
+ points[1, 0] = max(points[1, 0], np.max(x, initial=-np.inf))
+ minpos[0] = min(minpos[0], np.min(x[x > 0], initial=np.inf))
+ if updatey:
+ y = path.vertices[..., 1][valid_points]
+ points[0, 1] = min(points[0, 1], np.min(y, initial=np.inf))
+ points[1, 1] = max(points[1, 1], np.max(y, initial=-np.inf))
+ minpos[1] = min(minpos[1], np.min(y[y > 0], initial=np.inf))
+
+ if np.any(points != self._points) or np.any(minpos != self._minpos):
self.invalidate()
if updatex:
self._points[:, 0] = points[:, 0]
@@ -906,8 +915,9 @@ def update_from_data_x(self, x, ignore=None):
- When ``None``, use the last value passed to :meth:`ignore`.
"""
x = np.ravel(x)
- self.update_from_data_xy(np.column_stack([x, np.ones(x.size)]),
- ignore=ignore, updatey=False)
+ # The y-component in np.array([x, *y*]).T is not used. We simply pass
+ # x again to not spend extra time on creating an array of unused data
+ self.update_from_data_xy(np.array([x, x]).T, ignore=ignore, updatey=False)
def update_from_data_y(self, y, ignore=None):
"""
@@ -925,8 +935,9 @@ def update_from_data_y(self, y, ignore=None):
- When ``None``, use the last value passed to :meth:`ignore`.
"""
y = np.ravel(y)
- self.update_from_data_xy(np.column_stack([np.ones(y.size), y]),
- ignore=ignore, updatex=False)
+ # The x-component in np.array([*x*, y]).T is not used. We simply pass
+ # y again to not spend extra time on creating an array of unused data
+ self.update_from_data_xy(np.array([y, y]).T, ignore=ignore, updatex=False)
def update_from_data_xy(self, xy, ignore=None, updatex=True, updatey=True):
"""
@@ -1486,14 +1497,14 @@ def transform(self, values):
Parameters
----------
values : array-like
- The input values as an array of length :attr:`input_dims` or
- shape (N, :attr:`input_dims`).
+ The input values as an array of length :attr:`~Transform.input_dims` or
+ shape (N, :attr:`~Transform.input_dims`).
Returns
-------
array
- The output values as an array of length :attr:`output_dims` or
- shape (N, :attr:`output_dims`), depending on the input.
+ The output values as an array of length :attr:`~Transform.output_dims` or
+ shape (N, :attr:`~Transform.output_dims`), depending on the input.
"""
# Ensure that values is a 2d array (but remember whether
# we started with a 1d or 2d array).
@@ -1531,14 +1542,14 @@ def transform_affine(self, values):
Parameters
----------
values : array
- The input values as an array of length :attr:`input_dims` or
- shape (N, :attr:`input_dims`).
+ The input values as an array of length :attr:`~Transform.input_dims` or
+ shape (N, :attr:`~Transform.input_dims`).
Returns
-------
array
- The output values as an array of length :attr:`output_dims` or
- shape (N, :attr:`output_dims`), depending on the input.
+ The output values as an array of length :attr:`~Transform.output_dims` or
+ shape (N, :attr:`~Transform.output_dims`), depending on the input.
"""
return self.get_affine().transform(values)
@@ -1556,14 +1567,17 @@ def transform_non_affine(self, values):
Parameters
----------
values : array
- The input values as an array of length :attr:`input_dims` or
- shape (N, :attr:`input_dims`).
+ The input values as an array of length
+ :attr:`~matplotlib.transforms.Transform.input_dims` or
+ shape (N, :attr:`~matplotlib.transforms.Transform.input_dims`).
Returns
-------
array
- The output values as an array of length :attr:`output_dims` or
- shape (N, :attr:`output_dims`), depending on the input.
+ The output values as an array of length
+ :attr:`~matplotlib.transforms.Transform.output_dims` or shape
+ (N, :attr:`~matplotlib.transforms.Transform.output_dims`),
+ depending on the input.
"""
return values
@@ -1798,7 +1812,6 @@ def transform_affine(self, values):
raise NotImplementedError('Affine subclasses should override this '
'method.')
- @_api.rename_parameter("3.8", "points", "values")
def transform_non_affine(self, values):
# docstring inherited
return values
@@ -1856,7 +1869,6 @@ def to_values(self):
mtx = self.get_matrix()
return tuple(mtx[:2].swapaxes(0, 1).flat)
- @_api.rename_parameter("3.8", "points", "values")
def transform_affine(self, values):
mtx = self.get_matrix()
if isinstance(values, np.ma.MaskedArray):
@@ -1867,7 +1879,6 @@ def transform_affine(self, values):
if DEBUG:
_transform_affine = transform_affine
- @_api.rename_parameter("3.8", "points", "values")
def transform_affine(self, values):
# docstring inherited
# The major speed trap here is just converting to the
@@ -2130,17 +2141,14 @@ def get_matrix(self):
# docstring inherited
return self._mtx
- @_api.rename_parameter("3.8", "points", "values")
def transform(self, values):
# docstring inherited
return np.asanyarray(values)
- @_api.rename_parameter("3.8", "points", "values")
def transform_affine(self, values):
# docstring inherited
return np.asanyarray(values)
- @_api.rename_parameter("3.8", "points", "values")
def transform_non_affine(self, values):
# docstring inherited
return np.asanyarray(values)
@@ -2229,7 +2237,6 @@ def frozen(self):
# docstring inherited
return blended_transform_factory(self._x.frozen(), self._y.frozen())
- @_api.rename_parameter("3.8", "points", "values")
def transform_non_affine(self, values):
# docstring inherited
if self._x.is_affine and self._y.is_affine:
@@ -2422,12 +2429,10 @@ def contains_branch_seperately(self, other_transform):
__str__ = _make_str_method("_a", "_b")
- @_api.rename_parameter("3.8", "points", "values")
def transform_affine(self, values):
# docstring inherited
return self.get_affine().transform(values)
- @_api.rename_parameter("3.8", "points", "values")
def transform_non_affine(self, values):
# docstring inherited
if self._a.is_affine and self._b.is_affine:
@@ -2574,9 +2579,9 @@ def get_matrix(self):
if DEBUG and (x_scale == 0 or y_scale == 0):
raise ValueError(
"Transforming from or to a singular bounding box")
- self._mtx = np.array([[x_scale, 0.0 , (-inl*x_scale+outl)],
- [0.0 , y_scale, (-inb*y_scale+outb)],
- [0.0 , 0.0 , 1.0 ]],
+ self._mtx = np.array([[x_scale, 0.0, -inl*x_scale+outl],
+ [ 0.0, y_scale, -inb*y_scale+outb],
+ [ 0.0, 0.0, 1.0]],
float)
self._inverted = None
self._invalid = 0
@@ -2621,27 +2626,6 @@ def get_matrix(self):
return self._mtx
-@_api.deprecated("3.9")
-class BboxTransformToMaxOnly(BboxTransformTo):
- """
- `BboxTransformToMaxOnly` is a transformation that linearly transforms points from
- the unit bounding box to a given `Bbox` with a fixed upper left of (0, 0).
- """
- def get_matrix(self):
- # docstring inherited
- if self._invalid:
- xmax, ymax = self._boxout.max
- if DEBUG and (xmax == 0 or ymax == 0):
- raise ValueError("Transforming to a singular bounding box.")
- self._mtx = np.array([[xmax, 0.0, 0.0],
- [ 0.0, ymax, 0.0],
- [ 0.0, 0.0, 1.0]],
- float)
- self._inverted = None
- self._invalid = 0
- return self._mtx
-
-
class BboxTransformFrom(Affine2DBase):
"""
`BboxTransformFrom` linearly transforms points from a given `Bbox` to the
@@ -2668,9 +2652,9 @@ def get_matrix(self):
raise ValueError("Transforming from a singular bounding box.")
x_scale = 1.0 / inw
y_scale = 1.0 / inh
- self._mtx = np.array([[x_scale, 0.0 , (-inl*x_scale)],
- [0.0 , y_scale, (-inb*y_scale)],
- [0.0 , 0.0 , 1.0 ]],
+ self._mtx = np.array([[x_scale, 0.0, -inl*x_scale],
+ [ 0.0, y_scale, -inb*y_scale],
+ [ 0.0, 0.0, 1.0]],
float)
self._inverted = None
self._invalid = 0
@@ -2703,6 +2687,25 @@ def get_matrix(self):
return self._mtx
+class _ScaledRotation(Affine2DBase):
+ """
+ A transformation that applies rotation by *theta*, after transform by *trans_shift*.
+ """
+ def __init__(self, theta, trans_shift):
+ super().__init__()
+ self._theta = theta
+ self._trans_shift = trans_shift
+ self._mtx = None
+
+ def get_matrix(self):
+ if self._invalid:
+ transformed_coords = self._trans_shift.transform([[self._theta, 0]])[0]
+ adjusted_theta = transformed_coords[0]
+ rotation = Affine2D().rotate(adjusted_theta)
+ self._mtx = rotation.get_matrix()
+ return self._mtx
+
+
class AffineDeltaTransform(Affine2DBase):
r"""
A transform wrapper for transforming displacements between pairs of points.
@@ -2720,9 +2723,12 @@ class AffineDeltaTransform(Affine2DBase):
This class is experimental as of 3.3, and the API may change.
"""
+ pass_through = True
+
def __init__(self, transform, **kwargs):
super().__init__(**kwargs)
self._base_transform = transform
+ self.set_children(transform)
__str__ = _make_str_method("_base_transform")
diff --git a/lib/matplotlib/transforms.pyi b/lib/matplotlib/transforms.pyi
index 90a527e5bfc5..07d299be297c 100644
--- a/lib/matplotlib/transforms.pyi
+++ b/lib/matplotlib/transforms.pyi
@@ -12,7 +12,6 @@ class TransformNode:
INVALID_NON_AFFINE: int
INVALID_AFFINE: int
INVALID: int
- is_bbox: bool
# Implemented as a standard attr in base class, but functionally readonly and some subclasses implement as such
@property
def is_affine(self) -> bool: ...
@@ -24,7 +23,6 @@ class TransformNode:
def frozen(self) -> TransformNode: ...
class BboxBase(TransformNode):
- is_bbox: bool
is_affine: bool
def frozen(self) -> Bbox: ...
def __array__(self, *args, **kwargs): ...
@@ -77,9 +75,10 @@ class BboxBase(TransformNode):
def fully_overlaps(self, other: BboxBase) -> bool: ...
def transformed(self, transform: Transform) -> Bbox: ...
coefs: dict[str, tuple[float, float]]
- # anchored type can be s/str/Literal["C", "SW", "S", "SE", "E", "NE", "N", "NW", "W"]
def anchored(
- self, c: tuple[float, float] | str, container: BboxBase | None = ...
+ self,
+ c: tuple[float, float] | Literal['C', 'SW', 'S', 'SE', 'E', 'NE', 'N', 'NW', 'W'],
+ container: BboxBase,
) -> Bbox: ...
def shrunk(self, mx: float, my: float) -> Bbox: ...
def shrunk_to_aspect(
@@ -294,8 +293,6 @@ class BboxTransform(Affine2DBase):
class BboxTransformTo(Affine2DBase):
def __init__(self, boxout: BboxBase, **kwargs) -> None: ...
-class BboxTransformToMaxOnly(BboxTransformTo): ...
-
class BboxTransformFrom(Affine2DBase):
def __init__(self, boxin: BboxBase, **kwargs) -> None: ...
@@ -333,3 +330,8 @@ def offset_copy(
y: float = ...,
units: Literal["inches", "points", "dots"] = ...,
) -> Transform: ...
+
+
+class _ScaledRotation(Affine2DBase):
+ def __init__(self, theta: float, trans_shift: Transform) -> None: ...
+ def get_matrix(self) -> np.ndarray: ...
diff --git a/lib/matplotlib/tri/_tricontour.py b/lib/matplotlib/tri/_tricontour.py
index 1db3715d01af..8250515f3ef8 100644
--- a/lib/matplotlib/tri/_tricontour.py
+++ b/lib/matplotlib/tri/_tricontour.py
@@ -5,7 +5,7 @@
from matplotlib.tri._triangulation import Triangulation
-@_docstring.dedent_interpd
+@_docstring.interpd
class TriContourSet(ContourSet):
"""
Create and store a set of contour lines or filled regions for
@@ -79,7 +79,7 @@ def _contour_args(self, args, kwargs):
return (tri, z)
-_docstring.interpd.update(_tricontour_doc="""
+_docstring.interpd.register(_tricontour_doc="""
Draw contour %%(type)s on an unstructured triangular grid.
Call signatures::
@@ -218,7 +218,7 @@ def _contour_args(self, args, kwargs):
@_docstring.Substitution(func='tricontour', type='lines')
-@_docstring.dedent_interpd
+@_docstring.interpd
def tricontour(ax, *args, **kwargs):
"""
%(_tricontour_doc)s
@@ -247,7 +247,7 @@ def tricontour(ax, *args, **kwargs):
@_docstring.Substitution(func='tricontourf', type='regions')
-@_docstring.dedent_interpd
+@_docstring.interpd
def tricontourf(ax, *args, **kwargs):
"""
%(_tricontour_doc)s
diff --git a/lib/matplotlib/tri/_triinterpolate.py b/lib/matplotlib/tri/_triinterpolate.py
index 90ad6cf3a76c..2dc62770c7ed 100644
--- a/lib/matplotlib/tri/_triinterpolate.py
+++ b/lib/matplotlib/tri/_triinterpolate.py
@@ -928,7 +928,7 @@ def get_Kff_and_Ff(self, J, ecc, triangles, Uc):
Returns
-------
- (Kff_rows, Kff_cols, Kff_vals) Kff matrix in coo format - Duplicate
+ (Kff_rows, Kff_cols, Kff_vals) Kff matrix in COO format - Duplicate
(row, col) entries must be summed.
Ff: force vector - dim npts * 3
"""
@@ -961,12 +961,12 @@ def get_Kff_and_Ff(self, J, ecc, triangles, Uc):
# [ Kcf Kff ]
# * As F = K x U one gets straightforwardly: Ff = - Kfc x Uc
- # Computing Kff stiffness matrix in sparse coo format
+ # Computing Kff stiffness matrix in sparse COO format
Kff_vals = np.ravel(K_elem[np.ix_(vec_range, f_dof, f_dof)])
Kff_rows = np.ravel(f_row_indices[np.ix_(vec_range, f_dof, f_dof)])
Kff_cols = np.ravel(f_col_indices[np.ix_(vec_range, f_dof, f_dof)])
- # Computing Ff force vector in sparse coo format
+ # Computing Ff force vector in sparse COO format
Kfc_elem = K_elem[np.ix_(vec_range, f_dof, c_dof)]
Uc_elem = np.expand_dims(Uc, axis=2)
Ff_elem = -(Kfc_elem @ Uc_elem)[:, :, 0]
@@ -1178,7 +1178,7 @@ def compute_dz(self):
triangles = self._triangles
Uc = self.z[self._triangles]
- # Building stiffness matrix and force vector in coo format
+ # Building stiffness matrix and force vector in COO format
Kff_rows, Kff_cols, Kff_vals, Ff = reference_element.get_Kff_and_Ff(
J, eccs, triangles, Uc)
@@ -1215,7 +1215,7 @@ def compute_dz(self):
class _Sparse_Matrix_coo:
def __init__(self, vals, rows, cols, shape):
"""
- Create a sparse matrix in coo format.
+ Create a sparse matrix in COO format.
*vals*: arrays of values of non-null entries of the matrix
*rows*: int arrays of rows of non-null entries of the matrix
*cols*: int arrays of cols of non-null entries of the matrix
diff --git a/lib/matplotlib/tri/_triinterpolate.pyi b/lib/matplotlib/tri/_triinterpolate.pyi
index 8a56b22acdb2..33b2fd8be4cd 100644
--- a/lib/matplotlib/tri/_triinterpolate.pyi
+++ b/lib/matplotlib/tri/_triinterpolate.pyi
@@ -28,3 +28,5 @@ class CubicTriInterpolator(TriInterpolator):
trifinder: TriFinder | None = ...,
dz: tuple[ArrayLike, ArrayLike] | None = ...,
) -> None: ...
+
+__all__ = ('TriInterpolator', 'LinearTriInterpolator', 'CubicTriInterpolator')
diff --git a/lib/matplotlib/tri/_tripcolor.py b/lib/matplotlib/tri/_tripcolor.py
index 1ac6c48a2d7c..5a5b24522d17 100644
--- a/lib/matplotlib/tri/_tripcolor.py
+++ b/lib/matplotlib/tri/_tripcolor.py
@@ -1,10 +1,11 @@
import numpy as np
-from matplotlib import _api
+from matplotlib import _api, _docstring
from matplotlib.collections import PolyCollection, TriMesh
from matplotlib.tri._triangulation import Triangulation
+@_docstring.interpd
def tripcolor(ax, *args, alpha=1.0, norm=None, cmap=None, vmin=None,
vmax=None, shading='flat', facecolors=None, **kwargs):
"""
@@ -54,8 +55,25 @@ def tripcolor(ax, *args, alpha=1.0, norm=None, cmap=None, vmin=None,
values used for each triangle are from the mean c of the triangle's
three points. If *shading* is 'gouraud' then color values must be
defined at points.
- other_parameters
- All other parameters are the same as for `~.Axes.pcolor`.
+ %(cmap_doc)s
+
+ %(norm_doc)s
+
+ %(vmin_vmax_doc)s
+
+ %(colorizer_doc)s
+
+ Returns
+ -------
+ `~matplotlib.collections.PolyCollection` or `~matplotlib.collections.TriMesh`
+ The result depends on *shading*: For ``shading='flat'`` the result is a
+ `.PolyCollection`, for ``shading='gouraud'`` the result is a `.TriMesh`.
+
+ Other Parameters
+ ----------------
+ **kwargs : `~matplotlib.collections.Collection` properties
+
+ %(Collection:kwdoc)s
"""
_api.check_in_list(['flat', 'gouraud'], shading=shading)
@@ -145,5 +163,7 @@ def tripcolor(ax, *args, alpha=1.0, norm=None, cmap=None, vmin=None,
corners = (minx, miny), (maxx, maxy)
ax.update_datalim(corners)
ax.autoscale_view()
- ax.add_collection(collection)
+ # TODO: check whether the above explicit limit handling can be
+ # replaced by autolim=True
+ ax.add_collection(collection, autolim=False)
return collection
diff --git a/lib/matplotlib/tri/_trirefine.py b/lib/matplotlib/tri/_trirefine.py
index 7f5110ab9e21..6a7037ad74fd 100644
--- a/lib/matplotlib/tri/_trirefine.py
+++ b/lib/matplotlib/tri/_trirefine.py
@@ -64,7 +64,7 @@ def __init__(self, triangulation):
def refine_triangulation(self, return_tri_index=False, subdiv=3):
"""
Compute a uniformly refined triangulation *refi_triangulation* of
- the encapsulated :attr:`triangulation`.
+ the encapsulated :attr:`!triangulation`.
This function refines the encapsulated triangulation by splitting each
father triangle into 4 child sub-triangles built on the edges midside
diff --git a/lib/matplotlib/typing.py b/lib/matplotlib/typing.py
index 02059be94ba2..cedeb1ad5d5e 100644
--- a/lib/matplotlib/typing.py
+++ b/lib/matplotlib/typing.py
@@ -4,57 +4,169 @@
This module contains Type aliases which are useful for Matplotlib and potentially
downstream libraries.
-.. admonition:: Provisional status of typing
+.. warning::
+ **Provisional status of typing**
The ``typing`` module and type stub files are considered provisional and may change
at any time without a deprecation period.
"""
from collections.abc import Hashable, Sequence
import pathlib
-from typing import Any, Literal, TypeVar, Union
+from typing import Any, Literal, TypeAlias, TypeVar, Union
+from collections.abc import Callable
from . import path
from ._enums import JoinStyle, CapStyle
+from .artist import Artist
+from .backend_bases import RendererBase
from .markers import MarkerStyle
+from .transforms import Bbox, Transform
-# The following are type aliases. Once python 3.9 is dropped, they should be annotated
-# using ``typing.TypeAlias`` and Unions should be converted to using ``|`` syntax.
+RGBColorType: TypeAlias = tuple[float, float, float] | str
+"""Any RGB color specification accepted by Matplotlib."""
-RGBColorType = Union[tuple[float, float, float], str]
-RGBAColorType = Union[
- str, # "none" or "#RRGGBBAA"/"#RGBA" hex strings
- tuple[float, float, float, float],
+RGBAColorType: TypeAlias = (
+ str | # "none" or "#RRGGBBAA"/"#RGBA" hex strings
+ tuple[float, float, float, float] |
# 2 tuple (color, alpha) representations, not infinitely recursive
# RGBColorType includes the (str, float) tuple, even for RGBA strings
- tuple[RGBColorType, float],
+ tuple[RGBColorType, float] |
# (4-tuple, float) is odd, but accepted as the outer float overriding A of 4-tuple
- tuple[tuple[float, float, float, float], float],
-]
+ tuple[tuple[float, float, float, float], float]
+)
+"""Any RGBA color specification accepted by Matplotlib."""
-ColorType = Union[RGBColorType, RGBAColorType]
+ColorType: TypeAlias = RGBColorType | RGBAColorType
+"""Any color specification accepted by Matplotlib. See :mpltype:`color`."""
-RGBColourType = RGBColorType
-RGBAColourType = RGBAColorType
-ColourType = ColorType
+RGBColourType: TypeAlias = RGBColorType
+"""Alias of `.RGBColorType`."""
-LineStyleType = Union[str, tuple[float, Sequence[float]]]
-DrawStyleType = Literal["default", "steps", "steps-pre", "steps-mid", "steps-post"]
-MarkEveryType = Union[
- None, int, tuple[int, int], slice, list[int], float, tuple[float, float], list[bool]
-]
+RGBAColourType: TypeAlias = RGBAColorType
+"""Alias of `.RGBAColorType`."""
+
+ColourType: TypeAlias = ColorType
+"""Alias of `.ColorType`."""
+
+LineStyleType: TypeAlias = (
+ Literal["-", "solid", "--", "dashed", "-.", "dashdot", ":", "dotted",
+ "", "none", " ", "None"] |
+ tuple[float, Sequence[float]]
+)
+"""
+Any line style specification accepted by Matplotlib.
+See :doc:`/gallery/lines_bars_and_markers/linestyles`.
+"""
+
+DrawStyleType: TypeAlias = Literal["default", "steps", "steps-pre", "steps-mid",
+ "steps-post"]
+"""See :doc:`/gallery/lines_bars_and_markers/step_demo`."""
+
+MarkEveryType: TypeAlias = (
+ None |
+ int | tuple[int, int] | slice | list[int] |
+ float | tuple[float, float] |
+ list[bool]
+)
+"""See :doc:`/gallery/lines_bars_and_markers/markevery_demo`."""
+
+MarkerType: TypeAlias = (
+ path.Path | MarkerStyle | str | # str required for "$...$" marker
+ Literal[
+ ".", ",", "o", "v", "^", "<", ">",
+ "1", "2", "3", "4", "8", "s", "p",
+ "P", "*", "h", "H", "+", "x", "X",
+ "D", "d", "|", "_", "none", " ",
+ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11
+ ] | list[tuple[int, int]] | tuple[int, Literal[0, 1, 2], int]
+)
+"""
+Marker specification. See :doc:`/gallery/lines_bars_and_markers/marker_reference`.
+"""
+
+FillStyleType: TypeAlias = Literal["full", "left", "right", "bottom", "top", "none"]
+"""Marker fill styles. See :doc:`/gallery/lines_bars_and_markers/marker_reference`."""
-MarkerType = Union[str, path.Path, MarkerStyle]
-FillStyleType = Literal["full", "left", "right", "bottom", "top", "none"]
-JoinStyleType = Union[JoinStyle, Literal["miter", "round", "bevel"]]
-CapStyleType = Union[CapStyle, Literal["butt", "projecting", "round"]]
+JoinStyleType: TypeAlias = JoinStyle | Literal["miter", "round", "bevel"]
+"""Line join styles. See :doc:`/gallery/lines_bars_and_markers/joinstyle`."""
-RcStyleType = Union[
+CapStyleType: TypeAlias = CapStyle | Literal["butt", "projecting", "round"]
+"""Line cap styles. See :doc:`/gallery/lines_bars_and_markers/capstyle`."""
+
+LogLevel: TypeAlias = Literal["NOTSET", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
+"""Literal type for valid logging levels accepted by `set_loglevel()`."""
+
+CoordsBaseType = Union[
str,
- dict[str, Any],
- pathlib.Path,
- Sequence[Union[str, pathlib.Path, dict[str, Any]]],
+ Artist,
+ Transform,
+ Callable[
+ [RendererBase],
+ Union[Bbox, Transform]
+ ]
+]
+CoordsType = Union[
+ CoordsBaseType,
+ tuple[CoordsBaseType, CoordsBaseType]
]
+RcStyleType: TypeAlias = (
+ str |
+ dict[str, Any] |
+ pathlib.Path |
+ Sequence[str | pathlib.Path | dict[str, Any]]
+)
+
_HT = TypeVar("_HT", bound=Hashable)
-HashableList = list[Union[_HT, "HashableList[_HT]"]]
+HashableList: TypeAlias = list[_HT | "HashableList[_HT]"]
"""A nested list of Hashable values."""
+
+MouseEventType: TypeAlias = Literal[
+ "button_press_event",
+ "button_release_event",
+ "motion_notify_event",
+ "scroll_event",
+ "figure_enter_event",
+ "figure_leave_event",
+ "axes_enter_event",
+ "axes_leave_event",
+]
+
+KeyEventType: TypeAlias = Literal[
+ "key_press_event",
+ "key_release_event"
+]
+
+DrawEventType: TypeAlias = Literal["draw_event"]
+PickEventType: TypeAlias = Literal["pick_event"]
+ResizeEventType: TypeAlias = Literal["resize_event"]
+CloseEventType: TypeAlias = Literal["close_event"]
+
+EventType: TypeAlias = Literal[
+ MouseEventType,
+ KeyEventType,
+ DrawEventType,
+ PickEventType,
+ ResizeEventType,
+ CloseEventType,
+]
+
+LegendLocType: TypeAlias = (
+ Literal[
+ # for simplicity, we don't distinguish the between allowed positions for
+ # Axes legend and figure legend. It's still better to limit the allowed
+ # range to the union of both rather than to accept arbitrary strings
+ "upper right", "upper left", "lower left", "lower right",
+ "right", "center left", "center right", "lower center", "upper center",
+ "center",
+ # Axes only
+ "best",
+ # Figure only
+ "outside upper left", "outside upper center", "outside upper right",
+ "outside right upper", "outside right center", "outside right lower",
+ "outside lower right", "outside lower center", "outside lower left",
+ "outside left lower", "outside left center", "outside left upper",
+ ] |
+ tuple[float, float] |
+ int
+)
diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py
index a298f3ae3d6a..034a9b4db7a0 100644
--- a/lib/matplotlib/widgets.py
+++ b/lib/matplotlib/widgets.py
@@ -117,7 +117,9 @@ def __init__(self, ax):
self.ax = ax
self._cids = []
- canvas = property(lambda self: self.ax.figure.canvas)
+ canvas = property(
+ lambda self: getattr(self.ax.get_figure(root=True), 'canvas', None)
+ )
def connect_event(self, event, callback):
"""
@@ -144,6 +146,10 @@ def _get_data_coords(self, event):
return ((event.xdata, event.ydata) if event.inaxes is self.ax
else self.ax.transData.inverted().transform((event.x, event.y)))
+ def ignore(self, event):
+ # docstring inherited
+ return super().ignore(event) or self.canvas is None
+
class Button(AxesWidget):
"""
@@ -273,10 +279,10 @@ def __init__(self, ax, orientation, closedmin, closedmax,
self.valfmt = valfmt
if orientation == "vertical":
- ax.set_ylim((valmin, valmax))
+ ax.set_ylim(valmin, valmax)
axis = ax.yaxis
else:
- ax.set_xlim((valmin, valmax))
+ ax.set_xlim(valmin, valmax)
axis = ax.xaxis
self._fmt = axis.get_major_formatter()
@@ -364,8 +370,9 @@ def __init__(self, ax, label, valmin, valmax, *, valinit=0.5, valfmt=None,
The slider initial position.
valfmt : str, default: None
- %-format string used to format the slider value. If None, a
- `.ScalarFormatter` is used instead.
+ The way to format the slider value. If a string, it must be in %-format.
+ If a callable, it must have the signature ``valfmt(val: float) -> str``.
+ If None, a `.ScalarFormatter` is used.
closedmin : bool, default: True
Whether the slider interval is closed on the bottom.
@@ -547,7 +554,10 @@ def _update(self, event):
def _format(self, val):
"""Pretty-print *val*."""
if self.valfmt is not None:
- return self.valfmt % val
+ if callable(self.valfmt):
+ return self.valfmt(val)
+ else:
+ return self.valfmt % val
else:
_, s, _ = self._fmt.format_ticks([self.valmin, val, self.valmax])
# fmt.get_offset is actually the multiplicative factor, if any.
@@ -569,7 +579,7 @@ def set_val(self, val):
self._handle.set_xdata([val])
self.valtext.set_text(self._format(val))
if self.drawon:
- self.ax.figure.canvas.draw_idle()
+ self.ax.get_figure(root=True).canvas.draw_idle()
self.val = val
if self.eventson:
self._observers.process('changed', val)
@@ -644,9 +654,11 @@ def __init__(
The initial positions of the slider. If None the initial positions
will be at the 25th and 75th percentiles of the range.
- valfmt : str, default: None
- %-format string used to format the slider values. If None, a
- `.ScalarFormatter` is used instead.
+ valfmt : str or callable, default: None
+ The way to format the range's minimal and maximal values. If a
+ string, it must be in %-format. If a callable, it must have the
+ signature ``valfmt(val: float) -> str``. If None, a
+ `.ScalarFormatter` is used.
closedmin : bool, default: True
Whether the slider interval is closed on the bottom.
@@ -890,7 +902,10 @@ def _update(self, event):
def _format(self, val):
"""Pretty-print *val*."""
if self.valfmt is not None:
- return f"({self.valfmt % val[0]}, {self.valfmt % val[1]})"
+ if callable(self.valfmt):
+ return f"({self.valfmt(val[0])}, {self.valfmt(val[1])})"
+ else:
+ return f"({self.valfmt % val[0]}, {self.valfmt % val[1]})"
else:
_, s1, s2, _ = self._fmt.format_ticks(
[self.valmin, *val, self.valmax]
@@ -945,7 +960,7 @@ def set_val(self, val):
self.valtext.set_text(self._format((vmin, vmax)))
if self.drawon:
- self.ax.figure.canvas.draw_idle()
+ self.ax.get_figure(root=True).canvas.draw_idle()
self.val = (vmin, vmax)
if self.eventson:
self._observers.process("changed", (vmin, vmax))
@@ -1010,8 +1025,11 @@ def __init__(self, ax, labels, actives=None, *, useblit=True,
.. versionadded:: 3.7
- label_props : dict, optional
- Dictionary of `.Text` properties to be used for the labels.
+ label_props : dict of lists, optional
+ Dictionary of `.Text` properties to be used for the labels. Each
+ dictionary value should be a list of at least a single element. If
+ the list is of length M, its values are cycled such that the Nth
+ label gets the (N mod M) property.
.. versionadded:: 3.7
frame_props : dict, optional
@@ -1111,7 +1129,8 @@ def set_label_props(self, props):
Parameters
----------
props : dict
- Dictionary of `.Text` properties to be used for the labels.
+ Dictionary of `.Text` properties to be used for the labels. Same
+ format as label_props argument of :class:`CheckButtons`.
"""
_api.check_isinstance(dict, props=props)
props = _expand_text_props(props)
@@ -1159,7 +1178,7 @@ def set_active(self, index, state=None):
"""
Modify the state of a check button by index.
- Callbacks will be triggered if :attr:`eventson` is True.
+ Callbacks will be triggered if :attr:`!eventson` is True.
Parameters
----------
@@ -1370,8 +1389,9 @@ def _rendercursor(self):
# This causes a single extra draw if the figure has never been rendered
# yet, which should be fine as we're going to repeatedly re-render the
# figure later anyways.
- if self.ax.figure._get_renderer() is None:
- self.ax.figure.canvas.draw()
+ fig = self.ax.get_figure(root=True)
+ if fig._get_renderer() is None:
+ fig.canvas.draw()
text = self.text_disp.get_text() # Save value before overwriting it.
widthtext = text[:self.cursor_index]
@@ -1393,7 +1413,7 @@ def _rendercursor(self):
visible=True)
self.text_disp.set_text(text)
- self.ax.figure.canvas.draw()
+ fig.canvas.draw()
def _release(self, event):
if self.ignore(event):
@@ -1456,7 +1476,7 @@ def begin_typing(self):
stack = ExitStack() # Register cleanup actions when user stops typing.
self._on_stop_typing = stack.close
toolmanager = getattr(
- self.ax.figure.canvas.manager, "toolmanager", None)
+ self.ax.get_figure(root=True).canvas.manager, "toolmanager", None)
if toolmanager is not None:
# If using toolmanager, lock keypresses, and plan to release the
# lock when typing stops.
@@ -1478,7 +1498,7 @@ def stop_typing(self):
notifysubmit = False
self.capturekeystrokes = False
self.cursor.set_visible(False)
- self.ax.figure.canvas.draw()
+ self.ax.get_figure(root=True).canvas.draw()
if notifysubmit and self.eventson:
# Because process() might throw an error in the user's code, only
# call it once we've already done our cleanup.
@@ -1509,7 +1529,7 @@ def _motion(self, event):
if not colors.same_color(c, self.ax.get_facecolor()):
self.ax.set_facecolor(c)
if self.drawon:
- self.ax.figure.canvas.draw()
+ self.ax.get_figure(root=True).canvas.draw()
def on_text_change(self, func):
"""
@@ -1578,8 +1598,11 @@ def __init__(self, ax, labels, active=0, activecolor=None, *,
.. versionadded:: 3.7
- label_props : dict or list of dict, optional
- Dictionary of `.Text` properties to be used for the labels.
+ label_props : dict of lists, optional
+ Dictionary of `.Text` properties to be used for the labels. Each
+ dictionary value should be a list of at least a single element. If
+ the list is of length M, its values are cycled such that the Nth
+ label gets the (N mod M) property.
.. versionadded:: 3.7
radio_props : dict, optional
@@ -1688,7 +1711,8 @@ def set_label_props(self, props):
Parameters
----------
props : dict
- Dictionary of `.Text` properties to be used for the labels.
+ Dictionary of `.Text` properties to be used for the labels. Same
+ format as label_props argument of :class:`RadioButtons`.
"""
_api.check_isinstance(dict, props=props)
props = _expand_text_props(props)
@@ -1733,7 +1757,7 @@ def set_active(self, index):
"""
Select button with number *index*.
- Callbacks will be triggered if :attr:`eventson` is True.
+ Callbacks will be triggered if :attr:`!eventson` is True.
Parameters
----------
@@ -1840,7 +1864,7 @@ def __init__(self, targetfig, toolfig):
self.sliderbottom.slidermax = self.slidertop
self.slidertop.slidermin = self.sliderbottom
- bax = toolfig.add_axes([0.8, 0.05, 0.15, 0.075])
+ bax = toolfig.add_axes((0.8, 0.05, 0.15, 0.075))
self.buttonreset = Button(bax, 'Reset')
self.buttonreset.on_clicked(self._on_reset)
@@ -2003,7 +2027,8 @@ def __init__(self, canvas, axes, *, useblit=True, horizOn=False, vertOn=True,
self.vertOn = vertOn
self._canvas_infos = {
- ax.figure.canvas: {"cids": [], "background": None} for ax in axes}
+ ax.get_figure(root=True).canvas:
+ {"cids": [], "background": None} for ax in axes}
xmin, xmax = axes[-1].get_xlim()
ymin, ymax = axes[-1].get_ylim()
@@ -2089,12 +2114,15 @@ def onmove(self, event):
class _SelectorWidget(AxesWidget):
- def __init__(self, ax, onselect, useblit=False, button=None,
+ def __init__(self, ax, onselect=None, useblit=False, button=None,
state_modifier_keys=None, use_data_coordinates=False):
super().__init__(ax)
self._visible = True
- self.onselect = onselect
+ if onselect is None:
+ self.onselect = lambda *args: None
+ else:
+ self.onselect = onselect
self.useblit = useblit and self.canvas.supports_blit
self.connect_default_events()
@@ -2176,7 +2204,9 @@ def connect_default_events(self):
def ignore(self, event):
# docstring inherited
- if not self.active or not self.ax.get_visible():
+ if super().ignore(event):
+ return True
+ if not self.ax.get_visible():
return True
# If canvas was locked
if not self.canvas.widgetlock.available(self):
@@ -2201,7 +2231,7 @@ def ignore(self, event):
def update(self):
"""Draw using blit() or draw_idle(), depending on ``self.useblit``."""
if (not self.ax.get_visible() or
- self.ax.figure._get_renderer() is None):
+ self.ax.get_figure(root=True)._get_renderer() is None):
return
if self.useblit:
if self.background is not None:
@@ -2345,11 +2375,6 @@ def get_visible(self):
"""Get the visibility of the selector artists."""
return self._visible
- @property
- def visible(self):
- _api.warn_deprecated("3.8", alternative="get_visible")
- return self.get_visible()
-
def clear(self):
"""Clear the selection and set the selector ready to make a new one."""
self._clear_without_update()
@@ -2574,7 +2599,7 @@ def __init__(self, ax, onselect, direction, *, minspan=0, useblit=False,
def new_axes(self, ax, *, _props=None, _init=False):
"""Set SpanSelector to operate on a new Axes."""
reconnect = False
- if _init or self.canvas is not ax.figure.canvas:
+ if _init or self.canvas is not ax.get_figure(root=True).canvas:
if self.canvas is not None:
self.disconnect_events()
reconnect = True
@@ -2627,7 +2652,7 @@ def _set_cursor(self, enabled):
else:
cursor = backend_tools.Cursors.POINTER
- self.ax.figure.canvas.set_cursor(cursor)
+ self.ax.get_figure(root=True).canvas.set_cursor(cursor)
def connect_default_events(self):
# docstring inherited
@@ -3039,7 +3064,7 @@ def closest(self, x, y):
ax : `~matplotlib.axes.Axes`
The parent Axes for the widget.
- onselect : function
+ onselect : function, optional
A callback function that is called after a release event and the
selection is created, changed or removed.
It must have the signature::
@@ -3152,7 +3177,8 @@ class RectangleSelector(_SelectorWidget):
See also: :doc:`/gallery/widgets/rectangle_selector`
"""
- def __init__(self, ax, onselect, *, minspanx=0, minspany=0, useblit=False,
+ def __init__(self, ax, onselect=None, *, minspanx=0,
+ minspany=0, useblit=False,
props=None, spancoords='data', button=None, grab_range=10,
handle_props=None, interactive=False,
state_modifier_keys=None, drag_from_anywhere=False,
@@ -3674,7 +3700,7 @@ def onselect(verts):
----------
ax : `~matplotlib.axes.Axes`
The parent Axes for the widget.
- onselect : function
+ onselect : function, optional
Whenever the lasso is released, the *onselect* function is called and
passed the vertices of the selected path.
useblit : bool, default: True
@@ -3689,7 +3715,7 @@ def onselect(verts):
which corresponds to all buttons.
"""
- def __init__(self, ax, onselect, *, useblit=True, props=None, button=None):
+ def __init__(self, ax, onselect=None, *, useblit=True, props=None, button=None):
super().__init__(ax, onselect, useblit=useblit, button=button)
self.verts = None
props = {
@@ -3747,7 +3773,7 @@ class PolygonSelector(_SelectorWidget):
ax : `~matplotlib.axes.Axes`
The parent Axes for the widget.
- onselect : function
+ onselect : function, optional
When a polygon is completed or modified after completion,
the *onselect* function is called and passed a list of the vertices as
``(xdata, ydata)`` tuples.
@@ -3799,7 +3825,7 @@ class PolygonSelector(_SelectorWidget):
point.
"""
- def __init__(self, ax, onselect, *, useblit=False,
+ def __init__(self, ax, onselect=None, *, useblit=False,
props=None, handle_props=None, grab_range=10,
draw_bounding_box=False, box_handle_props=None,
box_props=None):
@@ -3849,7 +3875,6 @@ def _get_bbox(self):
def _add_box(self):
self._box = RectangleSelector(self.ax,
- onselect=lambda *args, **kwargs: None,
useblit=self.useblit,
grab_range=self.grab_range,
handle_props=self._box_handle_props,
@@ -3971,11 +3996,17 @@ def onmove(self, event):
# needs to process the move callback even if there is no button press.
# _SelectorWidget.onmove include logic to ignore move event if
# _eventpress is None.
- if not self.ignore(event):
+ if self.ignore(event):
+ # Hide the cursor when interactive zoom/pan is active
+ if not self.canvas.widgetlock.available(self) and self._xys:
+ self._xys[-1] = (np.nan, np.nan)
+ self._draw_polygon()
+ return False
+
+ else:
event = self._clean_event(event)
self._onmove(event)
return True
- return False
def _onmove(self, event):
"""Cursor move event handler."""
diff --git a/lib/matplotlib/widgets.pyi b/lib/matplotlib/widgets.pyi
index 58adf85aae60..e143d0b2c96e 100644
--- a/lib/matplotlib/widgets.pyi
+++ b/lib/matplotlib/widgets.pyi
@@ -4,7 +4,7 @@ from .backend_bases import FigureCanvasBase, Event, MouseEvent, MouseButton
from .collections import LineCollection
from .figure import Figure
from .lines import Line2D
-from .patches import Circle, Polygon, Rectangle
+from .patches import Polygon, Rectangle
from .text import Text
import PIL.Image
@@ -64,7 +64,7 @@ class SliderBase(AxesWidget):
valmax: float
valstep: float | ArrayLike | None
drag_active: bool
- valfmt: str
+ valfmt: str | Callable[[float], str] | None
def __init__(
self,
ax: Axes,
@@ -73,7 +73,7 @@ class SliderBase(AxesWidget):
closedmax: bool,
valmin: float,
valmax: float,
- valfmt: str,
+ valfmt: str | Callable[[float], str] | None,
dragging: Slider | None,
valstep: float | ArrayLike | None,
) -> None: ...
@@ -130,7 +130,7 @@ class RangeSlider(SliderBase):
valmax: float,
*,
valinit: tuple[float, float] | None = ...,
- valfmt: str | None = ...,
+ valfmt: str | Callable[[float], str] | None = ...,
closedmin: bool = ...,
closedmax: bool = ...,
dragging: bool = ...,
@@ -154,11 +154,11 @@ class CheckButtons(AxesWidget):
actives: Iterable[bool] | None = ...,
*,
useblit: bool = ...,
- label_props: dict[str, Any] | None = ...,
+ label_props: dict[str, Sequence[Any]] | None = ...,
frame_props: dict[str, Any] | None = ...,
check_props: dict[str, Any] | None = ...,
) -> None: ...
- def set_label_props(self, props: dict[str, Any]) -> None: ...
+ def set_label_props(self, props: dict[str, Sequence[Any]]) -> None: ...
def set_frame_props(self, props: dict[str, Any]) -> None: ...
def set_check_props(self, props: dict[str, Any]) -> None: ...
def set_active(self, index: int, state: bool | None = ...) -> None: ... # type: ignore[override]
@@ -208,10 +208,10 @@ class RadioButtons(AxesWidget):
activecolor: ColorType | None = ...,
*,
useblit: bool = ...,
- label_props: dict[str, Any] | Sequence[dict[str, Any]] | None = ...,
+ label_props: dict[str, Sequence[Any]] | None = ...,
radio_props: dict[str, Any] | None = ...,
) -> None: ...
- def set_label_props(self, props: dict[str, Any]) -> None: ...
+ def set_label_props(self, props: dict[str, Sequence[Any]]) -> None: ...
def set_radio_props(self, props: dict[str, Any]) -> None: ...
def set_active(self, index: int) -> None: ...
def clear(self) -> None: ...
@@ -276,7 +276,7 @@ class _SelectorWidget(AxesWidget):
def __init__(
self,
ax: Axes,
- onselect: Callable[[float, float], Any],
+ onselect: Callable[[float, float], Any] | None = ...,
useblit: bool = ...,
button: MouseButton | Collection[MouseButton] | None = ...,
state_modifier_keys: dict[str, str] | None = ...,
@@ -294,8 +294,6 @@ class _SelectorWidget(AxesWidget):
def on_key_release(self, event: Event) -> None: ...
def set_visible(self, visible: bool) -> None: ...
def get_visible(self) -> bool: ...
- @property
- def visible(self) -> bool: ...
def clear(self) -> None: ...
@property
def artists(self) -> tuple[Artist]: ...
@@ -403,7 +401,7 @@ class RectangleSelector(_SelectorWidget):
def __init__(
self,
ax: Axes,
- onselect: Callable[[MouseEvent, MouseEvent], Any],
+ onselect: Callable[[MouseEvent, MouseEvent], Any] | None = ...,
*,
minspanx: float = ...,
minspany: float = ...,
@@ -443,7 +441,7 @@ class LassoSelector(_SelectorWidget):
def __init__(
self,
ax: Axes,
- onselect: Callable[[list[tuple[float, float]]], Any],
+ onselect: Callable[[list[tuple[float, float]]], Any] | None = ...,
*,
useblit: bool = ...,
props: dict[str, Any] | None = ...,
@@ -455,7 +453,7 @@ class PolygonSelector(_SelectorWidget):
def __init__(
self,
ax: Axes,
- onselect: Callable[[ArrayLike, ArrayLike], Any],
+ onselect: Callable[[ArrayLike, ArrayLike], Any] | None = ...,
*,
useblit: bool = ...,
props: dict[str, Any] | None = ...,
diff --git a/lib/mpl_toolkits/axes_grid1/anchored_artists.py b/lib/mpl_toolkits/axes_grid1/anchored_artists.py
index 1238310b462b..a8be06800a07 100644
--- a/lib/mpl_toolkits/axes_grid1/anchored_artists.py
+++ b/lib/mpl_toolkits/axes_grid1/anchored_artists.py
@@ -1,12 +1,12 @@
-from matplotlib import _api, transforms
+from matplotlib import transforms
from matplotlib.offsetbox import (AnchoredOffsetbox, AuxTransformBox,
DrawingArea, TextArea, VPacker)
-from matplotlib.patches import (Rectangle, Ellipse, ArrowStyle,
+from matplotlib.patches import (Rectangle, ArrowStyle,
FancyArrowPatch, PathPatch)
from matplotlib.text import TextPath
__all__ = ['AnchoredDrawingArea', 'AnchoredAuxTransformBox',
- 'AnchoredEllipse', 'AnchoredSizeBar', 'AnchoredDirectionArrows']
+ 'AnchoredSizeBar', 'AnchoredDirectionArrows']
class AnchoredDrawingArea(AnchoredOffsetbox):
@@ -83,7 +83,7 @@ def __init__(self, transform, loc,
----------
transform : `~matplotlib.transforms.Transform`
The transformation object for the coordinate system in use, i.e.,
- :attr:`matplotlib.axes.Axes.transData`.
+ :attr:`!matplotlib.axes.Axes.transData`.
loc : str
Location of this artist. Valid locations are
'upper left', 'upper center', 'upper right',
@@ -124,54 +124,6 @@ def __init__(self, transform, loc,
**kwargs)
-@_api.deprecated("3.8")
-class AnchoredEllipse(AnchoredOffsetbox):
- def __init__(self, transform, width, height, angle, loc,
- pad=0.1, borderpad=0.1, prop=None, frameon=True, **kwargs):
- """
- Draw an anchored ellipse of a given size.
-
- Parameters
- ----------
- transform : `~matplotlib.transforms.Transform`
- The transformation object for the coordinate system in use, i.e.,
- :attr:`matplotlib.axes.Axes.transData`.
- width, height : float
- Width and height of the ellipse, given in coordinates of
- *transform*.
- angle : float
- Rotation of the ellipse, in degrees, anti-clockwise.
- loc : str
- Location of the ellipse. Valid locations are
- 'upper left', 'upper center', 'upper right',
- 'center left', 'center', 'center right',
- 'lower left', 'lower center', 'lower right'.
- For backward compatibility, numeric values are accepted as well.
- See the parameter *loc* of `.Legend` for details.
- pad : float, default: 0.1
- Padding around the ellipse, in fraction of the font size.
- borderpad : float, default: 0.1
- Border padding, in fraction of the font size.
- frameon : bool, default: True
- If True, draw a box around the ellipse.
- prop : `~matplotlib.font_manager.FontProperties`, optional
- Font property used as a reference for paddings.
- **kwargs
- Keyword arguments forwarded to `.AnchoredOffsetbox`.
-
- Attributes
- ----------
- ellipse : `~matplotlib.patches.Ellipse`
- Ellipse patch drawn.
- """
- self._box = AuxTransformBox(transform)
- self.ellipse = Ellipse((0, 0), width, height, angle=angle)
- self._box.add_artist(self.ellipse)
-
- super().__init__(loc, pad=pad, borderpad=borderpad, child=self._box,
- prop=prop, frameon=frameon, **kwargs)
-
-
class AnchoredSizeBar(AnchoredOffsetbox):
def __init__(self, transform, size, label, loc,
pad=0.1, borderpad=0.1, sep=2,
@@ -185,7 +137,7 @@ def __init__(self, transform, size, label, loc,
----------
transform : `~matplotlib.transforms.Transform`
The transformation object for the coordinate system in use, i.e.,
- :attr:`matplotlib.axes.Axes.transData`.
+ :attr:`!matplotlib.axes.Axes.transData`.
size : float
Horizontal length of the size bar, given in coordinates of
*transform*.
@@ -304,7 +256,7 @@ def __init__(self, transform, label_x, label_y, length=0.15,
----------
transform : `~matplotlib.transforms.Transform`
The transformation object for the coordinate system in use, i.e.,
- :attr:`matplotlib.axes.Axes.transAxes`.
+ :attr:`!matplotlib.axes.Axes.transAxes`.
label_x, label_y : str
Label text for the x and y arrows
length : float, default: 0.15
diff --git a/lib/mpl_toolkits/axes_grid1/axes_divider.py b/lib/mpl_toolkits/axes_grid1/axes_divider.py
index f6c38f35dbc4..50365f482b72 100644
--- a/lib/mpl_toolkits/axes_grid1/axes_divider.py
+++ b/lib/mpl_toolkits/axes_grid1/axes_divider.py
@@ -199,31 +199,6 @@ def new_locator(self, nx, ny, nx1=None, ny1=None):
locator.get_subplotspec = self.get_subplotspec
return locator
- @_api.deprecated(
- "3.8", alternative="divider.new_locator(...)(ax, renderer)")
- def locate(self, nx, ny, nx1=None, ny1=None, axes=None, renderer=None):
- """
- Implementation of ``divider.new_locator().__call__``.
-
- Parameters
- ----------
- nx, nx1 : int
- Integers specifying the column-position of the cell. When *nx1* is
- None, a single *nx*-th column is specified. Otherwise, the
- location of columns spanning between *nx* to *nx1* (but excluding
- *nx1*-th column) is specified.
- ny, ny1 : int
- Same as *nx* and *nx1*, but for row positions.
- axes
- renderer
- """
- xref = self._xrefindex
- yref = self._yrefindex
- return self._locate(
- nx - xref, (nx + 1 if nx1 is None else nx1) - xref,
- ny - yref, (ny + 1 if ny1 is None else ny1) - yref,
- axes, renderer)
-
def _locate(self, nx, ny, nx1, ny1, axes, renderer):
"""
Implementation of ``divider.new_locator().__call__``.
@@ -305,57 +280,6 @@ def add_auto_adjustable_area(self, use_axes, pad=0.1, adjust_dirs=None):
self.append_size(d, Size._AxesDecorationsSize(use_axes, d) + pad)
-@_api.deprecated("3.8")
-class AxesLocator:
- """
- A callable object which returns the position and size of a given
- `.AxesDivider` cell.
- """
-
- def __init__(self, axes_divider, nx, ny, nx1=None, ny1=None):
- """
- Parameters
- ----------
- axes_divider : `~mpl_toolkits.axes_grid1.axes_divider.AxesDivider`
- nx, nx1 : int
- Integers specifying the column-position of the
- cell. When *nx1* is None, a single *nx*-th column is
- specified. Otherwise, location of columns spanning between *nx*
- to *nx1* (but excluding *nx1*-th column) is specified.
- ny, ny1 : int
- Same as *nx* and *nx1*, but for row positions.
- """
- self._axes_divider = axes_divider
-
- _xrefindex = axes_divider._xrefindex
- _yrefindex = axes_divider._yrefindex
-
- self._nx, self._ny = nx - _xrefindex, ny - _yrefindex
-
- if nx1 is None:
- nx1 = len(self._axes_divider)
- if ny1 is None:
- ny1 = len(self._axes_divider[0])
-
- self._nx1 = nx1 - _xrefindex
- self._ny1 = ny1 - _yrefindex
-
- def __call__(self, axes, renderer):
-
- _xrefindex = self._axes_divider._xrefindex
- _yrefindex = self._axes_divider._yrefindex
-
- return self._axes_divider.locate(self._nx + _xrefindex,
- self._ny + _yrefindex,
- self._nx1 + _xrefindex,
- self._ny1 + _yrefindex,
- axes,
- renderer)
-
- def get_subplotspec(self):
- return self._axes_divider.get_subplotspec()
-
-
class SubplotDivider(Divider):
"""
The Divider class whose rectangle area is specified as a subplot geometry.
diff --git a/lib/mpl_toolkits/axes_grid1/axes_grid.py b/lib/mpl_toolkits/axes_grid1/axes_grid.py
index 315a7bccd668..f7d2968f1990 100644
--- a/lib/mpl_toolkits/axes_grid1/axes_grid.py
+++ b/lib/mpl_toolkits/axes_grid1/axes_grid.py
@@ -17,14 +17,9 @@ def __init__(self, *args, orientation, **kwargs):
super().__init__(*args, **kwargs)
def colorbar(self, mappable, **kwargs):
- return self.figure.colorbar(
+ return self.get_figure(root=False).colorbar(
mappable, cax=self, location=self.orientation, **kwargs)
- @_api.deprecated("3.8", alternative="ax.tick_params and colorbar.set_label")
- def toggle_label(self, b):
- axis = self.axis[self.orientation]
- axis.toggle(ticklabels=b, label=b)
-
_cbaraxes_class_factory = cbook._make_class_factory(CbarAxesBase, "Cbar{}")
@@ -56,16 +51,17 @@ class Grid:
in the usage pattern ``grid.axes_row[row][col]``.
axes_llc : Axes
The Axes in the lower left corner.
- ngrids : int
+ n_axes : int
Number of Axes in the grid.
"""
_defaultAxesClass = Axes
+ @_api.rename_parameter("3.11", "ngrids", "n_axes")
def __init__(self, fig,
rect,
nrows_ncols,
- ngrids=None,
+ n_axes=None,
direction="row",
axes_pad=0.02,
*,
@@ -88,8 +84,8 @@ def __init__(self, fig,
``121``), or as a `~.SubplotSpec`.
nrows_ncols : (int, int)
Number of rows and columns in the grid.
- ngrids : int or None, default: None
- If not None, only the first *ngrids* axes in the grid are created.
+ n_axes : int, optional
+ If given, only the first *n_axes* axes in the grid are created.
direction : {"row", "column"}, default: "row"
Whether axes are created in row-major ("row by row") or
column-major order ("column by column"). This also affects the
@@ -121,14 +117,12 @@ def __init__(self, fig,
"""
self._nrows, self._ncols = nrows_ncols
- if ngrids is None:
- ngrids = self._nrows * self._ncols
+ if n_axes is None:
+ n_axes = self._nrows * self._ncols
else:
- if not 0 < ngrids <= self._nrows * self._ncols:
+ if not 0 < n_axes <= self._nrows * self._ncols:
raise ValueError(
- "ngrids must be positive and not larger than nrows*ncols")
-
- self.ngrids = ngrids
+ "n_axes must be positive and not larger than nrows*ncols")
self._horiz_pad_size, self._vert_pad_size = map(
Size.Fixed, np.broadcast_to(axes_pad, 2))
@@ -155,7 +149,7 @@ def __init__(self, fig,
rect = self._divider.get_position()
axes_array = np.full((self._nrows, self._ncols), None, dtype=object)
- for i in range(self.ngrids):
+ for i in range(n_axes):
col, row = self._get_col_row(i)
if share_all:
sharex = sharey = axes_array[0, 0]
@@ -165,9 +159,9 @@ def __init__(self, fig,
axes_array[row, col] = axes_class(
fig, rect, sharex=sharex, sharey=sharey)
self.axes_all = axes_array.ravel(
- order="C" if self._direction == "row" else "F").tolist()
- self.axes_column = axes_array.T.tolist()
- self.axes_row = axes_array.tolist()
+ order="C" if self._direction == "row" else "F").tolist()[:n_axes]
+ self.axes_row = [[ax for ax in row if ax] for row in axes_array]
+ self.axes_column = [[ax for ax in col if ax] for col in axes_array.T]
self.axes_llc = self.axes_column[0][-1]
self._init_locators()
@@ -182,7 +176,7 @@ def _init_locators(self):
[Size.Scaled(1), self._horiz_pad_size] * (self._ncols-1) + [Size.Scaled(1)])
self._divider.set_vertical(
[Size.Scaled(1), self._vert_pad_size] * (self._nrows-1) + [Size.Scaled(1)])
- for i in range(self.ngrids):
+ for i in range(self.n_axes):
col, row = self._get_col_row(i)
self.axes_all[i].set_axes_locator(
self._divider.new_locator(nx=2 * col, ny=2 * (self._nrows - 1 - row)))
@@ -195,6 +189,9 @@ def _get_col_row(self, n):
return col, row
+ n_axes = property(lambda self: len(self.axes_all))
+ ngrids = _api.deprecated(property(lambda self: len(self.axes_all)))
+
# Good to propagate __len__ if we have __getitem__
def __len__(self):
return len(self.axes_all)
@@ -256,28 +253,27 @@ def set_label_mode(self, mode):
- "keep": Do not do anything.
"""
_api.check_in_list(["all", "L", "1", "keep"], mode=mode)
- is_last_row, is_first_col = (
- np.mgrid[:self._nrows, :self._ncols] == [[[self._nrows - 1]], [[0]]])
- if mode == "all":
- bottom = left = np.full((self._nrows, self._ncols), True)
- elif mode == "L":
- bottom = is_last_row
- left = is_first_col
- elif mode == "1":
- bottom = left = is_last_row & is_first_col
- else:
+ if mode == "keep":
return
- for i in range(self._nrows):
- for j in range(self._ncols):
+ for i, j in np.ndindex(self._nrows, self._ncols):
+ try:
ax = self.axes_row[i][j]
- if isinstance(ax.axis, MethodType):
- bottom_axis = SimpleAxisArtist(ax.xaxis, 1, ax.spines["bottom"])
- left_axis = SimpleAxisArtist(ax.yaxis, 1, ax.spines["left"])
- else:
- bottom_axis = ax.axis["bottom"]
- left_axis = ax.axis["left"]
- bottom_axis.toggle(ticklabels=bottom[i, j], label=bottom[i, j])
- left_axis.toggle(ticklabels=left[i, j], label=left[i, j])
+ except IndexError:
+ continue
+ if isinstance(ax.axis, MethodType):
+ bottom_axis = SimpleAxisArtist(ax.xaxis, 1, ax.spines["bottom"])
+ left_axis = SimpleAxisArtist(ax.yaxis, 1, ax.spines["left"])
+ else:
+ bottom_axis = ax.axis["bottom"]
+ left_axis = ax.axis["left"]
+ display_at_bottom = (i == self._nrows - 1 if mode == "L" else
+ i == self._nrows - 1 and j == 0 if mode == "1" else
+ True) # if mode == "all"
+ display_at_left = (j == 0 if mode == "L" else
+ i == self._nrows - 1 and j == 0 if mode == "1" else
+ True) # if mode == "all"
+ bottom_axis.toggle(ticklabels=display_at_bottom, label=display_at_bottom)
+ left_axis.toggle(ticklabels=display_at_left, label=display_at_left)
def get_divider(self):
return self._divider
@@ -302,7 +298,7 @@ class ImageGrid(Grid):
def __init__(self, fig,
rect,
nrows_ncols,
- ngrids=None,
+ n_axes=None,
direction="row",
axes_pad=0.02,
*,
@@ -326,8 +322,8 @@ def __init__(self, fig,
as a three-digit subplot position code (e.g., "121").
nrows_ncols : (int, int)
Number of rows and columns in the grid.
- ngrids : int or None, default: None
- If not None, only the first *ngrids* axes in the grid are created.
+ n_axes : int, optional
+ If given, only the first *n_axes* axes in the grid are created.
direction : {"row", "column"}, default: "row"
Whether axes are created in row-major ("row by row") or
column-major order ("column by column"). This also affects the
@@ -354,16 +350,21 @@ def __init__(self, fig,
Whether to create a colorbar for "each" axes, a "single" colorbar
for the entire grid, colorbars only for axes on the "edge"
determined by *cbar_location*, or no colorbars. The colorbars are
- stored in the :attr:`cbar_axes` attribute.
+ stored in the :attr:`!cbar_axes` attribute.
cbar_location : {"left", "right", "bottom", "top"}, default: "right"
cbar_pad : float, default: None
Padding between the image axes and the colorbar axes.
- cbar_size : size specification (see `.Size.from_any`), default: "5%"
+
+ .. versionchanged:: 3.10
+ ``cbar_mode="single"`` no longer adds *axes_pad* between the axes
+ and the colorbar if the *cbar_location* is "left" or "bottom".
+
+ cbar_size : size specification (see `!.Size.from_any`), default: "5%"
Colorbar size.
cbar_set_cax : bool, default: True
If True, each axes in the grid has a *cax* attribute that is bound
to associated *cbar_axes*.
- axes_class : subclass of `matplotlib.axes.Axes`, default: None
+ axes_class : subclass of `matplotlib.axes.Axes`, default: `.mpl_axes.Axes`
"""
_api.check_in_list(["each", "single", "edge", None],
cbar_mode=cbar_mode)
@@ -376,7 +377,7 @@ def __init__(self, fig,
# The colorbar axes are created in _init_locators().
super().__init__(
- fig, rect, nrows_ncols, ngrids,
+ fig, rect, nrows_ncols, n_axes,
direction=direction, axes_pad=axes_pad,
share_all=share_all, share_x=True, share_y=True, aspect=aspect,
label_mode=label_mode, axes_class=axes_class)
@@ -410,9 +411,9 @@ def _init_locators(self):
self._colorbar_pad = self._vert_pad_size.fixed_size
self.cbar_axes = [
_cbaraxes_class_factory(self._defaultAxesClass)(
- self.axes_all[0].figure, self._divider.get_position(),
+ self.axes_all[0].get_figure(root=False), self._divider.get_position(),
orientation=self._colorbar_location)
- for _ in range(self.ngrids)]
+ for _ in range(self.n_axes)]
cb_mode = self._colorbar_mode
cb_location = self._colorbar_location
@@ -433,13 +434,13 @@ def _init_locators(self):
v.append(Size.from_any(self._colorbar_size, sz))
v.append(Size.from_any(self._colorbar_pad, sz))
locator = self._divider.new_locator(nx=0, nx1=-1, ny=0)
- for i in range(self.ngrids):
+ for i in range(self.n_axes):
self.cbar_axes[i].set_visible(False)
self.cbar_axes[0].set_axes_locator(locator)
self.cbar_axes[0].set_visible(True)
for col, ax in enumerate(self.axes_row[0]):
- if h:
+ if col != 0:
h.append(self._horiz_pad_size)
if ax:
@@ -468,7 +469,7 @@ def _init_locators(self):
v_ax_pos = []
v_cb_pos = []
for row, ax in enumerate(self.axes_column[0][::-1]):
- if v:
+ if row != 0:
v.append(self._vert_pad_size)
if ax:
@@ -494,7 +495,7 @@ def _init_locators(self):
v_cb_pos.append(len(v))
v.append(Size.from_any(self._colorbar_size, sz))
- for i in range(self.ngrids):
+ for i in range(self.n_axes):
col, row = self._get_col_row(i)
locator = self._divider.new_locator(nx=h_ax_pos[col],
ny=v_ax_pos[self._nrows-1-row])
@@ -534,12 +535,12 @@ def _init_locators(self):
v.append(Size.from_any(self._colorbar_size, sz))
locator = self._divider.new_locator(nx=0, nx1=-1, ny=-2)
if cb_location in ("right", "top"):
- for i in range(self.ngrids):
+ for i in range(self.n_axes):
self.cbar_axes[i].set_visible(False)
self.cbar_axes[0].set_axes_locator(locator)
self.cbar_axes[0].set_visible(True)
elif cb_mode == "each":
- for i in range(self.ngrids):
+ for i in range(self.n_axes):
self.cbar_axes[i].set_visible(True)
elif cb_mode == "edge":
if cb_location in ("right", "left"):
@@ -548,10 +549,10 @@ def _init_locators(self):
count = self._ncols
for i in range(count):
self.cbar_axes[i].set_visible(True)
- for j in range(i + 1, self.ngrids):
+ for j in range(i + 1, self.n_axes):
self.cbar_axes[j].set_visible(False)
else:
- for i in range(self.ngrids):
+ for i in range(self.n_axes):
self.cbar_axes[i].set_visible(False)
self.cbar_axes[i].set_position([1., 1., 0.001, 0.001],
which="active")
diff --git a/lib/mpl_toolkits/axes_grid1/axes_size.py b/lib/mpl_toolkits/axes_grid1/axes_size.py
index e417c1a899ac..55820827cd6a 100644
--- a/lib/mpl_toolkits/axes_grid1/axes_size.py
+++ b/lib/mpl_toolkits/axes_grid1/axes_size.py
@@ -1,12 +1,16 @@
"""
Provides classes of simple units that will be used with `.AxesDivider`
class (or others) to determine the size of each Axes. The unit
-classes define `get_size` method that returns a tuple of two floats,
+classes define `!get_size` method that returns a tuple of two floats,
meaning relative and absolute sizes, respectively.
Note that this class is nothing more than a simple tuple of two
floats. Take a look at the Divider class to see how these two
values are used.
+
+Once created, the unit classes can be modified by simple arithmetic
+operations: addition /subtraction with another unit type or a real number and scaling
+(multiplication or division) by a real number.
"""
from numbers import Real
@@ -17,14 +21,33 @@ class (or others) to determine the size of each Axes. The unit
class _Base:
def __rmul__(self, other):
+ return self * other
+
+ def __mul__(self, other):
+ if not isinstance(other, Real):
+ return NotImplemented
return Fraction(other, self)
+ def __div__(self, other):
+ return (1 / other) * self
+
def __add__(self, other):
if isinstance(other, _Base):
return Add(self, other)
else:
return Add(self, Fixed(other))
+ def __neg__(self):
+ return -1 * self
+
+ def __radd__(self, other):
+ # other cannot be a _Base instance, because A + B would trigger
+ # A.__add__(B) first.
+ return Add(self, Fixed(other))
+
+ def __sub__(self, other):
+ return self + (-other)
+
def get_size(self, renderer):
"""
Return two-float tuple with relative and absolute sizes.
diff --git a/lib/mpl_toolkits/axes_grid1/inset_locator.py b/lib/mpl_toolkits/axes_grid1/inset_locator.py
index 6d591a45311b..a1a9cc8df591 100644
--- a/lib/mpl_toolkits/axes_grid1/inset_locator.py
+++ b/lib/mpl_toolkits/axes_grid1/inset_locator.py
@@ -6,58 +6,13 @@
from matplotlib.offsetbox import AnchoredOffsetbox
from matplotlib.patches import Patch, Rectangle
from matplotlib.path import Path
-from matplotlib.transforms import Bbox, BboxTransformTo
+from matplotlib.transforms import Bbox
from matplotlib.transforms import IdentityTransform, TransformedBbox
from . import axes_size as Size
from .parasite_axes import HostAxes
-@_api.deprecated("3.8", alternative="Axes.inset_axes")
-class InsetPosition:
- @_docstring.dedent_interpd
- def __init__(self, parent, lbwh):
- """
- An object for positioning an inset axes.
-
- This is created by specifying the normalized coordinates in the axes,
- instead of the figure.
-
- Parameters
- ----------
- parent : `~matplotlib.axes.Axes`
- Axes to use for normalizing coordinates.
-
- lbwh : iterable of four floats
- The left edge, bottom edge, width, and height of the inset axes, in
- units of the normalized coordinate of the *parent* axes.
-
- See Also
- --------
- :meth:`matplotlib.axes.Axes.set_axes_locator`
-
- Examples
- --------
- The following bounds the inset axes to a box with 20%% of the parent
- axes height and 40%% of the width. The size of the axes specified
- ([0, 0, 1, 1]) ensures that the axes completely fills the bounding box:
-
- >>> parent_axes = plt.gca()
- >>> ax_ins = plt.axes([0, 0, 1, 1])
- >>> ip = InsetPosition(parent_axes, [0.5, 0.1, 0.4, 0.2])
- >>> ax_ins.set_axes_locator(ip)
- """
- self.parent = parent
- self.lbwh = lbwh
-
- def __call__(self, ax, renderer):
- bbox_parent = self.parent.get_position(original=False)
- trans = BboxTransformTo(bbox_parent)
- bbox_inset = Bbox.from_bounds(*self.lbwh)
- bb = TransformedBbox(bbox_inset, trans)
- return bb
-
-
class AnchoredLocatorBase(AnchoredOffsetbox):
def __init__(self, bbox_to_anchor, offsetbox, loc,
borderpad=0.5, bbox_transform=None):
@@ -70,13 +25,14 @@ def draw(self, renderer):
raise RuntimeError("No draw method should be called")
def __call__(self, ax, renderer):
+ fig = ax.get_figure(root=False)
if renderer is None:
- renderer = ax.figure._get_renderer()
+ renderer = fig._get_renderer()
self.axes = ax
bbox = self.get_window_extent(renderer)
px, py = self.get_offset(bbox.width, bbox.height, 0, 0, renderer)
bbox_canvas = Bbox.from_bounds(px, py, bbox.width, bbox.height)
- tr = ax.figure.transSubfigure.inverted()
+ tr = fig.transSubfigure.inverted()
return TransformedBbox(bbox_canvas, tr)
@@ -130,7 +86,7 @@ def get_bbox(self, renderer):
class BboxPatch(Patch):
- @_docstring.dedent_interpd
+ @_docstring.interpd
def __init__(self, bbox, **kwargs):
"""
Patch showing the shape bounded by a Bbox.
@@ -192,7 +148,7 @@ def connect_bbox(bbox1, bbox2, loc1, loc2=None):
x2, y2 = BboxConnector.get_bbox_edge_pos(bbox2, loc2)
return Path([[x1, y1], [x2, y2]])
- @_docstring.dedent_interpd
+ @_docstring.interpd
def __init__(self, bbox1, bbox2, loc1, loc2=None, **kwargs):
"""
Connect two bboxes with a straight line.
@@ -236,7 +192,7 @@ def get_path(self):
class BboxConnectorPatch(BboxConnector):
- @_docstring.dedent_interpd
+ @_docstring.interpd
def __init__(self, bbox1, bbox2, loc1a, loc2a, loc1b, loc2b, **kwargs):
"""
Connect two bboxes with a quadrilateral.
@@ -287,13 +243,14 @@ def _add_inset_axes(parent_axes, axes_class, axes_kwargs, axes_locator):
axes_class = HostAxes
if axes_kwargs is None:
axes_kwargs = {}
+ fig = parent_axes.get_figure(root=False)
inset_axes = axes_class(
- parent_axes.figure, parent_axes.get_position(),
+ fig, parent_axes.get_position(),
**{"navigate": False, **axes_kwargs, "axes_locator": axes_locator})
- return parent_axes.figure.add_axes(inset_axes)
+ return fig.add_axes(inset_axes)
-@_docstring.dedent_interpd
+@_docstring.interpd
def inset_axes(parent_axes, width, height, loc='upper right',
bbox_to_anchor=None, bbox_transform=None,
axes_class=None, axes_kwargs=None,
@@ -384,18 +341,24 @@ def inset_axes(parent_axes, width, height, loc='upper right',
%(Axes:kwdoc)s
- borderpad : float, default: 0.5
+ borderpad : float or (float, float), default: 0.5
Padding between inset axes and the bbox_to_anchor.
+ If a float, the same padding is used for both x and y.
+ If a tuple of two floats, it specifies the (x, y) padding.
The units are axes font size, i.e. for a default font size of 10 points
*borderpad = 0.5* is equivalent to a padding of 5 points.
+ .. versionadded:: 3.11
+ The *borderpad* parameter now accepts a tuple of (x, y) paddings.
+
Returns
-------
inset_axes : *axes_class*
Inset axes object created.
"""
- if (bbox_transform in [parent_axes.transAxes, parent_axes.figure.transFigure]
+ if (bbox_transform in [parent_axes.transAxes,
+ parent_axes.get_figure(root=False).transFigure]
and bbox_to_anchor is None):
_api.warn_external("Using the axes or figure transform requires a "
"bounding box in the respective coordinates. "
@@ -416,7 +379,7 @@ def inset_axes(parent_axes, width, height, loc='upper right',
bbox_transform=bbox_transform, borderpad=borderpad))
-@_docstring.dedent_interpd
+@_docstring.interpd
def zoomed_inset_axes(parent_axes, zoom, loc='upper right',
bbox_to_anchor=None, bbox_transform=None,
axes_class=None, axes_kwargs=None,
@@ -509,7 +472,7 @@ def get_points(self):
return super().get_points()
-@_docstring.dedent_interpd
+@_docstring.interpd
def mark_inset(parent_axes, inset_axes, loc1, loc2, **kwargs):
"""
Draw a box to mark the location of an area represented by an inset axes.
diff --git a/lib/mpl_toolkits/axes_grid1/parasite_axes.py b/lib/mpl_toolkits/axes_grid1/parasite_axes.py
index 2a2b5957e844..fbc6e8141272 100644
--- a/lib/mpl_toolkits/axes_grid1/parasite_axes.py
+++ b/lib/mpl_toolkits/axes_grid1/parasite_axes.py
@@ -13,7 +13,8 @@ def __init__(self, parent_axes, aux_transform=None,
self.transAux = aux_transform
self.set_viewlim_mode(viewlim_mode)
kwargs["frameon"] = False
- super().__init__(parent_axes.figure, parent_axes._position, **kwargs)
+ super().__init__(parent_axes.get_figure(root=False),
+ parent_axes._position, **kwargs)
def clear(self):
super().clear()
@@ -24,6 +25,9 @@ def clear(self):
self._parent_axes.callbacks._connect_picklable(
"ylim_changed", self._sync_lims)
+ def get_axes_locator(self):
+ return self._parent_axes.get_axes_locator()
+
def pick(self, mouseevent):
# This most likely goes to Artist.pick (depending on axes_class given
# to the factory), which only handles pick events registered on the
@@ -215,8 +219,7 @@ def _remove_any_twin(self, ax):
self.axis[tuple(restore)].set_visible(True)
self.axis[tuple(restore)].toggle(ticklabels=False, label=False)
- @_api.make_keyword_only("3.8", "call_axes_locator")
- def get_tightbbox(self, renderer=None, call_axes_locator=True,
+ def get_tightbbox(self, renderer=None, *, call_axes_locator=True,
bbox_extra_artists=None):
bbs = [
*[ax.get_tightbbox(renderer, call_axes_locator=call_axes_locator)
diff --git a/lib/mpl_toolkits/axes_grid1/tests/baseline_images/test_axes_grid1/insetposition.png b/lib/mpl_toolkits/axes_grid1/tests/baseline_images/test_axes_grid1/insetposition.png
deleted file mode 100644
index e8676cfd6c95..000000000000
Binary files a/lib/mpl_toolkits/axes_grid1/tests/baseline_images/test_axes_grid1/insetposition.png and /dev/null differ
diff --git a/lib/mpl_toolkits/axes_grid1/tests/test_axes_grid1.py b/lib/mpl_toolkits/axes_grid1/tests/test_axes_grid1.py
index 7c444f6ae178..b6d72e408a52 100644
--- a/lib/mpl_toolkits/axes_grid1/tests/test_axes_grid1.py
+++ b/lib/mpl_toolkits/axes_grid1/tests/test_axes_grid1.py
@@ -9,7 +9,7 @@
from matplotlib.backend_bases import MouseEvent
from matplotlib.colors import LogNorm
from matplotlib.patches import Circle, Ellipse
-from matplotlib.transforms import Bbox, TransformedBbox
+from matplotlib.transforms import Affine2D, Bbox, TransformedBbox
from matplotlib.testing.decorators import (
check_figures_equal, image_comparison, remove_ticks_and_titles)
@@ -18,15 +18,15 @@
host_subplot, make_axes_locatable,
Grid, AxesGrid, ImageGrid)
from mpl_toolkits.axes_grid1.anchored_artists import (
- AnchoredAuxTransformBox, AnchoredDrawingArea, AnchoredEllipse,
+ AnchoredAuxTransformBox, AnchoredDrawingArea,
AnchoredDirectionArrows, AnchoredSizeBar)
from mpl_toolkits.axes_grid1.axes_divider import (
Divider, HBoxDivider, make_axes_area_auto_adjustable, SubplotDivider,
VBoxDivider)
from mpl_toolkits.axes_grid1.axes_rgb import RGBAxes
from mpl_toolkits.axes_grid1.inset_locator import (
- zoomed_inset_axes, mark_inset, inset_axes, BboxConnectorPatch,
- InsetPosition)
+ zoomed_inset_axes, mark_inset, inset_axes, BboxConnectorPatch)
+from mpl_toolkits.axes_grid1.parasite_axes import HostAxes
import mpl_toolkits.axes_grid1.mpl_axes
import pytest
@@ -93,8 +93,8 @@ def test_twin_axes_empty_and_removed():
def test_twin_axes_both_with_units():
host = host_subplot(111)
- with pytest.warns(mpl.MatplotlibDeprecationWarning):
- host.plot_date([0, 1, 2], [0, 1, 2], xdate=False, ydate=True)
+ host.yaxis.axis_date()
+ host.plot([0, 1, 2], [0, 1, 2])
twin = host.twinx()
twin.plot(["a", "b", "c"])
assert host.get_yticklabels()[0].get_text() == "00:00:00"
@@ -105,7 +105,6 @@ def test_axesgrid_colorbar_log_smoketest():
fig = plt.figure()
grid = AxesGrid(fig, 111, # modified to be only subplot
nrows_ncols=(1, 1),
- ngrids=1,
label_mode="L",
cbar_location="top",
cbar_mode="single",
@@ -347,7 +346,7 @@ def test_fill_facecolor():
# Update style when regenerating the test image
@image_comparison(['zoomed_axes.png', 'inverted_zoomed_axes.png'],
style=('classic', '_classic_test_patch'),
- tol=0.02 if platform.machine() == 'arm64' else 0)
+ tol=0 if platform.machine() == 'x86_64' else 0.02)
def test_zooming_with_inverted_axes():
fig, ax = plt.subplots()
ax.plot([1, 2, 3], [1, 2, 3])
@@ -424,7 +423,7 @@ def test_image_grid_single_bottom():
fig = plt.figure(1, (2.5, 1.5))
grid = ImageGrid(fig, (0, 0, 1, 1), nrows_ncols=(1, 3),
- axes_pad=(0.2, 0.15), cbar_mode="single",
+ axes_pad=(0.2, 0.15), cbar_mode="single", cbar_pad=0.3,
cbar_location="bottom", cbar_size="10%", label_mode="1")
# 4-tuple rect => Divider, isinstance will give True for SubplotDivider
assert type(grid.get_divider()) is Divider
@@ -469,6 +468,26 @@ def test_gettightbbox():
[-17.7, -13.9, 7.2, 5.4])
+def test_gettightbbox_parasite():
+ fig = plt.figure()
+
+ y0 = 0.3
+ horiz = [Size.Scaled(1.0)]
+ vert = [Size.Scaled(1.0)]
+ ax0_div = Divider(fig, [0.1, y0, 0.8, 0.2], horiz, vert)
+ ax1_div = Divider(fig, [0.1, 0.5, 0.8, 0.4], horiz, vert)
+
+ ax0 = fig.add_subplot(
+ xticks=[], yticks=[], axes_locator=ax0_div.new_locator(nx=0, ny=0))
+ ax1 = fig.add_subplot(
+ axes_class=HostAxes, axes_locator=ax1_div.new_locator(nx=0, ny=0))
+ aux_ax = ax1.get_aux_axes(Affine2D())
+
+ fig.canvas.draw()
+ rdr = fig.canvas.get_renderer()
+ assert rdr.get_canvas_width_height()[1] * y0 / fig.dpi == fig.get_tightbbox(rdr).y0
+
+
@pytest.mark.parametrize("click_on", ["big", "small"])
@pytest.mark.parametrize("big_on_axes,small_on_axes", [
("gca", "gca"),
@@ -515,7 +534,7 @@ def on_pick(event):
if click_axes is axes["parasite"]:
click_axes = axes["host"]
(x, y) = click_axes.transAxes.transform(axes_coords)
- m = MouseEvent("button_press_event", click_axes.figure.canvas, x, y,
+ m = MouseEvent("button_press_event", click_axes.get_figure(root=True).canvas, x, y,
button=1)
click_axes.pick(m)
# Checks
@@ -543,12 +562,14 @@ def test_anchored_artists():
box.drawing_area.add_artist(el)
ax.add_artist(box)
- # Manually construct the ellipse instead, once the deprecation elapses.
- with pytest.warns(mpl.MatplotlibDeprecationWarning):
- ae = AnchoredEllipse(ax.transData, width=0.1, height=0.25, angle=-60,
- loc='lower left', pad=0.5, borderpad=0.4,
- frameon=True)
- ax.add_artist(ae)
+ # This block used to test the AnchoredEllipse class, but that was removed. The block
+ # remains, though it duplicates the above ellipse, so that the test image doesn't
+ # need to be regenerated.
+ box = AnchoredAuxTransformBox(ax.transData, loc='lower left', frameon=True,
+ pad=0.5, borderpad=0.4)
+ el = Ellipse((0, 0), width=0.1, height=0.25, angle=-60)
+ box.drawing_area.add_artist(el)
+ ax.add_artist(box)
asb = AnchoredSizeBar(ax.transData, 0.2, r"0.2 units", loc='lower right',
pad=0.3, borderpad=0.4, sep=4, fill_bar=True,
@@ -637,15 +658,15 @@ def test_grid_axes_position(direction):
assert loc[3].args[1] == loc[2].args[1]
-@pytest.mark.parametrize('rect, ngrids, error, message', (
+@pytest.mark.parametrize('rect, n_axes, error, message', (
((1, 1), None, TypeError, "Incorrect rect format"),
- (111, -1, ValueError, "ngrids must be positive"),
- (111, 7, ValueError, "ngrids must be positive"),
+ (111, -1, ValueError, "n_axes must be positive"),
+ (111, 7, ValueError, "n_axes must be positive"),
))
-def test_grid_errors(rect, ngrids, error, message):
+def test_grid_errors(rect, n_axes, error, message):
fig = plt.figure()
with pytest.raises(error, match=message):
- Grid(fig, rect, (2, 3), ngrids=ngrids)
+ Grid(fig, rect, (2, 3), n_axes=n_axes)
@pytest.mark.parametrize('anchor, error, message', (
@@ -660,7 +681,7 @@ def test_divider_errors(anchor, error, message):
anchor=anchor)
-@check_figures_equal(extensions=["png"])
+@check_figures_equal()
def test_mark_inset_unstales_viewlim(fig_test, fig_ref):
inset, full = fig_test.subplots(1, 2)
full.plot([0, 5], [0, 5])
@@ -678,7 +699,7 @@ def test_mark_inset_unstales_viewlim(fig_test, fig_ref):
def test_auto_adjustable():
fig = plt.figure()
- ax = fig.add_axes([0, 0, 1, 1])
+ ax = fig.add_axes((0, 0, 1, 1))
pad = 0.1
make_axes_area_auto_adjustable(ax, pad=pad)
fig.canvas.draw()
@@ -702,17 +723,6 @@ def test_rgb_axes():
ax.imshow_rgb(r, g, b, interpolation='none')
-# Update style when regenerating the test image
-@image_comparison(['insetposition.png'], remove_text=True,
- style=('classic', '_classic_test_patch'))
-def test_insetposition():
- fig, ax = plt.subplots(figsize=(2, 2))
- ax_ins = plt.axes([0, 0, 1, 1])
- with pytest.warns(mpl.MatplotlibDeprecationWarning):
- ip = InsetPosition(ax, [0.2, 0.25, 0.5, 0.4])
- ax_ins.set_axes_locator(ip)
-
-
# The original version of this test relied on mpl_toolkits's slightly different
# colorbar implementation; moving to matplotlib's own colorbar implementation
# caused the small image comparison error.
@@ -790,3 +800,9 @@ def test_anchored_locator_base_call():
def test_grid_with_axes_class_not_overriding_axis():
Grid(plt.figure(), 111, (2, 2), axes_class=mpl.axes.Axes)
RGBAxes(plt.figure(), 111, axes_class=mpl.axes.Axes)
+
+
+def test_grid_n_axes():
+ fig = plt.figure()
+ grid = Grid(fig, 111, (3, 3), n_axes=5)
+ assert len(fig.axes) == grid.n_axes == 5
diff --git a/lib/mpl_toolkits/axisartist/angle_helper.py b/lib/mpl_toolkits/axisartist/angle_helper.py
index 1786cd70bcdb..56b461e4a1d3 100644
--- a/lib/mpl_toolkits/axisartist/angle_helper.py
+++ b/lib/mpl_toolkits/axisartist/angle_helper.py
@@ -1,6 +1,7 @@
import numpy as np
import math
+from matplotlib.transforms import Bbox
from mpl_toolkits.axisartist.grid_finder import ExtremeFinderSimple
@@ -347,11 +348,12 @@ def __init__(self, nx, ny,
self.lon_minmax = lon_minmax
self.lat_minmax = lat_minmax
- def __call__(self, transform_xy, x1, y1, x2, y2):
+ def _find_transformed_bbox(self, trans, bbox):
# docstring inherited
- x, y = np.meshgrid(
- np.linspace(x1, x2, self.nx), np.linspace(y1, y2, self.ny))
- lon, lat = transform_xy(np.ravel(x), np.ravel(y))
+ grid = np.reshape(np.meshgrid(np.linspace(bbox.x0, bbox.x1, self.nx),
+ np.linspace(bbox.y0, bbox.y1, self.ny)),
+ (2, -1)).T
+ lon, lat = trans.transform(grid).T
# iron out jumps, but algorithm should be improved.
# This is just naive way of doing and my fail for some cases.
@@ -367,11 +369,10 @@ def __call__(self, transform_xy, x1, y1, x2, y2):
lat0 = np.nanmin(lat)
lat -= 360. * ((lat - lat0) > 180.)
- lon_min, lon_max = np.nanmin(lon), np.nanmax(lon)
- lat_min, lat_max = np.nanmin(lat), np.nanmax(lat)
-
- lon_min, lon_max, lat_min, lat_max = \
- self._add_pad(lon_min, lon_max, lat_min, lat_max)
+ tbbox = Bbox.null()
+ tbbox.update_from_data_xy(np.column_stack([lon, lat]))
+ tbbox = tbbox.expanded(1 + 2 / self.nx, 1 + 2 / self.ny)
+ lon_min, lat_min, lon_max, lat_max = tbbox.extents
# check cycle
if self.lon_cycle:
@@ -391,4 +392,4 @@ def __call__(self, transform_xy, x1, y1, x2, y2):
max0 = self.lat_minmax[1]
lat_max = min(max0, lat_max)
- return lon_min, lon_max, lat_min, lat_max
+ return Bbox.from_extents(lon_min, lat_min, lon_max, lat_max)
diff --git a/lib/mpl_toolkits/axisartist/axes_divider.py b/lib/mpl_toolkits/axisartist/axes_divider.py
index a01d4e27df93..d0392be782d9 100644
--- a/lib/mpl_toolkits/axisartist/axes_divider.py
+++ b/lib/mpl_toolkits/axisartist/axes_divider.py
@@ -1,2 +1,2 @@
from mpl_toolkits.axes_grid1.axes_divider import ( # noqa
- Divider, AxesLocator, SubplotDivider, AxesDivider, make_axes_locatable)
+ Divider, SubplotDivider, AxesDivider, make_axes_locatable)
diff --git a/lib/mpl_toolkits/axisartist/axes_grid.py b/lib/mpl_toolkits/axisartist/axes_grid.py
deleted file mode 100644
index ecb3e9d92c18..000000000000
--- a/lib/mpl_toolkits/axisartist/axes_grid.py
+++ /dev/null
@@ -1,23 +0,0 @@
-from matplotlib import _api
-
-import mpl_toolkits.axes_grid1.axes_grid as axes_grid_orig
-from .axislines import Axes
-
-
-_api.warn_deprecated(
- "3.8", name=__name__, obj_type="module", alternative="axes_grid1.axes_grid")
-
-
-@_api.deprecated("3.8", alternative=(
- "axes_grid1.axes_grid.Grid(..., axes_class=axislines.Axes"))
-class Grid(axes_grid_orig.Grid):
- _defaultAxesClass = Axes
-
-
-@_api.deprecated("3.8", alternative=(
- "axes_grid1.axes_grid.ImageGrid(..., axes_class=axislines.Axes"))
-class ImageGrid(axes_grid_orig.ImageGrid):
- _defaultAxesClass = Axes
-
-
-AxesGrid = ImageGrid
diff --git a/lib/mpl_toolkits/axisartist/axes_rgb.py b/lib/mpl_toolkits/axisartist/axes_rgb.py
deleted file mode 100644
index 2195747469a1..000000000000
--- a/lib/mpl_toolkits/axisartist/axes_rgb.py
+++ /dev/null
@@ -1,18 +0,0 @@
-from matplotlib import _api
-from mpl_toolkits.axes_grid1.axes_rgb import ( # noqa
- make_rgb_axes, RGBAxes as _RGBAxes)
-from .axislines import Axes
-
-
-_api.warn_deprecated(
- "3.8", name=__name__, obj_type="module", alternative="axes_grid1.axes_rgb")
-
-
-@_api.deprecated("3.8", alternative=(
- "axes_grid1.axes_rgb.RGBAxes(..., axes_class=axislines.Axes"))
-class RGBAxes(_RGBAxes):
- """
- Subclass of `~.axes_grid1.axes_rgb.RGBAxes` with
- ``_defaultAxesClass`` = `.axislines.Axes`.
- """
- _defaultAxesClass = Axes
diff --git a/lib/mpl_toolkits/axisartist/axis_artist.py b/lib/mpl_toolkits/axisartist/axis_artist.py
index 407ad07a3dc2..9ba6f6075844 100644
--- a/lib/mpl_toolkits/axisartist/axis_artist.py
+++ b/lib/mpl_toolkits/axisartist/axis_artist.py
@@ -7,7 +7,7 @@
There is one `AxisArtist` per Axis; it can be accessed through
the ``axis`` dictionary of the parent Axes (which should be a
-`mpl_toolkits.axislines.Axes`), e.g. ``ax.axis["bottom"]``.
+`~mpl_toolkits.axisartist.axislines.Axes`), e.g. ``ax.axis["bottom"]``.
Children of the AxisArtist are accessed as attributes: ``.line`` and ``.label``
for the axis line and label, ``.major_ticks``, ``.major_ticklabels``,
@@ -253,7 +253,7 @@ def draw(self, renderer):
def get_window_extent(self, renderer=None):
if renderer is None:
- renderer = self.figure._get_renderer()
+ renderer = self.get_figure(root=True)._get_renderer()
# save original and adjust some properties
tr = self.get_transform()
@@ -312,13 +312,13 @@ def get_pad(self):
def get_ref_artist(self):
# docstring inherited
- return self._axis.get_label()
+ return self._axis.label
def get_text(self):
# docstring inherited
t = super().get_text()
if t == "__from_axes__":
- return self._axis.get_label().get_text()
+ return self._axis.label.get_text()
return self._text
_default_alignments = dict(left=("bottom", "center"),
@@ -391,7 +391,7 @@ def draw(self, renderer):
def get_window_extent(self, renderer=None):
if renderer is None:
- renderer = self.figure._get_renderer()
+ renderer = self.get_figure(root=True)._get_renderer()
if not self.get_visible():
return
@@ -550,7 +550,7 @@ def set_locs_angles_labels(self, locs_angles_labels):
def get_window_extents(self, renderer=None):
if renderer is None:
- renderer = self.figure._get_renderer()
+ renderer = self.get_figure(root=True)._get_renderer()
if not self.get_visible():
self._axislabel_pad = self._external_pad
@@ -588,8 +588,9 @@ def get_texts_widths_heights_descents(self, renderer):
if not label.strip():
continue
clean_line, ismath = self._preprocess_math(label)
- whd = renderer.get_text_width_height_descent(
- clean_line, self._fontproperties, ismath=ismath)
+ whd = mtext._get_text_metrics_with_cache(
+ renderer, clean_line, self._fontproperties, ismath=ismath,
+ dpi=self.get_figure(root=True).dpi)
whd_list.append(whd)
return whd_list
@@ -691,7 +692,7 @@ def __init__(self, axes,
self.offset_transform = ScaledTranslation(
*offset,
Affine2D().scale(1 / 72) # points to inches.
- + self.axes.figure.dpi_scale_trans)
+ + self.axes.get_figure(root=False).dpi_scale_trans)
if axis_direction in ["left", "right"]:
self.axis = axes.yaxis
@@ -879,7 +880,7 @@ def _init_ticks(self, **kwargs):
self.major_ticklabels = TickLabels(
axis=self.axis,
axis_direction=self._axis_direction,
- figure=self.axes.figure,
+ figure=self.axes.get_figure(root=False),
transform=trans,
fontsize=size,
pad=kwargs.get(
@@ -888,7 +889,7 @@ def _init_ticks(self, **kwargs):
self.minor_ticklabels = TickLabels(
axis=self.axis,
axis_direction=self._axis_direction,
- figure=self.axes.figure,
+ figure=self.axes.get_figure(root=False),
transform=trans,
fontsize=size,
pad=kwargs.get(
@@ -922,7 +923,7 @@ def _update_ticks(self, renderer=None):
# majorticks even for minor ticks. not clear what is best.
if renderer is None:
- renderer = self.figure._get_renderer()
+ renderer = self.get_figure(root=True)._get_renderer()
dpi_cor = renderer.points_to_pixels(1.)
if self.major_ticks.get_visible() and self.major_ticks.get_tick_out():
@@ -997,7 +998,7 @@ def _init_label(self, **kwargs):
transform=tr,
axis_direction=self._axis_direction,
)
- self.label.set_figure(self.axes.figure)
+ self.label.set_figure(self.axes.get_figure(root=False))
labelpad = kwargs.get("labelpad", 5)
self.label.set_pad(labelpad)
diff --git a/lib/mpl_toolkits/axisartist/axisline_style.py b/lib/mpl_toolkits/axisartist/axisline_style.py
index 7f25b98082ef..ac89603e0844 100644
--- a/lib/mpl_toolkits/axisartist/axisline_style.py
+++ b/lib/mpl_toolkits/axisartist/axisline_style.py
@@ -177,8 +177,7 @@ def __init__(self, size=1, facecolor=None):
.. versionadded:: 3.7
"""
- if facecolor is None:
- facecolor = mpl.rcParams['axes.edgecolor']
+ facecolor = mpl._val_or_rc(facecolor, 'axes.edgecolor')
self.size = size
self._facecolor = facecolor
super().__init__(size=size)
diff --git a/lib/mpl_toolkits/axisartist/axislines.py b/lib/mpl_toolkits/axisartist/axislines.py
index 1d695c129ae2..c921ea597cb4 100644
--- a/lib/mpl_toolkits/axisartist/axislines.py
+++ b/lib/mpl_toolkits/axisartist/axislines.py
@@ -45,6 +45,8 @@
from matplotlib import _api
import matplotlib.axes as maxes
from matplotlib.path import Path
+from matplotlib.transforms import Bbox
+
from mpl_toolkits.axes_grid1 import mpl_axes
from .axisline_style import AxislineStyle # noqa
from .axis_artist import AxisArtist, GridlinesCollection
@@ -118,8 +120,7 @@ def _to_xy(self, values, const):
class _FixedAxisArtistHelperBase(_AxisArtistHelperBase):
"""Helper class for a fixed (in the axes coordinate) axis."""
- @_api.delete_parameter("3.9", "nth_coord")
- def __init__(self, loc, nth_coord=None):
+ def __init__(self, loc):
"""``nth_coord = 0``: x-axis; ``nth_coord = 1``: y-axis."""
super().__init__(_api.check_getitem(
{"bottom": 0, "top": 0, "left": 1, "right": 1}, loc=loc))
@@ -169,12 +170,7 @@ def get_line(self, axes):
class FixedAxisArtistHelperRectilinear(_FixedAxisArtistHelperBase):
- @_api.delete_parameter("3.9", "nth_coord")
- def __init__(self, axes, loc, nth_coord=None):
- """
- nth_coord = along which coordinate value varies
- in 2D, nth_coord = 0 -> x axis, nth_coord = 1 -> y axis
- """
+ def __init__(self, axes, loc):
super().__init__(loc)
self.axis = [axes.xaxis, axes.yaxis][self.nth_coord]
@@ -285,10 +281,10 @@ def update_lim(self, axes):
x1, x2 = axes.get_xlim()
y1, y2 = axes.get_ylim()
if self._old_limits != (x1, x2, y1, y2):
- self._update_grid(x1, y1, x2, y2)
+ self._update_grid(Bbox.from_extents(x1, y1, x2, y2))
self._old_limits = (x1, x2, y1, y2)
- def _update_grid(self, x1, y1, x2, y2):
+ def _update_grid(self, bbox):
"""Cache relevant computations when the axes limits have changed."""
def get_gridlines(self, which, axis):
@@ -309,10 +305,9 @@ def __init__(self, axes):
super().__init__()
self.axes = axes
- @_api.delete_parameter(
- "3.9", "nth_coord", addendum="'nth_coord' is now inferred from 'loc'.")
def new_fixed_axis(
- self, loc, nth_coord=None, axis_direction=None, offset=None, axes=None):
+ self, loc, *, axis_direction=None, offset=None, axes=None
+ ):
if axes is None:
_api.warn_external(
"'new_fixed_axis' explicitly requires the axes keyword.")
@@ -370,10 +365,6 @@ def get_gridlines(self, which="major", axis="both"):
class Axes(maxes.Axes):
- @_api.deprecated("3.8", alternative="ax.axis")
- def __call__(self, *args, **kwargs):
- return maxes.Axes.axis(self.axes, *args, **kwargs)
-
def __init__(self, *args, grid_helper=None, **kwargs):
self._axisline_on = True
self._grid_helper = grid_helper if grid_helper else GridHelperRectlinear(self)
diff --git a/lib/mpl_toolkits/axisartist/floating_axes.py b/lib/mpl_toolkits/axisartist/floating_axes.py
index 24c9ce61afa7..a533b2a9c427 100644
--- a/lib/mpl_toolkits/axisartist/floating_axes.py
+++ b/lib/mpl_toolkits/axisartist/floating_axes.py
@@ -13,9 +13,8 @@
from matplotlib import _api, cbook
import matplotlib.patches as mpatches
from matplotlib.path import Path
-
+from matplotlib.transforms import Bbox
from mpl_toolkits.axes_grid1.parasite_axes import host_axes_class_factory
-
from . import axislines, grid_helper_curvelinear
from .axis_artist import AxisArtist
from .grid_finder import ExtremeFinderSimple
@@ -71,25 +70,19 @@ def trf_xy(x, y):
if self.nth_coord == 0:
mask = (ymin <= yy0) & (yy0 <= ymax)
- (xx1, yy1), (dxx1, dyy1), (dxx2, dyy2) = \
- grid_helper_curvelinear._value_and_jacobian(
+ (xx1, yy1), angle_normal, angle_tangent = \
+ grid_helper_curvelinear._value_and_jac_angle(
trf_xy, self.value, yy0[mask], (xmin, xmax), (ymin, ymax))
labels = self._grid_info["lat_labels"]
elif self.nth_coord == 1:
mask = (xmin <= xx0) & (xx0 <= xmax)
- (xx1, yy1), (dxx2, dyy2), (dxx1, dyy1) = \
- grid_helper_curvelinear._value_and_jacobian(
+ (xx1, yy1), angle_tangent, angle_normal = \
+ grid_helper_curvelinear._value_and_jac_angle(
trf_xy, xx0[mask], self.value, (xmin, xmax), (ymin, ymax))
labels = self._grid_info["lon_labels"]
labels = [l for l, m in zip(labels, mask) if m]
-
- angle_normal = np.arctan2(dyy1, dxx1)
- angle_tangent = np.arctan2(dyy2, dxx2)
- mm = (dyy1 == 0) & (dxx1 == 0) # points with degenerate normal
- angle_normal[mm] = angle_tangent[mm] + np.pi / 2
-
tick_to_axes = self.get_tick_transform(axes) - axes.transAxes
in_01 = functools.partial(
mpl.transforms._interval_contains_close, (0, 1))
@@ -109,8 +102,7 @@ def get_line(self, axes):
right=("lon_lines0", 1),
bottom=("lat_lines0", 0),
top=("lat_lines0", 1))[self._side]
- xx, yy = self._grid_info[k][v]
- return Path(np.column_stack([xx, yy]))
+ return Path(self._grid_info[k][v])
class ExtremeFinderFixed(ExtremeFinderSimple):
@@ -125,11 +117,12 @@ def __init__(self, extremes):
extremes : (float, float, float, float)
The bounding box that this helper always returns.
"""
- self._extremes = extremes
+ x0, x1, y0, y1 = extremes
+ self._tbbox = Bbox.from_extents(x0, y0, x1, y1)
- def __call__(self, transform_xy, x1, y1, x2, y2):
+ def _find_transformed_bbox(self, trans, bbox):
# docstring inherited
- return self._extremes
+ return self._tbbox
class GridHelperCurveLinear(grid_helper_curvelinear.GridHelperCurveLinear):
@@ -147,17 +140,6 @@ def __init__(self, aux_trans, extremes,
tick_formatter1=tick_formatter1,
tick_formatter2=tick_formatter2)
- @_api.deprecated("3.8")
- def get_data_boundary(self, side):
- """
- Return v=0, nth=1.
- """
- lon1, lon2, lat1, lat2 = self.grid_finder.extreme_finder(*[None] * 5)
- return dict(left=(lon1, 0),
- right=(lon2, 0),
- bottom=(lat1, 1),
- top=(lat2, 1))[side]
-
def new_fixed_axis(
self, loc, nth_coord=None, axis_direction=None, offset=None, axes=None):
if axes is None:
@@ -188,25 +170,22 @@ def new_fixed_axis(
# axis.get_helper().set_extremes(*self._extremes[2:])
# return axis
- def _update_grid(self, x1, y1, x2, y2):
+ def _update_grid(self, bbox):
if self._grid_info is None:
self._grid_info = dict()
grid_info = self._grid_info
grid_finder = self.grid_finder
- extremes = grid_finder.extreme_finder(grid_finder.inv_transform_xy,
- x1, y1, x2, y2)
+ tbbox = grid_finder.extreme_finder._find_transformed_bbox(
+ grid_finder.get_transform().inverted(), bbox)
- lon_min, lon_max = sorted(extremes[:2])
- lat_min, lat_max = sorted(extremes[2:])
- grid_info["extremes"] = lon_min, lon_max, lat_min, lat_max # extremes
+ lon_min, lat_min, lon_max, lat_max = tbbox.extents
+ grid_info["extremes"] = tbbox
- lon_levs, lon_n, lon_factor = \
- grid_finder.grid_locator1(lon_min, lon_max)
+ lon_levs, lon_n, lon_factor = grid_finder.grid_locator1(lon_min, lon_max)
lon_levs = np.asarray(lon_levs)
- lat_levs, lat_n, lat_factor = \
- grid_finder.grid_locator2(lat_min, lat_max)
+ lat_levs, lat_n, lat_factor = grid_finder.grid_locator2(lat_min, lat_max)
lat_levs = np.asarray(lat_levs)
grid_info["lon_info"] = lon_levs, lon_n, lon_factor
@@ -223,14 +202,13 @@ def _update_grid(self, x1, y1, x2, y2):
lon_lines, lat_lines = grid_finder._get_raw_grid_lines(
lon_values[(lon_min < lon_values) & (lon_values < lon_max)],
lat_values[(lat_min < lat_values) & (lat_values < lat_max)],
- lon_min, lon_max, lat_min, lat_max)
+ tbbox)
grid_info["lon_lines"] = lon_lines
grid_info["lat_lines"] = lat_lines
lon_lines, lat_lines = grid_finder._get_raw_grid_lines(
- # lon_min, lon_max, lat_min, lat_max)
- extremes[:2], extremes[2:], *extremes)
+ tbbox.intervalx, tbbox.intervaly, tbbox)
grid_info["lon_lines0"] = lon_lines
grid_info["lat_lines0"] = lat_lines
@@ -238,9 +216,9 @@ def _update_grid(self, x1, y1, x2, y2):
def get_gridlines(self, which="major", axis="both"):
grid_lines = []
if axis in ["both", "x"]:
- grid_lines.extend(self._grid_info["lon_lines"])
+ grid_lines.extend(map(np.transpose, self._grid_info["lon_lines"]))
if axis in ["both", "y"]:
- grid_lines.extend(self._grid_info["lat_lines"])
+ grid_lines.extend(map(np.transpose, self._grid_info["lat_lines"]))
return grid_lines
@@ -266,7 +244,7 @@ def clear(self):
# The original patch is not in the draw tree; it is only used for
# clipping purposes.
orig_patch = super()._gen_axes_patch()
- orig_patch.set_figure(self.figure)
+ orig_patch.set_figure(self.get_figure(root=False))
orig_patch.set_transform(self.transAxes)
self.patch.set_clip_path(orig_patch)
self.gridlines.set_clip_path(orig_patch)
diff --git a/lib/mpl_toolkits/axisartist/grid_finder.py b/lib/mpl_toolkits/axisartist/grid_finder.py
index ff67aa6e8720..b984c18cab6c 100644
--- a/lib/mpl_toolkits/axisartist/grid_finder.py
+++ b/lib/mpl_toolkits/axisartist/grid_finder.py
@@ -36,14 +36,10 @@ def _find_line_box_crossings(xys, bbox):
for u0, inside in [(umin, us > umin), (umax, us < umax)]:
cross = []
idxs, = (inside[:-1] ^ inside[1:]).nonzero()
- for idx in idxs:
- v = vs[idx] + (u0 - us[idx]) * dvs[idx] / dus[idx]
- if not vmin <= v <= vmax:
- continue
- crossing = (u0, v)[sl]
- theta = np.degrees(np.arctan2(*dxys[idx][::-1]))
- cross.append((crossing, theta))
- crossings.append(cross)
+ vv = vs[idxs] + (u0 - us[idxs]) * dvs[idxs] / dus[idxs]
+ crossings.append([
+ ((u0, v)[sl], np.degrees(np.arctan2(*dxy[::-1]))) # ((x, y), theta)
+ for v, dxy in zip(vv, dxys[idxs]) if vmin <= v <= vmax])
return crossings
@@ -77,20 +73,29 @@ def __call__(self, transform_xy, x1, y1, x2, y2):
extremal coordinates; then adding some padding to take into account the
finite sampling.
- As each sampling step covers a relative range of *1/nx* or *1/ny*,
+ As each sampling step covers a relative range of ``1/nx`` or ``1/ny``,
the padding is computed by expanding the span covered by the extremal
coordinates by these fractions.
"""
- x, y = np.meshgrid(
- np.linspace(x1, x2, self.nx), np.linspace(y1, y2, self.ny))
- xt, yt = transform_xy(np.ravel(x), np.ravel(y))
- return self._add_pad(xt.min(), xt.max(), yt.min(), yt.max())
+ tbbox = self._find_transformed_bbox(
+ _User2DTransform(transform_xy, None), Bbox.from_extents(x1, y1, x2, y2))
+ return tbbox.x0, tbbox.x1, tbbox.y0, tbbox.y1
- def _add_pad(self, x_min, x_max, y_min, y_max):
- """Perform the padding mentioned in `__call__`."""
- dx = (x_max - x_min) / self.nx
- dy = (y_max - y_min) / self.ny
- return x_min - dx, x_max + dx, y_min - dy, y_max + dy
+ def _find_transformed_bbox(self, trans, bbox):
+ """
+ Compute an approximation of the bounding box obtained by applying
+ *trans* to *bbox*.
+
+ See ``__call__`` for details; this method performs similar
+ calculations, but using a different representation of the arguments and
+ return value.
+ """
+ grid = np.reshape(np.meshgrid(np.linspace(bbox.x0, bbox.x1, self.nx),
+ np.linspace(bbox.y0, bbox.y1, self.ny)),
+ (2, -1)).T
+ tbbox = Bbox.null()
+ tbbox.update_from_data_xy(trans.transform(grid))
+ return tbbox.expanded(1 + 2 / self.nx, 1 + 2 / self.ny)
class _User2DTransform(Transform):
@@ -164,48 +169,47 @@ def _format_ticks(self, idx, direction, factor, levels):
return (fmt.format_ticks(levels) if isinstance(fmt, mticker.Formatter)
else fmt(direction, factor, levels))
- def get_grid_info(self, x1, y1, x2, y2):
+ def get_grid_info(self, *args, **kwargs):
"""
- lon_values, lat_values : list of grid values. if integer is given,
- rough number of grids in each direction.
+ Compute positioning information for grid lines and ticks, given the
+ axes' data *bbox*.
"""
+ params = _api.select_matching_signature(
+ [lambda x1, y1, x2, y2: locals(), lambda bbox: locals()], *args, **kwargs)
+ if "x1" in params:
+ _api.warn_deprecated("3.11", message=(
+ "Passing extents as separate arguments to get_grid_info is deprecated "
+ "since %(since)s and support will be removed %(removal)s; pass a "
+ "single bbox instead."))
+ bbox = Bbox.from_extents(
+ params["x1"], params["y1"], params["x2"], params["y2"])
+ else:
+ bbox = params["bbox"]
- extremes = self.extreme_finder(self.inv_transform_xy, x1, y1, x2, y2)
-
- # min & max rage of lat (or lon) for each grid line will be drawn.
- # i.e., gridline of lon=0 will be drawn from lat_min to lat_max.
-
- lon_min, lon_max, lat_min, lat_max = extremes
- lon_levs, lon_n, lon_factor = self.grid_locator1(lon_min, lon_max)
- lon_levs = np.asarray(lon_levs)
- lat_levs, lat_n, lat_factor = self.grid_locator2(lat_min, lat_max)
- lat_levs = np.asarray(lat_levs)
+ tbbox = self.extreme_finder._find_transformed_bbox(
+ self.get_transform().inverted(), bbox)
- lon_values = lon_levs[:lon_n] / lon_factor
- lat_values = lat_levs[:lat_n] / lat_factor
+ lon_levs, lon_n, lon_factor = self.grid_locator1(*tbbox.intervalx)
+ lat_levs, lat_n, lat_factor = self.grid_locator2(*tbbox.intervaly)
- lon_lines, lat_lines = self._get_raw_grid_lines(lon_values,
- lat_values,
- lon_min, lon_max,
- lat_min, lat_max)
+ lon_values = np.asarray(lon_levs[:lon_n]) / lon_factor
+ lat_values = np.asarray(lat_levs[:lat_n]) / lat_factor
- bb = Bbox.from_extents(x1, y1, x2, y2).expanded(1 + 2e-10, 1 + 2e-10)
+ lon_lines, lat_lines = self._get_raw_grid_lines(lon_values, lat_values, tbbox)
- grid_info = {
- "extremes": extremes,
- # "lon", "lat", filled below.
- }
+ bbox_expanded = bbox.expanded(1 + 2e-10, 1 + 2e-10)
+ grid_info = {"extremes": tbbox} # "lon", "lat" keys filled below.
for idx, lon_or_lat, levs, factor, values, lines in [
(1, "lon", lon_levs, lon_factor, lon_values, lon_lines),
(2, "lat", lat_levs, lat_factor, lat_values, lat_lines),
]:
grid_info[lon_or_lat] = gi = {
- "lines": [[l] for l in lines],
+ "lines": lines,
"ticks": {"left": [], "right": [], "bottom": [], "top": []},
}
- for (lx, ly), v, level in zip(lines, values, levs):
- all_crossings = _find_line_box_crossings(np.column_stack([lx, ly]), bb)
+ for xys, v, level in zip(lines, values, levs):
+ all_crossings = _find_line_box_crossings(xys, bbox_expanded)
for side, crossings in zip(
["left", "right", "bottom", "top"], all_crossings):
for crossing in crossings:
@@ -218,18 +222,14 @@ def get_grid_info(self, x1, y1, x2, y2):
return grid_info
- def _get_raw_grid_lines(self,
- lon_values, lat_values,
- lon_min, lon_max, lat_min, lat_max):
-
- lons_i = np.linspace(lon_min, lon_max, 100) # for interpolation
- lats_i = np.linspace(lat_min, lat_max, 100)
-
- lon_lines = [self.transform_xy(np.full_like(lats_i, lon), lats_i)
+ def _get_raw_grid_lines(self, lon_values, lat_values, bbox):
+ trans = self.get_transform()
+ lons = np.linspace(bbox.x0, bbox.x1, 100) # for interpolation
+ lats = np.linspace(bbox.y0, bbox.y1, 100)
+ lon_lines = [trans.transform(np.column_stack([np.full_like(lats, lon), lats]))
for lon in lon_values]
- lat_lines = [self.transform_xy(lons_i, np.full_like(lons_i, lat))
+ lat_lines = [trans.transform(np.column_stack([lons, np.full_like(lons, lat)]))
for lat in lat_values]
-
return lon_lines, lat_lines
def set_transform(self, aux_trans):
@@ -246,9 +246,11 @@ def get_transform(self):
update_transform = set_transform # backcompat alias.
+ @_api.deprecated("3.11", alternative="grid_finder.get_transform()")
def transform_xy(self, x, y):
return self._aux_transform.transform(np.column_stack([x, y])).T
+ @_api.deprecated("3.11", alternative="grid_finder.get_transform().inverted()")
def inv_transform_xy(self, x, y):
return self._aux_transform.inverted().transform(
np.column_stack([x, y])).T
diff --git a/lib/mpl_toolkits/axisartist/grid_helper_curvelinear.py b/lib/mpl_toolkits/axisartist/grid_helper_curvelinear.py
index a7eb9d5cfe21..aa37a3680fa5 100644
--- a/lib/mpl_toolkits/axisartist/grid_helper_curvelinear.py
+++ b/lib/mpl_toolkits/axisartist/grid_helper_curvelinear.py
@@ -7,38 +7,83 @@
import numpy as np
import matplotlib as mpl
-from matplotlib import _api
from matplotlib.path import Path
-from matplotlib.transforms import Affine2D, IdentityTransform
+from matplotlib.transforms import Affine2D, Bbox, IdentityTransform
from .axislines import (
_FixedAxisArtistHelperBase, _FloatingAxisArtistHelperBase, GridHelperBase)
from .axis_artist import AxisArtist
from .grid_finder import GridFinder
-def _value_and_jacobian(func, xs, ys, xlims, ylims):
+def _value_and_jac_angle(func, xs, ys, xlim, ylim):
"""
- Compute *func* and its derivatives along x and y at positions *xs*, *ys*,
- while ensuring that finite difference calculations don't try to evaluate
- values outside of *xlims*, *ylims*.
+ Parameters
+ ----------
+ func : callable
+ A function that transforms the coordinates of a point (x, y) to a new coordinate
+ system (u, v), and which can also take x and y as arrays of shape *shape* and
+ returns (u, v) as a ``(2, shape)`` array.
+ xs, ys : array-likes
+ Points where *func* and its derivatives will be evaluated.
+ xlim, ylim : pairs of floats
+ (min, max) beyond which *func* should not be evaluated.
+
+ Returns
+ -------
+ val
+ Value of *func* at each point of ``(xs, ys)``.
+ thetas_dx
+ Angles (in radians) defined by the (u, v) components of the numerically
+ differentiated df/dx vector, at each point of ``(xs, ys)``. If needed, the
+ differentiation step size is increased until at least one component of df/dx
+ is nonzero, under the constraint of not going out of the *xlims*, *ylims*
+ bounds. If the gridline at a point is actually null (and the angle is thus not
+ well defined), the derivatives are evaluated after taking a small step along y;
+ this ensures e.g. that the tick at r=0 on a radial axis of a polar plot is
+ parallel with the ticks at r!=0.
+ thetas_dy
+ Like *thetas_dx*, but for df/dy.
"""
- eps = np.finfo(float).eps ** (1/2) # see e.g. scipy.optimize.approx_fprime
+
+ shape = np.broadcast_shapes(np.shape(xs), np.shape(ys))
val = func(xs, ys)
- # Take the finite difference step in the direction where the bound is the
- # furthest; the step size is min of epsilon and distance to that bound.
- xlo, xhi = sorted(xlims)
- dxlo = xs - xlo
- dxhi = xhi - xs
- xeps = (np.take([-1, 1], dxhi >= dxlo)
- * np.minimum(eps, np.maximum(dxlo, dxhi)))
- val_dx = func(xs + xeps, ys)
- ylo, yhi = sorted(ylims)
- dylo = ys - ylo
- dyhi = yhi - ys
- yeps = (np.take([-1, 1], dyhi >= dylo)
- * np.minimum(eps, np.maximum(dylo, dyhi)))
- val_dy = func(xs, ys + yeps)
- return (val, (val_dx - val) / xeps, (val_dy - val) / yeps)
+
+ # Take finite difference steps towards the furthest bound; the step size will be the
+ # min of epsilon and the distance to that bound.
+ eps0 = np.finfo(float).eps ** (1/2) # cf. scipy.optimize.approx_fprime
+
+ def calc_eps(vals, lim):
+ lo, hi = sorted(lim)
+ dlo = vals - lo
+ dhi = hi - vals
+ eps_max = np.maximum(dlo, dhi)
+ eps = np.where(dhi >= dlo, 1, -1) * np.minimum(eps0, eps_max)
+ return eps, eps_max
+
+ xeps, xeps_max = calc_eps(xs, xlim)
+ yeps, yeps_max = calc_eps(ys, ylim)
+
+ def calc_thetas(dfunc, ps, eps_p0, eps_max, eps_q):
+ thetas_dp = np.full(shape, np.nan)
+ missing = np.full(shape, True)
+ eps_p = eps_p0
+ for it, eps_q in enumerate([0, eps_q]):
+ while missing.any() and (abs(eps_p) < eps_max).any():
+ if it == 0 and (eps_p > 1).any():
+ break # Degenerate derivative, move a bit along the other coord.
+ eps_p = np.minimum(eps_p, eps_max)
+ df_x, df_y = (dfunc(eps_p, eps_q) - dfunc(0, eps_q)) / eps_p
+ good = missing & ((df_x != 0) | (df_y != 0))
+ thetas_dp[good] = np.arctan2(df_y, df_x)[good]
+ missing &= ~good
+ eps_p *= 2
+ return thetas_dp
+
+ thetas_dx = calc_thetas(lambda eps_p, eps_q: func(xs + eps_p, ys + eps_q),
+ xs, xeps, xeps_max, yeps)
+ thetas_dy = calc_thetas(lambda eps_p, eps_q: func(xs + eps_q, ys + eps_p),
+ ys, yeps, yeps_max, xeps)
+ return (val, thetas_dx, thetas_dy)
class FixedAxisArtistHelper(_FixedAxisArtistHelperBase):
@@ -115,10 +160,10 @@ def update_lim(self, axes):
x1, x2 = axes.get_xlim()
y1, y2 = axes.get_ylim()
grid_finder = self.grid_helper.grid_finder
- extremes = grid_finder.extreme_finder(grid_finder.inv_transform_xy,
- x1, y1, x2, y2)
+ tbbox = grid_finder.extreme_finder._find_transformed_bbox(
+ grid_finder.get_transform().inverted(), Bbox.from_extents(x1, y1, x2, y2))
- lon_min, lon_max, lat_min, lat_max = extremes
+ lon_min, lat_min, lon_max, lat_max = tbbox.extents
e_min, e_max = self._extremes # ranges of other coordinates
if self.nth_coord == 0:
lat_min = max(e_min, lat_min)
@@ -127,29 +172,29 @@ def update_lim(self, axes):
lon_min = max(e_min, lon_min)
lon_max = min(e_max, lon_max)
- lon_levs, lon_n, lon_factor = \
- grid_finder.grid_locator1(lon_min, lon_max)
- lat_levs, lat_n, lat_factor = \
- grid_finder.grid_locator2(lat_min, lat_max)
+ lon_levs, lon_n, lon_factor = grid_finder.grid_locator1(lon_min, lon_max)
+ lat_levs, lat_n, lat_factor = grid_finder.grid_locator2(lat_min, lat_max)
if self.nth_coord == 0:
- xx0 = np.full(self._line_num_points, self.value)
- yy0 = np.linspace(lat_min, lat_max, self._line_num_points)
- xx, yy = grid_finder.transform_xy(xx0, yy0)
+ xys = grid_finder.get_transform().transform(np.column_stack([
+ np.full(self._line_num_points, self.value),
+ np.linspace(lat_min, lat_max, self._line_num_points),
+ ]))
elif self.nth_coord == 1:
- xx0 = np.linspace(lon_min, lon_max, self._line_num_points)
- yy0 = np.full(self._line_num_points, self.value)
- xx, yy = grid_finder.transform_xy(xx0, yy0)
+ xys = grid_finder.get_transform().transform(np.column_stack([
+ np.linspace(lon_min, lon_max, self._line_num_points),
+ np.full(self._line_num_points, self.value),
+ ]))
self._grid_info = {
- "extremes": (lon_min, lon_max, lat_min, lat_max),
+ "extremes": Bbox.from_extents(lon_min, lat_min, lon_max, lat_max),
"lon_info": (lon_levs, lon_n, np.asarray(lon_factor)),
"lat_info": (lat_levs, lat_n, np.asarray(lat_factor)),
"lon_labels": grid_finder._format_ticks(
1, "bottom", lon_factor, lon_levs),
"lat_labels": grid_finder._format_ticks(
2, "bottom", lat_factor, lat_levs),
- "line_xy": (xx, yy),
+ "line_xy": xys,
}
def get_axislabel_transform(self, axes):
@@ -160,19 +205,18 @@ def trf_xy(x, y):
trf = self.grid_helper.grid_finder.get_transform() + axes.transData
return trf.transform([x, y]).T
- xmin, xmax, ymin, ymax = self._grid_info["extremes"]
+ xmin, ymin, xmax, ymax = self._grid_info["extremes"].extents
if self.nth_coord == 0:
xx0 = self.value
yy0 = (ymin + ymax) / 2
elif self.nth_coord == 1:
xx0 = (xmin + xmax) / 2
yy0 = self.value
- xy1, dxy1_dx, dxy1_dy = _value_and_jacobian(
+ xy1, angle_dx, angle_dy = _value_and_jac_angle(
trf_xy, xx0, yy0, (xmin, xmax), (ymin, ymax))
p = axes.transAxes.inverted().transform(xy1)
if 0 <= p[0] <= 1 and 0 <= p[1] <= 1:
- d = [dxy1_dy, dxy1_dx][self.nth_coord]
- return xy1, np.rad2deg(np.arctan2(*d[::-1]))
+ return xy1, np.rad2deg([angle_dy, angle_dx][self.nth_coord])
else:
return None, None
@@ -197,23 +241,17 @@ def trf_xy(x, y):
# find angles
if self.nth_coord == 0:
mask = (e0 <= yy0) & (yy0 <= e1)
- (xx1, yy1), (dxx1, dyy1), (dxx2, dyy2) = _value_and_jacobian(
+ (xx1, yy1), angle_normal, angle_tangent = _value_and_jac_angle(
trf_xy, self.value, yy0[mask], (-np.inf, np.inf), (e0, e1))
labels = self._grid_info["lat_labels"]
elif self.nth_coord == 1:
mask = (e0 <= xx0) & (xx0 <= e1)
- (xx1, yy1), (dxx2, dyy2), (dxx1, dyy1) = _value_and_jacobian(
+ (xx1, yy1), angle_tangent, angle_normal = _value_and_jac_angle(
trf_xy, xx0[mask], self.value, (-np.inf, np.inf), (e0, e1))
labels = self._grid_info["lon_labels"]
labels = [l for l, m in zip(labels, mask) if m]
-
- angle_normal = np.arctan2(dyy1, dxx1)
- angle_tangent = np.arctan2(dyy2, dxx2)
- mm = (dyy1 == 0) & (dxx1 == 0) # points with degenerate normal
- angle_normal[mm] = angle_tangent[mm] + np.pi / 2
-
tick_to_axes = self.get_tick_transform(axes) - axes.transAxes
in_01 = functools.partial(
mpl.transforms._interval_contains_close, (0, 1))
@@ -232,8 +270,7 @@ def get_line_transform(self, axes):
def get_line(self, axes):
self.update_lim(axes)
- x, y = self._grid_info["line_xy"]
- return Path(np.column_stack([x, y]))
+ return Path(self._grid_info["line_xy"])
class GridHelperCurveLinear(GridHelperBase):
@@ -278,9 +315,9 @@ def update_grid_finder(self, aux_trans=None, **kwargs):
self.grid_finder.update(**kwargs)
self._old_limits = None # Force revalidation.
- @_api.make_keyword_only("3.9", "nth_coord")
def new_fixed_axis(
- self, loc, nth_coord=None, axis_direction=None, offset=None, axes=None):
+ self, loc, *, axis_direction=None, offset=None, axes=None, nth_coord=None
+ ):
if axes is None:
axes = self.axes
if axis_direction is None:
@@ -303,26 +340,13 @@ def new_floating_axis(self, nth_coord, value, axes=None, axis_direction="bottom"
# axisline.minor_ticklabels.set_visible(False)
return axisline
- def _update_grid(self, x1, y1, x2, y2):
- self._grid_info = self.grid_finder.get_grid_info(x1, y1, x2, y2)
+ def _update_grid(self, bbox):
+ self._grid_info = self.grid_finder.get_grid_info(bbox)
def get_gridlines(self, which="major", axis="both"):
grid_lines = []
if axis in ["both", "x"]:
- for gl in self._grid_info["lon"]["lines"]:
- grid_lines.extend(gl)
+ grid_lines.extend([gl.T for gl in self._grid_info["lon"]["lines"]])
if axis in ["both", "y"]:
- for gl in self._grid_info["lat"]["lines"]:
- grid_lines.extend(gl)
+ grid_lines.extend([gl.T for gl in self._grid_info["lat"]["lines"]])
return grid_lines
-
- @_api.deprecated("3.9")
- def get_tick_iterator(self, nth_coord, axis_side, minor=False):
- angle_tangent = dict(left=90, right=90, bottom=0, top=0)[axis_side]
- lon_or_lat = ["lon", "lat"][nth_coord]
- if not minor: # major ticks
- for tick in self._grid_info[lon_or_lat]["ticks"][axis_side]:
- yield *tick["loc"], angle_tangent, tick["label"]
- else:
- for tick in self._grid_info[lon_or_lat]["ticks"][axis_side]:
- yield *tick["loc"], angle_tangent, ""
diff --git a/lib/mpl_toolkits/axisartist/meson.build b/lib/mpl_toolkits/axisartist/meson.build
index 8d9314e42576..6d95cf0dfdcd 100644
--- a/lib/mpl_toolkits/axisartist/meson.build
+++ b/lib/mpl_toolkits/axisartist/meson.build
@@ -2,8 +2,6 @@ python_sources = [
'__init__.py',
'angle_helper.py',
'axes_divider.py',
- 'axes_grid.py',
- 'axes_rgb.py',
'axis_artist.py',
'axislines.py',
'axisline_style.py',
diff --git a/lib/mpl_toolkits/axisartist/tests/baseline_images/test_axislines/axisline_style_tight.png b/lib/mpl_toolkits/axisartist/tests/baseline_images/test_axislines/axisline_style_tight.png
index 77314c1695a0..3b2b80f1f678 100644
Binary files a/lib/mpl_toolkits/axisartist/tests/baseline_images/test_axislines/axisline_style_tight.png and b/lib/mpl_toolkits/axisartist/tests/baseline_images/test_axislines/axisline_style_tight.png differ
diff --git a/lib/mpl_toolkits/axisartist/tests/test_axislines.py b/lib/mpl_toolkits/axisartist/tests/test_axislines.py
index b722316a5c0c..a1485d4f436b 100644
--- a/lib/mpl_toolkits/axisartist/tests/test_axislines.py
+++ b/lib/mpl_toolkits/axisartist/tests/test_axislines.py
@@ -83,8 +83,8 @@ def test_ParasiteAxesAuxTrans():
getattr(ax2, name)(xx, yy, data[:-1, :-1])
else:
getattr(ax2, name)(xx, yy, data)
- ax1.set_xlim((0, 5))
- ax1.set_ylim((0, 5))
+ ax1.set_xlim(0, 5)
+ ax1.set_ylim(0, 5)
ax2.contour(xx, yy, data, colors='k')
@@ -119,7 +119,7 @@ def test_axisline_style_size_color():
@image_comparison(['axisline_style_tight.png'], remove_text=True,
style='mpl20')
def test_axisline_style_tight():
- fig = plt.figure(figsize=(2, 2))
+ fig = plt.figure(figsize=(2, 2), layout='tight')
ax = fig.add_subplot(axes_class=AxesZero)
ax.axis["xzero"].set_axisline_style("-|>", size=5, facecolor='g')
ax.axis["xzero"].set_visible(True)
@@ -129,8 +129,6 @@ def test_axisline_style_tight():
for direction in ("left", "right", "bottom", "top"):
ax.axis[direction].set_visible(False)
- fig.tight_layout()
-
@image_comparison(['subplotzero_ylabel.png'], style='mpl20')
def test_subplotzero_ylabel():
diff --git a/lib/mpl_toolkits/axisartist/tests/test_floating_axes.py b/lib/mpl_toolkits/axisartist/tests/test_floating_axes.py
index 7644fea16965..feb667af013e 100644
--- a/lib/mpl_toolkits/axisartist/tests/test_floating_axes.py
+++ b/lib/mpl_toolkits/axisartist/tests/test_floating_axes.py
@@ -1,5 +1,7 @@
import numpy as np
+import pytest
+
import matplotlib.pyplot as plt
import matplotlib.projections as mprojections
import matplotlib.transforms as mtransforms
@@ -24,7 +26,7 @@ def test_curvelinear3():
fig = plt.figure(figsize=(5, 5))
tr = (mtransforms.Affine2D().scale(np.pi / 180, 1) +
- mprojections.PolarAxes.PolarTransform(apply_theta_transforms=False))
+ mprojections.PolarAxes.PolarTransform())
grid_helper = GridHelperCurveLinear(
tr,
extremes=(0, 360, 10, 3),
@@ -73,7 +75,7 @@ def test_curvelinear4():
fig = plt.figure(figsize=(5, 5))
tr = (mtransforms.Affine2D().scale(np.pi / 180, 1) +
- mprojections.PolarAxes.PolarTransform(apply_theta_transforms=False))
+ mprojections.PolarAxes.PolarTransform())
grid_helper = GridHelperCurveLinear(
tr,
extremes=(120, 30, 10, 0),
@@ -113,3 +115,29 @@ def test_axis_direction():
ax.axis['y'] = ax.new_floating_axis(nth_coord=1, value=0,
axis_direction='left')
assert ax.axis['y']._axis_direction == 'left'
+
+
+def test_transform_with_zero_derivatives():
+ # The transform is really a 45° rotation
+ # tr(x, y) = x-y, x+y; inv_tr(u, v) = (u+v)/2, (u-v)/2
+ # with an additional x->exp(-x**-2) on each coordinate.
+ # Therefore all ticks should be at +/-45°, even the one at zero where the
+ # transform derivatives are zero.
+
+ # at x=0, exp(-x**-2)=0; div-by-zero can be ignored.
+ @np.errstate(divide="ignore")
+ def tr(x, y):
+ return np.exp(-x**-2) - np.exp(-y**-2), np.exp(-x**-2) + np.exp(-y**-2)
+
+ def inv_tr(u, v):
+ return (-np.log((u+v)/2))**(1/2), (-np.log((v-u)/2))**(1/2)
+
+ fig = plt.figure()
+ ax = fig.add_subplot(
+ axes_class=FloatingAxes, grid_helper=GridHelperCurveLinear(
+ (tr, inv_tr), extremes=(0, 10, 0, 10)))
+ fig.canvas.draw()
+
+ for k in ax.axis:
+ for l, a in ax.axis[k].major_ticks.locs_angles:
+ assert a % 90 == pytest.approx(45, abs=1e-3)
diff --git a/lib/mpl_toolkits/axisartist/tests/test_grid_helper_curvelinear.py b/lib/mpl_toolkits/axisartist/tests/test_grid_helper_curvelinear.py
index 1b266044bdd0..7d6554782fe6 100644
--- a/lib/mpl_toolkits/axisartist/tests/test_grid_helper_curvelinear.py
+++ b/lib/mpl_toolkits/axisartist/tests/test_grid_helper_curvelinear.py
@@ -82,8 +82,7 @@ def test_polar_box():
# PolarAxes.PolarTransform takes radian. However, we want our coordinate
# system in degree
- tr = (Affine2D().scale(np.pi / 180., 1.) +
- PolarAxes.PolarTransform(apply_theta_transforms=False))
+ tr = Affine2D().scale(np.pi / 180., 1.) + PolarAxes.PolarTransform()
# polar projection, which involves cycle, and also has limits in
# its coordinates, needs a special method to find the extremes
@@ -145,8 +144,7 @@ def test_axis_direction():
# PolarAxes.PolarTransform takes radian. However, we want our coordinate
# system in degree
- tr = (Affine2D().scale(np.pi / 180., 1.) +
- PolarAxes.PolarTransform(apply_theta_transforms=False))
+ tr = Affine2D().scale(np.pi / 180., 1.) + PolarAxes.PolarTransform()
# polar projection, which involves cycle, and also has limits in
# its coordinates, needs a special method to find the extremes
diff --git a/lib/mpl_toolkits/mplot3d/art3d.py b/lib/mpl_toolkits/mplot3d/art3d.py
index 44585ccd05e7..e051e44fb23d 100644
--- a/lib/mpl_toolkits/mplot3d/art3d.py
+++ b/lib/mpl_toolkits/mplot3d/art3d.py
@@ -14,11 +14,10 @@
from contextlib import contextmanager
from matplotlib import (
- artist, cbook, colors as mcolors, lines, text as mtext,
- path as mpath)
+ _api, artist, cbook, colors as mcolors, lines, text as mtext,
+ path as mpath, rcParams)
from matplotlib.collections import (
Collection, LineCollection, PolyCollection, PatchCollection, PathCollection)
-from matplotlib.colors import Normalize
from matplotlib.patches import Patch
from . import proj3d
@@ -73,6 +72,31 @@ def get_dir_vector(zdir):
raise ValueError("'x', 'y', 'z', None or vector of length 3 expected")
+def _viewlim_mask(xs, ys, zs, axes):
+ """
+ Return the mask of the points outside the axes view limits.
+
+ Parameters
+ ----------
+ xs, ys, zs : array-like
+ The points to mask.
+ axes : Axes3D
+ The axes to use for the view limits.
+
+ Returns
+ -------
+ mask : np.array
+ The mask of the points as a bool array.
+ """
+ mask = np.logical_or.reduce((xs < axes.xy_viewLim.xmin,
+ xs > axes.xy_viewLim.xmax,
+ ys < axes.xy_viewLim.ymin,
+ ys > axes.xy_viewLim.ymax,
+ zs < axes.zz_viewLim.xmin,
+ zs > axes.zz_viewLim.xmax))
+ return mask
+
+
class Text3D(mtext.Text):
"""
Text object with 3D position and direction.
@@ -86,6 +110,10 @@ class Text3D(mtext.Text):
zdir : {'x', 'y', 'z', None, 3-tuple}
The direction of the text. See `.get_dir_vector` for a description of
the values.
+ axlim_clip : bool, default: False
+ Whether to hide text outside the axes view limits.
+
+ .. versionadded:: 3.10
Other Parameters
----------------
@@ -93,9 +121,10 @@ class Text3D(mtext.Text):
All other parameters are passed on to `~matplotlib.text.Text`.
"""
- def __init__(self, x=0, y=0, z=0, text='', zdir='z', **kwargs):
+ def __init__(self, x=0, y=0, z=0, text='', zdir='z', axlim_clip=False,
+ **kwargs):
mtext.Text.__init__(self, x, y, text, **kwargs)
- self.set_3d_properties(z, zdir)
+ self.set_3d_properties(z, zdir, axlim_clip)
def get_position_3d(self):
"""Return the (x, y, z) position of the text."""
@@ -129,7 +158,7 @@ def set_z(self, z):
self._z = z
self.stale = True
- def set_3d_properties(self, z=0, zdir='z'):
+ def set_3d_properties(self, z=0, zdir='z', axlim_clip=False):
"""
Set the *z* position and direction of the text.
@@ -140,16 +169,26 @@ def set_3d_properties(self, z=0, zdir='z'):
zdir : {'x', 'y', 'z', 3-tuple}
The direction of the text. Default: 'z'.
See `.get_dir_vector` for a description of the values.
+ axlim_clip : bool, default: False
+ Whether to hide text outside the axes view limits.
+
+ .. versionadded:: 3.10
"""
self._z = z
self._dir_vec = get_dir_vector(zdir)
+ self._axlim_clip = axlim_clip
self.stale = True
@artist.allow_rasterization
def draw(self, renderer):
- position3d = np.array((self._x, self._y, self._z))
- proj = proj3d._proj_trans_points(
- [position3d, position3d + self._dir_vec], self.axes.M)
+ if self._axlim_clip:
+ mask = _viewlim_mask(self._x, self._y, self._z, self.axes)
+ pos3d = np.ma.array([self._x, self._y, self._z],
+ mask=mask, dtype=float).filled(np.nan)
+ else:
+ pos3d = np.array([self._x, self._y, self._z], dtype=float)
+
+ proj = proj3d._proj_trans_points([pos3d, pos3d + self._dir_vec], self.axes.M)
dx = proj[0][1] - proj[0][0]
dy = proj[1][1] - proj[1][0]
angle = math.degrees(math.atan2(dy, dx))
@@ -164,7 +203,7 @@ def get_tightbbox(self, renderer=None):
return None
-def text_2d_to_3d(obj, z=0, zdir='z'):
+def text_2d_to_3d(obj, z=0, zdir='z', axlim_clip=False):
"""
Convert a `.Text` to a `.Text3D` object.
@@ -175,9 +214,13 @@ def text_2d_to_3d(obj, z=0, zdir='z'):
zdir : {'x', 'y', 'z', 3-tuple}
The direction of the text. Default: 'z'.
See `.get_dir_vector` for a description of the values.
+ axlim_clip : bool, default: False
+ Whether to hide text outside the axes view limits.
+
+ .. versionadded:: 3.10
"""
obj.__class__ = Text3D
- obj.set_3d_properties(z, zdir)
+ obj.set_3d_properties(z, zdir, axlim_clip)
class Line3D(lines.Line2D):
@@ -191,7 +234,7 @@ class Line3D(lines.Line2D):
`~.Line2D.set_data`, `~.Line2D.set_xdata`, and `~.Line2D.set_ydata`.
"""
- def __init__(self, xs, ys, zs, *args, **kwargs):
+ def __init__(self, xs, ys, zs, *args, axlim_clip=False, **kwargs):
"""
Parameters
@@ -207,8 +250,9 @@ def __init__(self, xs, ys, zs, *args, **kwargs):
"""
super().__init__([], [], *args, **kwargs)
self.set_data_3d(xs, ys, zs)
+ self._axlim_clip = axlim_clip
- def set_3d_properties(self, zs=0, zdir='z'):
+ def set_3d_properties(self, zs=0, zdir='z', axlim_clip=False):
"""
Set the *z* position and direction of the line.
@@ -220,12 +264,17 @@ def set_3d_properties(self, zs=0, zdir='z'):
zdir : {'x', 'y', 'z'}
Plane to plot line orthogonal to. Default: 'z'.
See `.get_dir_vector` for a description of the values.
+ axlim_clip : bool, default: False
+ Whether to hide lines with an endpoint outside the axes view limits.
+
+ .. versionadded:: 3.10
"""
xs = self.get_xdata()
ys = self.get_ydata()
zs = cbook._to_unmasked_float_array(zs).ravel()
zs = np.broadcast_to(zs, len(xs))
self._verts3d = juggle_axes(xs, ys, zs, zdir)
+ self._axlim_clip = axlim_clip
self.stale = True
def set_data_3d(self, *args):
@@ -266,14 +315,24 @@ def get_data_3d(self):
@artist.allow_rasterization
def draw(self, renderer):
- xs3d, ys3d, zs3d = self._verts3d
- xs, ys, zs = proj3d.proj_transform(xs3d, ys3d, zs3d, self.axes.M)
+ if self._axlim_clip:
+ mask = np.broadcast_to(
+ _viewlim_mask(*self._verts3d, self.axes),
+ (len(self._verts3d), *self._verts3d[0].shape)
+ )
+ xs3d, ys3d, zs3d = np.ma.array(self._verts3d,
+ dtype=float, mask=mask).filled(np.nan)
+ else:
+ xs3d, ys3d, zs3d = self._verts3d
+ xs, ys, zs, tis = proj3d._proj_transform_clip(xs3d, ys3d, zs3d,
+ self.axes.M,
+ self.axes._focal_length)
self.set_data(xs, ys)
super().draw(renderer)
self.stale = False
-def line_2d_to_3d(line, zs=0, zdir='z'):
+def line_2d_to_3d(line, zs=0, zdir='z', axlim_clip=False):
"""
Convert a `.Line2D` to a `.Line3D` object.
@@ -284,10 +343,14 @@ def line_2d_to_3d(line, zs=0, zdir='z'):
zdir : {'x', 'y', 'z'}
Plane to plot line orthogonal to. Default: 'z'.
See `.get_dir_vector` for a description of the values.
+ axlim_clip : bool, default: False
+ Whether to hide lines with an endpoint outside the axes view limits.
+
+ .. versionadded:: 3.10
"""
line.__class__ = Line3D
- line.set_3d_properties(zs, zdir)
+ line.set_3d_properties(zs, zdir, axlim_clip)
def _path_to_3d_segment(path, zs=0, zdir='z'):
@@ -349,15 +412,19 @@ class Collection3D(Collection):
def do_3d_projection(self):
"""Project the points according to renderer matrix."""
- xyzs_list = [proj3d.proj_transform(*vs.T, self.axes.M)
- for vs, _ in self._3dverts_codes]
- self._paths = [mpath.Path(np.column_stack([xs, ys]), cs)
+ vs_list = [vs for vs, _ in self._3dverts_codes]
+ if self._axlim_clip:
+ vs_list = [np.ma.array(vs, mask=np.broadcast_to(
+ _viewlim_mask(*vs.T, self.axes), vs.shape))
+ for vs in vs_list]
+ xyzs_list = [proj3d.proj_transform(*vs.T, self.axes.M) for vs in vs_list]
+ self._paths = [mpath.Path(np.ma.column_stack([xs, ys]), cs)
for (xs, ys, _), (_, cs) in zip(xyzs_list, self._3dverts_codes)]
zs = np.concatenate([zs for _, _, zs in xyzs_list])
return zs.min() if len(zs) else 1e9
-def collection_2d_to_3d(col, zs=0, zdir='z'):
+def collection_2d_to_3d(col, zs=0, zdir='z', axlim_clip=False):
"""Convert a `.Collection` to a `.Collection3D` object."""
zs = np.broadcast_to(zs, len(col.get_paths()))
col._3dverts_codes = [
@@ -367,12 +434,40 @@ def collection_2d_to_3d(col, zs=0, zdir='z'):
p.codes)
for p, z in zip(col.get_paths(), zs)]
col.__class__ = cbook._make_class_factory(Collection3D, "{}3D")(type(col))
+ col._axlim_clip = axlim_clip
class Line3DCollection(LineCollection):
"""
A collection of 3D lines.
"""
+ def __init__(self, lines, axlim_clip=False, **kwargs):
+ super().__init__(lines, **kwargs)
+ self._axlim_clip = axlim_clip
+ """
+ Parameters
+ ----------
+ lines : list of (N, 3) array-like
+ A sequence ``[line0, line1, ...]`` where each line is a (N, 3)-shape
+ array-like containing points:: line0 = [(x0, y0, z0), (x1, y1, z1), ...]
+ Each line can contain a different number of points.
+ linewidths : float or list of float, default: :rc:`lines.linewidth`
+ The width of each line in points.
+ colors : :mpltype:`color` or list of color, default: :rc:`lines.color`
+ A sequence of RGBA tuples (e.g., arbitrary color strings, etc, not
+ allowed).
+ antialiaseds : bool or list of bool, default: :rc:`lines.antialiased`
+ Whether to use antialiasing for each line.
+ facecolors : :mpltype:`color` or list of :mpltype:`color`, default: 'none'
+ When setting *facecolors*, each line is interpreted as a boundary
+ for an area, implicitly closing the path from the last point to the
+ first point. The enclosed area is filled with *facecolor*.
+ In order to manually specify what should count as the "interior" of
+ each line, please use `.PathCollection` instead, where the
+ "interior" can be specified by appropriate usage of
+ `~.path.Path.CLOSEPOLY`.
+ **kwargs : Forwarded to `.Collection`.
+ """
def set_sort_zpos(self, val):
"""Set the position to use for z-sorting."""
@@ -390,23 +485,41 @@ def do_3d_projection(self):
"""
Project the points according to renderer matrix.
"""
- xyslist = [proj3d._proj_trans_points(points, self.axes.M)
- for points in self._segments3d]
- segments_2d = [np.column_stack([xs, ys]) for xs, ys, zs in xyslist]
+ segments = np.asanyarray(self._segments3d)
+
+ mask = False
+ if np.ma.isMA(segments):
+ mask = segments.mask
+
+ if self._axlim_clip:
+ viewlim_mask = _viewlim_mask(segments[..., 0],
+ segments[..., 1],
+ segments[..., 2],
+ self.axes)
+ if np.any(viewlim_mask):
+ # broadcast mask to 3D
+ viewlim_mask = np.broadcast_to(viewlim_mask[..., np.newaxis],
+ (*viewlim_mask.shape, 3))
+ mask = mask | viewlim_mask
+ xyzs = np.ma.array(proj3d._proj_transform_vectors(segments, self.axes.M),
+ mask=mask)
+ segments_2d = xyzs[..., 0:2]
LineCollection.set_segments(self, segments_2d)
# FIXME
- minz = 1e9
- for xs, ys, zs in xyslist:
- minz = min(minz, min(zs))
+ if len(xyzs) > 0:
+ minz = min(xyzs[..., 2].min(), 1e9)
+ else:
+ minz = np.nan
return minz
-def line_collection_2d_to_3d(col, zs=0, zdir='z'):
+def line_collection_2d_to_3d(col, zs=0, zdir='z', axlim_clip=False):
"""Convert a `.LineCollection` to a `.Line3DCollection` object."""
segments3d = _paths_to_3d_segments(col.get_paths(), zs, zdir)
col.__class__ = Line3DCollection
col.set_segments(segments3d)
+ col._axlim_clip = axlim_clip
class Patch3D(Patch):
@@ -414,7 +527,7 @@ class Patch3D(Patch):
3D patch object.
"""
- def __init__(self, *args, zs=(), zdir='z', **kwargs):
+ def __init__(self, *args, zs=(), zdir='z', axlim_clip=False, **kwargs):
"""
Parameters
----------
@@ -425,11 +538,15 @@ def __init__(self, *args, zs=(), zdir='z', **kwargs):
zdir : {'x', 'y', 'z'}
Plane to plot patch orthogonal to. Default: 'z'.
See `.get_dir_vector` for a description of the values.
+ axlim_clip : bool, default: False
+ Whether to hide patches with a vertex outside the axes view limits.
+
+ .. versionadded:: 3.10
"""
super().__init__(*args, **kwargs)
- self.set_3d_properties(zs, zdir)
+ self.set_3d_properties(zs, zdir, axlim_clip)
- def set_3d_properties(self, verts, zs=0, zdir='z'):
+ def set_3d_properties(self, verts, zs=0, zdir='z', axlim_clip=False):
"""
Set the *z* position and direction of the patch.
@@ -442,10 +559,15 @@ def set_3d_properties(self, verts, zs=0, zdir='z'):
zdir : {'x', 'y', 'z'}
Plane to plot patch orthogonal to. Default: 'z'.
See `.get_dir_vector` for a description of the values.
+ axlim_clip : bool, default: False
+ Whether to hide patches with a vertex outside the axes view limits.
+
+ .. versionadded:: 3.10
"""
zs = np.broadcast_to(zs, len(verts))
self._segment3d = [juggle_axes(x, y, z, zdir)
for ((x, y), z) in zip(verts, zs)]
+ self._axlim_clip = axlim_clip
def get_path(self):
# docstring inherited
@@ -457,10 +579,16 @@ def get_path(self):
def do_3d_projection(self):
s = self._segment3d
- xs, ys, zs = zip(*s)
- vxs, vys, vzs, vis = proj3d.proj_transform_clip(xs, ys, zs,
- self.axes.M)
- self._path2d = mpath.Path(np.column_stack([vxs, vys]))
+ if self._axlim_clip:
+ mask = _viewlim_mask(*zip(*s), self.axes)
+ xs, ys, zs = np.ma.array(zip(*s),
+ dtype=float, mask=mask).filled(np.nan)
+ else:
+ xs, ys, zs = zip(*s)
+ vxs, vys, vzs, vis = proj3d._proj_transform_clip(xs, ys, zs,
+ self.axes.M,
+ self.axes._focal_length)
+ self._path2d = mpath.Path(np.ma.column_stack([vxs, vys]))
return min(vzs)
@@ -469,7 +597,7 @@ class PathPatch3D(Patch3D):
3D PathPatch object.
"""
- def __init__(self, path, *, zs=(), zdir='z', **kwargs):
+ def __init__(self, path, *, zs=(), zdir='z', axlim_clip=False, **kwargs):
"""
Parameters
----------
@@ -480,12 +608,16 @@ def __init__(self, path, *, zs=(), zdir='z', **kwargs):
zdir : {'x', 'y', 'z', 3-tuple}
Plane to plot path patch orthogonal to. Default: 'z'.
See `.get_dir_vector` for a description of the values.
+ axlim_clip : bool, default: False
+ Whether to hide path patches with a point outside the axes view limits.
+
+ .. versionadded:: 3.10
"""
# Not super().__init__!
Patch.__init__(self, **kwargs)
- self.set_3d_properties(path, zs, zdir)
+ self.set_3d_properties(path, zs, zdir, axlim_clip)
- def set_3d_properties(self, path, zs=0, zdir='z'):
+ def set_3d_properties(self, path, zs=0, zdir='z', axlim_clip=False):
"""
Set the *z* position and direction of the path patch.
@@ -498,16 +630,27 @@ def set_3d_properties(self, path, zs=0, zdir='z'):
zdir : {'x', 'y', 'z', 3-tuple}
Plane to plot path patch orthogonal to. Default: 'z'.
See `.get_dir_vector` for a description of the values.
+ axlim_clip : bool, default: False
+ Whether to hide path patches with a point outside the axes view limits.
+
+ .. versionadded:: 3.10
"""
- Patch3D.set_3d_properties(self, path.vertices, zs=zs, zdir=zdir)
+ Patch3D.set_3d_properties(self, path.vertices, zs=zs, zdir=zdir,
+ axlim_clip=axlim_clip)
self._code3d = path.codes
def do_3d_projection(self):
s = self._segment3d
- xs, ys, zs = zip(*s)
- vxs, vys, vzs, vis = proj3d.proj_transform_clip(xs, ys, zs,
- self.axes.M)
- self._path2d = mpath.Path(np.column_stack([vxs, vys]), self._code3d)
+ if self._axlim_clip:
+ mask = _viewlim_mask(*zip(*s), self.axes)
+ xs, ys, zs = np.ma.array(zip(*s),
+ dtype=float, mask=mask).filled(np.nan)
+ else:
+ xs, ys, zs = zip(*s)
+ vxs, vys, vzs, vis = proj3d._proj_transform_clip(xs, ys, zs,
+ self.axes.M,
+ self.axes._focal_length)
+ self._path2d = mpath.Path(np.ma.column_stack([vxs, vys]), self._code3d)
return min(vzs)
@@ -519,11 +662,11 @@ def _get_patch_verts(patch):
return polygons[0] if len(polygons) else np.array([])
-def patch_2d_to_3d(patch, z=0, zdir='z'):
+def patch_2d_to_3d(patch, z=0, zdir='z', axlim_clip=False):
"""Convert a `.Patch` to a `.Patch3D` object."""
verts = _get_patch_verts(patch)
patch.__class__ = Patch3D
- patch.set_3d_properties(verts, z, zdir)
+ patch.set_3d_properties(verts, z, zdir, axlim_clip)
def pathpatch_2d_to_3d(pathpatch, z=0, zdir='z'):
@@ -541,7 +684,16 @@ class Patch3DCollection(PatchCollection):
A collection of 3D patches.
"""
- def __init__(self, *args, zs=0, zdir='z', depthshade=True, **kwargs):
+ def __init__(
+ self,
+ *args,
+ zs=0,
+ zdir="z",
+ depthshade=None,
+ depthshade_minalpha=None,
+ axlim_clip=False,
+ **kwargs
+ ):
"""
Create a collection of flat 3D patches with its normal vector
pointed in *zdir* direction, and located at *zs* on the *zdir*
@@ -552,18 +704,31 @@ def __init__(self, *args, zs=0, zdir='z', depthshade=True, **kwargs):
:class:`~matplotlib.collections.PatchCollection`. In addition,
keywords *zs=0* and *zdir='z'* are available.
- Also, the keyword argument *depthshade* is available to indicate
- whether to shade the patches in order to give the appearance of depth
- (default is *True*). This is typically desired in scatter plots.
+ The keyword argument *depthshade* is available to
+ indicate whether or not to shade the patches in order to
+ give the appearance of depth (default is *True*).
+ This is typically desired in scatter plots.
+
+ *depthshade_minalpha* sets the minimum alpha value applied by
+ depth-shading.
"""
+ if depthshade is None:
+ depthshade = rcParams['axes3d.depthshade']
+ if depthshade_minalpha is None:
+ depthshade_minalpha = rcParams['axes3d.depthshade_minalpha']
self._depthshade = depthshade
+ self._depthshade_minalpha = depthshade_minalpha
super().__init__(*args, **kwargs)
- self.set_3d_properties(zs, zdir)
+ self.set_3d_properties(zs, zdir, axlim_clip)
def get_depthshade(self):
return self._depthshade
- def set_depthshade(self, depthshade):
+ def set_depthshade(
+ self,
+ depthshade,
+ depthshade_minalpha=None,
+ ):
"""
Set whether depth shading is performed on collection members.
@@ -572,8 +737,15 @@ def set_depthshade(self, depthshade):
depthshade : bool
Whether to shade the patches in order to give the appearance of
depth.
+ depthshade_minalpha : float, default: :rc:`axes3d.depthshade_minalpha`
+ Sets the minimum alpha value used by depth-shading.
+
+ .. versionadded:: 3.11
"""
+ if depthshade_minalpha is None:
+ depthshade_minalpha = rcParams['axes3d.depthshade_minalpha']
self._depthshade = depthshade
+ self._depthshade_minalpha = depthshade_minalpha
self.stale = True
def set_sort_zpos(self, val):
@@ -581,7 +753,7 @@ def set_sort_zpos(self, val):
self._sort_zpos = val
self.stale = True
- def set_3d_properties(self, zs, zdir):
+ def set_3d_properties(self, zs, zdir, axlim_clip=False):
"""
Set the *z* positions and direction of the patches.
@@ -594,6 +766,10 @@ def set_3d_properties(self, zs, zdir):
Plane to plot patches orthogonal to.
All patches must have the same direction.
See `.get_dir_vector` for a description of the values.
+ axlim_clip : bool, default: False
+ Whether to hide patches with a vertex outside the axes view limits.
+
+ .. versionadded:: 3.10
"""
# Force the collection to initialize the face and edgecolors
# just in case it is a scalarmappable with a colormap.
@@ -607,14 +783,23 @@ def set_3d_properties(self, zs, zdir):
self._offsets3d = juggle_axes(xs, ys, np.atleast_1d(zs), zdir)
self._z_markers_idx = slice(-1)
self._vzs = None
+ self._axlim_clip = axlim_clip
self.stale = True
def do_3d_projection(self):
- xs, ys, zs = self._offsets3d
- vxs, vys, vzs, vis = proj3d.proj_transform_clip(xs, ys, zs,
- self.axes.M)
+ if self._axlim_clip:
+ mask = _viewlim_mask(*self._offsets3d, self.axes)
+ xs, ys, zs = np.ma.array(self._offsets3d, mask=mask)
+ else:
+ xs, ys, zs = self._offsets3d
+ vxs, vys, vzs, vis = proj3d._proj_transform_clip(xs, ys, zs,
+ self.axes.M,
+ self.axes._focal_length)
self._vzs = vzs
- super().set_offsets(np.column_stack([vxs, vys]))
+ if np.ma.isMA(vxs):
+ super().set_offsets(np.ma.column_stack([vxs, vys]))
+ else:
+ super().set_offsets(np.column_stack([vxs, vys]))
if vzs.size > 0:
return min(vzs)
@@ -623,7 +808,11 @@ def do_3d_projection(self):
def _maybe_depth_shade_and_sort_colors(self, color_array):
color_array = (
- _zalpha(color_array, self._vzs)
+ _zalpha(
+ color_array,
+ self._vzs,
+ min_alpha=self._depthshade_minalpha,
+ )
if self._vzs is not None and self._depthshade
else color_array
)
@@ -643,12 +832,44 @@ def get_edgecolor(self):
return self._maybe_depth_shade_and_sort_colors(super().get_edgecolor())
+def _get_data_scale(X, Y, Z):
+ """
+ Estimate the scale of the 3D data for use in depth shading
+
+ Parameters
+ ----------
+ X, Y, Z : masked arrays
+ The data to estimate the scale of.
+ """
+ # Account for empty datasets. Assume that X Y and Z have the same number
+ # of elements.
+ if not np.ma.count(X):
+ return 0
+
+ # Estimate the scale using the RSS of the ranges of the dimensions
+ # Note that we don't use np.ma.ptp() because we otherwise get a build
+ # warning about handing empty arrays.
+ ptp_x = X.max() - X.min()
+ ptp_y = Y.max() - Y.min()
+ ptp_z = Z.max() - Z.min()
+ return np.sqrt(ptp_x ** 2 + ptp_y ** 2 + ptp_z ** 2)
+
+
class Path3DCollection(PathCollection):
"""
A collection of 3D paths.
"""
- def __init__(self, *args, zs=0, zdir='z', depthshade=True, **kwargs):
+ def __init__(
+ self,
+ *args,
+ zs=0,
+ zdir="z",
+ depthshade=None,
+ depthshade_minalpha=None,
+ axlim_clip=False,
+ **kwargs
+ ):
"""
Create a collection of flat 3D paths with its normal vector
pointed in *zdir* direction, and located at *zs* on the *zdir*
@@ -659,14 +880,23 @@ def __init__(self, *args, zs=0, zdir='z', depthshade=True, **kwargs):
:class:`~matplotlib.collections.PathCollection`. In addition,
keywords *zs=0* and *zdir='z'* are available.
- Also, the keyword argument *depthshade* is available to indicate
- whether to shade the patches in order to give the appearance of depth
- (default is *True*). This is typically desired in scatter plots.
+ Also, the keyword argument *depthshade* is available to
+ indicate whether or not to shade the patches in order to
+ give the appearance of depth (default is *True*).
+ This is typically desired in scatter plots.
+
+ *depthshade_minalpha* sets the minimum alpha value applied by
+ depth-shading.
"""
+ if depthshade is None:
+ depthshade = rcParams['axes3d.depthshade']
+ if depthshade_minalpha is None:
+ depthshade_minalpha = rcParams['axes3d.depthshade_minalpha']
self._depthshade = depthshade
+ self._depthshade_minalpha = depthshade_minalpha
self._in_draw = False
super().__init__(*args, **kwargs)
- self.set_3d_properties(zs, zdir)
+ self.set_3d_properties(zs, zdir, axlim_clip)
self._offset_zordered = None
def draw(self, renderer):
@@ -679,7 +909,7 @@ def set_sort_zpos(self, val):
self._sort_zpos = val
self.stale = True
- def set_3d_properties(self, zs, zdir):
+ def set_3d_properties(self, zs, zdir, axlim_clip=False):
"""
Set the *z* positions and direction of the paths.
@@ -692,6 +922,10 @@ def set_3d_properties(self, zs, zdir):
Plane to plot paths orthogonal to.
All paths must have the same direction.
See `.get_dir_vector` for a description of the values.
+ axlim_clip : bool, default: False
+ Whether to hide paths with a vertex outside the axes view limits.
+
+ .. versionadded:: 3.10
"""
# Force the collection to initialize the face and edgecolors
# just in case it is a scalarmappable with a colormap.
@@ -702,6 +936,7 @@ def set_3d_properties(self, zs, zdir):
else:
xs = []
ys = []
+ self._zdir = zdir
self._offsets3d = juggle_axes(xs, ys, np.atleast_1d(zs), zdir)
# In the base draw methods we access the attributes directly which
# means we cannot resolve the shuffling in the getter methods like
@@ -722,6 +957,8 @@ def set_3d_properties(self, zs, zdir):
# points and point properties according to the index array
self._z_markers_idx = slice(-1)
self._vzs = None
+
+ self._axlim_clip = axlim_clip
self.stale = True
def set_sizes(self, sizes, dpi=72.0):
@@ -737,7 +974,11 @@ def set_linewidth(self, lw):
def get_depthshade(self):
return self._depthshade
- def set_depthshade(self, depthshade):
+ def set_depthshade(
+ self,
+ depthshade,
+ depthshade_minalpha=None,
+ ):
"""
Set whether depth shading is performed on collection members.
@@ -746,18 +987,37 @@ def set_depthshade(self, depthshade):
depthshade : bool
Whether to shade the patches in order to give the appearance of
depth.
+ depthshade_minalpha : float
+ Sets the minimum alpha value used by depth-shading.
+
+ .. versionadded:: 3.11
"""
+ if depthshade_minalpha is None:
+ depthshade_minalpha = rcParams['axes3d.depthshade_minalpha']
self._depthshade = depthshade
+ self._depthshade_minalpha = depthshade_minalpha
self.stale = True
def do_3d_projection(self):
- xs, ys, zs = self._offsets3d
- vxs, vys, vzs, vis = proj3d.proj_transform_clip(xs, ys, zs,
- self.axes.M)
+ mask = False
+ for xyz in self._offsets3d:
+ if np.ma.isMA(xyz):
+ mask = mask | xyz.mask
+ if self._axlim_clip:
+ mask = mask | _viewlim_mask(*self._offsets3d, self.axes)
+ mask = np.broadcast_to(mask,
+ (len(self._offsets3d), *self._offsets3d[0].shape))
+ xyzs = np.ma.array(self._offsets3d, mask=mask)
+ else:
+ xyzs = self._offsets3d
+ vxs, vys, vzs, vis = proj3d._proj_transform_clip(*xyzs,
+ self.axes.M,
+ self.axes._focal_length)
+ self._data_scale = _get_data_scale(vxs, vys, vzs)
# Sort the points based on z coordinates
# Performance optimization: Create a sorted index array and reorder
# points and point properties according to the index array
- z_markers_idx = self._z_markers_idx = np.argsort(vzs)[::-1]
+ z_markers_idx = self._z_markers_idx = np.ma.argsort(vzs)[::-1]
self._vzs = vzs
# we have to special case the sizes because of code in collections.py
@@ -771,7 +1031,7 @@ def do_3d_projection(self):
if len(self._linewidths3d) > 1:
self._linewidths = self._linewidths3d[z_markers_idx]
- PathCollection.set_offsets(self, np.column_stack((vxs, vys)))
+ PathCollection.set_offsets(self, np.ma.column_stack((vxs, vys)))
# Re-order items
vzs = vzs[z_markers_idx]
@@ -779,7 +1039,7 @@ def do_3d_projection(self):
vys = vys[z_markers_idx]
# Store ordered offset for drawing purpose
- self._offset_zordered = np.column_stack((vxs, vys))
+ self._offset_zordered = np.ma.column_stack((vxs, vys))
return np.min(vzs) if vzs.size else np.nan
@@ -798,14 +1058,22 @@ def _use_zordered_offset(self):
self._offsets = old_offset
def _maybe_depth_shade_and_sort_colors(self, color_array):
- color_array = (
- _zalpha(color_array, self._vzs)
- if self._vzs is not None and self._depthshade
- else color_array
- )
+ # Adjust the color_array alpha values if point depths are defined
+ # and depth shading is active
+ if self._vzs is not None and self._depthshade:
+ color_array = _zalpha(
+ color_array,
+ self._vzs,
+ min_alpha=self._depthshade_minalpha,
+ _data_scale=self._data_scale,
+ )
+
+ # Adjust the order of the color_array using the _z_markers_idx,
+ # which has been sorted by z-depth
if len(color_array) > 1:
color_array = color_array[self._z_markers_idx]
- return mcolors.to_rgba_array(color_array, self._alpha)
+
+ return mcolors.to_rgba_array(color_array)
def get_facecolor(self):
return self._maybe_depth_shade_and_sort_colors(super().get_facecolor())
@@ -819,7 +1087,15 @@ def get_edgecolor(self):
return self._maybe_depth_shade_and_sort_colors(super().get_edgecolor())
-def patch_collection_2d_to_3d(col, zs=0, zdir='z', depthshade=True):
+def patch_collection_2d_to_3d(
+ col,
+ zs=0,
+ zdir="z",
+ depthshade=None,
+ axlim_clip=False,
+ *args,
+ depthshade_minalpha=None,
+):
"""
Convert a `.PatchCollection` into a `.Patch3DCollection` object
(or a `.PathCollection` into a `.Path3DCollection` object).
@@ -835,18 +1111,31 @@ def patch_collection_2d_to_3d(col, zs=0, zdir='z', depthshade=True):
zdir : {'x', 'y', 'z'}
The axis in which to place the patches. Default: "z".
See `.get_dir_vector` for a description of the values.
- depthshade : bool, default: True
+ depthshade : bool, default: :rc:`axes3d.depthshade`
Whether to shade the patches to give a sense of depth.
+ axlim_clip : bool, default: False
+ Whether to hide patches with a vertex outside the axes view limits.
+
+ .. versionadded:: 3.10
+
+ depthshade_minalpha : float, default: :rc:`axes3d.depthshade_minalpha`
+ Sets the minimum alpha value used by depth-shading.
+ .. versionadded:: 3.11
"""
if isinstance(col, PathCollection):
col.__class__ = Path3DCollection
col._offset_zordered = None
elif isinstance(col, PatchCollection):
col.__class__ = Patch3DCollection
+ if depthshade is None:
+ depthshade = rcParams['axes3d.depthshade']
+ if depthshade_minalpha is None:
+ depthshade_minalpha = rcParams['axes3d.depthshade_minalpha']
col._depthshade = depthshade
+ col._depthshade_minalpha = depthshade_minalpha
col._in_draw = False
- col.set_3d_properties(zs, zdir)
+ col.set_3d_properties(zs, zdir, axlim_clip)
class Poly3DCollection(PolyCollection):
@@ -871,7 +1160,7 @@ class Poly3DCollection(PolyCollection):
"""
def __init__(self, verts, *args, zsort='average', shade=False,
- lightsource=None, **kwargs):
+ lightsource=None, axlim_clip=False, **kwargs):
"""
Parameters
----------
@@ -893,6 +1182,11 @@ def __init__(self, verts, *args, zsort='average', shade=False,
.. versionadded:: 3.7
+ axlim_clip : bool, default: False
+ Whether to hide polygons with a vertex outside the view limits.
+
+ .. versionadded:: 3.10
+
*args, **kwargs
All other parameters are forwarded to `.PolyCollection`.
@@ -927,6 +1221,7 @@ def __init__(self, verts, *args, zsort='average', shade=False,
raise ValueError('verts must be a list of (N, 3) array-like')
self.set_zsort(zsort)
self._codes3d = None
+ self._axlim_clip = axlim_clip
_zsort_functions = {
'average': np.average,
@@ -948,17 +1243,40 @@ def set_zsort(self, zsort):
self._sort_zpos = None
self.stale = True
+ @_api.deprecated("3.10")
def get_vector(self, segments3d):
- """Optimize points for projection."""
- if len(segments3d):
- xs, ys, zs = np.vstack(segments3d).T
- else: # vstack can't stack zero arrays.
- xs, ys, zs = [], [], []
- ones = np.ones(len(xs))
- self._vec = np.array([xs, ys, zs, ones])
+ return self._get_vector(segments3d)
- indices = [0, *np.cumsum([len(segment) for segment in segments3d])]
- self._segslices = [*map(slice, indices[:-1], indices[1:])]
+ def _get_vector(self, segments3d):
+ """
+ Optimize points for projection.
+
+ Parameters
+ ----------
+ segments3d : NumPy array or list of NumPy arrays
+ List of vertices of the boundary of every segment. If all paths are
+ of equal length and this argument is a NumPy array, then it should
+ be of shape (num_faces, num_vertices, 3).
+ """
+ if isinstance(segments3d, np.ndarray):
+ _api.check_shape((None, None, 3), segments3d=segments3d)
+ if isinstance(segments3d, np.ma.MaskedArray):
+ self._faces = segments3d.data
+ self._invalid_vertices = segments3d.mask.any(axis=-1)
+ else:
+ self._faces = segments3d
+ self._invalid_vertices = False
+ else:
+ # Turn the potentially ragged list into a numpy array for later speedups
+ # If it is ragged, set the unused vertices per face as invalid
+ num_faces = len(segments3d)
+ num_verts = np.fromiter(map(len, segments3d), dtype=np.intp)
+ max_verts = num_verts.max(initial=0)
+ segments = np.empty((num_faces, max_verts, 3))
+ for i, face in enumerate(segments3d):
+ segments[i, :len(face)] = face
+ self._faces = segments
+ self._invalid_vertices = np.arange(max_verts) >= num_verts[:, None]
def set_verts(self, verts, closed=True):
"""
@@ -974,7 +1292,7 @@ def set_verts(self, verts, closed=True):
Whether the polygon should be closed by adding a CLOSEPOLY
connection at the end.
"""
- self.get_vector(verts)
+ self._get_vector(verts)
# 2D verts will be updated at draw time
super().set_verts([], False)
self._closed = closed
@@ -987,7 +1305,7 @@ def set_verts_and_codes(self, verts, codes):
# and set our own codes instead.
self._codes3d = codes
- def set_3d_properties(self):
+ def set_3d_properties(self, axlim_clip=False):
# Force the collection to initialize the face and edgecolors
# just in case it is a scalarmappable with a colormap.
self.update_scalarmappable()
@@ -1020,43 +1338,73 @@ def do_3d_projection(self):
self._facecolor3d = self._facecolors
if self._edge_is_mapped:
self._edgecolor3d = self._edgecolors
- txs, tys, tzs = proj3d._proj_transform_vec(self._vec, self.axes.M)
- xyzlist = [(txs[sl], tys[sl], tzs[sl]) for sl in self._segslices]
+
+ needs_masking = np.any(self._invalid_vertices)
+ num_faces = len(self._faces)
+ mask = self._invalid_vertices
+
+ # Some faces might contain masked vertices, so we want to ignore any
+ # errors that those might cause
+ with np.errstate(invalid='ignore', divide='ignore'):
+ pfaces = proj3d._proj_transform_vectors(self._faces, self.axes.M)
+
+ if self._axlim_clip:
+ viewlim_mask = _viewlim_mask(self._faces[..., 0], self._faces[..., 1],
+ self._faces[..., 2], self.axes)
+ if np.any(viewlim_mask):
+ needs_masking = True
+ mask = mask | viewlim_mask
+
+ pzs = pfaces[..., 2]
+ if needs_masking:
+ pzs = np.ma.MaskedArray(pzs, mask=mask)
# This extra fuss is to re-order face / edge colors
cface = self._facecolor3d
cedge = self._edgecolor3d
- if len(cface) != len(xyzlist):
- cface = cface.repeat(len(xyzlist), axis=0)
- if len(cedge) != len(xyzlist):
+ if len(cface) != num_faces:
+ cface = cface.repeat(num_faces, axis=0)
+ if len(cedge) != num_faces:
if len(cedge) == 0:
cedge = cface
else:
- cedge = cedge.repeat(len(xyzlist), axis=0)
-
- if xyzlist:
- # sort by depth (furthest drawn first)
- z_segments_2d = sorted(
- ((self._zsortfunc(zs), np.column_stack([xs, ys]), fc, ec, idx)
- for idx, ((xs, ys, zs), fc, ec)
- in enumerate(zip(xyzlist, cface, cedge))),
- key=lambda x: x[0], reverse=True)
-
- _, segments_2d, self._facecolors2d, self._edgecolors2d, idxs = \
- zip(*z_segments_2d)
- else:
- segments_2d = []
- self._facecolors2d = np.empty((0, 4))
- self._edgecolors2d = np.empty((0, 4))
- idxs = []
-
- if self._codes3d is not None:
- codes = [self._codes3d[idx] for idx in idxs]
- PolyCollection.set_verts_and_codes(self, segments_2d, codes)
+ cedge = cedge.repeat(num_faces, axis=0)
+
+ if len(pzs) > 0:
+ face_z = self._zsortfunc(pzs, axis=-1)
else:
- PolyCollection.set_verts(self, segments_2d, self._closed)
+ face_z = pzs
+ if needs_masking:
+ face_z = face_z.data
+ face_order = np.argsort(face_z, axis=-1)[::-1]
- if len(self._edgecolor3d) != len(cface):
+ if len(pfaces) > 0:
+ faces_2d = pfaces[face_order, :, :2]
+ else:
+ faces_2d = pfaces
+ if self._codes3d is not None and len(self._codes3d) > 0:
+ if needs_masking:
+ segment_mask = ~mask[face_order, :]
+ faces_2d = [face[mask, :] for face, mask
+ in zip(faces_2d, segment_mask)]
+ codes = [self._codes3d[idx] for idx in face_order]
+ PolyCollection.set_verts_and_codes(self, faces_2d, codes)
+ else:
+ if needs_masking and len(faces_2d) > 0:
+ invalid_vertices_2d = np.broadcast_to(
+ mask[face_order, :, None],
+ faces_2d.shape)
+ faces_2d = np.ma.MaskedArray(
+ faces_2d, mask=invalid_vertices_2d)
+ PolyCollection.set_verts(self, faces_2d, self._closed)
+
+ if len(cface) > 0:
+ self._facecolors2d = cface[face_order]
+ else:
+ self._facecolors2d = cface
+ if len(self._edgecolor3d) == len(cface) and len(cedge) > 0:
+ self._edgecolors2d = cedge[face_order]
+ else:
self._edgecolors2d = self._edgecolor3d
# Return zorder value
@@ -1064,11 +1412,11 @@ def do_3d_projection(self):
zvec = np.array([[0], [0], [self._sort_zpos], [1]])
ztrans = proj3d._proj_transform_vec(zvec, self.axes.M)
return ztrans[2][0]
- elif tzs.size > 0:
+ elif pzs.size > 0:
# FIXME: Some results still don't look quite right.
# In particular, examine contourf3d_demo2.py
# with az = -54 and elev = -45.
- return np.min(tzs)
+ return np.min(pzs)
else:
return np.nan
@@ -1114,7 +1462,7 @@ def get_edgecolor(self):
return np.asarray(self._edgecolors2d)
-def poly_collection_2d_to_3d(col, zs=0, zdir='z'):
+def poly_collection_2d_to_3d(col, zs=0, zdir='z', axlim_clip=False):
"""
Convert a `.PolyCollection` into a `.Poly3DCollection` object.
@@ -1134,6 +1482,7 @@ def poly_collection_2d_to_3d(col, zs=0, zdir='z'):
col.__class__ = Poly3DCollection
col.set_verts_and_codes(segments_3d, codes)
col.set_3d_properties()
+ col._axlim_clip = axlim_clip
def juggle_axes(xs, ys, zs, zdir):
@@ -1167,20 +1516,75 @@ def rotate_axes(xs, ys, zs, zdir):
return xs, ys, zs
-def _zalpha(colors, zs):
- """Modify the alphas of the color list according to depth."""
- # FIXME: This only works well if the points for *zs* are well-spaced
- # in all three dimensions. Otherwise, at certain orientations,
- # the min and max zs are very close together.
- # Should really normalize against the viewing depth.
+def _zalpha(
+ colors,
+ zs,
+ min_alpha=0.3,
+ _data_scale=None,
+):
+ """Modify the alpha values of the color list according to z-depth."""
+
if len(colors) == 0 or len(zs) == 0:
return np.zeros((0, 4))
- norm = Normalize(min(zs), max(zs))
- sats = 1 - norm(zs) * 0.7
+
+ # Alpha values beyond the range 0-1 inclusive make no sense, so clip them
+ min_alpha = np.clip(min_alpha, 0, 1)
+
+ if _data_scale is None or _data_scale == 0:
+ # Don't scale the alpha values since we have no valid data scale for reference
+ sats = np.ones_like(zs)
+
+ else:
+ # Deeper points have an increasingly transparent appearance
+ sats = np.clip(1 - (zs - np.min(zs)) / _data_scale, min_alpha, 1)
+
rgba = np.broadcast_to(mcolors.to_rgba_array(colors), (len(zs), 4))
+
+ # Change the alpha values of the colors using the generated alpha multipliers
return np.column_stack([rgba[:, :3], rgba[:, 3] * sats])
+def _all_points_on_plane(xs, ys, zs, atol=1e-8):
+ """
+ Check if all points are on the same plane. Note that NaN values are
+ ignored.
+
+ Parameters
+ ----------
+ xs, ys, zs : array-like
+ The x, y, and z coordinates of the points.
+ atol : float, default: 1e-8
+ The tolerance for the equality check.
+ """
+ xs, ys, zs = np.asarray(xs), np.asarray(ys), np.asarray(zs)
+ points = np.column_stack([xs, ys, zs])
+ points = points[~np.isnan(points).any(axis=1)]
+ # Check for the case where we have less than 3 unique points
+ points = np.unique(points, axis=0)
+ if len(points) <= 3:
+ return True
+ # Calculate the vectors from the first point to all other points
+ vs = (points - points[0])[1:]
+ vs = vs / np.linalg.norm(vs, axis=1)[:, np.newaxis]
+ # Filter out parallel vectors
+ vs = np.unique(vs, axis=0)
+ if len(vs) <= 2:
+ return True
+ # Filter out parallel and antiparallel vectors to the first vector
+ cross_norms = np.linalg.norm(np.cross(vs[0], vs[1:]), axis=1)
+ zero_cross_norms = np.where(np.isclose(cross_norms, 0, atol=atol))[0] + 1
+ vs = np.delete(vs, zero_cross_norms, axis=0)
+ if len(vs) <= 2:
+ return True
+ # Calculate the normal vector from the first three points
+ n = np.cross(vs[0], vs[1])
+ n = n / np.linalg.norm(n)
+ # If the dot product of the normal vector and all other vectors is zero,
+ # all points are on the same plane
+ dots = np.dot(n, vs.transpose())
+ return np.allclose(dots, 0, atol=atol)
+
+
def _generate_normals(polygons):
"""
Compute the normals of a list of polygons, one normal per polygon.
@@ -1218,6 +1622,7 @@ def _generate_normals(polygons):
v2 = np.empty((len(polygons), 3))
for poly_i, ps in enumerate(polygons):
n = len(ps)
+ ps = np.asarray(ps)
i1, i2, i3 = 0, n//3, 2*n//3
v1[poly_i, :] = ps[i1, :] - ps[i2, :]
v2[poly_i, :] = ps[i2, :] - ps[i3, :]
diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py
index 71cd8f062d40..c56e4c6b7039 100644
--- a/lib/mpl_toolkits/mplot3d/axes3d.py
+++ b/lib/mpl_toolkits/mplot3d/axes3d.py
@@ -14,6 +14,7 @@
import itertools
import math
import textwrap
+import warnings
import numpy as np
@@ -57,17 +58,19 @@ class Axes3D(Axes):
Axes._shared_axes["view"] = cbook.Grouper()
def __init__(
- self, fig, rect=None, *args,
- elev=30, azim=-60, roll=0, sharez=None, proj_type='persp',
- box_aspect=None, computed_zorder=True, focal_length=None,
- shareview=None,
- **kwargs):
+ self, fig, rect=None, *args,
+ elev=30, azim=-60, roll=0, shareview=None, sharez=None,
+ proj_type='persp', focal_length=None,
+ box_aspect=None,
+ computed_zorder=True,
+ **kwargs,
+ ):
"""
Parameters
----------
fig : Figure
The parent figure.
- rect : tuple (left, bottom, width, height), default: None.
+ rect : tuple (left, bottom, width, height), default: (0, 0, 1, 1)
The ``(left, bottom, width, height)`` Axes position.
elev : float, default: 30
The elevation angle in degrees rotates the camera above and below
@@ -82,11 +85,21 @@ def __init__(
The roll angle in degrees rotates the camera about the viewing
axis. A positive angle spins the camera clockwise, causing the
scene to rotate counter-clockwise.
+ shareview : Axes3D, optional
+ Other Axes to share view angles with. Note that it is not possible
+ to unshare axes.
sharez : Axes3D, optional
Other Axes to share z-limits with. Note that it is not possible to
unshare axes.
proj_type : {'persp', 'ortho'}
The projection type, default 'persp'.
+ focal_length : float, default: None
+ For a projection type of 'persp', the focal length of the virtual
+ camera. Must be > 0. If None, defaults to 1.
+ For a projection type of 'ortho', must be set to either None
+ or infinity (numpy.inf). If None, defaults to infinity.
+ The focal length can be computed from a desired Field Of View via
+ the equation: focal_length = 1/tan(FOV/2)
box_aspect : 3-tuple of floats, default: None
Changes the physical dimensions of the Axes3D, such that the ratio
of the axis lengths in display units is x:y:z.
@@ -100,16 +113,6 @@ def __init__(
does not produce the desired result. Note however, that a manual
zorder will only be correct for a limited view angle. If the figure
is rotated by the user, it will look wrong from certain angles.
- focal_length : float, default: None
- For a projection type of 'persp', the focal length of the virtual
- camera. Must be > 0. If None, defaults to 1.
- For a projection type of 'ortho', must be set to either None
- or infinity (numpy.inf). If None, defaults to infinity.
- The focal length can be computed from a desired Field Of View via
- the equation: focal_length = 1/tan(FOV/2)
- shareview : Axes3D, optional
- Other Axes to share view angles with. Note that it is not possible
- to unshare axes.
**kwargs
Other optional keyword arguments:
@@ -170,11 +173,12 @@ def __init__(
self.fmt_zdata = None
self.mouse_init()
- self.figure.canvas.callbacks._connect_picklable(
+ fig = self.get_figure(root=True)
+ fig.canvas.callbacks._connect_picklable(
'motion_notify_event', self._on_move)
- self.figure.canvas.callbacks._connect_picklable(
+ fig.canvas.callbacks._connect_picklable(
'button_press_event', self._button_press)
- self.figure.canvas.callbacks._connect_picklable(
+ fig.canvas.callbacks._connect_picklable(
'button_release_event', self._button_release)
self.set_top_view()
@@ -1361,17 +1365,21 @@ def _button_press(self, event):
if event.inaxes == self:
self.button_pressed = event.button
self._sx, self._sy = event.xdata, event.ydata
- toolbar = self.figure.canvas.toolbar
+ toolbar = self.get_figure(root=True).canvas.toolbar
if toolbar and toolbar._nav_stack() is None:
toolbar.push_current()
+ if toolbar:
+ toolbar.set_message(toolbar._mouse_event_to_message(event))
def _button_release(self, event):
self.button_pressed = None
- toolbar = self.figure.canvas.toolbar
+ toolbar = self.get_figure(root=True).canvas.toolbar
# backend_bases.release_zoom and backend_bases.release_pan call
# push_current, so check the navigation mode so we don't call it twice
if toolbar and self.get_navigate_mode() is None:
toolbar.push_current()
+ if toolbar:
+ toolbar.set_message(toolbar._mouse_event_to_message(event))
def _get_view(self):
# docstring inherited
@@ -1392,7 +1400,7 @@ def _set_view(self, view):
def format_zdata(self, z):
"""
Return *z* string formatted. This function will use the
- :attr:`fmt_zdata` attribute if it is callable, else will fall
+ :attr:`!fmt_zdata` attribute if it is callable, else will fall
back on the zaxis major formatter
"""
try:
@@ -1500,6 +1508,39 @@ def _calc_coord(self, xv, yv, renderer=None):
p2 = p1 - scale*vec
return p2, pane_idx
+ def _arcball(self, x: float, y: float) -> np.ndarray:
+ """
+ Convert a point (x, y) to a point on a virtual trackball.
+
+ This is Ken Shoemake's arcball (a sphere), modified
+ to soften the abrupt edge (optionally).
+ See: Ken Shoemake, "ARCBALL: A user interface for specifying
+ three-dimensional rotation using a mouse." in
+ Proceedings of Graphics Interface '92, 1992, pp. 151-156,
+ https://doi.org/10.20380/GI1992.18
+ The smoothing of the edge is inspired by Gavin Bell's arcball
+ (a sphere combined with a hyperbola), but here, the sphere
+ is combined with a section of a cylinder, so it has finite support.
+ """
+ s = mpl.rcParams['axes3d.trackballsize'] / 2
+ b = mpl.rcParams['axes3d.trackballborder'] / s
+ x /= s
+ y /= s
+ r2 = x*x + y*y
+ r = np.sqrt(r2)
+ ra = 1 + b
+ a = b * (1 + b/2)
+ ri = 2/(ra + 1/ra)
+ if r < ri:
+ p = np.array([np.sqrt(1 - r2), x, y])
+ elif r < ra:
+ dr = ra - r
+ p = np.array([a - np.sqrt((a + dr) * (a - dr)), x, y])
+ p /= np.linalg.norm(p)
+ else:
+ p = np.array([0, x/r, y/r])
+ return p
+
def _on_move(self, event):
"""
Mouse moving.
@@ -1535,12 +1576,35 @@ def _on_move(self, event):
if dx == 0 and dy == 0:
return
- roll = np.deg2rad(self.roll)
- delev = -(dy/h)*180*np.cos(roll) + (dx/w)*180*np.sin(roll)
- dazim = -(dy/h)*180*np.sin(roll) - (dx/w)*180*np.cos(roll)
- elev = self.elev + delev
- azim = self.azim + dazim
- roll = self.roll
+ style = mpl.rcParams['axes3d.mouserotationstyle']
+ if style == 'azel':
+ roll = np.deg2rad(self.roll)
+ delev = -(dy/h)*180*np.cos(roll) + (dx/w)*180*np.sin(roll)
+ dazim = -(dy/h)*180*np.sin(roll) - (dx/w)*180*np.cos(roll)
+ elev = self.elev + delev
+ azim = self.azim + dazim
+ roll = self.roll
+ else:
+ q = _Quaternion.from_cardan_angles(
+ *np.deg2rad((self.elev, self.azim, self.roll)))
+
+ if style == 'trackball':
+ k = np.array([0, -dy/h, dx/w])
+ nk = np.linalg.norm(k)
+ th = nk / mpl.rcParams['axes3d.trackballsize']
+ dq = _Quaternion(np.cos(th), k*np.sin(th)/nk)
+ else: # 'sphere', 'arcball'
+ current_vec = self._arcball(self._sx/w, self._sy/h)
+ new_vec = self._arcball(x/w, y/h)
+ if style == 'sphere':
+ dq = _Quaternion.rotate_from_to(current_vec, new_vec)
+ else: # 'arcball'
+ dq = _Quaternion(0, new_vec) * _Quaternion(0, -current_vec)
+
+ q = dq * q
+ elev, azim, roll = np.rad2deg(q.as_cardan_angles())
+
+ # update view
vertical_axis = self._axis_names[self._vertical_axis]
self.view_init(
elev=elev,
@@ -1569,7 +1633,7 @@ def _on_move(self, event):
# Store the event coordinates for the next time through.
self._sx, self._sy = x, y
# Always request a draw update at the end of interaction
- self.figure.canvas.draw_idle()
+ self.get_figure(root=True).canvas.draw_idle()
def drag_pan(self, button, key, x, y):
# docstring inherited
@@ -1764,7 +1828,7 @@ def get_zlabel(self):
"""
Get the z-label text string.
"""
- label = self.zaxis.get_label()
+ label = self.zaxis.label
return label.get_text()
# Axes rectangle characteristics
@@ -1822,18 +1886,34 @@ def tick_params(self, axis='both', **kwargs):
def invert_zaxis(self):
"""
- Invert the z-axis.
+ [*Discouraged*] Invert the z-axis.
+
+ .. admonition:: Discouraged
+
+ The use of this method is discouraged.
+ Use `.Axes3D.set_zinverted` instead.
See Also
--------
- zaxis_inverted
+ get_zinverted
get_zlim, set_zlim
get_zbound, set_zbound
"""
bottom, top = self.get_zlim()
self.set_zlim(top, bottom, auto=None)
+ set_zinverted = _axis_method_wrapper("zaxis", "set_inverted")
+ get_zinverted = _axis_method_wrapper("zaxis", "get_inverted")
zaxis_inverted = _axis_method_wrapper("zaxis", "get_inverted")
+ if zaxis_inverted.__doc__:
+ zaxis_inverted.__doc__ = ("[*Discouraged*] " + zaxis_inverted.__doc__ +
+ textwrap.dedent("""
+
+ .. admonition:: Discouraged
+
+ The use of this method is discouraged.
+ Use `.Axes3D.get_zinverted` instead.
+ """))
def get_zbound(self):
"""
@@ -1851,7 +1931,7 @@ def get_zbound(self):
else:
return upper, lower
- def text(self, x, y, z, s, zdir=None, **kwargs):
+ def text(self, x, y, z, s, zdir=None, *, axlim_clip=False, **kwargs):
"""
Add the text *s* to the 3D Axes at location *x*, *y*, *z* in data coordinates.
@@ -1864,6 +1944,10 @@ def text(self, x, y, z, s, zdir=None, **kwargs):
zdir : {'x', 'y', 'z', 3-tuple}, optional
The direction to be used as the z-direction. Default: 'z'.
See `.get_dir_vector` for a description of the values.
+ axlim_clip : bool, default: False
+ Whether to hide text that is outside the axes view limits.
+
+ .. versionadded:: 3.10
**kwargs
Other arguments are forwarded to `matplotlib.axes.Axes.text`.
@@ -1873,13 +1957,13 @@ def text(self, x, y, z, s, zdir=None, **kwargs):
The created `.Text3D` instance.
"""
text = super().text(x, y, s, **kwargs)
- art3d.text_2d_to_3d(text, z, zdir)
+ art3d.text_2d_to_3d(text, z, zdir, axlim_clip)
return text
text3D = text
text2D = Axes.text
- def plot(self, xs, ys, *args, zdir='z', **kwargs):
+ def plot(self, xs, ys, *args, zdir='z', axlim_clip=False, **kwargs):
"""
Plot 2D or 3D data.
@@ -1894,6 +1978,10 @@ def plot(self, xs, ys, *args, zdir='z', **kwargs):
each point.
zdir : {'x', 'y', 'z'}, default: 'z'
When plotting 2D data, the direction to use as z.
+ axlim_clip : bool, default: False
+ Whether to hide data that is outside the axes view limits.
+
+ .. versionadded:: 3.10
**kwargs
Other arguments are forwarded to `matplotlib.axes.Axes.plot`.
"""
@@ -1913,7 +2001,7 @@ def plot(self, xs, ys, *args, zdir='z', **kwargs):
lines = super().plot(xs, ys, *args, **kwargs)
for line in lines:
- art3d.line_2d_to_3d(line, zs=zs, zdir=zdir)
+ art3d.line_2d_to_3d(line, zs=zs, zdir=zdir, axlim_clip=axlim_clip)
xs, ys, zs = art3d.juggle_axes(xs, ys, zs, zdir)
self.auto_scale_xyz(xs, ys, zs, had_data)
@@ -1921,8 +2009,138 @@ def plot(self, xs, ys, *args, zdir='z', **kwargs):
plot3D = plot
+ def fill_between(self, x1, y1, z1, x2, y2, z2, *,
+ where=None, mode='auto', facecolors=None, shade=None,
+ axlim_clip=False, **kwargs):
+ """
+ Fill the area between two 3D curves.
+
+ The curves are defined by the points (*x1*, *y1*, *z1*) and
+ (*x2*, *y2*, *z2*). This creates one or multiple quadrangle
+ polygons that are filled. All points must be the same length N, or a
+ single value to be used for all points.
+
+ Parameters
+ ----------
+ x1, y1, z1 : float or 1D array-like
+ x, y, and z coordinates of vertices for 1st line.
+
+ x2, y2, z2 : float or 1D array-like
+ x, y, and z coordinates of vertices for 2nd line.
+
+ where : array of bool (length N), optional
+ Define *where* to exclude some regions from being filled. The
+ filled regions are defined by the coordinates ``pts[where]``,
+ for all x, y, and z pts. More precisely, fill between ``pts[i]``
+ and ``pts[i+1]`` if ``where[i] and where[i+1]``. Note that this
+ definition implies that an isolated *True* value between two
+ *False* values in *where* will not result in filling. Both sides of
+ the *True* position remain unfilled due to the adjacent *False*
+ values.
+
+ mode : {'quad', 'polygon', 'auto'}, default: 'auto'
+ The fill mode. One of:
+
+ - 'quad': A separate quadrilateral polygon is created for each
+ pair of subsequent points in the two lines.
+ - 'polygon': The two lines are connected to form a single polygon.
+ This is faster and can render more cleanly for simple shapes
+ (e.g. for filling between two lines that lie within a plane).
+ - 'auto': If the points all lie on the same 3D plane, 'polygon' is
+ used. Otherwise, 'quad' is used.
+
+ facecolors : :mpltype:`color` or list of :mpltype:`color`, optional
+ Colors of each individual patch, or a single color to be used for
+ all patches. If not given, the next color from the patch color
+ cycle is used.
+
+ shade : bool, default: None
+ Whether to shade the facecolors. If *None*, then defaults to *True*
+ for 'quad' mode and *False* for 'polygon' mode.
+
+ axlim_clip : bool, default: False
+ Whether to hide data that is outside the axes view limits.
+
+ .. versionadded:: 3.10
+
+ **kwargs
+ All other keyword arguments are passed on to `.Poly3DCollection`.
+
+ Returns
+ -------
+ `.Poly3DCollection`
+ A `.Poly3DCollection` containing the plotted polygons.
+
+ """
+ _api.check_in_list(['auto', 'quad', 'polygon'], mode=mode)
+
+ had_data = self.has_data()
+ x1, y1, z1, x2, y2, z2 = cbook._broadcast_with_masks(x1, y1, z1, x2, y2, z2)
+
+ if facecolors is None:
+ facecolors = [self._get_patches_for_fill.get_next_color()]
+ facecolors = list(mcolors.to_rgba_array(facecolors))
+
+ if where is None:
+ where = True
+ else:
+ where = np.asarray(where, dtype=bool)
+ if where.size != x1.size:
+ raise ValueError(f"where size ({where.size}) does not match "
+ f"size ({x1.size})")
+ where = where & ~np.isnan(x1) # NaNs were broadcast in _broadcast_with_masks
+
+ if mode == 'auto':
+ if art3d._all_points_on_plane(np.concatenate((x1[where], x2[where])),
+ np.concatenate((y1[where], y2[where])),
+ np.concatenate((z1[where], z2[where])),
+ atol=1e-12):
+ mode = 'polygon'
+ else:
+ mode = 'quad'
+
+ if shade is None:
+ if mode == 'quad':
+ shade = True
+ else:
+ shade = False
+
+ polys = []
+ for idx0, idx1 in cbook.contiguous_regions(where):
+ x1i = x1[idx0:idx1]
+ y1i = y1[idx0:idx1]
+ z1i = z1[idx0:idx1]
+ x2i = x2[idx0:idx1]
+ y2i = y2[idx0:idx1]
+ z2i = z2[idx0:idx1]
+
+ if not len(x1i):
+ continue
+
+ if mode == 'quad':
+ # Preallocate the array for the region's vertices, and fill it in
+ n_polys_i = len(x1i) - 1
+ polys_i = np.empty((n_polys_i, 4, 3))
+ polys_i[:, 0, :] = np.column_stack((x1i[:-1], y1i[:-1], z1i[:-1]))
+ polys_i[:, 1, :] = np.column_stack((x1i[1:], y1i[1:], z1i[1:]))
+ polys_i[:, 2, :] = np.column_stack((x2i[1:], y2i[1:], z2i[1:]))
+ polys_i[:, 3, :] = np.column_stack((x2i[:-1], y2i[:-1], z2i[:-1]))
+ polys = polys + [*polys_i]
+ elif mode == 'polygon':
+ line1 = np.column_stack((x1i, y1i, z1i))
+ line2 = np.column_stack((x2i[::-1], y2i[::-1], z2i[::-1]))
+ poly = np.concatenate((line1, line2), axis=0)
+ polys.append(poly)
+
+ polyc = art3d.Poly3DCollection(polys, facecolors=facecolors, shade=shade,
+ axlim_clip=axlim_clip, **kwargs)
+ self.add_collection(polyc, autolim="_datalim_only")
+
+ self.auto_scale_xyz([x1, x2], [y1, y2], [z1, z2], had_data)
+ return polyc
+
def plot_surface(self, X, Y, Z, *, norm=None, vmin=None,
- vmax=None, lightsource=None, **kwargs):
+ vmax=None, lightsource=None, axlim_clip=False, **kwargs):
"""
Create a surface plot.
@@ -1987,6 +2205,11 @@ def plot_surface(self, X, Y, Z, *, norm=None, vmin=None,
lightsource : `~matplotlib.colors.LightSource`, optional
The lightsource to use when *shade* is True.
+ axlim_clip : bool, default: False
+ Whether to hide patches with a vertex outside the axes view limits.
+
+ .. versionadded:: 3.10
+
**kwargs
Other keyword arguments are forwarded to `.Poly3DCollection`.
"""
@@ -2046,8 +2269,8 @@ def plot_surface(self, X, Y, Z, *, norm=None, vmin=None,
col_inds = list(range(0, cols-1, cstride)) + [cols-1]
polys = []
- for rs, rs_next in zip(row_inds[:-1], row_inds[1:]):
- for cs, cs_next in zip(col_inds[:-1], col_inds[1:]):
+ for rs, rs_next in itertools.pairwise(row_inds):
+ for cs, cs_next in itertools.pairwise(col_inds):
ps = [
# +1 ensures we share edges between polygons
cbook._array_perimeter(a[rs:rs_next+1, cs:cs_next+1])
@@ -2087,9 +2310,9 @@ def plot_surface(self, X, Y, Z, *, norm=None, vmin=None,
if fcolors is not None:
polyc = art3d.Poly3DCollection(
polys, edgecolors=colset, facecolors=colset, shade=shade,
- lightsource=lightsource, **kwargs)
+ lightsource=lightsource, axlim_clip=axlim_clip, **kwargs)
elif cmap:
- polyc = art3d.Poly3DCollection(polys, **kwargs)
+ polyc = art3d.Poly3DCollection(polys, axlim_clip=axlim_clip, **kwargs)
# can't always vectorize, because polys might be jagged
if isinstance(polys, np.ndarray):
avg_z = polys[..., 2].mean(axis=-1)
@@ -2107,15 +2330,15 @@ def plot_surface(self, X, Y, Z, *, norm=None, vmin=None,
color = np.array(mcolors.to_rgba(color))
polyc = art3d.Poly3DCollection(
- polys, facecolors=color, shade=shade,
- lightsource=lightsource, **kwargs)
+ polys, facecolors=color, shade=shade, lightsource=lightsource,
+ axlim_clip=axlim_clip, **kwargs)
- self.add_collection(polyc)
+ self.add_collection(polyc, autolim="_datalim_only")
self.auto_scale_xyz(X, Y, Z, had_data)
return polyc
- def plot_wireframe(self, X, Y, Z, **kwargs):
+ def plot_wireframe(self, X, Y, Z, *, axlim_clip=False, **kwargs):
"""
Plot a 3D wireframe.
@@ -2131,6 +2354,12 @@ def plot_wireframe(self, X, Y, Z, **kwargs):
X, Y, Z : 2D arrays
Data values.
+ axlim_clip : bool, default: False
+ Whether to hide lines and patches with vertices outside the axes
+ view limits.
+
+ .. versionadded:: 3.10
+
rcount, ccount : int
Maximum number of samples used in each direction. If the input
data is larger, it will be downsampled (by slicing) to these
@@ -2185,56 +2414,57 @@ def plot_wireframe(self, X, Y, Z, **kwargs):
rstride = int(max(np.ceil(rows / rcount), 1)) if rcount else 0
cstride = int(max(np.ceil(cols / ccount), 1)) if ccount else 0
+ if rstride == 0 and cstride == 0:
+ raise ValueError("Either rstride or cstride must be non zero")
+
# We want two sets of lines, one running along the "rows" of
# Z and another set of lines running along the "columns" of Z.
# This transpose will make it easy to obtain the columns.
tX, tY, tZ = np.transpose(X), np.transpose(Y), np.transpose(Z)
- if rstride:
- rii = list(range(0, rows, rstride))
- # Add the last index only if needed
- if rows > 0 and rii[-1] != (rows - 1):
- rii += [rows-1]
+ # Compute the indices of the row and column lines to be drawn
+ # For Z.size == 0, we don't want to draw any lines since the data is empty
+ if rstride == 0 or Z.size == 0:
+ rii = np.array([], dtype=int)
+ elif (rows - 1) % rstride == 0:
+ # last index is hit: rii[-1] == rows - 1
+ rii = np.arange(0, rows, rstride)
else:
- rii = []
- if cstride:
- cii = list(range(0, cols, cstride))
- # Add the last index only if needed
- if cols > 0 and cii[-1] != (cols - 1):
- cii += [cols-1]
+ # add the last index
+ rii = np.arange(0, rows + rstride, rstride)
+ rii[-1] = rows - 1
+
+ if cstride == 0 or Z.size == 0:
+ cii = np.array([], dtype=int)
+ elif (cols - 1) % cstride == 0:
+ # last index is hit: cii[-1] == cols - 1
+ cii = np.arange(0, cols, cstride)
else:
- cii = []
-
- if rstride == 0 and cstride == 0:
- raise ValueError("Either rstride or cstride must be non zero")
-
- # If the inputs were empty, then just
- # reset everything.
- if Z.size == 0:
- rii = []
- cii = []
-
- xlines = [X[i] for i in rii]
- ylines = [Y[i] for i in rii]
- zlines = [Z[i] for i in rii]
-
- txlines = [tX[i] for i in cii]
- tylines = [tY[i] for i in cii]
- tzlines = [tZ[i] for i in cii]
-
- lines = ([list(zip(xl, yl, zl))
- for xl, yl, zl in zip(xlines, ylines, zlines)]
- + [list(zip(xl, yl, zl))
- for xl, yl, zl in zip(txlines, tylines, tzlines)])
-
- linec = art3d.Line3DCollection(lines, **kwargs)
- self.add_collection(linec)
- self.auto_scale_xyz(X, Y, Z, had_data)
+ # add the last index
+ cii = np.arange(0, cols + cstride, cstride)
+ cii[-1] = cols - 1
+
+ row_lines = np.stack([X[rii], Y[rii], Z[rii]], axis=-1)
+ col_lines = np.stack([tX[cii], tY[cii], tZ[cii]], axis=-1)
+
+ # We autoscale twice because autoscaling is much faster with vectorized numpy
+ # arrays, but row_lines and col_lines might not be the same shape, so we can't
+ # stack them to check them in a single pass.
+ # Note that while the column and row grid points are the same, the lines
+ # between them may expand the view limits, so we have to check both.
+ self.auto_scale_xyz(row_lines[..., 0], row_lines[..., 1], row_lines[..., 2],
+ had_data)
+ self.auto_scale_xyz(col_lines[..., 0], col_lines[..., 1], col_lines[..., 2],
+ had_data=True)
+
+ lines = list(row_lines) + list(col_lines)
+ linec = art3d.Line3DCollection(lines, axlim_clip=axlim_clip, **kwargs)
+ self.add_collection(linec, autolim="_datalim_only")
return linec
def plot_trisurf(self, *args, color=None, norm=None, vmin=None, vmax=None,
- lightsource=None, **kwargs):
+ lightsource=None, axlim_clip=False, **kwargs):
"""
Plot a triangulated surface.
@@ -2276,6 +2506,10 @@ def plot_trisurf(self, *args, color=None, norm=None, vmin=None, vmax=None,
*cmap* is specified.
lightsource : `~matplotlib.colors.LightSource`, optional
The lightsource to use when *shade* is True.
+ axlim_clip : bool, default: False
+ Whether to hide patches with a vertex outside the axes view limits.
+
+ .. versionadded:: 3.10
**kwargs
All other keyword arguments are passed on to
:class:`~mpl_toolkits.mplot3d.art3d.Poly3DCollection`
@@ -2312,7 +2546,8 @@ def plot_trisurf(self, *args, color=None, norm=None, vmin=None, vmax=None,
verts = np.stack((xt, yt, zt), axis=-1)
if cmap:
- polyc = art3d.Poly3DCollection(verts, *args, **kwargs)
+ polyc = art3d.Poly3DCollection(verts, *args,
+ axlim_clip=axlim_clip, **kwargs)
# average over the three points of each triangle
avg_z = verts[:, :, 2].mean(axis=1)
polyc.set_array(avg_z)
@@ -2323,9 +2558,9 @@ def plot_trisurf(self, *args, color=None, norm=None, vmin=None, vmax=None,
else:
polyc = art3d.Poly3DCollection(
verts, *args, shade=shade, lightsource=lightsource,
- facecolors=color, **kwargs)
+ facecolors=color, axlim_clip=axlim_clip, **kwargs)
- self.add_collection(polyc)
+ self.add_collection(polyc, autolim="_datalim_only")
self.auto_scale_xyz(tri.x, tri.y, z, had_data)
return polyc
@@ -2359,18 +2594,21 @@ def _3d_extend_contour(self, cset, stride=5):
cset.remove()
def add_contour_set(
- self, cset, extend3d=False, stride=5, zdir='z', offset=None):
+ self, cset, extend3d=False, stride=5, zdir='z', offset=None,
+ axlim_clip=False):
zdir = '-' + zdir
if extend3d:
self._3d_extend_contour(cset, stride)
else:
art3d.collection_2d_to_3d(
- cset, zs=offset if offset is not None else cset.levels, zdir=zdir)
+ cset, zs=offset if offset is not None else cset.levels, zdir=zdir,
+ axlim_clip=axlim_clip)
- def add_contourf_set(self, cset, zdir='z', offset=None):
- self._add_contourf_set(cset, zdir=zdir, offset=offset)
+ def add_contourf_set(self, cset, zdir='z', offset=None, *, axlim_clip=False):
+ self._add_contourf_set(cset, zdir=zdir, offset=offset,
+ axlim_clip=axlim_clip)
- def _add_contourf_set(self, cset, zdir='z', offset=None):
+ def _add_contourf_set(self, cset, zdir='z', offset=None, axlim_clip=False):
"""
Returns
-------
@@ -2389,12 +2627,14 @@ def _add_contourf_set(self, cset, zdir='z', offset=None):
midpoints = np.append(midpoints, max_level)
art3d.collection_2d_to_3d(
- cset, zs=offset if offset is not None else midpoints, zdir=zdir)
+ cset, zs=offset if offset is not None else midpoints, zdir=zdir,
+ axlim_clip=axlim_clip)
return midpoints
@_preprocess_data()
def contour(self, X, Y, Z, *args,
- extend3d=False, stride=5, zdir='z', offset=None, **kwargs):
+ extend3d=False, stride=5, zdir='z', offset=None, axlim_clip=False,
+ **kwargs):
"""
Create a 3D contour plot.
@@ -2411,6 +2651,10 @@ def contour(self, X, Y, Z, *args,
offset : float, optional
If specified, plot a projection of the contour lines at this
position in a plane normal to *zdir*.
+ axlim_clip : bool, default: False
+ Whether to hide lines with a vertex outside the axes view limits.
+
+ .. versionadded:: 3.10
data : indexable object, optional
DATA_PARAMETER_PLACEHOLDER
@@ -2425,7 +2669,7 @@ def contour(self, X, Y, Z, *args,
jX, jY, jZ = art3d.rotate_axes(X, Y, Z, zdir)
cset = super().contour(jX, jY, jZ, *args, **kwargs)
- self.add_contour_set(cset, extend3d, stride, zdir, offset)
+ self.add_contour_set(cset, extend3d, stride, zdir, offset, axlim_clip)
self.auto_scale_xyz(X, Y, Z, had_data)
return cset
@@ -2434,7 +2678,8 @@ def contour(self, X, Y, Z, *args,
@_preprocess_data()
def tricontour(self, *args,
- extend3d=False, stride=5, zdir='z', offset=None, **kwargs):
+ extend3d=False, stride=5, zdir='z', offset=None, axlim_clip=False,
+ **kwargs):
"""
Create a 3D contour plot.
@@ -2455,6 +2700,10 @@ def tricontour(self, *args,
offset : float, optional
If specified, plot a projection of the contour lines at this
position in a plane normal to *zdir*.
+ axlim_clip : bool, default: False
+ Whether to hide lines with a vertex outside the axes view limits.
+
+ .. versionadded:: 3.10
data : indexable object, optional
DATA_PARAMETER_PLACEHOLDER
*args, **kwargs
@@ -2480,7 +2729,7 @@ def tricontour(self, *args,
tri = Triangulation(jX, jY, tri.triangles, tri.mask)
cset = super().tricontour(tri, jZ, *args, **kwargs)
- self.add_contour_set(cset, extend3d, stride, zdir, offset)
+ self.add_contour_set(cset, extend3d, stride, zdir, offset, axlim_clip)
self.auto_scale_xyz(X, Y, Z, had_data)
return cset
@@ -2496,7 +2745,8 @@ def _auto_scale_contourf(self, X, Y, Z, zdir, levels, had_data):
self.auto_scale_xyz(*limits, had_data)
@_preprocess_data()
- def contourf(self, X, Y, Z, *args, zdir='z', offset=None, **kwargs):
+ def contourf(self, X, Y, Z, *args,
+ zdir='z', offset=None, axlim_clip=False, **kwargs):
"""
Create a 3D filled contour plot.
@@ -2509,6 +2759,10 @@ def contourf(self, X, Y, Z, *args, zdir='z', offset=None, **kwargs):
offset : float, optional
If specified, plot a projection of the contour lines at this
position in a plane normal to *zdir*.
+ axlim_clip : bool, default: False
+ Whether to hide lines with a vertex outside the axes view limits.
+
+ .. versionadded:: 3.10
data : indexable object, optional
DATA_PARAMETER_PLACEHOLDER
*args, **kwargs
@@ -2522,7 +2776,7 @@ def contourf(self, X, Y, Z, *args, zdir='z', offset=None, **kwargs):
jX, jY, jZ = art3d.rotate_axes(X, Y, Z, zdir)
cset = super().contourf(jX, jY, jZ, *args, **kwargs)
- levels = self._add_contourf_set(cset, zdir, offset)
+ levels = self._add_contourf_set(cset, zdir, offset, axlim_clip)
self._auto_scale_contourf(X, Y, Z, zdir, levels, had_data)
return cset
@@ -2530,7 +2784,7 @@ def contourf(self, X, Y, Z, *args, zdir='z', offset=None, **kwargs):
contourf3D = contourf
@_preprocess_data()
- def tricontourf(self, *args, zdir='z', offset=None, **kwargs):
+ def tricontourf(self, *args, zdir='z', offset=None, axlim_clip=False, **kwargs):
"""
Create a 3D filled contour plot.
@@ -2547,6 +2801,10 @@ def tricontourf(self, *args, zdir='z', offset=None, **kwargs):
offset : float, optional
If specified, plot a projection of the contour lines at this
position in a plane normal to zdir.
+ axlim_clip : bool, default: False
+ Whether to hide lines with a vertex outside the axes view limits.
+
+ .. versionadded:: 3.10
data : indexable object, optional
DATA_PARAMETER_PLACEHOLDER
*args, **kwargs
@@ -2573,12 +2831,13 @@ def tricontourf(self, *args, zdir='z', offset=None, **kwargs):
tri = Triangulation(jX, jY, tri.triangles, tri.mask)
cset = super().tricontourf(tri, jZ, *args, **kwargs)
- levels = self._add_contourf_set(cset, zdir, offset)
+ levels = self._add_contourf_set(cset, zdir, offset, axlim_clip)
self._auto_scale_contourf(X, Y, Z, zdir, levels, had_data)
return cset
- def add_collection3d(self, col, zs=0, zdir='z', autolim=True):
+ def add_collection3d(self, col, zs=0, zdir='z', autolim=True, *,
+ axlim_clip=False):
"""
Add a 3D collection object to the plot.
@@ -2602,6 +2861,10 @@ def add_collection3d(self, col, zs=0, zdir='z', autolim=True):
The direction to use for the z-positions.
autolim : bool, default: True
Whether to update the data limits.
+ axlim_clip : bool, default: False
+ Whether to hide the scatter points outside the axes view limits.
+
+ .. versionadded:: 3.10
"""
had_data = self.has_data()
@@ -2613,13 +2876,16 @@ def add_collection3d(self, col, zs=0, zdir='z', autolim=True):
# object would also pass.) Maybe have a collection3d
# abstract class to test for and exclude?
if type(col) is mcoll.PolyCollection:
- art3d.poly_collection_2d_to_3d(col, zs=zs, zdir=zdir)
+ art3d.poly_collection_2d_to_3d(col, zs=zs, zdir=zdir,
+ axlim_clip=axlim_clip)
col.set_sort_zpos(zsortval)
elif type(col) is mcoll.LineCollection:
- art3d.line_collection_2d_to_3d(col, zs=zs, zdir=zdir)
+ art3d.line_collection_2d_to_3d(col, zs=zs, zdir=zdir,
+ axlim_clip=axlim_clip)
col.set_sort_zpos(zsortval)
elif type(col) is mcoll.PatchCollection:
- art3d.patch_collection_2d_to_3d(col, zs=zs, zdir=zdir)
+ art3d.patch_collection_2d_to_3d(col, zs=zs, zdir=zdir,
+ axlim_clip=axlim_clip)
col.set_sort_zpos(zsortval)
if autolim:
@@ -2627,21 +2893,26 @@ def add_collection3d(self, col, zs=0, zdir='z', autolim=True):
self.auto_scale_xyz(*np.array(col._segments3d).transpose(),
had_data=had_data)
elif isinstance(col, art3d.Poly3DCollection):
- self.auto_scale_xyz(*col._vec[:-1], had_data=had_data)
+ self.auto_scale_xyz(col._faces[..., 0],
+ col._faces[..., 1],
+ col._faces[..., 2], had_data=had_data)
elif isinstance(col, art3d.Patch3DCollection):
pass
# FIXME: Implement auto-scaling function for Patch3DCollection
# Currently unable to do so due to issues with Patch3DCollection
# See https://github.com/matplotlib/matplotlib/issues/14298 for details
- collection = super().add_collection(col)
+ collection = super().add_collection(col, autolim="_datalim_only")
return collection
@_preprocess_data(replace_names=["xs", "ys", "zs", "s",
"edgecolors", "c", "facecolor",
"facecolors", "color"])
- def scatter(self, xs, ys, zs=0, zdir='z', s=20, c=None, depthshade=True,
- *args, **kwargs):
+ def scatter(self, xs, ys, zs=0, zdir='z', s=20, c=None, depthshade=None,
+ *args,
+ depthshade_minalpha=None,
+ axlim_clip=False,
+ **kwargs):
"""
Create a scatter plot.
@@ -2673,12 +2944,24 @@ def scatter(self, xs, ys, zs=0, zdir='z', s=20, c=None, depthshade=True,
- A 2D array in which the rows are RGB or RGBA.
For more details see the *c* argument of `~.axes.Axes.scatter`.
- depthshade : bool, default: True
+ depthshade : bool, default: :rc:`axes3d.depthshade`
Whether to shade the scatter markers to give the appearance of
depth. Each call to ``scatter()`` will perform its depthshading
independently.
+
+ depthshade_minalpha : float, default: :rc:`axes3d.depthshade_minalpha`
+ The lowest alpha value applied by depth-shading.
+
+ .. versionadded:: 3.11
+
+ axlim_clip : bool, default: False
+ Whether to hide the scatter points outside the axes view limits.
+
+ .. versionadded:: 3.10
+
data : indexable object, optional
DATA_PARAMETER_PLACEHOLDER
+
**kwargs
All other keyword arguments are passed on to `~.axes.Axes.scatter`.
@@ -2698,15 +2981,24 @@ def scatter(self, xs, ys, zs=0, zdir='z', s=20, c=None, depthshade=True,
)
if kwargs.get("color") is not None:
kwargs['color'] = color
+ if depthshade is None:
+ depthshade = mpl.rcParams['axes3d.depthshade']
+ if depthshade_minalpha is None:
+ depthshade_minalpha = mpl.rcParams['axes3d.depthshade_minalpha']
# For xs and ys, 2D scatter() will do the copying.
if np.may_share_memory(zs_orig, zs): # Avoid unnecessary copies.
zs = zs.copy()
patches = super().scatter(xs, ys, s=s, c=c, *args, **kwargs)
- art3d.patch_collection_2d_to_3d(patches, zs=zs, zdir=zdir,
- depthshade=depthshade)
-
+ art3d.patch_collection_2d_to_3d(
+ patches,
+ zs=zs,
+ zdir=zdir,
+ depthshade=depthshade,
+ depthshade_minalpha=depthshade_minalpha,
+ axlim_clip=axlim_clip,
+ )
if self._zmargin < 0.05 and xs.size > 0:
self.set_zmargin(0.05)
@@ -2717,7 +3009,8 @@ def scatter(self, xs, ys, zs=0, zdir='z', s=20, c=None, depthshade=True,
scatter3D = scatter
@_preprocess_data()
- def bar(self, left, height, zs=0, zdir='z', *args, **kwargs):
+ def bar(self, left, height, zs=0, zdir='z', *args,
+ axlim_clip=False, **kwargs):
"""
Add 2D bar(s).
@@ -2732,6 +3025,10 @@ def bar(self, left, height, zs=0, zdir='z', *args, **kwargs):
used for all bars.
zdir : {'x', 'y', 'z'}, default: 'z'
When plotting 2D data, the direction to use as z ('x', 'y' or 'z').
+ axlim_clip : bool, default: False
+ Whether to hide bars with points outside the axes view limits.
+
+ .. versionadded:: 3.10
data : indexable object, optional
DATA_PARAMETER_PLACEHOLDER
**kwargs
@@ -2754,7 +3051,7 @@ def bar(self, left, height, zs=0, zdir='z', *args, **kwargs):
vs = art3d._get_patch_verts(p)
verts += vs.tolist()
verts_zs += [z] * len(vs)
- art3d.patch_2d_to_3d(p, z, zdir)
+ art3d.patch_2d_to_3d(p, z, zdir, axlim_clip)
if 'alpha' in kwargs:
p.set_alpha(kwargs['alpha'])
@@ -2773,7 +3070,8 @@ def bar(self, left, height, zs=0, zdir='z', *args, **kwargs):
@_preprocess_data()
def bar3d(self, x, y, z, dx, dy, dz, color=None,
- zsort='average', shade=True, lightsource=None, *args, **kwargs):
+ zsort='average', shade=True, lightsource=None, *args,
+ axlim_clip=False, **kwargs):
"""
Generate a 3D barplot.
@@ -2820,6 +3118,11 @@ def bar3d(self, x, y, z, dx, dy, dz, color=None,
lightsource : `~matplotlib.colors.LightSource`, optional
The lightsource to use when *shade* is True.
+ axlim_clip : bool, default: False
+ Whether to hide the bars with points outside the axes view limits.
+
+ .. versionadded:: 3.10
+
data : indexable object, optional
DATA_PARAMETER_PLACEHOLDER
@@ -2925,8 +3228,9 @@ def bar3d(self, x, y, z, dx, dy, dz, color=None,
facecolors=facecolors,
shade=shade,
lightsource=lightsource,
+ axlim_clip=axlim_clip,
*args, **kwargs)
- self.add_collection(col)
+ self.add_collection(col, autolim="_datalim_only")
self.auto_scale_xyz((minx, maxx), (miny, maxy), (minz, maxz), had_data)
@@ -2942,7 +3246,7 @@ def set_title(self, label, fontdict=None, loc='center', **kwargs):
@_preprocess_data()
def quiver(self, X, Y, Z, U, V, W, *,
length=1, arrow_length_ratio=.3, pivot='tail', normalize=False,
- **kwargs):
+ axlim_clip=False, **kwargs):
"""
Plot a 3D field of arrows.
@@ -2974,6 +3278,11 @@ def quiver(self, X, Y, Z, U, V, W, *,
Whether all arrows are normalized to have the same length, or keep
the lengths defined by *u*, *v*, and *w*.
+ axlim_clip : bool, default: False
+ Whether to hide arrows with points outside the axes view limits.
+
+ .. versionadded:: 3.10
+
data : indexable object, optional
DATA_PARAMETER_PLACEHOLDER
@@ -3018,7 +3327,7 @@ def calc_arrows(UVW):
if any(len(v) == 0 for v in input_args):
# No quivers, so just make an empty collection and return early
linec = art3d.Line3DCollection([], **kwargs)
- self.add_collection(linec)
+ self.add_collection(linec, autolim="_datalim_only")
return linec
shaft_dt = np.array([0., length], dtype=float)
@@ -3055,8 +3364,8 @@ def calc_arrows(UVW):
else:
lines = []
- linec = art3d.Line3DCollection(lines, **kwargs)
- self.add_collection(linec)
+ linec = art3d.Line3DCollection(lines, axlim_clip=axlim_clip, **kwargs)
+ self.add_collection(linec, autolim="_datalim_only")
self.auto_scale_xyz(XYZ[:, 0], XYZ[:, 1], XYZ[:, 2], had_data)
@@ -3065,7 +3374,7 @@ def calc_arrows(UVW):
quiver3D = quiver
def voxels(self, *args, facecolors=None, edgecolors=None, shade=True,
- lightsource=None, **kwargs):
+ lightsource=None, axlim_clip=False, **kwargs):
"""
ax.voxels([x, y, z,] /, filled, facecolors=None, edgecolors=None, \
**kwargs)
@@ -3112,6 +3421,11 @@ def voxels(self, *args, facecolors=None, edgecolors=None, shade=True,
lightsource : `~matplotlib.colors.LightSource`, optional
The lightsource to use when *shade* is True.
+ axlim_clip : bool, default: False
+ Whether to hide voxels with points outside the axes view limits.
+
+ .. versionadded:: 3.10
+
**kwargs
Additional keyword arguments to pass onto
`~mpl_toolkits.mplot3d.art3d.Poly3DCollection`.
@@ -3225,7 +3539,7 @@ def permutation_matrices(n):
voxel_faces[i0].append(p0 + square_rot_neg)
# draw middle faces
- for r1, r2 in zip(rinds[:-1], rinds[1:]):
+ for r1, r2 in itertools.pairwise(rinds):
p1 = permute.dot([p, q, r1])
p2 = permute.dot([p, q, r2])
@@ -3267,7 +3581,8 @@ def permutation_matrices(n):
poly = art3d.Poly3DCollection(
faces, facecolors=facecolor, edgecolors=edgecolor,
- shade=shade, lightsource=lightsource, **kwargs)
+ shade=shade, lightsource=lightsource, axlim_clip=axlim_clip,
+ **kwargs)
self.add_collection3d(poly)
polygons[coord] = poly
@@ -3278,6 +3593,7 @@ def errorbar(self, x, y, z, zerr=None, yerr=None, xerr=None, fmt='',
barsabove=False, errorevery=1, ecolor=None, elinewidth=None,
capsize=None, capthick=None, xlolims=False, xuplims=False,
ylolims=False, yuplims=False, zlolims=False, zuplims=False,
+ axlim_clip=False,
**kwargs):
"""
Plot lines and/or markers with errorbars around them.
@@ -3310,12 +3626,12 @@ def errorbar(self, x, y, z, zerr=None, yerr=None, xerr=None, fmt='',
Use 'none' (case-insensitive) to plot errorbars without any data
markers.
- ecolor : :mpltype:`color`, default: None
- The color of the errorbar lines. If None, use the color of the
+ ecolor : :mpltype:`color`, optional
+ The color of the errorbar lines. If not given, use the color of the
line connecting the markers.
- elinewidth : float, default: None
- The linewidth of the errorbar lines. If None, the linewidth of
+ elinewidth : float, optional
+ The linewidth of the errorbar lines. If not given, the linewidth of
the current style is used.
capsize : float, default: :rc:`errorbar.capsize`
@@ -3355,6 +3671,11 @@ def errorbar(self, x, y, z, zerr=None, yerr=None, xerr=None, fmt='',
Used to avoid overlapping error bars when two series share x-axis
values.
+ axlim_clip : bool, default: False
+ Whether to hide error bars that are outside the axes limits.
+
+ .. versionadded:: 3.10
+
Returns
-------
errlines : list
@@ -3410,7 +3731,7 @@ def errorbar(self, x, y, z, zerr=None, yerr=None, xerr=None, fmt='',
# data processing.
(data_line, base_style), = self._get_lines._plot_args(
self, (x, y) if fmt == '' else (x, y, fmt), kwargs, return_kwargs=True)
- art3d.line_2d_to_3d(data_line, zs=z)
+ art3d.line_2d_to_3d(data_line, zs=z, axlim_clip=axlim_clip)
# Do this after creating `data_line` to avoid modifying `base_style`.
if barsabove:
@@ -3496,7 +3817,7 @@ def _extract_errs(err, data, lomask, himask):
# them directly in planar form.
quiversize = eb_cap_style.get('markersize',
mpl.rcParams['lines.markersize']) ** 2
- quiversize *= self.figure.dpi / 72
+ quiversize *= self.get_figure(root=True).dpi / 72
quiversize = self.transAxes.inverted().transform([
(0, 0), (quiversize, quiversize)])
quiversize = np.mean(np.diff(quiversize, axis=0))
@@ -3554,9 +3875,11 @@ def _extract_errs(err, data, lomask, himask):
# these markers will rotate as the viewing angle changes
cap_lo = art3d.Line3D(*lo_caps_xyz, ls='',
marker=capmarker[i_zdir],
+ axlim_clip=axlim_clip,
**eb_cap_style)
cap_hi = art3d.Line3D(*hi_caps_xyz, ls='',
marker=capmarker[i_zdir],
+ axlim_clip=axlim_clip,
**eb_cap_style)
self.add_line(cap_lo)
self.add_line(cap_hi)
@@ -3571,8 +3894,9 @@ def _extract_errs(err, data, lomask, himask):
self.quiver(xl0, yl0, zl0, *-dir_vector, **eb_quiver_style)
errline = art3d.Line3DCollection(np.array(coorderr).T,
+ axlim_clip=axlim_clip,
**eb_lines_style)
- self.add_collection(errline)
+ self.add_collection(errline, autolim="_datalim_only")
errlines.append(errline)
coorderrs.append(coorderr)
@@ -3597,9 +3921,8 @@ def _digout_minmax(err_arr, coord_label):
return errlines, caplines, limmarks
- @_api.make_keyword_only("3.8", "call_axes_locator")
- def get_tightbbox(self, renderer=None, call_axes_locator=True,
- bbox_extra_artists=None, *, for_layout_only=False):
+ def get_tightbbox(self, renderer=None, *, call_axes_locator=True,
+ bbox_extra_artists=None, for_layout_only=False):
ret = super().get_tightbbox(renderer,
call_axes_locator=call_axes_locator,
bbox_extra_artists=bbox_extra_artists,
@@ -3616,7 +3939,7 @@ def get_tightbbox(self, renderer=None, call_axes_locator=True,
@_preprocess_data()
def stem(self, x, y, z, *, linefmt='C0-', markerfmt='C0o', basefmt='C3-',
- bottom=0, label=None, orientation='z'):
+ bottom=0, label=None, orientation='z', axlim_clip=False):
"""
Create a 3D stem plot.
@@ -3666,6 +3989,11 @@ def stem(self, x, y, z, *, linefmt='C0-', markerfmt='C0o', basefmt='C3-',
orientation : {'x', 'y', 'z'}, default: 'z'
The direction along which stems are drawn.
+ axlim_clip : bool, default: False
+ Whether to hide stems that are outside the axes limits.
+
+ .. versionadded:: 3.10
+
data : indexable object, optional
DATA_PARAMETER_PLACEHOLDER
@@ -3710,15 +4038,15 @@ def stem(self, x, y, z, *, linefmt='C0-', markerfmt='C0o', basefmt='C3-',
# Determine style for stem lines.
linestyle, linemarker, linecolor = _process_plot_format(linefmt)
- if linestyle is None:
- linestyle = mpl.rcParams['lines.linestyle']
+ linestyle = mpl._val_or_rc(linestyle, 'lines.linestyle')
# Plot everything in required order.
baseline, = self.plot(basex, basey, basefmt, zs=bottom,
zdir=orientation, label='_nolegend_')
stemlines = art3d.Line3DCollection(
- lines, linestyles=linestyle, colors=linecolor, label='_nolegend_')
- self.add_collection(stemlines)
+ lines, linestyles=linestyle, colors=linecolor, label='_nolegend_',
+ axlim_clip=axlim_clip)
+ self.add_collection(stemlines, autolim="_datalim_only")
markerline, = self.plot(x, y, z, markerfmt, label='_nolegend_')
stem_container = StemContainer((markerline, stemlines, baseline),
@@ -3748,3 +4076,124 @@ def get_test_data(delta=0.05):
Y = Y * 10
Z = Z * 500
return X, Y, Z
+
+
+class _Quaternion:
+ """
+ Quaternions
+ consisting of scalar, along 1, and vector, with components along i, j, k
+ """
+
+ def __init__(self, scalar, vector):
+ self.scalar = scalar
+ self.vector = np.array(vector)
+
+ def __neg__(self):
+ return self.__class__(-self.scalar, -self.vector)
+
+ def __mul__(self, other):
+ """
+ Product of two quaternions
+ i*i = j*j = k*k = i*j*k = -1
+ Quaternion multiplication can be expressed concisely
+ using scalar and vector parts,
+ see
+ """
+ return self.__class__(
+ self.scalar*other.scalar - np.dot(self.vector, other.vector),
+ self.scalar*other.vector + self.vector*other.scalar
+ + np.cross(self.vector, other.vector))
+
+ def conjugate(self):
+ """The conjugate quaternion -(1/2)*(q+i*q*i+j*q*j+k*q*k)"""
+ return self.__class__(self.scalar, -self.vector)
+
+ @property
+ def norm(self):
+ """The 2-norm, q*q', a scalar"""
+ return self.scalar*self.scalar + np.dot(self.vector, self.vector)
+
+ def normalize(self):
+ """Scaling such that norm equals 1"""
+ n = np.sqrt(self.norm)
+ return self.__class__(self.scalar/n, self.vector/n)
+
+ def reciprocal(self):
+ """The reciprocal, 1/q = q'/(q*q') = q' / norm(q)"""
+ n = self.norm
+ return self.__class__(self.scalar/n, -self.vector/n)
+
+ def __div__(self, other):
+ return self*other.reciprocal()
+
+ __truediv__ = __div__
+
+ def rotate(self, v):
+ # Rotate the vector v by the quaternion q, i.e.,
+ # calculate (the vector part of) q*v/q
+ v = self.__class__(0, v)
+ v = self*v/self
+ return v.vector
+
+ def __eq__(self, other):
+ return (self.scalar == other.scalar) and (self.vector == other.vector).all
+
+ def __repr__(self):
+ return "_Quaternion({}, {})".format(repr(self.scalar), repr(self.vector))
+
+ @classmethod
+ def rotate_from_to(cls, r1, r2):
+ """
+ The quaternion for the shortest rotation from vector r1 to vector r2
+ i.e., q = sqrt(r2*r1'), normalized.
+ If r1 and r2 are antiparallel, then the result is ambiguous;
+ a normal vector will be returned, and a warning will be issued.
+ """
+ k = np.cross(r1, r2)
+ nk = np.linalg.norm(k)
+ th = np.arctan2(nk, np.dot(r1, r2))
+ th /= 2
+ if nk == 0: # r1 and r2 are parallel or anti-parallel
+ if np.dot(r1, r2) < 0:
+ warnings.warn("Rotation defined by anti-parallel vectors is ambiguous")
+ k = np.zeros(3)
+ k[np.argmin(r1*r1)] = 1 # basis vector most perpendicular to r1-r2
+ k = np.cross(r1, k)
+ k = k / np.linalg.norm(k) # unit vector normal to r1-r2
+ q = cls(0, k)
+ else:
+ q = cls(1, [0, 0, 0]) # = 1, no rotation
+ else:
+ q = cls(np.cos(th), k*np.sin(th)/nk)
+ return q
+
+ @classmethod
+ def from_cardan_angles(cls, elev, azim, roll):
+ """
+ Converts the angles to a quaternion
+ q = exp((roll/2)*e_x)*exp((elev/2)*e_y)*exp((-azim/2)*e_z)
+ i.e., the angles are a kind of Tait-Bryan angles, -z,y',x".
+ The angles should be given in radians, not degrees.
+ """
+ ca, sa = np.cos(azim/2), np.sin(azim/2)
+ ce, se = np.cos(elev/2), np.sin(elev/2)
+ cr, sr = np.cos(roll/2), np.sin(roll/2)
+
+ qw = ca*ce*cr + sa*se*sr
+ qx = ca*ce*sr - sa*se*cr
+ qy = ca*se*cr + sa*ce*sr
+ qz = ca*se*sr - sa*ce*cr
+ return cls(qw, [qx, qy, qz])
+
+ def as_cardan_angles(self):
+ """
+ The inverse of `from_cardan_angles()`.
+ Note that the angles returned are in radians, not degrees.
+ The angles are not sensitive to the quaternion's norm().
+ """
+ qw = self.scalar
+ qx, qy, qz = self.vector[..., :]
+ azim = np.arctan2(2*(-qw*qz+qx*qy), qw*qw+qx*qx-qy*qy-qz*qz)
+ elev = np.arcsin(np.clip(2*(qw*qy+qz*qx)/(qw*qw+qx*qx+qy*qy+qz*qz), -1, 1))
+ roll = np.arctan2(2*(qw*qx-qy*qz), qw*qw-qx*qx-qy*qy+qz*qz)
+ return elev, azim, roll
diff --git a/lib/mpl_toolkits/mplot3d/axis3d.py b/lib/mpl_toolkits/mplot3d/axis3d.py
index 79b78657bdb9..4da5031b990c 100644
--- a/lib/mpl_toolkits/mplot3d/axis3d.py
+++ b/lib/mpl_toolkits/mplot3d/axis3d.py
@@ -195,11 +195,6 @@ def set_ticks_position(self, position):
position : {'lower', 'upper', 'both', 'default', 'none'}
The position of the bolded axis lines, ticks, and tick labels.
"""
- if position in ['top', 'bottom']:
- _api.warn_deprecated('3.8', name=f'{position=}',
- obj_type='argument value',
- alternative="'upper' or 'lower'")
- return
_api.check_in_list(['lower', 'upper', 'both', 'default', 'none'],
position=position)
self._tick_position = position
@@ -224,11 +219,6 @@ def set_label_position(self, position):
position : {'lower', 'upper', 'both', 'default', 'none'}
The position of the axis label.
"""
- if position in ['top', 'bottom']:
- _api.warn_deprecated('3.8', name=f'{position=}',
- obj_type='argument value',
- alternative="'upper' or 'lower'")
- return
_api.check_in_list(['lower', 'upper', 'both', 'default', 'none'],
position=position)
self._label_position = position
@@ -586,7 +576,7 @@ def draw(self, renderer):
# Calculate offset distances
# A rough estimate; points are ambiguous since 3D plots rotate
- reltoinches = self.figure.dpi_scale_trans.inverted()
+ reltoinches = self.get_figure(root=False).dpi_scale_trans.inverted()
ax_inches = reltoinches.transform(self.axes.bbox.size)
ax_points_estimate = sum(72. * ax_inches)
deltas_per_point = 48 / ax_points_estimate
diff --git a/lib/mpl_toolkits/mplot3d/proj3d.py b/lib/mpl_toolkits/mplot3d/proj3d.py
index 098a7b6f6667..87c59ae05714 100644
--- a/lib/mpl_toolkits/mplot3d/proj3d.py
+++ b/lib/mpl_toolkits/mplot3d/proj3d.py
@@ -23,18 +23,10 @@ def world_transformation(xmin, xmax,
dy /= ay
dz /= az
- return np.array([[1/dx, 0, 0, -xmin/dx],
- [0, 1/dy, 0, -ymin/dy],
- [0, 0, 1/dz, -zmin/dz],
- [0, 0, 0, 1]])
-
-
-@_api.deprecated("3.8")
-def rotation_about_vector(v, angle):
- """
- Produce a rotation matrix for an angle in radians about a vector.
- """
- return _rotation_about_vector(v, angle)
+ return np.array([[1/dx, 0, 0, -xmin/dx],
+ [ 0, 1/dy, 0, -ymin/dy],
+ [ 0, 0, 1/dz, -zmin/dz],
+ [ 0, 0, 0, 1]])
def _rotation_about_vector(v, angle):
@@ -116,32 +108,6 @@ def _view_transformation_uvw(u, v, w, E):
return M
-@_api.deprecated("3.8")
-def view_transformation(E, R, V, roll):
- """
- Return the view transformation matrix.
-
- Parameters
- ----------
- E : 3-element numpy array
- The coordinates of the eye/camera.
- R : 3-element numpy array
- The coordinates of the center of the view box.
- V : 3-element numpy array
- Unit vector in the direction of the vertical axis.
- roll : float
- The roll angle in radians.
- """
- u, v, w = _view_axes(E, R, V, roll)
- M = _view_transformation_uvw(u, v, w, E)
- return M
-
-
-@_api.deprecated("3.8")
-def persp_transformation(zfront, zback, focal_length):
- return _persp_transformation(zfront, zback, focal_length)
-
-
def _persp_transformation(zfront, zback, focal_length):
e = focal_length
a = 1 # aspect ratio
@@ -154,11 +120,6 @@ def _persp_transformation(zfront, zback, focal_length):
return proj_matrix
-@_api.deprecated("3.8")
-def ortho_transformation(zfront, zback):
- return _ortho_transformation(zfront, zback)
-
-
def _ortho_transformation(zfront, zback):
# note: w component in the resulting vector will be (zback-zfront), not 1
a = -(zfront + zback)
@@ -171,21 +132,53 @@ def _ortho_transformation(zfront, zback):
def _proj_transform_vec(vec, M):
- vecw = np.dot(M, vec)
- w = vecw[3]
- # clip here..
- txs, tys, tzs = vecw[0]/w, vecw[1]/w, vecw[2]/w
- return txs, tys, tzs
-
-
-def _proj_transform_vec_clip(vec, M):
- vecw = np.dot(M, vec)
- w = vecw[3]
- # clip here.
- txs, tys, tzs = vecw[0] / w, vecw[1] / w, vecw[2] / w
- tis = (0 <= vecw[0]) & (vecw[0] <= 1) & (0 <= vecw[1]) & (vecw[1] <= 1)
- if np.any(tis):
- tis = vecw[1] < 1
+ vecw = np.dot(M, vec.data)
+ ts = vecw[0:3]/vecw[3]
+ if np.ma.isMA(vec):
+ ts = np.ma.array(ts, mask=vec.mask)
+ return ts[0], ts[1], ts[2]
+
+
+def _proj_transform_vectors(vecs, M):
+ """
+ Vectorized version of ``_proj_transform_vec``.
+
+ Parameters
+ ----------
+ vecs : ... x 3 np.ndarray
+ Input vectors
+ M : 4 x 4 np.ndarray
+ Projection matrix
+ """
+ vecs_shape = vecs.shape
+ vecs = vecs.reshape(-1, 3).T
+
+ vecs_pad = np.empty((vecs.shape[0] + 1,) + vecs.shape[1:])
+ vecs_pad[:-1] = vecs
+ vecs_pad[-1] = 1
+ product = np.dot(M, vecs_pad)
+ tvecs = product[:3] / product[3]
+
+ return tvecs.T.reshape(vecs_shape)
+
+
+def _proj_transform_vec_clip(vec, M, focal_length):
+ vecw = np.dot(M, vec.data)
+ txs, tys, tzs = vecw[0:3] / vecw[3]
+ if np.isinf(focal_length): # don't clip orthographic projection
+ tis = np.ones(txs.shape, dtype=bool)
+ else:
+ tis = (-1 <= txs) & (txs <= 1) & (-1 <= tys) & (tys <= 1) & (tzs <= 0)
+ if np.ma.isMA(vec[0]):
+ tis = tis & ~vec[0].mask
+ if np.ma.isMA(vec[1]):
+ tis = tis & ~vec[1].mask
+ if np.ma.isMA(vec[2]):
+ tis = tis & ~vec[2].mask
+
+ txs = np.ma.masked_array(txs, ~tis)
+ tys = np.ma.masked_array(tys, ~tis)
+ tzs = np.ma.masked_array(tzs, ~tis)
return txs, tys, tzs, tis
@@ -204,7 +197,10 @@ def inv_transform(xs, ys, zs, invM):
def _vec_pad_ones(xs, ys, zs):
- return np.array([xs, ys, zs, np.ones_like(xs)])
+ if np.ma.isMA(xs) or np.ma.isMA(ys) or np.ma.isMA(zs):
+ return np.ma.array([xs, ys, zs, np.ones_like(xs)])
+ else:
+ return np.array([xs, ys, zs, np.ones_like(xs)])
def proj_transform(xs, ys, zs, M):
@@ -215,45 +211,26 @@ def proj_transform(xs, ys, zs, M):
return _proj_transform_vec(vec, M)
-transform = _api.deprecated(
- "3.8", obj_type="function", name="transform",
- alternative="proj_transform")(proj_transform)
+@_api.deprecated("3.10")
+def proj_transform_clip(xs, ys, zs, M):
+ return _proj_transform_clip(xs, ys, zs, M, focal_length=np.inf)
-def proj_transform_clip(xs, ys, zs, M):
+def _proj_transform_clip(xs, ys, zs, M, focal_length):
"""
Transform the points by the projection matrix
and return the clipping result
returns txs, tys, tzs, tis
"""
vec = _vec_pad_ones(xs, ys, zs)
- return _proj_transform_vec_clip(vec, M)
-
-
-@_api.deprecated("3.8")
-def proj_points(points, M):
- return _proj_points(points, M)
+ return _proj_transform_vec_clip(vec, M, focal_length)
def _proj_points(points, M):
return np.column_stack(_proj_trans_points(points, M))
-@_api.deprecated("3.8")
-def proj_trans_points(points, M):
- return _proj_trans_points(points, M)
-
-
def _proj_trans_points(points, M):
- xs, ys, zs = zip(*points)
+ points = np.asanyarray(points)
+ xs, ys, zs = points[:, 0], points[:, 1], points[:, 2]
return proj_transform(xs, ys, zs, M)
-
-
-@_api.deprecated("3.8")
-def rot_x(V, alpha):
- cosa, sina = np.cos(alpha), np.sin(alpha)
- M1 = np.array([[1, 0, 0, 0],
- [0, cosa, -sina, 0],
- [0, sina, cosa, 0],
- [0, 0, 0, 1]])
- return np.dot(M1, V)
diff --git a/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/fill_between_polygon.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/fill_between_polygon.png
new file mode 100644
index 000000000000..f1f160fe5579
Binary files /dev/null and b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/fill_between_polygon.png differ
diff --git a/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/fill_between_quad.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/fill_between_quad.png
new file mode 100644
index 000000000000..e405bcffb965
Binary files /dev/null and b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/fill_between_quad.png differ
diff --git a/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/quiver3d.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/quiver3d.png
index 5d58cea8bccf..af8cc16b14cc 100644
Binary files a/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/quiver3d.png and b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/quiver3d.png differ
diff --git a/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/scatter3d.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/scatter3d.png
index ed8b3831726e..aa15bb95168c 100644
Binary files a/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/scatter3d.png and b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/scatter3d.png differ
diff --git a/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/scatter3d_color.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/scatter3d_color.png
index 2d35d95e68bd..f295ec7132ba 100644
Binary files a/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/scatter3d_color.png and b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/scatter3d_color.png differ
diff --git a/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/scatter3d_linewidth.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/scatter3d_linewidth.png
index 15cc2d77a2ac..676ee10370f6 100644
Binary files a/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/scatter3d_linewidth.png and b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/scatter3d_linewidth.png differ
diff --git a/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/scatter_spiral.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/scatter_spiral.png
index 8e8df221d640..ee562e27242b 100644
Binary files a/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/scatter_spiral.png and b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/scatter_spiral.png differ
diff --git a/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/surface3d_masked.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/surface3d_masked.png
index df893f9c843f..9e5af36ffbfc 100644
Binary files a/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/surface3d_masked.png and b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/surface3d_masked.png differ
diff --git a/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/wireframe3dasymmetric.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/wireframe3dasymmetric.png
new file mode 100644
index 000000000000..73507bf2f6c1
Binary files /dev/null and b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/wireframe3dasymmetric.png differ
diff --git a/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/wireframe3dzerocstride.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/wireframe3dzerocstride.png
index 0623cad002e8..7e4cf6a0c014 100644
Binary files a/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/wireframe3dzerocstride.png and b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/wireframe3dzerocstride.png differ
diff --git a/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_legend3d/fancy.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_legend3d/fancy.png
index b9b0fb6ef094..62e7dbc6cdae 100644
Binary files a/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_legend3d/fancy.png and b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_legend3d/fancy.png differ
diff --git a/lib/mpl_toolkits/mplot3d/tests/test_art3d.py b/lib/mpl_toolkits/mplot3d/tests/test_art3d.py
index 4ed48aae4685..8ff6050443ab 100644
--- a/lib/mpl_toolkits/mplot3d/tests/test_art3d.py
+++ b/lib/mpl_toolkits/mplot3d/tests/test_art3d.py
@@ -3,7 +3,11 @@
import matplotlib.pyplot as plt
from matplotlib.backend_bases import MouseEvent
-from mpl_toolkits.mplot3d.art3d import Line3DCollection
+from mpl_toolkits.mplot3d.art3d import (
+ Line3DCollection,
+ Poly3DCollection,
+ _all_points_on_plane,
+)
def test_scatter_3d_projection_conservation():
@@ -51,6 +55,48 @@ def test_zordered_error():
fig = plt.figure()
ax = fig.add_subplot(projection="3d")
- ax.add_collection(Line3DCollection(lc))
+ ax.add_collection(Line3DCollection(lc), autolim="_datalim_only")
ax.scatter(*pc, visible=False)
plt.draw()
+
+
+def test_all_points_on_plane():
+ # Non-coplanar points
+ points = np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0], [0, 0, 1]])
+ assert not _all_points_on_plane(*points.T)
+
+ # Duplicate points
+ points = np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0], [0, 0, 0]])
+ assert _all_points_on_plane(*points.T)
+
+ # NaN values
+ points = np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0], [0, 0, np.nan]])
+ assert _all_points_on_plane(*points.T)
+
+ # Less than 3 unique points
+ points = np.array([[0, 0, 0], [0, 0, 0], [0, 0, 0]])
+ assert _all_points_on_plane(*points.T)
+
+ # All points lie on a line
+ points = np.array([[0, 0, 0], [0, 1, 0], [0, 2, 0], [0, 3, 0]])
+ assert _all_points_on_plane(*points.T)
+
+ # All points lie on two lines, with antiparallel vectors
+ points = np.array([[-2, 2, 0], [-1, 1, 0], [1, -1, 0],
+ [0, 0, 0], [2, 0, 0], [1, 0, 0]])
+ assert _all_points_on_plane(*points.T)
+
+ # All points lie on a plane
+ points = np.array([[0, 0, 0], [0, 1, 0], [1, 0, 0], [1, 1, 0], [1, 2, 0]])
+ assert _all_points_on_plane(*points.T)
+
+
+def test_generate_normals():
+ # Smoke test for https://github.com/matplotlib/matplotlib/issues/29156
+ vertices = ((0, 0, 0), (0, 5, 0), (5, 5, 0), (5, 0, 0))
+ shape = Poly3DCollection([vertices], edgecolors='r', shade=True)
+
+ fig = plt.figure()
+ ax = fig.add_subplot(projection='3d')
+ ax.add_collection3d(shape)
+ plt.draw()
diff --git a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py
index ecb51b724c27..e6d11f793b46 100644
--- a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py
+++ b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py
@@ -1,17 +1,18 @@
import functools
import itertools
import platform
+import sys
import pytest
from mpl_toolkits.mplot3d import Axes3D, axes3d, proj3d, art3d
+from mpl_toolkits.mplot3d.axes3d import _Quaternion as Quaternion
import matplotlib as mpl
from matplotlib.backend_bases import (MouseButton, MouseEvent,
NavigationToolbar2)
from matplotlib import cm
from matplotlib import colors as mcolors, patches as mpatch
from matplotlib.testing.decorators import image_comparison, check_figures_equal
-from matplotlib.testing.widgets import mock_event
from matplotlib.collections import LineCollection, PolyCollection
from matplotlib.patches import Circle, PathPatch
from matplotlib.path import Path
@@ -34,7 +35,7 @@ def plot_cuboid(ax, scale):
ax.plot3D(*zip(start*np.array(scale), end*np.array(scale)))
-@check_figures_equal(extensions=["png"])
+@check_figures_equal()
def test_invisible_axes(fig_test, fig_ref):
ax = fig_test.subplots(subplot_kw=dict(projection='3d'))
ax.set_visible(False)
@@ -114,7 +115,7 @@ def test_axes3d_repr():
@mpl3d_image_comparison(['axes3d_primary_views.png'], style='mpl20',
- tol=0.05 if platform.machine() == "arm64" else 0)
+ tol=0.05 if sys.platform == "darwin" else 0)
def test_axes3d_primary_views():
# (elev, azim, roll)
views = [(90, -90, 0), # XY
@@ -219,17 +220,16 @@ def test_bar3d_lightsource():
np.testing.assert_array_max_ulp(color, collection._facecolor3d[1::6], 4)
-@mpl3d_image_comparison(
- ['contour3d.png'], style='mpl20',
- tol=0.002 if platform.machine() in ('aarch64', 'ppc64le', 's390x') else 0)
+@mpl3d_image_comparison(['contour3d.png'], style='mpl20',
+ tol=0 if platform.machine() == 'x86_64' else 0.002)
def test_contour3d():
plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated
fig = plt.figure()
ax = fig.add_subplot(projection='3d')
X, Y, Z = axes3d.get_test_data(0.05)
- ax.contour(X, Y, Z, zdir='z', offset=-100, cmap=cm.coolwarm)
- ax.contour(X, Y, Z, zdir='x', offset=-40, cmap=cm.coolwarm)
- ax.contour(X, Y, Z, zdir='y', offset=40, cmap=cm.coolwarm)
+ ax.contour(X, Y, Z, zdir='z', offset=-100, cmap="coolwarm")
+ ax.contour(X, Y, Z, zdir='x', offset=-40, cmap="coolwarm")
+ ax.contour(X, Y, Z, zdir='y', offset=40, cmap="coolwarm")
ax.axis(xmin=-40, xmax=40, ymin=-40, ymax=40, zmin=-100, zmax=100)
@@ -239,7 +239,7 @@ def test_contour3d_extend3d():
fig = plt.figure()
ax = fig.add_subplot(projection='3d')
X, Y, Z = axes3d.get_test_data(0.05)
- ax.contour(X, Y, Z, zdir='z', offset=-100, cmap=cm.coolwarm, extend3d=True)
+ ax.contour(X, Y, Z, zdir='z', offset=-100, cmap="coolwarm", extend3d=True)
ax.set_xlim(-30, 30)
ax.set_ylim(-20, 40)
ax.set_zlim(-80, 80)
@@ -251,9 +251,9 @@ def test_contourf3d():
fig = plt.figure()
ax = fig.add_subplot(projection='3d')
X, Y, Z = axes3d.get_test_data(0.05)
- ax.contourf(X, Y, Z, zdir='z', offset=-100, cmap=cm.coolwarm)
- ax.contourf(X, Y, Z, zdir='x', offset=-40, cmap=cm.coolwarm)
- ax.contourf(X, Y, Z, zdir='y', offset=40, cmap=cm.coolwarm)
+ ax.contourf(X, Y, Z, zdir='z', offset=-100, cmap="coolwarm")
+ ax.contourf(X, Y, Z, zdir='x', offset=-40, cmap="coolwarm")
+ ax.contourf(X, Y, Z, zdir='y', offset=40, cmap="coolwarm")
ax.set_xlim(-40, 40)
ax.set_ylim(-40, 40)
ax.set_zlim(-100, 100)
@@ -269,7 +269,7 @@ def test_contourf3d_fill():
# This produces holes in the z=0 surface that causes rendering errors if
# the Poly3DCollection is not aware of path code information (issue #4784)
Z[::5, ::5] = 0.1
- ax.contourf(X, Y, Z, offset=0, levels=[-0.1, 0], cmap=cm.coolwarm)
+ ax.contourf(X, Y, Z, offset=0, levels=[-0.1, 0], cmap="coolwarm")
ax.set_xlim(-2, 2)
ax.set_ylim(-2, 2)
ax.set_zlim(-1, 1)
@@ -278,7 +278,7 @@ def test_contourf3d_fill():
@pytest.mark.parametrize('extend, levels', [['both', [2, 4, 6]],
['min', [2, 4, 6, 8]],
['max', [0, 2, 4, 6]]])
-@check_figures_equal(extensions=["png"])
+@check_figures_equal()
def test_contourf3d_extend(fig_test, fig_ref, extend, levels):
X, Y = np.meshgrid(np.arange(-2, 2, 0.25), np.arange(-2, 2, 0.25))
# Z is in the range [0, 8]
@@ -342,7 +342,7 @@ def test_lines3d():
ax.plot(x, y, z)
-@check_figures_equal(extensions=["png"])
+@check_figures_equal()
def test_plot_scalar(fig_test, fig_ref):
ax1 = fig_test.add_subplot(projection='3d')
ax1.plot([1], [1], "o")
@@ -392,7 +392,7 @@ def f(t):
ax.set_zlim3d(-1, 1)
-@check_figures_equal(extensions=['png'])
+@check_figures_equal()
def test_tight_layout_text(fig_test, fig_ref):
# text is currently ignored in tight layout. So the order of text() and
# tight_layout() calls should not influence the result.
@@ -407,7 +407,6 @@ def test_tight_layout_text(fig_test, fig_ref):
@mpl3d_image_comparison(['scatter3d.png'], style='mpl20')
def test_scatter3d():
- plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated
fig = plt.figure()
ax = fig.add_subplot(projection='3d')
ax.scatter(np.arange(10), np.arange(10), np.arange(10),
@@ -421,7 +420,6 @@ def test_scatter3d():
@mpl3d_image_comparison(['scatter3d_color.png'], style='mpl20')
def test_scatter3d_color():
- plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated
fig = plt.figure()
ax = fig.add_subplot(projection='3d')
@@ -446,7 +444,7 @@ def test_scatter3d_linewidth():
marker='o', linewidth=np.arange(10))
-@check_figures_equal(extensions=['png'])
+@check_figures_equal()
def test_scatter3d_linewidth_modification(fig_ref, fig_test):
# Changing Path3DCollection linewidths with array-like post-creation
# should work correctly.
@@ -460,12 +458,12 @@ def test_scatter3d_linewidth_modification(fig_ref, fig_test):
linewidths=np.arange(10))
-@check_figures_equal(extensions=['png'])
+@check_figures_equal()
def test_scatter3d_modification(fig_ref, fig_test):
# Changing Path3DCollection properties post-creation should work correctly.
ax_test = fig_test.add_subplot(projection='3d')
c = ax_test.scatter(np.arange(10), np.arange(10), np.arange(10),
- marker='o')
+ marker='o', depthshade=True)
c.set_facecolor('C1')
c.set_edgecolor('C2')
c.set_alpha([0.3, 0.7] * 5)
@@ -481,13 +479,13 @@ def test_scatter3d_modification(fig_ref, fig_test):
depthshade=False, s=75, linewidths=3)
-@pytest.mark.parametrize('depthshade', [True, False])
-@check_figures_equal(extensions=['png'])
-def test_scatter3d_sorting(fig_ref, fig_test, depthshade):
+@check_figures_equal()
+def test_scatter3d_sorting(fig_ref, fig_test):
"""Test that marker properties are correctly sorted."""
y, x = np.mgrid[:10, :10]
z = np.arange(x.size).reshape(x.shape)
+ depthshade = False
sizes = np.full(z.shape, 25)
sizes[0::2, 0::2] = 100
@@ -507,10 +505,10 @@ def test_scatter3d_sorting(fig_ref, fig_test, depthshade):
linewidths[0::2, 0::2] = 5
linewidths[1::2, 1::2] = 5
- x, y, z, sizes, facecolors, edgecolors, linewidths = [
+ x, y, z, sizes, facecolors, edgecolors, linewidths = (
a.flatten()
for a in [x, y, z, sizes, facecolors, edgecolors, linewidths]
- ]
+ )
ax_ref = fig_ref.add_subplot(projection='3d')
sets = (np.unique(a) for a in [sizes, facecolors, edgecolors, linewidths])
@@ -538,7 +536,7 @@ def test_scatter3d_sorting(fig_ref, fig_test, depthshade):
@pytest.mark.parametrize('azim', [-50, 130]) # yellow first, blue first
-@check_figures_equal(extensions=['png'])
+@check_figures_equal()
def test_marker_draw_order_data_reversed(fig_test, fig_ref, azim):
"""
Test that the draw order does not depend on the data point order.
@@ -558,7 +556,7 @@ def test_marker_draw_order_data_reversed(fig_test, fig_ref, azim):
ax.view_init(elev=0, azim=azim, roll=0)
-@check_figures_equal(extensions=['png'])
+@check_figures_equal()
def test_marker_draw_order_view_rotated(fig_test, fig_ref):
"""
Test that the draw order changes with the direction.
@@ -592,6 +590,48 @@ def test_plot_3d_from_2d():
ax.plot(xs, ys, zs=0, zdir='y')
+@mpl3d_image_comparison(['fill_between_quad.png'], style='mpl20')
+def test_fill_between_quad():
+ fig = plt.figure()
+ ax = fig.add_subplot(projection='3d')
+
+ theta = np.linspace(0, 2*np.pi, 50)
+
+ x1 = np.cos(theta)
+ y1 = np.sin(theta)
+ z1 = 0.1 * np.sin(6 * theta)
+
+ x2 = 0.6 * np.cos(theta)
+ y2 = 0.6 * np.sin(theta)
+ z2 = 2
+
+ where = (theta < np.pi/2) | (theta > 3*np.pi/2)
+
+ # Since none of x1 == x2, y1 == y2, or z1 == z2 is True, the fill_between
+ # mode will map to 'quad'
+ ax.fill_between(x1, y1, z1, x2, y2, z2,
+ where=where, mode='auto', alpha=0.5, edgecolor='k')
+
+
+@mpl3d_image_comparison(['fill_between_polygon.png'], style='mpl20')
+def test_fill_between_polygon():
+ fig = plt.figure()
+ ax = fig.add_subplot(projection='3d')
+
+ theta = np.linspace(0, 2*np.pi, 50)
+
+ x1 = x2 = theta
+ y1 = y2 = 0
+ z1 = np.cos(theta)
+ z2 = z1 + 1
+
+ where = (theta < np.pi/2) | (theta > 3*np.pi/2)
+
+ # Since x1 == x2 and y1 == y2, the fill_between mode will be 'polygon'
+ ax.fill_between(x1, y1, z1, x2, y2, z2,
+ where=where, mode='auto', edgecolor='k')
+
+
@mpl3d_image_comparison(['surface3d.png'], style='mpl20')
def test_surface3d():
# Remove this line when this test image is regenerated.
@@ -604,7 +644,7 @@ def test_surface3d():
X, Y = np.meshgrid(X, Y)
R = np.hypot(X, Y)
Z = np.sin(R)
- surf = ax.plot_surface(X, Y, Z, rcount=40, ccount=40, cmap=cm.coolwarm,
+ surf = ax.plot_surface(X, Y, Z, rcount=40, ccount=40, cmap="coolwarm",
lw=0, antialiased=False)
plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated
ax.set_zlim(-1.01, 1.01)
@@ -624,8 +664,6 @@ def test_surface3d_label_offset_tick_position():
ax.set_ylabel("Y label")
ax.set_zlabel("Z label")
- ax.figure.canvas.draw()
-
@mpl3d_image_comparison(['surface3d_shaded.png'], style='mpl20')
def test_surface3d_shaded():
@@ -669,7 +707,7 @@ def test_surface3d_masked():
ax.view_init(30, -80, 0)
-@check_figures_equal(extensions=["png"])
+@check_figures_equal()
def test_plot_scatter_masks(fig_test, fig_ref):
x = np.linspace(0, 10, 100)
y = np.linspace(0, 10, 100)
@@ -687,7 +725,7 @@ def test_plot_scatter_masks(fig_test, fig_ref):
ax_ref.plot(x, y, z)
-@check_figures_equal(extensions=["png"])
+@check_figures_equal()
def test_plot_surface_None_arg(fig_test, fig_ref):
x, y = np.meshgrid(np.arange(5), np.arange(5))
z = x + y
@@ -734,7 +772,7 @@ def test_text3d():
ax.set_zlabel('Z axis')
-@check_figures_equal(extensions=['png'])
+@check_figures_equal()
def test_text3d_modification(fig_ref, fig_test):
# Modifying the Text position after the fact should work the same as
# setting it directly.
@@ -774,7 +812,7 @@ def test_trisurf3d():
fig = plt.figure()
ax = fig.add_subplot(projection='3d')
- ax.plot_trisurf(x, y, z, cmap=cm.jet, linewidth=0.2)
+ ax.plot_trisurf(x, y, z, cmap="jet", linewidth=0.2)
@mpl3d_image_comparison(['trisurf3d_shaded.png'], tol=0.03, style='mpl20')
@@ -803,6 +841,14 @@ def test_wireframe3d():
ax.plot_wireframe(X, Y, Z, rcount=13, ccount=13)
+@mpl3d_image_comparison(['wireframe3dasymmetric.png'], style='mpl20')
+def test_wireframe3dasymmetric():
+ fig = plt.figure()
+ ax = fig.add_subplot(projection='3d')
+ X, Y, Z = axes3d.get_test_data(0.05)
+ ax.plot_wireframe(X, Y, Z, rcount=3, ccount=13)
+
+
@mpl3d_image_comparison(['wireframe3dzerocstride.png'], style='mpl20')
def test_wireframe3dzerocstride():
fig = plt.figure()
@@ -840,7 +886,6 @@ def test_mixedsamplesraises():
# remove tolerance when regenerating the test image
@mpl3d_image_comparison(['quiver3d.png'], style='mpl20', tol=0.003)
def test_quiver3d():
- plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated
fig = plt.figure()
ax = fig.add_subplot(projection='3d')
pivots = ['tip', 'middle', 'tail']
@@ -860,7 +905,7 @@ def test_quiver3d():
ax.set_zlim(-1, 5)
-@check_figures_equal(extensions=["png"])
+@check_figures_equal()
def test_quiver3d_empty(fig_test, fig_ref):
fig_ref.add_subplot(projection='3d')
x = y = z = u = v = w = []
@@ -894,7 +939,7 @@ def test_quiver3d_colorcoded():
x = y = dx = dz = np.zeros(10)
z = dy = np.arange(10.)
- color = plt.cm.Reds(dy/dy.max())
+ color = plt.colormaps["Reds"](dy/dy.max())
ax.quiver(x, y, z, dx, dy, dz, colors=color)
ax.set_ylim(0, 10)
@@ -912,13 +957,13 @@ def test_patch_modification():
assert mcolors.same_color(circle.get_facecolor(), (1, 0, 0, 1))
-@check_figures_equal(extensions=['png'])
+@check_figures_equal()
def test_patch_collection_modification(fig_test, fig_ref):
# Test that modifying Patch3DCollection properties after creation works.
patch1 = Circle((0, 0), 0.05)
patch2 = Circle((0.1, 0.1), 0.03)
facecolors = np.array([[0., 0.5, 0., 1.], [0.5, 0., 0., 0.5]])
- c = art3d.Patch3DCollection([patch1, patch2], linewidths=3)
+ c = art3d.Patch3DCollection([patch1, patch2], linewidths=3, depthshade=True)
ax_test = fig_test.add_subplot(projection='3d')
ax_test.add_collection3d(c)
@@ -946,7 +991,7 @@ def test_poly3dcollection_verts_validation():
art3d.Poly3DCollection(poly) # should be Poly3DCollection([poly])
poly = np.array(poly, dtype=float)
- with pytest.raises(ValueError, match=r'list of \(N, 3\) array-like'):
+ with pytest.raises(ValueError, match=r'shape \(M, N, 3\)'):
art3d.Poly3DCollection(poly) # should be Poly3DCollection([poly])
@@ -1172,7 +1217,7 @@ def _test_proj_draw_axes(M, s=1, *args, **kwargs):
fig, ax = plt.subplots(*args, **kwargs)
linec = LineCollection(lines)
- ax.add_collection(linec)
+ ax.add_collection(linec, autolim="_datalim_only")
for x, y, t in zip(txs, tys, ['o', 'x', 'y', 'z']):
ax.text(x, y, t)
@@ -1282,6 +1327,21 @@ def test_unautoscale(axis, auto):
np.testing.assert_array_equal(get_lim(), (-0.5, 0.5))
+@check_figures_equal()
+def test_culling(fig_test, fig_ref):
+ xmins = (-100, -50)
+ for fig, xmin in zip((fig_test, fig_ref), xmins):
+ ax = fig.add_subplot(projection='3d')
+ n = abs(xmin) + 1
+ xs = np.linspace(0, xmin, n)
+ ys = np.ones(n)
+ zs = np.zeros(n)
+ ax.plot(xs, ys, zs, 'k')
+
+ ax.set(xlim=(-5, 5), ylim=(-5, 5), zlim=(-5, 5))
+ ax.view_init(5, 180, 0)
+
+
def test_axes3d_focal_length_checks():
fig = plt.figure()
ax = fig.add_subplot(projection='3d')
@@ -1322,6 +1382,45 @@ def test_axes3d_isometric():
ax.grid(True)
+@check_figures_equal()
+def test_axlim_clip(fig_test, fig_ref):
+ # With axlim clipping
+ ax = fig_test.add_subplot(projection="3d")
+ x = np.linspace(0, 1, 11)
+ y = np.linspace(0, 1, 11)
+ X, Y = np.meshgrid(x, y)
+ Z = X + Y
+ ax.plot_surface(X, Y, Z, facecolor='C1', edgecolors=None,
+ rcount=50, ccount=50, axlim_clip=True)
+ # This ax.plot is to cover the extra surface edge which is not clipped out
+ ax.plot([0.5, 0.5], [0, 1], [0.5, 1.5],
+ color='k', linewidth=3, zorder=5, axlim_clip=True)
+ ax.scatter(X.ravel(), Y.ravel(), Z.ravel() + 1, axlim_clip=True)
+ ax.quiver(X.ravel(), Y.ravel(), Z.ravel() + 2,
+ 0*X.ravel(), 0*Y.ravel(), 0*Z.ravel() + 1,
+ arrow_length_ratio=0, axlim_clip=True)
+ ax.plot(X[0], Y[0], Z[0] + 3, color='C2', axlim_clip=True)
+ ax.text(1.1, 0.5, 4, 'test', axlim_clip=True) # won't be visible
+ ax.set(xlim=(0, 0.5), ylim=(0, 1), zlim=(0, 5))
+
+ # With manual clipping
+ ax = fig_ref.add_subplot(projection="3d")
+ idx = (X <= 0.5)
+ X = X[idx].reshape(11, 6)
+ Y = Y[idx].reshape(11, 6)
+ Z = Z[idx].reshape(11, 6)
+ ax.plot_surface(X, Y, Z, facecolor='C1', edgecolors=None,
+ rcount=50, ccount=50, axlim_clip=False)
+ ax.plot([0.5, 0.5], [0, 1], [0.5, 1.5],
+ color='k', linewidth=3, zorder=5, axlim_clip=False)
+ ax.scatter(X.ravel(), Y.ravel(), Z.ravel() + 1, axlim_clip=False)
+ ax.quiver(X.ravel(), Y.ravel(), Z.ravel() + 2,
+ 0*X.ravel(), 0*Y.ravel(), 0*Z.ravel() + 1,
+ arrow_length_ratio=0, axlim_clip=False)
+ ax.plot(X[0], Y[0], Z[0] + 3, color='C2', axlim_clip=False)
+ ax.set(xlim=(0, 0.5), ylim=(0, 1), zlim=(0, 5))
+
+
@pytest.mark.parametrize('value', [np.inf, np.nan])
@pytest.mark.parametrize(('setter', 'side'), [
('set_xlim3d', 'left'),
@@ -1458,8 +1557,9 @@ def test_calling_conventions(self):
ax.voxels(x, y)
# x, y, z are positional only - this passes them on as attributes of
# Poly3DCollection
- with pytest.raises(AttributeError):
+ with pytest.raises(AttributeError, match="keyword argument 'x'") as exec_info:
ax.voxels(filled=filled, x=x, y=y, z=z)
+ assert exec_info.value.name == 'x'
def test_line3d_set_get_data_3d():
@@ -1480,7 +1580,7 @@ def test_line3d_set_get_data_3d():
np.testing.assert_array_equal((x, y, np.zeros_like(z)), line.get_data_3d())
-@check_figures_equal(extensions=["png"])
+@check_figures_equal()
def test_inverted(fig_test, fig_ref):
# Plot then invert.
ax = fig_test.add_subplot(projection="3d")
@@ -1529,7 +1629,7 @@ def test_ax3d_tickcolour():
assert tick.tick1line._color == 'red'
-@check_figures_equal(extensions=["png"])
+@check_figures_equal()
def test_ticklabel_format(fig_test, fig_ref):
axs = fig_test.subplots(4, 5, subplot_kw={"projection": "3d"})
for ax in axs.flat:
@@ -1569,7 +1669,7 @@ def get_formatters(ax, names):
not mpl.rcParams["axes.formatter.use_mathtext"])
-@check_figures_equal(extensions=["png"])
+@check_figures_equal()
def test_quiver3D_smoke(fig_test, fig_ref):
pivot = "middle"
# Make the grid
@@ -1616,7 +1716,7 @@ def test_errorbar3d_errorevery():
@mpl3d_image_comparison(['errorbar3d.png'], style='mpl20',
- tol=0.02 if platform.machine() == 'arm64' else 0)
+ tol=0 if platform.machine() == 'x86_64' else 0.02)
def test_errorbar3d():
"""Tests limits, color styling, and legend for 3D errorbars."""
fig = plt.figure()
@@ -1632,7 +1732,7 @@ def test_errorbar3d():
ax.legend()
-@image_comparison(['stem3d.png'], style='mpl20', tol=0.008)
+@image_comparison(['stem3d.png'], style='mpl20', tol=0.009)
def test_stem3d():
plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated
fig, axs = plt.subplots(2, 3, figsize=(8, 6),
@@ -1766,7 +1866,7 @@ def test_set_zlim():
ax.set_zlim(top=0, zmax=1)
-@check_figures_equal(extensions=["png"])
+@check_figures_equal()
def test_shared_view(fig_test, fig_ref):
elev, azim, roll = 5, 20, 30
ax1 = fig_test.add_subplot(131, projection="3d")
@@ -1792,29 +1892,168 @@ def test_shared_axes_retick():
assert ax2.get_zlim() == (-0.5, 2.5)
-def test_rotate():
+def test_quaternion():
+ # 1:
+ q1 = Quaternion(1, [0, 0, 0])
+ assert q1.scalar == 1
+ assert (q1.vector == [0, 0, 0]).all
+ # __neg__:
+ assert (-q1).scalar == -1
+ assert ((-q1).vector == [0, 0, 0]).all
+ # i, j, k:
+ qi = Quaternion(0, [1, 0, 0])
+ assert qi.scalar == 0
+ assert (qi.vector == [1, 0, 0]).all
+ qj = Quaternion(0, [0, 1, 0])
+ assert qj.scalar == 0
+ assert (qj.vector == [0, 1, 0]).all
+ qk = Quaternion(0, [0, 0, 1])
+ assert qk.scalar == 0
+ assert (qk.vector == [0, 0, 1]).all
+ # i^2 = j^2 = k^2 = -1:
+ assert qi*qi == -q1
+ assert qj*qj == -q1
+ assert qk*qk == -q1
+ # identity:
+ assert q1*qi == qi
+ assert q1*qj == qj
+ assert q1*qk == qk
+ # i*j=k, j*k=i, k*i=j:
+ assert qi*qj == qk
+ assert qj*qk == qi
+ assert qk*qi == qj
+ assert qj*qi == -qk
+ assert qk*qj == -qi
+ assert qi*qk == -qj
+ # __mul__:
+ assert (Quaternion(2, [3, 4, 5]) * Quaternion(6, [7, 8, 9])
+ == Quaternion(-86, [28, 48, 44]))
+ # conjugate():
+ for q in [q1, qi, qj, qk]:
+ assert q.conjugate().scalar == q.scalar
+ assert (q.conjugate().vector == -q.vector).all
+ assert q.conjugate().conjugate() == q
+ assert ((q*q.conjugate()).vector == 0).all
+ # norm:
+ q0 = Quaternion(0, [0, 0, 0])
+ assert q0.norm == 0
+ assert q1.norm == 1
+ assert qi.norm == 1
+ assert qj.norm == 1
+ assert qk.norm == 1
+ for q in [q0, q1, qi, qj, qk]:
+ assert q.norm == (q*q.conjugate()).scalar
+ # normalize():
+ for q in [
+ Quaternion(2, [0, 0, 0]),
+ Quaternion(0, [3, 0, 0]),
+ Quaternion(0, [0, 4, 0]),
+ Quaternion(0, [0, 0, 5]),
+ Quaternion(6, [7, 8, 9])
+ ]:
+ assert q.normalize().norm == 1
+ # reciprocal():
+ for q in [q1, qi, qj, qk]:
+ assert q*q.reciprocal() == q1
+ assert q.reciprocal()*q == q1
+ # rotate():
+ assert (qi.rotate([1, 2, 3]) == np.array([1, -2, -3])).all
+ # rotate_from_to():
+ for r1, r2, q in [
+ ([1, 0, 0], [0, 1, 0], Quaternion(np.sqrt(1/2), [0, 0, np.sqrt(1/2)])),
+ ([1, 0, 0], [0, 0, 1], Quaternion(np.sqrt(1/2), [0, -np.sqrt(1/2), 0])),
+ ([1, 0, 0], [1, 0, 0], Quaternion(1, [0, 0, 0]))
+ ]:
+ assert Quaternion.rotate_from_to(r1, r2) == q
+ # rotate_from_to(), special case:
+ for r1 in [[1, 0, 0], [0, 1, 0], [0, 0, 1], [1, 1, 1]]:
+ r1 = np.array(r1)
+ with pytest.warns(UserWarning):
+ q = Quaternion.rotate_from_to(r1, -r1)
+ assert np.isclose(q.norm, 1)
+ assert np.dot(q.vector, r1) == 0
+ # from_cardan_angles(), as_cardan_angles():
+ for elev, azim, roll in [(0, 0, 0),
+ (90, 0, 0), (0, 90, 0), (0, 0, 90),
+ (0, 30, 30), (30, 0, 30), (30, 30, 0),
+ (47, 11, -24)]:
+ for mag in [1, 2]:
+ q = Quaternion.from_cardan_angles(
+ np.deg2rad(elev), np.deg2rad(azim), np.deg2rad(roll))
+ assert np.isclose(q.norm, 1)
+ q = Quaternion(mag * q.scalar, mag * q.vector)
+ np.testing.assert_allclose(np.rad2deg(Quaternion.as_cardan_angles(q)),
+ (elev, azim, roll), atol=1e-6)
+
+
+@pytest.mark.parametrize('style',
+ ('azel', 'trackball', 'sphere', 'arcball'))
+def test_rotate(style):
"""Test rotating using the left mouse button."""
- for roll in [0, 30]:
- fig = plt.figure()
- ax = fig.add_subplot(1, 1, 1, projection='3d')
- ax.view_init(0, 0, roll)
- ax.figure.canvas.draw()
-
- # drag mouse horizontally to change azimuth
- dx = 0.1
- dy = 0.2
- ax._button_press(
- mock_event(ax, button=MouseButton.LEFT, xdata=0, ydata=0))
- ax._on_move(
- mock_event(ax, button=MouseButton.LEFT,
- xdata=dx*ax._pseudo_w, ydata=dy*ax._pseudo_h))
- ax.figure.canvas.draw()
- roll_radians = np.deg2rad(ax.roll)
- cs = np.cos(roll_radians)
- sn = np.sin(roll_radians)
- assert ax.elev == (-dy*180*cs + dx*180*sn)
- assert ax.azim == (-dy*180*sn - dx*180*cs)
- assert ax.roll == roll
+ if style == 'azel':
+ s = 0.5
+ else:
+ s = mpl.rcParams['axes3d.trackballsize'] / 2
+ s *= 0.5
+ mpl.rcParams['axes3d.trackballborder'] = 0
+ with mpl.rc_context({'axes3d.mouserotationstyle': style}):
+ for roll, dx, dy in [
+ [0, 1, 0],
+ [30, 1, 0],
+ [0, 0, 1],
+ [30, 0, 1],
+ [0, 0.5, np.sqrt(3)/2],
+ [30, 0.5, np.sqrt(3)/2],
+ [0, 2, 0]]:
+ fig = plt.figure()
+ ax = fig.add_subplot(1, 1, 1, projection='3d')
+ ax.view_init(0, 0, roll)
+ ax.figure.canvas.draw()
+
+ # drag mouse to change orientation
+ MouseEvent._from_ax_coords(
+ "button_press_event", ax, (0, 0), MouseButton.LEFT)._process()
+ MouseEvent._from_ax_coords(
+ "motion_notify_event", ax, (s*dx*ax._pseudo_w, s*dy*ax._pseudo_h),
+ MouseButton.LEFT)._process()
+ ax.figure.canvas.draw()
+
+ c = np.sqrt(3)/2
+ expectations = {
+ ('azel', 0, 1, 0): (0, -45, 0),
+ ('azel', 0, 0, 1): (-45, 0, 0),
+ ('azel', 0, 0.5, c): (-38.971143, -22.5, 0),
+ ('azel', 0, 2, 0): (0, -90, 0),
+ ('azel', 30, 1, 0): (22.5, -38.971143, 30),
+ ('azel', 30, 0, 1): (-38.971143, -22.5, 30),
+ ('azel', 30, 0.5, c): (-22.5, -38.971143, 30),
+
+ ('trackball', 0, 1, 0): (0, -28.64789, 0),
+ ('trackball', 0, 0, 1): (-28.64789, 0, 0),
+ ('trackball', 0, 0.5, c): (-24.531578, -15.277726, 3.340403),
+ ('trackball', 0, 2, 0): (0, -180/np.pi, 0),
+ ('trackball', 30, 1, 0): (13.869588, -25.319385, 26.87008),
+ ('trackball', 30, 0, 1): (-24.531578, -15.277726, 33.340403),
+ ('trackball', 30, 0.5, c): (-13.869588, -25.319385, 33.129920),
+
+ ('sphere', 0, 1, 0): (0, -30, 0),
+ ('sphere', 0, 0, 1): (-30, 0, 0),
+ ('sphere', 0, 0.5, c): (-25.658906, -16.102114, 3.690068),
+ ('sphere', 0, 2, 0): (0, -90, 0),
+ ('sphere', 30, 1, 0): (14.477512, -26.565051, 26.565051),
+ ('sphere', 30, 0, 1): (-25.658906, -16.102114, 33.690068),
+ ('sphere', 30, 0.5, c): (-14.477512, -26.565051, 33.434949),
+
+ ('arcball', 0, 1, 0): (0, -60, 0),
+ ('arcball', 0, 0, 1): (-60, 0, 0),
+ ('arcball', 0, 0.5, c): (-48.590378, -40.893395, 19.106605),
+ ('arcball', 0, 2, 0): (0, 180, 0),
+ ('arcball', 30, 1, 0): (25.658906, -56.309932, 16.102114),
+ ('arcball', 30, 0, 1): (-48.590378, -40.893395, 49.106605),
+ ('arcball', 30, 0.5, c): (-25.658906, -56.309932, 43.897886)}
+ new_elev, new_azim, new_roll = expectations[(style, roll, dx, dy)]
+ np.testing.assert_allclose((ax.elev, ax.azim, ax.roll),
+ (new_elev, new_azim, new_roll), atol=1e-6)
def test_pan():
@@ -1826,19 +2065,20 @@ def convert_lim(dmin, dmax):
range_ = dmax - dmin
return center, range_
- ax = plt.figure().add_subplot(projection='3d')
+ fig = plt.figure()
+ ax = fig.add_subplot(projection='3d')
ax.scatter(0, 0, 0)
- ax.figure.canvas.draw()
+ fig.canvas.draw()
x_center0, x_range0 = convert_lim(*ax.get_xlim3d())
y_center0, y_range0 = convert_lim(*ax.get_ylim3d())
z_center0, z_range0 = convert_lim(*ax.get_zlim3d())
# move mouse diagonally to pan along all axis.
- ax._button_press(
- mock_event(ax, button=MouseButton.MIDDLE, xdata=0, ydata=0))
- ax._on_move(
- mock_event(ax, button=MouseButton.MIDDLE, xdata=1, ydata=1))
+ MouseEvent._from_ax_coords(
+ "button_press_event", ax, (0, 0), MouseButton.MIDDLE)._process()
+ MouseEvent._from_ax_coords(
+ "motion_notify_event", ax, (1, 1), MouseButton.MIDDLE)._process()
x_center, x_range = convert_lim(*ax.get_xlim3d())
y_center, y_range = convert_lim(*ax.get_ylim3d())
@@ -1893,20 +2133,20 @@ def test_toolbar_zoom_pan(tool, button, key, expected):
# Set up the mouse movements
start_event = MouseEvent(
"button_press_event", fig.canvas, *s0, button, key=key)
+ drag_event = MouseEvent(
+ "motion_notify_event", fig.canvas, *s1, button, key=key, buttons={button})
stop_event = MouseEvent(
"button_release_event", fig.canvas, *s1, button, key=key)
tb = NavigationToolbar2(fig.canvas)
if tool == "zoom":
tb.zoom()
- tb.press_zoom(start_event)
- tb.drag_zoom(stop_event)
- tb.release_zoom(stop_event)
else:
tb.pan()
- tb.press_pan(start_event)
- tb.drag_pan(stop_event)
- tb.release_pan(stop_event)
+
+ start_event._process()
+ drag_event._process()
+ stop_event._process()
# Should be close, but won't be exact due to screen integer resolution
xlim, ylim, zlim = expected
@@ -1932,7 +2172,7 @@ def test_toolbar_zoom_pan(tool, button, key, expected):
@mpl.style.context('default')
-@check_figures_equal(extensions=["png"])
+@check_figures_equal()
def test_scalarmap_update(fig_test, fig_ref):
x, y, z = np.array(list(itertools.product(*[np.arange(0, 5, 1),
@@ -1986,9 +2226,9 @@ def test_computed_zorder():
# plot some points
ax.scatter((3, 3), (1, 3), (1, 3), c='red', zorder=10)
- ax.set_xlim((0, 5.0))
- ax.set_ylim((0, 5.0))
- ax.set_zlim((0, 2.5))
+ ax.set_xlim(0, 5.0)
+ ax.set_ylim(0, 5.0)
+ ax.set_zlim(0, 2.5)
ax3 = fig.add_subplot(223, projection='3d')
ax4 = fig.add_subplot(224, projection='3d')
@@ -2132,7 +2372,7 @@ def test_margins_errors(err, args, kwargs, match):
ax.margins(*args, **kwargs)
-@check_figures_equal(extensions=["png"])
+@check_figures_equal()
def test_text_3d(fig_test, fig_ref):
ax = fig_ref.add_subplot(projection="3d")
txt = Text(0.5, 0.5, r'Foo bar $\int$')
@@ -2153,7 +2393,7 @@ def test_draw_single_lines_from_Nx1():
ax.plot([[0], [1]], [[0], [1]], [[0], [1]])
-@check_figures_equal(extensions=["png"])
+@check_figures_equal()
def test_pathpatch_3d(fig_test, fig_ref):
ax = fig_ref.add_subplot(projection="3d")
path = Path.unit_rectangle()
@@ -2283,7 +2523,7 @@ def test_view_init_vertical_axis(
rtol = 2e-06
ax = plt.subplot(1, 1, 1, projection="3d")
ax.view_init(elev=0, azim=0, roll=0, vertical_axis=vertical_axis)
- ax.figure.canvas.draw()
+ ax.get_figure().canvas.draw()
# Assert the projection matrix:
proj_actual = ax.get_proj()
@@ -2309,14 +2549,13 @@ def test_on_move_vertical_axis(vertical_axis: str) -> None:
"""
ax = plt.subplot(1, 1, 1, projection="3d")
ax.view_init(elev=0, azim=0, roll=0, vertical_axis=vertical_axis)
- ax.figure.canvas.draw()
+ ax.get_figure().canvas.draw()
proj_before = ax.get_proj()
- event_click = mock_event(ax, button=MouseButton.LEFT, xdata=0, ydata=1)
- ax._button_press(event_click)
-
- event_move = mock_event(ax, button=MouseButton.LEFT, xdata=0.5, ydata=0.8)
- ax._on_move(event_move)
+ MouseEvent._from_ax_coords(
+ "button_press_event", ax, (0, 1), MouseButton.LEFT)._process()
+ MouseEvent._from_ax_coords(
+ "motion_notify_event", ax, (.5, .8), MouseButton.LEFT)._process()
assert ax._axis_names.index(vertical_axis) == ax._vertical_axis
@@ -2338,7 +2577,7 @@ def test_on_move_vertical_axis(vertical_axis: str) -> None:
def test_set_box_aspect_vertical_axis(vertical_axis, aspect_expected):
ax = plt.subplot(1, 1, 1, projection="3d")
ax.view_init(elev=0, azim=0, roll=0, vertical_axis=vertical_axis)
- ax.figure.canvas.draw()
+ ax.get_figure().canvas.draw()
ax.set_box_aspect(None)
@@ -2367,7 +2606,7 @@ def test_panecolor_rcparams():
fig.add_subplot(projection='3d')
-@check_figures_equal(extensions=["png"])
+@check_figures_equal()
def test_mutating_input_arrays_y_and_z(fig_test, fig_ref):
"""
Test to see if the `z` axis does not get mutated
diff --git a/lib/mpl_toolkits/mplot3d/tests/test_legend3d.py b/lib/mpl_toolkits/mplot3d/tests/test_legend3d.py
index 0935bbe7f6b0..091ae2c3e12f 100644
--- a/lib/mpl_toolkits/mplot3d/tests/test_legend3d.py
+++ b/lib/mpl_toolkits/mplot3d/tests/test_legend3d.py
@@ -28,7 +28,7 @@ def test_legend_bar():
@image_comparison(['fancy.png'], remove_text=True, style='mpl20',
- tol=0.011 if platform.machine() == 'arm64' else 0)
+ tol=0 if platform.machine() == 'x86_64' else 0.011)
def test_fancy():
fig, ax = plt.subplots(subplot_kw=dict(projection='3d'))
ax.plot(np.arange(10), np.full(10, 5), np.full(10, 5), 'o--', label='line')
@@ -47,9 +47,9 @@ def test_linecollection_scaled_dashes():
lc3 = art3d.Line3DCollection(lines3, linestyles=":", lw=.5)
fig, ax = plt.subplots(subplot_kw=dict(projection='3d'))
- ax.add_collection(lc1)
- ax.add_collection(lc2)
- ax.add_collection(lc3)
+ ax.add_collection(lc1, autolim="_datalim_only")
+ ax.add_collection(lc2, autolim="_datalim_only")
+ ax.add_collection(lc3, autolim="_datalim_only")
leg = ax.legend([lc1, lc2, lc3], ['line1', 'line2', 'line 3'])
h1, h2, h3 = leg.legend_handles
diff --git a/meson.build b/meson.build
index c022becfd9d9..54249473fe8e 100644
--- a/meson.build
+++ b/meson.build
@@ -1,7 +1,10 @@
project(
'matplotlib',
'c', 'cpp',
- version: run_command(find_program('python3'), '-m', 'setuptools_scm', check: true).stdout().strip(),
+ version: run_command(
+ # Also keep version in sync with pyproject.toml.
+ find_program('python3', 'python', version: '>= 3.11'),
+ '-m', 'setuptools_scm', check: true).stdout().strip(),
# qt_editor backend is MIT
# ResizeObserver at end of lib/matplotlib/backends/web_backend/js/mpl.js is CC0
# Carlogo, STIX and Computer Modern is OFL
@@ -36,7 +39,7 @@ py_mod = import('python')
py3 = py_mod.find_installation(pure: false)
py3_dep = py3.dependency()
-pybind11_dep = dependency('pybind11', version: '>=2.6')
+pybind11_dep = dependency('pybind11', version: '>=2.13.2')
subdir('extern')
subdir('src')
diff --git a/pyproject.toml b/pyproject.toml
index 52bbe308c0f9..cf8503a0f3fe 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -16,10 +16,9 @@ classifiers=[
"License :: OSI Approved :: Python Software Foundation License",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
- "Programming Language :: Python :: 3.9",
- "Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
"Topic :: Scientific/Engineering :: Visualization",
]
@@ -35,25 +34,24 @@ dependencies = [
"cycler >= 0.10",
"fonttools >= 4.22.0",
"kiwisolver >= 1.3.1",
- "numpy >= 1.23",
+ "numpy >= 1.25",
"packaging >= 20.0",
- "pillow >= 8",
- "pyparsing >= 2.3.1",
+ "pillow >= 9",
+ "pyparsing >= 3",
"python-dateutil >= 2.7",
- "importlib-resources >= 3.2.0; python_version < '3.10'",
]
-requires-python = ">=3.9"
+# Also keep in sync with find_program of meson.build.
+requires-python = ">=3.11"
[project.optional-dependencies]
# Should be a copy of the build dependencies below.
dev = [
- "meson-python>=0.13.1",
- "numpy>=1.25",
- "pybind11>=2.6",
+ "meson-python>=0.13.1,!=0.17.*",
+ "pybind11>=2.13.2,!=2.13.3",
"setuptools_scm>=7",
# Not required by us but setuptools_scm without a version, cso _if_
# installed, then setuptools_scm 8 requires at least this version.
- # Unfortunately, we can't do a sort of minimum-if-instaled dependency, so
+ # Unfortunately, we can't do a sort of minimum-if-installed dependency, so
# we need to keep this for now until setuptools_scm _fully_ drops
# setuptools.
"setuptools>=64",
@@ -72,21 +70,11 @@ dev = [
build-backend = "mesonpy"
# Also keep in sync with optional dependencies above.
requires = [
- "meson-python>=0.13.1",
- "pybind11>=2.6",
+ # meson-python 0.17.x breaks symlinks in sdists. You can remove this pin if
+ # you really need it and aren't using an sdist.
+ "meson-python>=0.13.1,!=0.17.*",
+ "pybind11>=2.13.2,!=2.13.3",
"setuptools_scm>=7",
-
- # Comments on numpy build requirement range:
- #
- # 1. >=2.0.x is the numpy requirement for wheel builds for distribution
- # on PyPI - building against 2.x yields wheels that are also
- # ABI-compatible with numpy 1.x at runtime.
- # 2. Note that building against numpy 1.x works fine too - users and
- # redistributors can do this by installing the numpy version they like
- # and disabling build isolation.
- # 3. The <2.3 upper bound is for matching the numpy deprecation policy,
- # it should not be loosened.
- "numpy>=2.0.0rc1,<2.3",
]
[tool.meson-python.args]
@@ -98,22 +86,31 @@ local_scheme = "node-and-date"
parentdir_prefix_version = "matplotlib-"
fallback_version = "0.0+UNKNOWN"
+# FIXME: Remove this override once dependencies are available on PyPI.
+[[tool.cibuildwheel.overrides]]
+select = "*-win_arm64"
+before-test = """\
+ pip install --pre \
+ --index-url https://pypi.anaconda.org/scientific-python-nightly-wheels/simple \
+ contourpy numpy"""
+
[tool.isort]
known_pydata = "numpy, matplotlib.pyplot"
known_firstparty = "matplotlib,mpl_toolkits"
sections = "FUTURE,STDLIB,THIRDPARTY,PYDATA,FIRSTPARTY,LOCALFOLDER"
force_sort_within_sections = true
+line_length = 88
[tool.ruff]
-exclude = [
- ".git",
+extend-exclude = [
"build",
"doc/gallery",
"doc/tutorials",
"tools/gh_api.py",
- ".tox",
- ".eggs",
]
+line-length = 88
+
+[tool.ruff.lint]
ignore = [
"D100",
"D101",
@@ -122,55 +119,70 @@ ignore = [
"D104",
"D105",
"D106",
+ "D107",
"D200",
"D202",
+ "D203",
"D204",
"D205",
+ "D212",
"D301",
"D400",
"D401",
+ "D402",
"D403",
"D404",
+ "D413",
+ "D415",
+ "D417",
+ "E266",
+ "E305",
+ "E306",
+ "E721",
"E741",
"F841",
]
-line-length = 88
+preview = true
+explicit-preview-rules = true
select = [
"D",
"E",
"F",
"W",
+ "UP035",
+ # The following error codes require the preview mode to be enabled.
+ "E201",
+ "E202",
+ "E203",
+ "E221",
+ "E251",
+ "E261",
+ "E272",
+ "E302",
+ "E703",
]
-# The following error codes are not supported by ruff v0.0.240
+# The following error codes are not supported by ruff v0.2.0
# They are planned and should be selected once implemented
# even if they are deselected by default.
# These are primarily whitespace/corrected by autoformatters (which we don't use).
# See https://github.com/charliermarsh/ruff/issues/2402 for status on implementation
external = [
"E122",
- "E201",
- "E202",
- "E203",
- "E221",
- "E251",
- "E261",
- "E272",
- "E302",
- "E703",
]
-target-version = "py39"
-
-[tool.ruff.pydocstyle]
+[tool.ruff.lint.pydocstyle]
convention = "numpy"
-[tool.ruff.per-file-ignores]
+[tool.ruff.lint.per-file-ignores]
+"*.pyi" = ["E501"]
+"*.ipynb" = ["E402"]
"doc/conf.py" = ["E402"]
-"galleries/examples/animation/frame_grabbing_sgskip.py" = ["E402"]
+"galleries/examples/images_contours_and_fields/tricontour_demo.py" = ["E201"]
+"galleries/examples/images_contours_and_fields/tripcolor_demo.py" = ["E201"]
+"galleries/examples/images_contours_and_fields/triplot_demo.py" = ["E201"]
"galleries/examples/lines_bars_and_markers/marker_reference.py" = ["E402"]
-"galleries/examples/misc/print_stdout_sgskip.py" = ["E402"]
-"galleries/examples/style_sheets/bmh.py" = ["E501"]
+"galleries/examples/misc/table_demo.py" = ["E201"]
"galleries/examples/subplots_axes_and_figures/demo_constrained_layout.py" = ["E402"]
"galleries/examples/text_labels_and_annotations/custom_legends.py" = ["E402"]
"galleries/examples/ticks/date_concise_formatter.py" = ["E402"]
@@ -184,35 +196,32 @@ convention = "numpy"
"galleries/examples/user_interfaces/mpl_with_glade3_sgskip.py" = ["E402"]
"galleries/examples/user_interfaces/pylab_with_gtk3_sgskip.py" = ["E402"]
"galleries/examples/user_interfaces/pylab_with_gtk4_sgskip.py" = ["E402"]
-"galleries/examples/userdemo/pgf_preamble_sgskip.py" = ["E402"]
-"lib/matplotlib/__init__.py" = ["E402", "F401"]
-"lib/matplotlib/_animation_data.py" = ["E501"]
-"lib/matplotlib/_api/__init__.py" = ["F401"]
-"lib/matplotlib/axes/__init__.py" = ["F401", "F403"]
+"lib/matplotlib/__init__.py" = ["F822"]
+"lib/matplotlib/_cm.py" = ["E202", "E203", "E302"]
+"lib/matplotlib/_mathtext.py" = ["E221"]
+"lib/matplotlib/_mathtext_data.py" = ["E203"]
"lib/matplotlib/backends/backend_template.py" = ["F401"]
-"lib/matplotlib/font_manager.py" = ["E501"]
-"lib/matplotlib/image.py" = ["F401", "F403"]
"lib/matplotlib/pylab.py" = ["F401", "F403"]
-"lib/matplotlib/pyplot.py" = ["F401", "F811"]
+"lib/matplotlib/pyplot.py" = ["F811"]
"lib/matplotlib/tests/test_mathtext.py" = ["E501"]
-"lib/mpl_toolkits/axisartist/__init__.py" = ["F401"]
-"lib/pylab.py" = ["F401", "F403"]
+"lib/matplotlib/transforms.py" = ["E201"]
+"lib/matplotlib/tri/_triinterpolate.py" = ["E201", "E221"]
+"lib/mpl_toolkits/axes_grid1/axes_size.py" = ["E272"]
+"lib/mpl_toolkits/axisartist/angle_helper.py" = ["E221"]
+"lib/mpl_toolkits/mplot3d/proj3d.py" = ["E201"]
-"galleries/users_explain/artists/paths.py" = ["E402"]
+"galleries/users_explain/quick_start.py" = ["E402"]
"galleries/users_explain/artists/patheffects_guide.py" = ["E402"]
-"galleries/users_explain/artists/transforms_tutorial.py" = ["E402", "E501"]
-"galleries/users_explain/colors/colormaps.py" = ["E501"]
+"galleries/users_explain/artists/transforms_tutorial.py" = ["E402"]
"galleries/users_explain/colors/colors.py" = ["E402"]
"galleries/tutorials/artists.py" = ["E402"]
"galleries/users_explain/axes/constrainedlayout_guide.py" = ["E402"]
"galleries/users_explain/axes/legend_guide.py" = ["E402"]
"galleries/users_explain/axes/tight_layout_guide.py" = ["E402"]
"galleries/users_explain/animations/animations.py" = ["E501"]
-"galleries/tutorials/images.py" = ["E501"]
"galleries/tutorials/pyplot.py" = ["E402", "E501"]
"galleries/users_explain/text/annotations.py" = ["E402", "E501"]
-"galleries/users_explain/text/mathtext.py" = ["E501"]
"galleries/users_explain/text/text_intro.py" = ["E402"]
"galleries/users_explain/text/text_props.py" = ["E501"]
@@ -223,24 +232,20 @@ enable_error_code = [
"redundant-expr",
"truthy-bool",
]
-enable_incomplete_feature = [
- "Unpack",
-]
exclude = [
#stubtest
- ".*/matplotlib/(sphinxext|backends|testing/jpl_units)",
+ ".*/matplotlib/(sphinxext|backends|pylab|testing/jpl_units)",
#mypy precommit
"galleries/",
"doc/",
- "lib/matplotlib/backends/",
- "lib/matplotlib/sphinxext",
- "lib/matplotlib/testing/jpl_units",
"lib/mpl_toolkits/",
#removing tests causes errors in backends
"lib/matplotlib/tests/",
# tinypages is used for testing the sphinx ext,
# stubtest will import and run, opening a figure if not excluded
- ".*/tinypages"
+ ".*/tinypages",
+ # pylab's numpy wildcard imports cause re-def failures since numpy 2.2
+ "lib/matplotlib/pylab.py",
]
files = [
"lib/matplotlib",
@@ -259,6 +264,7 @@ ignore_directives = [
# sphinxext.redirect_from
"redirect-from",
# sphinx-design
+ "card",
"dropdown",
"grid",
"tab-set",
@@ -279,6 +285,8 @@ ignore_directives = [
"ifconfig",
# sphinx.ext.inheritance_diagram
"inheritance-diagram",
+ # sphinx-tags
+ "tags",
# include directive is causing attribute errors
"include"
]
diff --git a/requirements/dev/build-requirements.txt b/requirements/dev/build-requirements.txt
new file mode 100644
index 000000000000..4d2a098c3c4f
--- /dev/null
+++ b/requirements/dev/build-requirements.txt
@@ -0,0 +1,3 @@
+pybind11>=2.13.2,!=2.13.3
+meson-python
+setuptools-scm
diff --git a/requirements/dev/dev-requirements.txt b/requirements/dev/dev-requirements.txt
index 117fd8acd3e6..3208949ba0e8 100644
--- a/requirements/dev/dev-requirements.txt
+++ b/requirements/dev/dev-requirements.txt
@@ -1,4 +1,5 @@
+-r build-requirements.txt
-r ../doc/doc-requirements.txt
-r ../testing/all.txt
-r ../testing/extra.txt
--r ../testing/flake8.txt
+ruff
diff --git a/requirements/doc/doc-requirements.txt b/requirements/doc/doc-requirements.txt
index 87bc483b15c0..77cb606130b0 100644
--- a/requirements/doc/doc-requirements.txt
+++ b/requirements/doc/doc-requirements.txt
@@ -7,7 +7,7 @@
# Install the documentation requirements with:
# pip install -r requirements/doc/doc-requirements.txt
#
-sphinx>=3.0.0,!=6.1.2
+sphinx>=5.1.0,!=6.1.2
colorspacious
ipython
ipywidgets
@@ -17,8 +17,10 @@ packaging>=20
pydata-sphinx-theme~=0.15.0
mpl-sphinx-theme~=3.9.0
pyyaml
+PyStemmer
sphinxcontrib-svg2pdfconverter>=1.1.0
+sphinxcontrib-video>=0.2.1
sphinx-copybutton
sphinx-design
-sphinx-gallery>=0.12.0
-sphinx-tags>=0.3.0
+sphinx-gallery[parallel]>=0.12.0
+sphinx-tags>=0.4.0
diff --git a/requirements/testing/extra.txt b/requirements/testing/extra.txt
index b3e9009b561c..e0d84d71c781 100644
--- a/requirements/testing/extra.txt
+++ b/requirements/testing/extra.txt
@@ -1,4 +1,4 @@
-# Extra pip requirements for the Python 3.9+ builds
+# Extra pip requirements
--prefer-binary
ipykernel
diff --git a/requirements/testing/flake8.txt b/requirements/testing/flake8.txt
deleted file mode 100644
index a4d006b8551e..000000000000
--- a/requirements/testing/flake8.txt
+++ /dev/null
@@ -1,9 +0,0 @@
-# Extra pip requirements for the GitHub Actions flake8 build
-
-flake8>=3.8
-# versions less than 5.1.0 raise on some interp'd docstrings
-pydocstyle>=5.1.0
-# 1.4.0 adds docstring-convention=all
-flake8-docstrings>=1.4.0
-# fix bug where flake8 aborts checking on syntax error
-flake8-force
diff --git a/requirements/testing/minver.txt b/requirements/testing/minver.txt
index 1a95367eff14..ee55f6c7b1bf 100644
--- a/requirements/testing/minver.txt
+++ b/requirements/testing/minver.txt
@@ -4,12 +4,20 @@ contourpy==1.0.1
cycler==0.10
fonttools==4.22.0
importlib-resources==3.2.0
-kiwisolver==1.3.1
+kiwisolver==1.3.2
meson-python==0.13.1
meson==1.1.0
-numpy==1.23.0
+numpy==1.25.0
packaging==20.0
-pillow==8.0.0
-pyparsing==2.3.1
+pillow==9.0.1
+pyparsing==3.0.0
pytest==7.0.0
python-dateutil==2.7
+
+# Test ipython/matplotlib-inline before backend mapping moved to mpl.
+# This should be tested for a reasonably long transition period,
+# but we will eventually remove the test when we no longer support
+# ipython/matplotlib-inline versions from before the transition.
+ipython==7.29.0
+ipykernel==5.5.6
+matplotlib-inline<0.1.7
diff --git a/requirements/testing/mypy.txt b/requirements/testing/mypy.txt
index a5ca15cfbdad..343517263f40 100644
--- a/requirements/testing/mypy.txt
+++ b/requirements/testing/mypy.txt
@@ -1,7 +1,7 @@
# Extra pip requirements for the GitHub Actions mypy build
-mypy==1.1.1
-typing-extensions>=4.1,<5
+mypy>=1.9
+typing-extensions>=4.6
# Extra stubs distributed separately from the main pypi package
pandas-stubs
@@ -18,12 +18,9 @@ contourpy>=1.0.1
cycler>=0.10
fonttools>=4.22.0
kiwisolver>=1.3.1
-numpy>=1.19
packaging>=20.0
-pillow>=8
-pyparsing>=2.3.1
+pillow>=9
+pyparsing>=3
python-dateutil>=2.7
setuptools_scm>=7
setuptools>=64
-
-importlib-resources>=3.2.0 ; python_version < "3.10"
diff --git a/src/_backend_agg.cpp b/src/_backend_agg.cpp
index ce88f504dc1e..4d097bc80716 100644
--- a/src/_backend_agg.cpp
+++ b/src/_backend_agg.cpp
@@ -10,9 +10,9 @@ RendererAgg::RendererAgg(unsigned int width, unsigned int height, double dpi)
height(height),
dpi(dpi),
NUMBYTES((size_t)width * (size_t)height * 4),
- pixBuffer(NULL),
+ pixBuffer(nullptr),
renderingBuffer(),
- alphaBuffer(NULL),
+ alphaBuffer(nullptr),
alphaMaskRenderingBuffer(),
alphaMask(alphaMaskRenderingBuffer),
pixfmtAlphaMask(alphaMaskRenderingBuffer),
@@ -26,9 +26,19 @@ RendererAgg::RendererAgg(unsigned int width, unsigned int height, double dpi)
rendererAA(),
rendererBin(),
theRasterizer(32768),
- lastclippath(NULL),
+ lastclippath(nullptr),
_fill_color(agg::rgba(1, 1, 1, 0))
{
+ if (dpi <= 0.0) {
+ throw std::range_error("dpi must be positive");
+ }
+
+ if (width >= 1 << 23 || height >= 1 << 23) {
+ throw std::range_error(
+ "Image size of " + std::to_string(width) + "x" + std::to_string(height) +
+ " pixels is too large. It must be less than 2^23 in each direction.");
+ }
+
unsigned stride(width * 4);
pixBuffer = new agg::int8u[NUMBYTES];
@@ -65,7 +75,7 @@ BufferRegion *RendererAgg::copy_from_bbox(agg::rect_d in_rect)
agg::rect_i rect(
(int)in_rect.x1, height - (int)in_rect.y2, (int)in_rect.x2, height - (int)in_rect.y1);
- BufferRegion *reg = NULL;
+ BufferRegion *reg = nullptr;
reg = new BufferRegion(rect);
agg::rendering_buffer rbuf;
@@ -80,21 +90,21 @@ BufferRegion *RendererAgg::copy_from_bbox(agg::rect_d in_rect)
void RendererAgg::restore_region(BufferRegion ®ion)
{
- if (region.get_data() == NULL) {
+ if (region.get_data() == nullptr) {
throw std::runtime_error("Cannot restore_region from NULL data");
}
agg::rendering_buffer rbuf;
rbuf.attach(region.get_data(), region.get_width(), region.get_height(), region.get_stride());
- rendererBase.copy_from(rbuf, 0, region.get_rect().x1, region.get_rect().y1);
+ rendererBase.copy_from(rbuf, nullptr, region.get_rect().x1, region.get_rect().y1);
}
// Restore the part of the saved region with offsets
void
RendererAgg::restore_region(BufferRegion ®ion, int xx1, int yy1, int xx2, int yy2, int x, int y )
{
- if (region.get_data() == NULL) {
+ if (region.get_data() == nullptr) {
throw std::runtime_error("Cannot restore_region from NULL data");
}
diff --git a/src/_backend_agg.h b/src/_backend_agg.h
index 470d459de341..1ac3d4c06b13 100644
--- a/src/_backend_agg.h
+++ b/src/_backend_agg.h
@@ -6,8 +6,13 @@
#ifndef MPL_BACKEND_AGG_H
#define MPL_BACKEND_AGG_H
+#include
+
#include
#include
+#include
+#include
+#include
#include "agg_alpha_mask_u8.h"
#include "agg_conv_curve.h"
@@ -40,6 +45,8 @@
#include "array.h"
#include "agg_workaround.h"
+namespace py = pybind11;
+
/**********************************************************************/
// a helper class to pass agg::buffer objects around.
@@ -60,6 +67,10 @@ class BufferRegion
delete[] data;
};
+ // prevent copying
+ BufferRegion(const BufferRegion &) = delete;
+ BufferRegion &operator=(const BufferRegion &) = delete;
+
agg::int8u *get_data()
{
return data;
@@ -91,15 +102,8 @@ class BufferRegion
int width;
int height;
int stride;
-
- private:
- // prevent copying
- BufferRegion(const BufferRegion &);
- BufferRegion &operator=(const BufferRegion &);
};
-#define MARKER_CACHE_SIZE 512
-
// the renderer
class RendererAgg
{
@@ -112,17 +116,14 @@ class RendererAgg
typedef agg::renderer_scanline_bin_solid renderer_bin;
typedef agg::rasterizer_scanline_aa rasterizer;
- typedef agg::scanline_p8 scanline_p8;
- typedef agg::scanline_bin scanline_bin;
+ typedef agg::scanline32_p8 scanline_p8;
+ typedef agg::scanline32_bin scanline_bin;
typedef agg::amask_no_clip_gray8 alpha_mask_type;
- typedef agg::scanline_u8_am scanline_am;
+ typedef agg::scanline32_u8_am scanline_am;
typedef agg::renderer_base renderer_base_alpha_mask_type;
typedef agg::renderer_scanline_aa_solid renderer_alpha_mask_type;
- /* TODO: Remove facepair_t */
- typedef std::pair facepair_t;
-
RendererAgg(unsigned int width, unsigned int height, double dpi);
virtual ~RendererAgg();
@@ -173,7 +174,8 @@ class RendererAgg
ColorArray &edgecolors,
LineWidthArray &linewidths,
DashesVector &linestyles,
- AntialiasedArray &antialiaseds);
+ AntialiasedArray &antialiaseds,
+ ColorArray &hatchcolors);
template
void draw_quad_mesh(GCAgg &gc,
@@ -244,7 +246,7 @@ class RendererAgg
bool render_clippath(mpl::PathIterator &clippath, const agg::trans_affine &clippath_trans, e_snap_mode snap_mode);
template
- void _draw_path(PathIteratorType &path, bool has_clippath, const facepair_t &face, GCAgg &gc);
+ void _draw_path(PathIteratorType &path, bool has_clippath, const std::optional &face, GCAgg &gc);
template
void _draw_gouraud_triangle(PointArray &points,
@@ -290,7 +293,7 @@ class RendererAgg
template
inline void
-RendererAgg::_draw_path(path_t &path, bool has_clippath, const facepair_t &face, GCAgg &gc)
+RendererAgg::_draw_path(path_t &path, bool has_clippath, const std::optional &face, GCAgg &gc)
{
typedef agg::conv_stroke stroke_t;
typedef agg::conv_dash dash_t;
@@ -301,7 +304,7 @@ RendererAgg::_draw_path(path_t &path, bool has_clippath, const facepair_t &face,
typedef agg::renderer_scanline_bin_solid amask_bin_renderer_type;
// Render face
- if (face.first) {
+ if (face) {
theRasterizer.add_path(path);
if (gc.isaa) {
@@ -309,10 +312,10 @@ RendererAgg::_draw_path(path_t &path, bool has_clippath, const facepair_t &face,
pixfmt_amask_type pfa(pixFmt, alphaMask);
amask_ren_type r(pfa);
amask_aa_renderer_type ren(r);
- ren.color(face.second);
+ ren.color(*face);
agg::render_scanlines(theRasterizer, scanlineAlphaMask, ren);
} else {
- rendererAA.color(face.second);
+ rendererAA.color(*face);
agg::render_scanlines(theRasterizer, slineP8, rendererAA);
}
} else {
@@ -320,10 +323,10 @@ RendererAgg::_draw_path(path_t &path, bool has_clippath, const facepair_t &face,
pixfmt_amask_type pfa(pixFmt, alphaMask);
amask_ren_type r(pfa);
amask_bin_renderer_type ren(r);
- ren.color(face.second);
+ ren.color(*face);
agg::render_scanlines(theRasterizer, scanlineAlphaMask, ren);
} else {
- rendererBin.color(face.second);
+ rendererBin.color(*face);
agg::render_scanlines(theRasterizer, slineP8, rendererBin);
}
}
@@ -345,7 +348,8 @@ RendererAgg::_draw_path(path_t &path, bool has_clippath, const facepair_t &face,
agg::trans_affine hatch_trans;
hatch_trans *= agg::trans_affine_scaling(1.0, -1.0);
hatch_trans *= agg::trans_affine_translation(0.0, 1.0);
- hatch_trans *= agg::trans_affine_scaling(hatch_size, hatch_size);
+ hatch_trans *= agg::trans_affine_scaling(static_cast(hatch_size),
+ static_cast(hatch_size));
hatch_path_trans_t hatch_path_trans(hatch_path, hatch_trans);
hatch_path_curve_t hatch_path_curve(hatch_path_trans);
hatch_path_stroke_t hatch_path_stroke(hatch_path_curve);
@@ -452,7 +456,10 @@ RendererAgg::draw_path(GCAgg &gc, PathIterator &path, agg::trans_affine &trans,
typedef agg::conv_curve curve_t;
typedef Sketch sketch_t;
- facepair_t face(color.a != 0.0, color);
+ std::optional face;
+ if (color.a != 0.0) {
+ face = color;
+ }
theRasterizer.reset_clipping();
rendererBase.reset_clipping(true);
@@ -461,7 +468,7 @@ RendererAgg::draw_path(GCAgg &gc, PathIterator &path, agg::trans_affine &trans,
trans *= agg::trans_affine_scaling(1.0, -1.0);
trans *= agg::trans_affine_translation(0.0, (double)height);
- bool clip = !face.first && !gc.has_hatchpath();
+ bool clip = !face && !gc.has_hatchpath();
bool simplify = path.should_simplify() && clip;
double snapping_linewidth = points_to_pixels(gc.linewidth);
if (gc.color.a == 0.0) {
@@ -523,7 +530,10 @@ inline void RendererAgg::draw_markers(GCAgg &gc,
curve_t path_curve(path_snapped);
path_curve.rewind(0);
- facepair_t face(color.a != 0.0, color);
+ std::optional face;
+ if (color.a != 0.0) {
+ face = color;
+ }
// maxim's suggestions for cached scanlines
agg::scanline_storage_aa8 scanlines;
@@ -532,22 +542,14 @@ inline void RendererAgg::draw_markers(GCAgg &gc,
rendererBase.reset_clipping(true);
agg::rect_i marker_size(0x7FFFFFFF, 0x7FFFFFFF, -0x7FFFFFFF, -0x7FFFFFFF);
- agg::int8u staticFillCache[MARKER_CACHE_SIZE];
- agg::int8u staticStrokeCache[MARKER_CACHE_SIZE];
- agg::int8u *fillCache = staticFillCache;
- agg::int8u *strokeCache = staticStrokeCache;
-
try
{
- unsigned fillSize = 0;
- if (face.first) {
+ std::vector fillBuffer;
+ if (face) {
theRasterizer.add_path(marker_path_curve);
agg::render_scanlines(theRasterizer, slineP8, scanlines);
- fillSize = scanlines.byte_size();
- if (fillSize >= MARKER_CACHE_SIZE) {
- fillCache = new agg::int8u[fillSize];
- }
- scanlines.serialize(fillCache);
+ fillBuffer.resize(scanlines.byte_size());
+ scanlines.serialize(fillBuffer.data());
marker_size = agg::rect_i(scanlines.min_x(),
scanlines.min_y(),
scanlines.max_x(),
@@ -562,11 +564,8 @@ inline void RendererAgg::draw_markers(GCAgg &gc,
theRasterizer.reset();
theRasterizer.add_path(stroke);
agg::render_scanlines(theRasterizer, slineP8, scanlines);
- unsigned strokeSize = scanlines.byte_size();
- if (strokeSize >= MARKER_CACHE_SIZE) {
- strokeCache = new agg::int8u[strokeSize];
- }
- scanlines.serialize(strokeCache);
+ std::vector strokeBuffer(scanlines.byte_size());
+ scanlines.serialize(strokeBuffer.data());
marker_size = agg::rect_i(std::min(marker_size.x1, scanlines.min_x()),
std::min(marker_size.y1, scanlines.min_y()),
std::max(marker_size.x2, scanlines.max_x()),
@@ -610,13 +609,13 @@ inline void RendererAgg::draw_markers(GCAgg &gc,
amask_ren_type r(pfa);
amask_aa_renderer_type ren(r);
- if (face.first) {
- ren.color(face.second);
- sa.init(fillCache, fillSize, x, y);
+ if (face) {
+ ren.color(*face);
+ sa.init(fillBuffer.data(), fillBuffer.size(), x, y);
agg::render_scanlines(sa, sl, ren);
}
ren.color(gc.color);
- sa.init(strokeCache, strokeSize, x, y);
+ sa.init(strokeBuffer.data(), strokeBuffer.size(), x, y);
agg::render_scanlines(sa, sl, ren);
}
} else {
@@ -638,34 +637,25 @@ inline void RendererAgg::draw_markers(GCAgg &gc,
continue;
}
- if (face.first) {
- rendererAA.color(face.second);
- sa.init(fillCache, fillSize, x, y);
+ if (face) {
+ rendererAA.color(*face);
+ sa.init(fillBuffer.data(), fillBuffer.size(), x, y);
agg::render_scanlines(sa, sl, rendererAA);
}
rendererAA.color(gc.color);
- sa.init(strokeCache, strokeSize, x, y);
+ sa.init(strokeBuffer.data(), strokeBuffer.size(), x, y);
agg::render_scanlines(sa, sl, rendererAA);
}
}
}
catch (...)
{
- if (fillCache != staticFillCache)
- delete[] fillCache;
- if (strokeCache != staticStrokeCache)
- delete[] strokeCache;
theRasterizer.reset_clipping();
rendererBase.reset_clipping(true);
throw;
}
- if (fillCache != staticFillCache)
- delete[] fillCache;
- if (strokeCache != staticStrokeCache)
- delete[] strokeCache;
-
theRasterizer.reset_clipping();
rendererBase.reset_clipping(true);
}
@@ -728,22 +718,25 @@ inline void RendererAgg::draw_text_image(GCAgg &gc, ImageArray &image, int x, in
rendererBase.reset_clipping(true);
if (angle != 0.0) {
agg::rendering_buffer srcbuf(
- image.data(), (unsigned)image.shape(1),
+ image.mutable_data(0, 0), (unsigned)image.shape(1),
(unsigned)image.shape(0), (unsigned)image.shape(1));
agg::pixfmt_gray8 pixf_img(srcbuf);
set_clipbox(gc.cliprect, theRasterizer);
+ auto image_height = static_cast(image.shape(0)),
+ image_width = static_cast(image.shape(1));
+
agg::trans_affine mtx;
- mtx *= agg::trans_affine_translation(0, -image.shape(0));
+ mtx *= agg::trans_affine_translation(0, -image_height);
mtx *= agg::trans_affine_rotation(-angle * (agg::pi / 180.0));
mtx *= agg::trans_affine_translation(x, y);
agg::path_storage rect;
rect.move_to(0, 0);
- rect.line_to(image.shape(1), 0);
- rect.line_to(image.shape(1), image.shape(0));
- rect.line_to(0, image.shape(0));
+ rect.line_to(image_width, 0);
+ rect.line_to(image_width, image_height);
+ rect.line_to(0, image_height);
rect.line_to(0, 0);
agg::conv_transform rect2(rect, mtx);
@@ -828,20 +821,24 @@ inline void RendererAgg::draw_image(GCAgg &gc,
bool has_clippath = render_clippath(gc.clippath.path, gc.clippath.trans, gc.snap_mode);
agg::rendering_buffer buffer;
- buffer.attach(
- image.data(), (unsigned)image.shape(1), (unsigned)image.shape(0), -(int)image.shape(1) * 4);
+ buffer.attach(image.mutable_data(0, 0, 0),
+ (unsigned)image.shape(1), (unsigned)image.shape(0),
+ -(int)image.shape(1) * 4);
pixfmt pixf(buffer);
if (has_clippath) {
agg::trans_affine mtx;
agg::path_storage rect;
- mtx *= agg::trans_affine_translation((int)x, (int)(height - (y + image.shape(0))));
+ auto image_height = static_cast(image.shape(0)),
+ image_width = static_cast(image.shape(1));
+
+ mtx *= agg::trans_affine_translation((int)x, (int)(height - (y + image_height)));
rect.move_to(0, 0);
- rect.line_to(image.shape(1), 0);
- rect.line_to(image.shape(1), image.shape(0));
- rect.line_to(0, image.shape(0));
+ rect.line_to(image_width, 0);
+ rect.line_to(image_width, image_height);
+ rect.line_to(0, image_height);
rect.line_to(0, 0);
agg::conv_transform rect2(rect, mtx);
@@ -877,7 +874,7 @@ inline void RendererAgg::draw_image(GCAgg &gc,
} else {
set_clipbox(gc.cliprect, rendererBase);
rendererBase.blend_from(
- pixf, 0, (int)x, (int)(height - (y + image.shape(0))), (agg::int8u)(alpha * 255));
+ pixf, nullptr, (int)x, (int)(height - (y + image.shape(0))), (agg::int8u)(alpha * 255));
}
rendererBase.reset_clipping(true);
@@ -905,7 +902,8 @@ inline void RendererAgg::_draw_path_collection_generic(GCAgg &gc,
DashesVector &linestyles,
AntialiasedArray &antialiaseds,
bool check_snap,
- bool has_codes)
+ bool has_codes,
+ ColorArray &hatchcolors)
{
typedef agg::conv_transform transformed_path_t;
typedef PathNanRemover nan_removed_t;
@@ -913,6 +911,10 @@ inline void RendererAgg::_draw_path_collection_generic(GCAgg &gc,
typedef PathSnapper snapped_t;
typedef agg::conv_curve snapped_curve_t;
typedef agg::conv_curve curve_t;
+ typedef Sketch sketch_clipped_t;
+ typedef Sketch sketch_curve_t;
+ typedef Sketch sketch_snapped_t;
+ typedef Sketch sketch_snapped_curve_t;
size_t Npaths = path_generator.num_paths();
size_t Noffsets = safe_first_shape(offsets);
@@ -921,11 +923,12 @@ inline void RendererAgg::_draw_path_collection_generic(GCAgg &gc,
size_t Ntransforms = safe_first_shape(transforms);
size_t Nfacecolors = safe_first_shape(facecolors);
size_t Nedgecolors = safe_first_shape(edgecolors);
+ size_t Nhatchcolors = safe_first_shape(hatchcolors);
size_t Nlinewidths = safe_first_shape(linewidths);
size_t Nlinestyles = std::min(linestyles.size(), N);
size_t Naa = safe_first_shape(antialiaseds);
- if ((Nfacecolors == 0 && Nedgecolors == 0) || Npaths == 0) {
+ if ((Nfacecolors == 0 && Nedgecolors == 0 && Nhatchcolors == 0) || Npaths == 0) {
return;
}
@@ -937,10 +940,9 @@ inline void RendererAgg::_draw_path_collection_generic(GCAgg &gc,
// Set some defaults, assuming no face or edge
gc.linewidth = 0.0;
- facepair_t face;
- face.first = Nfacecolors != 0;
+ std::optional face;
agg::trans_affine trans;
- bool do_clip = !face.first && !gc.has_hatchpath();
+ bool do_clip = Nfacecolors == 0 && !gc.has_hatchpath();
for (int i = 0; i < (int)N; ++i) {
typename PathGenerator::path_iterator path = path_generator(i);
@@ -971,7 +973,7 @@ inline void RendererAgg::_draw_path_collection_generic(GCAgg &gc,
if (Nfacecolors) {
int ic = i % Nfacecolors;
- face.second = agg::rgba(facecolors(ic, 0), facecolors(ic, 1), facecolors(ic, 2), facecolors(ic, 3));
+ face.emplace(facecolors(ic, 0), facecolors(ic, 1), facecolors(ic, 2), facecolors(ic, 3));
}
if (Nedgecolors) {
@@ -988,31 +990,34 @@ inline void RendererAgg::_draw_path_collection_generic(GCAgg &gc,
}
}
- if (check_snap) {
- gc.isaa = antialiaseds(i % Naa);
+ if(Nhatchcolors) {
+ int ic = i % Nhatchcolors;
+ gc.hatch_color = agg::rgba(hatchcolors(ic, 0), hatchcolors(ic, 1), hatchcolors(ic, 2), hatchcolors(ic, 3));
+ }
- transformed_path_t tpath(path, trans);
- nan_removed_t nan_removed(tpath, true, has_codes);
- clipped_t clipped(nan_removed, do_clip, width, height);
+ gc.isaa = antialiaseds(i % Naa);
+ transformed_path_t tpath(path, trans);
+ nan_removed_t nan_removed(tpath, true, has_codes);
+ clipped_t clipped(nan_removed, do_clip, width, height);
+ if (check_snap) {
snapped_t snapped(
clipped, gc.snap_mode, path.total_vertices(), points_to_pixels(gc.linewidth));
if (has_codes) {
snapped_curve_t curve(snapped);
- _draw_path(curve, has_clippath, face, gc);
+ sketch_snapped_curve_t sketch(curve, gc.sketch.scale, gc.sketch.length, gc.sketch.randomness);
+ _draw_path(sketch, has_clippath, face, gc);
} else {
- _draw_path(snapped, has_clippath, face, gc);
+ sketch_snapped_t sketch(snapped, gc.sketch.scale, gc.sketch.length, gc.sketch.randomness);
+ _draw_path(sketch, has_clippath, face, gc);
}
} else {
- gc.isaa = antialiaseds(i % Naa);
-
- transformed_path_t tpath(path, trans);
- nan_removed_t nan_removed(tpath, true, has_codes);
- clipped_t clipped(nan_removed, do_clip, width, height);
if (has_codes) {
curve_t curve(clipped);
- _draw_path(curve, has_clippath, face, gc);
+ sketch_curve_t sketch(curve, gc.sketch.scale, gc.sketch.length, gc.sketch.randomness);
+ _draw_path(sketch, has_clippath, face, gc);
} else {
- _draw_path(clipped, has_clippath, face, gc);
+ sketch_clipped_t sketch(clipped, gc.sketch.scale, gc.sketch.length, gc.sketch.randomness);
+ _draw_path(sketch, has_clippath, face, gc);
}
}
}
@@ -1034,7 +1039,8 @@ inline void RendererAgg::draw_path_collection(GCAgg &gc,
ColorArray &edgecolors,
LineWidthArray &linewidths,
DashesVector &linestyles,
- AntialiasedArray &antialiaseds)
+ AntialiasedArray &antialiaseds,
+ ColorArray &hatchcolors)
{
_draw_path_collection_generic(gc,
master_transform,
@@ -1051,7 +1057,8 @@ inline void RendererAgg::draw_path_collection(GCAgg &gc,
linestyles,
antialiaseds,
true,
- true);
+ true,
+ hatchcolors);
}
template
@@ -1145,6 +1152,7 @@ inline void RendererAgg::draw_quad_mesh(GCAgg &gc,
array::scalar linewidths(gc.linewidth);
array::scalar antialiaseds(antialiased);
DashesVector linestyles;
+ ColorArray hatchcolors = py::array_t().reshape({0, 4}).unchecked();
_draw_path_collection_generic(gc,
master_transform,
@@ -1161,7 +1169,8 @@ inline void RendererAgg::draw_quad_mesh(GCAgg &gc,
linestyles,
antialiaseds,
true, // check_snap
- false);
+ false,
+ hatchcolors);
}
template
@@ -1226,14 +1235,27 @@ inline void RendererAgg::draw_gouraud_triangles(GCAgg &gc,
ColorArray &colors,
agg::trans_affine &trans)
{
+ if (points.shape(0)) {
+ check_trailing_shape(points, "points", 3, 2);
+ }
+ if (colors.shape(0)) {
+ check_trailing_shape(colors, "colors", 3, 4);
+ }
+ if (points.shape(0) != colors.shape(0)) {
+ throw py::value_error(
+ "points and colors arrays must be the same length, got " +
+ std::to_string(points.shape(0)) + " points and " +
+ std::to_string(colors.shape(0)) + "colors");
+ }
+
theRasterizer.reset_clipping();
rendererBase.reset_clipping(true);
set_clipbox(gc.cliprect, theRasterizer);
bool has_clippath = render_clippath(gc.clippath.path, gc.clippath.trans, gc.snap_mode);
for (int i = 0; i < points.shape(0); ++i) {
- typename PointArray::sub_t point = points.subarray(i);
- typename ColorArray::sub_t color = colors.subarray(i);
+ auto point = std::bind(points, i, std::placeholders::_1, std::placeholders::_2);
+ auto color = std::bind(colors, i, std::placeholders::_1, std::placeholders::_2);
_draw_gouraud_triangle(point, color, trans, has_clippath);
}
diff --git a/src/_backend_agg_basic_types.h b/src/_backend_agg_basic_types.h
index 4fbf846d8cb4..b424419ec99e 100644
--- a/src/_backend_agg_basic_types.h
+++ b/src/_backend_agg_basic_types.h
@@ -4,6 +4,9 @@
/* Contains some simple types from the Agg backend that are also used
by other modules */
+#include
+
+#include
#include
#include "agg_color_rgba.h"
@@ -13,6 +16,8 @@
#include "py_adaptors.h"
+namespace py = pybind11;
+
struct ClipPath
{
mpl::PathIterator path;
@@ -43,7 +48,7 @@ class Dashes
}
void add_dash_pair(double length, double skip)
{
- dashes.push_back(std::make_pair(length, skip));
+ dashes.emplace_back(length, skip);
}
size_t size() const
{
@@ -54,9 +59,7 @@ class Dashes
void dash_to_stroke(T &stroke, double dpi, bool isaa)
{
double scaleddpi = dpi / 72.0;
- for (dash_t::const_iterator i = dashes.begin(); i != dashes.end(); ++i) {
- double val0 = i->first;
- double val1 = i->second;
+ for (auto [val0, val1] : dashes) {
val0 = val0 * scaleddpi;
val1 = val1 * scaleddpi;
if (!isaa) {
@@ -121,4 +124,132 @@ class GCAgg
GCAgg &operator=(const GCAgg &);
};
+namespace PYBIND11_NAMESPACE { namespace detail {
+ template <> struct type_caster {
+ public:
+ PYBIND11_TYPE_CASTER(agg::line_cap_e, const_name("line_cap_e"));
+
+ bool load(handle src, bool) {
+ const std::unordered_map enum_values = {
+ {"butt", agg::butt_cap},
+ {"round", agg::round_cap},
+ {"projecting", agg::square_cap},
+ };
+ value = enum_values.at(src.cast());
+ return true;
+ }
+ };
+
+ template <> struct type_caster {
+ public:
+ PYBIND11_TYPE_CASTER(agg::line_join_e, const_name("line_join_e"));
+
+ bool load(handle src, bool) {
+ const std::unordered_map enum_values = {
+ {"miter", agg::miter_join_revert},
+ {"round", agg::round_join},
+ {"bevel", agg::bevel_join},
+ };
+ value = enum_values.at(src.cast());
+ return true;
+ }
+ };
+
+ template <> struct type_caster {
+ public:
+ PYBIND11_TYPE_CASTER(ClipPath, const_name("ClipPath"));
+
+ bool load(handle src, bool) {
+ if (src.is_none()) {
+ return true;
+ }
+
+ auto [path, trans] =
+ src.cast, agg::trans_affine>>();
+ if (path) {
+ value.path = *path;
+ }
+ value.trans = trans;
+
+ return true;
+ }
+ };
+
+ template <> struct type_caster {
+ public:
+ PYBIND11_TYPE_CASTER(Dashes, const_name("Dashes"));
+
+ bool load(handle src, bool) {
+ auto [dash_offset, dashes_seq_or_none] =
+ src.cast>>();
+
+ if (!dashes_seq_or_none) {
+ return true;
+ }
+
+ auto dashes_seq = *dashes_seq_or_none;
+
+ auto nentries = dashes_seq.size();
+ // If the dashpattern has odd length, iterate through it twice (in
+ // accordance with the pdf/ps/svg specs).
+ auto dash_pattern_length = (nentries % 2) ? 2 * nentries : nentries;
+
+ for (py::size_t i = 0; i < dash_pattern_length; i += 2) {
+ auto length = dashes_seq[i % nentries].cast();
+ auto skip = dashes_seq[(i + 1) % nentries].cast();
+
+ value.add_dash_pair(length, skip);
+ }
+
+ value.set_dash_offset(dash_offset);
+
+ return true;
+ }
+ };
+
+ template <> struct type_caster {
+ public:
+ PYBIND11_TYPE_CASTER(SketchParams, const_name("SketchParams"));
+
+ bool load(handle src, bool) {
+ if (src.is_none()) {
+ value.scale = 0.0;
+ value.length = 0.0;
+ value.randomness = 0.0;
+ return true;
+ }
+
+ auto params = src.cast>();
+ std::tie(value.scale, value.length, value.randomness) = params;
+
+ return true;
+ }
+ };
+
+ template <> struct type_caster {
+ public:
+ PYBIND11_TYPE_CASTER(GCAgg, const_name("GCAgg"));
+
+ bool load(handle src, bool) {
+ value.linewidth = src.attr("_linewidth").cast();
+ value.alpha = src.attr("_alpha").cast();
+ value.forced_alpha = src.attr("_forced_alpha").cast();
+ value.color = src.attr("_rgb").cast();
+ value.isaa = src.attr("_antialiased").cast();
+ value.cap = src.attr("_capstyle").cast();
+ value.join = src.attr("_joinstyle").cast();
+ value.dashes = src.attr("get_dashes")().cast();
+ value.cliprect = src.attr("_cliprect").cast();
+ value.clippath = src.attr("get_clip_path")().cast();
+ value.snap_mode = src.attr("get_snap")().cast();
+ value.hatchpath = src.attr("get_hatch_path")().cast();
+ value.hatch_color = src.attr("get_hatch_color")().cast();
+ value.hatch_linewidth = src.attr("get_hatch_linewidth")().cast();
+ value.sketch = src.attr("get_sketch_params")().cast();
+
+ return true;
+ }
+ };
+}} // namespace PYBIND11_NAMESPACE::detail
+
#endif
diff --git a/src/_backend_agg_wrapper.cpp b/src/_backend_agg_wrapper.cpp
index eaf4bf6f5f9d..3dd50b31f64a 100644
--- a/src/_backend_agg_wrapper.cpp
+++ b/src/_backend_agg_wrapper.cpp
@@ -1,566 +1,287 @@
+#include
+#include
+#include
#include "mplutils.h"
-#include "numpy_cpp.h"
#include "py_converters.h"
#include "_backend_agg.h"
-typedef struct
-{
- PyObject_HEAD
- RendererAgg *x;
- Py_ssize_t shape[3];
- Py_ssize_t strides[3];
- Py_ssize_t suboffsets[3];
-} PyRendererAgg;
-
-static PyTypeObject PyRendererAggType;
-
-typedef struct
-{
- PyObject_HEAD
- BufferRegion *x;
- Py_ssize_t shape[3];
- Py_ssize_t strides[3];
- Py_ssize_t suboffsets[3];
-} PyBufferRegion;
-
-static PyTypeObject PyBufferRegionType;
-
+namespace py = pybind11;
+using namespace pybind11::literals;
/**********************************************************************
* BufferRegion
* */
-static PyObject *PyBufferRegion_new(PyTypeObject *type, PyObject *args, PyObject *kwds)
-{
- PyBufferRegion *self;
- self = (PyBufferRegion *)type->tp_alloc(type, 0);
- self->x = NULL;
- return (PyObject *)self;
-}
-
-static void PyBufferRegion_dealloc(PyBufferRegion *self)
-{
- delete self->x;
- Py_TYPE(self)->tp_free((PyObject *)self);
-}
-
/* TODO: This doesn't seem to be used internally. Remove? */
-static PyObject *PyBufferRegion_set_x(PyBufferRegion *self, PyObject *args)
-{
- int x;
- if (!PyArg_ParseTuple(args, "i:set_x", &x)) {
- return NULL;
- }
- self->x->get_rect().x1 = x;
-
- Py_RETURN_NONE;
-}
-
-static PyObject *PyBufferRegion_set_y(PyBufferRegion *self, PyObject *args)
+static void
+PyBufferRegion_set_x(BufferRegion *self, int x)
{
- int y;
- if (!PyArg_ParseTuple(args, "i:set_y", &y)) {
- return NULL;
- }
- self->x->get_rect().y1 = y;
-
- Py_RETURN_NONE;
+ self->get_rect().x1 = x;
}
-static PyObject *PyBufferRegion_get_extents(PyBufferRegion *self, PyObject *args)
+static void
+PyBufferRegion_set_y(BufferRegion *self, int y)
{
- agg::rect_i rect = self->x->get_rect();
-
- return Py_BuildValue("IIII", rect.x1, rect.y1, rect.x2, rect.y2);
+ self->get_rect().y1 = y;
}
-int PyBufferRegion_get_buffer(PyBufferRegion *self, Py_buffer *buf, int flags)
+static py::object
+PyBufferRegion_get_extents(BufferRegion *self)
{
- Py_INCREF(self);
- buf->obj = (PyObject *)self;
- buf->buf = self->x->get_data();
- buf->len = (Py_ssize_t)self->x->get_width() * (Py_ssize_t)self->x->get_height() * 4;
- buf->readonly = 0;
- buf->format = (char *)"B";
- buf->ndim = 3;
- self->shape[0] = self->x->get_height();
- self->shape[1] = self->x->get_width();
- self->shape[2] = 4;
- buf->shape = self->shape;
- self->strides[0] = self->x->get_width() * 4;
- self->strides[1] = 4;
- self->strides[2] = 1;
- buf->strides = self->strides;
- buf->suboffsets = NULL;
- buf->itemsize = 1;
- buf->internal = NULL;
-
- return 1;
-}
+ agg::rect_i rect = self->get_rect();
-static PyTypeObject *PyBufferRegion_init_type()
-{
- static PyMethodDef methods[] = {
- { "set_x", (PyCFunction)PyBufferRegion_set_x, METH_VARARGS, NULL },
- { "set_y", (PyCFunction)PyBufferRegion_set_y, METH_VARARGS, NULL },
- { "get_extents", (PyCFunction)PyBufferRegion_get_extents, METH_NOARGS, NULL },
- { NULL }
- };
-
- static PyBufferProcs buffer_procs;
- buffer_procs.bf_getbuffer = (getbufferproc)PyBufferRegion_get_buffer;
-
- PyBufferRegionType.tp_name = "matplotlib.backends._backend_agg.BufferRegion";
- PyBufferRegionType.tp_basicsize = sizeof(PyBufferRegion);
- PyBufferRegionType.tp_dealloc = (destructor)PyBufferRegion_dealloc;
- PyBufferRegionType.tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE;
- PyBufferRegionType.tp_methods = methods;
- PyBufferRegionType.tp_new = PyBufferRegion_new;
- PyBufferRegionType.tp_as_buffer = &buffer_procs;
-
- return &PyBufferRegionType;
+ return py::make_tuple(rect.x1, rect.y1, rect.x2, rect.y2);
}
/**********************************************************************
* RendererAgg
* */
-static PyObject *PyRendererAgg_new(PyTypeObject *type, PyObject *args, PyObject *kwds)
-{
- PyRendererAgg *self;
- self = (PyRendererAgg *)type->tp_alloc(type, 0);
- self->x = NULL;
- return (PyObject *)self;
-}
-
-static int PyRendererAgg_init(PyRendererAgg *self, PyObject *args, PyObject *kwds)
+static void
+PyRendererAgg_draw_path(RendererAgg *self,
+ GCAgg &gc,
+ mpl::PathIterator path,
+ agg::trans_affine trans,
+ py::object rgbFace)
{
- unsigned int width;
- unsigned int height;
- double dpi;
- int debug = 0;
-
- if (!PyArg_ParseTuple(args, "IId|i:RendererAgg", &width, &height, &dpi, &debug)) {
- return -1;
+ agg::rgba face = rgbFace.cast();
+ if (!rgbFace.is_none()) {
+ if (gc.forced_alpha || rgbFace.cast().size() == 3) {
+ face.a = gc.alpha;
+ }
}
- if (dpi <= 0.0) {
- PyErr_SetString(PyExc_ValueError, "dpi must be positive");
- return -1;
- }
-
- if (width >= 1 << 16 || height >= 1 << 16) {
- PyErr_Format(
- PyExc_ValueError,
- "Image size of %dx%d pixels is too large. "
- "It must be less than 2^16 in each direction.",
- width, height);
- return -1;
- }
-
- CALL_CPP_INIT("RendererAgg", self->x = new RendererAgg(width, height, dpi))
-
- return 0;
+ self->draw_path(gc, path, trans, face);
}
-static void PyRendererAgg_dealloc(PyRendererAgg *self)
+static void
+PyRendererAgg_draw_text_image(RendererAgg *self,
+ py::array_t image_obj,
+ std::variant vx,
+ std::variant vy,
+ double angle,
+ GCAgg &gc)
{
- delete self->x;
- Py_TYPE(self)->tp_free((PyObject *)self);
-}
-
-static PyObject *PyRendererAgg_draw_path(PyRendererAgg *self, PyObject *args)
-{
- GCAgg gc;
- mpl::PathIterator path;
- agg::trans_affine trans;
- PyObject *faceobj = NULL;
- agg::rgba face;
-
- if (!PyArg_ParseTuple(args,
- "O&O&O&|O:draw_path",
- &convert_gcagg,
- &gc,
- &convert_path,
- &path,
- &convert_trans_affine,
- &trans,
- &faceobj)) {
- return NULL;
- }
-
- if (!convert_face(faceobj, gc, &face)) {
- return NULL;
+ int x, y;
+
+ if (auto value = std::get_if(&vx)) {
+ auto api = py::module_::import("matplotlib._api");
+ auto warn = api.attr("warn_deprecated");
+ warn("since"_a="3.10", "name"_a="x", "obj_type"_a="parameter as float",
+ "alternative"_a="int(x)");
+ x = static_cast(*value);
+ } else if (auto value = std::get_if(&vx)) {
+ x = *value;
+ } else {
+ throw std::runtime_error("Should not happen");
}
- CALL_CPP("draw_path", (self->x->draw_path(gc, path, trans, face)));
-
- Py_RETURN_NONE;
-}
-
-static PyObject *PyRendererAgg_draw_text_image(PyRendererAgg *self, PyObject *args)
-{
- numpy::array_view image;
- double x;
- double y;
- double angle;
- GCAgg gc;
-
- if (!PyArg_ParseTuple(args,
- "O&dddO&:draw_text_image",
- &image.converter_contiguous,
- &image,
- &x,
- &y,
- &angle,
- &convert_gcagg,
- &gc)) {
- return NULL;
+ if (auto value = std::get_if(&vy)) {
+ auto api = py::module_::import("matplotlib._api");
+ auto warn = api.attr("warn_deprecated");
+ warn("since"_a="3.10", "name"_a="y", "obj_type"_a="parameter as float",
+ "alternative"_a="int(y)");
+ y = static_cast(*value);
+ } else if (auto value = std::get_if(&vy)) {
+ y = *value;
+ } else {
+ throw std::runtime_error("Should not happen");
}
- CALL_CPP("draw_text_image", (self->x->draw_text_image(gc, image, x, y, angle)));
+ // TODO: This really shouldn't be mutable, but Agg's renderer buffers aren't const.
+ auto image = image_obj.mutable_unchecked<2>();
- Py_RETURN_NONE;
+ self->draw_text_image(gc, image, x, y, angle);
}
-PyObject *PyRendererAgg_draw_markers(PyRendererAgg *self, PyObject *args)
+static void
+PyRendererAgg_draw_markers(RendererAgg *self,
+ GCAgg &gc,
+ mpl::PathIterator marker_path,
+ agg::trans_affine marker_path_trans,
+ mpl::PathIterator path,
+ agg::trans_affine trans,
+ py::object rgbFace)
{
- GCAgg gc;
- mpl::PathIterator marker_path;
- agg::trans_affine marker_path_trans;
- mpl::PathIterator path;
- agg::trans_affine trans;
- PyObject *faceobj = NULL;
- agg::rgba face;
-
- if (!PyArg_ParseTuple(args,
- "O&O&O&O&O&|O:draw_markers",
- &convert_gcagg,
- &gc,
- &convert_path,
- &marker_path,
- &convert_trans_affine,
- &marker_path_trans,
- &convert_path,
- &path,
- &convert_trans_affine,
- &trans,
- &faceobj)) {
- return NULL;
+ agg::rgba face = rgbFace.cast();
+ if (!rgbFace.is_none()) {
+ if (gc.forced_alpha || rgbFace.cast().size() == 3) {
+ face.a = gc.alpha;
+ }
}
- if (!convert_face(faceobj, gc, &face)) {
- return NULL;
- }
-
- CALL_CPP("draw_markers",
- (self->x->draw_markers(gc, marker_path, marker_path_trans, path, trans, face)));
-
- Py_RETURN_NONE;
+ self->draw_markers(gc, marker_path, marker_path_trans, path, trans, face);
}
-static PyObject *PyRendererAgg_draw_image(PyRendererAgg *self, PyObject *args)
+static void
+PyRendererAgg_draw_image(RendererAgg *self,
+ GCAgg &gc,
+ double x,
+ double y,
+ py::array_t image_obj)
{
- GCAgg gc;
- double x;
- double y;
- numpy::array_view image;
-
- if (!PyArg_ParseTuple(args,
- "O&ddO&:draw_image",
- &convert_gcagg,
- &gc,
- &x,
- &y,
- &image.converter_contiguous,
- &image)) {
- return NULL;
- }
+ // TODO: This really shouldn't be mutable, but Agg's renderer buffers aren't const.
+ auto image = image_obj.mutable_unchecked<3>();
x = mpl_round(x);
y = mpl_round(y);
gc.alpha = 1.0;
- CALL_CPP("draw_image", (self->x->draw_image(gc, x, y, image)));
-
- Py_RETURN_NONE;
-}
-
-static PyObject *
-PyRendererAgg_draw_path_collection(PyRendererAgg *self, PyObject *args)
-{
- GCAgg gc;
- agg::trans_affine master_transform;
- mpl::PathGenerator paths;
- numpy::array_view transforms;
- numpy::array_view offsets;
- agg::trans_affine offset_trans;
- numpy::array_view facecolors;
- numpy::array_view edgecolors;
- numpy::array_view linewidths;
- DashesVector dashes;
- numpy::array_view antialiaseds;
- PyObject *ignored;
- PyObject *offset_position; // offset position is no longer used
-
- if (!PyArg_ParseTuple(args,
- "O&O&O&O&O&O&O&O&O&O&O&OO:draw_path_collection",
- &convert_gcagg,
- &gc,
- &convert_trans_affine,
- &master_transform,
- &convert_pathgen,
- &paths,
- &convert_transforms,
- &transforms,
- &convert_points,
- &offsets,
- &convert_trans_affine,
- &offset_trans,
- &convert_colors,
- &facecolors,
- &convert_colors,
- &edgecolors,
- &linewidths.converter,
- &linewidths,
- &convert_dashes_vector,
- &dashes,
- &antialiaseds.converter,
- &antialiaseds,
- &ignored,
- &offset_position)) {
- return NULL;
- }
-
- CALL_CPP("draw_path_collection",
- (self->x->draw_path_collection(gc,
- master_transform,
- paths,
- transforms,
- offsets,
- offset_trans,
- facecolors,
- edgecolors,
- linewidths,
- dashes,
- antialiaseds)));
-
- Py_RETURN_NONE;
-}
-
-static PyObject *PyRendererAgg_draw_quad_mesh(PyRendererAgg *self, PyObject *args)
-{
- GCAgg gc;
- agg::trans_affine master_transform;
- unsigned int mesh_width;
- unsigned int mesh_height;
- numpy::array_view coordinates;
- numpy::array_view offsets;
- agg::trans_affine offset_trans;
- numpy::array_view facecolors;
- bool antialiased;
- numpy::array_view edgecolors;
-
- if (!PyArg_ParseTuple(args,
- "O&O&IIO&O&O&O&O&O&:draw_quad_mesh",
- &convert_gcagg,
- &gc,
- &convert_trans_affine,
- &master_transform,
- &mesh_width,
- &mesh_height,
- &coordinates.converter,
- &coordinates,
- &convert_points,
- &offsets,
- &convert_trans_affine,
- &offset_trans,
- &convert_colors,
- &facecolors,
- &convert_bool,
- &antialiased,
- &convert_colors,
- &edgecolors)) {
- return NULL;
- }
-
- CALL_CPP("draw_quad_mesh",
- (self->x->draw_quad_mesh(gc,
- master_transform,
- mesh_width,
- mesh_height,
- coordinates,
- offsets,
- offset_trans,
- facecolors,
- antialiased,
- edgecolors)));
-
- Py_RETURN_NONE;
-}
-
-static PyObject *
-PyRendererAgg_draw_gouraud_triangles(PyRendererAgg *self, PyObject *args)
-{
- GCAgg gc;
- numpy::array_view points;
- numpy::array_view colors;
- agg::trans_affine trans;
-
- if (!PyArg_ParseTuple(args,
- "O&O&O&O&|O:draw_gouraud_triangles",
- &convert_gcagg,
- &gc,
- &points.converter,
- &points,
- &colors.converter,
- &colors,
- &convert_trans_affine,
- &trans)) {
- return NULL;
- }
- if (points.shape(0) && !check_trailing_shape(points, "points", 3, 2)) {
- return NULL;
- }
- if (colors.shape(0) && !check_trailing_shape(colors, "colors", 3, 4)) {
- return NULL;
- }
- if (points.shape(0) != colors.shape(0)) {
- PyErr_Format(PyExc_ValueError,
- "points and colors arrays must be the same length, got "
- "%" NPY_INTP_FMT " points and %" NPY_INTP_FMT "colors",
- points.shape(0), colors.shape(0));
- return NULL;
- }
-
- CALL_CPP("draw_gouraud_triangles", self->x->draw_gouraud_triangles(gc, points, colors, trans));
-
- Py_RETURN_NONE;
+ self->draw_image(gc, x, y, image);
}
-int PyRendererAgg_get_buffer(PyRendererAgg *self, Py_buffer *buf, int flags)
+static void
+PyRendererAgg_draw_path_collection(RendererAgg *self,
+ GCAgg &gc,
+ agg::trans_affine master_transform,
+ mpl::PathGenerator paths,
+ py::array_t transforms_obj,
+ py::array_t offsets_obj,
+ agg::trans_affine offset_trans,
+ py::array_t facecolors_obj,
+ py::array_t edgecolors_obj,
+ py::array_t linewidths_obj,
+ DashesVector dashes,
+ py::array_t antialiaseds_obj,
+ py::object Py_UNUSED(ignored_obj),
+ // offset position is no longer used
+ py::object Py_UNUSED(offset_position_obj),
+ py::array_t hatchcolors_obj)
{
- Py_INCREF(self);
- buf->obj = (PyObject *)self;
- buf->buf = self->x->pixBuffer;
- buf->len = (Py_ssize_t)self->x->get_width() * (Py_ssize_t)self->x->get_height() * 4;
- buf->readonly = 0;
- buf->format = (char *)"B";
- buf->ndim = 3;
- self->shape[0] = self->x->get_height();
- self->shape[1] = self->x->get_width();
- self->shape[2] = 4;
- buf->shape = self->shape;
- self->strides[0] = self->x->get_width() * 4;
- self->strides[1] = 4;
- self->strides[2] = 1;
- buf->strides = self->strides;
- buf->suboffsets = NULL;
- buf->itemsize = 1;
- buf->internal = NULL;
-
- return 1;
+ auto transforms = convert_transforms(transforms_obj);
+ auto offsets = convert_points(offsets_obj);
+ auto facecolors = convert_colors(facecolors_obj);
+ auto edgecolors = convert_colors(edgecolors_obj);
+ auto hatchcolors = convert_colors(hatchcolors_obj);
+ auto linewidths = linewidths_obj.unchecked<1>();
+ auto antialiaseds = antialiaseds_obj.unchecked<1>();
+
+ self->draw_path_collection(gc,
+ master_transform,
+ paths,
+ transforms,
+ offsets,
+ offset_trans,
+ facecolors,
+ edgecolors,
+ linewidths,
+ dashes,
+ antialiaseds,
+ hatchcolors);
}
-static PyObject *PyRendererAgg_clear(PyRendererAgg *self, PyObject *args)
+static void
+PyRendererAgg_draw_quad_mesh(RendererAgg *self,
+ GCAgg &gc,
+ agg::trans_affine master_transform,
+ unsigned int mesh_width,
+ unsigned int mesh_height,
+ py::array_t coordinates_obj,
+ py::array_t offsets_obj,
+ agg::trans_affine offset_trans,
+ py::array_t facecolors_obj,
+ bool antialiased,
+ py::array_t edgecolors_obj)
{
- CALL_CPP("clear", self->x->clear());
-
- Py_RETURN_NONE;
+ auto coordinates = coordinates_obj.mutable_unchecked<3>();
+ auto offsets = convert_points(offsets_obj);
+ auto facecolors = convert_colors(facecolors_obj);
+ auto edgecolors = convert_colors(edgecolors_obj);
+
+ self->draw_quad_mesh(gc,
+ master_transform,
+ mesh_width,
+ mesh_height,
+ coordinates,
+ offsets,
+ offset_trans,
+ facecolors,
+ antialiased,
+ edgecolors);
}
-static PyObject *PyRendererAgg_copy_from_bbox(PyRendererAgg *self, PyObject *args)
+static void
+PyRendererAgg_draw_gouraud_triangles(RendererAgg *self,
+ GCAgg &gc,
+ py::array_t points_obj,
+ py::array_t colors_obj,
+ agg::trans_affine trans)
{
- agg::rect_d bbox;
- BufferRegion *reg;
- PyObject *regobj;
-
- if (!PyArg_ParseTuple(args, "O&:copy_from_bbox", &convert_rect, &bbox)) {
- return 0;
- }
+ auto points = points_obj.unchecked<3>();
+ auto colors = colors_obj.unchecked<3>();
- CALL_CPP("copy_from_bbox", (reg = self->x->copy_from_bbox(bbox)));
-
- regobj = PyBufferRegion_new(&PyBufferRegionType, NULL, NULL);
- ((PyBufferRegion *)regobj)->x = reg;
-
- return regobj;
+ self->draw_gouraud_triangles(gc, points, colors, trans);
}
-static PyObject *PyRendererAgg_restore_region(PyRendererAgg *self, PyObject *args)
+PYBIND11_MODULE(_backend_agg, m, py::mod_gil_not_used())
{
- PyBufferRegion *regobj;
- int xx1 = 0, yy1 = 0, xx2 = 0, yy2 = 0, x = 0, y = 0;
-
- if (!PyArg_ParseTuple(args,
- "O!|iiiiii:restore_region",
- &PyBufferRegionType,
- ®obj,
- &xx1,
- &yy1,
- &xx2,
- &yy2,
- &x,
- &y)) {
- return 0;
- }
-
- if (PySequence_Size(args) == 1) {
- CALL_CPP("restore_region", self->x->restore_region(*(regobj->x)));
- } else {
- CALL_CPP("restore_region", self->x->restore_region(*(regobj->x), xx1, yy1, xx2, yy2, x, y));
- }
-
- Py_RETURN_NONE;
-}
-
-static PyTypeObject *PyRendererAgg_init_type()
-{
- static PyMethodDef methods[] = {
- {"draw_path", (PyCFunction)PyRendererAgg_draw_path, METH_VARARGS, NULL},
- {"draw_markers", (PyCFunction)PyRendererAgg_draw_markers, METH_VARARGS, NULL},
- {"draw_text_image", (PyCFunction)PyRendererAgg_draw_text_image, METH_VARARGS, NULL},
- {"draw_image", (PyCFunction)PyRendererAgg_draw_image, METH_VARARGS, NULL},
- {"draw_path_collection", (PyCFunction)PyRendererAgg_draw_path_collection, METH_VARARGS, NULL},
- {"draw_quad_mesh", (PyCFunction)PyRendererAgg_draw_quad_mesh, METH_VARARGS, NULL},
- {"draw_gouraud_triangles", (PyCFunction)PyRendererAgg_draw_gouraud_triangles, METH_VARARGS, NULL},
-
- {"clear", (PyCFunction)PyRendererAgg_clear, METH_NOARGS, NULL},
-
- {"copy_from_bbox", (PyCFunction)PyRendererAgg_copy_from_bbox, METH_VARARGS, NULL},
- {"restore_region", (PyCFunction)PyRendererAgg_restore_region, METH_VARARGS, NULL},
- {NULL}
- };
-
- static PyBufferProcs buffer_procs;
- buffer_procs.bf_getbuffer = (getbufferproc)PyRendererAgg_get_buffer;
-
- PyRendererAggType.tp_name = "matplotlib.backends._backend_agg.RendererAgg";
- PyRendererAggType.tp_basicsize = sizeof(PyRendererAgg);
- PyRendererAggType.tp_dealloc = (destructor)PyRendererAgg_dealloc;
- PyRendererAggType.tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE;
- PyRendererAggType.tp_methods = methods;
- PyRendererAggType.tp_init = (initproc)PyRendererAgg_init;
- PyRendererAggType.tp_new = PyRendererAgg_new;
- PyRendererAggType.tp_as_buffer = &buffer_procs;
-
- return &PyRendererAggType;
-}
-
-static struct PyModuleDef moduledef = { PyModuleDef_HEAD_INIT, "_backend_agg" };
-
-PyMODINIT_FUNC PyInit__backend_agg(void)
-{
- import_array();
- PyObject *m;
- if (!(m = PyModule_Create(&moduledef))
- || prepare_and_add_type(PyRendererAgg_init_type(), m)
- // BufferRegion is not constructible from Python, thus not added to the module.
- || PyType_Ready(PyBufferRegion_init_type())
- ) {
- Py_XDECREF(m);
- return NULL;
- }
- return m;
+ py::class_(m, "RendererAgg", py::buffer_protocol())
+ .def(py::init(),
+ "width"_a, "height"_a, "dpi"_a)
+
+ .def("draw_path", &PyRendererAgg_draw_path,
+ "gc"_a, "path"_a, "trans"_a, "face"_a = nullptr)
+ .def("draw_markers", &PyRendererAgg_draw_markers,
+ "gc"_a, "marker_path"_a, "marker_path_trans"_a, "path"_a, "trans"_a,
+ "face"_a = nullptr)
+ .def("draw_text_image", &PyRendererAgg_draw_text_image,
+ "image"_a, "x"_a, "y"_a, "angle"_a, "gc"_a)
+ .def("draw_image", &PyRendererAgg_draw_image,
+ "gc"_a, "x"_a, "y"_a, "image"_a)
+ .def("draw_path_collection", &PyRendererAgg_draw_path_collection,
+ "gc"_a, "master_transform"_a, "paths"_a, "transforms"_a, "offsets"_a,
+ "offset_trans"_a, "facecolors"_a, "edgecolors"_a, "linewidths"_a,
+ "dashes"_a, "antialiaseds"_a, "ignored"_a, "offset_position"_a,
+ py::kw_only(), "hatchcolors"_a = py::array_t().reshape({0, 4}))
+ .def("draw_quad_mesh", &PyRendererAgg_draw_quad_mesh,
+ "gc"_a, "master_transform"_a, "mesh_width"_a, "mesh_height"_a,
+ "coordinates"_a, "offsets"_a, "offset_trans"_a, "facecolors"_a,
+ "antialiased"_a, "edgecolors"_a)
+ .def("draw_gouraud_triangles", &PyRendererAgg_draw_gouraud_triangles,
+ "gc"_a, "points"_a, "colors"_a, "trans"_a = nullptr)
+
+ .def("clear", &RendererAgg::clear)
+
+ .def("copy_from_bbox", &RendererAgg::copy_from_bbox,
+ "bbox"_a)
+ .def("restore_region",
+ py::overload_cast(&RendererAgg::restore_region),
+ "region"_a)
+ .def("restore_region",
+ py::overload_cast(&RendererAgg::restore_region),
+ "region"_a, "xx1"_a, "yy1"_a, "xx2"_a, "yy2"_a, "x"_a, "y"_a)
+
+ .def_buffer([](RendererAgg *renderer) -> py::buffer_info {
+ std::vector shape {
+ renderer->get_height(),
+ renderer->get_width(),
+ 4
+ };
+ std::vector strides {
+ renderer->get_width() * 4,
+ 4,
+ 1
+ };
+ return py::buffer_info(renderer->pixBuffer, shape, strides);
+ });
+
+ py::class_(m, "BufferRegion", py::buffer_protocol())
+ // BufferRegion is not constructible from Python, thus no py::init is added.
+ .def("set_x", &PyBufferRegion_set_x)
+ .def("set_y", &PyBufferRegion_set_y)
+ .def("get_extents", &PyBufferRegion_get_extents)
+ .def_buffer([](BufferRegion *buffer) -> py::buffer_info {
+ std::vector shape {
+ buffer->get_height(),
+ buffer->get_width(),
+ 4
+ };
+ std::vector strides {
+ buffer->get_width() * 4,
+ 4,
+ 1
+ };
+ return py::buffer_info(buffer->get_data(), shape, strides);
+ });
}
diff --git a/src/_c_internal_utils.cpp b/src/_c_internal_utils.cpp
index e118183ecc8b..0dddefaf32e3 100644
--- a/src/_c_internal_utils.cpp
+++ b/src/_c_internal_utils.cpp
@@ -7,7 +7,14 @@
#define WIN32_LEAN_AND_MEAN
// Windows 10, for latest HiDPI API support.
#define WINVER 0x0A00
-#define _WIN32_WINNT 0x0A00
+#if defined(_WIN32_WINNT)
+#if _WIN32_WINNT < WINVER
+#undef _WIN32_WINNT
+#define _WIN32_WINNT WINVER
+#endif
+#else
+#define _WIN32_WINNT WINVER
+#endif
#endif
#include
#ifdef __linux__
@@ -26,7 +33,7 @@ namespace py = pybind11;
using namespace pybind11::literals;
static bool
-mpl_display_is_valid(void)
+mpl_xdisplay_is_valid(void)
{
#ifdef __linux__
void* libX11;
@@ -36,11 +43,11 @@ mpl_display_is_valid(void)
&& (libX11 = dlopen("libX11.so.6", RTLD_LAZY))) {
typedef struct Display* (*XOpenDisplay_t)(char const*);
typedef int (*XCloseDisplay_t)(struct Display*);
- struct Display* display = NULL;
+ struct Display* display = nullptr;
XOpenDisplay_t XOpenDisplay = (XOpenDisplay_t)dlsym(libX11, "XOpenDisplay");
XCloseDisplay_t XCloseDisplay = (XCloseDisplay_t)dlsym(libX11, "XCloseDisplay");
if (XOpenDisplay && XCloseDisplay
- && (display = XOpenDisplay(NULL))) {
+ && (display = XOpenDisplay(nullptr))) {
XCloseDisplay(display);
}
if (dlclose(libX11)) {
@@ -50,18 +57,31 @@ mpl_display_is_valid(void)
return true;
}
}
+ return false;
+#else
+ return true;
+#endif
+}
+
+static bool
+mpl_display_is_valid(void)
+{
+#ifdef __linux__
+ if (mpl_xdisplay_is_valid()) {
+ return true;
+ }
void* libwayland_client;
if (getenv("WAYLAND_DISPLAY")
&& (libwayland_client = dlopen("libwayland-client.so.0", RTLD_LAZY))) {
typedef struct wl_display* (*wl_display_connect_t)(char const*);
typedef void (*wl_display_disconnect_t)(struct wl_display*);
- struct wl_display* display = NULL;
+ struct wl_display* display = nullptr;
wl_display_connect_t wl_display_connect =
(wl_display_connect_t)dlsym(libwayland_client, "wl_display_connect");
wl_display_disconnect_t wl_display_disconnect =
(wl_display_disconnect_t)dlsym(libwayland_client, "wl_display_disconnect");
if (wl_display_connect && wl_display_disconnect
- && (display = wl_display_connect(NULL))) {
+ && (display = wl_display_connect(nullptr))) {
wl_display_disconnect(display);
}
if (dlclose(libwayland_client)) {
@@ -125,7 +145,7 @@ static void
mpl_SetForegroundWindow(py::capsule UNUSED_ON_NON_WINDOWS(handle_p))
{
#ifdef _WIN32
- if (handle_p.name() != "HWND") {
+ if (strcmp(handle_p.name(), "HWND") != 0) {
throw std::runtime_error("Handle must be a value returned from Win32_GetForegroundWindow");
}
HWND handle = static_cast(handle_p.get_pointer());
@@ -158,7 +178,7 @@ mpl_SetProcessDpiAwareness_max(void)
DPI_AWARENESS_CONTEXT_SYSTEM_AWARE}; // Win10
if (IsValidDpiAwarenessContextPtr != NULL
&& SetProcessDpiAwarenessContextPtr != NULL) {
- for (int i = 0; i < sizeof(ctxs) / sizeof(DPI_AWARENESS_CONTEXT); ++i) {
+ for (size_t i = 0; i < sizeof(ctxs) / sizeof(DPI_AWARENESS_CONTEXT); ++i) {
if (IsValidDpiAwarenessContextPtr(ctxs[i])) {
SetProcessDpiAwarenessContextPtr(ctxs[i]);
break;
@@ -176,7 +196,7 @@ mpl_SetProcessDpiAwareness_max(void)
#endif
}
-PYBIND11_MODULE(_c_internal_utils, m)
+PYBIND11_MODULE(_c_internal_utils, m, py::mod_gil_not_used())
{
m.def(
"display_is_valid", &mpl_display_is_valid,
@@ -187,6 +207,16 @@ PYBIND11_MODULE(_c_internal_utils, m)
succeeds, or $WAYLAND_DISPLAY is set and wl_display_connect(NULL)
succeeds.
+ On other platforms, always returns True.)""");
+ m.def(
+ "xdisplay_is_valid", &mpl_xdisplay_is_valid,
+ R"""( --
+ Check whether the current X11 display is valid.
+
+ On Linux, returns True if either $DISPLAY is set and XOpenDisplay(NULL)
+ succeeds. Use this function if you need to specifically check for X11
+ only (e.g., for Tkinter).
+
On other platforms, always returns True.)""");
m.def(
"Win32_GetCurrentProcessExplicitAppUserModelID",
diff --git a/src/_enums.h b/src/_enums.h
new file mode 100644
index 000000000000..18f3d9aac9fa
--- /dev/null
+++ b/src/_enums.h
@@ -0,0 +1,95 @@
+#ifndef MPL_ENUMS_H
+#define MPL_ENUMS_H
+
+#include
+
+// Extension for pybind11: Pythonic enums.
+// This allows creating classes based on ``enum.*`` types.
+// This code was copied from mplcairo, with some slight tweaks.
+// The API is:
+//
+// - P11X_DECLARE_ENUM(py_name: str, py_base_cls: str, ...: {str, enum value}):
+// py_name: The name to expose in the module.
+// py_base_cls: The name of the enum base class to use.
+// ...: The enum name/value pairs to expose.
+//
+// Use this macro to declare an enum and its values.
+//
+// - py11x::bind_enums(m: pybind11::module):
+// m: The module to use to register the enum classes.
+//
+// Place this in PYBIND11_MODULE to register the enums declared by P11X_DECLARE_ENUM.
+
+// a1 includes the opening brace and a2 the closing brace.
+// This definition is compatible with older compiler versions compared to
+// #define P11X_ENUM_TYPE(...) decltype(std::map{std::pair __VA_ARGS__})::mapped_type
+#define P11X_ENUM_TYPE(a1, a2, ...) decltype(std::pair a1, a2)::second_type
+
+#define P11X_CAT2(a, b) a##b
+#define P11X_CAT(a, b) P11X_CAT2(a, b)
+
+namespace p11x {
+ namespace {
+ namespace py = pybind11;
+
+ // Holder is (py_base_cls, [(name, value), ...]) before module init;
+ // converted to the Python class object after init.
+ auto enums = std::unordered_map{};
+
+ auto bind_enums(py::module mod) -> void
+ {
+ for (auto& [py_name, spec]: enums) {
+ auto const& [py_base_cls, pairs] =
+ spec.cast>();
+ mod.attr(py::cast(py_name)) = spec =
+ py::module::import("enum").attr(py_base_cls.c_str())(
+ py_name, pairs, py::arg("module") = mod.attr("__name__"));
+ }
+ }
+ }
+}
+
+// Immediately converting the args to a vector outside of the lambda avoids
+// name collisions.
+#define P11X_DECLARE_ENUM(py_name, py_base_cls, ...) \
+ namespace p11x { \
+ namespace { \
+ [[maybe_unused]] auto const P11X_CAT(enum_placeholder_, __COUNTER__) = \
+ [](auto args) { \
+ py::gil_scoped_acquire gil; \
+ using int_t = std::underlying_type_t; \
+ auto pairs = std::vector>{}; \
+ for (auto& [k, v]: args) { \
+ pairs.emplace_back(k, int_t(v)); \
+ } \
+ p11x::enums[py_name] = pybind11::cast(std::pair{py_base_cls, pairs}); \
+ return 0; \
+ } (std::vector{std::pair __VA_ARGS__}); \
+ } \
+ } \
+ namespace pybind11::detail { \
+ template<> struct type_caster { \
+ using type = P11X_ENUM_TYPE(__VA_ARGS__); \
+ static_assert(std::is_enum_v, "Not an enum"); \
+ PYBIND11_TYPE_CASTER(type, _(py_name)); \
+ bool load(handle src, bool) { \
+ auto cls = p11x::enums.at(py_name); \
+ PyObject* tmp = nullptr; \
+ if (pybind11::isinstance(src, cls) \
+ && (tmp = PyNumber_Index(src.attr("value").ptr()))) { \
+ auto ival = PyLong_AsLong(tmp); \
+ value = decltype(value)(ival); \
+ Py_DECREF(tmp); \
+ return !(ival == -1 && !PyErr_Occurred()); \
+ } else { \
+ return false; \
+ } \
+ } \
+ static handle cast(decltype(value) obj, return_value_policy, handle) { \
+ auto cls = p11x::enums.at(py_name); \
+ return cls(std::underlying_type_t(obj)).inc_ref(); \
+ } \
+ }; \
+ }
+
+#endif /* MPL_ENUMS_H */
diff --git a/src/_image_resample.h b/src/_image_resample.h
index 745fe9f10cd7..7e6c32c6bf64 100644
--- a/src/_image_resample.h
+++ b/src/_image_resample.h
@@ -3,6 +3,8 @@
#ifndef MPL_RESAMPLE_H
#define MPL_RESAMPLE_H
+#define MPL_DISABLE_AGG_GRAY_CLIPPING
+
#include "agg_image_accessors.h"
#include "agg_path_storage.h"
#include "agg_pixfmt_gray.h"
@@ -58,20 +60,16 @@ namespace agg
value_type a;
//--------------------------------------------------------------------
- gray64() {}
+ gray64() = default;
//--------------------------------------------------------------------
- explicit gray64(value_type v_, value_type a_ = 1) :
- v(v_), a(a_) {}
+ explicit gray64(value_type v_, value_type a_ = 1) : v(v_), a(a_) {}
//--------------------------------------------------------------------
- gray64(const self_type& c, value_type a_) :
- v(c.v), a(a_) {}
+ gray64(const self_type& c, value_type a_) : v(c.v), a(a_) {}
//--------------------------------------------------------------------
- gray64(const gray64& c) :
- v(c.v),
- a(c.a) {}
+ gray64(const gray64& c) = default;
//--------------------------------------------------------------------
static AGG_INLINE double to_double(value_type a)
@@ -244,7 +242,7 @@ namespace agg
value_type a;
//--------------------------------------------------------------------
- rgba64() {}
+ rgba64() = default;
//--------------------------------------------------------------------
rgba64(value_type r_, value_type g_, value_type b_, value_type a_= 1) :
@@ -500,52 +498,42 @@ typedef enum {
// T is rgba if and only if it has an T::r field.
template struct is_grayscale : std::true_type {};
-template struct is_grayscale : std::false_type {};
+template struct is_grayscale> : std::false_type {};
+template constexpr bool is_grayscale_v = is_grayscale::value;
template
struct type_mapping
{
- using blender_type = typename std::conditional<
- is_grayscale::value,
+ using blender_type = std::conditional_t<
+ is_grayscale_v,
agg::blender_gray,
- typename std::conditional<
- std::is_same::value,
+ std::conditional_t<
+ std::is_same_v,
fixed_blender_rgba_plain,
agg::blender_rgba_plain
- >::type
- >::type;
- using pixfmt_type = typename std::conditional<
- is_grayscale::value,
+ >
+ >;
+ using pixfmt_type = std::conditional_t<
+ is_grayscale_v,
agg::pixfmt_alpha_blend_gray,
agg::pixfmt_alpha_blend_rgba
- >::type;
- using pixfmt_pre_type = typename std::conditional<
- is_grayscale::value,
- pixfmt_type,
- agg::pixfmt_alpha_blend_rgba<
- typename std::conditional<
- std::is_same::value,
- fixed_blender_rgba_pre,
- agg::blender_rgba_pre
- >::type,
- agg::rendering_buffer>
- >::type;
- template using span_gen_affine_type = typename std::conditional<
- is_grayscale::value,
+ >;
+ template using span_gen_affine_type = std::conditional_t<
+ is_grayscale_v,
agg::span_image_resample_gray_affine,
agg::span_image_resample_rgba_affine
- >::type;
- template using span_gen_filter_type = typename std::conditional<
- is_grayscale::value,
+ >;
+ template using span_gen_filter_type = std::conditional_t<
+ is_grayscale_v,
agg::span_image_filter_gray,
agg::span_image_filter_rgba
- >::type;
- template using span_gen_nn_type = typename std::conditional<
- is_grayscale::value,
+ >;
+ template using span_gen_nn_type = std::conditional_t<
+ is_grayscale_v,
agg::span_image_filter_gray_nn