3444 lines
127 KiB
Python
3444 lines
127 KiB
Python
"""
|
|
axes3d.py, original mplot3d version by John Porter
|
|
Created: 23 Sep 2005
|
|
|
|
Parts fixed by Reinier Heeres <reinier@heeres.eu>
|
|
Minor additions by Ben Axelrod <baxelrod@coroware.com>
|
|
Significant updates and revisions by Ben Root <ben.v.root@gmail.com>
|
|
|
|
Module containing Axes3D, an object which can plot 3D objects on a
|
|
2D matplotlib figure.
|
|
"""
|
|
|
|
from collections import defaultdict
|
|
import functools
|
|
import itertools
|
|
import math
|
|
import textwrap
|
|
|
|
import numpy as np
|
|
|
|
import matplotlib as mpl
|
|
from matplotlib import _api, cbook, _docstring, _preprocess_data
|
|
import matplotlib.artist as martist
|
|
import matplotlib.axes as maxes
|
|
import matplotlib.collections as mcoll
|
|
import matplotlib.colors as mcolors
|
|
import matplotlib.image as mimage
|
|
import matplotlib.lines as mlines
|
|
import matplotlib.patches as mpatches
|
|
import matplotlib.container as mcontainer
|
|
import matplotlib.transforms as mtransforms
|
|
from matplotlib.axes import Axes
|
|
from matplotlib.axes._base import _axis_method_wrapper, _process_plot_format
|
|
from matplotlib.transforms import Bbox
|
|
from matplotlib.tri._triangulation import Triangulation
|
|
|
|
from . import art3d
|
|
from . import proj3d
|
|
from . import axis3d
|
|
|
|
|
|
@_docstring.interpd
|
|
@_api.define_aliases({
|
|
"xlim": ["xlim3d"], "ylim": ["ylim3d"], "zlim": ["zlim3d"]})
|
|
class Axes3D(Axes):
|
|
"""
|
|
3D Axes object.
|
|
|
|
.. note::
|
|
|
|
As a user, you do not instantiate Axes directly, but use Axes creation
|
|
methods instead; e.g. from `.pyplot` or `.Figure`:
|
|
`~.pyplot.subplots`, `~.pyplot.subplot_mosaic` or `.Figure.add_axes`.
|
|
"""
|
|
name = '3d'
|
|
|
|
_axis_names = ("x", "y", "z")
|
|
Axes._shared_axes["z"] = cbook.Grouper()
|
|
Axes._shared_axes["view"] = cbook.Grouper()
|
|
|
|
vvec = _api.deprecate_privatize_attribute("3.7")
|
|
eye = _api.deprecate_privatize_attribute("3.7")
|
|
sx = _api.deprecate_privatize_attribute("3.7")
|
|
sy = _api.deprecate_privatize_attribute("3.7")
|
|
|
|
def __init__(
|
|
self, fig, rect=None, *args,
|
|
elev=30, azim=-60, roll=0, sharez=None, proj_type='persp',
|
|
box_aspect=None, computed_zorder=True, focal_length=None,
|
|
shareview=None,
|
|
**kwargs):
|
|
"""
|
|
Parameters
|
|
----------
|
|
fig : Figure
|
|
The parent figure.
|
|
rect : tuple (left, bottom, width, height), default: None.
|
|
The ``(left, bottom, width, height)`` axes position.
|
|
elev : float, default: 30
|
|
The elevation angle in degrees rotates the camera above and below
|
|
the x-y plane, with a positive angle corresponding to a location
|
|
above the plane.
|
|
azim : float, default: -60
|
|
The azimuthal angle in degrees rotates the camera about the z axis,
|
|
with a positive angle corresponding to a right-handed rotation. In
|
|
other words, a positive azimuth rotates the camera about the origin
|
|
from its location along the +x axis towards the +y axis.
|
|
roll : float, default: 0
|
|
The roll angle in degrees rotates the camera about the viewing
|
|
axis. A positive angle spins the camera clockwise, causing the
|
|
scene to rotate counter-clockwise.
|
|
sharez : Axes3D, optional
|
|
Other Axes to share z-limits with.
|
|
proj_type : {'persp', 'ortho'}
|
|
The projection type, default 'persp'.
|
|
box_aspect : 3-tuple of floats, default: None
|
|
Changes the physical dimensions of the Axes3D, such that the ratio
|
|
of the axis lengths in display units is x:y:z.
|
|
If None, defaults to 4:4:3
|
|
computed_zorder : bool, default: True
|
|
If True, the draw order is computed based on the average position
|
|
of the `.Artist`\\s along the view direction.
|
|
Set to False if you want to manually control the order in which
|
|
Artists are drawn on top of each other using their *zorder*
|
|
attribute. This can be used for fine-tuning if the automatic order
|
|
does not produce the desired result. Note however, that a manual
|
|
zorder will only be correct for a limited view angle. If the figure
|
|
is rotated by the user, it will look wrong from certain angles.
|
|
focal_length : float, default: None
|
|
For a projection type of 'persp', the focal length of the virtual
|
|
camera. Must be > 0. If None, defaults to 1.
|
|
For a projection type of 'ortho', must be set to either None
|
|
or infinity (numpy.inf). If None, defaults to infinity.
|
|
The focal length can be computed from a desired Field Of View via
|
|
the equation: focal_length = 1/tan(FOV/2)
|
|
shareview : Axes3D, optional
|
|
Other Axes to share view angles with.
|
|
|
|
**kwargs
|
|
Other optional keyword arguments:
|
|
|
|
%(Axes3D:kwdoc)s
|
|
"""
|
|
|
|
if rect is None:
|
|
rect = [0.0, 0.0, 1.0, 1.0]
|
|
|
|
self.initial_azim = azim
|
|
self.initial_elev = elev
|
|
self.initial_roll = roll
|
|
self.set_proj_type(proj_type, focal_length)
|
|
self.computed_zorder = computed_zorder
|
|
|
|
self.xy_viewLim = Bbox.unit()
|
|
self.zz_viewLim = Bbox.unit()
|
|
self.xy_dataLim = Bbox.unit()
|
|
# z-limits are encoded in the x-component of the Bbox, y is un-used
|
|
self.zz_dataLim = Bbox.unit()
|
|
|
|
# inhibit autoscale_view until the axes are defined
|
|
# they can't be defined until Axes.__init__ has been called
|
|
self.view_init(self.initial_elev, self.initial_azim, self.initial_roll)
|
|
|
|
self._sharez = sharez
|
|
if sharez is not None:
|
|
self._shared_axes["z"].join(self, sharez)
|
|
self._adjustable = 'datalim'
|
|
|
|
self._shareview = shareview
|
|
if shareview is not None:
|
|
self._shared_axes["view"].join(self, shareview)
|
|
|
|
if kwargs.pop('auto_add_to_figure', False):
|
|
raise AttributeError(
|
|
'auto_add_to_figure is no longer supported for Axes3D. '
|
|
'Use fig.add_axes(ax) instead.'
|
|
)
|
|
|
|
super().__init__(
|
|
fig, rect, frameon=True, box_aspect=box_aspect, *args, **kwargs
|
|
)
|
|
# Disable drawing of axes by base class
|
|
super().set_axis_off()
|
|
# Enable drawing of axes by Axes3D class
|
|
self.set_axis_on()
|
|
self.M = None
|
|
self.invM = None
|
|
|
|
# func used to format z -- fall back on major formatters
|
|
self.fmt_zdata = None
|
|
|
|
self.mouse_init()
|
|
self.figure.canvas.callbacks._connect_picklable(
|
|
'motion_notify_event', self._on_move)
|
|
self.figure.canvas.callbacks._connect_picklable(
|
|
'button_press_event', self._button_press)
|
|
self.figure.canvas.callbacks._connect_picklable(
|
|
'button_release_event', self._button_release)
|
|
self.set_top_view()
|
|
|
|
self.patch.set_linewidth(0)
|
|
# Calculate the pseudo-data width and height
|
|
pseudo_bbox = self.transLimits.inverted().transform([(0, 0), (1, 1)])
|
|
self._pseudo_w, self._pseudo_h = pseudo_bbox[1] - pseudo_bbox[0]
|
|
|
|
# mplot3d currently manages its own spines and needs these turned off
|
|
# for bounding box calculations
|
|
self.spines[:].set_visible(False)
|
|
|
|
def set_axis_off(self):
|
|
self._axis3don = False
|
|
self.stale = True
|
|
|
|
def set_axis_on(self):
|
|
self._axis3don = True
|
|
self.stale = True
|
|
|
|
def convert_zunits(self, z):
|
|
"""
|
|
For artists in an Axes, if the zaxis has units support,
|
|
convert *z* using zaxis unit type
|
|
"""
|
|
return self.zaxis.convert_units(z)
|
|
|
|
def set_top_view(self):
|
|
# this happens to be the right view for the viewing coordinates
|
|
# moved up and to the left slightly to fit labels and axes
|
|
xdwl = 0.95 / self._dist
|
|
xdw = 0.9 / self._dist
|
|
ydwl = 0.95 / self._dist
|
|
ydw = 0.9 / self._dist
|
|
# Set the viewing pane.
|
|
self.viewLim.intervalx = (-xdwl, xdw)
|
|
self.viewLim.intervaly = (-ydwl, ydw)
|
|
self.stale = True
|
|
|
|
def _init_axis(self):
|
|
"""Init 3D axes; overrides creation of regular X/Y axes."""
|
|
self.xaxis = axis3d.XAxis(self)
|
|
self.yaxis = axis3d.YAxis(self)
|
|
self.zaxis = axis3d.ZAxis(self)
|
|
|
|
def get_zaxis(self):
|
|
"""Return the ``ZAxis`` (`~.axis3d.Axis`) instance."""
|
|
return self.zaxis
|
|
|
|
get_zgridlines = _axis_method_wrapper("zaxis", "get_gridlines")
|
|
get_zticklines = _axis_method_wrapper("zaxis", "get_ticklines")
|
|
|
|
@_api.deprecated("3.7")
|
|
def unit_cube(self, vals=None):
|
|
return self._unit_cube(vals)
|
|
|
|
def _unit_cube(self, vals=None):
|
|
minx, maxx, miny, maxy, minz, maxz = vals or self.get_w_lims()
|
|
return [(minx, miny, minz),
|
|
(maxx, miny, minz),
|
|
(maxx, maxy, minz),
|
|
(minx, maxy, minz),
|
|
(minx, miny, maxz),
|
|
(maxx, miny, maxz),
|
|
(maxx, maxy, maxz),
|
|
(minx, maxy, maxz)]
|
|
|
|
@_api.deprecated("3.7")
|
|
def tunit_cube(self, vals=None, M=None):
|
|
return self._tunit_cube(vals, M)
|
|
|
|
def _tunit_cube(self, vals=None, M=None):
|
|
if M is None:
|
|
M = self.M
|
|
xyzs = self._unit_cube(vals)
|
|
tcube = proj3d._proj_points(xyzs, M)
|
|
return tcube
|
|
|
|
@_api.deprecated("3.7")
|
|
def tunit_edges(self, vals=None, M=None):
|
|
return self._tunit_edges(vals, M)
|
|
|
|
def _tunit_edges(self, vals=None, M=None):
|
|
tc = self._tunit_cube(vals, M)
|
|
edges = [(tc[0], tc[1]),
|
|
(tc[1], tc[2]),
|
|
(tc[2], tc[3]),
|
|
(tc[3], tc[0]),
|
|
|
|
(tc[0], tc[4]),
|
|
(tc[1], tc[5]),
|
|
(tc[2], tc[6]),
|
|
(tc[3], tc[7]),
|
|
|
|
(tc[4], tc[5]),
|
|
(tc[5], tc[6]),
|
|
(tc[6], tc[7]),
|
|
(tc[7], tc[4])]
|
|
return edges
|
|
|
|
def set_aspect(self, aspect, adjustable=None, anchor=None, share=False):
|
|
"""
|
|
Set the aspect ratios.
|
|
|
|
Parameters
|
|
----------
|
|
aspect : {'auto', 'equal', 'equalxy', 'equalxz', 'equalyz'}
|
|
Possible values:
|
|
|
|
========= ==================================================
|
|
value description
|
|
========= ==================================================
|
|
'auto' automatic; fill the position rectangle with data.
|
|
'equal' adapt all the axes to have equal aspect ratios.
|
|
'equalxy' adapt the x and y axes to have equal aspect ratios.
|
|
'equalxz' adapt the x and z axes to have equal aspect ratios.
|
|
'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.
|
|
|
|
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.
|
|
|
|
share : bool, default: False
|
|
If ``True``, apply the settings to all shared Axes.
|
|
|
|
See Also
|
|
--------
|
|
mpl_toolkits.mplot3d.axes3d.Axes3D.set_box_aspect
|
|
"""
|
|
_api.check_in_list(('auto', 'equal', 'equalxy', 'equalyz', 'equalxz'),
|
|
aspect=aspect)
|
|
super().set_aspect(
|
|
aspect='auto', adjustable=adjustable, anchor=anchor, share=share)
|
|
self._aspect = aspect
|
|
|
|
if aspect in ('equal', 'equalxy', 'equalxz', 'equalyz'):
|
|
ax_indices = self._equal_aspect_axis_indices(aspect)
|
|
|
|
view_intervals = np.array([self.xaxis.get_view_interval(),
|
|
self.yaxis.get_view_interval(),
|
|
self.zaxis.get_view_interval()])
|
|
ptp = np.ptp(view_intervals, axis=1)
|
|
if self._adjustable == 'datalim':
|
|
mean = np.mean(view_intervals, axis=1)
|
|
scale = max(ptp[ax_indices] / self._box_aspect[ax_indices])
|
|
deltas = scale * self._box_aspect
|
|
|
|
for i, set_lim in enumerate((self.set_xlim3d,
|
|
self.set_ylim3d,
|
|
self.set_zlim3d)):
|
|
if i in ax_indices:
|
|
set_lim(mean[i] - deltas[i]/2., mean[i] + deltas[i]/2.)
|
|
else: # 'box'
|
|
# Change the box aspect such that the ratio of the length of
|
|
# the unmodified axis to the length of the diagonal
|
|
# perpendicular to it remains unchanged.
|
|
box_aspect = np.array(self._box_aspect)
|
|
box_aspect[ax_indices] = ptp[ax_indices]
|
|
remaining_ax_indices = {0, 1, 2}.difference(ax_indices)
|
|
if remaining_ax_indices:
|
|
remaining = remaining_ax_indices.pop()
|
|
old_diag = np.linalg.norm(self._box_aspect[ax_indices])
|
|
new_diag = np.linalg.norm(box_aspect[ax_indices])
|
|
box_aspect[remaining] *= new_diag / old_diag
|
|
self.set_box_aspect(box_aspect)
|
|
|
|
def _equal_aspect_axis_indices(self, aspect):
|
|
"""
|
|
Get the indices for which of the x, y, z axes are constrained to have
|
|
equal aspect ratios.
|
|
|
|
Parameters
|
|
----------
|
|
aspect : {'auto', 'equal', 'equalxy', 'equalxz', 'equalyz'}
|
|
See descriptions in docstring for `.set_aspect()`.
|
|
"""
|
|
ax_indices = [] # aspect == 'auto'
|
|
if aspect == 'equal':
|
|
ax_indices = [0, 1, 2]
|
|
elif aspect == 'equalxy':
|
|
ax_indices = [0, 1]
|
|
elif aspect == 'equalxz':
|
|
ax_indices = [0, 2]
|
|
elif aspect == 'equalyz':
|
|
ax_indices = [1, 2]
|
|
return ax_indices
|
|
|
|
def set_box_aspect(self, aspect, *, zoom=1):
|
|
"""
|
|
Set the Axes box aspect.
|
|
|
|
The box aspect is the ratio of height to width in display
|
|
units for each face of the box when viewed perpendicular to
|
|
that face. This is not to be confused with the data aspect (see
|
|
`~.Axes3D.set_aspect`). The default ratios are 4:4:3 (x:y:z).
|
|
|
|
To simulate having equal aspect in data space, set the box
|
|
aspect to match your data range in each dimension.
|
|
|
|
*zoom* controls the overall size of the Axes3D in the figure.
|
|
|
|
Parameters
|
|
----------
|
|
aspect : 3-tuple of floats or None
|
|
Changes the physical dimensions of the Axes3D, such that the ratio
|
|
of the axis lengths in display units is x:y:z.
|
|
If None, defaults to (4, 4, 3).
|
|
|
|
zoom : float, default: 1
|
|
Control overall size of the Axes3D in the figure. Must be > 0.
|
|
"""
|
|
if zoom <= 0:
|
|
raise ValueError(f'Argument zoom = {zoom} must be > 0')
|
|
|
|
if aspect is None:
|
|
aspect = np.asarray((4, 4, 3), dtype=float)
|
|
else:
|
|
aspect = np.asarray(aspect, dtype=float)
|
|
_api.check_shape((3,), aspect=aspect)
|
|
# default scale tuned to match the mpl32 appearance.
|
|
aspect *= 1.8294640721620434 * zoom / np.linalg.norm(aspect)
|
|
|
|
self._box_aspect = aspect
|
|
self.stale = True
|
|
|
|
def apply_aspect(self, position=None):
|
|
if position is None:
|
|
position = self.get_position(original=True)
|
|
|
|
# in the superclass, we would go through and actually deal with axis
|
|
# scales and box/datalim. Those are all irrelevant - all we need to do
|
|
# is make sure our coordinate system is square.
|
|
trans = self.get_figure().transSubfigure
|
|
bb = mtransforms.Bbox.unit().transformed(trans)
|
|
# this is the physical aspect of the panel (or figure):
|
|
fig_aspect = bb.height / bb.width
|
|
|
|
box_aspect = 1
|
|
pb = position.frozen()
|
|
pb1 = pb.shrunk_to_aspect(box_aspect, pb, fig_aspect)
|
|
self._set_position(pb1.anchored(self.get_anchor(), pb), 'active')
|
|
|
|
@martist.allow_rasterization
|
|
def draw(self, renderer):
|
|
if not self.get_visible():
|
|
return
|
|
self._unstale_viewLim()
|
|
|
|
# draw the background patch
|
|
self.patch.draw(renderer)
|
|
self._frameon = False
|
|
|
|
# first, set the aspect
|
|
# this is duplicated from `axes._base._AxesBase.draw`
|
|
# but must be called before any of the artist are drawn as
|
|
# it adjusts the view limits and the size of the bounding box
|
|
# of the Axes
|
|
locator = self.get_axes_locator()
|
|
self.apply_aspect(locator(self, renderer) if locator else None)
|
|
|
|
# add the projection matrix to the renderer
|
|
self.M = self.get_proj()
|
|
self.invM = np.linalg.inv(self.M)
|
|
|
|
collections_and_patches = (
|
|
artist for artist in self._children
|
|
if isinstance(artist, (mcoll.Collection, mpatches.Patch))
|
|
and artist.get_visible())
|
|
if self.computed_zorder:
|
|
# Calculate projection of collections and patches and zorder
|
|
# them. Make sure they are drawn above the grids.
|
|
zorder_offset = max(axis.get_zorder()
|
|
for axis in self._axis_map.values()) + 1
|
|
collection_zorder = patch_zorder = zorder_offset
|
|
|
|
for artist in sorted(collections_and_patches,
|
|
key=lambda artist: artist.do_3d_projection(),
|
|
reverse=True):
|
|
if isinstance(artist, mcoll.Collection):
|
|
artist.zorder = collection_zorder
|
|
collection_zorder += 1
|
|
elif isinstance(artist, mpatches.Patch):
|
|
artist.zorder = patch_zorder
|
|
patch_zorder += 1
|
|
else:
|
|
for artist in collections_and_patches:
|
|
artist.do_3d_projection()
|
|
|
|
if self._axis3don:
|
|
# Draw panes first
|
|
for axis in self._axis_map.values():
|
|
axis.draw_pane(renderer)
|
|
# Then gridlines
|
|
for axis in self._axis_map.values():
|
|
axis.draw_grid(renderer)
|
|
# Then axes, labels, text, and ticks
|
|
for axis in self._axis_map.values():
|
|
axis.draw(renderer)
|
|
|
|
# Then rest
|
|
super().draw(renderer)
|
|
|
|
def get_axis_position(self):
|
|
vals = self.get_w_lims()
|
|
tc = self._tunit_cube(vals, self.M)
|
|
xhigh = tc[1][2] > tc[2][2]
|
|
yhigh = tc[3][2] > tc[2][2]
|
|
zhigh = tc[0][2] > tc[2][2]
|
|
return xhigh, yhigh, zhigh
|
|
|
|
def update_datalim(self, xys, **kwargs):
|
|
"""
|
|
Not implemented in `~mpl_toolkits.mplot3d.axes3d.Axes3D`.
|
|
"""
|
|
pass
|
|
|
|
get_autoscalez_on = _axis_method_wrapper("zaxis", "_get_autoscale_on")
|
|
set_autoscalez_on = _axis_method_wrapper("zaxis", "_set_autoscale_on")
|
|
|
|
def set_zmargin(self, m):
|
|
"""
|
|
Set padding of Z data limits prior to autoscaling.
|
|
|
|
*m* times the data interval will be added to each end of that interval
|
|
before it is used in autoscaling. If *m* is negative, this will clip
|
|
the data range instead of expanding it.
|
|
|
|
For example, if your data is in the range [0, 2], a margin of 0.1 will
|
|
result in a range [-0.2, 2.2]; a margin of -0.1 will result in a range
|
|
of [0.2, 1.8].
|
|
|
|
Parameters
|
|
----------
|
|
m : float greater than -0.5
|
|
"""
|
|
if m <= -0.5:
|
|
raise ValueError("margin must be greater than -0.5")
|
|
self._zmargin = m
|
|
self._request_autoscale_view("z")
|
|
self.stale = True
|
|
|
|
def margins(self, *margins, x=None, y=None, z=None, tight=True):
|
|
"""
|
|
Set or retrieve autoscaling margins.
|
|
|
|
See `.Axes.margins` for full documentation. Because this function
|
|
applies to 3D Axes, it also takes a *z* argument, and returns
|
|
``(xmargin, ymargin, zmargin)``.
|
|
"""
|
|
if margins and (x is not None or y is not None or z is not None):
|
|
raise TypeError('Cannot pass both positional and keyword '
|
|
'arguments for x, y, and/or z.')
|
|
elif len(margins) == 1:
|
|
x = y = z = margins[0]
|
|
elif len(margins) == 3:
|
|
x, y, z = margins
|
|
elif margins:
|
|
raise TypeError('Must pass a single positional argument for all '
|
|
'margins, or one for each margin (x, y, z).')
|
|
|
|
if x is None and y is None and z is None:
|
|
if tight is not True:
|
|
_api.warn_external(f'ignoring tight={tight!r} in get mode')
|
|
return self._xmargin, self._ymargin, self._zmargin
|
|
|
|
if x is not None:
|
|
self.set_xmargin(x)
|
|
if y is not None:
|
|
self.set_ymargin(y)
|
|
if z is not None:
|
|
self.set_zmargin(z)
|
|
|
|
self.autoscale_view(
|
|
tight=tight, scalex=(x is not None), scaley=(y is not None),
|
|
scalez=(z is not None)
|
|
)
|
|
|
|
def autoscale(self, enable=True, axis='both', tight=None):
|
|
"""
|
|
Convenience method for simple axis view autoscaling.
|
|
|
|
See `.Axes.autoscale` for full documentation. Because this function
|
|
applies to 3D Axes, *axis* can also be set to 'z', and setting *axis*
|
|
to 'both' autoscales all three axes.
|
|
"""
|
|
if enable is None:
|
|
scalex = True
|
|
scaley = True
|
|
scalez = True
|
|
else:
|
|
if axis in ['x', 'both']:
|
|
self.set_autoscalex_on(bool(enable))
|
|
scalex = self.get_autoscalex_on()
|
|
else:
|
|
scalex = False
|
|
if axis in ['y', 'both']:
|
|
self.set_autoscaley_on(bool(enable))
|
|
scaley = self.get_autoscaley_on()
|
|
else:
|
|
scaley = False
|
|
if axis in ['z', 'both']:
|
|
self.set_autoscalez_on(bool(enable))
|
|
scalez = self.get_autoscalez_on()
|
|
else:
|
|
scalez = False
|
|
if scalex:
|
|
self._request_autoscale_view("x", tight=tight)
|
|
if scaley:
|
|
self._request_autoscale_view("y", tight=tight)
|
|
if scalez:
|
|
self._request_autoscale_view("z", tight=tight)
|
|
|
|
def auto_scale_xyz(self, X, Y, Z=None, had_data=None):
|
|
# This updates the bounding boxes as to keep a record as to what the
|
|
# minimum sized rectangular volume holds the data.
|
|
if np.shape(X) == np.shape(Y):
|
|
self.xy_dataLim.update_from_data_xy(
|
|
np.column_stack([np.ravel(X), np.ravel(Y)]), not had_data)
|
|
else:
|
|
self.xy_dataLim.update_from_data_x(X, not had_data)
|
|
self.xy_dataLim.update_from_data_y(Y, not had_data)
|
|
if Z is not None:
|
|
self.zz_dataLim.update_from_data_x(Z, not had_data)
|
|
# Let autoscale_view figure out how to use this data.
|
|
self.autoscale_view()
|
|
|
|
def autoscale_view(self, tight=None, scalex=True, scaley=True,
|
|
scalez=True):
|
|
"""
|
|
Autoscale the view limits using the data limits.
|
|
|
|
See `.Axes.autoscale_view` for full documentation. Because this
|
|
function applies to 3D Axes, it also takes a *scalez* argument.
|
|
"""
|
|
# This method looks at the rectangular volume (see above)
|
|
# of data and decides how to scale the view portal to fit it.
|
|
if tight is None:
|
|
_tight = self._tight
|
|
if not _tight:
|
|
# if image data only just use the datalim
|
|
for artist in self._children:
|
|
if isinstance(artist, mimage.AxesImage):
|
|
_tight = True
|
|
elif isinstance(artist, (mlines.Line2D, mpatches.Patch)):
|
|
_tight = False
|
|
break
|
|
else:
|
|
_tight = self._tight = bool(tight)
|
|
|
|
if scalex and self.get_autoscalex_on():
|
|
x0, x1 = self.xy_dataLim.intervalx
|
|
xlocator = self.xaxis.get_major_locator()
|
|
x0, x1 = xlocator.nonsingular(x0, x1)
|
|
if self._xmargin > 0:
|
|
delta = (x1 - x0) * self._xmargin
|
|
x0 -= delta
|
|
x1 += delta
|
|
if not _tight:
|
|
x0, x1 = xlocator.view_limits(x0, x1)
|
|
self.set_xbound(x0, x1)
|
|
|
|
if scaley and self.get_autoscaley_on():
|
|
y0, y1 = self.xy_dataLim.intervaly
|
|
ylocator = self.yaxis.get_major_locator()
|
|
y0, y1 = ylocator.nonsingular(y0, y1)
|
|
if self._ymargin > 0:
|
|
delta = (y1 - y0) * self._ymargin
|
|
y0 -= delta
|
|
y1 += delta
|
|
if not _tight:
|
|
y0, y1 = ylocator.view_limits(y0, y1)
|
|
self.set_ybound(y0, y1)
|
|
|
|
if scalez and self.get_autoscalez_on():
|
|
z0, z1 = self.zz_dataLim.intervalx
|
|
zlocator = self.zaxis.get_major_locator()
|
|
z0, z1 = zlocator.nonsingular(z0, z1)
|
|
if self._zmargin > 0:
|
|
delta = (z1 - z0) * self._zmargin
|
|
z0 -= delta
|
|
z1 += delta
|
|
if not _tight:
|
|
z0, z1 = zlocator.view_limits(z0, z1)
|
|
self.set_zbound(z0, z1)
|
|
|
|
def get_w_lims(self):
|
|
"""Get 3D world limits."""
|
|
minx, maxx = self.get_xlim3d()
|
|
miny, maxy = self.get_ylim3d()
|
|
minz, maxz = self.get_zlim3d()
|
|
return minx, maxx, miny, maxy, minz, maxz
|
|
|
|
# set_xlim, set_ylim are directly inherited from base Axes.
|
|
def set_zlim(self, bottom=None, top=None, *, emit=True, auto=False,
|
|
zmin=None, zmax=None):
|
|
"""
|
|
Set 3D z limits.
|
|
|
|
See `.Axes.set_ylim` for full documentation
|
|
"""
|
|
if top is None and np.iterable(bottom):
|
|
bottom, top = bottom
|
|
if zmin is not None:
|
|
if bottom is not None:
|
|
raise TypeError("Cannot pass both 'bottom' and 'zmin'")
|
|
bottom = zmin
|
|
if zmax is not None:
|
|
if top is not None:
|
|
raise TypeError("Cannot pass both 'top' and 'zmax'")
|
|
top = zmax
|
|
return self.zaxis._set_lim(bottom, top, emit=emit, auto=auto)
|
|
|
|
set_xlim3d = maxes.Axes.set_xlim
|
|
set_ylim3d = maxes.Axes.set_ylim
|
|
set_zlim3d = set_zlim
|
|
|
|
def get_xlim(self):
|
|
# docstring inherited
|
|
return tuple(self.xy_viewLim.intervalx)
|
|
|
|
def get_ylim(self):
|
|
# docstring inherited
|
|
return tuple(self.xy_viewLim.intervaly)
|
|
|
|
def get_zlim(self):
|
|
"""
|
|
Return the 3D z-axis view limits.
|
|
|
|
Returns
|
|
-------
|
|
left, right : (float, float)
|
|
The current z-axis limits in data coordinates.
|
|
|
|
See Also
|
|
--------
|
|
set_zlim
|
|
set_zbound, get_zbound
|
|
invert_zaxis, zaxis_inverted
|
|
|
|
Notes
|
|
-----
|
|
The z-axis may be inverted, in which case the *left* value will
|
|
be greater than the *right* value.
|
|
"""
|
|
return tuple(self.zz_viewLim.intervalx)
|
|
|
|
get_zscale = _axis_method_wrapper("zaxis", "get_scale")
|
|
|
|
# Redefine all three methods to overwrite their docstrings.
|
|
set_xscale = _axis_method_wrapper("xaxis", "_set_axes_scale")
|
|
set_yscale = _axis_method_wrapper("yaxis", "_set_axes_scale")
|
|
set_zscale = _axis_method_wrapper("zaxis", "_set_axes_scale")
|
|
set_xscale.__doc__, set_yscale.__doc__, set_zscale.__doc__ = map(
|
|
"""
|
|
Set the {}-axis scale.
|
|
|
|
Parameters
|
|
----------
|
|
value : {{"linear"}}
|
|
The axis scale type to apply. 3D axes currently only support
|
|
linear scales; other scales yield nonsensical results.
|
|
|
|
**kwargs
|
|
Keyword arguments are nominally forwarded to the scale class, but
|
|
none of them is applicable for linear scales.
|
|
""".format,
|
|
["x", "y", "z"])
|
|
|
|
get_zticks = _axis_method_wrapper("zaxis", "get_ticklocs")
|
|
set_zticks = _axis_method_wrapper("zaxis", "set_ticks")
|
|
get_zmajorticklabels = _axis_method_wrapper("zaxis", "get_majorticklabels")
|
|
get_zminorticklabels = _axis_method_wrapper("zaxis", "get_minorticklabels")
|
|
get_zticklabels = _axis_method_wrapper("zaxis", "get_ticklabels")
|
|
set_zticklabels = _axis_method_wrapper(
|
|
"zaxis", "set_ticklabels",
|
|
doc_sub={"Axis.set_ticks": "Axes3D.set_zticks"})
|
|
|
|
zaxis_date = _axis_method_wrapper("zaxis", "axis_date")
|
|
if zaxis_date.__doc__:
|
|
zaxis_date.__doc__ += textwrap.dedent("""
|
|
|
|
Notes
|
|
-----
|
|
This function is merely provided for completeness, but 3D axes do not
|
|
support dates for ticks, and so this may not work as expected.
|
|
""")
|
|
|
|
def clabel(self, *args, **kwargs):
|
|
"""Currently not implemented for 3D axes, and returns *None*."""
|
|
return None
|
|
|
|
def view_init(self, elev=None, azim=None, roll=None, vertical_axis="z",
|
|
share=False):
|
|
"""
|
|
Set the elevation and azimuth of the axes in degrees (not radians).
|
|
|
|
This can be used to rotate the axes programmatically.
|
|
|
|
To look normal to the primary planes, the following elevation and
|
|
azimuth angles can be used. A roll angle of 0, 90, 180, or 270 deg
|
|
will rotate these views while keeping the axes at right angles.
|
|
|
|
========== ==== ====
|
|
view plane elev azim
|
|
========== ==== ====
|
|
XY 90 -90
|
|
XZ 0 -90
|
|
YZ 0 0
|
|
-XY -90 90
|
|
-XZ 0 90
|
|
-YZ 0 180
|
|
========== ==== ====
|
|
|
|
Parameters
|
|
----------
|
|
elev : float, default: None
|
|
The elevation angle in degrees rotates the camera above the plane
|
|
pierced by the vertical axis, with a positive angle corresponding
|
|
to a location above that plane. For example, with the default
|
|
vertical axis of 'z', the elevation defines the angle of the camera
|
|
location above the x-y plane.
|
|
If None, then the initial value as specified in the `Axes3D`
|
|
constructor is used.
|
|
azim : float, default: None
|
|
The azimuthal angle in degrees rotates the camera about the
|
|
vertical axis, with a positive angle corresponding to a
|
|
right-handed rotation. For example, with the default vertical axis
|
|
of 'z', a positive azimuth rotates the camera about the origin from
|
|
its location along the +x axis towards the +y axis.
|
|
If None, then the initial value as specified in the `Axes3D`
|
|
constructor is used.
|
|
roll : float, default: None
|
|
The roll angle in degrees rotates the camera about the viewing
|
|
axis. A positive angle spins the camera clockwise, causing the
|
|
scene to rotate counter-clockwise.
|
|
If None, then the initial value as specified in the `Axes3D`
|
|
constructor is used.
|
|
vertical_axis : {"z", "x", "y"}, default: "z"
|
|
The axis to align vertically. *azim* rotates about this axis.
|
|
share : bool, default: False
|
|
If ``True``, apply the settings to all Axes with shared views.
|
|
"""
|
|
|
|
self._dist = 10 # The camera distance from origin. Behaves like zoom
|
|
|
|
if elev is None:
|
|
elev = self.initial_elev
|
|
if azim is None:
|
|
azim = self.initial_azim
|
|
if roll is None:
|
|
roll = self.initial_roll
|
|
vertical_axis = _api.check_getitem(
|
|
dict(x=0, y=1, z=2), vertical_axis=vertical_axis
|
|
)
|
|
|
|
if share:
|
|
axes = {sibling for sibling
|
|
in self._shared_axes['view'].get_siblings(self)}
|
|
else:
|
|
axes = [self]
|
|
|
|
for ax in axes:
|
|
ax.elev = elev
|
|
ax.azim = azim
|
|
ax.roll = roll
|
|
ax._vertical_axis = vertical_axis
|
|
|
|
def set_proj_type(self, proj_type, focal_length=None):
|
|
"""
|
|
Set the projection type.
|
|
|
|
Parameters
|
|
----------
|
|
proj_type : {'persp', 'ortho'}
|
|
The projection type.
|
|
focal_length : float, default: None
|
|
For a projection type of 'persp', the focal length of the virtual
|
|
camera. Must be > 0. If None, defaults to 1.
|
|
The focal length can be computed from a desired Field Of View via
|
|
the equation: focal_length = 1/tan(FOV/2)
|
|
"""
|
|
_api.check_in_list(['persp', 'ortho'], proj_type=proj_type)
|
|
if proj_type == 'persp':
|
|
if focal_length is None:
|
|
focal_length = 1
|
|
elif focal_length <= 0:
|
|
raise ValueError(f"focal_length = {focal_length} must be "
|
|
"greater than 0")
|
|
self._focal_length = focal_length
|
|
else: # 'ortho':
|
|
if focal_length not in (None, np.inf):
|
|
raise ValueError(f"focal_length = {focal_length} must be "
|
|
f"None for proj_type = {proj_type}")
|
|
self._focal_length = np.inf
|
|
|
|
def _roll_to_vertical(self, arr):
|
|
"""Roll arrays to match the different vertical axis."""
|
|
return np.roll(arr, self._vertical_axis - 2)
|
|
|
|
def get_proj(self):
|
|
"""Create the projection matrix from the current viewing position."""
|
|
|
|
# Transform to uniform world coordinates 0-1, 0-1, 0-1
|
|
box_aspect = self._roll_to_vertical(self._box_aspect)
|
|
worldM = proj3d.world_transformation(
|
|
*self.get_xlim3d(),
|
|
*self.get_ylim3d(),
|
|
*self.get_zlim3d(),
|
|
pb_aspect=box_aspect,
|
|
)
|
|
|
|
# Look into the middle of the world coordinates:
|
|
R = 0.5 * box_aspect
|
|
|
|
# elev: elevation angle in the z plane.
|
|
# azim: azimuth angle in the xy plane.
|
|
# Coordinates for a point that rotates around the box of data.
|
|
# p0, p1 corresponds to rotating the box only around the vertical axis.
|
|
# p2 corresponds to rotating the box only around the horizontal axis.
|
|
elev_rad = np.deg2rad(self.elev)
|
|
azim_rad = np.deg2rad(self.azim)
|
|
p0 = np.cos(elev_rad) * np.cos(azim_rad)
|
|
p1 = np.cos(elev_rad) * np.sin(azim_rad)
|
|
p2 = np.sin(elev_rad)
|
|
|
|
# When changing vertical axis the coordinates changes as well.
|
|
# Roll the values to get the same behaviour as the default:
|
|
ps = self._roll_to_vertical([p0, p1, p2])
|
|
|
|
# The coordinates for the eye viewing point. The eye is looking
|
|
# towards the middle of the box of data from a distance:
|
|
eye = R + self._dist * ps
|
|
|
|
# vvec, self._vvec and self._eye are unused, remove when deprecated
|
|
vvec = R - eye
|
|
self._eye = eye
|
|
self._vvec = vvec / np.linalg.norm(vvec)
|
|
|
|
# Calculate the viewing axes for the eye position
|
|
u, v, w = self._calc_view_axes(eye)
|
|
self._view_u = u # _view_u is towards the right of the screen
|
|
self._view_v = v # _view_v is towards the top of the screen
|
|
self._view_w = w # _view_w is out of the screen
|
|
|
|
# Generate the view and projection transformation matrices
|
|
if self._focal_length == np.inf:
|
|
# Orthographic projection
|
|
viewM = proj3d._view_transformation_uvw(u, v, w, eye)
|
|
projM = proj3d._ortho_transformation(-self._dist, self._dist)
|
|
else:
|
|
# Perspective projection
|
|
# Scale the eye dist to compensate for the focal length zoom effect
|
|
eye_focal = R + self._dist * ps * self._focal_length
|
|
viewM = proj3d._view_transformation_uvw(u, v, w, eye_focal)
|
|
projM = proj3d._persp_transformation(-self._dist,
|
|
self._dist,
|
|
self._focal_length)
|
|
|
|
# Combine all the transformation matrices to get the final projection
|
|
M0 = np.dot(viewM, worldM)
|
|
M = np.dot(projM, M0)
|
|
return M
|
|
|
|
def mouse_init(self, rotate_btn=1, pan_btn=2, zoom_btn=3):
|
|
"""
|
|
Set the mouse buttons for 3D rotation and zooming.
|
|
|
|
Parameters
|
|
----------
|
|
rotate_btn : int or list of int, default: 1
|
|
The mouse button or buttons to use for 3D rotation of the axes.
|
|
pan_btn : int or list of int, default: 2
|
|
The mouse button or buttons to use to pan the 3D axes.
|
|
zoom_btn : int or list of int, default: 3
|
|
The mouse button or buttons to use to zoom the 3D axes.
|
|
"""
|
|
self.button_pressed = None
|
|
# coerce scalars into array-like, then convert into
|
|
# a regular list to avoid comparisons against None
|
|
# which breaks in recent versions of numpy.
|
|
self._rotate_btn = np.atleast_1d(rotate_btn).tolist()
|
|
self._pan_btn = np.atleast_1d(pan_btn).tolist()
|
|
self._zoom_btn = np.atleast_1d(zoom_btn).tolist()
|
|
|
|
def disable_mouse_rotation(self):
|
|
"""Disable mouse buttons for 3D rotation, panning, and zooming."""
|
|
self.mouse_init(rotate_btn=[], pan_btn=[], zoom_btn=[])
|
|
|
|
def can_zoom(self):
|
|
# doc-string inherited
|
|
return True
|
|
|
|
def can_pan(self):
|
|
# doc-string inherited
|
|
return True
|
|
|
|
def sharez(self, other):
|
|
"""
|
|
Share the z-axis with *other*.
|
|
|
|
This is equivalent to passing ``sharez=other`` when constructing the
|
|
Axes, and cannot be used if the z-axis is already being shared with
|
|
another Axes.
|
|
"""
|
|
_api.check_isinstance(Axes3D, other=other)
|
|
if self._sharez is not None and other is not self._sharez:
|
|
raise ValueError("z-axis is already shared")
|
|
self._shared_axes["z"].join(self, other)
|
|
self._sharez = other
|
|
self.zaxis.major = other.zaxis.major # Ticker instances holding
|
|
self.zaxis.minor = other.zaxis.minor # locator and formatter.
|
|
z0, z1 = other.get_zlim()
|
|
self.set_zlim(z0, z1, emit=False, auto=other.get_autoscalez_on())
|
|
self.zaxis._scale = other.zaxis._scale
|
|
|
|
def shareview(self, other):
|
|
"""
|
|
Share the view angles with *other*.
|
|
|
|
This is equivalent to passing ``shareview=other`` when
|
|
constructing the Axes, and cannot be used if the view angles are
|
|
already being shared with another Axes.
|
|
"""
|
|
_api.check_isinstance(Axes3D, other=other)
|
|
if self._shareview is not None and other is not self._shareview:
|
|
raise ValueError("view angles are already shared")
|
|
self._shared_axes["view"].join(self, other)
|
|
self._shareview = other
|
|
vertical_axis = {0: "x", 1: "y", 2: "z"}[other._vertical_axis]
|
|
self.view_init(elev=other.elev, azim=other.azim, roll=other.roll,
|
|
vertical_axis=vertical_axis, share=True)
|
|
|
|
def clear(self):
|
|
# docstring inherited.
|
|
super().clear()
|
|
if self._focal_length == np.inf:
|
|
self._zmargin = mpl.rcParams['axes.zmargin']
|
|
else:
|
|
self._zmargin = 0.
|
|
self.grid(mpl.rcParams['axes3d.grid'])
|
|
|
|
def _button_press(self, event):
|
|
if event.inaxes == self:
|
|
self.button_pressed = event.button
|
|
self._sx, self._sy = event.xdata, event.ydata
|
|
toolbar = self.figure.canvas.toolbar
|
|
if toolbar and toolbar._nav_stack() is None:
|
|
toolbar.push_current()
|
|
|
|
def _button_release(self, event):
|
|
self.button_pressed = None
|
|
toolbar = self.figure.canvas.toolbar
|
|
# backend_bases.release_zoom and backend_bases.release_pan call
|
|
# push_current, so check the navigation mode so we don't call it twice
|
|
if toolbar and self.get_navigate_mode() is None:
|
|
toolbar.push_current()
|
|
|
|
def _get_view(self):
|
|
# docstring inherited
|
|
return {
|
|
"xlim": self.get_xlim(), "autoscalex_on": self.get_autoscalex_on(),
|
|
"ylim": self.get_ylim(), "autoscaley_on": self.get_autoscaley_on(),
|
|
"zlim": self.get_zlim(), "autoscalez_on": self.get_autoscalez_on(),
|
|
}, (self.elev, self.azim, self.roll)
|
|
|
|
def _set_view(self, view):
|
|
# docstring inherited
|
|
props, (elev, azim, roll) = view
|
|
self.set(**props)
|
|
self.elev = elev
|
|
self.azim = azim
|
|
self.roll = roll
|
|
|
|
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
|
|
back on the zaxis major formatter
|
|
"""
|
|
try:
|
|
return self.fmt_zdata(z)
|
|
except (AttributeError, TypeError):
|
|
func = self.zaxis.get_major_formatter().format_data_short
|
|
val = func(z)
|
|
return val
|
|
|
|
def format_coord(self, xv, yv, renderer=None):
|
|
"""
|
|
Return a string giving the current view rotation angles, or the x, y, z
|
|
coordinates of the point on the nearest axis pane underneath the mouse
|
|
cursor, depending on the mouse button pressed.
|
|
"""
|
|
coords = ''
|
|
|
|
if self.button_pressed in self._rotate_btn:
|
|
# ignore xv and yv and display angles instead
|
|
coords = self._rotation_coords()
|
|
|
|
elif self.M is not None:
|
|
coords = self._location_coords(xv, yv, renderer)
|
|
|
|
return coords
|
|
|
|
def _rotation_coords(self):
|
|
"""
|
|
Return the rotation angles as a string.
|
|
"""
|
|
norm_elev = art3d._norm_angle(self.elev)
|
|
norm_azim = art3d._norm_angle(self.azim)
|
|
norm_roll = art3d._norm_angle(self.roll)
|
|
coords = (f"elevation={norm_elev:.0f}\N{DEGREE SIGN}, "
|
|
f"azimuth={norm_azim:.0f}\N{DEGREE SIGN}, "
|
|
f"roll={norm_roll:.0f}\N{DEGREE SIGN}"
|
|
).replace("-", "\N{MINUS SIGN}")
|
|
return coords
|
|
|
|
def _location_coords(self, xv, yv, renderer):
|
|
"""
|
|
Return the location on the axis pane underneath the cursor as a string.
|
|
"""
|
|
p1, pane_idx = self._calc_coord(xv, yv, renderer)
|
|
xs = self.format_xdata(p1[0])
|
|
ys = self.format_ydata(p1[1])
|
|
zs = self.format_zdata(p1[2])
|
|
if pane_idx == 0:
|
|
coords = f'x pane={xs}, y={ys}, z={zs}'
|
|
elif pane_idx == 1:
|
|
coords = f'x={xs}, y pane={ys}, z={zs}'
|
|
elif pane_idx == 2:
|
|
coords = f'x={xs}, y={ys}, z pane={zs}'
|
|
return coords
|
|
|
|
def _get_camera_loc(self):
|
|
"""
|
|
Returns the current camera location in data coordinates.
|
|
"""
|
|
cx, cy, cz, dx, dy, dz = self._get_w_centers_ranges()
|
|
c = np.array([cx, cy, cz])
|
|
r = np.array([dx, dy, dz])
|
|
|
|
if self._focal_length == np.inf: # orthographic projection
|
|
focal_length = 1e9 # large enough to be effectively infinite
|
|
else: # perspective projection
|
|
focal_length = self._focal_length
|
|
eye = c + self._view_w * self._dist * r / self._box_aspect * focal_length
|
|
return eye
|
|
|
|
def _calc_coord(self, xv, yv, renderer=None):
|
|
"""
|
|
Given the 2D view coordinates, find the point on the nearest axis pane
|
|
that lies directly below those coordinates. Returns a 3D point in data
|
|
coordinates.
|
|
"""
|
|
if self._focal_length == np.inf: # orthographic projection
|
|
zv = 1
|
|
else: # perspective projection
|
|
zv = -1 / self._focal_length
|
|
|
|
# Convert point on view plane to data coordinates
|
|
p1 = np.array(proj3d.inv_transform(xv, yv, zv, self.invM)).ravel()
|
|
|
|
# Get the vector from the camera to the point on the view plane
|
|
vec = self._get_camera_loc() - p1
|
|
|
|
# Get the pane locations for each of the axes
|
|
pane_locs = []
|
|
for axis in self._axis_map.values():
|
|
xys, loc = axis.active_pane(renderer)
|
|
pane_locs.append(loc)
|
|
|
|
# Find the distance to the nearest pane by projecting the view vector
|
|
scales = np.zeros(3)
|
|
for i in range(3):
|
|
if vec[i] == 0:
|
|
scales[i] = np.inf
|
|
else:
|
|
scales[i] = (p1[i] - pane_locs[i]) / vec[i]
|
|
pane_idx = np.argmin(abs(scales))
|
|
scale = scales[pane_idx]
|
|
|
|
# Calculate the point on the closest pane
|
|
p2 = p1 - scale*vec
|
|
return p2, pane_idx
|
|
|
|
def _on_move(self, event):
|
|
"""
|
|
Mouse moving.
|
|
|
|
By default, button-1 rotates, button-2 pans, and button-3 zooms;
|
|
these buttons can be modified via `mouse_init`.
|
|
"""
|
|
|
|
if not self.button_pressed:
|
|
return
|
|
|
|
if self.get_navigate_mode() is not None:
|
|
# we don't want to rotate if we are zooming/panning
|
|
# from the toolbar
|
|
return
|
|
|
|
if self.M is None:
|
|
return
|
|
|
|
x, y = event.xdata, event.ydata
|
|
# In case the mouse is out of bounds.
|
|
if x is None or event.inaxes != self:
|
|
return
|
|
|
|
dx, dy = x - self._sx, y - self._sy
|
|
w = self._pseudo_w
|
|
h = self._pseudo_h
|
|
|
|
# Rotation
|
|
if self.button_pressed in self._rotate_btn:
|
|
# rotate viewing point
|
|
# get the x and y pixel coords
|
|
if dx == 0 and dy == 0:
|
|
return
|
|
|
|
roll = np.deg2rad(self.roll)
|
|
delev = -(dy/h)*180*np.cos(roll) + (dx/w)*180*np.sin(roll)
|
|
dazim = -(dy/h)*180*np.sin(roll) - (dx/w)*180*np.cos(roll)
|
|
elev = self.elev + delev
|
|
azim = self.azim + dazim
|
|
self.view_init(elev=elev, azim=azim, roll=roll, share=True)
|
|
self.stale = True
|
|
|
|
# Pan
|
|
elif self.button_pressed in self._pan_btn:
|
|
# Start the pan event with pixel coordinates
|
|
px, py = self.transData.transform([self._sx, self._sy])
|
|
self.start_pan(px, py, 2)
|
|
# pan view (takes pixel coordinate input)
|
|
self.drag_pan(2, None, event.x, event.y)
|
|
self.end_pan()
|
|
|
|
# Zoom
|
|
elif self.button_pressed in self._zoom_btn:
|
|
# zoom view (dragging down zooms in)
|
|
scale = h/(h - dy)
|
|
self._scale_axis_limits(scale, scale, scale)
|
|
|
|
# Store the event coordinates for the next time through.
|
|
self._sx, self._sy = x, y
|
|
# Always request a draw update at the end of interaction
|
|
self.figure.canvas.draw_idle()
|
|
|
|
def drag_pan(self, button, key, x, y):
|
|
# docstring inherited
|
|
|
|
# Get the coordinates from the move event
|
|
p = self._pan_start
|
|
(xdata, ydata), (xdata_start, ydata_start) = p.trans_inverse.transform(
|
|
[(x, y), (p.x, p.y)])
|
|
self._sx, self._sy = xdata, ydata
|
|
# Calling start_pan() to set the x/y of this event as the starting
|
|
# move location for the next event
|
|
self.start_pan(x, y, button)
|
|
du, dv = xdata - xdata_start, ydata - ydata_start
|
|
dw = 0
|
|
if key == 'x':
|
|
dv = 0
|
|
elif key == 'y':
|
|
du = 0
|
|
if du == 0 and dv == 0:
|
|
return
|
|
|
|
# Transform the pan from the view axes to the data axes
|
|
R = np.array([self._view_u, self._view_v, self._view_w])
|
|
R = -R / self._box_aspect * self._dist
|
|
duvw_projected = R.T @ np.array([du, dv, dw])
|
|
|
|
# Calculate pan distance
|
|
minx, maxx, miny, maxy, minz, maxz = self.get_w_lims()
|
|
dx = (maxx - minx) * duvw_projected[0]
|
|
dy = (maxy - miny) * duvw_projected[1]
|
|
dz = (maxz - minz) * duvw_projected[2]
|
|
|
|
# Set the new axis limits
|
|
self.set_xlim3d(minx + dx, maxx + dx)
|
|
self.set_ylim3d(miny + dy, maxy + dy)
|
|
self.set_zlim3d(minz + dz, maxz + dz)
|
|
|
|
def _calc_view_axes(self, eye):
|
|
"""
|
|
Get the unit vectors for the viewing axes in data coordinates.
|
|
`u` is towards the right of the screen
|
|
`v` is towards the top of the screen
|
|
`w` is out of the screen
|
|
"""
|
|
elev_rad = np.deg2rad(art3d._norm_angle(self.elev))
|
|
roll_rad = np.deg2rad(art3d._norm_angle(self.roll))
|
|
|
|
# Look into the middle of the world coordinates
|
|
R = 0.5 * self._roll_to_vertical(self._box_aspect)
|
|
|
|
# Define which axis should be vertical. A negative value
|
|
# indicates the plot is upside down and therefore the values
|
|
# have been reversed:
|
|
V = np.zeros(3)
|
|
V[self._vertical_axis] = -1 if abs(elev_rad) > np.pi/2 else 1
|
|
|
|
u, v, w = proj3d._view_axes(eye, R, V, roll_rad)
|
|
return u, v, w
|
|
|
|
def _set_view_from_bbox(self, bbox, direction='in',
|
|
mode=None, twinx=False, twiny=False):
|
|
"""
|
|
Zoom in or out of the bounding box.
|
|
|
|
Will center the view in the center of the bounding box, and zoom by
|
|
the ratio of the size of the bounding box to the size of the Axes3D.
|
|
"""
|
|
(start_x, start_y, stop_x, stop_y) = bbox
|
|
if mode == 'x':
|
|
start_y = self.bbox.min[1]
|
|
stop_y = self.bbox.max[1]
|
|
elif mode == 'y':
|
|
start_x = self.bbox.min[0]
|
|
stop_x = self.bbox.max[0]
|
|
|
|
# Clip to bounding box limits
|
|
start_x, stop_x = np.clip(sorted([start_x, stop_x]),
|
|
self.bbox.min[0], self.bbox.max[0])
|
|
start_y, stop_y = np.clip(sorted([start_y, stop_y]),
|
|
self.bbox.min[1], self.bbox.max[1])
|
|
|
|
# Move the center of the view to the center of the bbox
|
|
zoom_center_x = (start_x + stop_x)/2
|
|
zoom_center_y = (start_y + stop_y)/2
|
|
|
|
ax_center_x = (self.bbox.max[0] + self.bbox.min[0])/2
|
|
ax_center_y = (self.bbox.max[1] + self.bbox.min[1])/2
|
|
|
|
self.start_pan(zoom_center_x, zoom_center_y, 2)
|
|
self.drag_pan(2, None, ax_center_x, ax_center_y)
|
|
self.end_pan()
|
|
|
|
# Calculate zoom level
|
|
dx = abs(start_x - stop_x)
|
|
dy = abs(start_y - stop_y)
|
|
scale_u = dx / (self.bbox.max[0] - self.bbox.min[0])
|
|
scale_v = dy / (self.bbox.max[1] - self.bbox.min[1])
|
|
|
|
# Keep aspect ratios equal
|
|
scale = max(scale_u, scale_v)
|
|
|
|
# Zoom out
|
|
if direction == 'out':
|
|
scale = 1 / scale
|
|
|
|
self._zoom_data_limits(scale, scale, scale)
|
|
|
|
def _zoom_data_limits(self, scale_u, scale_v, scale_w):
|
|
"""
|
|
Zoom in or out of a 3D plot.
|
|
|
|
Will scale the data limits by the scale factors. These will be
|
|
transformed to the x, y, z data axes based on the current view angles.
|
|
A scale factor > 1 zooms out and a scale factor < 1 zooms in.
|
|
|
|
For an axes that has had its aspect ratio set to 'equal', 'equalxy',
|
|
'equalyz', or 'equalxz', the relevant axes are constrained to zoom
|
|
equally.
|
|
|
|
Parameters
|
|
----------
|
|
scale_u : float
|
|
Scale factor for the u view axis (view screen horizontal).
|
|
scale_v : float
|
|
Scale factor for the v view axis (view screen vertical).
|
|
scale_w : float
|
|
Scale factor for the w view axis (view screen depth).
|
|
"""
|
|
scale = np.array([scale_u, scale_v, scale_w])
|
|
|
|
# Only perform frame conversion if unequal scale factors
|
|
if not np.allclose(scale, scale_u):
|
|
# Convert the scale factors from the view frame to the data frame
|
|
R = np.array([self._view_u, self._view_v, self._view_w])
|
|
S = scale * np.eye(3)
|
|
scale = np.linalg.norm(R.T @ S, axis=1)
|
|
|
|
# Set the constrained scale factors to the factor closest to 1
|
|
if self._aspect in ('equal', 'equalxy', 'equalxz', 'equalyz'):
|
|
ax_idxs = self._equal_aspect_axis_indices(self._aspect)
|
|
min_ax_idxs = np.argmin(np.abs(scale[ax_idxs] - 1))
|
|
scale[ax_idxs] = scale[ax_idxs][min_ax_idxs]
|
|
|
|
self._scale_axis_limits(scale[0], scale[1], scale[2])
|
|
|
|
def _scale_axis_limits(self, scale_x, scale_y, scale_z):
|
|
"""
|
|
Keeping the center of the x, y, and z data axes fixed, scale their
|
|
limits by scale factors. A scale factor > 1 zooms out and a scale
|
|
factor < 1 zooms in.
|
|
|
|
Parameters
|
|
----------
|
|
scale_x : float
|
|
Scale factor for the x data axis.
|
|
scale_y : float
|
|
Scale factor for the y data axis.
|
|
scale_z : float
|
|
Scale factor for the z data axis.
|
|
"""
|
|
# Get the axis centers and ranges
|
|
cx, cy, cz, dx, dy, dz = self._get_w_centers_ranges()
|
|
|
|
# Set the scaled axis limits
|
|
self.set_xlim3d(cx - dx*scale_x/2, cx + dx*scale_x/2)
|
|
self.set_ylim3d(cy - dy*scale_y/2, cy + dy*scale_y/2)
|
|
self.set_zlim3d(cz - dz*scale_z/2, cz + dz*scale_z/2)
|
|
|
|
def _get_w_centers_ranges(self):
|
|
"""Get 3D world centers and axis ranges."""
|
|
# Calculate center of axis limits
|
|
minx, maxx, miny, maxy, minz, maxz = self.get_w_lims()
|
|
cx = (maxx + minx)/2
|
|
cy = (maxy + miny)/2
|
|
cz = (maxz + minz)/2
|
|
|
|
# Calculate range of axis limits
|
|
dx = (maxx - minx)
|
|
dy = (maxy - miny)
|
|
dz = (maxz - minz)
|
|
return cx, cy, cz, dx, dy, dz
|
|
|
|
def set_zlabel(self, zlabel, fontdict=None, labelpad=None, **kwargs):
|
|
"""
|
|
Set zlabel. See doc for `.set_ylabel` for description.
|
|
"""
|
|
if labelpad is not None:
|
|
self.zaxis.labelpad = labelpad
|
|
return self.zaxis.set_label_text(zlabel, fontdict, **kwargs)
|
|
|
|
def get_zlabel(self):
|
|
"""
|
|
Get the z-label text string.
|
|
"""
|
|
label = self.zaxis.get_label()
|
|
return label.get_text()
|
|
|
|
# Axes rectangle characteristics
|
|
|
|
# The frame_on methods are not available for 3D axes.
|
|
# Python will raise a TypeError if they are called.
|
|
get_frame_on = None
|
|
set_frame_on = None
|
|
|
|
def grid(self, visible=True, **kwargs):
|
|
"""
|
|
Set / unset 3D grid.
|
|
|
|
.. note::
|
|
|
|
Currently, this function does not behave the same as
|
|
`.axes.Axes.grid`, but it is intended to eventually support that
|
|
behavior.
|
|
"""
|
|
# TODO: Operate on each axes separately
|
|
if len(kwargs):
|
|
visible = True
|
|
self._draw_grid = visible
|
|
self.stale = True
|
|
|
|
def tick_params(self, axis='both', **kwargs):
|
|
"""
|
|
Convenience method for changing the appearance of ticks and
|
|
tick labels.
|
|
|
|
See `.Axes.tick_params` for full documentation. Because this function
|
|
applies to 3D Axes, *axis* can also be set to 'z', and setting *axis*
|
|
to 'both' autoscales all three axes.
|
|
|
|
Also, because of how Axes3D objects are drawn very differently
|
|
from regular 2D axes, some of these settings may have
|
|
ambiguous meaning. For simplicity, the 'z' axis will
|
|
accept settings as if it was like the 'y' axis.
|
|
|
|
.. note::
|
|
Axes3D currently ignores some of these settings.
|
|
"""
|
|
_api.check_in_list(['x', 'y', 'z', 'both'], axis=axis)
|
|
if axis in ['x', 'y', 'both']:
|
|
super().tick_params(axis, **kwargs)
|
|
if axis in ['z', 'both']:
|
|
zkw = dict(kwargs)
|
|
zkw.pop('top', None)
|
|
zkw.pop('bottom', None)
|
|
zkw.pop('labeltop', None)
|
|
zkw.pop('labelbottom', None)
|
|
self.zaxis.set_tick_params(**zkw)
|
|
|
|
# data limits, ticks, tick labels, and formatting
|
|
|
|
def invert_zaxis(self):
|
|
"""
|
|
Invert the z-axis.
|
|
|
|
See Also
|
|
--------
|
|
zaxis_inverted
|
|
get_zlim, set_zlim
|
|
get_zbound, set_zbound
|
|
"""
|
|
bottom, top = self.get_zlim()
|
|
self.set_zlim(top, bottom, auto=None)
|
|
|
|
zaxis_inverted = _axis_method_wrapper("zaxis", "get_inverted")
|
|
|
|
def get_zbound(self):
|
|
"""
|
|
Return the lower and upper z-axis bounds, in increasing order.
|
|
|
|
See Also
|
|
--------
|
|
set_zbound
|
|
get_zlim, set_zlim
|
|
invert_zaxis, zaxis_inverted
|
|
"""
|
|
bottom, top = self.get_zlim()
|
|
if bottom < top:
|
|
return bottom, top
|
|
else:
|
|
return top, bottom
|
|
|
|
def set_zbound(self, lower=None, upper=None):
|
|
"""
|
|
Set the lower and upper numerical bounds of the z-axis.
|
|
|
|
This method will honor axes inversion regardless of parameter order.
|
|
It will not change the autoscaling setting (`.get_autoscalez_on()`).
|
|
|
|
Parameters
|
|
----------
|
|
lower, upper : float or None
|
|
The lower and upper bounds. If *None*, the respective axis bound
|
|
is not modified.
|
|
|
|
See Also
|
|
--------
|
|
get_zbound
|
|
get_zlim, set_zlim
|
|
invert_zaxis, zaxis_inverted
|
|
"""
|
|
if upper is None and np.iterable(lower):
|
|
lower, upper = lower
|
|
|
|
old_lower, old_upper = self.get_zbound()
|
|
if lower is None:
|
|
lower = old_lower
|
|
if upper is None:
|
|
upper = old_upper
|
|
|
|
self.set_zlim(sorted((lower, upper),
|
|
reverse=bool(self.zaxis_inverted())),
|
|
auto=None)
|
|
|
|
def text(self, x, y, z, s, zdir=None, **kwargs):
|
|
"""
|
|
Add the text *s* to the 3D Axes at location *x*, *y*, *z* in data coordinates.
|
|
|
|
Parameters
|
|
----------
|
|
x, y, z : float
|
|
The position to place the text.
|
|
s : str
|
|
The text.
|
|
zdir : {'x', 'y', 'z', 3-tuple}, optional
|
|
The direction to be used as the z-direction. Default: 'z'.
|
|
See `.get_dir_vector` for a description of the values.
|
|
**kwargs
|
|
Other arguments are forwarded to `matplotlib.axes.Axes.text`.
|
|
|
|
Returns
|
|
-------
|
|
`.Text3D`
|
|
The created `.Text3D` instance.
|
|
"""
|
|
text = super().text(x, y, s, **kwargs)
|
|
art3d.text_2d_to_3d(text, z, zdir)
|
|
return text
|
|
|
|
text3D = text
|
|
text2D = Axes.text
|
|
|
|
def plot(self, xs, ys, *args, zdir='z', **kwargs):
|
|
"""
|
|
Plot 2D or 3D data.
|
|
|
|
Parameters
|
|
----------
|
|
xs : 1D array-like
|
|
x coordinates of vertices.
|
|
ys : 1D array-like
|
|
y coordinates of vertices.
|
|
zs : float or 1D array-like
|
|
z coordinates of vertices; either one for all points or one for
|
|
each point.
|
|
zdir : {'x', 'y', 'z'}, default: 'z'
|
|
When plotting 2D data, the direction to use as z.
|
|
**kwargs
|
|
Other arguments are forwarded to `matplotlib.axes.Axes.plot`.
|
|
"""
|
|
had_data = self.has_data()
|
|
|
|
# `zs` can be passed positionally or as keyword; checking whether
|
|
# args[0] is a string matches the behavior of 2D `plot` (via
|
|
# `_process_plot_var_args`).
|
|
if args and not isinstance(args[0], str):
|
|
zs, *args = args
|
|
if 'zs' in kwargs:
|
|
raise TypeError("plot() for multiple values for argument 'z'")
|
|
else:
|
|
zs = kwargs.pop('zs', 0)
|
|
|
|
# Match length
|
|
zs = np.broadcast_to(zs, np.shape(xs))
|
|
|
|
lines = super().plot(xs, ys, *args, **kwargs)
|
|
for line in lines:
|
|
art3d.line_2d_to_3d(line, zs=zs, zdir=zdir)
|
|
|
|
xs, ys, zs = art3d.juggle_axes(xs, ys, zs, zdir)
|
|
self.auto_scale_xyz(xs, ys, zs, had_data)
|
|
return lines
|
|
|
|
plot3D = plot
|
|
|
|
def plot_surface(self, X, Y, Z, *, norm=None, vmin=None,
|
|
vmax=None, lightsource=None, **kwargs):
|
|
"""
|
|
Create a surface plot.
|
|
|
|
By default, it will be colored in shades of a solid color, but it also
|
|
supports colormapping by supplying the *cmap* argument.
|
|
|
|
.. note::
|
|
|
|
The *rcount* and *ccount* kwargs, which both default to 50,
|
|
determine the maximum number of samples used in each direction. If
|
|
the input data is larger, it will be downsampled (by slicing) to
|
|
these numbers of points.
|
|
|
|
.. note::
|
|
|
|
To maximize rendering speed consider setting *rstride* and *cstride*
|
|
to divisors of the number of rows minus 1 and columns minus 1
|
|
respectively. For example, given 51 rows rstride can be any of the
|
|
divisors of 50.
|
|
|
|
Similarly, a setting of *rstride* and *cstride* equal to 1 (or
|
|
*rcount* and *ccount* equal the number of rows and columns) can use
|
|
the optimized path.
|
|
|
|
Parameters
|
|
----------
|
|
X, Y, Z : 2D arrays
|
|
Data values.
|
|
|
|
rcount, ccount : int
|
|
Maximum number of samples used in each direction. If the input
|
|
data is larger, it will be downsampled (by slicing) to these
|
|
numbers of points. Defaults to 50.
|
|
|
|
rstride, cstride : int
|
|
Downsampling stride in each direction. These arguments are
|
|
mutually exclusive with *rcount* and *ccount*. If only one of
|
|
*rstride* or *cstride* is set, the other defaults to 10.
|
|
|
|
'classic' mode uses a default of ``rstride = cstride = 10`` instead
|
|
of the new default of ``rcount = ccount = 50``.
|
|
|
|
color : color-like
|
|
Color of the surface patches.
|
|
|
|
cmap : Colormap
|
|
Colormap of the surface patches.
|
|
|
|
facecolors : array-like of colors.
|
|
Colors of each individual patch.
|
|
|
|
norm : Normalize
|
|
Normalization for the colormap.
|
|
|
|
vmin, vmax : float
|
|
Bounds for the normalization.
|
|
|
|
shade : bool, default: True
|
|
Whether to shade the facecolors. Shading is always disabled when
|
|
*cmap* is specified.
|
|
|
|
lightsource : `~matplotlib.colors.LightSource`
|
|
The lightsource to use when *shade* is True.
|
|
|
|
**kwargs
|
|
Other keyword arguments are forwarded to `.Poly3DCollection`.
|
|
"""
|
|
|
|
had_data = self.has_data()
|
|
|
|
if Z.ndim != 2:
|
|
raise ValueError("Argument Z must be 2-dimensional.")
|
|
|
|
Z = cbook._to_unmasked_float_array(Z)
|
|
X, Y, Z = np.broadcast_arrays(X, Y, Z)
|
|
rows, cols = Z.shape
|
|
|
|
has_stride = 'rstride' in kwargs or 'cstride' in kwargs
|
|
has_count = 'rcount' in kwargs or 'ccount' in kwargs
|
|
|
|
if has_stride and has_count:
|
|
raise ValueError("Cannot specify both stride and count arguments")
|
|
|
|
rstride = kwargs.pop('rstride', 10)
|
|
cstride = kwargs.pop('cstride', 10)
|
|
rcount = kwargs.pop('rcount', 50)
|
|
ccount = kwargs.pop('ccount', 50)
|
|
|
|
if mpl.rcParams['_internal.classic_mode']:
|
|
# Strides have priority over counts in classic mode.
|
|
# So, only compute strides from counts
|
|
# if counts were explicitly given
|
|
compute_strides = has_count
|
|
else:
|
|
# If the strides are provided then it has priority.
|
|
# Otherwise, compute the strides from the counts.
|
|
compute_strides = not has_stride
|
|
|
|
if compute_strides:
|
|
rstride = int(max(np.ceil(rows / rcount), 1))
|
|
cstride = int(max(np.ceil(cols / ccount), 1))
|
|
|
|
fcolors = kwargs.pop('facecolors', None)
|
|
|
|
cmap = kwargs.get('cmap', None)
|
|
shade = kwargs.pop('shade', cmap is None)
|
|
if shade is None:
|
|
raise ValueError("shade cannot be None.")
|
|
|
|
colset = [] # the sampled facecolor
|
|
if (rows - 1) % rstride == 0 and \
|
|
(cols - 1) % cstride == 0 and \
|
|
fcolors is None:
|
|
polys = np.stack(
|
|
[cbook._array_patch_perimeters(a, rstride, cstride)
|
|
for a in (X, Y, Z)],
|
|
axis=-1)
|
|
else:
|
|
# evenly spaced, and including both endpoints
|
|
row_inds = list(range(0, rows-1, rstride)) + [rows-1]
|
|
col_inds = list(range(0, cols-1, cstride)) + [cols-1]
|
|
|
|
polys = []
|
|
for rs, rs_next in zip(row_inds[:-1], row_inds[1:]):
|
|
for cs, cs_next in zip(col_inds[:-1], col_inds[1:]):
|
|
ps = [
|
|
# +1 ensures we share edges between polygons
|
|
cbook._array_perimeter(a[rs:rs_next+1, cs:cs_next+1])
|
|
for a in (X, Y, Z)
|
|
]
|
|
# ps = np.stack(ps, axis=-1)
|
|
ps = np.array(ps).T
|
|
polys.append(ps)
|
|
|
|
if fcolors is not None:
|
|
colset.append(fcolors[rs][cs])
|
|
|
|
# In cases where there are non-finite values in the data (possibly NaNs from
|
|
# masked arrays), artifacts can be introduced. Here check whether such values
|
|
# are present and remove them.
|
|
if not isinstance(polys, np.ndarray) or not np.isfinite(polys).all():
|
|
new_polys = []
|
|
new_colset = []
|
|
|
|
# Depending on fcolors, colset is either an empty list or has as
|
|
# many elements as polys. In the former case new_colset results in
|
|
# a list with None entries, that is discarded later.
|
|
for p, col in itertools.zip_longest(polys, colset):
|
|
new_poly = np.array(p)[np.isfinite(p).all(axis=1)]
|
|
if len(new_poly):
|
|
new_polys.append(new_poly)
|
|
new_colset.append(col)
|
|
|
|
# Replace previous polys and, if fcolors is not None, colset
|
|
polys = new_polys
|
|
if fcolors is not None:
|
|
colset = new_colset
|
|
|
|
# note that the striding causes some polygons to have more coordinates
|
|
# than others
|
|
|
|
if fcolors is not None:
|
|
polyc = art3d.Poly3DCollection(
|
|
polys, edgecolors=colset, facecolors=colset, shade=shade,
|
|
lightsource=lightsource, **kwargs)
|
|
elif cmap:
|
|
polyc = art3d.Poly3DCollection(polys, **kwargs)
|
|
# can't always vectorize, because polys might be jagged
|
|
if isinstance(polys, np.ndarray):
|
|
avg_z = polys[..., 2].mean(axis=-1)
|
|
else:
|
|
avg_z = np.array([ps[:, 2].mean() for ps in polys])
|
|
polyc.set_array(avg_z)
|
|
if vmin is not None or vmax is not None:
|
|
polyc.set_clim(vmin, vmax)
|
|
if norm is not None:
|
|
polyc.set_norm(norm)
|
|
else:
|
|
color = kwargs.pop('color', None)
|
|
if color is None:
|
|
color = self._get_lines.get_next_color()
|
|
color = np.array(mcolors.to_rgba(color))
|
|
|
|
polyc = art3d.Poly3DCollection(
|
|
polys, facecolors=color, shade=shade,
|
|
lightsource=lightsource, **kwargs)
|
|
|
|
self.add_collection(polyc)
|
|
self.auto_scale_xyz(X, Y, Z, had_data)
|
|
|
|
return polyc
|
|
|
|
def plot_wireframe(self, X, Y, Z, **kwargs):
|
|
"""
|
|
Plot a 3D wireframe.
|
|
|
|
.. note::
|
|
|
|
The *rcount* and *ccount* kwargs, which both default to 50,
|
|
determine the maximum number of samples used in each direction. If
|
|
the input data is larger, it will be downsampled (by slicing) to
|
|
these numbers of points.
|
|
|
|
Parameters
|
|
----------
|
|
X, Y, Z : 2D arrays
|
|
Data values.
|
|
|
|
rcount, ccount : int
|
|
Maximum number of samples used in each direction. If the input
|
|
data is larger, it will be downsampled (by slicing) to these
|
|
numbers of points. Setting a count to zero causes the data to be
|
|
not sampled in the corresponding direction, producing a 3D line
|
|
plot rather than a wireframe plot. Defaults to 50.
|
|
|
|
rstride, cstride : int
|
|
Downsampling stride in each direction. These arguments are
|
|
mutually exclusive with *rcount* and *ccount*. If only one of
|
|
*rstride* or *cstride* is set, the other defaults to 1. Setting a
|
|
stride to zero causes the data to be not sampled in the
|
|
corresponding direction, producing a 3D line plot rather than a
|
|
wireframe plot.
|
|
|
|
'classic' mode uses a default of ``rstride = cstride = 1`` instead
|
|
of the new default of ``rcount = ccount = 50``.
|
|
|
|
**kwargs
|
|
Other keyword arguments are forwarded to `.Line3DCollection`.
|
|
"""
|
|
|
|
had_data = self.has_data()
|
|
if Z.ndim != 2:
|
|
raise ValueError("Argument Z must be 2-dimensional.")
|
|
# FIXME: Support masked arrays
|
|
X, Y, Z = np.broadcast_arrays(X, Y, Z)
|
|
rows, cols = Z.shape
|
|
|
|
has_stride = 'rstride' in kwargs or 'cstride' in kwargs
|
|
has_count = 'rcount' in kwargs or 'ccount' in kwargs
|
|
|
|
if has_stride and has_count:
|
|
raise ValueError("Cannot specify both stride and count arguments")
|
|
|
|
rstride = kwargs.pop('rstride', 1)
|
|
cstride = kwargs.pop('cstride', 1)
|
|
rcount = kwargs.pop('rcount', 50)
|
|
ccount = kwargs.pop('ccount', 50)
|
|
|
|
if mpl.rcParams['_internal.classic_mode']:
|
|
# Strides have priority over counts in classic mode.
|
|
# So, only compute strides from counts
|
|
# if counts were explicitly given
|
|
if has_count:
|
|
rstride = int(max(np.ceil(rows / rcount), 1)) if rcount else 0
|
|
cstride = int(max(np.ceil(cols / ccount), 1)) if ccount else 0
|
|
else:
|
|
# If the strides are provided then it has priority.
|
|
# Otherwise, compute the strides from the counts.
|
|
if not has_stride:
|
|
rstride = int(max(np.ceil(rows / rcount), 1)) if rcount else 0
|
|
cstride = int(max(np.ceil(cols / ccount), 1)) if ccount else 0
|
|
|
|
# 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]
|
|
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]
|
|
else:
|
|
cii = []
|
|
|
|
if rstride == 0 and cstride == 0:
|
|
raise ValueError("Either rstride or cstride must be non zero")
|
|
|
|
# If the inputs were empty, then just
|
|
# reset everything.
|
|
if Z.size == 0:
|
|
rii = []
|
|
cii = []
|
|
|
|
xlines = [X[i] for i in rii]
|
|
ylines = [Y[i] for i in rii]
|
|
zlines = [Z[i] for i in rii]
|
|
|
|
txlines = [tX[i] for i in cii]
|
|
tylines = [tY[i] for i in cii]
|
|
tzlines = [tZ[i] for i in cii]
|
|
|
|
lines = ([list(zip(xl, yl, zl))
|
|
for xl, yl, zl in zip(xlines, ylines, zlines)]
|
|
+ [list(zip(xl, yl, zl))
|
|
for xl, yl, zl in zip(txlines, tylines, tzlines)])
|
|
|
|
linec = art3d.Line3DCollection(lines, **kwargs)
|
|
self.add_collection(linec)
|
|
self.auto_scale_xyz(X, Y, Z, had_data)
|
|
|
|
return linec
|
|
|
|
def plot_trisurf(self, *args, color=None, norm=None, vmin=None, vmax=None,
|
|
lightsource=None, **kwargs):
|
|
"""
|
|
Plot a triangulated surface.
|
|
|
|
The (optional) triangulation can be specified in one of two ways;
|
|
either::
|
|
|
|
plot_trisurf(triangulation, ...)
|
|
|
|
where triangulation is a `~matplotlib.tri.Triangulation` object, or::
|
|
|
|
plot_trisurf(X, Y, ...)
|
|
plot_trisurf(X, Y, triangles, ...)
|
|
plot_trisurf(X, Y, triangles=triangles, ...)
|
|
|
|
in which case a Triangulation object will be created. See
|
|
`.Triangulation` for an explanation of these possibilities.
|
|
|
|
The remaining arguments are::
|
|
|
|
plot_trisurf(..., Z)
|
|
|
|
where *Z* is the array of values to contour, one per point
|
|
in the triangulation.
|
|
|
|
Parameters
|
|
----------
|
|
X, Y, Z : array-like
|
|
Data values as 1D arrays.
|
|
color
|
|
Color of the surface patches.
|
|
cmap
|
|
A colormap for the surface patches.
|
|
norm : Normalize
|
|
An instance of Normalize to map values to colors.
|
|
vmin, vmax : float, default: None
|
|
Minimum and maximum value to map.
|
|
shade : bool, default: True
|
|
Whether to shade the facecolors. Shading is always disabled when
|
|
*cmap* is specified.
|
|
lightsource : `~matplotlib.colors.LightSource`
|
|
The lightsource to use when *shade* is True.
|
|
**kwargs
|
|
All other keyword arguments are passed on to
|
|
:class:`~mpl_toolkits.mplot3d.art3d.Poly3DCollection`
|
|
|
|
Examples
|
|
--------
|
|
.. plot:: gallery/mplot3d/trisurf3d.py
|
|
.. plot:: gallery/mplot3d/trisurf3d_2.py
|
|
"""
|
|
|
|
had_data = self.has_data()
|
|
|
|
# TODO: Support custom face colours
|
|
if color is None:
|
|
color = self._get_lines.get_next_color()
|
|
color = np.array(mcolors.to_rgba(color))
|
|
|
|
cmap = kwargs.get('cmap', None)
|
|
shade = kwargs.pop('shade', cmap is None)
|
|
|
|
tri, args, kwargs = \
|
|
Triangulation.get_from_args_and_kwargs(*args, **kwargs)
|
|
try:
|
|
z = kwargs.pop('Z')
|
|
except KeyError:
|
|
# We do this so Z doesn't get passed as an arg to PolyCollection
|
|
z, *args = args
|
|
z = np.asarray(z)
|
|
|
|
triangles = tri.get_masked_triangles()
|
|
xt = tri.x[triangles]
|
|
yt = tri.y[triangles]
|
|
zt = z[triangles]
|
|
verts = np.stack((xt, yt, zt), axis=-1)
|
|
|
|
if cmap:
|
|
polyc = art3d.Poly3DCollection(verts, *args, **kwargs)
|
|
# average over the three points of each triangle
|
|
avg_z = verts[:, :, 2].mean(axis=1)
|
|
polyc.set_array(avg_z)
|
|
if vmin is not None or vmax is not None:
|
|
polyc.set_clim(vmin, vmax)
|
|
if norm is not None:
|
|
polyc.set_norm(norm)
|
|
else:
|
|
polyc = art3d.Poly3DCollection(
|
|
verts, *args, shade=shade, lightsource=lightsource,
|
|
facecolors=color, **kwargs)
|
|
|
|
self.add_collection(polyc)
|
|
self.auto_scale_xyz(tri.x, tri.y, z, had_data)
|
|
|
|
return polyc
|
|
|
|
def _3d_extend_contour(self, cset, stride=5):
|
|
"""
|
|
Extend a contour in 3D by creating
|
|
"""
|
|
|
|
dz = (cset.levels[1] - cset.levels[0]) / 2
|
|
polyverts = []
|
|
colors = []
|
|
for idx, level in enumerate(cset.levels):
|
|
path = cset.get_paths()[idx]
|
|
subpaths = [*path._iter_connected_components()]
|
|
color = cset.get_edgecolor()[idx]
|
|
top = art3d._paths_to_3d_segments(subpaths, level - dz)
|
|
bot = art3d._paths_to_3d_segments(subpaths, level + dz)
|
|
if not len(top[0]):
|
|
continue
|
|
nsteps = max(round(len(top[0]) / stride), 2)
|
|
stepsize = (len(top[0]) - 1) / (nsteps - 1)
|
|
polyverts.extend([
|
|
(top[0][round(i * stepsize)], top[0][round((i + 1) * stepsize)],
|
|
bot[0][round((i + 1) * stepsize)], bot[0][round(i * stepsize)])
|
|
for i in range(round(nsteps) - 1)])
|
|
colors.extend([color] * (round(nsteps) - 1))
|
|
self.add_collection3d(art3d.Poly3DCollection(
|
|
np.array(polyverts), # All polygons have 4 vertices, so vectorize.
|
|
facecolors=colors, edgecolors=colors, shade=True))
|
|
cset.remove()
|
|
|
|
def add_contour_set(
|
|
self, cset, extend3d=False, stride=5, zdir='z', offset=None):
|
|
zdir = '-' + zdir
|
|
if extend3d:
|
|
self._3d_extend_contour(cset, stride)
|
|
else:
|
|
art3d.collection_2d_to_3d(
|
|
cset, zs=offset if offset is not None else cset.levels, zdir=zdir)
|
|
|
|
def add_contourf_set(self, cset, zdir='z', offset=None):
|
|
self._add_contourf_set(cset, zdir=zdir, offset=offset)
|
|
|
|
def _add_contourf_set(self, cset, zdir='z', offset=None):
|
|
"""
|
|
Returns
|
|
-------
|
|
levels : `numpy.ndarray`
|
|
Levels at which the filled contours are added.
|
|
"""
|
|
zdir = '-' + zdir
|
|
|
|
midpoints = cset.levels[:-1] + np.diff(cset.levels) / 2
|
|
# Linearly interpolate to get levels for any extensions
|
|
if cset._extend_min:
|
|
min_level = cset.levels[0] - np.diff(cset.levels[:2]) / 2
|
|
midpoints = np.insert(midpoints, 0, min_level)
|
|
if cset._extend_max:
|
|
max_level = cset.levels[-1] + np.diff(cset.levels[-2:]) / 2
|
|
midpoints = np.append(midpoints, max_level)
|
|
|
|
art3d.collection_2d_to_3d(
|
|
cset, zs=offset if offset is not None else midpoints, zdir=zdir)
|
|
return midpoints
|
|
|
|
@_preprocess_data()
|
|
def contour(self, X, Y, Z, *args,
|
|
extend3d=False, stride=5, zdir='z', offset=None, **kwargs):
|
|
"""
|
|
Create a 3D contour plot.
|
|
|
|
Parameters
|
|
----------
|
|
X, Y, Z : array-like,
|
|
Input data. See `.Axes.contour` for supported data shapes.
|
|
extend3d : bool, default: False
|
|
Whether to extend contour in 3D.
|
|
stride : int
|
|
Step size for extending contour.
|
|
zdir : {'x', 'y', 'z'}, default: 'z'
|
|
The direction to use.
|
|
offset : float, optional
|
|
If specified, plot a projection of the contour lines at this
|
|
position in a plane normal to *zdir*.
|
|
data : indexable object, optional
|
|
DATA_PARAMETER_PLACEHOLDER
|
|
|
|
*args, **kwargs
|
|
Other arguments are forwarded to `matplotlib.axes.Axes.contour`.
|
|
|
|
Returns
|
|
-------
|
|
matplotlib.contour.QuadContourSet
|
|
"""
|
|
had_data = self.has_data()
|
|
|
|
jX, jY, jZ = art3d.rotate_axes(X, Y, Z, zdir)
|
|
cset = super().contour(jX, jY, jZ, *args, **kwargs)
|
|
self.add_contour_set(cset, extend3d, stride, zdir, offset)
|
|
|
|
self.auto_scale_xyz(X, Y, Z, had_data)
|
|
return cset
|
|
|
|
contour3D = contour
|
|
|
|
@_preprocess_data()
|
|
def tricontour(self, *args,
|
|
extend3d=False, stride=5, zdir='z', offset=None, **kwargs):
|
|
"""
|
|
Create a 3D contour plot.
|
|
|
|
.. note::
|
|
This method currently produces incorrect output due to a
|
|
longstanding bug in 3D PolyCollection rendering.
|
|
|
|
Parameters
|
|
----------
|
|
X, Y, Z : array-like
|
|
Input data. See `.Axes.tricontour` for supported data shapes.
|
|
extend3d : bool, default: False
|
|
Whether to extend contour in 3D.
|
|
stride : int
|
|
Step size for extending contour.
|
|
zdir : {'x', 'y', 'z'}, default: 'z'
|
|
The direction to use.
|
|
offset : float, optional
|
|
If specified, plot a projection of the contour lines at this
|
|
position in a plane normal to *zdir*.
|
|
data : indexable object, optional
|
|
DATA_PARAMETER_PLACEHOLDER
|
|
*args, **kwargs
|
|
Other arguments are forwarded to `matplotlib.axes.Axes.tricontour`.
|
|
|
|
Returns
|
|
-------
|
|
matplotlib.tri._tricontour.TriContourSet
|
|
"""
|
|
had_data = self.has_data()
|
|
|
|
tri, args, kwargs = Triangulation.get_from_args_and_kwargs(
|
|
*args, **kwargs)
|
|
X = tri.x
|
|
Y = tri.y
|
|
if 'Z' in kwargs:
|
|
Z = kwargs.pop('Z')
|
|
else:
|
|
# We do this so Z doesn't get passed as an arg to Axes.tricontour
|
|
Z, *args = args
|
|
|
|
jX, jY, jZ = art3d.rotate_axes(X, Y, Z, zdir)
|
|
tri = Triangulation(jX, jY, tri.triangles, tri.mask)
|
|
|
|
cset = super().tricontour(tri, jZ, *args, **kwargs)
|
|
self.add_contour_set(cset, extend3d, stride, zdir, offset)
|
|
|
|
self.auto_scale_xyz(X, Y, Z, had_data)
|
|
return cset
|
|
|
|
def _auto_scale_contourf(self, X, Y, Z, zdir, levels, had_data):
|
|
# Autoscale in the zdir based on the levels added, which are
|
|
# different from data range if any contour extensions are present
|
|
dim_vals = {'x': X, 'y': Y, 'z': Z, zdir: levels}
|
|
# Input data and levels have different sizes, but auto_scale_xyz
|
|
# expected same-size input, so manually take min/max limits
|
|
limits = [(np.nanmin(dim_vals[dim]), np.nanmax(dim_vals[dim]))
|
|
for dim in ['x', 'y', 'z']]
|
|
self.auto_scale_xyz(*limits, had_data)
|
|
|
|
@_preprocess_data()
|
|
def contourf(self, X, Y, Z, *args, zdir='z', offset=None, **kwargs):
|
|
"""
|
|
Create a 3D filled contour plot.
|
|
|
|
Parameters
|
|
----------
|
|
X, Y, Z : array-like
|
|
Input data. See `.Axes.contourf` for supported data shapes.
|
|
zdir : {'x', 'y', 'z'}, default: 'z'
|
|
The direction to use.
|
|
offset : float, optional
|
|
If specified, plot a projection of the contour lines at this
|
|
position in a plane normal to *zdir*.
|
|
data : indexable object, optional
|
|
DATA_PARAMETER_PLACEHOLDER
|
|
*args, **kwargs
|
|
Other arguments are forwarded to `matplotlib.axes.Axes.contourf`.
|
|
|
|
Returns
|
|
-------
|
|
matplotlib.contour.QuadContourSet
|
|
"""
|
|
had_data = self.has_data()
|
|
|
|
jX, jY, jZ = art3d.rotate_axes(X, Y, Z, zdir)
|
|
cset = super().contourf(jX, jY, jZ, *args, **kwargs)
|
|
levels = self._add_contourf_set(cset, zdir, offset)
|
|
|
|
self._auto_scale_contourf(X, Y, Z, zdir, levels, had_data)
|
|
return cset
|
|
|
|
contourf3D = contourf
|
|
|
|
@_preprocess_data()
|
|
def tricontourf(self, *args, zdir='z', offset=None, **kwargs):
|
|
"""
|
|
Create a 3D filled contour plot.
|
|
|
|
.. note::
|
|
This method currently produces incorrect output due to a
|
|
longstanding bug in 3D PolyCollection rendering.
|
|
|
|
Parameters
|
|
----------
|
|
X, Y, Z : array-like
|
|
Input data. See `.Axes.tricontourf` for supported data shapes.
|
|
zdir : {'x', 'y', 'z'}, default: 'z'
|
|
The direction to use.
|
|
offset : float, optional
|
|
If specified, plot a projection of the contour lines at this
|
|
position in a plane normal to zdir.
|
|
data : indexable object, optional
|
|
DATA_PARAMETER_PLACEHOLDER
|
|
*args, **kwargs
|
|
Other arguments are forwarded to
|
|
`matplotlib.axes.Axes.tricontourf`.
|
|
|
|
Returns
|
|
-------
|
|
matplotlib.tri._tricontour.TriContourSet
|
|
"""
|
|
had_data = self.has_data()
|
|
|
|
tri, args, kwargs = Triangulation.get_from_args_and_kwargs(
|
|
*args, **kwargs)
|
|
X = tri.x
|
|
Y = tri.y
|
|
if 'Z' in kwargs:
|
|
Z = kwargs.pop('Z')
|
|
else:
|
|
# We do this so Z doesn't get passed as an arg to Axes.tricontourf
|
|
Z, *args = args
|
|
|
|
jX, jY, jZ = art3d.rotate_axes(X, Y, Z, zdir)
|
|
tri = Triangulation(jX, jY, tri.triangles, tri.mask)
|
|
|
|
cset = super().tricontourf(tri, jZ, *args, **kwargs)
|
|
levels = self._add_contourf_set(cset, zdir, offset)
|
|
|
|
self._auto_scale_contourf(X, Y, Z, zdir, levels, had_data)
|
|
return cset
|
|
|
|
def add_collection3d(self, col, zs=0, zdir='z'):
|
|
"""
|
|
Add a 3D collection object to the plot.
|
|
|
|
2D collection types are converted to a 3D version by
|
|
modifying the object and adding z coordinate information.
|
|
|
|
Supported are:
|
|
|
|
- PolyCollection
|
|
- LineCollection
|
|
- PatchCollection
|
|
"""
|
|
zvals = np.atleast_1d(zs)
|
|
zsortval = (np.min(zvals) if zvals.size
|
|
else 0) # FIXME: arbitrary default
|
|
|
|
# FIXME: use issubclass() (although, then a 3D collection
|
|
# object would also pass.) Maybe have a collection3d
|
|
# abstract class to test for and exclude?
|
|
if type(col) is mcoll.PolyCollection:
|
|
art3d.poly_collection_2d_to_3d(col, zs=zs, zdir=zdir)
|
|
col.set_sort_zpos(zsortval)
|
|
elif type(col) is mcoll.LineCollection:
|
|
art3d.line_collection_2d_to_3d(col, zs=zs, zdir=zdir)
|
|
col.set_sort_zpos(zsortval)
|
|
elif type(col) is mcoll.PatchCollection:
|
|
art3d.patch_collection_2d_to_3d(col, zs=zs, zdir=zdir)
|
|
col.set_sort_zpos(zsortval)
|
|
|
|
collection = super().add_collection(col)
|
|
return collection
|
|
|
|
@_preprocess_data(replace_names=["xs", "ys", "zs", "s",
|
|
"edgecolors", "c", "facecolor",
|
|
"facecolors", "color"])
|
|
def scatter(self, xs, ys, zs=0, zdir='z', s=20, c=None, depthshade=True,
|
|
*args, **kwargs):
|
|
"""
|
|
Create a scatter plot.
|
|
|
|
Parameters
|
|
----------
|
|
xs, ys : array-like
|
|
The data positions.
|
|
zs : float or array-like, default: 0
|
|
The z-positions. Either an array of the same length as *xs* and
|
|
*ys* or a single value to place all points in the same plane.
|
|
zdir : {'x', 'y', 'z', '-x', '-y', '-z'}, default: 'z'
|
|
The axis direction for the *zs*. This is useful when plotting 2D
|
|
data on a 3D Axes. The data must be passed as *xs*, *ys*. Setting
|
|
*zdir* to 'y' then plots the data to the x-z-plane.
|
|
|
|
See also :doc:`/gallery/mplot3d/2dcollections3d`.
|
|
|
|
s : float or array-like, default: 20
|
|
The marker size in points**2. Either an array of the same length
|
|
as *xs* and *ys* or a single value to make all markers the same
|
|
size.
|
|
c : color, sequence, or sequence of colors, optional
|
|
The marker color. Possible values:
|
|
|
|
- A single color format string.
|
|
- A sequence of colors of length n.
|
|
- A sequence of n numbers to be mapped to colors using *cmap* and
|
|
*norm*.
|
|
- 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
|
|
Whether to shade the scatter markers to give the appearance of
|
|
depth. Each call to ``scatter()`` will perform its depthshading
|
|
independently.
|
|
data : indexable object, optional
|
|
DATA_PARAMETER_PLACEHOLDER
|
|
**kwargs
|
|
All other keyword arguments are passed on to `~.axes.Axes.scatter`.
|
|
|
|
Returns
|
|
-------
|
|
paths : `~matplotlib.collections.PathCollection`
|
|
"""
|
|
|
|
had_data = self.has_data()
|
|
zs_orig = zs
|
|
|
|
xs, ys, zs = np.broadcast_arrays(
|
|
*[np.ravel(np.ma.filled(t, np.nan)) for t in [xs, ys, zs]])
|
|
s = np.ma.ravel(s) # This doesn't have to match x, y in size.
|
|
|
|
xs, ys, zs, s, c, color = cbook.delete_masked_points(
|
|
xs, ys, zs, s, c, kwargs.get('color', None)
|
|
)
|
|
if kwargs.get("color") is not None:
|
|
kwargs['color'] = color
|
|
|
|
# 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)
|
|
|
|
if self._zmargin < 0.05 and xs.size > 0:
|
|
self.set_zmargin(0.05)
|
|
|
|
self.auto_scale_xyz(xs, ys, zs, had_data)
|
|
|
|
return patches
|
|
|
|
scatter3D = scatter
|
|
|
|
@_preprocess_data()
|
|
def bar(self, left, height, zs=0, zdir='z', *args, **kwargs):
|
|
"""
|
|
Add 2D bar(s).
|
|
|
|
Parameters
|
|
----------
|
|
left : 1D array-like
|
|
The x coordinates of the left sides of the bars.
|
|
height : 1D array-like
|
|
The height of the bars.
|
|
zs : float or 1D array-like
|
|
Z coordinate of bars; if a single value is specified, it will be
|
|
used for all bars.
|
|
zdir : {'x', 'y', 'z'}, default: 'z'
|
|
When plotting 2D data, the direction to use as z ('x', 'y' or 'z').
|
|
data : indexable object, optional
|
|
DATA_PARAMETER_PLACEHOLDER
|
|
**kwargs
|
|
Other keyword arguments are forwarded to
|
|
`matplotlib.axes.Axes.bar`.
|
|
|
|
Returns
|
|
-------
|
|
mpl_toolkits.mplot3d.art3d.Patch3DCollection
|
|
"""
|
|
had_data = self.has_data()
|
|
|
|
patches = super().bar(left, height, *args, **kwargs)
|
|
|
|
zs = np.broadcast_to(zs, len(left))
|
|
|
|
verts = []
|
|
verts_zs = []
|
|
for p, z in zip(patches, zs):
|
|
vs = art3d._get_patch_verts(p)
|
|
verts += vs.tolist()
|
|
verts_zs += [z] * len(vs)
|
|
art3d.patch_2d_to_3d(p, z, zdir)
|
|
if 'alpha' in kwargs:
|
|
p.set_alpha(kwargs['alpha'])
|
|
|
|
if len(verts) > 0:
|
|
# the following has to be skipped if verts is empty
|
|
# NOTE: Bugs could still occur if len(verts) > 0,
|
|
# but the "2nd dimension" is empty.
|
|
xs, ys = zip(*verts)
|
|
else:
|
|
xs, ys = [], []
|
|
|
|
xs, ys, verts_zs = art3d.juggle_axes(xs, ys, verts_zs, zdir)
|
|
self.auto_scale_xyz(xs, ys, verts_zs, had_data)
|
|
|
|
return patches
|
|
|
|
@_preprocess_data()
|
|
def bar3d(self, x, y, z, dx, dy, dz, color=None,
|
|
zsort='average', shade=True, lightsource=None, *args, **kwargs):
|
|
"""
|
|
Generate a 3D barplot.
|
|
|
|
This method creates three-dimensional barplot where the width,
|
|
depth, height, and color of the bars can all be uniquely set.
|
|
|
|
Parameters
|
|
----------
|
|
x, y, z : array-like
|
|
The coordinates of the anchor point of the bars.
|
|
|
|
dx, dy, dz : float or array-like
|
|
The width, depth, and height of the bars, respectively.
|
|
|
|
color : sequence of colors, optional
|
|
The color of the bars can be specified globally or
|
|
individually. This parameter can be:
|
|
|
|
- A single color, to color all bars the same color.
|
|
- An array of colors of length N bars, to color each bar
|
|
independently.
|
|
- An array of colors of length 6, to color the faces of the
|
|
bars similarly.
|
|
- An array of colors of length 6 * N bars, to color each face
|
|
independently.
|
|
|
|
When coloring the faces of the boxes specifically, this is
|
|
the order of the coloring:
|
|
|
|
1. -Z (bottom of box)
|
|
2. +Z (top of box)
|
|
3. -Y
|
|
4. +Y
|
|
5. -X
|
|
6. +X
|
|
|
|
zsort : str, optional
|
|
The z-axis sorting scheme passed onto `~.art3d.Poly3DCollection`
|
|
|
|
shade : bool, default: True
|
|
When true, this shades the dark sides of the bars (relative
|
|
to the plot's source of light).
|
|
|
|
lightsource : `~matplotlib.colors.LightSource`
|
|
The lightsource to use when *shade* is True.
|
|
|
|
data : indexable object, optional
|
|
DATA_PARAMETER_PLACEHOLDER
|
|
|
|
**kwargs
|
|
Any additional keyword arguments are passed onto
|
|
`~.art3d.Poly3DCollection`.
|
|
|
|
Returns
|
|
-------
|
|
collection : `~.art3d.Poly3DCollection`
|
|
A collection of three-dimensional polygons representing the bars.
|
|
"""
|
|
|
|
had_data = self.has_data()
|
|
|
|
x, y, z, dx, dy, dz = np.broadcast_arrays(
|
|
np.atleast_1d(x), y, z, dx, dy, dz)
|
|
minx = np.min(x)
|
|
maxx = np.max(x + dx)
|
|
miny = np.min(y)
|
|
maxy = np.max(y + dy)
|
|
minz = np.min(z)
|
|
maxz = np.max(z + dz)
|
|
|
|
# shape (6, 4, 3)
|
|
# All faces are oriented facing outwards - when viewed from the
|
|
# outside, their vertices are in a counterclockwise ordering.
|
|
cuboid = np.array([
|
|
# -z
|
|
(
|
|
(0, 0, 0),
|
|
(0, 1, 0),
|
|
(1, 1, 0),
|
|
(1, 0, 0),
|
|
),
|
|
# +z
|
|
(
|
|
(0, 0, 1),
|
|
(1, 0, 1),
|
|
(1, 1, 1),
|
|
(0, 1, 1),
|
|
),
|
|
# -y
|
|
(
|
|
(0, 0, 0),
|
|
(1, 0, 0),
|
|
(1, 0, 1),
|
|
(0, 0, 1),
|
|
),
|
|
# +y
|
|
(
|
|
(0, 1, 0),
|
|
(0, 1, 1),
|
|
(1, 1, 1),
|
|
(1, 1, 0),
|
|
),
|
|
# -x
|
|
(
|
|
(0, 0, 0),
|
|
(0, 0, 1),
|
|
(0, 1, 1),
|
|
(0, 1, 0),
|
|
),
|
|
# +x
|
|
(
|
|
(1, 0, 0),
|
|
(1, 1, 0),
|
|
(1, 1, 1),
|
|
(1, 0, 1),
|
|
),
|
|
])
|
|
|
|
# indexed by [bar, face, vertex, coord]
|
|
polys = np.empty(x.shape + cuboid.shape)
|
|
|
|
# handle each coordinate separately
|
|
for i, p, dp in [(0, x, dx), (1, y, dy), (2, z, dz)]:
|
|
p = p[..., np.newaxis, np.newaxis]
|
|
dp = dp[..., np.newaxis, np.newaxis]
|
|
polys[..., i] = p + dp * cuboid[..., i]
|
|
|
|
# collapse the first two axes
|
|
polys = polys.reshape((-1,) + polys.shape[2:])
|
|
|
|
facecolors = []
|
|
if color is None:
|
|
color = [self._get_patches_for_fill.get_next_color()]
|
|
|
|
color = list(mcolors.to_rgba_array(color))
|
|
|
|
if len(color) == len(x):
|
|
# bar colors specified, need to expand to number of faces
|
|
for c in color:
|
|
facecolors.extend([c] * 6)
|
|
else:
|
|
# a single color specified, or face colors specified explicitly
|
|
facecolors = color
|
|
if len(facecolors) < len(x):
|
|
facecolors *= (6 * len(x))
|
|
|
|
col = art3d.Poly3DCollection(polys,
|
|
zsort=zsort,
|
|
facecolors=facecolors,
|
|
shade=shade,
|
|
lightsource=lightsource,
|
|
*args, **kwargs)
|
|
self.add_collection(col)
|
|
|
|
self.auto_scale_xyz((minx, maxx), (miny, maxy), (minz, maxz), had_data)
|
|
|
|
return col
|
|
|
|
def set_title(self, label, fontdict=None, loc='center', **kwargs):
|
|
# docstring inherited
|
|
ret = super().set_title(label, fontdict=fontdict, loc=loc, **kwargs)
|
|
(x, y) = self.title.get_position()
|
|
self.title.set_y(0.92 * y)
|
|
return ret
|
|
|
|
@_preprocess_data()
|
|
def quiver(self, X, Y, Z, U, V, W, *,
|
|
length=1, arrow_length_ratio=.3, pivot='tail', normalize=False,
|
|
**kwargs):
|
|
"""
|
|
Plot a 3D field of arrows.
|
|
|
|
The arguments can be array-like or scalars, so long as they can be
|
|
broadcast together. The arguments can also be masked arrays. If an
|
|
element in any of argument is masked, then that corresponding quiver
|
|
element will not be plotted.
|
|
|
|
Parameters
|
|
----------
|
|
X, Y, Z : array-like
|
|
The x, y and z coordinates of the arrow locations (default is
|
|
tail of arrow; see *pivot* kwarg).
|
|
|
|
U, V, W : array-like
|
|
The x, y and z components of the arrow vectors.
|
|
|
|
length : float, default: 1
|
|
The length of each quiver.
|
|
|
|
arrow_length_ratio : float, default: 0.3
|
|
The ratio of the arrow head with respect to the quiver.
|
|
|
|
pivot : {'tail', 'middle', 'tip'}, default: 'tail'
|
|
The part of the arrow that is at the grid point; the arrow
|
|
rotates about this point, hence the name *pivot*.
|
|
|
|
normalize : bool, default: False
|
|
Whether all arrows are normalized to have the same length, or keep
|
|
the lengths defined by *u*, *v*, and *w*.
|
|
|
|
data : indexable object, optional
|
|
DATA_PARAMETER_PLACEHOLDER
|
|
|
|
**kwargs
|
|
Any additional keyword arguments are delegated to
|
|
:class:`.Line3DCollection`
|
|
"""
|
|
|
|
def calc_arrows(UVW):
|
|
# get unit direction vector perpendicular to (u, v, w)
|
|
x = UVW[:, 0]
|
|
y = UVW[:, 1]
|
|
norm = np.linalg.norm(UVW[:, :2], axis=1)
|
|
x_p = np.divide(y, norm, where=norm != 0, out=np.zeros_like(x))
|
|
y_p = np.divide(-x, norm, where=norm != 0, out=np.ones_like(x))
|
|
# compute the two arrowhead direction unit vectors
|
|
rangle = math.radians(15)
|
|
c = math.cos(rangle)
|
|
s = math.sin(rangle)
|
|
# construct the rotation matrices of shape (3, 3, n)
|
|
r13 = y_p * s
|
|
r32 = x_p * s
|
|
r12 = x_p * y_p * (1 - c)
|
|
Rpos = np.array(
|
|
[[c + (x_p ** 2) * (1 - c), r12, r13],
|
|
[r12, c + (y_p ** 2) * (1 - c), -r32],
|
|
[-r13, r32, np.full_like(x_p, c)]])
|
|
# opposite rotation negates all the sin terms
|
|
Rneg = Rpos.copy()
|
|
Rneg[[0, 1, 2, 2], [2, 2, 0, 1]] *= -1
|
|
# Batch n (3, 3) x (3) matrix multiplications ((3, 3, n) x (n, 3)).
|
|
Rpos_vecs = np.einsum("ij...,...j->...i", Rpos, UVW)
|
|
Rneg_vecs = np.einsum("ij...,...j->...i", Rneg, UVW)
|
|
# Stack into (n, 2, 3) result.
|
|
return np.stack([Rpos_vecs, Rneg_vecs], axis=1)
|
|
|
|
had_data = self.has_data()
|
|
|
|
input_args = [X, Y, Z, U, V, W]
|
|
|
|
# extract the masks, if any
|
|
masks = [k.mask for k in input_args
|
|
if isinstance(k, np.ma.MaskedArray)]
|
|
# broadcast to match the shape
|
|
bcast = np.broadcast_arrays(*input_args, *masks)
|
|
input_args = bcast[:6]
|
|
masks = bcast[6:]
|
|
if masks:
|
|
# combine the masks into one
|
|
mask = functools.reduce(np.logical_or, masks)
|
|
# put mask on and compress
|
|
input_args = [np.ma.array(k, mask=mask).compressed()
|
|
for k in input_args]
|
|
else:
|
|
input_args = [np.ravel(k) for k in input_args]
|
|
|
|
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)
|
|
return linec
|
|
|
|
shaft_dt = np.array([0., length], dtype=float)
|
|
arrow_dt = shaft_dt * arrow_length_ratio
|
|
|
|
_api.check_in_list(['tail', 'middle', 'tip'], pivot=pivot)
|
|
if pivot == 'tail':
|
|
shaft_dt -= length
|
|
elif pivot == 'middle':
|
|
shaft_dt -= length / 2
|
|
|
|
XYZ = np.column_stack(input_args[:3])
|
|
UVW = np.column_stack(input_args[3:]).astype(float)
|
|
|
|
# Normalize rows of UVW
|
|
if normalize:
|
|
norm = np.linalg.norm(UVW, axis=1)
|
|
norm[norm == 0] = 1
|
|
UVW = UVW / norm.reshape((-1, 1))
|
|
|
|
if len(XYZ) > 0:
|
|
# compute the shaft lines all at once with an outer product
|
|
shafts = (XYZ - np.multiply.outer(shaft_dt, UVW)).swapaxes(0, 1)
|
|
# compute head direction vectors, n heads x 2 sides x 3 dimensions
|
|
head_dirs = calc_arrows(UVW)
|
|
# compute all head lines at once, starting from the shaft ends
|
|
heads = shafts[:, :1] - np.multiply.outer(arrow_dt, head_dirs)
|
|
# stack left and right head lines together
|
|
heads = heads.reshape((len(arrow_dt), -1, 3))
|
|
# transpose to get a list of lines
|
|
heads = heads.swapaxes(0, 1)
|
|
|
|
lines = [*shafts, *heads[::2], *heads[1::2]]
|
|
else:
|
|
lines = []
|
|
|
|
linec = art3d.Line3DCollection(lines, **kwargs)
|
|
self.add_collection(linec)
|
|
|
|
self.auto_scale_xyz(XYZ[:, 0], XYZ[:, 1], XYZ[:, 2], had_data)
|
|
|
|
return linec
|
|
|
|
quiver3D = quiver
|
|
|
|
def voxels(self, *args, facecolors=None, edgecolors=None, shade=True,
|
|
lightsource=None, **kwargs):
|
|
"""
|
|
ax.voxels([x, y, z,] /, filled, facecolors=None, edgecolors=None, \
|
|
**kwargs)
|
|
|
|
Plot a set of filled voxels
|
|
|
|
All voxels are plotted as 1x1x1 cubes on the axis, with
|
|
``filled[0, 0, 0]`` placed with its lower corner at the origin.
|
|
Occluded faces are not plotted.
|
|
|
|
Parameters
|
|
----------
|
|
filled : 3D np.array of bool
|
|
A 3D array of values, with truthy values indicating which voxels
|
|
to fill
|
|
|
|
x, y, z : 3D np.array, optional
|
|
The coordinates of the corners of the voxels. This should broadcast
|
|
to a shape one larger in every dimension than the shape of
|
|
*filled*. These can be used to plot non-cubic voxels.
|
|
|
|
If not specified, defaults to increasing integers along each axis,
|
|
like those returned by :func:`~numpy.indices`.
|
|
As indicated by the ``/`` in the function signature, these
|
|
arguments can only be passed positionally.
|
|
|
|
facecolors, edgecolors : array-like, optional
|
|
The color to draw the faces and edges of the voxels. Can only be
|
|
passed as keyword arguments.
|
|
These parameters can be:
|
|
|
|
- A single color value, to color all voxels the same color. This
|
|
can be either a string, or a 1D RGB/RGBA array
|
|
- ``None``, the default, to use a single color for the faces, and
|
|
the style default for the edges.
|
|
- A 3D `~numpy.ndarray` of color names, with each item the color
|
|
for the corresponding voxel. The size must match the voxels.
|
|
- A 4D `~numpy.ndarray` of RGB/RGBA data, with the components
|
|
along the last axis.
|
|
|
|
shade : bool, default: True
|
|
Whether to shade the facecolors.
|
|
|
|
lightsource : `~matplotlib.colors.LightSource`
|
|
The lightsource to use when *shade* is True.
|
|
|
|
**kwargs
|
|
Additional keyword arguments to pass onto
|
|
`~mpl_toolkits.mplot3d.art3d.Poly3DCollection`.
|
|
|
|
Returns
|
|
-------
|
|
faces : dict
|
|
A dictionary indexed by coordinate, where ``faces[i, j, k]`` is a
|
|
`.Poly3DCollection` of the faces drawn for the voxel
|
|
``filled[i, j, k]``. If no faces were drawn for a given voxel,
|
|
either because it was not asked to be drawn, or it is fully
|
|
occluded, then ``(i, j, k) not in faces``.
|
|
|
|
Examples
|
|
--------
|
|
.. plot:: gallery/mplot3d/voxels.py
|
|
.. plot:: gallery/mplot3d/voxels_rgb.py
|
|
.. plot:: gallery/mplot3d/voxels_torus.py
|
|
.. plot:: gallery/mplot3d/voxels_numpy_logo.py
|
|
"""
|
|
|
|
# work out which signature we should be using, and use it to parse
|
|
# the arguments. Name must be voxels for the correct error message
|
|
if len(args) >= 3:
|
|
# underscores indicate position only
|
|
def voxels(__x, __y, __z, filled, **kwargs):
|
|
return (__x, __y, __z), filled, kwargs
|
|
else:
|
|
def voxels(filled, **kwargs):
|
|
return None, filled, kwargs
|
|
|
|
xyz, filled, kwargs = voxels(*args, **kwargs)
|
|
|
|
# check dimensions
|
|
if filled.ndim != 3:
|
|
raise ValueError("Argument filled must be 3-dimensional")
|
|
size = np.array(filled.shape, dtype=np.intp)
|
|
|
|
# check xyz coordinates, which are one larger than the filled shape
|
|
coord_shape = tuple(size + 1)
|
|
if xyz is None:
|
|
x, y, z = np.indices(coord_shape)
|
|
else:
|
|
x, y, z = (np.broadcast_to(c, coord_shape) for c in xyz)
|
|
|
|
def _broadcast_color_arg(color, name):
|
|
if np.ndim(color) in (0, 1):
|
|
# single color, like "red" or [1, 0, 0]
|
|
return np.broadcast_to(color, filled.shape + np.shape(color))
|
|
elif np.ndim(color) in (3, 4):
|
|
# 3D array of strings, or 4D array with last axis rgb
|
|
if np.shape(color)[:3] != filled.shape:
|
|
raise ValueError(
|
|
f"When multidimensional, {name} must match the shape "
|
|
"of filled")
|
|
return color
|
|
else:
|
|
raise ValueError(f"Invalid {name} argument")
|
|
|
|
# broadcast and default on facecolors
|
|
if facecolors is None:
|
|
facecolors = self._get_patches_for_fill.get_next_color()
|
|
facecolors = _broadcast_color_arg(facecolors, 'facecolors')
|
|
|
|
# broadcast but no default on edgecolors
|
|
edgecolors = _broadcast_color_arg(edgecolors, 'edgecolors')
|
|
|
|
# scale to the full array, even if the data is only in the center
|
|
self.auto_scale_xyz(x, y, z)
|
|
|
|
# points lying on corners of a square
|
|
square = np.array([
|
|
[0, 0, 0],
|
|
[1, 0, 0],
|
|
[1, 1, 0],
|
|
[0, 1, 0],
|
|
], dtype=np.intp)
|
|
|
|
voxel_faces = defaultdict(list)
|
|
|
|
def permutation_matrices(n):
|
|
"""Generate cyclic permutation matrices."""
|
|
mat = np.eye(n, dtype=np.intp)
|
|
for i in range(n):
|
|
yield mat
|
|
mat = np.roll(mat, 1, axis=0)
|
|
|
|
# iterate over each of the YZ, ZX, and XY orientations, finding faces
|
|
# to render
|
|
for permute in permutation_matrices(3):
|
|
# find the set of ranges to iterate over
|
|
pc, qc, rc = permute.T.dot(size)
|
|
pinds = np.arange(pc)
|
|
qinds = np.arange(qc)
|
|
rinds = np.arange(rc)
|
|
|
|
square_rot_pos = square.dot(permute.T)
|
|
square_rot_neg = square_rot_pos[::-1]
|
|
|
|
# iterate within the current plane
|
|
for p in pinds:
|
|
for q in qinds:
|
|
# iterate perpendicularly to the current plane, handling
|
|
# boundaries. We only draw faces between a voxel and an
|
|
# empty space, to avoid drawing internal faces.
|
|
|
|
# draw lower faces
|
|
p0 = permute.dot([p, q, 0])
|
|
i0 = tuple(p0)
|
|
if filled[i0]:
|
|
voxel_faces[i0].append(p0 + square_rot_neg)
|
|
|
|
# draw middle faces
|
|
for r1, r2 in zip(rinds[:-1], rinds[1:]):
|
|
p1 = permute.dot([p, q, r1])
|
|
p2 = permute.dot([p, q, r2])
|
|
|
|
i1 = tuple(p1)
|
|
i2 = tuple(p2)
|
|
|
|
if filled[i1] and not filled[i2]:
|
|
voxel_faces[i1].append(p2 + square_rot_pos)
|
|
elif not filled[i1] and filled[i2]:
|
|
voxel_faces[i2].append(p2 + square_rot_neg)
|
|
|
|
# draw upper faces
|
|
pk = permute.dot([p, q, rc-1])
|
|
pk2 = permute.dot([p, q, rc])
|
|
ik = tuple(pk)
|
|
if filled[ik]:
|
|
voxel_faces[ik].append(pk2 + square_rot_pos)
|
|
|
|
# iterate over the faces, and generate a Poly3DCollection for each
|
|
# voxel
|
|
polygons = {}
|
|
for coord, faces_inds in voxel_faces.items():
|
|
# convert indices into 3D positions
|
|
if xyz is None:
|
|
faces = faces_inds
|
|
else:
|
|
faces = []
|
|
for face_inds in faces_inds:
|
|
ind = face_inds[:, 0], face_inds[:, 1], face_inds[:, 2]
|
|
face = np.empty(face_inds.shape)
|
|
face[:, 0] = x[ind]
|
|
face[:, 1] = y[ind]
|
|
face[:, 2] = z[ind]
|
|
faces.append(face)
|
|
|
|
# shade the faces
|
|
facecolor = facecolors[coord]
|
|
edgecolor = edgecolors[coord]
|
|
|
|
poly = art3d.Poly3DCollection(
|
|
faces, facecolors=facecolor, edgecolors=edgecolor,
|
|
shade=shade, lightsource=lightsource, **kwargs)
|
|
self.add_collection3d(poly)
|
|
polygons[coord] = poly
|
|
|
|
return polygons
|
|
|
|
@_preprocess_data(replace_names=["x", "y", "z", "xerr", "yerr", "zerr"])
|
|
def errorbar(self, x, y, z, zerr=None, yerr=None, xerr=None, fmt='',
|
|
barsabove=False, errorevery=1, ecolor=None, elinewidth=None,
|
|
capsize=None, capthick=None, xlolims=False, xuplims=False,
|
|
ylolims=False, yuplims=False, zlolims=False, zuplims=False,
|
|
**kwargs):
|
|
"""
|
|
Plot lines and/or markers with errorbars around them.
|
|
|
|
*x*/*y*/*z* define the data locations, and *xerr*/*yerr*/*zerr* define
|
|
the errorbar sizes. By default, this draws the data markers/lines as
|
|
well the errorbars. Use fmt='none' to draw errorbars only.
|
|
|
|
Parameters
|
|
----------
|
|
x, y, z : float or array-like
|
|
The data positions.
|
|
|
|
xerr, yerr, zerr : float or array-like, shape (N,) or (2, N), optional
|
|
The errorbar sizes:
|
|
|
|
- scalar: Symmetric +/- values for all data points.
|
|
- shape(N,): Symmetric +/-values for each data point.
|
|
- shape(2, N): Separate - and + values for each bar. First row
|
|
contains the lower errors, the second row contains the upper
|
|
errors.
|
|
- *None*: No errorbar.
|
|
|
|
Note that all error arrays should have *positive* values.
|
|
|
|
fmt : str, default: ''
|
|
The format for the data points / data lines. See `.plot` for
|
|
details.
|
|
|
|
Use 'none' (case-insensitive) to plot errorbars without any data
|
|
markers.
|
|
|
|
ecolor : color, default: None
|
|
The color of the errorbar lines. If None, use the color of the
|
|
line connecting the markers.
|
|
|
|
elinewidth : float, default: None
|
|
The linewidth of the errorbar lines. If None, the linewidth of
|
|
the current style is used.
|
|
|
|
capsize : float, default: :rc:`errorbar.capsize`
|
|
The length of the error bar caps in points.
|
|
|
|
capthick : float, default: None
|
|
An alias to the keyword argument *markeredgewidth* (a.k.a. *mew*).
|
|
This setting is a more sensible name for the property that
|
|
controls the thickness of the error bar cap in points. For
|
|
backwards compatibility, if *mew* or *markeredgewidth* are given,
|
|
then they will over-ride *capthick*. This may change in future
|
|
releases.
|
|
|
|
barsabove : bool, default: False
|
|
If True, will plot the errorbars above the plot
|
|
symbols. Default is below.
|
|
|
|
xlolims, ylolims, zlolims : bool, default: False
|
|
These arguments can be used to indicate that a value gives only
|
|
lower limits. In that case a caret symbol is used to indicate
|
|
this. *lims*-arguments may be scalars, or array-likes of the same
|
|
length as the errors. To use limits with inverted axes,
|
|
`~.Axes.set_xlim` or `~.Axes.set_ylim` must be called before
|
|
`errorbar`. Note the tricky parameter names: setting e.g.
|
|
*ylolims* to True means that the y-value is a *lower* limit of the
|
|
True value, so, only an *upward*-pointing arrow will be drawn!
|
|
|
|
xuplims, yuplims, zuplims : bool, default: False
|
|
Same as above, but for controlling the upper limits.
|
|
|
|
errorevery : int or (int, int), default: 1
|
|
draws error bars on a subset of the data. *errorevery* =N draws
|
|
error bars on the points (x[::N], y[::N], z[::N]).
|
|
*errorevery* =(start, N) draws error bars on the points
|
|
(x[start::N], y[start::N], z[start::N]). e.g. *errorevery* =(6, 3)
|
|
adds error bars to the data at (x[6], x[9], x[12], x[15], ...).
|
|
Used to avoid overlapping error bars when two series share x-axis
|
|
values.
|
|
|
|
Returns
|
|
-------
|
|
errlines : list
|
|
List of `~mpl_toolkits.mplot3d.art3d.Line3DCollection` instances
|
|
each containing an errorbar line.
|
|
caplines : list
|
|
List of `~mpl_toolkits.mplot3d.art3d.Line3D` instances each
|
|
containing a capline object.
|
|
limmarks : list
|
|
List of `~mpl_toolkits.mplot3d.art3d.Line3D` instances each
|
|
containing a marker with an upper or lower limit.
|
|
|
|
Other Parameters
|
|
----------------
|
|
data : indexable object, optional
|
|
DATA_PARAMETER_PLACEHOLDER
|
|
|
|
**kwargs
|
|
All other keyword arguments for styling errorbar lines are passed
|
|
`~mpl_toolkits.mplot3d.art3d.Line3DCollection`.
|
|
|
|
Examples
|
|
--------
|
|
.. plot:: gallery/mplot3d/errorbar3d.py
|
|
"""
|
|
had_data = self.has_data()
|
|
|
|
kwargs = cbook.normalize_kwargs(kwargs, mlines.Line2D)
|
|
# Drop anything that comes in as None to use the default instead.
|
|
kwargs = {k: v for k, v in kwargs.items() if v is not None}
|
|
kwargs.setdefault('zorder', 2)
|
|
|
|
self._process_unit_info([("x", x), ("y", y), ("z", z)], kwargs,
|
|
convert=False)
|
|
|
|
# make sure all the args are iterable; use lists not arrays to
|
|
# preserve units
|
|
x = x if np.iterable(x) else [x]
|
|
y = y if np.iterable(y) else [y]
|
|
z = z if np.iterable(z) else [z]
|
|
|
|
if not len(x) == len(y) == len(z):
|
|
raise ValueError("'x', 'y', and 'z' must have the same size")
|
|
|
|
everymask = self._errorevery_to_mask(x, errorevery)
|
|
|
|
label = kwargs.pop("label", None)
|
|
kwargs['label'] = '_nolegend_'
|
|
|
|
# Create the main line and determine overall kwargs for child artists.
|
|
# We avoid calling self.plot() directly, or self._get_lines(), because
|
|
# that would call self._process_unit_info again, and do other indirect
|
|
# data processing.
|
|
(data_line, base_style), = self._get_lines._plot_args(
|
|
self, (x, y) if fmt == '' else (x, y, fmt), kwargs, return_kwargs=True)
|
|
art3d.line_2d_to_3d(data_line, zs=z)
|
|
|
|
# Do this after creating `data_line` to avoid modifying `base_style`.
|
|
if barsabove:
|
|
data_line.set_zorder(kwargs['zorder'] - .1)
|
|
else:
|
|
data_line.set_zorder(kwargs['zorder'] + .1)
|
|
|
|
# Add line to plot, or throw it away and use it to determine kwargs.
|
|
if fmt.lower() != 'none':
|
|
self.add_line(data_line)
|
|
else:
|
|
data_line = None
|
|
# Remove alpha=0 color that _process_plot_format returns.
|
|
base_style.pop('color')
|
|
|
|
if 'color' not in base_style:
|
|
base_style['color'] = 'C0'
|
|
if ecolor is None:
|
|
ecolor = base_style['color']
|
|
|
|
# Eject any line-specific information from format string, as it's not
|
|
# needed for bars or caps.
|
|
for key in ['marker', 'markersize', 'markerfacecolor',
|
|
'markeredgewidth', 'markeredgecolor', 'markevery',
|
|
'linestyle', 'fillstyle', 'drawstyle', 'dash_capstyle',
|
|
'dash_joinstyle', 'solid_capstyle', 'solid_joinstyle']:
|
|
base_style.pop(key, None)
|
|
|
|
# Make the style dict for the line collections (the bars).
|
|
eb_lines_style = {**base_style, 'color': ecolor}
|
|
|
|
if elinewidth:
|
|
eb_lines_style['linewidth'] = elinewidth
|
|
elif 'linewidth' in kwargs:
|
|
eb_lines_style['linewidth'] = kwargs['linewidth']
|
|
|
|
for key in ('transform', 'alpha', 'zorder', 'rasterized'):
|
|
if key in kwargs:
|
|
eb_lines_style[key] = kwargs[key]
|
|
|
|
# Make the style dict for caps (the "hats").
|
|
eb_cap_style = {**base_style, 'linestyle': 'None'}
|
|
if capsize is None:
|
|
capsize = mpl.rcParams["errorbar.capsize"]
|
|
if capsize > 0:
|
|
eb_cap_style['markersize'] = 2. * capsize
|
|
if capthick is not None:
|
|
eb_cap_style['markeredgewidth'] = capthick
|
|
eb_cap_style['color'] = ecolor
|
|
|
|
def _apply_mask(arrays, mask):
|
|
# Return, for each array in *arrays*, the elements for which *mask*
|
|
# is True, without using fancy indexing.
|
|
return [[*itertools.compress(array, mask)] for array in arrays]
|
|
|
|
def _extract_errs(err, data, lomask, himask):
|
|
# For separate +/- error values we need to unpack err
|
|
if len(err.shape) == 2:
|
|
low_err, high_err = err
|
|
else:
|
|
low_err, high_err = err, err
|
|
|
|
lows = np.where(lomask | ~everymask, data, data - low_err)
|
|
highs = np.where(himask | ~everymask, data, data + high_err)
|
|
|
|
return lows, highs
|
|
|
|
# collect drawn items while looping over the three coordinates
|
|
errlines, caplines, limmarks = [], [], []
|
|
|
|
# list of endpoint coordinates, used for auto-scaling
|
|
coorderrs = []
|
|
|
|
# define the markers used for errorbar caps and limits below
|
|
# the dictionary key is mapped by the `i_xyz` helper dictionary
|
|
capmarker = {0: '|', 1: '|', 2: '_'}
|
|
i_xyz = {'x': 0, 'y': 1, 'z': 2}
|
|
|
|
# Calculate marker size from points to quiver length. Because these are
|
|
# not markers, and 3D Axes do not use the normal transform stack, this
|
|
# is a bit involved. Since the quiver arrows will change size as the
|
|
# scene is rotated, they are given a standard size based on viewing
|
|
# them directly in planar form.
|
|
quiversize = eb_cap_style.get('markersize',
|
|
mpl.rcParams['lines.markersize']) ** 2
|
|
quiversize *= self.figure.dpi / 72
|
|
quiversize = self.transAxes.inverted().transform([
|
|
(0, 0), (quiversize, quiversize)])
|
|
quiversize = np.mean(np.diff(quiversize, axis=0))
|
|
# quiversize is now in Axes coordinates, and to convert back to data
|
|
# coordinates, we need to run it through the inverse 3D transform. For
|
|
# consistency, this uses a fixed elevation, azimuth, and roll.
|
|
with cbook._setattr_cm(self, elev=0, azim=0, roll=0):
|
|
invM = np.linalg.inv(self.get_proj())
|
|
# elev=azim=roll=0 produces the Y-Z plane, so quiversize in 2D 'x' is
|
|
# 'y' in 3D, hence the 1 index.
|
|
quiversize = np.dot(invM, [quiversize, 0, 0, 0])[1]
|
|
# Quivers use a fixed 15-degree arrow head, so scale up the length so
|
|
# that the size corresponds to the base. In other words, this constant
|
|
# corresponds to the equation tan(15) = (base / 2) / (arrow length).
|
|
quiversize *= 1.8660254037844388
|
|
eb_quiver_style = {**eb_cap_style,
|
|
'length': quiversize, 'arrow_length_ratio': 1}
|
|
eb_quiver_style.pop('markersize', None)
|
|
|
|
# loop over x-, y-, and z-direction and draw relevant elements
|
|
for zdir, data, err, lolims, uplims in zip(
|
|
['x', 'y', 'z'], [x, y, z], [xerr, yerr, zerr],
|
|
[xlolims, ylolims, zlolims], [xuplims, yuplims, zuplims]):
|
|
|
|
dir_vector = art3d.get_dir_vector(zdir)
|
|
i_zdir = i_xyz[zdir]
|
|
|
|
if err is None:
|
|
continue
|
|
|
|
if not np.iterable(err):
|
|
err = [err] * len(data)
|
|
|
|
err = np.atleast_1d(err)
|
|
|
|
# arrays fine here, they are booleans and hence not units
|
|
lolims = np.broadcast_to(lolims, len(data)).astype(bool)
|
|
uplims = np.broadcast_to(uplims, len(data)).astype(bool)
|
|
|
|
# a nested list structure that expands to (xl,xh),(yl,yh),(zl,zh),
|
|
# where x/y/z and l/h correspond to dimensions and low/high
|
|
# positions of errorbars in a dimension we're looping over
|
|
coorderr = [
|
|
_extract_errs(err * dir_vector[i], coord, lolims, uplims)
|
|
for i, coord in enumerate([x, y, z])]
|
|
(xl, xh), (yl, yh), (zl, zh) = coorderr
|
|
|
|
# draws capmarkers - flat caps orthogonal to the error bars
|
|
nolims = ~(lolims | uplims)
|
|
if nolims.any() and capsize > 0:
|
|
lo_caps_xyz = _apply_mask([xl, yl, zl], nolims & everymask)
|
|
hi_caps_xyz = _apply_mask([xh, yh, zh], nolims & everymask)
|
|
|
|
# setting '_' for z-caps and '|' for x- and y-caps;
|
|
# these markers will rotate as the viewing angle changes
|
|
cap_lo = art3d.Line3D(*lo_caps_xyz, ls='',
|
|
marker=capmarker[i_zdir],
|
|
**eb_cap_style)
|
|
cap_hi = art3d.Line3D(*hi_caps_xyz, ls='',
|
|
marker=capmarker[i_zdir],
|
|
**eb_cap_style)
|
|
self.add_line(cap_lo)
|
|
self.add_line(cap_hi)
|
|
caplines.append(cap_lo)
|
|
caplines.append(cap_hi)
|
|
|
|
if lolims.any():
|
|
xh0, yh0, zh0 = _apply_mask([xh, yh, zh], lolims & everymask)
|
|
self.quiver(xh0, yh0, zh0, *dir_vector, **eb_quiver_style)
|
|
if uplims.any():
|
|
xl0, yl0, zl0 = _apply_mask([xl, yl, zl], uplims & everymask)
|
|
self.quiver(xl0, yl0, zl0, *-dir_vector, **eb_quiver_style)
|
|
|
|
errline = art3d.Line3DCollection(np.array(coorderr).T,
|
|
**eb_lines_style)
|
|
self.add_collection(errline)
|
|
errlines.append(errline)
|
|
coorderrs.append(coorderr)
|
|
|
|
coorderrs = np.array(coorderrs)
|
|
|
|
def _digout_minmax(err_arr, coord_label):
|
|
return (np.nanmin(err_arr[:, i_xyz[coord_label], :, :]),
|
|
np.nanmax(err_arr[:, i_xyz[coord_label], :, :]))
|
|
|
|
minx, maxx = _digout_minmax(coorderrs, 'x')
|
|
miny, maxy = _digout_minmax(coorderrs, 'y')
|
|
minz, maxz = _digout_minmax(coorderrs, 'z')
|
|
self.auto_scale_xyz((minx, maxx), (miny, maxy), (minz, maxz), had_data)
|
|
|
|
# Adapting errorbar containers for 3d case, assuming z-axis points "up"
|
|
errorbar_container = mcontainer.ErrorbarContainer(
|
|
(data_line, tuple(caplines), tuple(errlines)),
|
|
has_xerr=(xerr is not None or yerr is not None),
|
|
has_yerr=(zerr is not None),
|
|
label=label)
|
|
self.containers.append(errorbar_container)
|
|
|
|
return errlines, caplines, limmarks
|
|
|
|
@_api.make_keyword_only("3.8", "call_axes_locator")
|
|
def get_tightbbox(self, renderer=None, call_axes_locator=True,
|
|
bbox_extra_artists=None, *, for_layout_only=False):
|
|
ret = super().get_tightbbox(renderer,
|
|
call_axes_locator=call_axes_locator,
|
|
bbox_extra_artists=bbox_extra_artists,
|
|
for_layout_only=for_layout_only)
|
|
batch = [ret]
|
|
if self._axis3don:
|
|
for axis in self._axis_map.values():
|
|
if axis.get_visible():
|
|
axis_bb = martist._get_tightbbox_for_layout_only(
|
|
axis, renderer)
|
|
if axis_bb:
|
|
batch.append(axis_bb)
|
|
return mtransforms.Bbox.union(batch)
|
|
|
|
@_preprocess_data()
|
|
def stem(self, x, y, z, *, linefmt='C0-', markerfmt='C0o', basefmt='C3-',
|
|
bottom=0, label=None, orientation='z'):
|
|
"""
|
|
Create a 3D stem plot.
|
|
|
|
A stem plot draws lines perpendicular to a baseline, and places markers
|
|
at the heads. By default, the baseline is defined by *x* and *y*, and
|
|
stems are drawn vertically from *bottom* to *z*.
|
|
|
|
Parameters
|
|
----------
|
|
x, y, z : array-like
|
|
The positions of the heads of the stems. The stems are drawn along
|
|
the *orientation*-direction from the baseline at *bottom* (in the
|
|
*orientation*-coordinate) to the heads. By default, the *x* and *y*
|
|
positions are used for the baseline and *z* for the head position,
|
|
but this can be changed by *orientation*.
|
|
|
|
linefmt : str, default: 'C0-'
|
|
A string defining the properties of the vertical lines. Usually,
|
|
this will be a color or a color and a linestyle:
|
|
|
|
========= =============
|
|
Character Line Style
|
|
========= =============
|
|
``'-'`` solid line
|
|
``'--'`` dashed line
|
|
``'-.'`` dash-dot line
|
|
``':'`` dotted line
|
|
========= =============
|
|
|
|
Note: While it is technically possible to specify valid formats
|
|
other than color or color and linestyle (e.g. 'rx' or '-.'), this
|
|
is beyond the intention of the method and will most likely not
|
|
result in a reasonable plot.
|
|
|
|
markerfmt : str, default: 'C0o'
|
|
A string defining the properties of the markers at the stem heads.
|
|
|
|
basefmt : str, default: 'C3-'
|
|
A format string defining the properties of the baseline.
|
|
|
|
bottom : float, default: 0
|
|
The position of the baseline, in *orientation*-coordinates.
|
|
|
|
label : str, default: None
|
|
The label to use for the stems in legends.
|
|
|
|
orientation : {'x', 'y', 'z'}, default: 'z'
|
|
The direction along which stems are drawn.
|
|
|
|
data : indexable object, optional
|
|
DATA_PARAMETER_PLACEHOLDER
|
|
|
|
Returns
|
|
-------
|
|
`.StemContainer`
|
|
The container may be treated like a tuple
|
|
(*markerline*, *stemlines*, *baseline*)
|
|
|
|
Examples
|
|
--------
|
|
.. plot:: gallery/mplot3d/stem3d_demo.py
|
|
"""
|
|
|
|
from matplotlib.container import StemContainer
|
|
|
|
had_data = self.has_data()
|
|
|
|
_api.check_in_list(['x', 'y', 'z'], orientation=orientation)
|
|
|
|
xlim = (np.min(x), np.max(x))
|
|
ylim = (np.min(y), np.max(y))
|
|
zlim = (np.min(z), np.max(z))
|
|
|
|
# Determine the appropriate plane for the baseline and the direction of
|
|
# stemlines based on the value of orientation.
|
|
if orientation == 'x':
|
|
basex, basexlim = y, ylim
|
|
basey, baseylim = z, zlim
|
|
lines = [[(bottom, thisy, thisz), (thisx, thisy, thisz)]
|
|
for thisx, thisy, thisz in zip(x, y, z)]
|
|
elif orientation == 'y':
|
|
basex, basexlim = x, xlim
|
|
basey, baseylim = z, zlim
|
|
lines = [[(thisx, bottom, thisz), (thisx, thisy, thisz)]
|
|
for thisx, thisy, thisz in zip(x, y, z)]
|
|
else:
|
|
basex, basexlim = x, xlim
|
|
basey, baseylim = y, ylim
|
|
lines = [[(thisx, thisy, bottom), (thisx, thisy, thisz)]
|
|
for thisx, thisy, thisz in zip(x, y, z)]
|
|
|
|
# Determine style for stem lines.
|
|
linestyle, linemarker, linecolor = _process_plot_format(linefmt)
|
|
if linestyle is None:
|
|
linestyle = mpl.rcParams['lines.linestyle']
|
|
|
|
# Plot everything in required order.
|
|
baseline, = self.plot(basex, basey, basefmt, zs=bottom,
|
|
zdir=orientation, label='_nolegend_')
|
|
stemlines = art3d.Line3DCollection(
|
|
lines, linestyles=linestyle, colors=linecolor, label='_nolegend_')
|
|
self.add_collection(stemlines)
|
|
markerline, = self.plot(x, y, z, markerfmt, label='_nolegend_')
|
|
|
|
stem_container = StemContainer((markerline, stemlines, baseline),
|
|
label=label)
|
|
self.add_container(stem_container)
|
|
|
|
jx, jy, jz = art3d.juggle_axes(basexlim, baseylim, [bottom, bottom],
|
|
orientation)
|
|
self.auto_scale_xyz([*jx, *xlim], [*jy, *ylim], [*jz, *zlim], had_data)
|
|
|
|
return stem_container
|
|
|
|
stem3D = stem
|
|
|
|
|
|
def get_test_data(delta=0.05):
|
|
"""Return a tuple X, Y, Z with a test data set."""
|
|
x = y = np.arange(-3.0, 3.0, delta)
|
|
X, Y = np.meshgrid(x, y)
|
|
|
|
Z1 = np.exp(-(X**2 + Y**2) / 2) / (2 * np.pi)
|
|
Z2 = (np.exp(-(((X - 1) / 1.5)**2 + ((Y - 1) / 0.5)**2) / 2) /
|
|
(2 * np.pi * 0.5 * 1.5))
|
|
Z = Z2 - Z1
|
|
|
|
X = X * 10
|
|
Y = Y * 10
|
|
Z = Z * 500
|
|
return X, Y, Z
|