diff --git a/doc/release/next_whats_new/plot_surface_geometric_clipping.rst b/doc/release/next_whats_new/plot_surface_geometric_clipping.rst new file mode 100644 index 000000000000..369d91a3e0f0 --- /dev/null +++ b/doc/release/next_whats_new/plot_surface_geometric_clipping.rst @@ -0,0 +1,7 @@ +Geometric clipping for 3D surface plots +--------------------------------------- + +`.Axes3D.plot_surface` now supports ``axlim_clip_mode="clip"`` +when ``axlim_clip=True``. This clips surface polygons geometrically +to the axes view-limit box instead of hiding a whole polygon whenever +one of its vertices lies outside the limits. diff --git a/galleries/examples/mplot3d/axlim_clip.py b/galleries/examples/mplot3d/axlim_clip.py index 2a29f2bf2431..6f49d7e29938 100644 --- a/galleries/examples/mplot3d/axlim_clip.py +++ b/galleries/examples/mplot3d/axlim_clip.py @@ -3,37 +3,70 @@ 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. +Demonstrate clipping of 3D data to the axes view limits. + +Without ``axlim_clip``, data may extend beyond the axes view limits. With +``axlim_clip=True`` and ``axlim_clip_mode="hide"``, surface patches or line +segments with vertices outside the view limits are hidden. With +``axlim_clip_mode="clip"``, they are geometrically clipped to the axes +view-limit box. """ import matplotlib.pyplot as plt import numpy as np -fig, ax = plt.subplots(subplot_kw={"projection": "3d"}) +from matplotlib import cm + +fig, axs = plt.subplots( + 2, 3, + subplot_kw={"projection": "3d"}, + figsize=(10, 7), + layout="constrained", +) -# Make the data -x = np.arange(-5, 5, 0.5) -y = np.arange(-5, 5, 0.5) +x = np.arange(-5, 5, 0.25) +y = np.arange(-5, 5, 0.25) X, Y = np.meshgrid(x, y) -R = np.sqrt(X**2 + Y**2) +R = np.hypot(X, Y) Z = np.sin(R) -# Default behavior is axlim_clip=False -ax.plot_wireframe(X, Y, Z, color='C0') +xlim = (-3.2, 3.2) +ylim = (-3.0, 2.6) +zlim = (-0.45, 0.85) -# 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_wireframe(X, Y, Z, color='C1', axlim_clip=True) +cases = [ + ("unclipped", dict(axlim_clip=False)), + ('axlim_clip_mode="hide"', dict(axlim_clip=True, axlim_clip_mode="hide")), + ('axlim_clip_mode="clip"', dict(axlim_clip=True, axlim_clip_mode="clip")), +] -# In this example, data where x < 0 or z > 0.5 is clipped -ax.set(xlim=(0, 10), ylim=(-5, 5), zlim=(-1, 0.5)) -ax.legend(['axlim_clip=False (default)', 'axlim_clip=True']) +for ax, (title, clip_kwargs) in zip(axs[0], cases): + ax.plot_surface( + X, Y, Z, + cmap=cm.coolwarm, + linewidth=0, + antialiased=False, + **clip_kwargs, + ) + ax.set_title(f"surface\n{title}") + ax.set_xlim(*xlim) + ax.set_ylim(*ylim) + ax.set_zlim(*zlim) -plt.show() +for ax, (title, clip_kwargs) in zip(axs[1], cases): + ax.plot_wireframe( + X, Y, Z, + rstride=2, + cstride=2, + linewidth=0.6, + **clip_kwargs, + ) + ax.set_title(f"wireframe\n{title}") + ax.set_xlim(*xlim) + ax.set_ylim(*ylim) + ax.set_zlim(*zlim) +plt.show() # %% # .. tags:: # plot-type: 3D, diff --git a/lib/mpl_toolkits/mplot3d/art3d.py b/lib/mpl_toolkits/mplot3d/art3d.py index f664127dcb59..d49a9edbde26 100644 --- a/lib/mpl_toolkits/mplot3d/art3d.py +++ b/lib/mpl_toolkits/mplot3d/art3d.py @@ -97,6 +97,158 @@ def _viewlim_mask(xs, ys, zs, axes): return mask +def _clip_polygon_against_plane(poly, axis, value, keep_greater): + """Clip a 3D polygon against one axis-aligned plane.""" + if len(poly) == 0: + return np.empty((0, 3), dtype=float) + + def inside(point): + return point[axis] >= value if keep_greater else point[axis] <= value + + clipped = [] + prev = poly[-1] + prev_inside = inside(prev) + + for curr in poly: + curr_inside = inside(curr) + + if curr_inside != prev_inside: + denom = curr[axis] - prev[axis] + if not np.isclose(denom, 0.0): + t = (value - prev[axis]) / denom + t = np.clip(t, 0.0, 1.0) + clipped.append(prev + t * (curr - prev)) + + if curr_inside: + clipped.append(curr) + + prev = curr + prev_inside = curr_inside + + if len(clipped) == 0: + return np.empty((0, 3), dtype=float) + + return np.asarray(clipped, dtype=float) + + +def _is_degenerate_polygon(poly, tol=1e-12): + """Return whether a polygon has fewer than two independent dimensions.""" + poly = np.asarray(poly, dtype=float) + + if poly.ndim != 2 or poly.shape[1] != 3 or len(poly) < 3: + return True + + if not np.isfinite(poly).all(): + return True + + centered = poly - poly.mean(axis=0) + scale = np.linalg.norm(centered, axis=1).max(initial=0) + + if scale == 0: + return True + + return np.linalg.matrix_rank(centered, tol=tol * scale) < 2 + + +def _clip_polygon_to_box(poly, xlim, ylim, zlim): + """Clip a 3D polygon against the six planes of an axis-aligned box.""" + poly = np.asarray(poly, dtype=float) + + if poly.ndim != 2 or poly.shape[1] != 3: + raise ValueError("poly must be an (N, 3) array") + + if _is_degenerate_polygon(poly): + return np.empty((0, 3), dtype=float) + + planes = [ + (0, xlim[0], True), + (0, xlim[1], False), + (1, ylim[0], True), + (1, ylim[1], False), + (2, zlim[0], True), + (2, zlim[1], False), + ] + + for axis, value, keep_greater in planes: + poly = _clip_polygon_against_plane(poly, axis, value, keep_greater) + if len(poly) < 3: + return np.empty((0, 3), dtype=float) + + if _is_degenerate_polygon(poly): + return np.empty((0, 3), dtype=float) + + return poly + + +def _clip_polygon_to_axes_view(poly, axes): + """Clip a 3D polygon to the current Axes3D view limits.""" + xlim = (axes.xy_viewLim.xmin, axes.xy_viewLim.xmax) + ylim = (axes.xy_viewLim.ymin, axes.xy_viewLim.ymax) + zlim = (axes.zz_viewLim.xmin, axes.zz_viewLim.xmax) + + return _clip_polygon_to_box(poly, xlim, ylim, zlim) + + +def _clip_line_segment_to_box(p0, p1, xlim, ylim, zlim): + """Clip a 3D line segment against an axis-aligned box.""" + p0 = np.asarray(p0, dtype=float) + p1 = np.asarray(p1, dtype=float) + + if p0.shape != (3,) or p1.shape != (3,): + raise ValueError("line segment endpoints must be 3D points") + if not np.isfinite(p0).all() or not np.isfinite(p1).all(): + return np.empty((0, 3), dtype=float) + + d = p1 - p0 + t0, t1 = 0.0, 1.0 + bounds = (xlim, ylim, zlim) + + for axis, (lower, upper) in enumerate(bounds): + if d[axis] == 0: + if p0[axis] < lower or p0[axis] > upper: + return np.empty((0, 3), dtype=float) + continue + + t_lower = (lower - p0[axis]) / d[axis] + t_upper = (upper - p0[axis]) / d[axis] + t_enter = min(t_lower, t_upper) + t_exit = max(t_lower, t_upper) + + t0 = max(t0, t_enter) + t1 = min(t1, t_exit) + + if t0 > t1: + return np.empty((0, 3), dtype=float) + + return np.asarray([p0 + t0 * d, p0 + t1 * d], dtype=float) + + +def _clip_line_segment_to_axes_view(p0, p1, axes): + """Clip a 3D line segment to the current Axes3D view limits.""" + xlim = (axes.xy_viewLim.xmin, axes.xy_viewLim.xmax) + ylim = (axes.xy_viewLim.ymin, axes.xy_viewLim.ymax) + zlim = (axes.zz_viewLim.xmin, axes.zz_viewLim.xmax) + + return _clip_line_segment_to_box(p0, p1, xlim, ylim, zlim) + + +def _clip_lines_to_axes_view(segments, axes): + """Clip 3D polylines to the current Axes3D view limits.""" + clipped_segments = [] + + for segment in segments: + segment = np.asarray(segment, dtype=float) + if segment.ndim != 2 or segment.shape[1] != 3 or len(segment) < 2: + continue + + for p0, p1 in zip(segment[:-1], segment[1:]): + clipped = _clip_line_segment_to_axes_view(p0, p1, axes) + if len(clipped) == 2: + clipped_segments.append(clipped) + + return clipped_segments + + class Text3D(mtext.Text): """ Text object with 3D position and direction. @@ -453,9 +605,11 @@ class Line3DCollection(LineCollection): """ A collection of 3D lines. """ - def __init__(self, lines, axlim_clip=False, **kwargs): + def __init__(self, lines, axlim_clip=False, axlim_clip_mode='hide', **kwargs): + _api.check_in_list(['hide', 'clip'], axlim_clip_mode=axlim_clip_mode) super().__init__(lines, **kwargs) self._axlim_clip = axlim_clip + self._axlim_clip_mode = axlim_clip_mode """ Parameters ---------- @@ -478,6 +632,8 @@ def __init__(self, lines, axlim_clip=False, **kwargs): each line, please use `.PathCollection` instead, where the "interior" can be specified by appropriate usage of `~.path.Path.CLOSEPOLY`. + axlim_clip_mode : {'hide', 'clip'}, default: 'hide' + The clipping strategy used when *axlim_clip* is True. **kwargs : Forwarded to `.Collection`. """ @@ -497,6 +653,35 @@ def do_3d_projection(self): """ Project the points according to renderer matrix. """ + if len(self._segments3d) == 0: + LineCollection.set_segments(self, []) + return np.nan + + use_geometric_clip = ( + self._axlim_clip + and self._axlim_clip_mode == 'clip' + ) + + if use_geometric_clip: + segments = _clip_lines_to_axes_view(self._segments3d, self.axes) + if len(segments) == 0: + LineCollection.set_segments(self, []) + return np.nan + + # Geometric clipping can split one original polyline into multiple + # disconnected visible pieces. Keep those pieces as independent + # segments; otherwise LineCollection would draw artificial + # connections between clipped pieces. + xyzs = [ + proj3d._scale_proj_transform_vectors( + np.asarray(segment, dtype=float), self.axes) + for segment in segments + ] + LineCollection.set_segments( + self, [xyz[..., 0:2] for xyz in xyzs]) + + return np.min([xyz[..., 2].min() for xyz in xyzs]) + segments = np.asanyarray(self._segments3d) # Handle empty segments @@ -515,8 +700,9 @@ def do_3d_projection(self): self.axes) if np.any(viewlim_mask): # broadcast mask to 3D - viewlim_mask = np.broadcast_to(viewlim_mask[..., np.newaxis], - (*viewlim_mask.shape, 3)) + viewlim_mask = np.broadcast_to( + viewlim_mask[..., np.newaxis], + (*viewlim_mask.shape, 3)) mask = mask | viewlim_mask xyzs = np.ma.array( @@ -538,6 +724,7 @@ def line_collection_2d_to_3d(col, zs=0, zdir='z', axlim_clip=False): col.__class__ = Line3DCollection col.set_segments(segments3d) col._axlim_clip = axlim_clip + col._axlim_clip_mode = 'hide' class Patch3D(Patch): @@ -1183,7 +1370,8 @@ class Poly3DCollection(PolyCollection): """ def __init__(self, verts, *args, zsort='average', shade=False, - lightsource=None, axlim_clip=False, **kwargs): + lightsource=None, axlim_clip=False, + axlim_clip_mode='hide', **kwargs): """ Parameters ---------- @@ -1206,10 +1394,17 @@ 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. + Whether to apply axes-limit clipping to polygons. .. versionadded:: 3.10 + axlim_clip_mode : {'hide', 'clip'}, default: 'hide' + The clipping strategy used when *axlim_clip* is True. + + - 'hide': hide polygons with a vertex outside the axes view + limits. This preserves the existing behavior. + - 'clip': geometrically clip polygons to the axes view-limit box. + *args, **kwargs All other parameters are forwarded to `.PolyCollection`. @@ -1218,6 +1413,8 @@ def __init__(self, verts, *args, zsort='average', shade=False, Note that this class does a bit of magic with the _facecolors and _edgecolors properties. """ + _api.check_in_list(['hide', 'clip'], + axlim_clip_mode=axlim_clip_mode) if shade: normals = _generate_normals(verts) facecolors = kwargs.get('facecolors', None) @@ -1245,6 +1442,7 @@ def __init__(self, verts, *args, zsort='average', shade=False, self.set_zsort(zsort) self._codes3d = None self._axlim_clip = axlim_clip + self._axlim_clip_mode = axlim_clip_mode _zsort_functions = { 'average': np.average, @@ -1366,6 +1564,74 @@ def do_3d_projection(self): num_faces = len(self._faces) mask = self._invalid_vertices + use_geometric_clip = ( + self._axlim_clip + and self._axlim_clip_mode == 'clip' + and self._codes3d is None + ) + + if use_geometric_clip: + clipped_faces = [] + clip_indices = [] + invalid_vertices = np.broadcast_to( + self._invalid_vertices, self._faces.shape[:2]) + for idx, (face, invalid) in enumerate( + zip(self._faces, invalid_vertices)): + face = np.asarray(face[~invalid], dtype=float) + clipped = _clip_polygon_to_axes_view(face, self.axes) + if len(clipped) >= 3: + clipped_faces.append(clipped) + clip_indices.append(idx) + + clip_indices = np.asarray(clip_indices, dtype=np.intp) + num_clipped_faces = len(clipped_faces) + + # Some faces might contain masked vertices, so we want to ignore + # any errors that those might cause. + with np.errstate(invalid='ignore', divide='ignore'): + pfaces = [proj3d._scale_proj_transform_vectors( + face, self.axes) for face in clipped_faces] + + pzs = [face[:, 2] for face in pfaces] + face_z = np.asarray([self._zsortfunc(zs) for zs in pzs]) + face_order = np.argsort(face_z, axis=-1)[::-1] + faces_2d = [pfaces[idx][:, :2] for idx in face_order] + + # This extra fuss is to re-order face / edge colors. + cface = self._facecolor3d + cedge = self._edgecolor3d + if len(cface) != num_faces: + cface = cface.repeat(num_faces, axis=0) + if len(cedge) != num_faces: + if len(cedge) == 0: + cedge = cface + else: + cedge = cedge.repeat(num_faces, axis=0) + + if len(clip_indices) > 0: + cface = cface[clip_indices] + cedge = cedge[clip_indices] + + PolyCollection.set_verts(self, faces_2d, self._closed) + + if len(cface) > 0: + self._facecolors2d = cface[face_order] + else: + self._facecolors2d = cface + if len(cedge) > 0: + self._edgecolors2d = cedge[face_order] + else: + self._edgecolors2d = cedge + + if self._sort_zpos is not None: + zvec = np.array([[0], [0], [self._sort_zpos], [1]]) + ztrans = proj3d._proj_transform_vec(zvec, self.axes.M) + return ztrans[2][0] + elif num_clipped_faces > 0: + return np.min(np.concatenate(pzs)) + else: + return np.nan + # Some faces might contain masked vertices, so we want to ignore any # errors that those might cause with np.errstate(invalid='ignore', divide='ignore'): diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index 45f9319355e0..e6f83ca0b7f8 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -2371,7 +2371,8 @@ def fill_between(self, x1, y1, z1, x2, y2, z2, *, return polyc def plot_surface(self, X, Y, Z, *, norm=None, vmin=None, - vmax=None, lightsource=None, axlim_clip=False, **kwargs): + vmax=None, lightsource=None, axlim_clip=False, + axlim_clip_mode='hide', **kwargs): """ Create a surface plot. @@ -2437,14 +2438,24 @@ def plot_surface(self, X, Y, Z, *, norm=None, vmin=None, 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. + Whether to apply axes-limit clipping to patches. .. versionadded:: 3.10 + axlim_clip_mode : {'hide', 'clip'}, default: 'hide' + The clipping strategy used when *axlim_clip* is True. + + - 'hide': hide patches with a vertex outside the axes view + limits. This preserves the existing behavior. + - 'clip': geometrically clip patches to the axes view-limit box. + **kwargs Other keyword arguments are forwarded to `.Poly3DCollection`. """ + _api.check_in_list(['hide', 'clip'], + axlim_clip_mode=axlim_clip_mode) + had_data = self.has_data() if Z.ndim != 2: @@ -2541,9 +2552,12 @@ 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, axlim_clip=axlim_clip, **kwargs) + lightsource=lightsource, axlim_clip=axlim_clip, + axlim_clip_mode=axlim_clip_mode, **kwargs) elif cmap: - polyc = art3d.Poly3DCollection(polys, axlim_clip=axlim_clip, **kwargs) + polyc = art3d.Poly3DCollection( + polys, axlim_clip=axlim_clip, + axlim_clip_mode=axlim_clip_mode, **kwargs) # can't always vectorize, because polys might be jagged if isinstance(polys, np.ndarray): avg_z = polys[..., 2].mean(axis=-1) @@ -2562,14 +2576,16 @@ def plot_surface(self, X, Y, Z, *, norm=None, vmin=None, polyc = art3d.Poly3DCollection( polys, facecolors=color, shade=shade, lightsource=lightsource, - axlim_clip=axlim_clip, **kwargs) + axlim_clip=axlim_clip, axlim_clip_mode=axlim_clip_mode, + **kwargs) self.add_collection(polyc, autolim="_datalim_only") self.auto_scale_xyz(X, Y, Z, had_data) return polyc - def plot_wireframe(self, X, Y, Z, *, axlim_clip=False, **kwargs): + def plot_wireframe(self, X, Y, Z, *, axlim_clip=False, + axlim_clip_mode='hide', **kwargs): """ Plot a 3D wireframe. @@ -2586,11 +2602,18 @@ def plot_wireframe(self, X, Y, Z, *, axlim_clip=False, **kwargs): Data values. axlim_clip : bool, default: False - Whether to hide lines and patches with vertices outside the axes - view limits. + Whether to apply axes-limit clipping to lines. .. versionadded:: 3.10 + axlim_clip_mode : {'hide', 'clip'}, default: 'hide' + The clipping strategy used when *axlim_clip* is True. + + - 'hide': hide line segments with a vertex outside the axes view + limits. This preserves the existing behavior. + - 'clip': geometrically clip line segments to the axes view-limit + box. + 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 @@ -2613,6 +2636,8 @@ def plot_wireframe(self, X, Y, Z, *, axlim_clip=False, **kwargs): Other keyword arguments are forwarded to `.Line3DCollection`. """ + _api.check_in_list(['hide', 'clip'], axlim_clip_mode=axlim_clip_mode) + had_data = self.has_data() if Z.ndim != 2: raise ValueError("Argument Z must be 2-dimensional.") @@ -2689,7 +2714,9 @@ def plot_wireframe(self, X, Y, Z, *, axlim_clip=False, **kwargs): had_data=True) lines = list(row_lines) + list(col_lines) - linec = art3d.Line3DCollection(lines, axlim_clip=axlim_clip, **kwargs) + linec = art3d.Line3DCollection( + lines, axlim_clip=axlim_clip, + axlim_clip_mode=axlim_clip_mode, **kwargs) self.add_collection(linec, autolim="_datalim_only") return linec @@ -3479,7 +3506,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, - axlim_clip=False, **kwargs): + axlim_clip=False, axlim_clip_mode='hide', **kwargs): """ Plot a 3D field of arrows. @@ -3512,10 +3539,13 @@ def quiver(self, X, Y, Z, U, V, W, *, the lengths defined by *u*, *v*, and *w*. axlim_clip : bool, default: False - Whether to hide arrows with points outside the axes view limits. + Whether to apply axes-limit clipping to arrows. .. versionadded:: 3.10 + axlim_clip_mode : {'hide', 'clip'}, default: 'hide' + The clipping strategy used when *axlim_clip* is True. + data : indexable object, optional DATA_PARAMETER_PLACEHOLDER @@ -3524,6 +3554,8 @@ def quiver(self, X, Y, Z, U, V, W, *, :class:`.Line3DCollection` """ + _api.check_in_list(['hide', 'clip'], axlim_clip_mode=axlim_clip_mode) + def calc_arrows(UVW): # get unit direction vector perpendicular to (u, v, w) x = UVW[:, 0] @@ -3559,7 +3591,9 @@ def calc_arrows(UVW): if any(len(v) == 0 for v in input_args): # No quivers, so just make an empty collection and return early - linec = art3d.Line3DCollection([], **kwargs) + linec = art3d.Line3DCollection( + [], axlim_clip=axlim_clip, + axlim_clip_mode=axlim_clip_mode, **kwargs) self.add_collection(linec, autolim="_datalim_only") return linec @@ -3597,7 +3631,9 @@ def calc_arrows(UVW): else: lines = [] - linec = art3d.Line3DCollection(lines, axlim_clip=axlim_clip, **kwargs) + linec = art3d.Line3DCollection( + lines, axlim_clip=axlim_clip, + axlim_clip_mode=axlim_clip_mode, **kwargs) self.add_collection(linec, autolim="_datalim_only") self.auto_scale_xyz(XYZ[:, 0], XYZ[:, 1], XYZ[:, 2], had_data) @@ -4172,7 +4208,8 @@ 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', axlim_clip=False): + bottom=0, label=None, orientation='z', axlim_clip=False, + axlim_clip_mode='hide'): """ Create a 3D stem plot. @@ -4223,10 +4260,13 @@ def stem(self, x, y, z, *, linefmt='C0-', markerfmt='C0o', basefmt='C3-', The direction along which stems are drawn. axlim_clip : bool, default: False - Whether to hide stems that are outside the axes limits. + Whether to apply axes-limit clipping to stems. .. versionadded:: 3.10 + axlim_clip_mode : {'hide', 'clip'}, default: 'hide' + The clipping strategy used when *axlim_clip* is True. + data : indexable object, optional DATA_PARAMETER_PLACEHOLDER @@ -4243,6 +4283,8 @@ def stem(self, x, y, z, *, linefmt='C0-', markerfmt='C0o', basefmt='C3-', from matplotlib.container import StemContainer + _api.check_in_list(['hide', 'clip'], axlim_clip_mode=axlim_clip_mode) + had_data = self.has_data() _api.check_in_list(['x', 'y', 'z'], orientation=orientation) @@ -4278,7 +4320,7 @@ def stem(self, x, y, z, *, linefmt='C0-', markerfmt='C0o', basefmt='C3-', zdir=orientation, label='_nolegend_') stemlines = art3d.Line3DCollection( lines, linestyles=linestyle, colors=linecolor, label='_nolegend_', - axlim_clip=axlim_clip) + axlim_clip=axlim_clip, axlim_clip_mode=axlim_clip_mode) self.add_collection(stemlines, autolim="_datalim_only") markerline, = self.plot(x, y, z, markerfmt, label='_nolegend_') diff --git a/lib/mpl_toolkits/mplot3d/tests/test_art3d.py b/lib/mpl_toolkits/mplot3d/tests/test_art3d.py index aca943f9e0c0..aa6d74608a30 100644 --- a/lib/mpl_toolkits/mplot3d/tests/test_art3d.py +++ b/lib/mpl_toolkits/mplot3d/tests/test_art3d.py @@ -10,6 +10,8 @@ Line3DCollection, Poly3DCollection, _all_points_on_plane, + _clip_line_segment_to_box, + _clip_polygon_to_box, ) @@ -108,6 +110,95 @@ def test_all_points_on_plane(): assert _all_points_on_plane(*points.T) + +def test_clip_polygon_to_box_xmin(): + poly = np.array([ + [-1, 0, 0], + [1, 0, 0], + [1, 1, 0], + [-1, 1, 0], + ], dtype=float) + + clipped = _clip_polygon_to_box( + poly, + xlim=(0, 1), + ylim=(-1, 2), + zlim=(-1, 1), + ) + + expected = np.array([ + [0, 0, 0], + [1, 0, 0], + [1, 1, 0], + [0, 1, 0], + ], dtype=float) + + nptest.assert_allclose(clipped, expected) + + +def test_clip_polygon_to_box_fully_outside(): + poly = np.array([ + [-2, 0, 0], + [-1, 0, 0], + [-1, 1, 0], + [-2, 1, 0], + ], dtype=float) + + clipped = _clip_polygon_to_box( + poly, + xlim=(0, 1), + ylim=(-1, 2), + zlim=(-1, 1), + ) + + assert clipped.shape == (0, 3) + + +def test_clip_polygon_to_box_zmin(): + poly = np.array([ + [0, 0, -1], + [1, 0, 1], + [0, 1, 1], + ], dtype=float) + + clipped = _clip_polygon_to_box( + poly, + xlim=(-1, 2), + ylim=(-1, 2), + zlim=(0, 2), + ) + + assert clipped.shape[1] == 3 + assert len(clipped) >= 3 + assert np.all(clipped[:, 2] >= 0) + assert np.all(clipped[:, 2] <= 2) + + + + +def test_clip_line_segment_to_box_crossing(): + segment = _clip_line_segment_to_box( + [-1, 0.5, 0.5], [2, 0.5, 0.5], + xlim=(0, 1), ylim=(0, 1), zlim=(0, 1), + ) + + expected = np.array([ + [0, 0.5, 0.5], + [1, 0.5, 0.5], + ], dtype=float) + + nptest.assert_allclose(segment, expected) + + +def test_clip_line_segment_to_box_fully_outside(): + segment = _clip_line_segment_to_box( + [-2, 0.5, 0.5], [-1, 0.5, 0.5], + xlim=(0, 1), ylim=(0, 1), zlim=(0, 1), + ) + + assert segment.shape == (0, 3) + + def test_generate_normals(): # Smoke test for https://github.com/matplotlib/matplotlib/issues/29156 vertices = ((0, 0, 0), (0, 5, 0), (5, 5, 0), (5, 0, 0)) @@ -117,3 +208,63 @@ def test_generate_normals(): ax = fig.add_subplot(projection='3d') ax.add_collection3d(shape) plt.draw() + + +def test_clip_polygon_to_box_near_parallel(): + # Ensure near-parallel edge (very small denom) does not error and + # produced vertices lie within the box. + poly = np.array([ + [-1e-12, 0, 0], + [1, 0, 0], + [1, 1, 0], + [-1, 1, 0], + ], dtype=float) + + clipped = _clip_polygon_to_box( + poly, + xlim=(0, 1), + ylim=(-1, 2), + zlim=(-1, 1), + ) + + assert clipped.shape[1] == 3 + assert len(clipped) >= 3 + assert np.all(clipped[:, 0] >= 0) + assert np.all(clipped[:, 0] <= 1) + + +def test_clip_polygon_to_box_degenerate_collinear(): + # Collinear polygon should be considered degenerate after clipping + # and yield fewer than 3 vertices. + poly = np.array([ + [0, 0, 0], + [1, 0, 0], + [2, 0, 0], + ], dtype=float) + + clipped = _clip_polygon_to_box( + poly, + xlim=(0.5, 1.5), + ylim=(-1, 1), + zlim=(-1, 1), + ) + + assert clipped.shape[0] < 3 + + +def test_clip_polygon_to_box_with_nan(): + # Inputs containing NaNs must be treated as invalid and return empty. + poly = np.array([ + [0, 0, 0], + [1, np.nan, 0], + [0, 1, 0], + ], dtype=float) + + clipped = _clip_polygon_to_box( + poly, + xlim=(-1, 2), + ylim=(-1, 2), + zlim=(-1, 2), + ) + + assert clipped.shape == (0, 3) diff --git a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py index 2a5593a641c9..7f331776cf77 100644 --- a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py +++ b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py @@ -660,6 +660,92 @@ def test_surface3d(): fig.colorbar(surf, shrink=0.5, aspect=5) +def test_plot_surface_axlim_clip_mode_clip_smoke(): + fig = plt.figure() + ax = fig.add_subplot(projection="3d") + + x = np.linspace(-2, 2, 9) + y = np.linspace(-2, 2, 9) + X, Y = np.meshgrid(x, y) + Z = np.sin(np.hypot(X, Y)) + + ax.plot_surface( + X, Y, Z, + axlim_clip=True, + axlim_clip_mode="clip", + color="C0", + linewidth=0, + ) + + ax.set_xlim(-1, 1) + ax.set_ylim(-1, 1) + ax.set_zlim(-0.5, 0.5) + + fig.canvas.draw() + + +def test_plot_surface_axlim_clip_mode_invalid(): + fig = plt.figure() + ax = fig.add_subplot(projection="3d") + + X, Y = np.meshgrid([0, 1], [0, 1]) + Z = X + Y + + with pytest.raises(ValueError, match="axlim_clip_mode"): + ax.plot_surface(X, Y, Z, axlim_clip_mode="bad") + + +def test_plot_wireframe_axlim_clip_mode_clip_smoke(): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + + x = np.linspace(-2, 2, 9) + y = np.linspace(-2, 2, 9) + X, Y = np.meshgrid(x, y) + Z = np.sin(np.hypot(X, Y)) + + ax.plot_wireframe( + X, Y, Z, + axlim_clip=True, + axlim_clip_mode='clip', + ) + + ax.set_xlim(-1, 1) + ax.set_ylim(-1, 1) + ax.set_zlim(-0.5, 0.5) + + fig.canvas.draw() + + +def test_plot_wireframe_axlim_clip_mode_invalid(): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + + X, Y = np.meshgrid([0, 1], [0, 1]) + Z = X + Y + + with pytest.raises(ValueError, match='axlim_clip_mode'): + ax.plot_wireframe(X, Y, Z, axlim_clip_mode='bad') + + +def test_quiver_axlim_clip_mode_clip_smoke(): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + + ax.quiver( + [-2, 0, 2], [0, 0, 0], [0, 0, 0], + [1, 1, 1], [0, 0, 0], [0, 0, 0], + axlim_clip=True, + axlim_clip_mode='clip', + ) + + ax.set_xlim(-1, 1) + ax.set_ylim(-1, 1) + ax.set_zlim(-1, 1) + + fig.canvas.draw() + + @image_comparison(['surface3d_label_offset_tick_position.png'], style='mpl20') def test_surface3d_label_offset_tick_position(): ax = plt.figure().add_subplot(projection="3d")