"
+ 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 = []
@@ -459,19 +461,74 @@ def test_figure_hook():
def test_multiple_same_figure_calls():
- fig = mpl.pyplot.figure(1, figsize=(1, 2))
+ fig = plt.figure(1, figsize=(1, 2))
with pytest.warns(UserWarning, match="Ignoring specified arguments in this call"):
- fig2 = mpl.pyplot.figure(1, figsize=(3, 4))
+ fig2 = plt.figure(1, figsize=np.array([3, 4]))
with pytest.warns(UserWarning, match="Ignoring specified arguments in this call"):
- mpl.pyplot.figure(fig, figsize=(5, 6))
+ plt.figure(fig, figsize=np.array([5, 6]))
assert fig is fig2
- fig3 = mpl.pyplot.figure(1) # Checks for false warnings
+ fig3 = plt.figure(1) # Checks for false warnings
assert fig is fig3
+def test_register_existing_figure_with_pyplot():
+ from matplotlib.figure import Figure
+ # start with a standalone figure
+ fig = Figure()
+ assert fig.canvas.manager is None
+ with pytest.raises(AttributeError):
+ # Heads-up: This will change to returning None in the future
+ # See docstring for the Figure.number property
+ fig.number
+ # register the Figure with pyplot
+ plt.figure(fig)
+ assert fig.number == 1
+ # the figure can now be used in pyplot
+ plt.suptitle("my title")
+ assert fig.get_suptitle() == "my title"
+ # it also has a manager that is properly wired up in the pyplot state
+ assert plt._pylab_helpers.Gcf.get_fig_manager(fig.number) is fig.canvas.manager
+ # and we can regularly switch the pyplot state
+ fig2 = plt.figure()
+ assert fig2.number == 2
+ assert plt.figure(1) is fig
+ assert plt.gcf() is fig
+
+
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 e28b04025b5e..1205487cfe94 100644
--- a/lib/matplotlib/tests/test_quiver.py
+++ b/lib/matplotlib/tests/test_quiver.py
@@ -26,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',
@@ -43,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():
@@ -380,7 +381,7 @@ def draw_quiverkey_setzorder(fig, zorder=None):
@pytest.mark.parametrize('zorder', [0, 2, 5, None])
-@check_figures_equal(extensions=['png'])
+@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 0aa3ec0ba603..eb9d3bc8866b 100644
--- a/lib/matplotlib/tests/test_rcparams.py
+++ b/lib/matplotlib/tests/test_rcparams.py
@@ -13,6 +13,7 @@
import matplotlib.pyplot as plt
import matplotlib.colors as mcolors
import numpy as np
+from matplotlib import rcsetup
from matplotlib.rcsetup import (
validate_bool,
validate_color,
@@ -257,6 +258,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"]) +
@@ -455,6 +458,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
@@ -521,10 +530,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",
@@ -536,6 +546,28 @@ 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.xdisplay_is_valid(),
reason="headless")
@@ -554,6 +586,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
@@ -563,40 +596,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
@@ -656,3 +655,39 @@ 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
+
+
+def test_all_params_defined_as_code():
+ assert set(p.name for p in rcsetup._params) == set(mpl.rcParams.keys())
+
+
+def test_validators_defined_as_code():
+ for param in rcsetup._params:
+ validator = rcsetup._convert_validator_spec(param.name, param.validator)
+ assert validator == rcsetup._validators[param.name]
+
+
+def test_defaults_as_code():
+ for param in rcsetup._params:
+ if param.name == 'backend':
+ # backend has special handling and no meaningful default
+ continue
+ assert param.default == mpl.rcParamsDefault[param.name], param.name
diff --git a/lib/matplotlib/tests/test_sankey.py b/lib/matplotlib/tests/test_sankey.py
index cbb7f516a65c..745db5f767b2 100644
--- a/lib/matplotlib/tests/test_sankey.py
+++ b/lib/matplotlib/tests/test_sankey.py
@@ -6,7 +6,7 @@
def test_sankey():
- # lets just create a sankey instance and check the code runs
+ # let's just create a sankey instance and check the code runs
sankey = Sankey()
sankey.add()
@@ -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 a052c24cb655..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)
diff --git a/lib/matplotlib/tests/test_skew.py b/lib/matplotlib/tests/test_skew.py
index fd7e7cebfacb..125ecd7ff606 100644
--- a/lib/matplotlib/tests/test_skew.py
+++ b/lib/matplotlib/tests/test_skew.py
@@ -25,9 +25,9 @@ def draw(self, renderer):
for artist in [self.gridline, self.tick1line, self.tick2line,
self.label1, self.label2]:
stack.callback(artist.set_visible, artist.get_visible())
- needs_lower = transforms.interval_contains(
+ needs_lower = transforms._interval_contains(
self.axes.lower_xlim, self.get_loc())
- needs_upper = transforms.interval_contains(
+ needs_upper = transforms._interval_contains(
self.axes.upper_xlim, self.get_loc())
self.tick1line.set_visible(
self.tick1line.get_visible() and needs_lower)
@@ -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 6e7b5ec5e50e..c6f4e13c74c2 100644
--- a/lib/matplotlib/tests/test_sphinxext.py
+++ b/lib/matplotlib/tests/test_sphinxext.py
@@ -13,14 +13,21 @@
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):
# Build the pages with warnings turned into errors
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
@@ -33,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)
@@ -75,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()
@@ -122,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::
@@ -147,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::
@@ -167,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::
@@ -183,15 +183,62 @@ 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_plot_html_code_caption(tmp_path):
+ # Test that :code-caption: option adds caption to code block
+ 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::
+ :include-source:
+ :code-caption: Example plotting code
+
+ import matplotlib.pyplot as plt
+ plt.plot([1, 2, 3], [1, 4, 9])
+""")
+ html_dir = tmp_path / '_build' / 'html'
+ build_sphinx_html(tmp_path, doctree_dir, html_dir)
+
+ # Check that the HTML contains the code caption
+ html_content = (html_dir / 'index.html').read_text(encoding='utf-8')
+ assert 'Example plotting code' in html_content
+ # Verify the caption is associated with the code block
+ # (appears in a caption element)
+ assert ' info.misses
+def test_metrics_cache2():
+ # dig into the signature to get the mutable default used as a cache
+ renderer_cache = inspect.signature(
+ mpl.text._get_text_metrics_function
+ ).parameters['_cache'].default
+ gc.collect()
+ renderer_cache.clear()
+
+ def helper():
+ fig, ax = plt.subplots()
+ fig.draw_without_rendering()
+ # show we hit the outer cache
+ assert len(renderer_cache) == 1
+ func = renderer_cache[fig.canvas.get_renderer()]
+ cache_info = func.cache_info()
+ # show we hit the inner cache
+ assert cache_info.currsize > 0
+ assert cache_info.currsize == cache_info.misses
+ assert cache_info.hits > cache_info.misses
+ plt.close(fig)
+
+ helper()
+ gc.collect()
+ # show the outer cache has a lifetime tied to the renderer (via the figure)
+ assert len(renderer_cache) == 0
+
+
def test_annotate_offset_fontsize():
# Test that offset_fontsize parameter works and uses accurate values
fig, ax = plt.subplots()
@@ -958,7 +990,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 +1006,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 +1015,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)
@@ -1103,8 +1135,9 @@ def test_empty_annotation_get_window_extent():
assert points[0, 1] == 50.0
+# TODO: tighten tolerance after baseline image is regenerated for text overhaul
@image_comparison(baseline_images=['basictext_wrap'],
- extensions=['png'])
+ extensions=['png'], tol=0.3)
def test_basic_wrap():
fig = plt.figure()
plt.axis([0, 10, 0, 10])
@@ -1120,8 +1153,9 @@ def test_basic_wrap():
plt.text(-1, 0, t, ha='left', rotation=-15, wrap=True)
+# TODO: tighten tolerance after baseline image is regenerated for text overhaul
@image_comparison(baseline_images=['fonttext_wrap'],
- extensions=['png'])
+ extensions=['png'], tol=0.3)
def test_font_wrap():
fig = plt.figure()
plt.axis([0, 10, 0, 10])
@@ -1135,3 +1169,60 @@ 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']
+
+
+# TODO: tighten tolerance after baseline image is regenerated for text overhaul
+@image_comparison(baseline_images=['xtick_rotation_mode'],
+ remove_text=False, extensions=['png'], style='mpl20', tol=0.3)
+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)
+
+
+# TODO: tighten tolerance after baseline image is regenerated for text overhaul
+@image_comparison(baseline_images=['ytick_rotation_mode'],
+ remove_text=False, extensions=['png'], style='mpl20', tol=0.3)
+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 222a0d7e11b0..c3c53ebaea73 100644
--- a/lib/matplotlib/tests/test_ticker.py
+++ b/lib/matplotlib/tests/test_ticker.py
@@ -6,7 +6,7 @@
from packaging.version import parse as parse_version
import numpy as np
-from numpy.testing import assert_almost_equal, assert_array_equal
+from numpy.testing import assert_almost_equal, assert_array_equal, assert_allclose
import pytest
import matplotlib as mpl
@@ -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,6 +356,10 @@ 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):
"""
@@ -377,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):
@@ -387,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):
@@ -859,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']
@@ -1235,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))
@@ -1591,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
@@ -1825,14 +1914,57 @@ 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)
+ if parse_version(np.version.version).major < 2:
+ assert_allclose(ll.tick_values(*lims), ticks, rtol=2e-16)
+ else:
+ 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 96e78b6828f8..2b4351a5cfbb 100644
--- a/lib/matplotlib/tests/test_transforms.py
+++ b/lib/matplotlib/tests/test_transforms.py
@@ -694,9 +694,9 @@ def test_contains_branch(self):
assert not self.stack1.contains_branch(self.tn1 + self.ta2)
blend = mtransforms.BlendedGenericTransform(self.tn2, self.stack2)
- x, y = blend.contains_branch_seperately(self.stack2_subset)
+ x, y = blend.contains_branch_separately(self.stack2_subset)
stack_blend = self.tn3 + blend
- sx, sy = stack_blend.contains_branch_seperately(self.stack2_subset)
+ sx, sy = stack_blend.contains_branch_separately(self.stack2_subset)
assert x is sx is False
assert y is sy is True
@@ -835,6 +835,16 @@ def assert_bbox_eq(bbox1, bbox2):
assert_array_equal(bbox1.bounds, bbox2.bounds)
+def test_bbox_is_finite():
+ assert not Bbox([(1, 1), (1, 1)])._is_finite()
+ assert not Bbox([(0, 0), (np.inf, 1)])._is_finite()
+ assert not Bbox([(-np.inf, 0), (2, 2)])._is_finite()
+ assert not Bbox([(np.nan, 0), (2, 2)])._is_finite()
+ assert Bbox([(0, 0), (0, 2)])._is_finite()
+ assert Bbox([(0, 0), (2, 0)])._is_finite()
+ assert Bbox([(0, 0), (1, 2)])._is_finite()
+
+
def test_bbox_frozen_copies_minpos():
bbox = mtransforms.Bbox.from_extents(0.0, 0.0, 1.0, 1.0, minpos=1.0)
frozen = bbox.frozen()
@@ -891,8 +901,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(
@@ -968,7 +977,7 @@ def test_nonsingular():
zero_expansion = np.array([-0.001, 0.001])
cases = [(0, np.nan), (0, 0), (0, 7.9e-317)]
for args in cases:
- out = np.array(mtransforms.nonsingular(*args))
+ out = np.array(mtransforms._nonsingular(*args))
assert_array_equal(out, zero_expansion)
@@ -987,12 +996,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()
@@ -1053,7 +1056,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)
@@ -1090,21 +1093,21 @@ def test_transformedbbox_contains():
def test_interval_contains():
- assert mtransforms.interval_contains((0, 1), 0.5)
- assert mtransforms.interval_contains((0, 1), 0)
- assert mtransforms.interval_contains((0, 1), 1)
- assert not mtransforms.interval_contains((0, 1), -1)
- assert not mtransforms.interval_contains((0, 1), 2)
- assert mtransforms.interval_contains((1, 0), 0.5)
+ assert mtransforms._interval_contains((0, 1), 0.5)
+ assert mtransforms._interval_contains((0, 1), 0)
+ assert mtransforms._interval_contains((0, 1), 1)
+ assert not mtransforms._interval_contains((0, 1), -1)
+ assert not mtransforms._interval_contains((0, 1), 2)
+ assert mtransforms._interval_contains((1, 0), 0.5)
def test_interval_contains_open():
- assert mtransforms.interval_contains_open((0, 1), 0.5)
- assert not mtransforms.interval_contains_open((0, 1), 0)
- assert not mtransforms.interval_contains_open((0, 1), 1)
- 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)
+ assert mtransforms._interval_contains_open((0, 1), 0.5)
+ assert not mtransforms._interval_contains_open((0, 1), 0)
+ assert not mtransforms._interval_contains_open((0, 1), 1)
+ 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():
diff --git a/lib/matplotlib/tests/test_triangulation.py b/lib/matplotlib/tests/test_triangulation.py
index 337443eb1e27..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.
"""
diff --git a/lib/matplotlib/tests/test_type1font.py b/lib/matplotlib/tests/test_type1font.py
index 9b8a2d1f07c6..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,7 +137,7 @@ 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()
diff --git a/lib/matplotlib/tests/test_typing.py b/lib/matplotlib/tests/test_typing.py
new file mode 100644
index 000000000000..c9fc8e5b162f
--- /dev/null
+++ b/lib/matplotlib/tests/test_typing.py
@@ -0,0 +1,51 @@
+import re
+import typing
+from pathlib import Path
+
+import matplotlib.pyplot as plt
+from matplotlib.colors import Colormap
+from matplotlib.typing import RcKeyType, RcGroupKeyType
+
+
+def test_cm_stub_matches_runtime_colormaps():
+ runtime_cm = plt.cm
+ runtime_cmaps = {
+ name
+ for name, value in vars(runtime_cm).items()
+ if isinstance(value, Colormap)
+ }
+
+ cm_pyi_path = Path(__file__).parent.parent / "cm.pyi"
+ assert cm_pyi_path.exists(), f"{cm_pyi_path} does not exist"
+
+ pyi_content = cm_pyi_path.read_text(encoding='utf-8')
+
+ stubbed_cmaps = set(
+ re.findall(r"^(\w+):\s+colors\.Colormap", pyi_content, re.MULTILINE)
+ )
+
+ assert runtime_cmaps, (
+ "No colormaps variables found at runtime in matplotlib.colors"
+ )
+ assert stubbed_cmaps, (
+ "No colormaps found in cm.pyi"
+ )
+
+ assert runtime_cmaps == stubbed_cmaps
+
+
+def test_rcparam_stubs():
+ runtime_rc_keys = {
+ name for name in plt.rcParamsDefault.keys()
+ if not name.startswith('_')
+ }
+
+ assert {*typing.get_args(RcKeyType)} == runtime_rc_keys
+
+ runtime_rc_group_keys = set()
+ for name in runtime_rc_keys:
+ groups = name.split('.')
+ for i in range(1, len(groups)):
+ runtime_rc_group_keys.add('.'.join(groups[:i]))
+
+ assert {*typing.get_args(RcGroupKeyType)} == runtime_rc_group_keys
diff --git a/lib/matplotlib/tests/test_units.py b/lib/matplotlib/tests/test_units.py
index ae6372fea1e1..c13c54a101fc 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
@@ -78,8 +80,9 @@ def default_units(value, axis):
# Tests that the conversion machinery works properly for classes that
# work as a facade over numpy arrays (like pint)
+# TODO: tighten tolerance after baseline image is regenerated for text overhaul
@image_comparison(['plot_pint.png'], style='mpl20',
- tol=0 if platform.machine() == 'x86_64' else 0.03)
+ tol=0.03 if platform.machine() == 'x86_64' else 0.04)
def test_numpy_facade(quantity_converter):
# use former defaults to match existing baseline image
plt.rcParams['axes.formatter.limits'] = -7, 7
@@ -140,8 +143,9 @@ def test_jpl_bar_units():
ax.set_ylim([b - 1 * day, b + w[-1] + (1.001) * day])
+# TODO: tighten tolerance after baseline image is regenerated for text overhaul
@image_comparison(['jpl_barh_units.png'],
- savefig_kwarg={'dpi': 120}, style='mpl20')
+ savefig_kwarg={'dpi': 120}, style='mpl20', tol=0.02)
def test_jpl_barh_units():
import matplotlib.testing.jpl_units as units
units.register()
@@ -189,7 +193,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 +240,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 +339,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..78d9fd6cc948 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,20 +157,84 @@ 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:
_old_gs_version = True
+# TODO: tighten tolerance after baseline image is regenerated for text overhaul
@image_comparison(baseline_images=['rotation'], extensions=['eps', 'pdf', 'png', 'svg'],
- style='mpl20', tol=3.91 if _old_gs_version else 0)
+ style='mpl20', tol=3.91 if _old_gs_version else 0.2)
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 +250,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 585d846944e8..9eebf165e71f 100644
--- a/lib/matplotlib/tests/test_widgets.py
+++ b/lib/matplotlib/tests/test_widgets.py
@@ -3,13 +3,13 @@
import operator
from unittest import mock
-from matplotlib.backend_bases import MouseEvent
+import matplotlib as mpl
+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
@@ -71,11 +71,10 @@ def test_rectangle_selector(ax, kwargs):
onselect = mock.Mock(spec=noop, return_value=None)
tool = widgets.RectangleSelector(ax, onselect=onselect, **kwargs)
- do_event(tool, 'press', xdata=100, ydata=100, button=1)
- do_event(tool, 'onmove', xdata=199, ydata=199, button=1)
-
+ 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,
@@ -137,7 +136,7 @@ def test_rectangle_drag(ax, drag_from_anywhere, new_center):
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
#
@@ -178,8 +177,8 @@ def test_rectangle_selector_set_props_handle_props(ax):
def test_rectangle_resize(ax):
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
@@ -446,11 +445,11 @@ def test_rectangle_rotate(ax, selector_class):
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)
@@ -623,27 +622,36 @@ def test_rectangle_selector_ignore_outside(ax, ignore_event_outside):
('horizontal', False, dict(interactive=True)),
])
def test_span_selector(ax, orientation, onmove_callback, kwargs):
- onselect = mock.Mock(spec=noop, return_value=None)
- onmove = mock.Mock(spec=noop, return_value=None)
- if onmove_callback:
- kwargs['onmove_callback'] = onmove
-
- # While at it, also test that span selectors work in the presence of twin axes on
- # top of the axes that contain the selector. Note that we need to unforce the axes
- # aspect here, otherwise the twin axes forces the original axes' limits (to respect
- # aspect=1) which makes some of the values below go out of bounds.
+ # Also test that span selectors work in the presence of twin axes or for
+ # outside-inset axes on top of the axes that contain the selector. Note
+ # that we need to unforce the axes aspect here, otherwise the twin axes
+ # forces the original axes' limits (to respect aspect=1) which makes some
+ # of the values below go out of bounds.
ax.set_aspect("auto")
- tax = ax.twinx()
-
- tool = widgets.SpanSelector(ax, onselect, orientation, **kwargs)
- do_event(tool, 'press', xdata=100, ydata=100, button=1)
- # move outside of axis
- do_event(tool, 'onmove', xdata=199, ydata=199, button=1)
- do_event(tool, 'release', xdata=250, ydata=250, button=1)
-
- onselect.assert_called_once_with(100, 199)
- if onmove_callback:
- onmove.assert_called_once_with(100, 199)
+ ax.twinx()
+ child = ax.inset_axes([0, 1, 1, 1], xlim=(0, 200), ylim=(0, 200))
+
+ for target in [ax, child]:
+ selected = []
+ def onselect(*args): selected.append(args)
+ moved = []
+ def onmove(*args): moved.append(args)
+ if onmove_callback:
+ kwargs['onmove_callback'] = onmove
+
+ tool = widgets.SpanSelector(target, onselect, orientation, **kwargs)
+ MouseEvent._from_ax_coords(
+ "button_press_event", target, (100, 100), 1)._process()
+ # move outside of axis
+ MouseEvent._from_ax_coords(
+ "motion_notify_event", target, (199, 199), 1)._process()
+ MouseEvent._from_ax_coords(
+ "button_release_event", target, (250, 250), 1)._process()
+
+ # tol is set by pixel size (~100 pixels & span of 200 data units)
+ assert_allclose(selected, [(100, 199)], atol=.5)
+ if onmove_callback:
+ assert_allclose(moved, [(100, 199)], atol=.5)
@pytest.mark.parametrize('interactive', [True, False])
@@ -783,7 +791,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
@@ -905,10 +913,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
@@ -918,16 +924,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
@@ -988,9 +990,9 @@ def test_lasso_selector(ax, kwargs):
onselect = mock.Mock(spec=noop, return_value=None)
tool = widgets.LassoSelector(ax, onselect=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)
+ 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)])
@@ -1066,7 +1068,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')
@@ -1078,9 +1080,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
@@ -1126,7 +1128,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=[])
@@ -1136,7 +1138,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}
@@ -1160,7 +1162,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')
@@ -1171,7 +1173,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=[])
@@ -1183,7 +1185,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}
@@ -1343,162 +1345,160 @@ 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)
+ 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)
@@ -1510,15 +1510,13 @@ def test_polygon_selector_set_props_handle_props(ax, draw_bounding_box):
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'
@@ -1549,17 +1547,17 @@ def test_rect_visibility(fig_test, fig_ref):
# 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 = functools.reduce(operator.iadd, event_sequence, [])
check_polygon_selector(event_sequence, verts, 2,
@@ -1567,14 +1565,14 @@ def test_polygon_selector_remove(idx, 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)
@@ -1584,27 +1582,27 @@ 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, draw_bounding_box=draw_bounding_box)
- for (etype, event_args) in event_sequence:
- do_event(tool, etype, **event_args)
+ 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()
@@ -1615,14 +1613,13 @@ def test_polygon_selector_verts_setter(fig_test, fig_ref, draw_bounding_box):
ax_ref = fig_ref.add_subplot()
tool_ref = widgets.PolygonSelector(ax_ref, 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)
+ 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):
@@ -1630,40 +1627,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, 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.get_figure(root=True).canvas
+ 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)])
@@ -1671,10 +1657,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(
@@ -1687,9 +1671,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
@@ -1706,25 +1690,28 @@ def test_polygon_selector_clear_method(ax):
@pytest.mark.parametrize("horizOn", [False, True])
@pytest.mark.parametrize("vertOn", [False, True])
-def test_MultiCursor(horizOn, vertOn):
+@pytest.mark.parametrize("with_deprecated_canvas", [False, True])
+def test_MultiCursor(horizOn, vertOn, with_deprecated_canvas):
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
- multi = widgets.MultiCursor(
- None, (ax1, ax2), useblit=False, horizOn=horizOn, vertOn=vertOn
- )
+ if with_deprecated_canvas:
+ with pytest.warns(mpl.MatplotlibDeprecationWarning, match=r"canvas.*deprecat"):
+ multi = widgets.MultiCursor(
+ None, (ax1, ax2), useblit=False, horizOn=horizOn, vertOn=vertOn
+ )
+ else:
+ # useblit=false to avoid having to draw the figure to cache the renderer
+ multi = widgets.MultiCursor(
+ (ax1, ax2), useblit=False, horizOn=horizOn, vertOn=vertOn
+ )
# Only two of the axes should have a line drawn on them.
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
fig.canvas.draw()
@@ -1742,8 +1729,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()]) == (
@@ -1751,9 +1737,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 a374bfba8cab..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
@@ -63,10 +62,17 @@ class TexManager:
Repeated calls to this constructor always return the same instance.
"""
- _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}',
@@ -80,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',
@@ -105,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
@@ -163,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):
@@ -197,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",
@@ -220,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}}}%",
@@ -235,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(
@@ -273,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,
@@ -286,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):
@@ -303,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
@@ -357,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 0b65450f760b..0be32ca86009 100644
--- a/lib/matplotlib/text.py
+++ b/lib/matplotlib/text.py
@@ -64,17 +64,89 @@ def _get_textbox(text, renderer):
def _get_text_metrics_with_cache(renderer, text, fontprop, ismath, dpi):
"""Call ``renderer.get_text_width_height_descent``, caching the results."""
- # Cached based on a copy of fontprop so that later in-place mutations of
- # the passed-in argument do not mess up the cache.
- return _get_text_metrics_with_cache_impl(
- weakref.ref(renderer), text, fontprop.copy(), ismath, dpi)
+ # hit the outer cache layer and get the function to compute the metrics
+ # for this renderer instance
+ get_text_metrics = _get_text_metrics_function(renderer)
+ # call the function to compute the metrics and return
+ #
+ # We pass a copy of the fontprop because FontProperties is both mutable and
+ # has a `__hash__` that depends on that mutable state. This is not ideal
+ # as it means the hash of an object is not stable over time which leads to
+ # very confusing behavior when used as keys in dictionaries or hashes.
+ return get_text_metrics(text, fontprop.copy(), ismath, dpi)
-@functools.lru_cache(4096)
-def _get_text_metrics_with_cache_impl(
- renderer_ref, text, fontprop, ismath, dpi):
- # dpi is unused, but participates in cache invalidation (via the renderer).
- return renderer_ref().get_text_width_height_descent(text, fontprop, ismath)
+
+def _get_text_metrics_function(input_renderer, _cache=weakref.WeakKeyDictionary()):
+ """
+ Helper function to provide a two-layered cache for font metrics
+
+
+ To get the rendered size of a size of string we need to know:
+ - what renderer we are using
+ - the current dpi of the renderer
+ - the string
+ - the font properties
+ - is it math text or not
+
+ We do this as a two-layer cache with the outer layer being tied to a
+ renderer instance and the inner layer handling everything else.
+
+ The outer layer is implemented as `.WeakKeyDictionary` keyed on the
+ renderer. As long as someone else is holding a hard ref to the renderer
+ we will keep the cache alive, but it will be automatically dropped when
+ the renderer is garbage collected.
+
+ The inner layer is provided by an lru_cache with a large maximum size (such
+ that we expect very few cache misses in actual use cases). As the
+ dpi is mutable on the renderer, we need to explicitly include it as part of
+ the cache key on the inner layer even though we do not directly use it (it is
+ used in the method call on the renderer).
+
+ This function takes a renderer and returns a function that can be used to
+ get the font metrics.
+
+ Parameters
+ ----------
+ input_renderer : maplotlib.backend_bases.RendererBase
+ The renderer to set the cache up for.
+
+ _cache : dict, optional
+ We are using the mutable default value to attach the cache to the function.
+
+ In principle you could pass a different dict-like to this function to inject
+ a different cache, but please don't. This is an internal function not meant to
+ be reused outside of the narrow context we need it for.
+
+ There is a possible race condition here between threads, we may need to drop the
+ mutable default and switch to a threadlocal variable in the future.
+
+ """
+ if (_text_metrics := _cache.get(input_renderer, None)) is None:
+ # We are going to include this in the closure we put as values in the
+ # cache. Closing over a hard-ref would create an unbreakable reference
+ # cycle.
+ renderer_ref = weakref.ref(input_renderer)
+
+ # define the function locally to get a new lru_cache per renderer
+ @functools.lru_cache(4096)
+ # dpi is unused, but participates in cache invalidation (via the renderer).
+ def _text_metrics(text, fontprop, ismath, dpi):
+ # this should never happen under normal use, but this is a better error to
+ # raise than an AttributeError on `None`
+ if (local_renderer := renderer_ref()) is None:
+ raise RuntimeError(
+ "Trying to get text metrics for a renderer that no longer exists. "
+ "This should never happen and is evidence of a bug elsewhere."
+ )
+ # do the actual method call we need and return the result
+ return local_renderer.get_text_width_height_descent(text, fontprop, ismath)
+
+ # stash the function for later use.
+ _cache[input_renderer] = _text_metrics
+
+ # return the inner function
+ return _text_metrics
@_docstring.interpd
@@ -188,8 +260,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 +372,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
@@ -454,6 +528,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':
@@ -509,14 +588,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
--------
::
@@ -551,6 +637,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
@@ -678,10 +766,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):
@@ -974,7 +1062,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
----------
@@ -1336,10 +1427,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):
@@ -1380,6 +1468,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`."""
@@ -1511,9 +1625,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()
diff --git a/lib/matplotlib/text.pyi b/lib/matplotlib/text.pyi
index d65a3dc4c7da..7223693945ec 100644
--- a/lib/matplotlib/text.pyi
+++ b/lib/matplotlib/text.pyi
@@ -2,7 +2,7 @@ from .artist import Artist
from .backend_bases import RendererBase
from .font_manager import FontProperties
from .offsetbox import DraggableAnnotation
-from .path import Path
+from pathlib import Path
from .patches import FancyArrowPatch, FancyBboxPatch
from .textpath import ( # noqa: F401, reexported API
TextPath as TextPath,
@@ -14,7 +14,7 @@ from .transforms import (
Transform,
)
-from collections.abc import Callable, Iterable
+from collections.abc import Iterable
from typing import Any, Literal
from .typing import ColorType, CoordsType
@@ -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__(
diff --git a/lib/matplotlib/textpath.py b/lib/matplotlib/textpath.py
index 83182e3f5400..8deae19c42e7 100644
--- a/lib/matplotlib/textpath.py
+++ b/lib/matplotlib/textpath.py
@@ -232,23 +232,16 @@ 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)
+ font = get_font(text.font.resolve_path())
+ if text.font.subfont:
+ raise NotImplementedError("Indexing TTC fonts is not supported yet")
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=LoadFlags.TARGET_LIGHT)
- elif isinstance(glyph_name_or_index, int):
- self._select_native_charmap(font)
- font.load_char(
- glyph_name_or_index, flags=LoadFlags.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 +262,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 0053031ece3e..e27d71974471 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
-----
@@ -444,13 +449,12 @@ class ScalarFormatter(Formatter):
"""
- 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)
@@ -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
@@ -1014,7 +1029,7 @@ def __call__(self, x, pos=None):
return ''
vmin, vmax = self.axis.get_view_interval()
- vmin, vmax = mtransforms.nonsingular(vmin, vmax, expander=0.05)
+ vmin, vmax = mtransforms._nonsingular(vmin, vmax, expander=0.05)
s = self._num_to_string(x, vmin, vmax)
return self.fix_minus(s)
@@ -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
@@ -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):
@@ -1666,7 +1730,7 @@ def nonsingular(self, v0, v1):
default view limits.
- Otherwise, ``(v0, v1)`` is returned without modification.
"""
- return mtransforms.nonsingular(v0, v1, expander=.05)
+ return mtransforms._nonsingular(v0, v1, expander=.05)
def view_limits(self, vmin, vmax):
"""
@@ -1674,7 +1738,7 @@ def view_limits(self, vmin, vmax):
Subclasses should override this method to change locator behaviour.
"""
- return mtransforms.nonsingular(vmin, vmax)
+ return mtransforms._nonsingular(vmin, vmax)
class IndexLocator(Locator):
@@ -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
@@ -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
@@ -1820,7 +1881,7 @@ def __call__(self):
return self.tick_values(vmin, vmax)
def tick_values(self, vmin, vmax):
- vmin, vmax = mtransforms.nonsingular(vmin, vmax, expander=0.05)
+ vmin, vmax = mtransforms._nonsingular(vmin, vmax, expander=0.05)
if (vmin, vmax) in self.presets:
return self.presets[(vmin, vmax)]
@@ -1849,7 +1910,7 @@ def view_limits(self, vmin, vmax):
vmin = math.floor(scale * vmin) / scale
vmax = math.ceil(scale * vmax) / scale
- return mtransforms.nonsingular(vmin, vmax)
+ return mtransforms._nonsingular(vmin, vmax)
class MultipleLocator(Locator):
@@ -1919,7 +1980,7 @@ def view_limits(self, dmin, dmax):
vmin = dmin
vmax = dmax
- return mtransforms.nonsingular(vmin, vmax)
+ return mtransforms._nonsingular(vmin, vmax)
def scale_range(vmin, vmax, n=1, threshold=100):
@@ -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
@@ -2174,7 +2236,7 @@ def tick_values(self, vmin, vmax):
if self._symmetric:
vmax = max(abs(vmin), abs(vmax))
vmin = -vmax
- vmin, vmax = mtransforms.nonsingular(
+ vmin, vmax = mtransforms._nonsingular(
vmin, vmax, expander=1e-13, tiny=1e-14)
locs = self._raw_ticks(vmin, vmax)
@@ -2192,7 +2254,7 @@ def view_limits(self, dmin, dmax):
dmax = max(abs(dmin), abs(dmax))
dmin = -dmax
- dmin, dmax = mtransforms.nonsingular(
+ dmin, dmax = mtransforms._nonsingular(
dmin, dmax, expander=1e-12, tiny=1e-13)
if mpl.rcParams['axes.autolimit_mode'] == 'round_numbers':
@@ -2341,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:
@@ -2359,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:
@@ -2380,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)
@@ -2605,7 +2718,7 @@ def view_limits(self, vmin, vmax):
vmin = _decade_less(vmin, b)
vmax = _decade_greater(vmax, b)
- return mtransforms.nonsingular(vmin, vmax)
+ return mtransforms._nonsingular(vmin, vmax)
class AsinhLocator(Locator):
@@ -2873,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 fd8e41848671..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):
@@ -298,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 2934b0a77809..44d01926f2e8 100644
--- a/lib/matplotlib/transforms.py
+++ b/lib/matplotlib/transforms.py
@@ -35,7 +35,6 @@
# `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
@@ -45,9 +44,8 @@
import numpy as np
from numpy.linalg import inv
-from matplotlib import _api
-from matplotlib._path import (
- affine_transform, count_bboxes_overlapping_bbox, update_path_extents)
+from matplotlib import _api, _docstring
+from matplotlib._path import affine_transform, count_bboxes_overlapping_bbox
from .path import Path
DEBUG = False
@@ -99,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
"""
@@ -141,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 = {}
@@ -217,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:
@@ -242,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]
@@ -252,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]
@@ -262,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]
@@ -272,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]
@@ -282,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]
@@ -292,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]
@@ -364,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)
@@ -376,6 +377,29 @@ def extents(self):
def get_points(self):
raise NotImplementedError
+ def _is_finite(self):
+ """
+ Return whether the bounding box is finite and not degenerate to a
+ single point.
+
+ We count the box as finite if neither width nor height are infinite
+ and at least one direction is non-zero; i.e. a point is not finite,
+ but a horizontal or vertical line is.
+
+ .. versionadded:: 3.11
+
+ Notes
+ -----
+ We keep this private for now because concise naming is hard and
+ because we are not sure how universal the concept is. It is
+ currently used only for filtering bboxes to be included in
+ tightbbox calculation, but I'm unsure whether single points
+ should be included there as well.
+ """
+ width = self.width
+ height = self.height
+ return (width > 0 or height > 0) and width < np.inf and height < np.inf
+
def containsx(self, x):
"""
Return whether *x* is in the closed (:attr:`x0`, :attr:`x1`) interval.
@@ -865,13 +889,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]
@@ -896,8 +938,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):
"""
@@ -915,8 +958,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):
"""
@@ -1397,7 +1441,7 @@ def contains_branch(self, other):
return True
return False
- def contains_branch_seperately(self, other_transform):
+ def contains_branch_separately(self, other_transform):
"""
Return whether the given branch is a sub-tree of this transform on
each separate dimension.
@@ -1405,16 +1449,21 @@ def contains_branch_seperately(self, other_transform):
A common use for this method is to identify if a transform is a blended
transform containing an Axes' data transform. e.g.::
- x_isdata, y_isdata = trans.contains_branch_seperately(ax.transData)
+ x_isdata, y_isdata = trans.contains_branch_separately(ax.transData)
"""
if self.output_dims != 2:
- raise ValueError('contains_branch_seperately only supports '
+ raise ValueError('contains_branch_separately only supports '
'transforms with 2 output dimensions')
# for a non-blended transform each separate dimension is the same, so
# just return the appropriate shape.
return (self.contains_branch(other_transform), ) * 2
+ # Permanent alias for backwards compatibility (historical typo)
+ def contains_branch_seperately(self, other_transform):
+ """:meta private:"""
+ return self.contains_branch_separately(other_transform)
+
def __sub__(self, other):
"""
Compose *self* with the inverse of *other*, cancelling identical terms
@@ -1476,14 +1525,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).
@@ -1521,14 +1570,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)
@@ -1546,14 +1595,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
@@ -2161,7 +2213,7 @@ def __eq__(self, other):
else:
return NotImplemented
- def contains_branch_seperately(self, transform):
+ def contains_branch_separately(self, transform):
return (self._x.contains_branch(transform),
self._y.contains_branch(transform))
@@ -2387,14 +2439,14 @@ def _iter_break_from_left_to_right(self):
for left, right in self._b._iter_break_from_left_to_right():
yield self._a + left, right
- def contains_branch_seperately(self, other_transform):
+ def contains_branch_separately(self, other_transform):
# docstring inherited
if self.output_dims != 2:
- raise ValueError('contains_branch_seperately only supports '
+ raise ValueError('contains_branch_separately only supports '
'transforms with 2 output dimensions')
if self == other_transform:
return (True, True)
- return self._b.contains_branch_seperately(other_transform)
+ return self._b.contains_branch_separately(other_transform)
depth = property(lambda self: self._a.depth + self._b.depth)
is_affine = property(lambda self: self._a.is_affine and self._b.is_affine)
@@ -2602,27 +2654,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
@@ -2834,7 +2865,7 @@ def _revalidate(self):
super()._revalidate()
-def nonsingular(vmin, vmax, expander=0.001, tiny=1e-15, increasing=True):
+def _nonsingular(vmin, vmax, expander=0.001, tiny=1e-15, increasing=True):
"""
Modify the endpoints of a range as needed to avoid singularities.
@@ -2892,7 +2923,13 @@ def nonsingular(vmin, vmax, expander=0.001, tiny=1e-15, increasing=True):
return vmin, vmax
-def interval_contains(interval, val):
+@_api.deprecated("3.11")
+@_docstring.copy(_nonsingular)
+def nonsingular(vmin, vmax, expander=0.001, tiny=1e-15, increasing=True):
+ return _nonsingular(vmin, vmax, expander, tiny, increasing)
+
+
+def _interval_contains(interval, val):
"""
Check, inclusively, whether an interval includes a given value.
@@ -2914,6 +2951,12 @@ def interval_contains(interval, val):
return a <= val <= b
+@_api.deprecated("3.11")
+@_docstring.copy(_interval_contains)
+def interval_contains(interval, val):
+ return _interval_contains(interval, val)
+
+
def _interval_contains_close(interval, val, rtol=1e-10):
"""
Check, inclusively, whether an interval includes a given value, with the
@@ -2943,7 +2986,7 @@ def _interval_contains_close(interval, val, rtol=1e-10):
return a - rtol <= val <= b + rtol
-def interval_contains_open(interval, val):
+def _interval_contains_open(interval, val):
"""
Check, excluding endpoints, whether an interval includes a given value.
@@ -2963,6 +3006,12 @@ def interval_contains_open(interval, val):
return a < val < b or a > val > b
+@_api.deprecated("3.11")
+@_docstring.copy(_interval_contains_open)
+def interval_contains_open(interval, val):
+ return _interval_contains_open(interval, val)
+
+
def offset_copy(trans, fig=None, x=0.0, y=0.0, units='inches'):
"""
Return a new transform with an added offset.
diff --git a/lib/matplotlib/transforms.pyi b/lib/matplotlib/transforms.pyi
index 551487a11c60..ebee3954a3a7 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): ...
@@ -67,6 +65,7 @@ class BboxBase(TransformNode):
@property
def extents(self) -> tuple[float, float, float, float]: ...
def get_points(self) -> np.ndarray: ...
+ def _is_finite(self) -> bool: ...
def containsx(self, x: float) -> bool: ...
def containsy(self, y: float) -> bool: ...
def contains(self, x: float, y: float) -> bool: ...
@@ -191,9 +190,10 @@ class Transform(TransformNode):
@property
def depth(self) -> int: ...
def contains_branch(self, other: Transform) -> bool: ...
- def contains_branch_seperately(
+ def contains_branch_separately(
self, other_transform: Transform
) -> Sequence[bool]: ...
+ contains_branch_seperately = contains_branch_separately # Alias (historical typo)
def __sub__(self, other: Transform) -> Transform: ...
def __array__(self, *args, **kwargs) -> np.ndarray: ...
def transform(self, values: ArrayLike) -> np.ndarray: ...
@@ -254,7 +254,7 @@ class IdentityTransform(Affine2DBase): ...
class _BlendedMixin:
def __eq__(self, other: object) -> bool: ...
- def contains_branch_seperately(self, transform: Transform) -> Sequence[bool]: ...
+ def contains_branch_separately(self, transform: Transform) -> Sequence[bool]: ...
class BlendedGenericTransform(_BlendedMixin, Transform):
input_dims: Literal[2]
@@ -295,8 +295,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: ...
@@ -318,6 +316,13 @@ class TransformedPath(TransformNode):
class TransformedPatchPath(TransformedPath):
def __init__(self, patch: Patch) -> None: ...
+def _nonsingular(
+ vmin: float,
+ vmax: float,
+ expander: float = ...,
+ tiny: float = ...,
+ increasing: bool = ...,
+) -> tuple[float, float]: ...
def nonsingular(
vmin: float,
vmax: float,
@@ -325,7 +330,9 @@ def nonsingular(
tiny: float = ...,
increasing: bool = ...,
) -> tuple[float, float]: ...
+def _interval_contains(interval: tuple[float, float], val: float) -> bool: ...
def interval_contains(interval: tuple[float, float], val: float) -> bool: ...
+def _interval_contains_open(interval: tuple[float, float], val: float) -> bool: ...
def interval_contains_open(interval: tuple[float, float], val: float) -> bool: ...
def offset_copy(
trans: Transform,
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 20e1022fa0a5..d2e12c6e08d9 100644
--- a/lib/matplotlib/typing.py
+++ b/lib/matplotlib/typing.py
@@ -4,14 +4,16 @@
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, Callable, Literal, TypeAlias, TypeVar, Union
+from typing import Any, Literal, TypeAlias, TypeVar, Union
+from collections.abc import Callable
from . import path
from ._enums import JoinStyle, CapStyle
@@ -21,6 +23,8 @@
from .transforms import Bbox, Transform
RGBColorType: TypeAlias = tuple[float, float, float] | str
+"""Any RGB color specification accepted by Matplotlib."""
+
RGBAColorType: TypeAlias = (
str | # "none" or "#RRGGBBAA"/"#RGBA" hex strings
tuple[float, float, float, float] |
@@ -30,27 +34,67 @@
# (4-tuple, float) is odd, but accepted as the outer float overriding A of 4-tuple
tuple[tuple[float, float, float, float], float]
)
+"""Any RGBA color specification accepted by Matplotlib."""
ColorType: TypeAlias = RGBColorType | RGBAColorType
+"""Any color specification accepted by Matplotlib. See :mpltype:`color`."""
RGBColourType: TypeAlias = RGBColorType
+"""Alias of `.RGBColorType`."""
+
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`.
+"""
-LineStyleType: TypeAlias = str | tuple[float, Sequence[float]]
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`.
+"""
-MarkerType: TypeAlias = str | path.Path | MarkerStyle
FillStyleType: TypeAlias = Literal["full", "left", "right", "bottom", "top", "none"]
+"""Marker fill styles. See :doc:`/gallery/lines_bars_and_markers/marker_reference`."""
+
JoinStyleType: TypeAlias = JoinStyle | Literal["miter", "round", "bevel"]
+"""Line join styles. See :doc:`/gallery/lines_bars_and_markers/joinstyle`."""
+
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,
@@ -76,3 +120,455 @@
_HT = TypeVar("_HT", bound=Hashable)
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
+)
+
+RcKeyType: TypeAlias = Literal[
+ "agg.path.chunksize",
+ "animation.bitrate",
+ "animation.codec",
+ "animation.convert_args",
+ "animation.convert_path",
+ "animation.embed_limit",
+ "animation.ffmpeg_args",
+ "animation.ffmpeg_path",
+ "animation.frame_format",
+ "animation.html",
+ "animation.writer",
+ "axes.autolimit_mode",
+ "axes.axisbelow",
+ "axes.edgecolor",
+ "axes.facecolor",
+ "axes.formatter.limits",
+ "axes.formatter.min_exponent",
+ "axes.formatter.offset_threshold",
+ "axes.formatter.use_locale",
+ "axes.formatter.use_mathtext",
+ "axes.formatter.useoffset",
+ "axes.grid",
+ "axes.grid.axis",
+ "axes.grid.which",
+ "axes.labelcolor",
+ "axes.labelpad",
+ "axes.labelsize",
+ "axes.labelweight",
+ "axes.linewidth",
+ "axes.prop_cycle",
+ "axes.spines.bottom",
+ "axes.spines.left",
+ "axes.spines.right",
+ "axes.spines.top",
+ "axes.titlecolor",
+ "axes.titlelocation",
+ "axes.titlepad",
+ "axes.titlesize",
+ "axes.titleweight",
+ "axes.titley",
+ "axes.unicode_minus",
+ "axes.xmargin",
+ "axes.ymargin",
+ "axes.zmargin",
+ "axes3d.automargin",
+ "axes3d.depthshade",
+ "axes3d.depthshade_minalpha",
+ "axes3d.grid",
+ "axes3d.mouserotationstyle",
+ "axes3d.trackballborder",
+ "axes3d.trackballsize",
+ "axes3d.xaxis.panecolor",
+ "axes3d.yaxis.panecolor",
+ "axes3d.zaxis.panecolor",
+ "backend",
+ "backend_fallback",
+ "boxplot.bootstrap",
+ "boxplot.boxprops.color",
+ "boxplot.boxprops.linestyle",
+ "boxplot.boxprops.linewidth",
+ "boxplot.capprops.color",
+ "boxplot.capprops.linestyle",
+ "boxplot.capprops.linewidth",
+ "boxplot.flierprops.color",
+ "boxplot.flierprops.linestyle",
+ "boxplot.flierprops.linewidth",
+ "boxplot.flierprops.marker",
+ "boxplot.flierprops.markeredgecolor",
+ "boxplot.flierprops.markeredgewidth",
+ "boxplot.flierprops.markerfacecolor",
+ "boxplot.flierprops.markersize",
+ "boxplot.meanline",
+ "boxplot.meanprops.color",
+ "boxplot.meanprops.linestyle",
+ "boxplot.meanprops.linewidth",
+ "boxplot.meanprops.marker",
+ "boxplot.meanprops.markeredgecolor",
+ "boxplot.meanprops.markerfacecolor",
+ "boxplot.meanprops.markersize",
+ "boxplot.medianprops.color",
+ "boxplot.medianprops.linestyle",
+ "boxplot.medianprops.linewidth",
+ "boxplot.notch",
+ "boxplot.patchartist",
+ "boxplot.showbox",
+ "boxplot.showcaps",
+ "boxplot.showfliers",
+ "boxplot.showmeans",
+ "boxplot.vertical",
+ "boxplot.whiskerprops.color",
+ "boxplot.whiskerprops.linestyle",
+ "boxplot.whiskerprops.linewidth",
+ "boxplot.whiskers",
+ "contour.algorithm",
+ "contour.corner_mask",
+ "contour.linewidth",
+ "contour.negative_linestyle",
+ "date.autoformatter.day",
+ "date.autoformatter.hour",
+ "date.autoformatter.microsecond",
+ "date.autoformatter.minute",
+ "date.autoformatter.month",
+ "date.autoformatter.second",
+ "date.autoformatter.year",
+ "date.converter",
+ "date.epoch",
+ "date.interval_multiples",
+ "docstring.hardcopy",
+ "errorbar.capsize",
+ "figure.autolayout",
+ "figure.constrained_layout.h_pad",
+ "figure.constrained_layout.hspace",
+ "figure.constrained_layout.use",
+ "figure.constrained_layout.w_pad",
+ "figure.constrained_layout.wspace",
+ "figure.dpi",
+ "figure.edgecolor",
+ "figure.facecolor",
+ "figure.figsize",
+ "figure.frameon",
+ "figure.hooks",
+ "figure.labelsize",
+ "figure.labelweight",
+ "figure.max_open_warning",
+ "figure.raise_window",
+ "figure.subplot.bottom",
+ "figure.subplot.hspace",
+ "figure.subplot.left",
+ "figure.subplot.right",
+ "figure.subplot.top",
+ "figure.subplot.wspace",
+ "figure.titlesize",
+ "figure.titleweight",
+ "font.cursive",
+ "font.enable_last_resort",
+ "font.family",
+ "font.fantasy",
+ "font.monospace",
+ "font.sans-serif",
+ "font.serif",
+ "font.size",
+ "font.stretch",
+ "font.style",
+ "font.variant",
+ "font.weight",
+ "grid.alpha",
+ "grid.color",
+ "grid.linestyle",
+ "grid.linewidth",
+ "grid.major.alpha",
+ "grid.major.color",
+ "grid.major.linestyle",
+ "grid.major.linewidth",
+ "grid.minor.alpha",
+ "grid.minor.color",
+ "grid.minor.linestyle",
+ "grid.minor.linewidth",
+ "hatch.color",
+ "hatch.linewidth",
+ "hist.bins",
+ "image.aspect",
+ "image.cmap",
+ "image.composite_image",
+ "image.interpolation",
+ "image.interpolation_stage",
+ "image.lut",
+ "image.origin",
+ "image.resample",
+ "interactive",
+ "keymap.back",
+ "keymap.copy",
+ "keymap.forward",
+ "keymap.fullscreen",
+ "keymap.grid",
+ "keymap.grid_minor",
+ "keymap.help",
+ "keymap.home",
+ "keymap.pan",
+ "keymap.quit",
+ "keymap.quit_all",
+ "keymap.save",
+ "keymap.xscale",
+ "keymap.yscale",
+ "keymap.zoom",
+ "legend.borderaxespad",
+ "legend.borderpad",
+ "legend.columnspacing",
+ "legend.edgecolor",
+ "legend.facecolor",
+ "legend.fancybox",
+ "legend.fontsize",
+ "legend.framealpha",
+ "legend.frameon",
+ "legend.handleheight",
+ "legend.handlelength",
+ "legend.handletextpad",
+ "legend.labelcolor",
+ "legend.labelspacing",
+ "legend.linewidth",
+ "legend.loc",
+ "legend.markerscale",
+ "legend.numpoints",
+ "legend.scatterpoints",
+ "legend.shadow",
+ "legend.title_fontsize",
+ "lines.antialiased",
+ "lines.color",
+ "lines.dash_capstyle",
+ "lines.dash_joinstyle",
+ "lines.dashdot_pattern",
+ "lines.dashed_pattern",
+ "lines.dotted_pattern",
+ "lines.linestyle",
+ "lines.linewidth",
+ "lines.marker",
+ "lines.markeredgecolor",
+ "lines.markeredgewidth",
+ "lines.markerfacecolor",
+ "lines.markersize",
+ "lines.scale_dashes",
+ "lines.solid_capstyle",
+ "lines.solid_joinstyle",
+ "macosx.window_mode",
+ "markers.fillstyle",
+ "mathtext.bf",
+ "mathtext.bfit",
+ "mathtext.cal",
+ "mathtext.default",
+ "mathtext.fallback",
+ "mathtext.fontset",
+ "mathtext.it",
+ "mathtext.rm",
+ "mathtext.sf",
+ "mathtext.tt",
+ "patch.antialiased",
+ "patch.edgecolor",
+ "patch.facecolor",
+ "patch.force_edgecolor",
+ "patch.linewidth",
+ "path.effects",
+ "path.simplify",
+ "path.simplify_threshold",
+ "path.sketch",
+ "path.snap",
+ "pcolor.shading",
+ "pcolormesh.snap",
+ "pdf.compression",
+ "pdf.fonttype",
+ "pdf.inheritcolor",
+ "pdf.use14corefonts",
+ "pgf.preamble",
+ "pgf.rcfonts",
+ "pgf.texsystem",
+ "polaraxes.grid",
+ "ps.distiller.res",
+ "ps.fonttype",
+ "ps.papersize",
+ "ps.useafm",
+ "ps.usedistiller",
+ "savefig.bbox",
+ "savefig.directory",
+ "savefig.dpi",
+ "savefig.edgecolor",
+ "savefig.facecolor",
+ "savefig.format",
+ "savefig.orientation",
+ "savefig.pad_inches",
+ "savefig.transparent",
+ "scatter.edgecolors",
+ "scatter.marker",
+ "svg.fonttype",
+ "svg.hashsalt",
+ "svg.id",
+ "svg.image_inline",
+ "text.antialiased",
+ "text.color",
+ "text.hinting",
+ "text.hinting_factor",
+ "text.kerning_factor",
+ "text.latex.preamble",
+ "text.parse_math",
+ "text.usetex",
+ "timezone",
+ "tk.window_focus",
+ "toolbar",
+ "webagg.address",
+ "webagg.open_in_browser",
+ "webagg.port",
+ "webagg.port_retries",
+ "xaxis.labellocation",
+ "xtick.alignment",
+ "xtick.bottom",
+ "xtick.color",
+ "xtick.direction",
+ "xtick.labelbottom",
+ "xtick.labelcolor",
+ "xtick.labelsize",
+ "xtick.labeltop",
+ "xtick.major.bottom",
+ "xtick.major.pad",
+ "xtick.major.size",
+ "xtick.major.top",
+ "xtick.major.width",
+ "xtick.minor.bottom",
+ "xtick.minor.ndivs",
+ "xtick.minor.pad",
+ "xtick.minor.size",
+ "xtick.minor.top",
+ "xtick.minor.visible",
+ "xtick.minor.width",
+ "xtick.top",
+ "yaxis.labellocation",
+ "ytick.alignment",
+ "ytick.color",
+ "ytick.direction",
+ "ytick.labelcolor",
+ "ytick.labelleft",
+ "ytick.labelright",
+ "ytick.labelsize",
+ "ytick.left",
+ "ytick.major.left",
+ "ytick.major.pad",
+ "ytick.major.right",
+ "ytick.major.size",
+ "ytick.major.width",
+ "ytick.minor.left",
+ "ytick.minor.ndivs",
+ "ytick.minor.pad",
+ "ytick.minor.right",
+ "ytick.minor.size",
+ "ytick.minor.visible",
+ "ytick.minor.width",
+ "ytick.right",
+]
+
+RcGroupKeyType: TypeAlias = Literal[
+ "agg",
+ "agg.path",
+ "animation",
+ "axes",
+ "axes.formatter",
+ "axes.grid",
+ "axes.spines",
+ "axes3d",
+ "axes3d.xaxis",
+ "axes3d.yaxis",
+ "axes3d.zaxis",
+ "boxplot",
+ "boxplot.boxprops",
+ "boxplot.capprops",
+ "boxplot.flierprops",
+ "boxplot.meanprops",
+ "boxplot.medianprops",
+ "boxplot.whiskerprops",
+ "contour",
+ "date",
+ "date.autoformatter",
+ "docstring",
+ "errorbar",
+ "figure",
+ "figure.constrained_layout",
+ "figure.subplot",
+ "font",
+ "grid",
+ "grid.major",
+ "grid.minor",
+ "hatch",
+ "hist",
+ "image",
+ "keymap",
+ "legend",
+ "lines",
+ "macosx",
+ "markers",
+ "mathtext",
+ "patch",
+ "path",
+ "pcolor",
+ "pcolormesh",
+ "pdf",
+ "pgf",
+ "polaraxes",
+ "ps",
+ "ps.distiller",
+ "savefig",
+ "scatter",
+ "svg",
+ "text",
+ "text.latex",
+ "tk",
+ "webagg",
+ "xaxis",
+ "xtick",
+ "xtick.major",
+ "xtick.minor",
+ "yaxis",
+ "ytick",
+ "ytick.major",
+ "ytick.minor",
+]
diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py
index 9c676574310c..cfe8cdc7d9f5 100644
--- a/lib/matplotlib/widgets.py
+++ b/lib/matplotlib/widgets.py
@@ -1,8 +1,6 @@
"""
-GUI neutral widgets
-===================
-
Widgets that are designed to work for any of the GUI backends.
+
All of these widgets require you to predefine an `~.axes.Axes`
instance and pass that as the first parameter. Matplotlib doesn't try to
be too smart with respect to layout -- you will have to figure out how
@@ -11,6 +9,8 @@
from contextlib import ExitStack
import copy
+import enum
+import functools
import itertools
from numbers import Integral, Number
@@ -116,8 +116,15 @@ class AxesWidget(Widget):
def __init__(self, ax):
self.ax = ax
self._cids = []
+ self._blit_background_id = None
+
+ def __del__(self):
+ if self._blit_background_id is not None:
+ self.canvas._release_blit_background_id(self._blit_background_id)
- canvas = property(lambda self: self.ax.get_figure(root=True).canvas)
+ canvas = property(
+ lambda self: getattr(self.ax.get_figure(root=True), 'canvas', None)
+ )
def connect_event(self, event, callback):
"""
@@ -134,15 +141,59 @@ def disconnect_events(self):
for c in self._cids:
self.canvas.mpl_disconnect(c)
- def _get_data_coords(self, event):
- """Return *event*'s data coordinates in this widget's Axes."""
- # This method handles the possibility that event.inaxes != self.ax (which may
- # occur if multiple Axes are overlaid), in which case event.xdata/.ydata will
- # be wrong. Note that we still special-case the common case where
- # event.inaxes == self.ax and avoid re-running the inverse data transform,
- # because that can introduce floating point errors for synthetic events.
- 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
+
+ def _set_cursor(self, cursor):
+ """Update the canvas cursor."""
+ self.ax.get_figure(root=True).canvas.set_cursor(cursor)
+
+ def _save_blit_background(self, background):
+ """
+ Save a blit background.
+
+ The background is stored on the canvas in a uniquely identifiable way.
+ It should be read back via `._load_blit_background`. Be prepared that
+ some events may invalidate the background, in which case
+ `._load_blit_background` will return None.
+
+ This currently allows at most one background per widget, which is
+ good enough for all existing widgets.
+ """
+ if self._blit_background_id is None:
+ self._blit_background_id = self.canvas._get_blit_background_id()
+ self.canvas._blit_backgrounds[self._blit_background_id] = background
+
+ def _load_blit_background(self):
+ """Load a blit background; may be None at any time."""
+ return self.canvas._blit_backgrounds.get(self._blit_background_id)
+
+
+def _call_with_reparented_event(func):
+ """
+ Event callback decorator ensuring that the callback is called with an event
+ that has been reparented to the widget's axes.
+ """
+ # This decorator handles the possibility that event.inaxes != self.ax
+ # (e.g. if multiple Axes are overlaid), in which case event.xdata/.ydata
+ # will be wrong. Note that we still special-case the common case where
+ # event.inaxes == self.ax and avoid re-running the inverse data transform,
+ # because that can introduce floating point errors for synthetic events.
+ @functools.wraps(func)
+ def wrapper(self, event):
+ if event.inaxes is not self.ax:
+ event = copy.copy(event)
+ event.guiEvent = None
+ event.inaxes = self.ax
+ try:
+ event.xdata, event.ydata = (
+ self.ax.transData.inverted().transform((event.x, event.y)))
+ except ValueError: # cf LocationEvent._set_inaxes.
+ event.xdata = event.ydata = None
+ return func(self, event)
+
+ return wrapper
class Button(AxesWidget):
@@ -195,7 +246,7 @@ def __init__(self, ax, label, image=None,
horizontalalignment='center',
transform=ax.transAxes)
- self._useblit = useblit and self.canvas.supports_blit
+ self._useblit = useblit
self._observers = cbook.CallbackRegistry(signals=["clicked"])
@@ -209,12 +260,14 @@ def __init__(self, ax, label, image=None,
self.color = color
self.hovercolor = hovercolor
+ @_call_with_reparented_event
def _click(self, event):
if not self.eventson or self.ignore(event) or not self.ax.contains(event)[0]:
return
if event.canvas.mouse_grabber != self.ax:
event.canvas.grab_mouse(self.ax)
+ @_call_with_reparented_event
def _release(self, event):
if self.ignore(event) or event.canvas.mouse_grabber != self.ax:
return
@@ -222,6 +275,7 @@ def _release(self, event):
if self.eventson and self.ax.contains(event)[0]:
self._observers.process('clicked', event)
+ @_call_with_reparented_event
def _motion(self, event):
if self.ignore(event):
return
@@ -229,7 +283,7 @@ def _motion(self, event):
if not colors.same_color(c, self.ax.get_facecolor()):
self.ax.set_facecolor(c)
if self.drawon:
- if self._useblit:
+ if self._useblit and self.canvas.supports_blit:
self.ax.draw_artist(self.ax)
self.canvas.blit(self.ax.bbox)
else:
@@ -273,10 +327,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 +418,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.
@@ -520,6 +575,7 @@ def _value_in_bounds(self, val):
val = self.slidermax.val
return val
+ @_call_with_reparented_event
def _update(self, event):
"""Update the slider position."""
if self.ignore(event) or event.button != 1:
@@ -538,16 +594,18 @@ def _update(self, event):
event.canvas.release_mouse(self.ax)
return
- xdata, ydata = self._get_data_coords(event)
val = self._value_in_bounds(
- xdata if self.orientation == 'horizontal' else ydata)
+ event.xdata if self.orientation == 'horizontal' else event.ydata)
if val not in [None, self.val]:
self.set_val(val)
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.
@@ -644,9 +702,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.
@@ -853,6 +913,7 @@ def _update_val_from_pos(self, pos):
else:
self._active_handle.set_xdata([val])
+ @_call_with_reparented_event
def _update(self, event):
"""Update the slider position."""
if self.ignore(event) or event.button != 1:
@@ -873,11 +934,10 @@ def _update(self, event):
return
# determine which handle was grabbed
- xdata, ydata = self._get_data_coords(event)
handle_index = np.argmin(np.abs(
- [h.get_xdata()[0] - xdata for h in self._handles]
+ [h.get_xdata()[0] - event.xdata for h in self._handles]
if self.orientation == "horizontal" else
- [h.get_ydata()[0] - ydata for h in self._handles]))
+ [h.get_ydata()[0] - event.ydata for h in self._handles]))
handle = self._handles[handle_index]
# these checks ensure smooth behavior if the handles swap which one
@@ -885,12 +945,16 @@ def _update(self, event):
if handle is not self._active_handle:
self._active_handle = handle
- self._update_val_from_pos(xdata if self.orientation == "horizontal" else ydata)
+ self._update_val_from_pos(
+ event.xdata if self.orientation == "horizontal" else event.ydata)
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]
@@ -1010,8 +1074,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
@@ -1039,8 +1106,7 @@ def __init__(self, ax, labels, actives=None, *, useblit=True,
if actives is None:
actives = [False] * len(labels)
- self._useblit = useblit and self.canvas.supports_blit
- self._background = None
+ self._useblit = useblit
ys = np.linspace(1, 0, len(labels)+2)[1:-1]
@@ -1068,7 +1134,10 @@ def __init__(self, ax, labels, actives=None, *, useblit=True,
**cbook.normalize_kwargs(check_props, collections.PathCollection),
'marker': 'x',
'transform': ax.transAxes,
- 'animated': self._useblit,
+ 'animated': self._useblit and self.canvas.supports_blit,
+ # TODO: This may need an update when switching out the canvas.
+ # Can set this to `_useblit` only and live with the animated=True
+ # overhead on unsupported backends.
}
check_props.setdefault('facecolor', check_props.pop('color', 'black'))
self._checks = ax.scatter([0.15] * len(ys), ys, **check_props)
@@ -1087,9 +1156,11 @@ def _clear(self, event):
"""Internal event handler to clear the buttons."""
if self.ignore(event) or self.canvas.is_saving():
return
- self._background = self.canvas.copy_from_bbox(self.ax.bbox)
+ if self._useblit and self.canvas.supports_blit:
+ self._save_blit_background(self.canvas.copy_from_bbox(self.ax.bbox))
self.ax.draw_artist(self._checks)
+ @_call_with_reparented_event
def _clicked(self, event):
if self.ignore(event) or event.button != 1 or not self.ax.contains(event)[0]:
return
@@ -1111,7 +1182,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 +1231,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
----------
@@ -1190,9 +1262,10 @@ def set_active(self, index, state=None):
self._checks.set_facecolor(facecolors)
if self.drawon:
- if self._useblit:
- if self._background is not None:
- self.canvas.restore_region(self._background)
+ if self._useblit and self.canvas.supports_blit:
+ background = self._load_blit_background()
+ if background is not None:
+ self.canvas.restore_region(background)
self.ax.draw_artist(self._checks)
self.canvas.blit(self.ax.bbox)
else:
@@ -1396,6 +1469,7 @@ def _rendercursor(self):
fig.canvas.draw()
+ @_call_with_reparented_event
def _release(self, event):
if self.ignore(event):
return
@@ -1403,6 +1477,7 @@ def _release(self, event):
return
event.canvas.release_mouse(self.ax)
+ @_call_with_reparented_event
def _keypress(self, event):
if self.ignore(event):
return
@@ -1485,6 +1560,7 @@ def stop_typing(self):
# call it once we've already done our cleanup.
self._observers.process('submit', self.text)
+ @_call_with_reparented_event
def _click(self, event):
if self.ignore(event):
return
@@ -1500,9 +1576,11 @@ def _click(self, event):
self.cursor_index = self.text_disp._char_index_at(event.x)
self._rendercursor()
+ @_call_with_reparented_event
def _resize(self, event):
self.stop_typing()
+ @_call_with_reparented_event
def _motion(self, event):
if self.ignore(event):
return
@@ -1579,8 +1657,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
@@ -1622,8 +1703,7 @@ def __init__(self, ax, labels, active=0, activecolor=None, *,
ys = np.linspace(1, 0, len(labels) + 2)[1:-1]
- self._useblit = useblit and self.canvas.supports_blit
- self._background = None
+ self._useblit = useblit
label_props = _expand_text_props(label_props)
self.labels = [
@@ -1638,7 +1718,11 @@ def __init__(self, ax, labels, active=0, activecolor=None, *,
**radio_props,
'marker': 'o',
'transform': ax.transAxes,
- 'animated': self._useblit,
+ 'animated': self._useblit and self.canvas.supports_blit,
+ # TODO: This may need an update when switching out the canvas.
+ # Can set this to `_useblit` only and live with the animated=True
+ # overhead on unsupported backends.
+
}
radio_props.setdefault('edgecolor', radio_props.get('color', 'black'))
radio_props.setdefault('facecolor',
@@ -1665,9 +1749,11 @@ def _clear(self, event):
"""Internal event handler to clear the buttons."""
if self.ignore(event) or self.canvas.is_saving():
return
- self._background = self.canvas.copy_from_bbox(self.ax.bbox)
+ if self._useblit and self.canvas.supports_blit:
+ self._save_blit_background(self.canvas.copy_from_bbox(self.ax.bbox))
self.ax.draw_artist(self._buttons)
+ @_call_with_reparented_event
def _clicked(self, event):
if self.ignore(event) or event.button != 1 or not self.ax.contains(event)[0]:
return
@@ -1689,7 +1775,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)
@@ -1734,7 +1821,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
----------
@@ -1756,9 +1843,10 @@ def set_active(self, index):
self._buttons.set_facecolor(button_facecolors)
if self.drawon:
- if self._useblit:
- if self._background is not None:
- self.canvas.restore_region(self._background)
+ if self._useblit and self.canvas.supports_blit:
+ background = self._load_blit_background()
+ if background is not None:
+ self.canvas.restore_region(background)
self.ax.draw_artist(self._buttons)
self.canvas.blit(self.ax.bbox)
else:
@@ -1841,7 +1929,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)
@@ -1907,14 +1995,13 @@ def __init__(self, ax, *, horizOn=True, vertOn=True, useblit=False,
self.visible = True
self.horizOn = horizOn
self.vertOn = vertOn
- self.useblit = useblit and self.canvas.supports_blit
+ self.useblit = useblit and self.canvas.supports_blit # TODO: make dynamic
if self.useblit:
lineprops['animated'] = True
self.lineh = ax.axhline(ax.get_ybound()[0], visible=False, **lineprops)
self.linev = ax.axvline(ax.get_xbound()[0], visible=False, **lineprops)
- self.background = None
self.needclear = False
def clear(self, event):
@@ -1922,8 +2009,9 @@ def clear(self, event):
if self.ignore(event) or self.canvas.is_saving():
return
if self.useblit:
- self.background = self.canvas.copy_from_bbox(self.ax.bbox)
+ self._save_blit_background(self.canvas.copy_from_bbox(self.ax.bbox))
+ @_call_with_reparented_event
def onmove(self, event):
"""Internal event handler to draw the cursor when the mouse moves."""
if self.ignore(event):
@@ -1938,17 +2026,17 @@ def onmove(self, event):
self.needclear = False
return
self.needclear = True
- xdata, ydata = self._get_data_coords(event)
- self.linev.set_xdata((xdata, xdata))
+ self.linev.set_xdata((event.xdata, event.xdata))
self.linev.set_visible(self.visible and self.vertOn)
- self.lineh.set_ydata((ydata, ydata))
+ self.lineh.set_ydata((event.ydata, event.ydata))
self.lineh.set_visible(self.visible and self.horizOn)
if not (self.visible and (self.vertOn or self.horizOn)):
return
# Redraw.
if self.useblit:
- if self.background is not None:
- self.canvas.restore_region(self.background)
+ background = self._load_blit_background()
+ if background is not None:
+ self.canvas.restore_region(background)
self.ax.draw_artist(self.linev)
self.ax.draw_artist(self.lineh)
self.canvas.blit(self.ax.bbox)
@@ -1961,12 +2049,19 @@ class MultiCursor(Widget):
Provide a vertical (default) and/or horizontal line cursor shared between
multiple Axes.
+ Call signatures::
+
+ MultiCursor(axes, *, ...)
+ MultiCursor(canvas, axes, *, ...) # deprecated
+
For the cursor to remain responsive you must keep a reference to it.
Parameters
----------
canvas : object
- This parameter is entirely unused and only kept for back-compatibility.
+ This parameter is entirely unused.
+
+ .. deprecated:: 3.11
axes : list of `~matplotlib.axes.Axes`
The `~.axes.Axes` to attach the cursor to.
@@ -1993,11 +2088,25 @@ class MultiCursor(Widget):
See :doc:`/gallery/widgets/multicursor`.
"""
- def __init__(self, canvas, axes, *, useblit=True, horizOn=False, vertOn=True,
+ def __init__(self, *args, useblit=True, horizOn=False, vertOn=True,
**lineprops):
- # canvas is stored only to provide the deprecated .canvas attribute;
- # once it goes away the unused argument won't need to be stored at all.
- self._canvas = canvas
+ # Deprecation of canvas as the first attribute. When the deprecation expires:
+ # - change the signature to __init__(self, axes, *, ...)
+ # - delete the "Call signatures" block in the docstring
+ # - delete this block
+ kwargs = {k: lineprops.pop(k)
+ for k in list(lineprops) if k in ("canvas", "axes")}
+ params = _api.select_matching_signature(
+ [lambda axes: locals(), lambda canvas, axes: locals()], *args, **kwargs)
+ if "canvas" in params:
+ _api.warn_deprecated(
+ "3.11",
+ message="The canvas parameter in MultiCursor is unused and deprecated "
+ "since %(since)s. Please remove it and call MultiCursor(axes) "
+ "instead of MultiCursor(canvas, axes). The latter will start raising "
+ "an error in %(removal)s"
+ )
+ axes = params["axes"]
self.axes = axes
self.horizOn = horizOn
@@ -2016,6 +2125,7 @@ def __init__(self, canvas, axes, *, useblit=True, horizOn=False, vertOn=True,
self.useblit = (
useblit
and all(canvas.supports_blit for canvas in self._canvas_infos))
+ # TODO: make dynamic
if self.useblit:
lineprops['animated'] = True
@@ -2090,6 +2200,16 @@ def onmove(self, event):
class _SelectorWidget(AxesWidget):
+ """
+ The base class for selector widgets.
+
+ This class provides common functionality for selector widgets,
+ such as handling mouse and keyboard events, managing state modifier keys, etc.
+
+ The class itself is private and may be changed or removed without prior warning.
+ However, the public API it provides to subclasses is stable and considered
+ public on the subclasses.
+ """
def __init__(self, ax, onselect=None, useblit=False, button=None,
state_modifier_keys=None, use_data_coordinates=False):
@@ -2100,7 +2220,7 @@ def __init__(self, ax, onselect=None, useblit=False, button=None,
self.onselect = lambda *args: None
else:
self.onselect = onselect
- self.useblit = useblit and self.canvas.supports_blit
+ self._useblit = useblit
self.connect_default_events()
self._state_modifier_keys = dict(move=' ', clear='escape',
@@ -2109,8 +2229,6 @@ def __init__(self, ax, onselect=None, useblit=False, button=None,
self._state_modifier_keys.update(state_modifier_keys or {})
self._use_data_coordinates = use_data_coordinates
- self.background = None
-
if isinstance(button, Integral):
self.validButtons = [button]
else:
@@ -2126,6 +2244,11 @@ def __init__(self, ax, onselect=None, useblit=False, button=None,
self._prev_event = None
self._state = set()
+ @property
+ def useblit(self):
+ """Return whether blitting is used (requested and supported by canvas)."""
+ return self._useblit and self.canvas.supports_blit
+
def set_active(self, active):
super().set_active(active)
if active:
@@ -2149,6 +2272,8 @@ def update_background(self, event):
# `release` can call a draw event even when `ignore` is True.
if not self.useblit:
return
+ if self.canvas.is_saving():
+ return # saving does not use blitting
# Make sure that widget artists don't get accidentally included in the
# background, by re-rendering the background if needed (and then
# re-re-rendering the canvas with the visible widget artists).
@@ -2164,7 +2289,7 @@ def update_background(self, event):
for artist in artists:
stack.enter_context(artist._cm_set(visible=False))
self.canvas.draw()
- self.background = self.canvas.copy_from_bbox(self.ax.bbox)
+ self._save_blit_background(self.canvas.copy_from_bbox(self.ax.bbox))
if needs_redraw:
for artist in artists:
self.ax.draw_artist(artist)
@@ -2181,7 +2306,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):
@@ -2209,8 +2336,9 @@ def update(self):
self.ax.get_figure(root=True)._get_renderer() is None):
return
if self.useblit:
- if self.background is not None:
- self.canvas.restore_region(self.background)
+ background = self._load_blit_background()
+ if background is not None:
+ self.canvas.restore_region(background)
else:
self.update_background(None)
# We need to draw all artists, which are not included in the
@@ -2228,9 +2356,8 @@ def _get_data(self, event):
"""Get the xdata and ydata for event, with limits."""
if event.xdata is None:
return None, None
- xdata, ydata = self._get_data_coords(event)
- xdata = np.clip(xdata, *self.ax.get_xbound())
- ydata = np.clip(ydata, *self.ax.get_ybound())
+ xdata = np.clip(event.xdata, *self.ax.get_xbound())
+ ydata = np.clip(event.ydata, *self.ax.get_ybound())
return xdata, ydata
def _clean_event(self, event):
@@ -2250,6 +2377,7 @@ def _clean_event(self, event):
self._prev_event = event
return event
+ @_call_with_reparented_event
def press(self, event):
"""Button press handler and validator."""
if not self.ignore(event):
@@ -2268,6 +2396,7 @@ def press(self, event):
def _press(self, event):
"""Button press event handler."""
+ @_call_with_reparented_event
def release(self, event):
"""Button release event handler and validator."""
if not self.ignore(event) and self._eventpress:
@@ -2283,6 +2412,7 @@ def release(self, event):
def _release(self, event):
"""Button release event handler."""
+ @_call_with_reparented_event
def onmove(self, event):
"""Cursor move event handler and validator."""
if not self.ignore(event) and self._eventpress:
@@ -2294,6 +2424,7 @@ def onmove(self, event):
def _onmove(self, event):
"""Cursor move event handler."""
+ @_call_with_reparented_event
def on_scroll(self, event):
"""Mouse scroll event handler and validator."""
if not self.ignore(event):
@@ -2302,6 +2433,7 @@ def on_scroll(self, event):
def _on_scroll(self, event):
"""Mouse scroll event handler."""
+ @_call_with_reparented_event
def on_key_press(self, event):
"""Key press event handler and validator for all selection widgets."""
if self.active:
@@ -2326,6 +2458,7 @@ def on_key_press(self, event):
def _on_key_press(self, event):
"""Key press event handler - for widget-specific key press actions."""
+ @_call_with_reparented_event
def on_key_release(self, event):
"""Key release event handler and validator."""
if self.active:
@@ -2381,7 +2514,7 @@ def set_props(self, **props):
def set_handle_props(self, **handle_props):
"""
Set the properties of the handles selector artist. See the
- `handle_props` argument in the selector docstring to know which
+ *handle_props* argument in the selector docstring to know which
properties are supported.
"""
if not hasattr(self, '_handles_artists'):
@@ -2405,13 +2538,15 @@ def _validate_state(self, state):
def add_state(self, state):
"""
Add a state to define the widget's behavior. See the
- `state_modifier_keys` parameters for details.
+ *state_modifier_keys* parameter in the constructor of the concrete
+ selector class for details.
Parameters
----------
state : str
Must be a supported state of the selector. See the
- `state_modifier_keys` parameters for details.
+ *state_modifier_keys* parameter in the constructor of the concrete
+ selector class for details.
Raises
------
@@ -2425,13 +2560,15 @@ def add_state(self, state):
def remove_state(self, state):
"""
Remove a state to define the widget's behavior. See the
- `state_modifier_keys` parameters for details.
+ *state_modifier_keys* parameter in the constructor of the concrete
+ selector class for details.
Parameters
----------
state : str
Must be a supported state of the selector. See the
- `state_modifier_keys` parameters for details.
+ *state_modifier_keys* parameter in the constructor of the concrete
+ selector class for details.
Raises
------
@@ -2543,7 +2680,14 @@ def __init__(self, ax, onselect, direction, *, minspan=0, useblit=False,
if props is None:
props = dict(facecolor='red', alpha=0.5)
- props['animated'] = self.useblit
+ # Note: We set this based on the user setting during ínitialization,
+ # not on the actual capability of blitting. But the value is
+ # irrelevant if the backend does not support blitting, so that
+ # we don't have to dynamically update this on the backend.
+ # This relies on the current behavior that the request for
+ # useblit is fixed during initialization and cannot be changed
+ # afterwards.
+ props['animated'] = self._useblit
self.direction = direction
self._extents_on_press = None
@@ -2609,7 +2753,7 @@ def _setup_edge_handles(self, props):
self._edge_handles = ToolLineHandles(self.ax, positions,
direction=self.direction,
line_props=props,
- useblit=self.useblit)
+ useblit=self._useblit)
@property
def _handles_artists(self):
@@ -2618,7 +2762,7 @@ def _handles_artists(self):
else:
return ()
- def _set_cursor(self, enabled):
+ def _set_span_cursor(self, *, enabled):
"""Update the canvas cursor based on direction of the selector."""
if enabled:
cursor = (backend_tools.Cursors.RESIZE_HORIZONTAL
@@ -2627,7 +2771,7 @@ def _set_cursor(self, enabled):
else:
cursor = backend_tools.Cursors.POINTER
- self.ax.get_figure(root=True).canvas.set_cursor(cursor)
+ self._set_cursor(cursor)
def connect_default_events(self):
# docstring inherited
@@ -2637,7 +2781,7 @@ def connect_default_events(self):
def _press(self, event):
"""Button press event handler."""
- self._set_cursor(True)
+ self._set_span_cursor(enabled=True)
if self._interactive and self._selection_artist.get_visible():
self._set_active_handle(event)
else:
@@ -2647,8 +2791,7 @@ def _press(self, event):
# Clear previous rectangle before drawing new rectangle.
self.update()
- xdata, ydata = self._get_data_coords(event)
- v = xdata if self.direction == 'horizontal' else ydata
+ v = event.xdata if self.direction == 'horizontal' else event.ydata
if self._active_handle is None and not self.ignore_event_outside:
# when the press event outside the span, we initially set the
@@ -2685,9 +2828,10 @@ def direction(self, direction):
else:
self._direction = direction
+ @_call_with_reparented_event
def _release(self, event):
"""Button release event handler."""
- self._set_cursor(False)
+ self._set_span_cursor(enabled=False)
if not self._interactive:
self._selection_artist.set_visible(False)
@@ -2716,6 +2860,7 @@ def _release(self, event):
return False
+ @_call_with_reparented_event
def _hover(self, event):
"""Update the canvas cursor if it's over a handle."""
if self.ignore(event):
@@ -2729,17 +2874,16 @@ def _hover(self, event):
return
_, e_dist = self._edge_handles.closest(event.x, event.y)
- self._set_cursor(e_dist <= self.grab_range)
+ self._set_span_cursor(enabled=e_dist <= self.grab_range)
def _onmove(self, event):
"""Motion notify event handler."""
- xdata, ydata = self._get_data_coords(event)
if self.direction == 'horizontal':
- v = xdata
+ v = event.xdata
vpress = self._eventpress.xdata
else:
- v = ydata
+ v = event.ydata
vpress = self._eventpress.ydata
# move existing span
@@ -3054,7 +3198,7 @@ def onselect(eclick: MouseEvent, erelease: MouseEvent)
(when already existing) or cancelled.
minspany : float, default: 0
- Selections with an y-span less than or equal to *minspanx* are removed
+ Selections with a y-span less than or equal to *minspanx* are removed
(when already existing) or cancelled.
useblit : bool, default: False
@@ -3120,6 +3264,13 @@ def onselect(eclick: MouseEvent, erelease: MouseEvent)
"""
+class _RectangleSelectorAction(enum.Enum):
+ ROTATE = enum.auto()
+ MOVE = enum.auto()
+ RESIZE = enum.auto()
+ CREATE = enum.auto()
+
+
@_docstring.Substitution(_RECTANGLESELECTOR_PARAMETERS_DOCSTRING.replace(
'__ARTIST_NAME__', 'rectangle'))
class RectangleSelector(_SelectorWidget):
@@ -3176,7 +3327,7 @@ def __init__(self, ax, onselect=None, *, minspanx=0,
if props is None:
props = dict(facecolor='red', edgecolor='black',
alpha=0.2, fill=True)
- props = {**props, 'animated': self.useblit}
+ props = {**props, 'animated': self._useblit}
self._visible = props.pop('visible', self._visible)
to_draw = self._init_shape(**props)
self.ax.add_patch(to_draw)
@@ -3201,18 +3352,18 @@ def __init__(self, ax, onselect=None, *, minspanx=0,
xc, yc = self.corners
self._corner_handles = ToolHandles(self.ax, xc, yc,
marker_props=self._handle_props,
- useblit=self.useblit)
+ useblit=self._useblit)
self._edge_order = ['W', 'S', 'E', 'N']
xe, ye = self.edge_centers
self._edge_handles = ToolHandles(self.ax, xe, ye, marker='s',
marker_props=self._handle_props,
- useblit=self.useblit)
+ useblit=self._useblit)
xc, yc = self.center
self._center_handle = ToolHandles(self.ax, [xc], [yc], marker='s',
marker_props=self._handle_props,
- useblit=self.useblit)
+ useblit=self._useblit)
self._active_handle = None
@@ -3242,9 +3393,8 @@ def _press(self, event):
if (self._active_handle is None and not self.ignore_event_outside and
self._allow_creation):
- x, y = self._get_data_coords(event)
self._visible = False
- self.extents = x, x, y, y
+ self.extents = event.xdata, event.xdata, event.ydata, event.ydata
self._visible = True
else:
self.set_visible(True)
@@ -3253,10 +3403,24 @@ def _press(self, event):
self._rotation_on_press = self._rotation
self._set_aspect_ratio_correction()
+ match self._get_action():
+ case _RectangleSelectorAction.ROTATE:
+ # TODO: set to a rotate cursor if possible?
+ pass
+ case _RectangleSelectorAction.MOVE:
+ self._set_cursor(backend_tools.cursors.MOVE)
+ case _RectangleSelectorAction.RESIZE:
+ # TODO: set to a resize cursor if possible?
+ pass
+ case _RectangleSelectorAction.CREATE:
+ self._set_cursor(backend_tools.cursors.SELECT_REGION)
+
return False
+ @_call_with_reparented_event
def _release(self, event):
"""Button release event handler."""
+ self._set_cursor(backend_tools.Cursors.POINTER)
if not self._interactive:
self._selection_artist.set_visible(False)
@@ -3300,9 +3464,20 @@ def _release(self, event):
self.update()
self._active_handle = None
self._extents_on_press = None
-
return False
+ def _get_action(self):
+ state = self._state
+ if 'rotate' in state and self._active_handle in self._corner_order:
+ return _RectangleSelectorAction.ROTATE
+ elif self._active_handle == 'C':
+ return _RectangleSelectorAction.MOVE
+ elif self._active_handle:
+ return _RectangleSelectorAction.RESIZE
+
+ return _RectangleSelectorAction.CREATE
+
+
def _onmove(self, event):
"""
Motion notify event handler.
@@ -3317,12 +3492,10 @@ def _onmove(self, event):
# The calculations are done for rotation at zero: we apply inverse
# transformation to events except when we rotate and move
state = self._state
- rotate = 'rotate' in state and self._active_handle in self._corner_order
- move = self._active_handle == 'C'
- resize = self._active_handle and not move
+ action = self._get_action()
- xdata, ydata = self._get_data_coords(event)
- if resize:
+ xdata, ydata = event.xdata, event.ydata
+ if action == _RectangleSelectorAction.RESIZE:
inv_tr = self._get_rotation_transform().inverted()
xdata, ydata = inv_tr.transform([xdata, ydata])
eventpress.xdata, eventpress.ydata = inv_tr.transform(
@@ -3342,7 +3515,7 @@ def _onmove(self, event):
x0, x1, y0, y1 = self._extents_on_press
# rotate an existing shape
- if rotate:
+ if action == _RectangleSelectorAction.ROTATE:
# calculate angle abc
a = (eventpress.xdata, eventpress.ydata)
b = self.center
@@ -3351,7 +3524,7 @@ def _onmove(self, event):
np.arctan2(a[1]-b[1], a[0]-b[0]))
self.rotation = np.rad2deg(self._rotation_on_press + angle)
- elif resize:
+ elif action == _RectangleSelectorAction.RESIZE:
size_on_press = [x1 - x0, y1 - y0]
center = (x0 + size_on_press[0] / 2, y0 + size_on_press[1] / 2)
@@ -3402,7 +3575,7 @@ def _onmove(self, event):
sign = np.sign(xdata - x0)
x1 = x0 + sign * abs(y1 - y0) * self._aspect_ratio_correction
- elif move:
+ elif action == _RectangleSelectorAction.MOVE:
x0, x1, y0, y1 = self._extents_on_press
dx = xdata - eventpress.xdata
dy = ydata - eventpress.ydata
@@ -3697,7 +3870,7 @@ def __init__(self, ax, onselect=None, *, useblit=True, props=None, button=None):
**(props if props is not None else {}),
# Note that self.useblit may be != useblit, if the canvas doesn't
# support blitting.
- 'animated': self.useblit, 'visible': False,
+ 'animated': self._useblit, 'visible': False,
}
line = Line2D([], [], **props)
self.ax.add_line(line)
@@ -3707,6 +3880,7 @@ def _press(self, event):
self.verts = [self._get_data(event)]
self._selection_artist.set_visible(True)
+ @_call_with_reparented_event
def _release(self, event):
if self.verts is not None:
self.verts.append(self._get_data(event))
@@ -3821,7 +3995,7 @@ def __init__(self, ax, onselect=None, *, useblit=False,
if props is None:
props = dict(color='k', linestyle='-', linewidth=2, alpha=0.5)
- props = {**props, 'animated': self.useblit}
+ props = {**props, 'animated': self._useblit}
self._selection_artist = line = Line2D([], [], **props)
self.ax.add_line(line)
@@ -3830,7 +4004,7 @@ def __init__(self, ax, onselect=None, *, useblit=False,
markerfacecolor=props.get('color', 'k'))
self._handle_props = handle_props
self._polygon_handles = ToolHandles(self.ax, [], [],
- useblit=self.useblit,
+ useblit=self._useblit,
marker_props=self._handle_props)
self._active_handle_idx = -1
@@ -3850,7 +4024,7 @@ def _get_bbox(self):
def _add_box(self):
self._box = RectangleSelector(self.ax,
- useblit=self.useblit,
+ useblit=self._useblit,
grab_range=self.grab_range,
handle_props=self._box_handle_props,
props=self._box_props,
@@ -3877,6 +4051,7 @@ def _update_box(self):
# Save a copy
self._old_box_extents = self._box.extents
+ @_call_with_reparented_event
def _scale_polygon(self, event):
"""
Scale the polygon selector points when the bounding box is moved or
@@ -3941,6 +4116,7 @@ def _press(self, event):
# support the 'move_all' state modifier).
self._xys_at_press = self._xys.copy()
+ @_call_with_reparented_event
def _release(self, event):
"""Button release event handler."""
# Release active tool handle.
@@ -3960,11 +4136,12 @@ def _release(self, event):
elif (not self._selection_completed
and 'move_all' not in self._state
and 'move_vertex' not in self._state):
- self._xys.insert(-1, self._get_data_coords(event))
+ self._xys.insert(-1, (event.xdata, event.ydata))
if self._selection_completed:
self.onselect(self.verts)
+ @_call_with_reparented_event
def onmove(self, event):
"""Cursor move event handler and validator."""
# Method overrides _SelectorWidget.onmove because the polygon selector
@@ -3988,17 +4165,16 @@ def _onmove(self, event):
# Move the active vertex (ToolHandle).
if self._active_handle_idx >= 0:
idx = self._active_handle_idx
- self._xys[idx] = self._get_data_coords(event)
+ self._xys[idx] = (event.xdata, event.ydata)
# Also update the end of the polygon line if the first vertex is
# the active handle and the polygon is completed.
if idx == 0 and self._selection_completed:
- self._xys[-1] = self._get_data_coords(event)
+ self._xys[-1] = (event.xdata, event.ydata)
# Move all vertices.
elif 'move_all' in self._state and self._eventpress:
- xdata, ydata = self._get_data_coords(event)
- dx = xdata - self._eventpress.xdata
- dy = ydata - self._eventpress.ydata
+ dx = event.xdata - self._eventpress.xdata
+ dy = event.ydata - self._eventpress.ydata
for k in range(len(self._xys)):
x_at_press, y_at_press = self._xys_at_press[k]
self._xys[k] = x_at_press + dx, y_at_press + dy
@@ -4018,7 +4194,7 @@ def _onmove(self, event):
if len(self._xys) > 3 and v0_dist < self.grab_range:
self._xys[-1] = self._xys[0]
else:
- self._xys[-1] = self._get_data_coords(event)
+ self._xys[-1] = (event.xdata, event.ydata)
self._draw_polygon()
@@ -4040,12 +4216,12 @@ def _on_key_release(self, event):
and
(event.key == self._state_modifier_keys.get('move_vertex')
or event.key == self._state_modifier_keys.get('move_all'))):
- self._xys.append(self._get_data_coords(event))
+ self._xys.append((event.xdata, event.ydata))
self._draw_polygon()
# Reset the polygon if the released key is the 'clear' key.
elif event.key == self._state_modifier_keys.get('clear'):
event = self._clean_event(event)
- self._xys = [self._get_data_coords(event)]
+ self._xys = [(event.xdata, event.ydata)]
self._selection_completed = False
self._remove_box()
self.set_visible(True)
@@ -4130,7 +4306,7 @@ class Lasso(AxesWidget):
def __init__(self, ax, xy, callback, *, useblit=True, props=None):
super().__init__(ax)
- self.useblit = useblit and self.canvas.supports_blit
+ self.useblit = useblit and self.canvas.supports_blit # TODO: Make dynamic
if self.useblit:
self.background = self.canvas.copy_from_bbox(self.ax.bbox)
@@ -4147,24 +4323,26 @@ def __init__(self, ax, xy, callback, *, useblit=True, props=None):
self.connect_event('button_release_event', self.onrelease)
self.connect_event('motion_notify_event', self.onmove)
+ @_call_with_reparented_event
def onrelease(self, event):
if self.ignore(event):
return
if self.verts is not None:
- self.verts.append(self._get_data_coords(event))
+ self.verts.append((event.xdata, event.ydata))
if len(self.verts) > 2:
self.callback(self.verts)
self.line.remove()
self.verts = None
self.disconnect_events()
+ @_call_with_reparented_event
def onmove(self, event):
if (self.ignore(event)
or self.verts is None
or event.button != 1
or not self.ax.contains(event)[0]):
return
- self.verts.append(self._get_data_coords(event))
+ self.verts.append((event.xdata, event.ydata))
self.line.set_data(list(zip(*self.verts)))
if self.useblit:
diff --git a/lib/matplotlib/widgets.pyi b/lib/matplotlib/widgets.pyi
index 0fcd1990e17e..2f34255d625c 100644
--- a/lib/matplotlib/widgets.pyi
+++ b/lib/matplotlib/widgets.pyi
@@ -6,6 +6,7 @@ from .figure import Figure
from .lines import Line2D
from .patches import Polygon, Rectangle
from .text import Text
+from .backend_tools import Cursors
import PIL.Image
@@ -34,10 +35,12 @@ class Widget:
class AxesWidget(Widget):
ax: Axes
def __init__(self, ax: Axes) -> None: ...
+ def __del__(self) -> None: ...
@property
def canvas(self) -> FigureCanvasBase | None: ...
def connect_event(self, event: Event, callback: Callable) -> None: ...
def disconnect_events(self) -> None: ...
+ def _set_cursor(self, cursor: Cursors) -> None: ...
class Button(AxesWidget):
label: Text
@@ -64,7 +67,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 +76,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 +133,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 +157,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 +211,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: ...
@@ -270,7 +273,7 @@ class MultiCursor(Widget):
class _SelectorWidget(AxesWidget):
onselect: Callable[[float, float], Any]
- useblit: bool
+ _useblit: bool
background: Any
validButtons: list[MouseButton]
def __init__(
@@ -282,6 +285,8 @@ class _SelectorWidget(AxesWidget):
state_modifier_keys: dict[str, str] | None = ...,
use_data_coordinates: bool = ...,
) -> None: ...
+ @property
+ def useblit(self) -> bool: ...
def update_background(self, event: Event) -> None: ...
def connect_default_events(self) -> None: ...
def ignore(self, event: Event) -> bool: ...
@@ -335,6 +340,7 @@ class SpanSelector(_SelectorWidget):
_props: dict[str, Any] | None = ...,
_init: bool = ...,
) -> None: ...
+ def _set_span_cursor(self, *, enabled: bool) -> None: ...
def connect_default_events(self) -> None: ...
@property
def direction(self) -> Literal["horizontal", "vertical"]: ...
@@ -398,6 +404,7 @@ class RectangleSelector(_SelectorWidget):
minspany: float
spancoords: Literal["data", "pixels"]
grab_range: float
+ _active_handle: None | Literal["C", "N", "NE", "E", "SE", "S", "SW", "W", "NW"]
def __init__(
self,
ax: Axes,
diff --git a/lib/mpl_toolkits/axes_grid1/anchored_artists.py b/lib/mpl_toolkits/axes_grid1/anchored_artists.py
index 214b15843ebf..a8be06800a07 100644
--- a/lib/mpl_toolkits/axes_grid1/anchored_artists.py
+++ b/lib/mpl_toolkits/axes_grid1/anchored_artists.py
@@ -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',
@@ -137,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*.
@@ -256,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_grid.py b/lib/mpl_toolkits/axes_grid1/axes_grid.py
index 20abf18ea79c..b26c87edce1c 100644
--- a/lib/mpl_toolkits/axes_grid1/axes_grid.py
+++ b/lib/mpl_toolkits/axes_grid1/axes_grid.py
@@ -51,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,
*,
@@ -83,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
@@ -116,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))
@@ -150,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]
@@ -160,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()
@@ -177,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)))
@@ -190,6 +189,9 @@ def _get_col_row(self, n):
return col, row
+ n_axes = property(lambda self: len(self.axes_all))
+ ngrids = _api.deprecated('3.11')(property(lambda self: len(self.axes_all)))
+
# Good to propagate __len__ if we have __getitem__
def __len__(self):
return len(self.axes_all)
@@ -251,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
@@ -297,7 +298,7 @@ class ImageGrid(Grid):
def __init__(self, fig,
rect,
nrows_ncols,
- ngrids=None,
+ n_axes=None,
direction="row",
axes_pad=0.02,
*,
@@ -321,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
@@ -349,7 +350,7 @@ 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.
@@ -358,12 +359,12 @@ def __init__(self, fig,
``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%"
+ 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)
@@ -412,7 +413,7 @@ def _init_locators(self):
_cbaraxes_class_factory(self._defaultAxesClass)(
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,7 +434,7 @@ 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)
@@ -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 86e5f70d9824..55820827cd6a 100644
--- a/lib/mpl_toolkits/axes_grid1/axes_size.py
+++ b/lib/mpl_toolkits/axes_grid1/axes_size.py
@@ -1,7 +1,7 @@
"""
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
diff --git a/lib/mpl_toolkits/axes_grid1/inset_locator.py b/lib/mpl_toolkits/axes_grid1/inset_locator.py
index 52fe6efc0618..a1a9cc8df591 100644
--- a/lib/mpl_toolkits/axes_grid1/inset_locator.py
+++ b/lib/mpl_toolkits/axes_grid1/inset_locator.py
@@ -341,11 +341,16 @@ 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*
diff --git a/lib/mpl_toolkits/axes_grid1/parasite_axes.py b/lib/mpl_toolkits/axes_grid1/parasite_axes.py
index f7bc2df6d7e0..fbc6e8141272 100644
--- a/lib/mpl_toolkits/axes_grid1/parasite_axes.py
+++ b/lib/mpl_toolkits/axes_grid1/parasite_axes.py
@@ -25,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
diff --git a/lib/mpl_toolkits/axes_grid1/tests/baseline_images/test_axes_grid1/inset_locator.png b/lib/mpl_toolkits/axes_grid1/tests/baseline_images/test_axes_grid1/inset_locator.png
index c7ad1e64b84d..17a1460f6be4 100644
Binary files a/lib/mpl_toolkits/axes_grid1/tests/baseline_images/test_axes_grid1/inset_locator.png and b/lib/mpl_toolkits/axes_grid1/tests/baseline_images/test_axes_grid1/inset_locator.png 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 778bd9ca04d0..f550dc9f531e 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)
@@ -26,6 +26,7 @@
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)
+from mpl_toolkits.axes_grid1.parasite_axes import HostAxes
import mpl_toolkits.axes_grid1.mpl_axes
import pytest
@@ -92,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"
@@ -104,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",
@@ -346,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])
@@ -468,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"),
@@ -638,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', (
@@ -661,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])
@@ -679,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()
@@ -780,3 +800,11 @@ 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
+ with pytest.warns(mpl.MatplotlibDeprecationWarning, match="ngrids attribute"):
+ assert grid.ngrids == 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/axis_artist.py b/lib/mpl_toolkits/axisartist/axis_artist.py
index b416d56abe6b..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``,
@@ -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
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 8d06cb236269..e0057d4f6c1e 100644
--- a/lib/mpl_toolkits/axisartist/axislines.py
+++ b/lib/mpl_toolkits/axisartist/axislines.py
@@ -16,7 +16,7 @@
In the new axes class, xaxis and yaxis is set to not visible by
default, and new set of artist (AxisArtist) are defined to draw axis
line, ticks, ticklabels and axis label. Axes.axis attribute serves as
-a dictionary of these artists, i.e., ax.axis["left"] is a AxisArtist
+a dictionary of these artists, i.e., ax.axis["left"] is an AxisArtist
instance responsible to draw left y-axis. The default Axes.axis contains
"bottom", "left", "top" and "right".
@@ -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.")
diff --git a/lib/mpl_toolkits/axisartist/floating_axes.py b/lib/mpl_toolkits/axisartist/floating_axes.py
index 74e4c941879b..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):
@@ -177,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
@@ -212,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
@@ -227,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
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/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_axis_artist.py b/lib/mpl_toolkits/axisartist/tests/test_axis_artist.py
index d44a61b6dd4a..96d8a2cde0f3 100644
--- a/lib/mpl_toolkits/axisartist/tests/test_axis_artist.py
+++ b/lib/mpl_toolkits/axisartist/tests/test_axis_artist.py
@@ -24,7 +24,8 @@ def test_ticks():
ax.add_artist(ticks_out)
-@image_comparison(['axis_artist_labelbase.png'], style='default')
+# TODO: tighten tolerance after baseline image is regenerated for text overhaul
+@image_comparison(['axis_artist_labelbase.png'], style='default', tol=0.02)
def test_labelbase():
# Remove this line when this test image is regenerated.
plt.rcParams['text.kerning_factor'] = 6
@@ -41,7 +42,8 @@ def test_labelbase():
ax.add_artist(label)
-@image_comparison(['axis_artist_ticklabels.png'], style='default')
+# TODO: tighten tolerance after baseline image is regenerated for text overhaul
+@image_comparison(['axis_artist_ticklabels.png'], style='default', tol=0.03)
def test_ticklabels():
# Remove this line when this test image is regenerated.
plt.rcParams['text.kerning_factor'] = 6
@@ -76,7 +78,8 @@ def test_ticklabels():
ax.set_ylim(0, 1)
-@image_comparison(['axis_artist.png'], style='default')
+# TODO: tighten tolerance after baseline image is regenerated for text overhaul
+@image_comparison(['axis_artist.png'], style='default', tol=0.03)
def test_axis_artist():
# Remove this line when this test image is regenerated.
plt.rcParams['text.kerning_factor'] = 6
diff --git a/lib/mpl_toolkits/axisartist/tests/test_axislines.py b/lib/mpl_toolkits/axisartist/tests/test_axislines.py
index b722316a5c0c..a47ab2ea8a31 100644
--- a/lib/mpl_toolkits/axisartist/tests/test_axislines.py
+++ b/lib/mpl_toolkits/axisartist/tests/test_axislines.py
@@ -7,7 +7,8 @@
from mpl_toolkits.axisartist import Axes, SubplotHost
-@image_comparison(['SubplotZero.png'], style='default')
+# TODO: tighten tolerance after baseline image is regenerated for text overhaul
+@image_comparison(['SubplotZero.png'], style='default', tol=0.02)
def test_SubplotZero():
# Remove this line when this test image is regenerated.
plt.rcParams['text.kerning_factor'] = 6
@@ -28,7 +29,8 @@ def test_SubplotZero():
ax.set_ylabel("Test")
-@image_comparison(['Subplot.png'], style='default')
+# TODO: tighten tolerance after baseline image is regenerated for text overhaul
+@image_comparison(['Subplot.png'], style='default', tol=0.02)
def test_Subplot():
# Remove this line when this test image is regenerated.
plt.rcParams['text.kerning_factor'] = 6
@@ -83,8 +85,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 +121,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,10 +131,9 @@ 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')
+# TODO: tighten tolerance after baseline image is regenerated for text overhaul
+@image_comparison(['subplotzero_ylabel.png'], style='mpl20', tol=0.02)
def test_subplotzero_ylabel():
fig = plt.figure()
ax = fig.add_subplot(111, axes_class=SubplotZero)
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..ac31b8b30c97 100644
--- a/lib/mpl_toolkits/axisartist/tests/test_grid_helper_curvelinear.py
+++ b/lib/mpl_toolkits/axisartist/tests/test_grid_helper_curvelinear.py
@@ -76,14 +76,14 @@ def inverted(self):
ax1.grid(True)
-@image_comparison(['polar_box.png'], style='default', tol=0.04)
+# TODO: tighten tolerance after baseline image is regenerated for text overhaul
+@image_comparison(['polar_box.png'], style='default', tol=0.09)
def test_polar_box():
fig = plt.figure(figsize=(5, 5))
# 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
@@ -137,7 +137,7 @@ def test_polar_box():
# Remove tol & kerning_factor when this test image is regenerated.
-@image_comparison(['axis_direction.png'], style='default', tol=0.13)
+@image_comparison(['axis_direction.png'], style='default', tol=0.15)
def test_axis_direction():
plt.rcParams['text.kerning_factor'] = 6
@@ -145,8 +145,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 0467d2e96e5e..d06d157db4ce 100644
--- a/lib/mpl_toolkits/mplot3d/art3d.py
+++ b/lib/mpl_toolkits/mplot3d/art3d.py
@@ -15,10 +15,9 @@
from matplotlib import (
_api, artist, cbook, colors as mcolors, lines, text as mtext,
- path as mpath)
+ 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
@@ -59,11 +58,11 @@ def get_dir_vector(zdir):
x, y, z : array
The direction vector.
"""
- if zdir == 'x':
+ if cbook._str_equal(zdir, 'x'):
return np.array((1, 0, 0))
- elif zdir == 'y':
+ elif cbook._str_equal(zdir, 'y'):
return np.array((0, 1, 0))
- elif zdir == 'z':
+ elif cbook._str_equal(zdir, 'z'):
return np.array((0, 0, 1))
elif zdir is None:
return np.array((0, 0, 0))
@@ -75,7 +74,7 @@ def get_dir_vector(zdir):
def _viewlim_mask(xs, ys, zs, axes):
"""
- Return original points with points outside the axes view limits masked.
+ Return the mask of the points outside the axes view limits.
Parameters
----------
@@ -86,8 +85,8 @@ def _viewlim_mask(xs, ys, zs, axes):
Returns
-------
- xs_masked, ys_masked, zs_masked : np.ma.array
- The masked points.
+ 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,
@@ -95,10 +94,7 @@ def _viewlim_mask(xs, ys, zs, axes):
ys > axes.xy_viewLim.ymax,
zs < axes.zz_viewLim.xmin,
zs > axes.zz_viewLim.xmax))
- xs_masked = np.ma.array(xs, mask=mask)
- ys_masked = np.ma.array(ys, mask=mask)
- zs_masked = np.ma.array(zs, mask=mask)
- return xs_masked, ys_masked, zs_masked
+ return mask
class Text3D(mtext.Text):
@@ -117,6 +113,8 @@ class Text3D(mtext.Text):
axlim_clip : bool, default: False
Whether to hide text outside the axes view limits.
+ .. versionadded:: 3.10
+
Other Parameters
----------------
**kwargs
@@ -125,6 +123,16 @@ class Text3D(mtext.Text):
def __init__(self, x=0, y=0, z=0, text='', zdir='z', axlim_clip=False,
**kwargs):
+ if 'rotation' in kwargs:
+ _api.warn_external(
+ "The `rotation` parameter has not yet been implemented "
+ "and is currently ignored."
+ )
+ if 'rotation_mode' in kwargs:
+ _api.warn_external(
+ "The `rotation_mode` parameter has not yet been implemented "
+ "and is currently ignored."
+ )
mtext.Text.__init__(self, x, y, text, **kwargs)
self.set_3d_properties(z, zdir, axlim_clip)
@@ -173,6 +181,8 @@ def set_3d_properties(self, z=0, zdir='z', axlim_clip=False):
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)
@@ -182,14 +192,13 @@ def set_3d_properties(self, z=0, zdir='z', axlim_clip=False):
@artist.allow_rasterization
def draw(self, renderer):
if self._axlim_clip:
- xs, ys, zs = _viewlim_mask(self._x, self._y, self._z, self.axes)
- position3d = np.ma.row_stack((xs, ys, zs)).ravel().filled(np.nan)
+ 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:
- xs, ys, zs = self._x, self._y, self._z
- position3d = np.asanyarray([xs, ys, zs])
+ pos3d = np.array([self._x, self._y, self._z], dtype=float)
- proj = proj3d._proj_trans_points(
- [position3d, position3d + self._dir_vec], self.axes.M)
+ 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))
@@ -217,6 +226,8 @@ def text_2d_to_3d(obj, z=0, zdir='z', axlim_clip=False):
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, axlim_clip)
@@ -265,6 +276,8 @@ def set_3d_properties(self, zs=0, zdir='z', axlim_clip=False):
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()
@@ -313,7 +326,12 @@ def get_data_3d(self):
@artist.allow_rasterization
def draw(self, renderer):
if self._axlim_clip:
- xs3d, ys3d, zs3d = _viewlim_mask(*self._verts3d, self.axes)
+ 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,
@@ -337,6 +355,8 @@ def line_2d_to_3d(line, zs=0, zdir='z', axlim_clip=False):
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
@@ -404,7 +424,8 @@ def do_3d_projection(self):
"""Project the points according to renderer matrix."""
vs_list = [vs for vs, _ in self._3dverts_codes]
if self._axlim_clip:
- vs_list = [np.ma.row_stack(_viewlim_mask(*vs.T, self.axes)).T
+ 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)
@@ -433,6 +454,30 @@ class Line3DCollection(LineCollection):
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."""
@@ -450,22 +495,32 @@ def do_3d_projection(self):
"""
Project the points according to renderer matrix.
"""
- segments = self._segments3d
+ segments = np.asanyarray(self._segments3d)
+
+ mask = False
+ if np.ma.isMA(segments):
+ mask = segments.mask
+
if self._axlim_clip:
- all_points = np.ma.vstack(segments)
- masked_points = np.ma.column_stack([*_viewlim_mask(*all_points.T,
- self.axes)])
- segment_lengths = [np.shape(segment)[0] for segment in segments]
- segments = np.split(masked_points, np.cumsum(segment_lengths[:-1]))
- xyslist = [proj3d._proj_trans_points(points, self.axes.M)
- for points in segments]
- segments_2d = [np.ma.column_stack([xs, ys]) for xs, ys, zs in xyslist]
+ 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
@@ -495,6 +550,8 @@ def __init__(self, *args, zs=(), zdir='z', axlim_clip=False, **kwargs):
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, axlim_clip)
@@ -514,6 +571,8 @@ def set_3d_properties(self, verts, zs=0, zdir='z', axlim_clip=False):
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)
@@ -531,7 +590,9 @@ def get_path(self):
def do_3d_projection(self):
s = self._segment3d
if self._axlim_clip:
- xs, ys, zs = _viewlim_mask(*zip(*s), self.axes)
+ 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,
@@ -559,6 +620,8 @@ def __init__(self, path, *, zs=(), zdir='z', axlim_clip=False, **kwargs):
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)
@@ -579,6 +642,8 @@ def set_3d_properties(self, path, zs=0, zdir='z', axlim_clip=False):
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,
axlim_clip=axlim_clip)
@@ -587,7 +652,9 @@ def set_3d_properties(self, path, zs=0, zdir='z', axlim_clip=False):
def do_3d_projection(self):
s = self._segment3d
if self._axlim_clip:
- xs, ys, zs = _viewlim_mask(*zip(*s), self.axes)
+ 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,
@@ -627,8 +694,16 @@ class Patch3DCollection(PatchCollection):
A collection of 3D patches.
"""
- def __init__(self, *args,
- zs=0, zdir='z', depthshade=True, axlim_clip=False, **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*
@@ -639,18 +714,31 @@ def __init__(self, *args,
: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, 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.
@@ -659,8 +747,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):
@@ -683,6 +778,8 @@ def set_3d_properties(self, zs, zdir, axlim_clip=False):
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.
@@ -701,14 +798,18 @@ def set_3d_properties(self, zs, zdir, axlim_clip=False):
def do_3d_projection(self):
if self._axlim_clip:
- xs, ys, zs = _viewlim_mask(*self._offsets3d, self.axes)
+ 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.ma.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)
@@ -717,7 +818,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
)
@@ -737,13 +842,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, axlim_clip=False, **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*
@@ -754,11 +890,20 @@ def __init__(self, *args,
: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, axlim_clip)
@@ -789,6 +934,8 @@ def set_3d_properties(self, zs, zdir, axlim_clip=False):
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.
@@ -837,7 +984,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.
@@ -846,18 +997,33 @@ 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):
+ mask = False
+ for xyz in self._offsets3d:
+ if np.ma.isMA(xyz):
+ mask = mask | xyz.mask
if self._axlim_clip:
- xs, ys, zs = _viewlim_mask(*self._offsets3d, self.axes)
+ 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:
- xs, ys, zs = self._offsets3d
- vxs, vys, vzs, vis = proj3d._proj_transform_clip(xs, ys, zs,
+ 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
@@ -902,14 +1068,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())
@@ -923,7 +1097,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, axlim_clip=False):
+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).
@@ -939,17 +1121,29 @@ def patch_collection_2d_to_3d(col, zs=0, zdir='z', depthshade=True, axlim_clip=F
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, axlim_clip)
@@ -1001,6 +1195,8 @@ def __init__(self, verts, *args, zsort='average', shade=False,
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`.
@@ -1062,16 +1258,35 @@ def get_vector(self, segments3d):
return self._get_vector(segments3d)
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])
+ """
+ Optimize points for projection.
- indices = [0, *np.cumsum([len(segment) for segment in segments3d])]
- self._segslices = [*map(slice, indices[:-1], indices[1:])]
+ 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):
"""
@@ -1133,52 +1348,73 @@ def do_3d_projection(self):
self._facecolor3d = self._facecolors
if self._edge_is_mapped:
self._edgecolor3d = self._edgecolors
+
+ 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:
- xs, ys, zs = _viewlim_mask(*self._vec[0:3], self.axes)
- if self._vec.shape[0] == 4: # Will be 3 (xyz) or 4 (xyzw)
- w_masked = np.ma.masked_where(zs.mask, self._vec[3])
- vec = np.ma.array([xs, ys, zs, w_masked])
- else:
- vec = np.ma.array([xs, ys, zs])
- else:
- vec = self._vec
- txs, tys, tzs = proj3d._proj_transform_vec(vec, self.axes.M)
- xyzlist = [(txs[sl], tys[sl], tzs[sl]) for sl in self._segslices]
+ 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.data), np.ma.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
@@ -1186,11 +1422,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
@@ -1290,17 +1526,31 @@ 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])
@@ -1382,6 +1632,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 d0ba360c314b..e8b72c421cd2 100644
--- a/lib/mpl_toolkits/mplot3d/axes3d.py
+++ b/lib/mpl_toolkits/mplot3d/axes3d.py
@@ -70,7 +70,7 @@ def __init__(
----------
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
@@ -244,6 +244,8 @@ def _transformed_cube(self, vals):
(minx, maxy, maxz)]
return proj3d._proj_points(xyzs, self.M)
+ @_api.delete_parameter("3.11", "share")
+ @_api.delete_parameter("3.11", "anchor")
def set_aspect(self, aspect, adjustable=None, anchor=None, share=False):
"""
Set the aspect ratios.
@@ -263,39 +265,31 @@ def set_aspect(self, aspect, adjustable=None, anchor=None, share=False):
'equalyz' adapt the y and z axes to have equal aspect ratios.
========= ==================================================
- adjustable : None or {'box', 'datalim'}, optional
- If not *None*, this defines which parameter will be adjusted to
- meet the required aspect. See `.set_adjustable` for further
- details.
+ adjustable : {'box', 'datalim'}, default: 'box'
+ Defines which parameter to adjust to meet the aspect ratio.
+
+ - 'box': Change the physical dimensions of the axes bounding box.
+ - 'datalim': Change the x, y, or z data limits.
anchor : None or str or 2-tuple of float, optional
- If not *None*, this defines where the Axes will be drawn if there
- is extra space due to aspect constraints. The most common way to
- specify the anchor are abbreviations of cardinal directions:
-
- ===== =====================
- value description
- ===== =====================
- 'C' centered
- 'SW' lower left corner
- 'S' middle of bottom edge
- 'SE' lower right corner
- etc.
- ===== =====================
-
- See `~.Axes.set_anchor` for further details.
+ .. deprecated:: 3.11
+ This parameter has no effect.
share : bool, default: False
- If ``True``, apply the settings to all shared Axes.
+ .. deprecated:: 3.11
+ This parameter has no effect.
See Also
--------
mpl_toolkits.mplot3d.axes3d.Axes3D.set_box_aspect
"""
+ if adjustable is None:
+ adjustable = 'box'
+ _api.check_in_list(['box', 'datalim'], adjustable=adjustable)
_api.check_in_list(('auto', 'equal', 'equalxy', 'equalyz', 'equalxz'),
aspect=aspect)
- super().set_aspect(
- aspect='auto', adjustable=adjustable, anchor=anchor, share=share)
+
+ self.set_adjustable(adjustable)
self._aspect = aspect
if aspect in ('equal', 'equalxy', 'equalxz', 'equalyz'):
@@ -1400,7 +1394,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:
@@ -1886,18 +1880,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):
"""
@@ -1940,6 +1950,16 @@ def text(self, x, y, z, s, zdir=None, *, axlim_clip=False, **kwargs):
`.Text3D`
The created `.Text3D` instance.
"""
+ if 'rotation' in kwargs:
+ _api.warn_external(
+ "The `rotation` parameter has not yet been implemented "
+ "and is currently ignored."
+ )
+ if 'rotation_mode' in kwargs:
+ _api.warn_external(
+ "The `rotation_mode` parameter has not yet been implemented "
+ "and is currently ignored."
+ )
text = super().text(x, y, s, **kwargs)
art3d.text_2d_to_3d(text, z, zdir, axlim_clip)
return text
@@ -2033,9 +2053,10 @@ def fill_between(self, x1, y1, z1, x2, y2, z2, *,
- 'auto': If the points all lie on the same 3D plane, 'polygon' is
used. Otherwise, 'quad' is used.
- facecolors : list of :mpltype:`color`, default: None
+ facecolors : :mpltype:`color` or list of :mpltype:`color`, optional
Colors of each individual patch, or a single color to be used for
- all patches.
+ 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*
@@ -2117,7 +2138,7 @@ def fill_between(self, x1, y1, z1, x2, y2, z2, *,
polyc = art3d.Poly3DCollection(polys, facecolors=facecolors, shade=shade,
axlim_clip=axlim_clip, **kwargs)
- self.add_collection(polyc)
+ self.add_collection(polyc, autolim="_datalim_only")
self.auto_scale_xyz([x1, x2], [y1, y2], [z1, z2], had_data)
return polyc
@@ -2316,7 +2337,7 @@ def plot_surface(self, X, Y, Z, *, norm=None, vmin=None,
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
@@ -2397,51 +2418,52 @@ def plot_wireframe(self, X, Y, Z, *, axlim_clip=False, **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)])
-
+ # 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)
- self.auto_scale_xyz(X, Y, Z, had_data)
+ self.add_collection(linec, autolim="_datalim_only")
return linec
@@ -2542,7 +2564,7 @@ def plot_trisurf(self, *args, color=None, norm=None, vmin=None, vmax=None,
verts, *args, shade=shade, lightsource=lightsource,
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
@@ -2872,25 +2894,31 @@ def add_collection3d(self, col, zs=0, zdir='z', autolim=True, *,
if autolim:
if isinstance(col, art3d.Line3DCollection):
- self.auto_scale_xyz(*np.array(col._segments3d).transpose(),
- had_data=had_data)
+ # Handle ragged arrays by extracting coordinates separately
+ all_points = np.concatenate(col._segments3d)
+ self.auto_scale_xyz(all_points[:, 0], all_points[:, 1],
+ all_points[:, 2], 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,
- axlim_clip=False, **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.
@@ -2922,16 +2950,24 @@ def scatter(self, xs, ys,
- 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`.
@@ -2951,16 +2987,24 @@ def scatter(self, xs, ys,
)
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,
- axlim_clip=axlim_clip)
-
+ 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)
@@ -3192,7 +3236,7 @@ def bar3d(self, x, y, z, dx, dy, dz, color=None,
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)
@@ -3289,7 +3333,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)
@@ -3327,7 +3371,7 @@ def calc_arrows(UVW):
lines = []
linec = art3d.Line3DCollection(lines, axlim_clip=axlim_clip, **kwargs)
- self.add_collection(linec)
+ self.add_collection(linec, autolim="_datalim_only")
self.auto_scale_xyz(XYZ[:, 0], XYZ[:, 1], XYZ[:, 2], had_data)
@@ -3588,12 +3632,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`
@@ -3858,7 +3902,7 @@ def _extract_errs(err, data, lomask, himask):
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)
@@ -4000,8 +4044,7 @@ 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,
@@ -4009,7 +4052,7 @@ def stem(self, x, y, z, *, linefmt='C0-', markerfmt='C0o', basefmt='C3-',
stemlines = art3d.Line3DCollection(
lines, linestyles=linestyle, colors=linecolor, label='_nolegend_',
axlim_clip=axlim_clip)
- self.add_collection(stemlines)
+ self.add_collection(stemlines, autolim="_datalim_only")
markerline, = self.plot(x, y, z, markerfmt, label='_nolegend_')
stem_container = StemContainer((markerline, stemlines, baseline),
diff --git a/lib/mpl_toolkits/mplot3d/axis3d.py b/lib/mpl_toolkits/mplot3d/axis3d.py
index 4da5031b990c..fdd22b717f67 100644
--- a/lib/mpl_toolkits/mplot3d/axis3d.py
+++ b/lib/mpl_toolkits/mplot3d/axis3d.py
@@ -708,6 +708,8 @@ def get_tightbbox(self, renderer=None, *, for_layout_only=False):
bb_1, bb_2 = self._get_ticklabel_bboxes(ticks, renderer)
other = []
+ if self.offsetText.get_visible() and self.offsetText.get_text():
+ other.append(self.offsetText.get_window_extent(renderer))
if self.line.get_visible():
other.append(self.line.get_window_extent(renderer))
if (self.label.get_visible() and not for_layout_only and
diff --git a/lib/mpl_toolkits/mplot3d/proj3d.py b/lib/mpl_toolkits/mplot3d/proj3d.py
index 923bd32c9ce0..87c59ae05714 100644
--- a/lib/mpl_toolkits/mplot3d/proj3d.py
+++ b/lib/mpl_toolkits/mplot3d/proj3d.py
@@ -133,21 +133,38 @@ def _ortho_transformation(zfront, zback):
def _proj_transform_vec(vec, M):
vecw = np.dot(M, vec.data)
- w = vecw[3]
- txs, tys, tzs = vecw[0]/w, vecw[1]/w, vecw[2]/w
- if np.ma.isMA(vec[0]): # we check each to protect for scalars
- txs = np.ma.array(txs, mask=vec[0].mask)
- if np.ma.isMA(vec[1]):
- tys = np.ma.array(tys, mask=vec[1].mask)
- if np.ma.isMA(vec[2]):
- tzs = np.ma.array(tzs, mask=vec[2].mask)
- return txs, tys, tzs
+ 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)
- w = vecw[3]
- txs, tys, tzs = vecw[0] / w, vecw[1] / w, vecw[2] / w
+ 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:
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 f4f7067b76bb..aca943f9e0c0 100644
--- a/lib/mpl_toolkits/mplot3d/tests/test_art3d.py
+++ b/lib/mpl_toolkits/mplot3d/tests/test_art3d.py
@@ -1,9 +1,30 @@
import numpy as np
+import numpy.testing as nptest
+import pytest
import matplotlib.pyplot as plt
from matplotlib.backend_bases import MouseEvent
-from mpl_toolkits.mplot3d.art3d import Line3DCollection, _all_points_on_plane
+from mpl_toolkits.mplot3d.art3d import (
+ get_dir_vector,
+ Line3DCollection,
+ Poly3DCollection,
+ _all_points_on_plane,
+)
+
+
+@pytest.mark.parametrize("zdir, expected", [
+ ("x", (1, 0, 0)),
+ ("y", (0, 1, 0)),
+ ("z", (0, 0, 1)),
+ (None, (0, 0, 0)),
+ ((1, 2, 3), (1, 2, 3)),
+ (np.array([4, 5, 6]), (4, 5, 6)),
+])
+def test_get_dir_vector(zdir, expected):
+ res = get_dir_vector(zdir)
+ assert isinstance(res, np.ndarray)
+ nptest.assert_array_equal(res, expected)
def test_scatter_3d_projection_conservation():
@@ -51,7 +72,7 @@ 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()
@@ -85,3 +106,14 @@ def test_all_points_on_plane():
# 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 ad952e4395af..e9809ce2a106 100644
--- a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py
+++ b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py
@@ -3,6 +3,7 @@
import platform
import sys
+from packaging.version import parse as parse_version
import pytest
from mpl_toolkits.mplot3d import Axes3D, axes3d, proj3d, art3d
@@ -13,11 +14,11 @@
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
from matplotlib.text import Text
+from matplotlib import _api
import matplotlib.pyplot as plt
import numpy as np
@@ -36,7 +37,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)
@@ -181,7 +182,8 @@ def test_bar3d_shaded():
fig.canvas.draw()
-@mpl3d_image_comparison(['bar3d_notshaded.png'], style='mpl20')
+@mpl3d_image_comparison(['bar3d_notshaded.png'], style='mpl20',
+ tol=0.01 if parse_version(np.version.version).major < 2 else 0)
def test_bar3d_notshaded():
fig = plt.figure()
ax = fig.add_subplot(projection='3d')
@@ -221,17 +223,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', 'arm64', '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)
@@ -241,7 +242,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)
@@ -253,9 +254,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)
@@ -271,7 +272,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)
@@ -280,24 +281,17 @@ 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]
Z = X**2 + Y**2
- # Manually set the over/under colors to be the end of the colormap
- cmap = mpl.colormaps['viridis'].copy()
- cmap.set_under(cmap(0))
- cmap.set_over(cmap(255))
- # Set vmin/max to be the min/max values plotted on the reference image
- kwargs = {'vmin': 1, 'vmax': 7, 'cmap': cmap}
-
ax_ref = fig_ref.add_subplot(projection='3d')
- ax_ref.contourf(X, Y, Z, levels=[0, 2, 4, 6, 8], **kwargs)
+ ax_ref.contourf(X, Y, Z, levels=[0, 2, 4, 6, 8], vmin=1, vmax=7)
ax_test = fig_test.add_subplot(projection='3d')
- ax_test.contourf(X, Y, Z, levels, extend=extend, **kwargs)
+ ax_test.contourf(X, Y, Z, levels, extend=extend, vmin=1, vmax=7)
for ax in [ax_ref, ax_test]:
ax.set_xlim(-2, 2)
@@ -344,7 +338,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")
@@ -394,7 +388,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.
@@ -409,7 +403,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),
@@ -423,7 +416,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')
@@ -448,7 +440,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.
@@ -462,12 +454,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)
@@ -483,13 +475,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
@@ -540,7 +532,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.
@@ -560,7 +552,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.
@@ -648,14 +640,15 @@ 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)
fig.colorbar(surf, shrink=0.5, aspect=5)
-@image_comparison(['surface3d_label_offset_tick_position.png'], style='mpl20')
+# TODO: tighten tolerance after baseline image is regenerated for text overhaul
+@image_comparison(['surface3d_label_offset_tick_position.png'], style='mpl20', tol=0.07)
def test_surface3d_label_offset_tick_position():
plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated
ax = plt.figure().add_subplot(projection="3d")
@@ -711,7 +704,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)
@@ -729,7 +722,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
@@ -751,7 +744,8 @@ def test_surface3d_masked_strides():
ax.view_init(60, -45, 0)
-@mpl3d_image_comparison(['text3d.png'], remove_text=False, style='mpl20')
+# TODO: tighten tolerance after baseline image is regenerated for text overhaul
+@mpl3d_image_comparison(['text3d.png'], remove_text=False, style='mpl20', tol=0.1)
def test_text3d():
fig = plt.figure()
ax = fig.add_subplot(projection='3d')
@@ -776,7 +770,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.
@@ -816,7 +810,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')
@@ -845,6 +839,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()
@@ -882,7 +884,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']
@@ -902,7 +903,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 = []
@@ -936,7 +937,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)
@@ -954,13 +955,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)
@@ -988,7 +989,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])
@@ -1123,8 +1124,9 @@ def test_poly3dCollection_autoscaling():
assert np.allclose(ax.get_zlim3d(), (-0.0833333333333333, 4.083333333333333))
+# TODO: tighten tolerance after baseline image is regenerated for text overhaul
@mpl3d_image_comparison(['axes3d_labelpad.png'],
- remove_text=False, style='mpl20')
+ remove_text=False, style='mpl20', tol=0.06)
def test_axes3d_labelpad():
fig = plt.figure()
ax = fig.add_axes(Axes3D(fig))
@@ -1214,7 +1216,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)
@@ -1324,7 +1326,7 @@ def test_unautoscale(axis, auto):
np.testing.assert_array_equal(get_lim(), (-0.5, 0.5))
-@check_figures_equal(extensions=["png"])
+@check_figures_equal()
def test_culling(fig_test, fig_ref):
xmins = (-100, -50)
for fig, xmin in zip((fig_test, fig_ref), xmins):
@@ -1379,7 +1381,7 @@ def test_axes3d_isometric():
ax.grid(True)
-@check_figures_equal(extensions=["png"])
+@check_figures_equal()
def test_axlim_clip(fig_test, fig_ref):
# With axlim clipping
ax = fig_test.add_subplot(projection="3d")
@@ -1577,7 +1579,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")
@@ -1626,7 +1628,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:
@@ -1666,7 +1668,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
@@ -1713,7 +1715,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()
@@ -1729,7 +1731,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),
@@ -1863,7 +1865,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")
@@ -2008,16 +2010,16 @@ def test_rotate(style):
ax.figure.canvas.draw()
# drag mouse to change orientation
- ax._button_press(
- mock_event(ax, button=MouseButton.LEFT, xdata=0, ydata=0))
- ax._on_move(
- mock_event(ax, button=MouseButton.LEFT,
- xdata=s*dx*ax._pseudo_w, ydata=s*dy*ax._pseudo_h))
+ 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, 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),
@@ -2072,10 +2074,10 @@ def convert_lim(dmin, dmax):
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())
@@ -2130,20 +2132,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
@@ -2169,7 +2171,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),
@@ -2223,9 +2225,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')
@@ -2369,7 +2371,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$')
@@ -2390,7 +2392,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()
@@ -2549,11 +2551,10 @@ def test_on_move_vertical_axis(vertical_axis: str) -> None:
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
@@ -2604,7 +2605,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
@@ -2687,3 +2688,100 @@ def test_ndarray_color_kwargs_value_error():
ax = fig.add_subplot(111, projection='3d')
ax.scatter(1, 0, 0, color=np.array([0, 0, 0, 1]))
fig.canvas.draw()
+
+
+def test_line3dcollection_autolim_ragged():
+ """Test Line3DCollection with autolim=True and lines of different lengths."""
+ fig = plt.figure()
+ ax = fig.add_subplot(projection='3d')
+
+ # Create lines with different numbers of points (ragged arrays)
+ edges = [
+ [(0, 0, 0), (1, 1, 1), (2, 2, 2)], # 3 points
+ [(0, 1, 0), (1, 2, 1)], # 2 points
+ [(1, 0, 1), (2, 1, 2), (3, 2, 3), (4, 3, 4)] # 4 points
+ ]
+
+ # This should not raise an exception.
+ collections = ax.add_collection3d(art3d.Line3DCollection(edges), autolim=True)
+
+ # Check that limits were computed correctly with margins
+ # The limits should include all points with default margins
+ assert np.allclose(ax.get_xlim3d(), (-0.08333333333333333, 4.083333333333333))
+ assert np.allclose(ax.get_ylim3d(), (-0.0625, 3.0625))
+ assert np.allclose(ax.get_zlim3d(), (-0.08333333333333333, 4.083333333333333))
+
+
+def test_axes3d_set_aspect_deperecated_params():
+ """
+ Test that using the deprecated 'anchor' and 'share' kwargs in
+ set_aspect raises the correct warning.
+ """
+ fig = plt.figure()
+ ax = fig.add_subplot(projection='3d')
+
+ # Test that providing the `anchor` parameter raises a deprecation warning.
+ with pytest.warns(_api.MatplotlibDeprecationWarning, match="'anchor' parameter"):
+ ax.set_aspect('equal', anchor='C')
+
+ # Test that using the 'share' parameter is now deprecated.
+ with pytest.warns(_api.MatplotlibDeprecationWarning, match="'share' parameter"):
+ ax.set_aspect('equal', share=True)
+
+ # Test that the `adjustable` parameter is correctly processed to satisfy
+ # code coverage.
+ ax.set_aspect('equal', adjustable='box')
+ assert ax.get_adjustable() == 'box'
+
+ ax.set_aspect('equal', adjustable='datalim')
+ assert ax.get_adjustable() == 'datalim'
+
+ with pytest.raises(ValueError, match="adjustable"):
+ ax.set_aspect('equal', adjustable='invalid_value')
+
+
+def test_axis_get_tightbbox_includes_offset_text():
+ # Test that axis.get_tightbbox includes the offset_text
+ # Regression test for issue #30744
+ fig = plt.figure()
+ ax = fig.add_subplot(111, projection='3d')
+
+ # Create data with high precision values that trigger offset text
+ Z = np.array([[0.1, 0.100000001], [0.100000000001, 0.100000000]])
+ ny, nx = Z.shape
+ x = np.arange(nx)
+ y = np.arange(ny)
+ X, Y = np.meshgrid(x, y)
+
+ ax.plot_surface(X, Y, Z)
+
+ # Force a draw to ensure offset text is created and positioned
+ fig.canvas.draw()
+ renderer = fig.canvas.get_renderer()
+
+ # Get the z-axis (which should have the offset text)
+ zaxis = ax.zaxis
+
+ # Check that offset text is visible and has content
+ # The offset text may not be visible on all backends/configurations,
+ # so we only test the inclusion when it's actually present
+ if (zaxis.offsetText.get_visible() and
+ zaxis.offsetText.get_text()):
+ offset_bbox = zaxis.offsetText.get_window_extent(renderer)
+
+ # Get the tight bbox - this should include the offset text
+ bbox = zaxis.get_tightbbox(renderer)
+ assert bbox is not None
+ assert offset_bbox is not None
+
+ # The tight bbox should fully contain the offset text bbox
+ # Check that offset_bbox is within bbox bounds (with small tolerance for
+ # floating point errors)
+ assert bbox.x0 <= offset_bbox.x0 + 1e-6, \
+ f"bbox.x0 ({bbox.x0}) should be <= offset_bbox.x0 ({offset_bbox.x0})"
+ assert bbox.y0 <= offset_bbox.y0 + 1e-6, \
+ f"bbox.y0 ({bbox.y0}) should be <= offset_bbox.y0 ({offset_bbox.y0})"
+ assert bbox.x1 >= offset_bbox.x1 - 1e-6, \
+ f"bbox.x1 ({bbox.x1}) should be >= offset_bbox.x1 ({offset_bbox.x1})"
+ assert bbox.y1 >= offset_bbox.y1 - 1e-6, \
+ f"bbox.y1 ({bbox.y1}) should be >= offset_bbox.y1 ({offset_bbox.y1})"
diff --git a/lib/mpl_toolkits/mplot3d/tests/test_legend3d.py b/lib/mpl_toolkits/mplot3d/tests/test_legend3d.py
index 0935bbe7f6b0..9ca048e18ba9 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
@@ -90,8 +90,7 @@ def test_contourf_legend_elements():
cs = ax.contourf(x, y, h, levels=[10, 30, 50],
colors=['#FFFF00', '#FF00FF', '#00FFFF'],
extend='both')
- cs.cmap.set_over('red')
- cs.cmap.set_under('blue')
+ cs.cmap = cs.cmap.with_extremes(over='red', under='blue')
cs.changed()
artists, labels = cs.legend_elements()
assert labels == ['$x \\leq -1e+250s$',
diff --git a/meson.build b/meson.build
index a50f0b8f743a..47244656705f 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
@@ -28,6 +31,10 @@ project(
],
)
+# Enable bug fixes in Agg
+add_project_arguments('-DMPL_FIX_AGG_IMAGE_FILTER_LUT_BUGS', language : 'cpp')
+add_project_arguments('-DMPL_FIX_AGG_INTERPOLATION_ENDPOINT_BUG', language : 'cpp')
+
cc = meson.get_compiler('c')
cpp = meson.get_compiler('cpp')
diff --git a/pyproject.toml b/pyproject.toml
index 48a174731440..b2e5451818f4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -16,10 +16,10 @@ classifiers=[
"License :: OSI Approved :: Python Software Foundation License",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
- "Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
+ "Programming Language :: Python :: 3.14",
"Topic :: Scientific/Engineering :: Visualization",
]
@@ -35,23 +35,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",
]
-requires-python = ">=3.10"
+# 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",
+ "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",
@@ -70,7 +71,9 @@ dev = [
build-backend = "mesonpy"
# Also keep in sync with optional dependencies above.
requires = [
- "meson-python>=0.13.1",
+ # 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",
]
@@ -89,19 +92,16 @@ 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
-target-version = "py310"
[tool.ruff.lint]
ignore = [
@@ -112,15 +112,26 @@ 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",
]
@@ -131,6 +142,7 @@ select = [
"E",
"F",
"W",
+ "UP035",
# The following error codes require the preview mode to be enabled.
"E201",
"E202",
@@ -139,6 +151,7 @@ select = [
"E251",
"E261",
"E272",
+ "E302",
"E703",
]
@@ -149,22 +162,20 @@ select = [
# See https://github.com/charliermarsh/ruff/issues/2402 for status on implementation
external = [
"E122",
- "E302",
]
[tool.ruff.lint.pydocstyle]
convention = "numpy"
[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/misc/table_demo.py" = ["E201"]
-"galleries/examples/style_sheets/bmh.py" = ["E501"]
"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"]
@@ -178,9 +189,9 @@ 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/_cm.py" = ["E202", "E203"]
+"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"]
@@ -193,20 +204,17 @@ convention = "numpy"
"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"]
@@ -217,24 +225,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",
diff --git a/requirements/dev/dev-requirements.txt b/requirements/dev/dev-requirements.txt
index e5cbc1091bb2..3208949ba0e8 100644
--- a/requirements/dev/dev-requirements.txt
+++ b/requirements/dev/dev-requirements.txt
@@ -2,4 +2,4 @@
-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 77cb606130b0..1a352eaae975 100644
--- a/requirements/doc/doc-requirements.txt
+++ b/requirements/doc/doc-requirements.txt
@@ -14,8 +14,7 @@ ipywidgets
ipykernel
numpydoc>=1.0
packaging>=20
-pydata-sphinx-theme~=0.15.0
-mpl-sphinx-theme~=3.9.0
+mpl-sphinx-theme~=3.10.0
pyyaml
PyStemmer
sphinxcontrib-svg2pdfconverter>=1.1.0
diff --git a/requirements/testing/all.txt b/requirements/testing/all.txt
index e386924a9b67..dd1dbf3f29fd 100644
--- a/requirements/testing/all.txt
+++ b/requirements/testing/all.txt
@@ -1,12 +1,12 @@
# pip requirements for all the CI builds
-black<24
+black<26
certifi
coverage!=6.3
psutil
pytest!=4.6.0,!=5.4.0,!=8.1.0
pytest-cov
-pytest-rerunfailures
+pytest-rerunfailures!=16.0
pytest-timeout
pytest-xdist
pytest-xvfb
diff --git a/requirements/testing/extra.txt b/requirements/testing/extra.txt
index a5c1bef5f03a..e0d84d71c781 100644
--- a/requirements/testing/extra.txt
+++ b/requirements/testing/extra.txt
@@ -1,4 +1,4 @@
-# Extra pip requirements for the Python 3.10+ 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 3932e68eb015..ee55f6c7b1bf 100644
--- a/requirements/testing/minver.txt
+++ b/requirements/testing/minver.txt
@@ -7,9 +7,17 @@ importlib-resources==3.2.0
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.3.2
-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 aa20581ee69b..343517263f40 100644
--- a/requirements/testing/mypy.txt
+++ b/requirements/testing/mypy.txt
@@ -19,8 +19,8 @@ cycler>=0.10
fonttools>=4.22.0
kiwisolver>=1.3.1
packaging>=20.0
-pillow>=8
-pyparsing>=2.3.1
+pillow>=9
+pyparsing>=3
python-dateutil>=2.7
setuptools_scm>=7
setuptools>=64
diff --git a/src/_backend_agg.cpp b/src/_backend_agg.cpp
index eed27323ba9e..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,7 +26,7 @@ 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) {
@@ -75,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;
@@ -90,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 8010508ae920..1ac3d4c06b13 100644
--- a/src/_backend_agg.h
+++ b/src/_backend_agg.h
@@ -11,6 +11,8 @@
#include
#include
#include
+#include
+#include
#include "agg_alpha_mask_u8.h"
#include "agg_conv_curve.h"
@@ -65,6 +67,10 @@ class BufferRegion
delete[] data;
};
+ // prevent copying
+ BufferRegion(const BufferRegion &) = delete;
+ BufferRegion &operator=(const BufferRegion &) = delete;
+
agg::int8u *get_data()
{
return data;
@@ -96,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
{
@@ -125,9 +124,6 @@ class RendererAgg
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();
@@ -178,7 +174,8 @@ class RendererAgg
ColorArray &edgecolors,
LineWidthArray &linewidths,
DashesVector &linestyles,
- AntialiasedArray &antialiaseds);
+ AntialiasedArray &antialiaseds,
+ ColorArray &hatchcolors);
template
void draw_quad_mesh(GCAgg &gc,
@@ -249,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,
@@ -295,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;
@@ -306,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) {
@@ -314,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 {
@@ -325,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);
}
}
@@ -458,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);
@@ -467,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) {
@@ -529,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;
@@ -538,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(),
@@ -568,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()),
@@ -616,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 {
@@ -644,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);
}
@@ -890,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);
@@ -918,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;
@@ -938,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;
}
@@ -954,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);
@@ -988,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) {
@@ -1005,6 +990,11 @@ inline void RendererAgg::_draw_path_collection_generic(GCAgg &gc,
}
}
+ if(Nhatchcolors) {
+ int ic = i % Nhatchcolors;
+ gc.hatch_color = agg::rgba(hatchcolors(ic, 0), hatchcolors(ic, 1), hatchcolors(ic, 2), hatchcolors(ic, 3));
+ }
+
gc.isaa = antialiaseds(i % Naa);
transformed_path_t tpath(path, trans);
nan_removed_t nan_removed(tpath, true, has_codes);
@@ -1049,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,
@@ -1066,7 +1057,8 @@ inline void RendererAgg::draw_path_collection(GCAgg &gc,
linestyles,
antialiaseds,
true,
- true);
+ true,
+ hatchcolors);
}
template
@@ -1160,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,
@@ -1176,7 +1169,8 @@ inline void RendererAgg::draw_quad_mesh(GCAgg &gc,
linestyles,
antialiaseds,
true, // check_snap
- false);
+ false,
+ hatchcolors);
}
template
diff --git a/src/_backend_agg_basic_types.h b/src/_backend_agg_basic_types.h
index e3e6be9a4532..b424419ec99e 100644
--- a/src/_backend_agg_basic_types.h
+++ b/src/_backend_agg_basic_types.h
@@ -48,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
{
@@ -59,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) {
diff --git a/src/_backend_agg_wrapper.cpp b/src/_backend_agg_wrapper.cpp
index 269e2aaa9ee5..3dd50b31f64a 100644
--- a/src/_backend_agg_wrapper.cpp
+++ b/src/_backend_agg_wrapper.cpp
@@ -146,12 +146,14 @@ PyRendererAgg_draw_path_collection(RendererAgg *self,
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::object Py_UNUSED(offset_position_obj),
+ py::array_t hatchcolors_obj)
{
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>();
@@ -165,7 +167,8 @@ PyRendererAgg_draw_path_collection(RendererAgg *self,
edgecolors,
linewidths,
dashes,
- antialiaseds);
+ antialiaseds,
+ hatchcolors);
}
static void
@@ -229,7 +232,8 @@ PYBIND11_MODULE(_backend_agg, m, py::mod_gil_not_used())
.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)
+ "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,
diff --git a/src/_c_internal_utils.cpp b/src/_c_internal_utils.cpp
index db6191849bbe..31eb92444862 100644
--- a/src/_c_internal_utils.cpp
+++ b/src/_c_internal_utils.cpp
@@ -41,13 +41,13 @@ mpl_xdisplay_is_valid(void)
// than dlopen().
if (getenv("DISPLAY")
&& (libX11 = dlopen("libX11.so.6", RTLD_LAZY))) {
- typedef struct Display* (*XOpenDisplay_t)(char const*);
- typedef int (*XCloseDisplay_t)(struct Display*);
- struct Display* display = NULL;
- XOpenDisplay_t XOpenDisplay = (XOpenDisplay_t)dlsym(libX11, "XOpenDisplay");
- XCloseDisplay_t XCloseDisplay = (XCloseDisplay_t)dlsym(libX11, "XCloseDisplay");
+ struct Display* display = nullptr;
+ auto XOpenDisplay = (struct Display* (*)(char const*))
+ dlsym(libX11, "XOpenDisplay");
+ auto XCloseDisplay = (int (*)(struct Display*))
+ dlsym(libX11, "XCloseDisplay");
if (XOpenDisplay && XCloseDisplay
- && (display = XOpenDisplay(NULL))) {
+ && (display = XOpenDisplay(nullptr))) {
XCloseDisplay(display);
}
if (dlclose(libX11)) {
@@ -73,15 +73,13 @@ mpl_display_is_valid(void)
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;
- 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");
+ struct wl_display* display = nullptr;
+ auto wl_display_connect = (struct wl_display* (*)(char const*))
+ dlsym(libwayland_client, "wl_display_connect");
+ auto wl_display_disconnect = (void (*)(struct wl_display*))
+ 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)) {
@@ -162,25 +160,19 @@ mpl_SetProcessDpiAwareness_max(void)
#ifdef _DPI_AWARENESS_CONTEXTS_
// These functions and options were added in later Windows 10 updates, so
// must be loaded dynamically.
- typedef BOOL (WINAPI *IsValidDpiAwarenessContext_t)(DPI_AWARENESS_CONTEXT);
- typedef BOOL (WINAPI *SetProcessDpiAwarenessContext_t)(DPI_AWARENESS_CONTEXT);
-
HMODULE user32 = LoadLibrary("user32.dll");
- IsValidDpiAwarenessContext_t IsValidDpiAwarenessContextPtr =
- (IsValidDpiAwarenessContext_t)GetProcAddress(
- user32, "IsValidDpiAwarenessContext");
- SetProcessDpiAwarenessContext_t SetProcessDpiAwarenessContextPtr =
- (SetProcessDpiAwarenessContext_t)GetProcAddress(
- user32, "SetProcessDpiAwarenessContext");
+ auto IsValidDpiAwarenessContext = (BOOL (WINAPI *)(DPI_AWARENESS_CONTEXT))
+ GetProcAddress(user32, "IsValidDpiAwarenessContext");
+ auto SetProcessDpiAwarenessContext = (BOOL (WINAPI *)(DPI_AWARENESS_CONTEXT))
+ GetProcAddress(user32, "SetProcessDpiAwarenessContext");
DPI_AWARENESS_CONTEXT ctxs[3] = {
DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2, // Win10 Creators Update
DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE, // Win10
DPI_AWARENESS_CONTEXT_SYSTEM_AWARE}; // Win10
- if (IsValidDpiAwarenessContextPtr != NULL
- && SetProcessDpiAwarenessContextPtr != NULL) {
+ if (IsValidDpiAwarenessContext && SetProcessDpiAwarenessContext) {
for (size_t i = 0; i < sizeof(ctxs) / sizeof(DPI_AWARENESS_CONTEXT); ++i) {
- if (IsValidDpiAwarenessContextPtr(ctxs[i])) {
- SetProcessDpiAwarenessContextPtr(ctxs[i]);
+ if (IsValidDpiAwarenessContext(ctxs[i])) {
+ SetProcessDpiAwarenessContext(ctxs[i]);
break;
}
}
diff --git a/src/_image_resample.h b/src/_image_resample.h
index 282bf8ef82f6..1b7af133de31 100644
--- a/src/_image_resample.h
+++ b/src/_image_resample.h
@@ -60,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)
@@ -246,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,54 +496,44 @@ typedef enum {
} interpolation_e;
-// T is rgba if and only if it has an T::r field.
+// T is rgba if and only if it has a T::r field.
template struct is_grayscale : std::true_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,
agg::span_image_filter_rgba_nn
- >::type;
+ >;
};
@@ -583,23 +569,28 @@ class lookup_distortion
{
public:
lookup_distortion(const double *mesh, int in_width, int in_height,
- int out_width, int out_height) :
+ int out_width, int out_height, bool edge_aligned_subpixels) :
m_mesh(mesh),
m_in_width(in_width),
m_in_height(in_height),
m_out_width(out_width),
- m_out_height(out_height)
+ m_out_height(out_height),
+ m_edge_aligned_subpixels(edge_aligned_subpixels)
{}
void calculate(int* x, int* y) {
if (m_mesh) {
+ // Nearest-neighbor interpolation needs edge-aligned subpixels
+ // All other interpolation approaches need center-aligned subpixels
+ double offset = m_edge_aligned_subpixels ? 0 : 0.5;
+
double dx = double(*x) / agg::image_subpixel_scale;
double dy = double(*y) / agg::image_subpixel_scale;
if (dx >= 0 && dx < m_out_width &&
dy >= 0 && dy < m_out_height) {
const double *coord = m_mesh + (int(dy) * m_out_width + int(dx)) * 2;
- *x = int(coord[0] * agg::image_subpixel_scale);
- *y = int(coord[1] * agg::image_subpixel_scale);
+ *x = int(coord[0] * agg::image_subpixel_scale + offset);
+ *y = int(coord[1] * agg::image_subpixel_scale + offset);
}
}
}
@@ -610,6 +601,7 @@ class lookup_distortion
int m_in_height;
int m_out_width;
int m_out_height;
+ bool m_edge_aligned_subpixels;
};
@@ -795,7 +787,7 @@ void resample(
using span_conv_t = agg::span_converter;
using nn_renderer_t = agg::renderer_scanline_aa;
lookup_distortion dist(
- params.transform_mesh, in_width, in_height, out_width, out_height);
+ params.transform_mesh, in_width, in_height, out_width, out_height, true);
arbitrary_interpolator_t interpolator(inverted, dist);
span_gen_t span_gen(input_accessor, interpolator);
span_conv_t span_conv(span_gen, conv_alpha);
@@ -820,7 +812,7 @@ void resample(
using span_conv_t = agg::span_converter;
using int_renderer_t = agg::renderer_scanline_aa;
lookup_distortion dist(
- params.transform_mesh, in_width, in_height, out_width, out_height);
+ params.transform_mesh, in_width, in_height, out_width, out_height, false);
arbitrary_interpolator_t interpolator(inverted, dist);
span_gen_t span_gen(input_accessor, interpolator, filter);
span_conv_t span_conv(span_gen, conv_alpha);
diff --git a/src/_image_wrapper.cpp b/src/_image_wrapper.cpp
index 0f7b0da88de8..c062ef14a8f1 100644
--- a/src/_image_wrapper.cpp
+++ b/src/_image_wrapper.cpp
@@ -1,6 +1,8 @@
#include
#include
+#include
+
#include "_image_resample.h"
#include "py_converters.h"
@@ -54,7 +56,7 @@ _get_transform_mesh(const py::object& transform, const py::ssize_t *dims)
/* TODO: Could we get away with float, rather than double, arrays here? */
/* Given a non-affine transform object, create a mesh that maps
- every pixel in the output image to the input image. This is used
+ every pixel center in the output image to the input image. This is used
as a lookup table during the actual resampling. */
// If attribute doesn't exist, raises Python AttributeError
@@ -66,8 +68,10 @@ _get_transform_mesh(const py::object& transform, const py::ssize_t *dims)
for (auto y = 0; y < dims[0]; ++y) {
for (auto x = 0; x < dims[1]; ++x) {
- *p++ = (double)x;
- *p++ = (double)y;
+ // The convention for the supplied transform is that pixel centers
+ // are at 0.5, 1.5, 2.5, etc.
+ *p++ = (double)x + 0.5;
+ *p++ = (double)y + 0.5;
}
}
@@ -163,12 +167,17 @@ image_resample(py::array input_array,
if (is_affine) {
convert_trans_affine(transform, params.affine);
- params.is_affine = true;
- } else {
+ // If affine parameters will make subpixels visible, treat as nonaffine instead
+ if (params.affine.sx >= agg::image_subpixel_scale / 2 || params.affine.sy >= agg::image_subpixel_scale / 2) {
+ is_affine = false;
+ params.affine = agg::trans_affine(); // reset to identity affine parameters
+ }
+ }
+ if (!is_affine) {
transform_mesh = _get_transform_mesh(transform, output_array.shape());
params.transform_mesh = transform_mesh.data();
- params.is_affine = false;
}
+ params.is_affine = is_affine;
}
if (auto resampler =
@@ -200,6 +209,80 @@ image_resample(py::array input_array,
}
+// This is used by matplotlib.testing.compare to calculate RMS and a difference image.
+static py::tuple
+calculate_rms_and_diff(py::array_t expected_image,
+ py::array_t actual_image)
+{
+ for (const auto & [image, name] : {std::pair{expected_image, "Expected"},
+ std::pair{actual_image, "Actual"}})
+ {
+ if (image.ndim() != 3) {
+ auto exceptions = py::module_::import("matplotlib.testing.exceptions");
+ auto ImageComparisonFailure = exceptions.attr("ImageComparisonFailure");
+ py::set_error(
+ ImageComparisonFailure,
+ "{name} image must be 3-dimensional, but is {ndim}-dimensional"_s.format(
+ "name"_a=name, "ndim"_a=image.ndim()));
+ throw py::error_already_set();
+ }
+ }
+
+ auto height = expected_image.shape(0);
+ auto width = expected_image.shape(1);
+ auto depth = expected_image.shape(2);
+
+ if (depth != 3 && depth != 4) {
+ auto exceptions = py::module_::import("matplotlib.testing.exceptions");
+ auto ImageComparisonFailure = exceptions.attr("ImageComparisonFailure");
+ py::set_error(
+ ImageComparisonFailure,
+ "Image must be RGB or RGBA but has depth {depth}"_s.format(
+ "depth"_a=depth));
+ throw py::error_already_set();
+ }
+
+ if (height != actual_image.shape(0) || width != actual_image.shape(1) ||
+ depth != actual_image.shape(2)) {
+ auto exceptions = py::module_::import("matplotlib.testing.exceptions");
+ auto ImageComparisonFailure = exceptions.attr("ImageComparisonFailure");
+ py::set_error(
+ ImageComparisonFailure,
+ "Image sizes do not match expected size: {expected_image.shape} "_s
+ "actual size {actual_image.shape}"_s.format(
+ "expected_image"_a=expected_image, "actual_image"_a=actual_image));
+ throw py::error_already_set();
+ }
+ auto expected = expected_image.unchecked<3>();
+ auto actual = actual_image.unchecked<3>();
+
+ py::ssize_t diff_dims[3] = {height, width, 3};
+ py::array_t diff_image(diff_dims);
+ auto diff = diff_image.mutable_unchecked<3>();
+
+ double total = 0.0;
+ for (auto i = 0; i < height; i++) {
+ for (auto j = 0; j < width; j++) {
+ for (auto k = 0; k < depth; k++) {
+ auto pixel_diff = static_cast(expected(i, j, k)) -
+ static_cast(actual(i, j, k));
+
+ total += pixel_diff*pixel_diff;
+
+ if (k != 3) { // Hard-code a fully solid alpha channel by omitting it.
+ diff(i, j, k) = static_cast(std::clamp(
+ abs(pixel_diff) * 10, // Expand differences in luminance domain.
+ 0.0, 255.0));
+ }
+ }
+ }
+ }
+ total = total / (width * height * depth);
+
+ return py::make_tuple(sqrt(total), diff_image);
+}
+
+
PYBIND11_MODULE(_image, m, py::mod_gil_not_used())
{
py::enum_(m, "_InterpolationType")
@@ -232,4 +315,7 @@ PYBIND11_MODULE(_image, m, py::mod_gil_not_used())
"norm"_a = false,
"radius"_a = 1,
image_resample__doc__);
+
+ m.def("calculate_rms_and_diff", &calculate_rms_and_diff,
+ "expected_image"_a, "actual_image"_a);
}
diff --git a/src/_macosx.m b/src/_macosx.m
index 09838eccaf98..9ca6c0749322 100755
--- a/src/_macosx.m
+++ b/src/_macosx.m
@@ -40,60 +40,84 @@
static bool keyChangeCapsLock = false;
/* Keep track of the current mouse up/down state for open/closed cursor hand */
static bool leftMouseGrabbing = false;
-/* Keep track of whether stdin has been received */
-static bool stdin_received = false;
-static bool stdin_sigint = false;
// Global variable to store the original SIGINT handler
static PyOS_sighandler_t originalSigintAction = NULL;
-// Signal handler for SIGINT, only sets a flag to exit the run loop
+// Stop the current app's run loop, sending an event to ensure it actually stops
+static void stopWithEvent() {
+ [NSApp stop: nil];
+ // Post an event to trigger the actual stopping.
+ [NSApp postEvent: [NSEvent otherEventWithType: NSEventTypeApplicationDefined
+ location: NSZeroPoint
+ modifierFlags: 0
+ timestamp: 0
+ windowNumber: 0
+ context: nil
+ subtype: 0
+ data1: 0
+ data2: 0]
+ atStart: YES];
+}
+
+// Signal handler for SIGINT, only argument matching for stopWithEvent
static void handleSigint(int signal) {
- stdin_sigint = true;
+ stopWithEvent();
+}
+
+// Helper function to flush all events.
+// This is needed in some instances to ensure e.g. that windows are properly closed.
+// It is used in the input hook as well as wrapped in a version callable from Python.
+static void flushEvents() {
+ while (true) {
+ NSEvent* event = [NSApp nextEventMatchingMask: NSEventMaskAny
+ untilDate: [NSDate distantPast]
+ inMode: NSDefaultRunLoopMode
+ dequeue: YES];
+ if (!event) {
+ break;
+ }
+ [NSApp sendEvent:event];
+ }
}
static int wait_for_stdin() {
- @autoreleasepool {
- stdin_received = false;
- stdin_sigint = false;
+ // Short circuit if no windows are active
+ // Rely on Python's input handling to manage CPU usage
+ // This queries the NSApp, rather than using our FigureWindowCount because that is decremented when events still
+ // need to be processed to properly close the windows.
+ if (![[NSApp windows] count]) {
+ flushEvents();
+ return 1;
+ }
+ @autoreleasepool {
// Set up a SIGINT handler to interrupt the event loop if ctrl+c comes in too
originalSigintAction = PyOS_setsig(SIGINT, handleSigint);
// Create an NSFileHandle for standard input
NSFileHandle *stdinHandle = [NSFileHandle fileHandleWithStandardInput];
+
// Register for data available notifications on standard input
- [[NSNotificationCenter defaultCenter] addObserverForName: NSFileHandleDataAvailableNotification
- object: stdinHandle
- queue: [NSOperationQueue mainQueue] // Use the main queue
- usingBlock: ^(NSNotification *notification) {
- // Mark that input has been received
- stdin_received = true;
- }
+ id notificationID = [[NSNotificationCenter defaultCenter] addObserverForName: NSFileHandleDataAvailableNotification
+ object: stdinHandle
+ queue: [NSOperationQueue mainQueue] // Use the main queue
+ usingBlock: ^(NSNotification *notification) {stopWithEvent();}
];
// Wait in the background for anything that happens to stdin
[stdinHandle waitForDataInBackgroundAndNotify];
- // continuously run an event loop until the stdin_received flag is set to exit
- while (!stdin_received && !stdin_sigint) {
- // This loop is similar to the main event loop and flush_events which have
- // Py_[BEGIN|END]_ALLOW_THREADS surrounding the loop.
- // This should not be necessary here because PyOS_InputHook releases the GIL for us.
- while (true) {
- NSEvent *event = [NSApp nextEventMatchingMask: NSEventMaskAny
- untilDate: [NSDate distantPast]
- inMode: NSDefaultRunLoopMode
- dequeue: YES];
- if (!event) { break; }
- [NSApp sendEvent: event];
- }
- }
+ // Run the application's event loop, which will be interrupted on stdin or SIGINT
+ [NSApp run];
+
// Remove the input handler as an observer
- [[NSNotificationCenter defaultCenter] removeObserver: stdinHandle];
+ [[NSNotificationCenter defaultCenter] removeObserver: notificationID];
+
// Restore the original SIGINT handler upon exiting the function
PyOS_setsig(SIGINT, originalSigintAction);
+
return 1;
}
}
@@ -234,20 +258,9 @@ static void lazy_init(void) {
}
static PyObject*
-stop(PyObject* self)
+stop(PyObject* self, PyObject* _ /* ignored */)
{
- [NSApp stop: nil];
- // Post an event to trigger the actual stopping.
- [NSApp postEvent: [NSEvent otherEventWithType: NSEventTypeApplicationDefined
- location: NSZeroPoint
- modifierFlags: 0
- timestamp: 0
- windowNumber: 0
- context: nil
- subtype: 0
- data1: 0
- data2: 0]
- atStart: YES];
+ stopWithEvent();
Py_RETURN_NONE;
}
@@ -257,20 +270,46 @@ static CGFloat _get_device_scale(CGContextRef cr)
return pixelSize.width;
}
-bool
-mpl_check_modifier(
- NSUInteger modifiers, NSEventModifierFlags flag,
- PyObject* list, char const* name)
+bool mpl_check_button(bool present, PyObject* set, char const* name) {
+ PyObject* module = NULL, * cls = NULL, * button = NULL;
+ bool failed = (
+ present
+ && (!(module = PyImport_ImportModule("matplotlib.backend_bases"))
+ || !(cls = PyObject_GetAttrString(module, "MouseButton"))
+ || !(button = PyObject_GetAttrString(cls, name))
+ || PySet_Add(set, button)));
+ Py_XDECREF(module);
+ Py_XDECREF(cls);
+ Py_XDECREF(button);
+ return failed;
+}
+
+PyObject* mpl_buttons()
{
- bool failed = false;
- if (modifiers & flag) {
- PyObject* py_name = NULL;
- if (!(py_name = PyUnicode_FromString(name))
- || PyList_Append(list, py_name)) {
- failed = true;
- }
- Py_XDECREF(py_name);
+ PyGILState_STATE gstate = PyGILState_Ensure();
+ PyObject* set = NULL;
+ NSUInteger buttons = [NSEvent pressedMouseButtons];
+
+ if (!(set = PySet_New(NULL))
+ || mpl_check_button(buttons & (1 << 0), set, "LEFT")
+ || mpl_check_button(buttons & (1 << 1), set, "RIGHT")
+ || mpl_check_button(buttons & (1 << 2), set, "MIDDLE")
+ || mpl_check_button(buttons & (1 << 3), set, "BACK")
+ || mpl_check_button(buttons & (1 << 4), set, "FORWARD")) {
+ Py_CLEAR(set); // On failure, return NULL with an exception set.
}
+ PyGILState_Release(gstate);
+ return set;
+}
+
+bool mpl_check_modifier(bool present, PyObject* list, char const* name)
+{
+ PyObject* py_name = NULL;
+ bool failed = (
+ present
+ && (!(py_name = PyUnicode_FromString(name))
+ || (PyList_Append(list, py_name))));
+ Py_XDECREF(py_name);
return failed;
}
@@ -278,17 +317,14 @@ static CGFloat _get_device_scale(CGContextRef cr)
{
PyGILState_STATE gstate = PyGILState_Ensure();
PyObject* list = NULL;
- if (!(list = PyList_New(0))) {
- goto exit;
- }
NSUInteger modifiers = [event modifierFlags];
- if (mpl_check_modifier(modifiers, NSEventModifierFlagControl, list, "ctrl")
- || mpl_check_modifier(modifiers, NSEventModifierFlagOption, list, "alt")
- || mpl_check_modifier(modifiers, NSEventModifierFlagShift, list, "shift")
- || mpl_check_modifier(modifiers, NSEventModifierFlagCommand, list, "cmd")) {
+ if (!(list = PyList_New(0))
+ || mpl_check_modifier(modifiers & NSEventModifierFlagControl, list, "ctrl")
+ || mpl_check_modifier(modifiers & NSEventModifierFlagOption, list, "alt")
+ || mpl_check_modifier(modifiers & NSEventModifierFlagShift, list, "shift")
+ || mpl_check_modifier(modifiers & NSEventModifierFlagCommand, list, "cmd")) {
Py_CLEAR(list); // On failure, return NULL with an exception set.
}
-exit:
PyGILState_Release(gstate);
return list;
}
@@ -382,20 +418,9 @@ static CGFloat _get_device_scale(CGContextRef cr)
// We run the app, matching any events that are waiting in the queue
// to process, breaking out of the loop when no events remain and
// displaying the canvas if needed.
- NSEvent *event;
-
Py_BEGIN_ALLOW_THREADS
- while (true) {
- event = [NSApp nextEventMatchingMask: NSEventMaskAny
- untilDate: [NSDate distantPast]
- inMode: NSDefaultRunLoopMode
- dequeue: YES];
- if (!event) {
- break;
- }
- [NSApp sendEvent:event];
- }
+ flushEvents();
Py_END_ALLOW_THREADS
@@ -547,6 +572,8 @@ static CGFloat _get_device_scale(CGContextRef cr)
},
};
+static PyTypeObject FigureManagerType; // forward declaration, needed in destroy()
+
typedef struct {
PyObject_HEAD
Window* window;
@@ -555,6 +582,16 @@ static CGFloat _get_device_scale(CGContextRef cr)
static PyObject*
FigureManager_new(PyTypeObject *type, PyObject *args, PyObject *kwds)
{
+ if (![NSThread isMainThread]) {
+ PyErr_SetString(
+ PyExc_RuntimeError,
+ "Cannot create a GUI FigureManager outside the main thread "
+ "using the MacOS backend. Use a non-interactive "
+ "backend like 'agg' to make plots on worker threads."
+ );
+ return NULL;
+ }
+
lazy_init();
Window* window = [Window alloc];
if (!window) { return NULL; }
@@ -661,6 +698,25 @@ static CGFloat _get_device_scale(CGContextRef cr)
{
[self->window close];
self->window = NULL;
+
+ // call super(self, FigureManager).destroy() - it seems we need the
+ // explicit arguments, and just super() doesn't work in the C API.
+ PyObject *super_obj = PyObject_CallFunctionObjArgs(
+ (PyObject *)&PySuper_Type,
+ (PyObject *)&FigureManagerType,
+ self,
+ NULL
+ );
+ if (super_obj == NULL) {
+ return NULL; // error
+ }
+ PyObject *result = PyObject_CallMethod(super_obj, "destroy", NULL);
+ Py_DECREF(super_obj);
+ if (result == NULL) {
+ return NULL; // error
+ }
+ Py_DECREF(result);
+
Py_RETURN_NONE;
}
@@ -978,7 +1034,7 @@ -(void)save_figure:(id)sender { gil_call_method(toolbar, "save_figure"); }
// Make it a zero-width box if we don't have enough room
rect.size.width = fmax(bounds.size.width - rect.origin.x, 0);
rect.origin.x = bounds.size.width - rect.size.width;
- NSTextView* messagebox = [[[NSTextView alloc] initWithFrame: rect] autorelease];
+ NSTextView* messagebox = [[NSTextView alloc] initWithFrame: rect];
messagebox.textContainer.maximumNumberOfLines = 2;
messagebox.textContainer.lineBreakMode = NSLineBreakByTruncatingTail;
messagebox.alignment = NSTextAlignmentRight;
@@ -988,7 +1044,6 @@ -(void)save_figure:(id)sender { gil_call_method(toolbar, "save_figure"); }
/* if selectable, the messagebox can become first responder,
* which is not supposed to happen */
[[window contentView] addSubview: messagebox];
- [messagebox release];
[[window contentView] display];
self->messagebox = messagebox;
@@ -999,6 +1054,7 @@ -(void)save_figure:(id)sender { gil_call_method(toolbar, "save_figure"); }
NavigationToolbar2_dealloc(NavigationToolbar2 *self)
{
[self->handler release];
+ [self->messagebox release];
Py_TYPE(self)->tp_free((PyObject*)self);
}
@@ -1448,9 +1504,9 @@ - (void)mouseMoved:(NSEvent *)event
x = location.x * device_scale;
y = location.y * device_scale;
process_event(
- "MouseEvent", "{s:s, s:O, s:i, s:i, s:N}",
+ "MouseEvent", "{s:s, s:O, s:i, s:i, s:N, s:N}",
"name", "motion_notify_event", "canvas", canvas, "x", x, "y", y,
- "modifiers", mpl_modifiers(event));
+ "buttons", mpl_buttons(), "modifiers", mpl_modifiers(event));
}
- (void)mouseDragged:(NSEvent *)event
@@ -1461,9 +1517,9 @@ - (void)mouseDragged:(NSEvent *)event
x = location.x * device_scale;
y = location.y * device_scale;
process_event(
- "MouseEvent", "{s:s, s:O, s:i, s:i, s:N}",
+ "MouseEvent", "{s:s, s:O, s:i, s:i, s:N, s:N}",
"name", "motion_notify_event", "canvas", canvas, "x", x, "y", y,
- "modifiers", mpl_modifiers(event));
+ "buttons", mpl_buttons(), "modifiers", mpl_modifiers(event));
}
- (void)rightMouseDown:(NSEvent *)event { [self mouseDown: event]; }
@@ -1838,7 +1894,7 @@ - (void)flagsChanged:(NSEvent *)event
"written on the file descriptor given as argument.")},
{"stop",
(PyCFunction)stop,
- METH_NOARGS,
+ METH_VARARGS,
PyDoc_STR("Stop the NSApp.")},
{"show",
(PyCFunction)show,
@@ -1870,6 +1926,9 @@ - (void)flagsChanged:(NSEvent *)event
Py_XDECREF(m);
return NULL;
}
+#ifdef Py_GIL_DISABLED
+ PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED);
+#endif
return m;
}
diff --git a/src/_path.h b/src/_path.h
index f5c06e4a6a15..226d60231682 100644
--- a/src/_path.h
+++ b/src/_path.h
@@ -3,12 +3,12 @@
#ifndef MPL_PATH_H
#define MPL_PATH_H
-#include
-#include
-#include
-#include
#include
+#include
+#include
+#include
#include
+#include
#include "agg_conv_contour.h"
#include "agg_conv_curve.h"
@@ -26,6 +26,8 @@ struct XY
double x;
double y;
+ XY() : x(0), y(0) {}
+
XY(double x_, double y_) : x(x_), y(y_)
{
}
@@ -43,7 +45,8 @@ struct XY
typedef std::vector Polygon;
-void _finalize_polygon(std::vector &result, int closed_only)
+inline void
+_finalize_polygon(std::vector &result, bool closed_only)
{
if (result.size() == 0) {
return;
@@ -311,43 +314,39 @@ inline bool point_on_path(
struct extent_limits
{
- double x0;
- double y0;
- double x1;
- double y1;
- double xm;
- double ym;
-};
+ XY start;
+ XY end;
+ /* minpos is the minimum positive values in the data; used by log scaling. */
+ XY minpos;
-void reset_limits(extent_limits &e)
-{
- e.x0 = std::numeric_limits::infinity();
- e.y0 = std::numeric_limits::infinity();
- e.x1 = -std::numeric_limits::infinity();
- e.y1 = -std::numeric_limits::infinity();
- /* xm and ym are the minimum positive values in the data, used
- by log scaling */
- e.xm = std::numeric_limits::infinity();
- e.ym = std::numeric_limits::infinity();
-}
+ extent_limits() : start{0,0}, end{0,0}, minpos{0,0} {
+ reset();
+ }
-inline void update_limits(double x, double y, extent_limits &e)
-{
- if (x < e.x0)
- e.x0 = x;
- if (y < e.y0)
- e.y0 = y;
- if (x > e.x1)
- e.x1 = x;
- if (y > e.y1)
- e.y1 = y;
- /* xm and ym are the minimum positive values in the data, used
- by log scaling */
- if (x > 0.0 && x < e.xm)
- e.xm = x;
- if (y > 0.0 && y < e.ym)
- e.ym = y;
-}
+ void reset()
+ {
+ start.x = std::numeric_limits::infinity();
+ start.y = std::numeric_limits::infinity();
+ end.x = -std::numeric_limits::infinity();
+ end.y = -std::numeric_limits::infinity();
+ minpos.x = std::numeric_limits::infinity();
+ minpos.y = std::numeric_limits::infinity();
+ }
+
+ void update(double x, double y)
+ {
+ start.x = std::min(start.x, x);
+ start.y = std::min(start.y, y);
+ end.x = std::max(end.x, x);
+ end.y = std::max(end.y, y);
+ if (x > 0.0) {
+ minpos.x = std::min(minpos.x, x);
+ }
+ if (y > 0.0) {
+ minpos.y = std::min(minpos.y, y);
+ }
+ }
+};
template
void update_path_extents(PathIterator &path, agg::trans_affine &trans, extent_limits &extents)
@@ -366,7 +365,7 @@ void update_path_extents(PathIterator &path, agg::trans_affine &trans, extent_li
if ((code & agg::path_cmd_end_poly) == agg::path_cmd_end_poly) {
continue;
}
- update_limits(x, y, extents);
+ extents.update(x, y);
}
}
@@ -389,7 +388,7 @@ void get_path_collection_extents(agg::trans_affine &master_transform,
agg::trans_affine trans;
- reset_limits(extent);
+ extent.reset();
for (auto i = 0; i < N; ++i) {
typename PathGenerator::path_iterator path(paths(i % Npaths));
@@ -524,12 +523,14 @@ struct bisectx
{
}
- inline void bisect(double sx, double sy, double px, double py, double *bx, double *by) const
+ inline XY bisect(const XY s, const XY p) const
{
- *bx = m_x;
- double dx = px - sx;
- double dy = py - sy;
- *by = sy + dy * ((m_x - sx) / dx);
+ double dx = p.x - s.x;
+ double dy = p.y - s.y;
+ return {
+ m_x,
+ s.y + dy * ((m_x - s.x) / dx),
+ };
}
};
@@ -539,9 +540,9 @@ struct xlt : public bisectx
{
}
- inline bool is_inside(double x, double y) const
+ inline bool is_inside(const XY point) const
{
- return x <= m_x;
+ return point.x <= m_x;
}
};
@@ -551,9 +552,9 @@ struct xgt : public bisectx
{
}
- inline bool is_inside(double x, double y) const
+ inline bool is_inside(const XY point) const
{
- return x >= m_x;
+ return point.x >= m_x;
}
};
@@ -565,12 +566,14 @@ struct bisecty
{
}
- inline void bisect(double sx, double sy, double px, double py, double *bx, double *by) const
+ inline XY bisect(const XY s, const XY p) const
{
- *by = m_y;
- double dx = px - sx;
- double dy = py - sy;
- *bx = sx + dx * ((m_y - sy) / dy);
+ double dx = p.x - s.x;
+ double dy = p.y - s.y;
+ return {
+ s.x + dx * ((m_y - s.y) / dy),
+ m_y,
+ };
}
};
@@ -580,9 +583,9 @@ struct ylt : public bisecty
{
}
- inline bool is_inside(double x, double y) const
+ inline bool is_inside(const XY point) const
{
- return y <= m_y;
+ return point.y <= m_y;
}
};
@@ -592,9 +595,9 @@ struct ygt : public bisecty
{
}
- inline bool is_inside(double x, double y) const
+ inline bool is_inside(const XY point) const
{
- return y >= m_y;
+ return point.y >= m_y;
}
};
}
@@ -602,7 +605,6 @@ struct ygt : public bisecty
template
inline void clip_to_rect_one_step(const Polygon &polygon, Polygon &result, const Filter &filter)
{
- double sx, sy, px, py, bx, by;
bool sinside, pinside;
result.clear();
@@ -610,49 +612,30 @@ inline void clip_to_rect_one_step(const Polygon &polygon, Polygon &result, const
return;
}
- sx = polygon.back().x;
- sy = polygon.back().y;
- for (Polygon::const_iterator i = polygon.begin(); i != polygon.end(); ++i) {
- px = i->x;
- py = i->y;
-
- sinside = filter.is_inside(sx, sy);
- pinside = filter.is_inside(px, py);
+ auto s = polygon.back();
+ for (auto p : polygon) {
+ sinside = filter.is_inside(s);
+ pinside = filter.is_inside(p);
if (sinside ^ pinside) {
- filter.bisect(sx, sy, px, py, &bx, &by);
- result.push_back(XY(bx, by));
+ result.emplace_back(filter.bisect(s, p));
}
if (pinside) {
- result.push_back(XY(px, py));
+ result.emplace_back(p);
}
- sx = px;
- sy = py;
+ s = p;
}
}
template
-void
-clip_path_to_rect(PathIterator &path, agg::rect_d &rect, bool inside, std::vector &results)
+auto
+clip_path_to_rect(PathIterator &path, agg::rect_d &rect, bool inside)
{
- double xmin, ymin, xmax, ymax;
- if (rect.x1 < rect.x2) {
- xmin = rect.x1;
- xmax = rect.x2;
- } else {
- xmin = rect.x2;
- xmax = rect.x1;
- }
-
- if (rect.y1 < rect.y2) {
- ymin = rect.y1;
- ymax = rect.y2;
- } else {
- ymin = rect.y2;
- ymax = rect.y1;
- }
+ rect.normalize();
+ auto xmin = rect.x1, xmax = rect.x2;
+ auto ymin = rect.y1, ymax = rect.y2;
if (!inside) {
std::swap(xmin, xmax);
@@ -663,26 +646,27 @@ clip_path_to_rect(PathIterator &path, agg::rect_d &rect, bool inside, std::vecto
curve_t curve(path);
Polygon polygon1, polygon2;
- double x = 0, y = 0;
+ XY point;
unsigned code = 0;
curve.rewind(0);
+ std::vector results;
do {
// Grab the next subpath and store it in polygon1
polygon1.clear();
do {
if (code == agg::path_cmd_move_to) {
- polygon1.push_back(XY(x, y));
+ polygon1.emplace_back(point);
}
- code = curve.vertex(&x, &y);
+ code = curve.vertex(&point.x, &point.y);
if (code == agg::path_cmd_stop) {
break;
}
if (code != agg::path_cmd_move_to) {
- polygon1.push_back(XY(x, y));
+ polygon1.emplace_back(point);
}
} while ((code & agg::path_cmd_end_poly) != agg::path_cmd_end_poly);
@@ -695,12 +679,14 @@ clip_path_to_rect(PathIterator &path, agg::rect_d &rect, bool inside, std::vecto
// Empty polygons aren't very useful, so skip them
if (polygon1.size()) {
- _finalize_polygon(results, 1);
+ _finalize_polygon(results, true);
results.push_back(polygon1);
}
} while (code != agg::path_cmd_stop);
- _finalize_polygon(results, 1);
+ _finalize_polygon(results, true);
+
+ return results;
}
template
@@ -960,7 +946,7 @@ void convert_path_to_polygons(PathIterator &path,
agg::trans_affine &trans,
double width,
double height,
- int closed_only,
+ bool closed_only,
std::vector &result)
{
typedef agg::conv_transform transformed_path_t;
@@ -978,23 +964,20 @@ void convert_path_to_polygons(PathIterator &path,
simplify_t simplified(clipped, simplify, path.simplify_threshold());
curve_t curve(simplified);
- result.push_back(Polygon());
- Polygon *polygon = &result.back();
+ Polygon *polygon = &result.emplace_back();
double x, y;
unsigned code;
while ((code = curve.vertex(&x, &y)) != agg::path_cmd_stop) {
if ((code & agg::path_cmd_end_poly) == agg::path_cmd_end_poly) {
- _finalize_polygon(result, 1);
- result.push_back(Polygon());
- polygon = &result.back();
+ _finalize_polygon(result, true);
+ polygon = &result.emplace_back();
} else {
if (code == agg::path_cmd_move_to) {
_finalize_polygon(result, closed_only);
- result.push_back(Polygon());
- polygon = &result.back();
+ polygon = &result.emplace_back();
}
- polygon->push_back(XY(x, y));
+ polygon->emplace_back(x, y);
}
}
@@ -1058,15 +1041,14 @@ void cleanup_path(PathIterator &path,
void quad2cubic(double x0, double y0,
double x1, double y1,
double x2, double y2,
- double *outx, double *outy)
+ std::array &outx, std::array &outy)
{
-
- outx[0] = x0 + 2./3. * (x1 - x0);
- outy[0] = y0 + 2./3. * (y1 - y0);
- outx[1] = outx[0] + 1./3. * (x2 - x0);
- outy[1] = outy[0] + 1./3. * (y2 - y0);
- outx[2] = x2;
- outy[2] = y2;
+ std::get<0>(outx) = x0 + 2./3. * (x1 - x0);
+ std::get<0>(outy) = y0 + 2./3. * (y1 - y0);
+ std::get<1>(outx) = std::get<0>(outx) + 1./3. * (x2 - x0);
+ std::get<1>(outy) = std::get<0>(outy) + 1./3. * (y2 - y0);
+ std::get<2>(outx) = x2;
+ std::get<2>(outy) = y2;
}
@@ -1086,7 +1068,7 @@ void __add_number(double val, char format_code, int precision,
buffer += str;
} else {
char *str = PyOS_double_to_string(
- val, format_code, precision, Py_DTSF_ADD_DOT_0, NULL);
+ val, format_code, precision, Py_DTSF_ADD_DOT_0, nullptr);
// Delete trailing zeros and decimal point
char *c = str + strlen(str) - 1; // Start at last character.
// Rewind through all the zeros and, if present, the trailing decimal
@@ -1111,27 +1093,27 @@ void __add_number(double val, char format_code, int precision,
template
bool __convert_to_string(PathIterator &path,
int precision,
- char **codes,
+ const std::array &codes,
bool postfix,
std::string& buffer)
{
const char format_code = 'f';
- double x[3];
- double y[3];
+ std::array x;
+ std::array y;
double last_x = 0.0;
double last_y = 0.0;
unsigned code;
- while ((code = path.vertex(&x[0], &y[0])) != agg::path_cmd_stop) {
+ while ((code = path.vertex(&std::get<0>(x), &std::get<0>(y))) != agg::path_cmd_stop) {
if (code == CLOSEPOLY) {
- buffer += codes[4];
+ buffer += std::get<4>(codes);
} else if (code < 5) {
size_t size = NUM_VERTICES[code];
for (size_t i = 1; i < size; ++i) {
- unsigned subcode = path.vertex(&x[i], &y[i]);
+ unsigned subcode = path.vertex(&x.at(i), &y.at(i));
if (subcode != code) {
return false;
}
@@ -1140,29 +1122,29 @@ bool __convert_to_string(PathIterator &path,
/* For formats that don't support quad curves, convert to
cubic curves */
if (code == CURVE3 && codes[code - 1][0] == '\0') {
- quad2cubic(last_x, last_y, x[0], y[0], x[1], y[1], x, y);
+ quad2cubic(last_x, last_y, x.at(0), y.at(0), x.at(1), y.at(1), x, y);
code++;
size = 3;
}
if (!postfix) {
- buffer += codes[code - 1];
+ buffer += codes.at(code - 1);
buffer += ' ';
}
for (size_t i = 0; i < size; ++i) {
- __add_number(x[i], format_code, precision, buffer);
+ __add_number(x.at(i), format_code, precision, buffer);
buffer += ' ';
- __add_number(y[i], format_code, precision, buffer);
+ __add_number(y.at(i), format_code, precision, buffer);
buffer += ' ';
}
if (postfix) {
- buffer += codes[code - 1];
+ buffer += codes.at(code - 1);
}
- last_x = x[size - 1];
- last_y = y[size - 1];
+ last_x = x.at(size - 1);
+ last_y = y.at(size - 1);
} else {
// Unknown code value
return false;
@@ -1181,7 +1163,7 @@ bool convert_to_string(PathIterator &path,
bool simplify,
SketchParams sketch_params,
int precision,
- char **codes,
+ const std::array &codes,
bool postfix,
std::string& buffer)
{
@@ -1218,7 +1200,6 @@ bool convert_to_string(PathIterator &path,
sketch_t sketch(curve, sketch_params.scale, sketch_params.length, sketch_params.randomness);
return __convert_to_string(sketch, precision, codes, postfix, buffer);
}
-
}
template
diff --git a/src/_path_wrapper.cpp b/src/_path_wrapper.cpp
index e8322cb51b7b..802189c428d3 100644
--- a/src/_path_wrapper.cpp
+++ b/src/_path_wrapper.cpp
@@ -52,64 +52,6 @@ Py_points_in_path(py::array_t points_obj, double r, mpl::PathIterator pa
return results;
}
-static py::tuple
-Py_update_path_extents(mpl::PathIterator path, agg::trans_affine trans,
- agg::rect_d rect, py::array_t minpos, bool ignore)
-{
- bool changed;
-
- if (minpos.ndim() != 1) {
- throw py::value_error(
- "minpos must be 1D, got " + std::to_string(minpos.ndim()));
- }
- if (minpos.shape(0) != 2) {
- throw py::value_error(
- "minpos must be of length 2, got " + std::to_string(minpos.shape(0)));
- }
-
- extent_limits e;
-
- if (ignore) {
- reset_limits(e);
- } else {
- if (rect.x1 > rect.x2) {
- e.x0 = std::numeric_limits::infinity();
- e.x1 = -std::numeric_limits::infinity();
- } else {
- e.x0 = rect.x1;
- e.x1 = rect.x2;
- }
- if (rect.y1 > rect.y2) {
- e.y0 = std::numeric_limits::infinity();
- e.y1 = -std::numeric_limits::infinity();
- } else {
- e.y0 = rect.y1;
- e.y1 = rect.y2;
- }
- e.xm = *minpos.data(0);
- e.ym = *minpos.data(1);
- }
-
- update_path_extents(path, trans, e);
-
- changed = (e.x0 != rect.x1 || e.y0 != rect.y1 || e.x1 != rect.x2 || e.y1 != rect.y2 ||
- e.xm != *minpos.data(0) || e.ym != *minpos.data(1));
-
- py::ssize_t extentsdims[] = { 2, 2 };
- py::array_t outextents(extentsdims);
- *outextents.mutable_data(0, 0) = e.x0;
- *outextents.mutable_data(0, 1) = e.y0;
- *outextents.mutable_data(1, 0) = e.x1;
- *outextents.mutable_data(1, 1) = e.y1;
-
- py::ssize_t minposdims[] = { 2 };
- py::array_t outminpos(minposdims);
- *outminpos.mutable_data(0) = e.xm;
- *outminpos.mutable_data(1) = e.ym;
-
- return py::make_tuple(outextents, outminpos, changed);
-}
-
static py::tuple
Py_get_path_collection_extents(agg::trans_affine master_transform,
mpl::PathGenerator paths,
@@ -126,15 +68,15 @@ Py_get_path_collection_extents(agg::trans_affine master_transform,
py::ssize_t dims[] = { 2, 2 };
py::array_t extents(dims);
- *extents.mutable_data(0, 0) = e.x0;
- *extents.mutable_data(0, 1) = e.y0;
- *extents.mutable_data(1, 0) = e.x1;
- *extents.mutable_data(1, 1) = e.y1;
+ *extents.mutable_data(0, 0) = e.start.x;
+ *extents.mutable_data(0, 1) = e.start.y;
+ *extents.mutable_data(1, 0) = e.end.x;
+ *extents.mutable_data(1, 1) = e.end.y;
py::ssize_t minposdims[] = { 2 };
py::array_t minpos(minposdims);
- *minpos.mutable_data(0) = e.xm;
- *minpos.mutable_data(1) = e.ym;
+ *minpos.mutable_data(0) = e.minpos.x;
+ *minpos.mutable_data(1) = e.minpos.y;
return py::make_tuple(extents, minpos);
}
@@ -167,9 +109,7 @@ Py_path_in_path(mpl::PathIterator a, agg::trans_affine atrans,
static py::list
Py_clip_path_to_rect(mpl::PathIterator path, agg::rect_d rect, bool inside)
{
- std::vector result;
-
- clip_path_to_rect(path, rect, inside, result);
+ auto result = clip_path_to_rect(path, rect, inside);
return convert_polygon_vector(result);
}
@@ -310,16 +250,11 @@ static py::object
Py_convert_to_string(mpl::PathIterator path, agg::trans_affine trans,
agg::rect_d cliprect, std::optional simplify,
SketchParams sketch, int precision,
- std::array codes_obj, bool postfix)
+ const std::array &codes, bool postfix)
{
- char *codes[5];
std::string buffer;
bool status;
- for (auto i = 0; i < 5; ++i) {
- codes[i] = const_cast(codes_obj[i].c_str());
- }
-
if (!simplify.has_value()) {
simplify = path.should_simplify();
}
@@ -374,8 +309,6 @@ PYBIND11_MODULE(_path, m, py::mod_gil_not_used())
"x"_a, "y"_a, "radius"_a, "path"_a, "trans"_a);
m.def("points_in_path", &Py_points_in_path,
"points"_a, "radius"_a, "path"_a, "trans"_a);
- m.def("update_path_extents", &Py_update_path_extents,
- "path"_a, "trans"_a, "rect"_a, "minpos"_a, "ignore"_a);
m.def("get_path_collection_extents", &Py_get_path_collection_extents,
"master_transform"_a, "paths"_a, "transforms"_a, "offsets"_a,
"offset_transform"_a);
diff --git a/src/_qhull_wrapper.cpp b/src/_qhull_wrapper.cpp
index da623a8d1b71..f8a3103b65f1 100644
--- a/src/_qhull_wrapper.cpp
+++ b/src/_qhull_wrapper.cpp
@@ -167,13 +167,13 @@ delaunay_impl(py::ssize_t npoints, const double* x, const double* y,
}
/* qhull expects a FILE* to write errors to. */
- FILE* error_file = NULL;
+ FILE* error_file = nullptr;
if (hide_qhull_errors) {
/* qhull errors are ignored by writing to OS-equivalent of /dev/null.
* Rather than have OS-specific code here, instead it is determined by
* meson.build and passed in via the macro MPL_DEVNULL. */
error_file = fopen(STRINGIFY(MPL_DEVNULL), "w");
- if (error_file == NULL) {
+ if (error_file == nullptr) {
throw std::runtime_error("Could not open devnull");
}
}
@@ -186,7 +186,7 @@ delaunay_impl(py::ssize_t npoints, const double* x, const double* y,
QhullInfo info(error_file, qh);
qh_zero(qh, error_file);
exitcode = qh_new_qhull(qh, ndim, (int)npoints, points.data(), False,
- (char*)"qhull d Qt Qbb Qc Qz", NULL, error_file);
+ (char*)"qhull d Qt Qbb Qc Qz", nullptr, error_file);
if (exitcode != qh_ERRnone) {
std::string msg =
py::str("Error in qhull Delaunay triangulation calculation: {} (exitcode={})")
diff --git a/src/_tkagg.cpp b/src/_tkagg.cpp
index 04ca3d4d1b84..955ce2103f90 100644
--- a/src/_tkagg.cpp
+++ b/src/_tkagg.cpp
@@ -7,7 +7,7 @@
// and methods of operation are now quite different. Because our review of
// the codebase showed that all the code that came from PIL was removed or
// rewritten, we have removed the PIL licensing information. If you want PIL,
-// you can get it at https://python-pillow.org/
+// you can get it at https://python-pillow.github.io
#include
#include
@@ -92,6 +92,7 @@ static Tk_PhotoPutBlock_t TK_PHOTO_PUT_BLOCK;
// Global vars for Tcl functions. We load these symbols from the tkinter
// extension module or loaded Tcl libraries at run-time.
static Tcl_SetVar_t TCL_SETVAR;
+static Tcl_SetVar2_t TCL_SETVAR2;
static void
mpl_tk_blit(py::object interp_obj, const char *photo_name,
@@ -173,7 +174,15 @@ DpiSubclassProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam,
std::string dpi = std::to_string(LOWORD(wParam));
Tcl_Interp* interp = (Tcl_Interp*)dwRefData;
- TCL_SETVAR(interp, var_name.c_str(), dpi.c_str(), 0);
+ if (TCL_SETVAR) {
+ TCL_SETVAR(interp, var_name.c_str(), dpi.c_str(), 0);
+ } else if (TCL_SETVAR2) {
+ TCL_SETVAR2(interp, var_name.c_str(), NULL, dpi.c_str(), 0);
+ } else {
+ // This should be prevented at import time, and therefore unreachable.
+ // But defensively throw just in case.
+ throw std::runtime_error("Unable to call Tcl_SetVar or Tcl_SetVar2");
+ }
}
return 0;
case WM_NCDESTROY:
@@ -246,13 +255,16 @@ bool load_tcl_tk(T lib)
if (auto ptr = dlsym(lib, "Tcl_SetVar")) {
TCL_SETVAR = (Tcl_SetVar_t)ptr;
}
+ if (auto ptr = dlsym(lib, "Tcl_SetVar2")) {
+ TCL_SETVAR2 = (Tcl_SetVar2_t)ptr;
+ }
if (auto ptr = dlsym(lib, "Tk_FindPhoto")) {
TK_FIND_PHOTO = (Tk_FindPhoto_t)ptr;
}
if (auto ptr = dlsym(lib, "Tk_PhotoPutBlock")) {
TK_PHOTO_PUT_BLOCK = (Tk_PhotoPutBlock_t)ptr;
}
- return TCL_SETVAR && TK_FIND_PHOTO && TK_PHOTO_PUT_BLOCK;
+ return (TCL_SETVAR || TCL_SETVAR2) && TK_FIND_PHOTO && TK_PHOTO_PUT_BLOCK;
}
#ifdef WIN32_DLL
@@ -300,7 +312,7 @@ load_tkinter_funcs()
// Load tkinter global funcs from tkinter compiled module.
// Try loading from the main program namespace first.
- auto main_program = dlopen(NULL, RTLD_LAZY);
+ auto main_program = dlopen(nullptr, RTLD_LAZY);
auto success = load_tcl_tk(main_program);
// We don't need to keep a reference open as the main program always exists.
if (dlclose(main_program)) {
@@ -343,8 +355,8 @@ PYBIND11_MODULE(_tkagg, m, py::mod_gil_not_used())
throw py::error_already_set();
}
- if (!TCL_SETVAR) {
- throw py::import_error("Failed to load Tcl_SetVar");
+ if (!(TCL_SETVAR || TCL_SETVAR2)) {
+ throw py::import_error("Failed to load Tcl_SetVar or Tcl_SetVar2");
} else if (!TK_FIND_PHOTO) {
throw py::import_error("Failed to load Tk_FindPhoto");
} else if (!TK_PHOTO_PUT_BLOCK) {
diff --git a/src/_tkmini.h b/src/_tkmini.h
index 85f245815e4c..1c74cf9720f8 100644
--- a/src/_tkmini.h
+++ b/src/_tkmini.h
@@ -104,6 +104,9 @@ typedef int (*Tk_PhotoPutBlock_t) (Tcl_Interp *interp, Tk_PhotoHandle handle,
/* Tcl_SetVar typedef */
typedef const char *(*Tcl_SetVar_t)(Tcl_Interp *interp, const char *varName,
const char *newValue, int flags);
+/* Tcl_SetVar2 typedef */
+typedef const char *(*Tcl_SetVar2_t)(Tcl_Interp *interp, const char *part1, const char *part2,
+ const char *newValue, int flags);
#ifdef __cplusplus
}
diff --git a/src/agg_workaround.h b/src/agg_workaround.h
index 476219519280..a167be97e171 100644
--- a/src/agg_workaround.h
+++ b/src/agg_workaround.h
@@ -8,46 +8,6 @@
blending of RGBA32 pixels does not preserve enough precision
*/
-template
-struct fixed_blender_rgba_pre : agg::conv_rgba_pre
-{
- typedef ColorT color_type;
- typedef Order order_type;
- typedef typename color_type::value_type value_type;
- typedef typename color_type::calc_type calc_type;
- typedef typename color_type::long_type long_type;
- enum base_scale_e
- {
- base_shift = color_type::base_shift,
- base_mask = color_type::base_mask
- };
-
- //--------------------------------------------------------------------
- static AGG_INLINE void blend_pix(value_type* p,
- value_type cr, value_type cg, value_type cb,
- value_type alpha, agg::cover_type cover)
- {
- blend_pix(p,
- color_type::mult_cover(cr, cover),
- color_type::mult_cover(cg, cover),
- color_type::mult_cover(cb, cover),
- color_type::mult_cover(alpha, cover));
- }
-
- //--------------------------------------------------------------------
- static AGG_INLINE void blend_pix(value_type* p,
- value_type cr, value_type cg, value_type cb,
- value_type alpha)
- {
- alpha = base_mask - alpha;
- p[Order::R] = (value_type)(((p[Order::R] * alpha) >> base_shift) + cr);
- p[Order::G] = (value_type)(((p[Order::G] * alpha) >> base_shift) + cg);
- p[Order::B] = (value_type)(((p[Order::B] * alpha) >> base_shift) + cb);
- p[Order::A] = (value_type)(base_mask - ((alpha * (base_mask - p[Order::A])) >> base_shift));
- }
-};
-
-
template
struct fixed_blender_rgba_plain : agg::conv_rgba_plain
{
diff --git a/src/array.h b/src/array.h
index 97d66dd4a6d2..0e8db3c4cac7 100644
--- a/src/array.h
+++ b/src/array.h
@@ -56,9 +56,7 @@ class empty
public:
typedef empty sub_t;
- empty()
- {
- }
+ empty() = default;
T &operator()(int i, int j = 0, int k = 0)
{
diff --git a/src/ft2font.cpp b/src/ft2font.cpp
index c0e8b7c27125..da1bd19dca57 100644
--- a/src/ft2font.cpp
+++ b/src/ft2font.cpp
@@ -1,5 +1,8 @@
/* -*- mode: c++; c-basic-offset: 4 -*- */
+#include "ft2font.h"
+#include "mplutils.h"
+
#include
#include
#include
@@ -9,9 +12,6 @@
#include
#include
-#include "ft2font.h"
-#include "mplutils.h"
-
#ifndef M_PI
#define M_PI 3.14159265358979323846264338328
#endif
@@ -43,71 +43,23 @@
FT_Library _ft2Library;
-// FreeType error codes; loaded as per fterror.h.
-static char const* ft_error_string(FT_Error error) {
-#undef __FTERRORS_H__
-#define FT_ERROR_START_LIST switch (error) {
-#define FT_ERRORDEF( e, v, s ) case v: return s;
-#define FT_ERROR_END_LIST default: return NULL; }
-#include FT_ERRORS_H
-}
-
-void throw_ft_error(std::string message, FT_Error error) {
- char const* s = ft_error_string(error);
- std::ostringstream os("");
- if (s) {
- os << message << " (" << s << "; error code 0x" << std::hex << error << ")";
- } else { // Should not occur, but don't add another error from failed lookup.
- os << message << " (error code 0x" << std::hex << error << ")";
- }
- throw std::runtime_error(os.str());
-}
-
-FT2Image::FT2Image() : m_buffer(NULL), m_width(0), m_height(0)
-{
-}
-
FT2Image::FT2Image(unsigned long width, unsigned long height)
- : m_buffer(NULL), m_width(0), m_height(0)
+ : m_buffer((unsigned char *)calloc(width * height, 1)), m_width(width), m_height(height)
{
- resize(width, height);
}
FT2Image::~FT2Image()
{
- delete[] m_buffer;
+ free(m_buffer);
}
-void FT2Image::resize(long width, long height)
+void draw_bitmap(
+ py::array_t im, FT_Bitmap *bitmap, FT_Int x, FT_Int y)
{
- if (width <= 0) {
- width = 1;
- }
- if (height <= 0) {
- height = 1;
- }
- size_t numBytes = width * height;
-
- if ((unsigned long)width != m_width || (unsigned long)height != m_height) {
- if (numBytes > m_width * m_height) {
- delete[] m_buffer;
- m_buffer = NULL;
- m_buffer = new unsigned char[numBytes];
- }
-
- m_width = (unsigned long)width;
- m_height = (unsigned long)height;
- }
+ auto buf = im.mutable_data(0);
- if (numBytes && m_buffer) {
- memset(m_buffer, 0, numBytes);
- }
-}
-
-void FT2Image::draw_bitmap(FT_Bitmap *bitmap, FT_Int x, FT_Int y)
-{
- FT_Int image_width = (FT_Int)m_width;
- FT_Int image_height = (FT_Int)m_height;
+ FT_Int image_width = (FT_Int)im.shape(1);
+ FT_Int image_height = (FT_Int)im.shape(0);
FT_Int char_width = bitmap->width;
FT_Int char_height = bitmap->rows;
@@ -121,14 +73,14 @@ void FT2Image::draw_bitmap(FT_Bitmap *bitmap, FT_Int x, FT_Int y)
if (bitmap->pixel_mode == FT_PIXEL_MODE_GRAY) {
for (FT_Int i = y1; i < y2; ++i) {
- unsigned char *dst = m_buffer + (i * image_width + x1);
+ unsigned char *dst = buf + (i * image_width + x1);
unsigned char *src = bitmap->buffer + (((i - y_offset) * bitmap->pitch) + x_start);
for (FT_Int j = x1; j < x2; ++j, ++dst, ++src)
*dst |= *src;
}
} else if (bitmap->pixel_mode == FT_PIXEL_MODE_MONO) {
for (FT_Int i = y1; i < y2; ++i) {
- unsigned char *dst = m_buffer + (i * image_width + x1);
+ unsigned char *dst = buf + (i * image_width + x1);
unsigned char *src = bitmap->buffer + ((i - y_offset) * bitmap->pitch);
for (FT_Int j = x1; j < x2; ++j, ++dst) {
int x = (j - x1 + x_start);
@@ -258,43 +210,31 @@ FT2Font::get_path(std::vector &vertices, std::vector &cod
FT2Font::FT2Font(FT_Open_Args &open_args,
long hinting_factor_,
std::vector &fallback_list,
- FT2Font::WarnFunc warn)
- : ft_glyph_warn(warn), image(), face(NULL)
+ FT2Font::WarnFunc warn, bool warn_if_used)
+ : ft_glyph_warn(warn), warn_if_used(warn_if_used), image({1, 1}), face(nullptr),
+ hinting_factor(hinting_factor_),
+ // set default kerning factor to 0, i.e., no kerning manipulation
+ kerning_factor(0)
{
clear();
-
- FT_Error error = FT_Open_Face(_ft2Library, &open_args, 0, &face);
- if (error) {
- throw_ft_error("Can not load face", error);
+ FT_CHECK(FT_Open_Face, _ft2Library, &open_args, 0, &face);
+ if (open_args.stream != nullptr) {
+ face->face_flags |= FT_FACE_FLAG_EXTERNAL_STREAM;
}
-
- // set default kerning factor to 0, i.e., no kerning manipulation
- kerning_factor = 0;
-
- // set a default fontsize 12 pt at 72dpi
- hinting_factor = hinting_factor_;
-
- error = FT_Set_Char_Size(face, 12 * 64, 0, 72 * (unsigned int)hinting_factor, 72);
- if (error) {
+ try {
+ set_size(12., 72.); // Set a default fontsize 12 pt at 72dpi.
+ } catch (...) {
FT_Done_Face(face);
- throw_ft_error("Could not set the fontsize", error);
+ throw;
}
-
- if (open_args.stream != NULL) {
- face->face_flags |= FT_FACE_FLAG_EXTERNAL_STREAM;
- }
-
- FT_Matrix transform = { 65536 / hinting_factor, 0, 0, 65536 };
- FT_Set_Transform(face, &transform, 0);
-
// Set fallbacks
std::copy(fallback_list.begin(), fallback_list.end(), std::back_inserter(fallbacks));
}
FT2Font::~FT2Font()
{
- for (size_t i = 0; i < glyphs.size(); i++) {
- FT_Done_Glyph(glyphs[i]);
+ for (auto & glyph : glyphs) {
+ FT_Done_Glyph(glyph);
}
if (face) {
@@ -308,31 +248,29 @@ void FT2Font::clear()
bbox.xMin = bbox.yMin = bbox.xMax = bbox.yMax = 0;
advance = 0;
- for (size_t i = 0; i < glyphs.size(); i++) {
- FT_Done_Glyph(glyphs[i]);
+ for (auto & glyph : glyphs) {
+ FT_Done_Glyph(glyph);
}
glyphs.clear();
glyph_to_font.clear();
char_to_font.clear();
- for (size_t i = 0; i < fallbacks.size(); i++) {
- fallbacks[i]->clear();
+ for (auto & fallback : fallbacks) {
+ fallback->clear();
}
}
void FT2Font::set_size(double ptsize, double dpi)
{
- FT_Error error = FT_Set_Char_Size(
+ FT_CHECK(
+ FT_Set_Char_Size,
face, (FT_F26Dot6)(ptsize * 64), 0, (FT_UInt)(dpi * hinting_factor), (FT_UInt)dpi);
- if (error) {
- throw_ft_error("Could not set the fontsize", error);
- }
FT_Matrix transform = { 65536 / hinting_factor, 0, 0, 65536 };
- FT_Set_Transform(face, &transform, 0);
+ FT_Set_Transform(face, &transform, nullptr);
- for (size_t i = 0; i < fallbacks.size(); i++) {
- fallbacks[i]->set_size(ptsize, dpi);
+ for (auto & fallback : fallbacks) {
+ fallback->set_size(ptsize, dpi);
}
}
@@ -341,17 +279,12 @@ void FT2Font::set_charmap(int i)
if (i >= face->num_charmaps) {
throw std::runtime_error("i exceeds the available number of char maps");
}
- FT_CharMap charmap = face->charmaps[i];
- if (FT_Error error = FT_Set_Charmap(face, charmap)) {
- throw_ft_error("Could not set the charmap", error);
- }
+ FT_CHECK(FT_Set_Charmap, face, face->charmaps[i]);
}
void FT2Font::select_charmap(unsigned long i)
{
- if (FT_Error error = FT_Select_Charmap(face, (FT_Encoding)i)) {
- throw_ft_error("Could not set the charmap", error);
- }
+ FT_CHECK(FT_Select_Charmap, face, (FT_Encoding)i);
}
int FT2Font::get_kerning(FT_UInt left, FT_UInt right, FT_Kerning_Mode mode,
@@ -393,8 +326,8 @@ int FT2Font::get_kerning(FT_UInt left, FT_UInt right, FT_Kerning_Mode mode,
void FT2Font::set_kerning_factor(int factor)
{
kerning_factor = factor;
- for (size_t i = 0; i < fallbacks.size(); i++) {
- fallbacks[i]->set_kerning_factor(factor);
+ for (auto & fallback : fallbacks) {
+ fallback->set_kerning_factor(factor);
}
}
@@ -420,7 +353,7 @@ void FT2Font::set_text(
bbox.xMax = bbox.yMax = -32000;
FT_UInt previous = 0;
- FT2Font *previous_ft_object = NULL;
+ FT2Font *previous_ft_object = nullptr;
for (auto codepoint : text) {
FT_UInt glyph_index = 0;
@@ -441,6 +374,8 @@ void FT2Font::set_text(
char_to_font[codepoint] = ft_object_with_glyph;
glyph_to_font[glyph_index] = ft_object_with_glyph;
ft_object_with_glyph->load_glyph(glyph_index, flags, ft_object_with_glyph, false);
+ } else if (ft_object_with_glyph->warn_if_used) {
+ ft_glyph_warn((FT_ULong)codepoint, glyph_seen_fonts);
}
// retrieve kerning distance and move pen position
@@ -456,8 +391,8 @@ void FT2Font::set_text(
FT_Glyph &thisGlyph = glyphs[glyphs.size() - 1];
last_advance = ft_object_with_glyph->get_face()->glyph->advance.x;
- FT_Glyph_Transform(thisGlyph, 0, &pen);
- FT_Glyph_Transform(thisGlyph, &matrix, 0);
+ FT_Glyph_Transform(thisGlyph, nullptr, &pen);
+ FT_Glyph_Transform(thisGlyph, &matrix, nullptr);
xys.push_back(pen.x);
xys.push_back(pen.y);
@@ -492,7 +427,7 @@ void FT2Font::load_char(long charcode, FT_Int32 flags, FT2Font *&ft_object, bool
if (fallback && char_to_font.find(charcode) != char_to_font.end()) {
ft_object = char_to_font[charcode];
// since it will be assigned to ft_object anyway
- FT2Font *throwaway = NULL;
+ FT2Font *throwaway = nullptr;
ft_object->load_char(charcode, flags, throwaway, false);
} else if (fallback) {
FT_UInt final_glyph_index;
@@ -505,11 +440,13 @@ void FT2Font::load_char(long charcode, FT_Int32 flags, FT2Font *&ft_object, bool
if (!was_found) {
ft_glyph_warn(charcode, glyph_seen_fonts);
if (charcode_error) {
- throw_ft_error("Could not load charcode", charcode_error);
+ THROW_FT_ERROR("charcode loading", charcode_error);
}
else if (glyph_error) {
- throw_ft_error("Could not load charcode", glyph_error);
+ THROW_FT_ERROR("charcode loading", glyph_error);
}
+ } else if (ft_object_with_glyph->warn_if_used) {
+ ft_glyph_warn(charcode, glyph_seen_fonts);
}
ft_object = ft_object_with_glyph;
} else {
@@ -517,16 +454,12 @@ void FT2Font::load_char(long charcode, FT_Int32 flags, FT2Font *&ft_object, bool
ft_object = this;
FT_UInt glyph_index = FT_Get_Char_Index(face, (FT_ULong) charcode);
if (!glyph_index){
- glyph_seen_fonts.insert((face != NULL)?face->family_name: NULL);
+ glyph_seen_fonts.insert((face != nullptr)?face->family_name: nullptr);
ft_glyph_warn((FT_ULong)charcode, glyph_seen_fonts);
}
- if (FT_Error error = FT_Load_Glyph(face, glyph_index, flags)) {
- throw_ft_error("Could not load charcode", error);
- }
+ FT_CHECK(FT_Load_Glyph, face, glyph_index, flags);
FT_Glyph thisGlyph;
- if (FT_Error error = FT_Get_Glyph(face->glyph, &thisGlyph)) {
- throw_ft_error("Could not get glyph", error);
- }
+ FT_CHECK(FT_Get_Glyph, face->glyph, &thisGlyph);
glyphs.push_back(thisGlyph);
}
}
@@ -569,7 +502,9 @@ bool FT2Font::load_char_with_fallback(FT2Font *&ft_object_with_glyph,
bool override = false)
{
FT_UInt glyph_index = FT_Get_Char_Index(face, charcode);
- glyph_seen_fonts.insert(face->family_name);
+ if (!warn_if_used) {
+ glyph_seen_fonts.insert(face->family_name);
+ }
if (glyph_index || override) {
charcode_error = FT_Load_Glyph(face, glyph_index, flags);
@@ -594,8 +529,8 @@ bool FT2Font::load_char_with_fallback(FT2Font *&ft_object_with_glyph,
return true;
}
else {
- for (size_t i = 0; i < fallbacks.size(); ++i) {
- bool was_found = fallbacks[i]->load_char_with_fallback(
+ for (auto & fallback : fallbacks) {
+ bool was_found = fallback->load_char_with_fallback(
ft_object_with_glyph, final_glyph_index, parent_glyphs,
parent_char_to_font, parent_glyph_to_font, charcode, flags,
charcode_error, glyph_error, glyph_seen_fonts, override);
@@ -624,19 +559,15 @@ void FT2Font::load_glyph(FT_UInt glyph_index,
void FT2Font::load_glyph(FT_UInt glyph_index, FT_Int32 flags)
{
- if (FT_Error error = FT_Load_Glyph(face, glyph_index, flags)) {
- throw_ft_error("Could not load glyph", error);
- }
+ FT_CHECK(FT_Load_Glyph, face, glyph_index, flags);
FT_Glyph thisGlyph;
- if (FT_Error error = FT_Get_Glyph(face->glyph, &thisGlyph)) {
- throw_ft_error("Could not get glyph", error);
- }
+ FT_CHECK(FT_Get_Glyph, face->glyph, &thisGlyph);
glyphs.push_back(thisGlyph);
}
FT_UInt FT2Font::get_char_index(FT_ULong charcode, bool fallback = false)
{
- FT2Font *ft_object = NULL;
+ FT2Font *ft_object = nullptr;
if (fallback && char_to_font.find(charcode) != char_to_font.end()) {
// fallback denotes whether we want to search fallback list.
// should call set_text/load_char_with_fallback to parent FT2Font before
@@ -672,27 +603,27 @@ void FT2Font::draw_glyphs_to_bitmap(bool antialiased)
long width = (bbox.xMax - bbox.xMin) / 64 + 2;
long height = (bbox.yMax - bbox.yMin) / 64 + 2;
- image.resize(width, height);
-
- for (size_t n = 0; n < glyphs.size(); n++) {
- FT_Error error = FT_Glyph_To_Bitmap(
- &glyphs[n], antialiased ? FT_RENDER_MODE_NORMAL : FT_RENDER_MODE_MONO, 0, 1);
- if (error) {
- throw_ft_error("Could not convert glyph to bitmap", error);
- }
+ image = py::array_t{{height, width}};
+ std::memset(image.mutable_data(0), 0, image.nbytes());
- FT_BitmapGlyph bitmap = (FT_BitmapGlyph)glyphs[n];
+ for (auto & glyph: glyphs) {
+ FT_CHECK(
+ FT_Glyph_To_Bitmap,
+ &glyph, antialiased ? FT_RENDER_MODE_NORMAL : FT_RENDER_MODE_MONO, nullptr, 1);
+ FT_BitmapGlyph bitmap = (FT_BitmapGlyph)glyph;
// now, draw to our target surface (convert position)
// bitmap left and top in pixel, string bbox in subpixel
FT_Int x = (FT_Int)(bitmap->left - (bbox.xMin * (1. / 64.)));
FT_Int y = (FT_Int)((bbox.yMax * (1. / 64.)) - bitmap->top + 1);
- image.draw_bitmap(&bitmap->bitmap, x, y);
+ draw_bitmap(image, &bitmap->bitmap, x, y);
}
}
-void FT2Font::draw_glyph_to_bitmap(FT2Image &im, int x, int y, size_t glyphInd, bool antialiased)
+void FT2Font::draw_glyph_to_bitmap(
+ py::array_t im,
+ int x, int y, size_t glyphInd, bool antialiased)
{
FT_Vector sub_offset;
sub_offset.x = 0; // int((xd - (double)x) * 64.0);
@@ -702,19 +633,15 @@ void FT2Font::draw_glyph_to_bitmap(FT2Image &im, int x, int y, size_t glyphInd,
throw std::runtime_error("glyph num is out of range");
}
- FT_Error error = FT_Glyph_To_Bitmap(
- &glyphs[glyphInd],
- antialiased ? FT_RENDER_MODE_NORMAL : FT_RENDER_MODE_MONO,
- &sub_offset, // additional translation
- 1 // destroy image
- );
- if (error) {
- throw_ft_error("Could not convert glyph to bitmap", error);
- }
-
+ FT_CHECK(
+ FT_Glyph_To_Bitmap,
+ &glyphs[glyphInd],
+ antialiased ? FT_RENDER_MODE_NORMAL : FT_RENDER_MODE_MONO,
+ &sub_offset, // additional translation
+ 1); // destroy image
FT_BitmapGlyph bitmap = (FT_BitmapGlyph)glyphs[glyphInd];
- im.draw_bitmap(&bitmap->bitmap, x + bitmap->left, y);
+ draw_bitmap(im, &bitmap->bitmap, x + bitmap->left, y);
}
void FT2Font::get_glyph_name(unsigned int glyph_number, std::string &buffer,
@@ -736,9 +663,7 @@ void FT2Font::get_glyph_name(unsigned int glyph_number, std::string &buffer,
throw std::runtime_error("Failed to convert glyph to standard name");
}
} else {
- if (FT_Error error = FT_Get_Glyph_Name(face, glyph_number, buffer.data(), buffer.size())) {
- throw_ft_error("Could not get glyph names", error);
- }
+ FT_CHECK(FT_Get_Glyph_Name, face, glyph_number, buffer.data(), buffer.size());
auto len = buffer.find('\0');
if (len != buffer.npos) {
buffer.resize(len);
diff --git a/src/ft2font.h b/src/ft2font.h
index 5524930d5ad0..6676a7dd4818 100644
--- a/src/ft2font.h
+++ b/src/ft2font.h
@@ -6,6 +6,9 @@
#ifndef MPL_FT2FONT_H
#define MPL_FT2FONT_H
+#include
+#include
+
#include