diff --git a/doc/missing-references.json b/doc/missing-references.json index a93a03b6ef73..2e4b482d845e 100644 --- a/doc/missing-references.json +++ b/doc/missing-references.json @@ -325,7 +325,7 @@ "lib/matplotlib/quiver.py:docstring of matplotlib.quiver.Barbs:212", "lib/matplotlib/quiver.py:docstring of matplotlib.quiver.Quiver:251", "lib/mpl_toolkits/mplot3d/art3d.py:docstring of matplotlib.artist.Path3DCollection.set:46", - "lib/mpl_toolkits/mplot3d/art3d.py:docstring of matplotlib.artist.Poly3DCollection.set:44" + "lib/mpl_toolkits/mplot3d/art3d.py:docstring of matplotlib.artist.Poly3DCollection.set:45" ], "matplotlib.collections._MeshData.set_array": [ "lib/matplotlib/axes/_axes.py:docstring of matplotlib.axes._axes.Axes.pcolormesh:164", diff --git a/doc/users/next_whats_new/3d_clip_to_axis_limits.rst b/doc/users/next_whats_new/3d_clip_to_axis_limits.rst new file mode 100644 index 000000000000..d97ba1b675ba --- /dev/null +++ b/doc/users/next_whats_new/3d_clip_to_axis_limits.rst @@ -0,0 +1,30 @@ +Data in 3D plots can now be dynamically clipped to the axes view limits +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +All 3D plotting functions now support the *axlim_clip* keyword argument, which +will clip the data to the axes view limits, hiding all data outside those +bounds. This clipping will be dynamically applied in real time while panning +and zooming. + +Please note that if one vertex of a line segment or 3D patch is clipped, then +the entire segment or patch will be hidden. Not being able to show partial +lines or patches such that they are "smoothly" cut off at the boundaries of the +view box is a limitation of the current renderer. + +.. plot:: + :include-source: true + :alt: Example of default behavior (left) and axlim_clip=True (right) + + import matplotlib.pyplot as plt + import numpy as np + + fig, ax = plt.subplots(subplot_kw={"projection": "3d"}) + np.random.seed(1) + xyz = np.random.rand(25, 3) + + # Note that when a line has one vertex outside the view limits, the entire + # line is hidden. The same is true for 3D patches (not shown). + ax.plot(xyz[:, 0], xyz[:, 1], xyz[:, 2], '-o') + ax.plot(xyz[:, 0], xyz[:, 1], xyz[:, 2], '--*', axlim_clip=True) + ax.set(xlim=(0.25, 0.75), ylim=(0, 1), zlim=(0, 1)) + ax.legend(['axlim_clip=False (default)', 'axlim_clip=True']) diff --git a/galleries/examples/mplot3d/axlim_clip.py b/galleries/examples/mplot3d/axlim_clip.py new file mode 100644 index 000000000000..b25c55a30ad1 --- /dev/null +++ b/galleries/examples/mplot3d/axlim_clip.py @@ -0,0 +1,31 @@ +""" +===================================== +Clip the data to the axes view limits +===================================== + +Demonstrate clipping of line and marker data to the axes view limits. The +``axlim_clip`` keyword argument can be used in any of the 3D plotting +functions. +""" + +import matplotlib.pyplot as plt +import numpy as np + +fig, ax = plt.subplots(subplot_kw={"projection": "3d"}) + +# Generate the random data +np.random.seed(1) +xyz = np.random.rand(25, 3) + +# Default behavior is axlim_clip=False +ax.plot(xyz[:, 0], xyz[:, 1], xyz[:, 2], '-o') + +# When axlim_clip=True, note that when a line segment has one vertex outside +# the view limits, the entire line is hidden. The same is true for 3D patches +# if one of their vertices is outside the limits (not shown). +ax.plot(xyz[:, 0], xyz[:, 1], xyz[:, 2], '--*', axlim_clip=True) + +ax.set(xlim=(0.25, 0.75), ylim=(0, 1), zlim=(-1, 1)) +ax.legend(['axlim_clip=False (default)', 'axlim_clip=True']) + +plt.show() diff --git a/galleries/users_explain/toolkits/mplot3d.rst b/galleries/users_explain/toolkits/mplot3d.rst index 100449f23a0e..b4ddc48790cb 100644 --- a/galleries/users_explain/toolkits/mplot3d.rst +++ b/galleries/users_explain/toolkits/mplot3d.rst @@ -121,6 +121,8 @@ See `.Axes3D.fill_between` for API documentation. :target: /gallery/mplot3d/fillbetween3d.html :align: center +.. versionadded:: 3.10 + .. _polygon3d: Polygon plots diff --git a/lib/matplotlib/text.py b/lib/matplotlib/text.py index af990ec1bf9f..d03b9336bca3 100644 --- a/lib/matplotlib/text.py +++ b/lib/matplotlib/text.py @@ -753,9 +753,16 @@ def draw(self, renderer): # don't use self.get_position here, which refers to text # position in Text: - posx = float(self.convert_xunits(self._x)) - posy = float(self.convert_yunits(self._y)) + x, y = self._x, self._y + if np.ma.is_masked(x): + x = np.nan + if np.ma.is_masked(y): + y = np.nan + posx = float(self.convert_xunits(x)) + posy = float(self.convert_yunits(y)) posx, posy = trans.transform((posx, posy)) + if np.isnan(posx) or np.isnan(posy): + return # don't throw a warning here if not np.isfinite(posx) or not np.isfinite(posy): _log.warning("posx and posy should be finite values") return diff --git a/lib/mpl_toolkits/mplot3d/art3d.py b/lib/mpl_toolkits/mplot3d/art3d.py index 38ebe88dc80e..74106cfdf91b 100644 --- a/lib/mpl_toolkits/mplot3d/art3d.py +++ b/lib/mpl_toolkits/mplot3d/art3d.py @@ -73,6 +73,34 @@ def get_dir_vector(zdir): raise ValueError("'x', 'y', 'z', None or vector of length 3 expected") +def _viewlim_mask(xs, ys, zs, axes): + """ + Return original points with points outside the axes view limits masked. + + Parameters + ---------- + xs, ys, zs : array-like + The points to mask. + axes : Axes3D + The axes to use for the view limits. + + Returns + ------- + xs_masked, ys_masked, zs_masked : np.ma.array + The masked points. + """ + mask = np.logical_or.reduce((xs < axes.xy_viewLim.xmin, + xs > axes.xy_viewLim.xmax, + ys < axes.xy_viewLim.ymin, + ys > axes.xy_viewLim.ymax, + zs < axes.zz_viewLim.xmin, + zs > axes.zz_viewLim.xmax)) + xs_masked = np.ma.array(xs, mask=mask) + ys_masked = np.ma.array(ys, mask=mask) + zs_masked = np.ma.array(zs, mask=mask) + return xs_masked, ys_masked, zs_masked + + class Text3D(mtext.Text): """ Text object with 3D position and direction. @@ -86,6 +114,8 @@ class Text3D(mtext.Text): zdir : {'x', 'y', 'z', None, 3-tuple} The direction of the text. See `.get_dir_vector` for a description of the values. + axlim_clip : bool, default: False + Whether to hide text outside the axes view limits. Other Parameters ---------------- @@ -93,9 +123,10 @@ class Text3D(mtext.Text): All other parameters are passed on to `~matplotlib.text.Text`. """ - def __init__(self, x=0, y=0, z=0, text='', zdir='z', **kwargs): + def __init__(self, x=0, y=0, z=0, text='', zdir='z', axlim_clip=False, + **kwargs): mtext.Text.__init__(self, x, y, text, **kwargs) - self.set_3d_properties(z, zdir) + self.set_3d_properties(z, zdir, axlim_clip) def get_position_3d(self): """Return the (x, y, z) position of the text.""" @@ -129,7 +160,7 @@ def set_z(self, z): self._z = z self.stale = True - def set_3d_properties(self, z=0, zdir='z'): + def set_3d_properties(self, z=0, zdir='z', axlim_clip=False): """ Set the *z* position and direction of the text. @@ -140,14 +171,23 @@ def set_3d_properties(self, z=0, zdir='z'): zdir : {'x', 'y', 'z', 3-tuple} The direction of the text. Default: 'z'. See `.get_dir_vector` for a description of the values. + axlim_clip : bool, default: False + Whether to hide text outside the axes view limits. """ self._z = z self._dir_vec = get_dir_vector(zdir) + self._axlim_clip = axlim_clip self.stale = True @artist.allow_rasterization def draw(self, renderer): - position3d = np.array((self._x, self._y, self._z)) + if self._axlim_clip: + xs, ys, zs = _viewlim_mask(self._x, self._y, self._z, self.axes) + position3d = np.ma.row_stack((xs, ys, zs)).ravel().filled(np.nan) + else: + xs, ys, zs = self._x, self._y, self._z + position3d = np.asanyarray([xs, ys, zs]) + proj = proj3d._proj_trans_points( [position3d, position3d + self._dir_vec], self.axes.M) dx = proj[0][1] - proj[0][0] @@ -164,7 +204,7 @@ def get_tightbbox(self, renderer=None): return None -def text_2d_to_3d(obj, z=0, zdir='z'): +def text_2d_to_3d(obj, z=0, zdir='z', axlim_clip=False): """ Convert a `.Text` to a `.Text3D` object. @@ -175,9 +215,11 @@ def text_2d_to_3d(obj, z=0, zdir='z'): zdir : {'x', 'y', 'z', 3-tuple} The direction of the text. Default: 'z'. See `.get_dir_vector` for a description of the values. + axlim_clip : bool, default: False + Whether to hide text outside the axes view limits. """ obj.__class__ = Text3D - obj.set_3d_properties(z, zdir) + obj.set_3d_properties(z, zdir, axlim_clip) class Line3D(lines.Line2D): @@ -191,7 +233,7 @@ class Line3D(lines.Line2D): `~.Line2D.set_data`, `~.Line2D.set_xdata`, and `~.Line2D.set_ydata`. """ - def __init__(self, xs, ys, zs, *args, **kwargs): + def __init__(self, xs, ys, zs, *args, axlim_clip=False, **kwargs): """ Parameters @@ -207,8 +249,9 @@ def __init__(self, xs, ys, zs, *args, **kwargs): """ super().__init__([], [], *args, **kwargs) self.set_data_3d(xs, ys, zs) + self._axlim_clip = axlim_clip - def set_3d_properties(self, zs=0, zdir='z'): + def set_3d_properties(self, zs=0, zdir='z', axlim_clip=False): """ Set the *z* position and direction of the line. @@ -220,12 +263,15 @@ def set_3d_properties(self, zs=0, zdir='z'): zdir : {'x', 'y', 'z'} Plane to plot line orthogonal to. Default: 'z'. See `.get_dir_vector` for a description of the values. + axlim_clip : bool, default: False + Whether to hide lines with an endpoint outside the axes view limits. """ xs = self.get_xdata() ys = self.get_ydata() zs = cbook._to_unmasked_float_array(zs).ravel() zs = np.broadcast_to(zs, len(xs)) self._verts3d = juggle_axes(xs, ys, zs, zdir) + self._axlim_clip = axlim_clip self.stale = True def set_data_3d(self, *args): @@ -266,7 +312,10 @@ def get_data_3d(self): @artist.allow_rasterization def draw(self, renderer): - xs3d, ys3d, zs3d = self._verts3d + if self._axlim_clip: + xs3d, ys3d, zs3d = _viewlim_mask(*self._verts3d, self.axes) + else: + xs3d, ys3d, zs3d = self._verts3d xs, ys, zs, tis = proj3d._proj_transform_clip(xs3d, ys3d, zs3d, self.axes.M, self.axes._focal_length) @@ -275,7 +324,7 @@ def draw(self, renderer): self.stale = False -def line_2d_to_3d(line, zs=0, zdir='z'): +def line_2d_to_3d(line, zs=0, zdir='z', axlim_clip=False): """ Convert a `.Line2D` to a `.Line3D` object. @@ -286,10 +335,12 @@ def line_2d_to_3d(line, zs=0, zdir='z'): zdir : {'x', 'y', 'z'} Plane to plot line orthogonal to. Default: 'z'. See `.get_dir_vector` for a description of the values. + axlim_clip : bool, default: False + Whether to hide lines with an endpoint outside the axes view limits. """ line.__class__ = Line3D - line.set_3d_properties(zs, zdir) + line.set_3d_properties(zs, zdir, axlim_clip) def _path_to_3d_segment(path, zs=0, zdir='z'): @@ -351,15 +402,18 @@ class Collection3D(Collection): def do_3d_projection(self): """Project the points according to renderer matrix.""" - xyzs_list = [proj3d.proj_transform(*vs.T, self.axes.M) - for vs, _ in self._3dverts_codes] - self._paths = [mpath.Path(np.column_stack([xs, ys]), cs) + vs_list = [vs for vs, _ in self._3dverts_codes] + if self._axlim_clip: + vs_list = [np.ma.row_stack(_viewlim_mask(*vs.T, self.axes)).T + for vs in vs_list] + xyzs_list = [proj3d.proj_transform(*vs.T, self.axes.M) for vs in vs_list] + self._paths = [mpath.Path(np.ma.column_stack([xs, ys]), cs) for (xs, ys, _), (_, cs) in zip(xyzs_list, self._3dverts_codes)] zs = np.concatenate([zs for _, _, zs in xyzs_list]) return zs.min() if len(zs) else 1e9 -def collection_2d_to_3d(col, zs=0, zdir='z'): +def collection_2d_to_3d(col, zs=0, zdir='z', axlim_clip=False): """Convert a `.Collection` to a `.Collection3D` object.""" zs = np.broadcast_to(zs, len(col.get_paths())) col._3dverts_codes = [ @@ -369,12 +423,16 @@ def collection_2d_to_3d(col, zs=0, zdir='z'): p.codes) for p, z in zip(col.get_paths(), zs)] col.__class__ = cbook._make_class_factory(Collection3D, "{}3D")(type(col)) + col._axlim_clip = axlim_clip class Line3DCollection(LineCollection): """ A collection of 3D lines. """ + def __init__(self, lines, axlim_clip=False, **kwargs): + super().__init__(lines, **kwargs) + self._axlim_clip = axlim_clip def set_sort_zpos(self, val): """Set the position to use for z-sorting.""" @@ -392,9 +450,16 @@ def do_3d_projection(self): """ Project the points according to renderer matrix. """ + segments = self._segments3d + if self._axlim_clip: + all_points = np.ma.vstack(segments) + masked_points = np.ma.column_stack([*_viewlim_mask(*all_points.T, + self.axes)]) + segment_lengths = [segment.shape[0] for segment in segments] + segments = np.split(masked_points, np.cumsum(segment_lengths[:-1])) xyslist = [proj3d._proj_trans_points(points, self.axes.M) - for points in self._segments3d] - segments_2d = [np.column_stack([xs, ys]) for xs, ys, zs in xyslist] + for points in segments] + segments_2d = [np.ma.column_stack([xs, ys]) for xs, ys, zs in xyslist] LineCollection.set_segments(self, segments_2d) # FIXME @@ -404,11 +469,12 @@ def do_3d_projection(self): return minz -def line_collection_2d_to_3d(col, zs=0, zdir='z'): +def line_collection_2d_to_3d(col, zs=0, zdir='z', axlim_clip=False): """Convert a `.LineCollection` to a `.Line3DCollection` object.""" segments3d = _paths_to_3d_segments(col.get_paths(), zs, zdir) col.__class__ = Line3DCollection col.set_segments(segments3d) + col._axlim_clip = axlim_clip class Patch3D(Patch): @@ -416,7 +482,7 @@ class Patch3D(Patch): 3D patch object. """ - def __init__(self, *args, zs=(), zdir='z', **kwargs): + def __init__(self, *args, zs=(), zdir='z', axlim_clip=False, **kwargs): """ Parameters ---------- @@ -427,11 +493,13 @@ def __init__(self, *args, zs=(), zdir='z', **kwargs): zdir : {'x', 'y', 'z'} Plane to plot patch orthogonal to. Default: 'z'. See `.get_dir_vector` for a description of the values. + axlim_clip : bool, default: False + Whether to hide patches with a vertex outside the axes view limits. """ super().__init__(*args, **kwargs) - self.set_3d_properties(zs, zdir) + self.set_3d_properties(zs, zdir, axlim_clip) - def set_3d_properties(self, verts, zs=0, zdir='z'): + def set_3d_properties(self, verts, zs=0, zdir='z', axlim_clip=False): """ Set the *z* position and direction of the patch. @@ -444,10 +512,13 @@ def set_3d_properties(self, verts, zs=0, zdir='z'): zdir : {'x', 'y', 'z'} Plane to plot patch orthogonal to. Default: 'z'. See `.get_dir_vector` for a description of the values. + axlim_clip : bool, default: False + Whether to hide patches with a vertex outside the axes view limits. """ zs = np.broadcast_to(zs, len(verts)) self._segment3d = [juggle_axes(x, y, z, zdir) for ((x, y), z) in zip(verts, zs)] + self._axlim_clip = axlim_clip def get_path(self): # docstring inherited @@ -459,11 +530,14 @@ def get_path(self): def do_3d_projection(self): s = self._segment3d - xs, ys, zs = zip(*s) + if self._axlim_clip: + xs, ys, zs = _viewlim_mask(*zip(*s), self.axes) + else: + xs, ys, zs = zip(*s) vxs, vys, vzs, vis = proj3d._proj_transform_clip(xs, ys, zs, self.axes.M, self.axes._focal_length) - self._path2d = mpath.Path(np.column_stack([vxs, vys])) + self._path2d = mpath.Path(np.ma.column_stack([vxs, vys])) return min(vzs) @@ -472,7 +546,7 @@ class PathPatch3D(Patch3D): 3D PathPatch object. """ - def __init__(self, path, *, zs=(), zdir='z', **kwargs): + def __init__(self, path, *, zs=(), zdir='z', axlim_clip=False, **kwargs): """ Parameters ---------- @@ -483,12 +557,14 @@ def __init__(self, path, *, zs=(), zdir='z', **kwargs): zdir : {'x', 'y', 'z', 3-tuple} Plane to plot path patch orthogonal to. Default: 'z'. See `.get_dir_vector` for a description of the values. + axlim_clip : bool, default: False + Whether to hide path patches with a point outside the axes view limits. """ # Not super().__init__! Patch.__init__(self, **kwargs) - self.set_3d_properties(path, zs, zdir) + self.set_3d_properties(path, zs, zdir, axlim_clip) - def set_3d_properties(self, path, zs=0, zdir='z'): + def set_3d_properties(self, path, zs=0, zdir='z', axlim_clip=False): """ Set the *z* position and direction of the path patch. @@ -501,17 +577,23 @@ def set_3d_properties(self, path, zs=0, zdir='z'): zdir : {'x', 'y', 'z', 3-tuple} Plane to plot path patch orthogonal to. Default: 'z'. See `.get_dir_vector` for a description of the values. + axlim_clip : bool, default: False + Whether to hide path patches with a point outside the axes view limits. """ - Patch3D.set_3d_properties(self, path.vertices, zs=zs, zdir=zdir) + Patch3D.set_3d_properties(self, path.vertices, zs=zs, zdir=zdir, + axlim_clip=axlim_clip) self._code3d = path.codes def do_3d_projection(self): s = self._segment3d - xs, ys, zs = zip(*s) + if self._axlim_clip: + xs, ys, zs = _viewlim_mask(*zip(*s), self.axes) + else: + xs, ys, zs = zip(*s) vxs, vys, vzs, vis = proj3d._proj_transform_clip(xs, ys, zs, self.axes.M, self.axes._focal_length) - self._path2d = mpath.Path(np.column_stack([vxs, vys]), self._code3d) + self._path2d = mpath.Path(np.ma.column_stack([vxs, vys]), self._code3d) return min(vzs) @@ -523,11 +605,11 @@ def _get_patch_verts(patch): return polygons[0] if len(polygons) else np.array([]) -def patch_2d_to_3d(patch, z=0, zdir='z'): +def patch_2d_to_3d(patch, z=0, zdir='z', axlim_clip=False): """Convert a `.Patch` to a `.Patch3D` object.""" verts = _get_patch_verts(patch) patch.__class__ = Patch3D - patch.set_3d_properties(verts, z, zdir) + patch.set_3d_properties(verts, z, zdir, axlim_clip) def pathpatch_2d_to_3d(pathpatch, z=0, zdir='z'): @@ -545,7 +627,8 @@ class Patch3DCollection(PatchCollection): A collection of 3D patches. """ - def __init__(self, *args, zs=0, zdir='z', depthshade=True, **kwargs): + def __init__(self, *args, + zs=0, zdir='z', depthshade=True, axlim_clip=False, **kwargs): """ Create a collection of flat 3D patches with its normal vector pointed in *zdir* direction, and located at *zs* on the *zdir* @@ -562,7 +645,7 @@ def __init__(self, *args, zs=0, zdir='z', depthshade=True, **kwargs): """ self._depthshade = depthshade super().__init__(*args, **kwargs) - self.set_3d_properties(zs, zdir) + self.set_3d_properties(zs, zdir, axlim_clip) def get_depthshade(self): return self._depthshade @@ -585,7 +668,7 @@ def set_sort_zpos(self, val): self._sort_zpos = val self.stale = True - def set_3d_properties(self, zs, zdir): + def set_3d_properties(self, zs, zdir, axlim_clip=False): """ Set the *z* positions and direction of the patches. @@ -598,6 +681,8 @@ def set_3d_properties(self, zs, zdir): Plane to plot patches orthogonal to. All patches must have the same direction. See `.get_dir_vector` for a description of the values. + axlim_clip : bool, default: False + Whether to hide patches with a vertex outside the axes view limits. """ # Force the collection to initialize the face and edgecolors # just in case it is a scalarmappable with a colormap. @@ -611,15 +696,19 @@ def set_3d_properties(self, zs, zdir): self._offsets3d = juggle_axes(xs, ys, np.atleast_1d(zs), zdir) self._z_markers_idx = slice(-1) self._vzs = None + self._axlim_clip = axlim_clip self.stale = True def do_3d_projection(self): - xs, ys, zs = self._offsets3d + if self._axlim_clip: + xs, ys, zs = _viewlim_mask(*self._offsets3d, self.axes) + else: + xs, ys, zs = self._offsets3d vxs, vys, vzs, vis = proj3d._proj_transform_clip(xs, ys, zs, self.axes.M, self.axes._focal_length) self._vzs = vzs - super().set_offsets(np.column_stack([vxs, vys])) + super().set_offsets(np.ma.column_stack([vxs, vys])) if vzs.size > 0: return min(vzs) @@ -653,7 +742,8 @@ class Path3DCollection(PathCollection): A collection of 3D paths. """ - def __init__(self, *args, zs=0, zdir='z', depthshade=True, **kwargs): + def __init__(self, *args, + zs=0, zdir='z', depthshade=True, axlim_clip=False, **kwargs): """ Create a collection of flat 3D paths with its normal vector pointed in *zdir* direction, and located at *zs* on the *zdir* @@ -671,7 +761,7 @@ def __init__(self, *args, zs=0, zdir='z', depthshade=True, **kwargs): self._depthshade = depthshade self._in_draw = False super().__init__(*args, **kwargs) - self.set_3d_properties(zs, zdir) + self.set_3d_properties(zs, zdir, axlim_clip) self._offset_zordered = None def draw(self, renderer): @@ -684,7 +774,7 @@ def set_sort_zpos(self, val): self._sort_zpos = val self.stale = True - def set_3d_properties(self, zs, zdir): + def set_3d_properties(self, zs, zdir, axlim_clip=False): """ Set the *z* positions and direction of the paths. @@ -697,6 +787,8 @@ def set_3d_properties(self, zs, zdir): Plane to plot paths orthogonal to. All paths must have the same direction. See `.get_dir_vector` for a description of the values. + axlim_clip : bool, default: False + Whether to hide paths with a vertex outside the axes view limits. """ # Force the collection to initialize the face and edgecolors # just in case it is a scalarmappable with a colormap. @@ -707,6 +799,7 @@ def set_3d_properties(self, zs, zdir): else: xs = [] ys = [] + self._zdir = zdir self._offsets3d = juggle_axes(xs, ys, np.atleast_1d(zs), zdir) # In the base draw methods we access the attributes directly which # means we cannot resolve the shuffling in the getter methods like @@ -727,6 +820,8 @@ def set_3d_properties(self, zs, zdir): # points and point properties according to the index array self._z_markers_idx = slice(-1) self._vzs = None + + self._axlim_clip = axlim_clip self.stale = True def set_sizes(self, sizes, dpi=72.0): @@ -756,14 +851,17 @@ def set_depthshade(self, depthshade): self.stale = True def do_3d_projection(self): - xs, ys, zs = self._offsets3d + if self._axlim_clip: + xs, ys, zs = _viewlim_mask(*self._offsets3d, self.axes) + else: + xs, ys, zs = self._offsets3d vxs, vys, vzs, vis = proj3d._proj_transform_clip(xs, ys, zs, self.axes.M, self.axes._focal_length) # Sort the points based on z coordinates # Performance optimization: Create a sorted index array and reorder # points and point properties according to the index array - z_markers_idx = self._z_markers_idx = np.argsort(vzs)[::-1] + z_markers_idx = self._z_markers_idx = np.ma.argsort(vzs)[::-1] self._vzs = vzs # we have to special case the sizes because of code in collections.py @@ -777,7 +875,7 @@ def do_3d_projection(self): if len(self._linewidths3d) > 1: self._linewidths = self._linewidths3d[z_markers_idx] - PathCollection.set_offsets(self, np.column_stack((vxs, vys))) + PathCollection.set_offsets(self, np.ma.column_stack((vxs, vys))) # Re-order items vzs = vzs[z_markers_idx] @@ -785,7 +883,7 @@ def do_3d_projection(self): vys = vys[z_markers_idx] # Store ordered offset for drawing purpose - self._offset_zordered = np.column_stack((vxs, vys)) + self._offset_zordered = np.ma.column_stack((vxs, vys)) return np.min(vzs) if vzs.size else np.nan @@ -825,7 +923,7 @@ def get_edgecolor(self): return self._maybe_depth_shade_and_sort_colors(super().get_edgecolor()) -def patch_collection_2d_to_3d(col, zs=0, zdir='z', depthshade=True): +def patch_collection_2d_to_3d(col, zs=0, zdir='z', depthshade=True, axlim_clip=False): """ Convert a `.PatchCollection` into a `.Patch3DCollection` object (or a `.PathCollection` into a `.Path3DCollection` object). @@ -843,7 +941,8 @@ def patch_collection_2d_to_3d(col, zs=0, zdir='z', depthshade=True): See `.get_dir_vector` for a description of the values. depthshade : bool, default: True Whether to shade the patches to give a sense of depth. - + axlim_clip : bool, default: False + Whether to hide patches with a vertex outside the axes view limits. """ if isinstance(col, PathCollection): col.__class__ = Path3DCollection @@ -852,7 +951,7 @@ def patch_collection_2d_to_3d(col, zs=0, zdir='z', depthshade=True): col.__class__ = Patch3DCollection col._depthshade = depthshade col._in_draw = False - col.set_3d_properties(zs, zdir) + col.set_3d_properties(zs, zdir, axlim_clip) class Poly3DCollection(PolyCollection): @@ -877,7 +976,7 @@ class Poly3DCollection(PolyCollection): """ def __init__(self, verts, *args, zsort='average', shade=False, - lightsource=None, **kwargs): + lightsource=None, axlim_clip=False, **kwargs): """ Parameters ---------- @@ -899,6 +998,9 @@ def __init__(self, verts, *args, zsort='average', shade=False, .. versionadded:: 3.7 + axlim_clip : bool, default: False + Whether to hide polygons with a vertex outside the view limits. + *args, **kwargs All other parameters are forwarded to `.PolyCollection`. @@ -933,6 +1035,7 @@ def __init__(self, verts, *args, zsort='average', shade=False, raise ValueError('verts must be a list of (N, 3) array-like') self.set_zsort(zsort) self._codes3d = None + self._axlim_clip = axlim_clip _zsort_functions = { 'average': np.average, @@ -997,7 +1100,7 @@ def set_verts_and_codes(self, verts, codes): # and set our own codes instead. self._codes3d = codes - def set_3d_properties(self): + def set_3d_properties(self, axlim_clip=False): # Force the collection to initialize the face and edgecolors # just in case it is a scalarmappable with a colormap. self.update_scalarmappable() @@ -1030,7 +1133,16 @@ def do_3d_projection(self): self._facecolor3d = self._facecolors if self._edge_is_mapped: self._edgecolor3d = self._edgecolors - txs, tys, tzs = proj3d._proj_transform_vec(self._vec, self.axes.M) + if self._axlim_clip: + xs, ys, zs = _viewlim_mask(*self._vec[0:3], self.axes) + if self._vec.shape[0] == 4: # Will be 3 (xyz) or 4 (xyzw) + w_masked = np.ma.masked_where(zs.mask, self._vec[3]) + vec = np.ma.array([xs, ys, zs, w_masked]) + else: + vec = np.ma.array([xs, ys, zs]) + else: + vec = self._vec + txs, tys, tzs = proj3d._proj_transform_vec(vec, self.axes.M) xyzlist = [(txs[sl], tys[sl], tzs[sl]) for sl in self._segslices] # This extra fuss is to re-order face / edge colors @@ -1047,7 +1159,7 @@ def do_3d_projection(self): if xyzlist: # sort by depth (furthest drawn first) z_segments_2d = sorted( - ((self._zsortfunc(zs), np.column_stack([xs, ys]), fc, ec, idx) + ((self._zsortfunc(zs.data), np.ma.column_stack([xs, ys]), fc, ec, idx) for idx, ((xs, ys, zs), fc, ec) in enumerate(zip(xyzlist, cface, cedge))), key=lambda x: x[0], reverse=True) @@ -1124,7 +1236,7 @@ def get_edgecolor(self): return np.asarray(self._edgecolors2d) -def poly_collection_2d_to_3d(col, zs=0, zdir='z'): +def poly_collection_2d_to_3d(col, zs=0, zdir='z', axlim_clip=False): """ Convert a `.PolyCollection` into a `.Poly3DCollection` object. @@ -1144,6 +1256,7 @@ def poly_collection_2d_to_3d(col, zs=0, zdir='z'): col.__class__ = Poly3DCollection col.set_verts_and_codes(segments_3d, codes) col.set_3d_properties() + col._axlim_clip = axlim_clip def juggle_axes(xs, ys, zs, zdir): diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index ea93d3eadf82..90944d19a692 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -1887,7 +1887,7 @@ def get_zbound(self): else: return upper, lower - def text(self, x, y, z, s, zdir=None, **kwargs): + def text(self, x, y, z, s, zdir=None, *, axlim_clip=False, **kwargs): """ Add the text *s* to the 3D Axes at location *x*, *y*, *z* in data coordinates. @@ -1900,6 +1900,10 @@ def text(self, x, y, z, s, zdir=None, **kwargs): zdir : {'x', 'y', 'z', 3-tuple}, optional The direction to be used as the z-direction. Default: 'z'. See `.get_dir_vector` for a description of the values. + axlim_clip : bool, default: False + Whether to hide text that is outside the axes view limits. + + .. versionadded:: 3.10 **kwargs Other arguments are forwarded to `matplotlib.axes.Axes.text`. @@ -1909,13 +1913,13 @@ def text(self, x, y, z, s, zdir=None, **kwargs): The created `.Text3D` instance. """ text = super().text(x, y, s, **kwargs) - art3d.text_2d_to_3d(text, z, zdir) + art3d.text_2d_to_3d(text, z, zdir, axlim_clip) return text text3D = text text2D = Axes.text - def plot(self, xs, ys, *args, zdir='z', **kwargs): + def plot(self, xs, ys, *args, zdir='z', axlim_clip=False, **kwargs): """ Plot 2D or 3D data. @@ -1930,6 +1934,10 @@ def plot(self, xs, ys, *args, zdir='z', **kwargs): each point. zdir : {'x', 'y', 'z'}, default: 'z' When plotting 2D data, the direction to use as z. + axlim_clip : bool, default: False + Whether to hide data that is outside the axes view limits. + + .. versionadded:: 3.10 **kwargs Other arguments are forwarded to `matplotlib.axes.Axes.plot`. """ @@ -1949,7 +1957,7 @@ def plot(self, xs, ys, *args, zdir='z', **kwargs): lines = super().plot(xs, ys, *args, **kwargs) for line in lines: - art3d.line_2d_to_3d(line, zs=zs, zdir=zdir) + art3d.line_2d_to_3d(line, zs=zs, zdir=zdir, axlim_clip=axlim_clip) xs, ys, zs = art3d.juggle_axes(xs, ys, zs, zdir) self.auto_scale_xyz(xs, ys, zs, had_data) @@ -1959,7 +1967,7 @@ def plot(self, xs, ys, *args, zdir='z', **kwargs): def fill_between(self, x1, y1, z1, x2, y2, z2, *, where=None, mode='auto', facecolors=None, shade=None, - **kwargs): + axlim_clip=False, **kwargs): """ Fill the area between two 3D curves. @@ -2005,6 +2013,11 @@ def fill_between(self, x1, y1, z1, x2, y2, z2, *, Whether to shade the facecolors. If *None*, then defaults to *True* for 'quad' mode and *False* for 'polygon' mode. + axlim_clip : bool, default: False + Whether to hide data that is outside the axes view limits. + + .. versionadded:: 3.10 + **kwargs All other keyword arguments are passed on to `.Poly3DCollection`. @@ -2075,14 +2088,14 @@ def fill_between(self, x1, y1, z1, x2, y2, z2, *, polys.append(poly) polyc = art3d.Poly3DCollection(polys, facecolors=facecolors, shade=shade, - **kwargs) + axlim_clip=axlim_clip, **kwargs) self.add_collection(polyc) self.auto_scale_xyz([x1, x2], [y1, y2], [z1, z2], had_data) return polyc def plot_surface(self, X, Y, Z, *, norm=None, vmin=None, - vmax=None, lightsource=None, **kwargs): + vmax=None, lightsource=None, axlim_clip=False, **kwargs): """ Create a surface plot. @@ -2147,6 +2160,11 @@ def plot_surface(self, X, Y, Z, *, norm=None, vmin=None, lightsource : `~matplotlib.colors.LightSource`, optional The lightsource to use when *shade* is True. + axlim_clip : bool, default: False + Whether to hide patches with a vertex outside the axes view limits. + + .. versionadded:: 3.10 + **kwargs Other keyword arguments are forwarded to `.Poly3DCollection`. """ @@ -2247,9 +2265,9 @@ def plot_surface(self, X, Y, Z, *, norm=None, vmin=None, if fcolors is not None: polyc = art3d.Poly3DCollection( polys, edgecolors=colset, facecolors=colset, shade=shade, - lightsource=lightsource, **kwargs) + lightsource=lightsource, axlim_clip=axlim_clip, **kwargs) elif cmap: - polyc = art3d.Poly3DCollection(polys, **kwargs) + polyc = art3d.Poly3DCollection(polys, axlim_clip=axlim_clip, **kwargs) # can't always vectorize, because polys might be jagged if isinstance(polys, np.ndarray): avg_z = polys[..., 2].mean(axis=-1) @@ -2267,15 +2285,15 @@ def plot_surface(self, X, Y, Z, *, norm=None, vmin=None, color = np.array(mcolors.to_rgba(color)) polyc = art3d.Poly3DCollection( - polys, facecolors=color, shade=shade, - lightsource=lightsource, **kwargs) + polys, facecolors=color, shade=shade, lightsource=lightsource, + axlim_clip=axlim_clip, **kwargs) self.add_collection(polyc) self.auto_scale_xyz(X, Y, Z, had_data) return polyc - def plot_wireframe(self, X, Y, Z, **kwargs): + def plot_wireframe(self, X, Y, Z, *, axlim_clip=False, **kwargs): """ Plot a 3D wireframe. @@ -2291,6 +2309,12 @@ def plot_wireframe(self, X, Y, Z, **kwargs): X, Y, Z : 2D arrays Data values. + axlim_clip : bool, default: False + Whether to hide lines and patches with vertices outside the axes + view limits. + + .. versionadded:: 3.10 + rcount, ccount : int Maximum number of samples used in each direction. If the input data is larger, it will be downsampled (by slicing) to these @@ -2387,14 +2411,14 @@ def plot_wireframe(self, X, Y, Z, **kwargs): + [list(zip(xl, yl, zl)) for xl, yl, zl in zip(txlines, tylines, tzlines)]) - linec = art3d.Line3DCollection(lines, **kwargs) + linec = art3d.Line3DCollection(lines, axlim_clip=axlim_clip, **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): + lightsource=None, axlim_clip=False, **kwargs): """ Plot a triangulated surface. @@ -2436,6 +2460,10 @@ def plot_trisurf(self, *args, color=None, norm=None, vmin=None, vmax=None, *cmap* is specified. lightsource : `~matplotlib.colors.LightSource`, optional The lightsource to use when *shade* is True. + axlim_clip : bool, default: False + Whether to hide patches with a vertex outside the axes view limits. + + .. versionadded:: 3.10 **kwargs All other keyword arguments are passed on to :class:`~mpl_toolkits.mplot3d.art3d.Poly3DCollection` @@ -2472,7 +2500,8 @@ def plot_trisurf(self, *args, color=None, norm=None, vmin=None, vmax=None, verts = np.stack((xt, yt, zt), axis=-1) if cmap: - polyc = art3d.Poly3DCollection(verts, *args, **kwargs) + polyc = art3d.Poly3DCollection(verts, *args, + axlim_clip=axlim_clip, **kwargs) # average over the three points of each triangle avg_z = verts[:, :, 2].mean(axis=1) polyc.set_array(avg_z) @@ -2483,7 +2512,7 @@ def plot_trisurf(self, *args, color=None, norm=None, vmin=None, vmax=None, else: polyc = art3d.Poly3DCollection( verts, *args, shade=shade, lightsource=lightsource, - facecolors=color, **kwargs) + facecolors=color, axlim_clip=axlim_clip, **kwargs) self.add_collection(polyc) self.auto_scale_xyz(tri.x, tri.y, z, had_data) @@ -2519,18 +2548,21 @@ def _3d_extend_contour(self, cset, stride=5): cset.remove() def add_contour_set( - self, cset, extend3d=False, stride=5, zdir='z', offset=None): + self, cset, extend3d=False, stride=5, zdir='z', offset=None, + axlim_clip=False): zdir = '-' + zdir if extend3d: self._3d_extend_contour(cset, stride) else: art3d.collection_2d_to_3d( - cset, zs=offset if offset is not None else cset.levels, zdir=zdir) + cset, zs=offset if offset is not None else cset.levels, zdir=zdir, + axlim_clip=axlim_clip) - def add_contourf_set(self, cset, zdir='z', offset=None): - self._add_contourf_set(cset, zdir=zdir, offset=offset) + def add_contourf_set(self, cset, zdir='z', offset=None, *, axlim_clip=False): + self._add_contourf_set(cset, zdir=zdir, offset=offset, + axlim_clip=axlim_clip) - def _add_contourf_set(self, cset, zdir='z', offset=None): + def _add_contourf_set(self, cset, zdir='z', offset=None, axlim_clip=False): """ Returns ------- @@ -2549,12 +2581,14 @@ def _add_contourf_set(self, cset, zdir='z', offset=None): midpoints = np.append(midpoints, max_level) art3d.collection_2d_to_3d( - cset, zs=offset if offset is not None else midpoints, zdir=zdir) + cset, zs=offset if offset is not None else midpoints, zdir=zdir, + axlim_clip=axlim_clip) return midpoints @_preprocess_data() def contour(self, X, Y, Z, *args, - extend3d=False, stride=5, zdir='z', offset=None, **kwargs): + extend3d=False, stride=5, zdir='z', offset=None, axlim_clip=False, + **kwargs): """ Create a 3D contour plot. @@ -2571,6 +2605,10 @@ def contour(self, X, Y, Z, *args, offset : float, optional If specified, plot a projection of the contour lines at this position in a plane normal to *zdir*. + axlim_clip : bool, default: False + Whether to hide lines with a vertex outside the axes view limits. + + .. versionadded:: 3.10 data : indexable object, optional DATA_PARAMETER_PLACEHOLDER @@ -2585,7 +2623,7 @@ def contour(self, X, Y, Z, *args, jX, jY, jZ = art3d.rotate_axes(X, Y, Z, zdir) cset = super().contour(jX, jY, jZ, *args, **kwargs) - self.add_contour_set(cset, extend3d, stride, zdir, offset) + self.add_contour_set(cset, extend3d, stride, zdir, offset, axlim_clip) self.auto_scale_xyz(X, Y, Z, had_data) return cset @@ -2594,7 +2632,8 @@ def contour(self, X, Y, Z, *args, @_preprocess_data() def tricontour(self, *args, - extend3d=False, stride=5, zdir='z', offset=None, **kwargs): + extend3d=False, stride=5, zdir='z', offset=None, axlim_clip=False, + **kwargs): """ Create a 3D contour plot. @@ -2615,6 +2654,10 @@ def tricontour(self, *args, offset : float, optional If specified, plot a projection of the contour lines at this position in a plane normal to *zdir*. + axlim_clip : bool, default: False + Whether to hide lines with a vertex outside the axes view limits. + + .. versionadded:: 3.10 data : indexable object, optional DATA_PARAMETER_PLACEHOLDER *args, **kwargs @@ -2640,7 +2683,7 @@ def tricontour(self, *args, tri = Triangulation(jX, jY, tri.triangles, tri.mask) cset = super().tricontour(tri, jZ, *args, **kwargs) - self.add_contour_set(cset, extend3d, stride, zdir, offset) + self.add_contour_set(cset, extend3d, stride, zdir, offset, axlim_clip) self.auto_scale_xyz(X, Y, Z, had_data) return cset @@ -2656,7 +2699,8 @@ def _auto_scale_contourf(self, X, Y, Z, zdir, levels, had_data): self.auto_scale_xyz(*limits, had_data) @_preprocess_data() - def contourf(self, X, Y, Z, *args, zdir='z', offset=None, **kwargs): + def contourf(self, X, Y, Z, *args, + zdir='z', offset=None, axlim_clip=False, **kwargs): """ Create a 3D filled contour plot. @@ -2669,6 +2713,10 @@ def contourf(self, X, Y, Z, *args, zdir='z', offset=None, **kwargs): offset : float, optional If specified, plot a projection of the contour lines at this position in a plane normal to *zdir*. + axlim_clip : bool, default: False + Whether to hide lines with a vertex outside the axes view limits. + + .. versionadded:: 3.10 data : indexable object, optional DATA_PARAMETER_PLACEHOLDER *args, **kwargs @@ -2682,7 +2730,7 @@ def contourf(self, X, Y, Z, *args, zdir='z', offset=None, **kwargs): jX, jY, jZ = art3d.rotate_axes(X, Y, Z, zdir) cset = super().contourf(jX, jY, jZ, *args, **kwargs) - levels = self._add_contourf_set(cset, zdir, offset) + levels = self._add_contourf_set(cset, zdir, offset, axlim_clip) self._auto_scale_contourf(X, Y, Z, zdir, levels, had_data) return cset @@ -2690,7 +2738,7 @@ def contourf(self, X, Y, Z, *args, zdir='z', offset=None, **kwargs): contourf3D = contourf @_preprocess_data() - def tricontourf(self, *args, zdir='z', offset=None, **kwargs): + def tricontourf(self, *args, zdir='z', offset=None, axlim_clip=False, **kwargs): """ Create a 3D filled contour plot. @@ -2707,6 +2755,10 @@ def tricontourf(self, *args, zdir='z', offset=None, **kwargs): offset : float, optional If specified, plot a projection of the contour lines at this position in a plane normal to zdir. + axlim_clip : bool, default: False + Whether to hide lines with a vertex outside the axes view limits. + + .. versionadded:: 3.10 data : indexable object, optional DATA_PARAMETER_PLACEHOLDER *args, **kwargs @@ -2733,12 +2785,13 @@ def tricontourf(self, *args, zdir='z', offset=None, **kwargs): tri = Triangulation(jX, jY, tri.triangles, tri.mask) cset = super().tricontourf(tri, jZ, *args, **kwargs) - levels = self._add_contourf_set(cset, zdir, offset) + levels = self._add_contourf_set(cset, zdir, offset, axlim_clip) self._auto_scale_contourf(X, Y, Z, zdir, levels, had_data) return cset - def add_collection3d(self, col, zs=0, zdir='z', autolim=True): + def add_collection3d(self, col, zs=0, zdir='z', autolim=True, *, + axlim_clip=False): """ Add a 3D collection object to the plot. @@ -2762,6 +2815,10 @@ def add_collection3d(self, col, zs=0, zdir='z', autolim=True): The direction to use for the z-positions. autolim : bool, default: True Whether to update the data limits. + axlim_clip : bool, default: False + Whether to hide the scatter points outside the axes view limits. + + .. versionadded:: 3.10 """ had_data = self.has_data() @@ -2773,13 +2830,16 @@ def add_collection3d(self, col, zs=0, zdir='z', autolim=True): # object would also pass.) Maybe have a collection3d # abstract class to test for and exclude? if type(col) is mcoll.PolyCollection: - art3d.poly_collection_2d_to_3d(col, zs=zs, zdir=zdir) + art3d.poly_collection_2d_to_3d(col, zs=zs, zdir=zdir, + axlim_clip=axlim_clip) col.set_sort_zpos(zsortval) elif type(col) is mcoll.LineCollection: - art3d.line_collection_2d_to_3d(col, zs=zs, zdir=zdir) + art3d.line_collection_2d_to_3d(col, zs=zs, zdir=zdir, + axlim_clip=axlim_clip) col.set_sort_zpos(zsortval) elif type(col) is mcoll.PatchCollection: - art3d.patch_collection_2d_to_3d(col, zs=zs, zdir=zdir) + art3d.patch_collection_2d_to_3d(col, zs=zs, zdir=zdir, + axlim_clip=axlim_clip) col.set_sort_zpos(zsortval) if autolim: @@ -2800,8 +2860,9 @@ def add_collection3d(self, col, zs=0, zdir='z', autolim=True): @_preprocess_data(replace_names=["xs", "ys", "zs", "s", "edgecolors", "c", "facecolor", "facecolors", "color"]) - def scatter(self, xs, ys, zs=0, zdir='z', s=20, c=None, depthshade=True, - *args, **kwargs): + def scatter(self, xs, ys, + zs=0, zdir='z', s=20, c=None, depthshade=True, *args, + axlim_clip=False, **kwargs): """ Create a scatter plot. @@ -2837,6 +2898,10 @@ def scatter(self, xs, ys, zs=0, zdir='z', s=20, c=None, depthshade=True, Whether to shade the scatter markers to give the appearance of depth. Each call to ``scatter()`` will perform its depthshading independently. + axlim_clip : bool, default: False + Whether to hide the scatter points outside the axes view limits. + + .. versionadded:: 3.10 data : indexable object, optional DATA_PARAMETER_PLACEHOLDER **kwargs @@ -2865,7 +2930,8 @@ def scatter(self, xs, ys, zs=0, zdir='z', s=20, c=None, depthshade=True, patches = super().scatter(xs, ys, s=s, c=c, *args, **kwargs) art3d.patch_collection_2d_to_3d(patches, zs=zs, zdir=zdir, - depthshade=depthshade) + depthshade=depthshade, + axlim_clip=axlim_clip) if self._zmargin < 0.05 and xs.size > 0: self.set_zmargin(0.05) @@ -2877,7 +2943,8 @@ def scatter(self, xs, ys, zs=0, zdir='z', s=20, c=None, depthshade=True, scatter3D = scatter @_preprocess_data() - def bar(self, left, height, zs=0, zdir='z', *args, **kwargs): + def bar(self, left, height, zs=0, zdir='z', *args, + axlim_clip=False, **kwargs): """ Add 2D bar(s). @@ -2892,6 +2959,10 @@ def bar(self, left, height, zs=0, zdir='z', *args, **kwargs): used for all bars. zdir : {'x', 'y', 'z'}, default: 'z' When plotting 2D data, the direction to use as z ('x', 'y' or 'z'). + axlim_clip : bool, default: False + Whether to hide bars with points outside the axes view limits. + + .. versionadded:: 3.10 data : indexable object, optional DATA_PARAMETER_PLACEHOLDER **kwargs @@ -2914,7 +2985,7 @@ def bar(self, left, height, zs=0, zdir='z', *args, **kwargs): vs = art3d._get_patch_verts(p) verts += vs.tolist() verts_zs += [z] * len(vs) - art3d.patch_2d_to_3d(p, z, zdir) + art3d.patch_2d_to_3d(p, z, zdir, axlim_clip) if 'alpha' in kwargs: p.set_alpha(kwargs['alpha']) @@ -2933,7 +3004,8 @@ def bar(self, left, height, zs=0, zdir='z', *args, **kwargs): @_preprocess_data() def bar3d(self, x, y, z, dx, dy, dz, color=None, - zsort='average', shade=True, lightsource=None, *args, **kwargs): + zsort='average', shade=True, lightsource=None, *args, + axlim_clip=False, **kwargs): """ Generate a 3D barplot. @@ -2980,6 +3052,11 @@ def bar3d(self, x, y, z, dx, dy, dz, color=None, lightsource : `~matplotlib.colors.LightSource`, optional The lightsource to use when *shade* is True. + axlim_clip : bool, default: False + Whether to hide the bars with points outside the axes view limits. + + .. versionadded:: 3.10 + data : indexable object, optional DATA_PARAMETER_PLACEHOLDER @@ -3085,6 +3162,7 @@ def bar3d(self, x, y, z, dx, dy, dz, color=None, facecolors=facecolors, shade=shade, lightsource=lightsource, + axlim_clip=axlim_clip, *args, **kwargs) self.add_collection(col) @@ -3102,7 +3180,7 @@ def set_title(self, label, fontdict=None, loc='center', **kwargs): @_preprocess_data() def quiver(self, X, Y, Z, U, V, W, *, length=1, arrow_length_ratio=.3, pivot='tail', normalize=False, - **kwargs): + axlim_clip=False, **kwargs): """ Plot a 3D field of arrows. @@ -3134,6 +3212,11 @@ def quiver(self, X, Y, Z, U, V, W, *, Whether all arrows are normalized to have the same length, or keep the lengths defined by *u*, *v*, and *w*. + axlim_clip : bool, default: False + Whether to hide arrows with points outside the axes view limits. + + .. versionadded:: 3.10 + data : indexable object, optional DATA_PARAMETER_PLACEHOLDER @@ -3215,7 +3298,7 @@ def calc_arrows(UVW): else: lines = [] - linec = art3d.Line3DCollection(lines, **kwargs) + linec = art3d.Line3DCollection(lines, axlim_clip=axlim_clip, **kwargs) self.add_collection(linec) self.auto_scale_xyz(XYZ[:, 0], XYZ[:, 1], XYZ[:, 2], had_data) @@ -3225,7 +3308,7 @@ def calc_arrows(UVW): quiver3D = quiver def voxels(self, *args, facecolors=None, edgecolors=None, shade=True, - lightsource=None, **kwargs): + lightsource=None, axlim_clip=False, **kwargs): """ ax.voxels([x, y, z,] /, filled, facecolors=None, edgecolors=None, \ **kwargs) @@ -3272,6 +3355,11 @@ def voxels(self, *args, facecolors=None, edgecolors=None, shade=True, lightsource : `~matplotlib.colors.LightSource`, optional The lightsource to use when *shade* is True. + axlim_clip : bool, default: False + Whether to hide voxels with points outside the axes view limits. + + .. versionadded:: 3.10 + **kwargs Additional keyword arguments to pass onto `~mpl_toolkits.mplot3d.art3d.Poly3DCollection`. @@ -3427,7 +3515,8 @@ def permutation_matrices(n): poly = art3d.Poly3DCollection( faces, facecolors=facecolor, edgecolors=edgecolor, - shade=shade, lightsource=lightsource, **kwargs) + shade=shade, lightsource=lightsource, axlim_clip=axlim_clip, + **kwargs) self.add_collection3d(poly) polygons[coord] = poly @@ -3438,6 +3527,7 @@ def errorbar(self, x, y, z, zerr=None, yerr=None, xerr=None, fmt='', barsabove=False, errorevery=1, ecolor=None, elinewidth=None, capsize=None, capthick=None, xlolims=False, xuplims=False, ylolims=False, yuplims=False, zlolims=False, zuplims=False, + axlim_clip=False, **kwargs): """ Plot lines and/or markers with errorbars around them. @@ -3515,6 +3605,11 @@ def errorbar(self, x, y, z, zerr=None, yerr=None, xerr=None, fmt='', Used to avoid overlapping error bars when two series share x-axis values. + axlim_clip : bool, default: False + Whether to hide error bars that are outside the axes limits. + + .. versionadded:: 3.10 + Returns ------- errlines : list @@ -3570,7 +3665,7 @@ def errorbar(self, x, y, z, zerr=None, yerr=None, xerr=None, fmt='', # data processing. (data_line, base_style), = self._get_lines._plot_args( self, (x, y) if fmt == '' else (x, y, fmt), kwargs, return_kwargs=True) - art3d.line_2d_to_3d(data_line, zs=z) + art3d.line_2d_to_3d(data_line, zs=z, axlim_clip=axlim_clip) # Do this after creating `data_line` to avoid modifying `base_style`. if barsabove: @@ -3714,9 +3809,11 @@ def _extract_errs(err, data, lomask, himask): # these markers will rotate as the viewing angle changes cap_lo = art3d.Line3D(*lo_caps_xyz, ls='', marker=capmarker[i_zdir], + axlim_clip=axlim_clip, **eb_cap_style) cap_hi = art3d.Line3D(*hi_caps_xyz, ls='', marker=capmarker[i_zdir], + axlim_clip=axlim_clip, **eb_cap_style) self.add_line(cap_lo) self.add_line(cap_hi) @@ -3731,6 +3828,7 @@ def _extract_errs(err, data, lomask, himask): self.quiver(xl0, yl0, zl0, *-dir_vector, **eb_quiver_style) errline = art3d.Line3DCollection(np.array(coorderr).T, + axlim_clip=axlim_clip, **eb_lines_style) self.add_collection(errline) errlines.append(errline) @@ -3776,7 +3874,7 @@ def get_tightbbox(self, renderer=None, call_axes_locator=True, @_preprocess_data() def stem(self, x, y, z, *, linefmt='C0-', markerfmt='C0o', basefmt='C3-', - bottom=0, label=None, orientation='z'): + bottom=0, label=None, orientation='z', axlim_clip=False): """ Create a 3D stem plot. @@ -3826,6 +3924,11 @@ def stem(self, x, y, z, *, linefmt='C0-', markerfmt='C0o', basefmt='C3-', orientation : {'x', 'y', 'z'}, default: 'z' The direction along which stems are drawn. + axlim_clip : bool, default: False + Whether to hide stems that are outside the axes limits. + + .. versionadded:: 3.10 + data : indexable object, optional DATA_PARAMETER_PLACEHOLDER @@ -3877,7 +3980,8 @@ def stem(self, x, y, z, *, linefmt='C0-', markerfmt='C0o', basefmt='C3-', baseline, = self.plot(basex, basey, basefmt, zs=bottom, zdir=orientation, label='_nolegend_') stemlines = art3d.Line3DCollection( - lines, linestyles=linestyle, colors=linecolor, label='_nolegend_') + lines, linestyles=linestyle, colors=linecolor, label='_nolegend_', + axlim_clip=axlim_clip) self.add_collection(stemlines) markerline, = self.plot(x, y, z, markerfmt, label='_nolegend_') diff --git a/lib/mpl_toolkits/mplot3d/proj3d.py b/lib/mpl_toolkits/mplot3d/proj3d.py index 1fcbafbbcdbc..c79c8eeba899 100644 --- a/lib/mpl_toolkits/mplot3d/proj3d.py +++ b/lib/mpl_toolkits/mplot3d/proj3d.py @@ -171,20 +171,33 @@ def _ortho_transformation(zfront, zback): def _proj_transform_vec(vec, M): - vecw = np.dot(M, vec) + vecw = np.dot(M, vec.data) w = vecw[3] txs, tys, tzs = vecw[0]/w, vecw[1]/w, vecw[2]/w + if np.ma.isMA(vec[0]): # we check each to protect for scalars + txs = np.ma.array(txs, mask=vec[0].mask) + if np.ma.isMA(vec[1]): + tys = np.ma.array(tys, mask=vec[1].mask) + if np.ma.isMA(vec[2]): + tzs = np.ma.array(tzs, mask=vec[2].mask) return txs, tys, tzs def _proj_transform_vec_clip(vec, M, focal_length): - vecw = np.dot(M, vec) + vecw = np.dot(M, vec.data) w = vecw[3] txs, tys, tzs = vecw[0] / w, vecw[1] / w, vecw[2] / w if np.isinf(focal_length): # don't clip orthographic projection tis = np.ones(txs.shape, dtype=bool) else: tis = (-1 <= txs) & (txs <= 1) & (-1 <= tys) & (tys <= 1) & (tzs <= 0) + if np.ma.isMA(vec[0]): + tis = tis & ~vec[0].mask + if np.ma.isMA(vec[1]): + tis = tis & ~vec[1].mask + if np.ma.isMA(vec[2]): + tis = tis & ~vec[2].mask + txs = np.ma.masked_array(txs, ~tis) tys = np.ma.masked_array(tys, ~tis) tzs = np.ma.masked_array(tzs, ~tis) @@ -206,7 +219,10 @@ def inv_transform(xs, ys, zs, invM): def _vec_pad_ones(xs, ys, zs): - return np.array([xs, ys, zs, np.ones_like(xs)]) + if np.ma.isMA(xs) or np.ma.isMA(ys) or np.ma.isMA(zs): + return np.ma.array([xs, ys, zs, np.ones_like(xs)]) + else: + return np.array([xs, ys, zs, np.ones_like(xs)]) def proj_transform(xs, ys, zs, M): @@ -252,7 +268,8 @@ def proj_trans_points(points, M): def _proj_trans_points(points, M): - xs, ys, zs = zip(*points) + points = np.asanyarray(points) + xs, ys, zs = points[:, 0], points[:, 1], points[:, 2] return proj_transform(xs, ys, zs, M) diff --git a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py index c64e888fdc2e..295548591b18 100644 --- a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py +++ b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py @@ -1380,6 +1380,45 @@ def test_axes3d_isometric(): ax.grid(True) +@check_figures_equal(extensions=["png"]) +def test_axlim_clip(fig_test, fig_ref): + # With axlim clipping + ax = fig_test.add_subplot(projection="3d") + x = np.linspace(0, 1, 11) + y = np.linspace(0, 1, 11) + X, Y = np.meshgrid(x, y) + Z = X + Y + ax.plot_surface(X, Y, Z, facecolor='C1', edgecolors=None, + rcount=50, ccount=50, axlim_clip=True) + # This ax.plot is to cover the extra surface edge which is not clipped out + ax.plot([0.5, 0.5], [0, 1], [0.5, 1.5], + color='k', linewidth=3, zorder=5, axlim_clip=True) + ax.scatter(X.ravel(), Y.ravel(), Z.ravel() + 1, axlim_clip=True) + ax.quiver(X.ravel(), Y.ravel(), Z.ravel() + 2, + 0*X.ravel(), 0*Y.ravel(), 0*Z.ravel() + 1, + arrow_length_ratio=0, axlim_clip=True) + ax.plot(X[0], Y[0], Z[0] + 3, color='C2', axlim_clip=True) + ax.text(1.1, 0.5, 4, 'test', axlim_clip=True) # won't be visible + ax.set(xlim=(0, 0.5), ylim=(0, 1), zlim=(0, 5)) + + # With manual clipping + ax = fig_ref.add_subplot(projection="3d") + idx = (X <= 0.5) + X = X[idx].reshape(11, 6) + Y = Y[idx].reshape(11, 6) + Z = Z[idx].reshape(11, 6) + ax.plot_surface(X, Y, Z, facecolor='C1', edgecolors=None, + rcount=50, ccount=50, axlim_clip=False) + ax.plot([0.5, 0.5], [0, 1], [0.5, 1.5], + color='k', linewidth=3, zorder=5, axlim_clip=False) + ax.scatter(X.ravel(), Y.ravel(), Z.ravel() + 1, axlim_clip=False) + ax.quiver(X.ravel(), Y.ravel(), Z.ravel() + 2, + 0*X.ravel(), 0*Y.ravel(), 0*Z.ravel() + 1, + arrow_length_ratio=0, axlim_clip=False) + ax.plot(X[0], Y[0], Z[0] + 3, color='C2', axlim_clip=False) + ax.set(xlim=(0, 0.5), ylim=(0, 1), zlim=(0, 5)) + + @pytest.mark.parametrize('value', [np.inf, np.nan]) @pytest.mark.parametrize(('setter', 'side'), [ ('set_xlim3d', 'left'),