diff --git a/lib/matplotlib/transforms.py b/lib/matplotlib/transforms.py index 7228f05bcf9e..7e88da1878e2 100644 --- a/lib/matplotlib/transforms.py +++ b/lib/matplotlib/transforms.py @@ -963,6 +963,28 @@ def update_from_data_xy(self, xy, ignore=None, updatex=True, updatey=True): self.update_from_path(path, ignore=ignore, updatex=updatex, updatey=updatey) + def update_from_bbox(self, bbox, ignore=False, updatex=True, updatey=True): + """ + Update the Bbox to include another Bbox. + + This is equivalent to performing an in-place union of this Bbox with *bbox*. + + Parameters + ---------- + bbox3d : Bbox3d + The Bbox to merge into this one. + ignore : bool, default: False + Whether to ignore the current bounds (start fresh) or not. + updatex, updatey : bool, default: True + Whether to update the x/y dimensions. + """ + if not updatex and not updatey: + return + + vertices = np.array([[bbox.x0, bbox.y0],[bbox.x1, bbox.y1]]) + path = Path(vertices) + self.update_from_path(path, ignore=ignore, updatex=updatex, updatey=updatey) + @BboxBase.x0.setter def x0(self, val): self._points[0, 0] = val diff --git a/lib/matplotlib/transforms.pyi b/lib/matplotlib/transforms.pyi index 07d299be297c..ffbee8303d65 100644 --- a/lib/matplotlib/transforms.pyi +++ b/lib/matplotlib/transforms.pyi @@ -129,6 +129,7 @@ class Bbox(BboxBase): updatex: bool = ..., updatey: bool = ..., ) -> None: ... + def update_from_bbox(self, bbox: Bbox, ignore: bool = ..., updatex: bool = ..., updatey: bool = ...) -> None: ... @property def minpos(self) -> float: ... @property diff --git a/lib/mpl_toolkits/mplot3d/art3d.py b/lib/mpl_toolkits/mplot3d/art3d.py index 483fd09be163..4010910fa9a1 100644 --- a/lib/mpl_toolkits/mplot3d/art3d.py +++ b/lib/mpl_toolkits/mplot3d/art3d.py @@ -20,6 +20,7 @@ Collection, LineCollection, PolyCollection, PatchCollection, PathCollection) from matplotlib.patches import Patch from . import proj3d +from .bbox3d import _Bbox3d def _norm_angle(a): @@ -97,6 +98,16 @@ def _viewlim_mask(xs, ys, zs, axes): return mask +def create_bbox3d_from_array(arr): + arr = np.asarray(arr) + if arr.ndim != 2 or arr.shape[1] != 3: + raise ValueError("Expected array of shape (N, 3)") + xmin, xmax = np.min(arr[:, 0]), np.max(arr[:, 0]) + ymin, ymax = np.min(arr[:, 1]), np.max(arr[:, 1]) + zmin, zmax = np.min(arr[:, 2]), np.max(arr[:, 2]) + return _Bbox3d(((xmin, xmax), (ymin, ymax), (zmin, zmax))) + + class Text3D(mtext.Text): """ Text object with 3D position and direction. @@ -331,6 +342,10 @@ def draw(self, renderer): super().draw(renderer) self.stale = False + def _get_datalim3d(self): + xs, ys, zs = self._verts3d + return create_bbox3d_from_array(np.column_stack((xs, ys, zs))) + def line_2d_to_3d(line, zs=0, zdir='z', axlim_clip=False): """ @@ -513,6 +528,10 @@ def do_3d_projection(self): minz = np.nan return minz + def _get_datalim3d(self): + segments = np.concatenate(self._segments3d) + return create_bbox3d_from_array(segments) + def line_collection_2d_to_3d(col, zs=0, zdir='z', axlim_clip=False): """Convert a `.LineCollection` to a `.Line3DCollection` object.""" @@ -591,6 +610,9 @@ def do_3d_projection(self): self._path2d = mpath.Path(np.ma.column_stack([vxs, vys])) return min(vzs) + def _get_datalim3d(self): + return create_bbox3d_from_array(self._segment3d) + class PathPatch3D(Patch3D): """ @@ -653,6 +675,9 @@ def do_3d_projection(self): self._path2d = mpath.Path(np.ma.column_stack([vxs, vys]), self._code3d) return min(vzs) + def _get_datalim3d(self): + return create_bbox3d_from_array(self._segment3d) + def _get_patch_verts(patch): """Return a list of vertices for the path of a patch.""" @@ -832,6 +857,10 @@ def get_edgecolor(self): return self.get_facecolor() return self._maybe_depth_shade_and_sort_colors(super().get_edgecolor()) + def _get_datalim3d(self): + xs, ys, zs = self._offsets3d + return create_bbox3d_from_array(np.column_stack((xs, ys, zs))) + def _get_data_scale(X, Y, Z): """ @@ -1087,6 +1116,10 @@ def get_edgecolor(self): return self.get_facecolor() return self._maybe_depth_shade_and_sort_colors(super().get_edgecolor()) + def _get_datalim3d(self): + xs, ys, zs = self._offsets3d + return create_bbox3d_from_array(np.column_stack((xs, ys, zs))) + def patch_collection_2d_to_3d( col, @@ -1464,6 +1497,9 @@ def get_edgecolor(self): self.do_3d_projection() return np.asarray(self._edgecolors2d) + def _get_datalim3d(self): + return create_bbox3d_from_array(self._faces.reshape(-1, 3)) + def poly_collection_2d_to_3d(col, zs=0, zdir='z', axlim_clip=False): """ diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index 55b204022fb9..c1d3e6dc1d8a 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -608,6 +608,25 @@ def auto_scale_xyz(self, X, Y, Z=None, had_data=None): # Let autoscale_view figure out how to use this data. self.autoscale_view() + def auto_scale_lim(self, bbox3d, had_data=False): + """ + Expand the 3D axes data limits to include the given Bbox3d. + + Parameters + ---------- + bbox3d : _Bbox3d + The 3D bounding box to incorporate into the data limits. + had_data : bool, default: False + Whether the axes already had data limits set before. + """ + self.xy_dataLim.update_from_bbox(bbox3d.to_bbox_xy(), ignore=not had_data) + self.zz_dataLim.update_from_bbox(bbox3d.to_bbox_zz(), ignore=not had_data) + if not had_data: + self._xy_dataLim_set = True + self._zz_dataLim_set = True + self.autoscale_view() + + def autoscale_view(self, tight=None, scalex=True, scaley=True, scalez=True): """ @@ -2887,19 +2906,9 @@ def add_collection3d(self, col, zs=0, zdir='z', autolim=True, *, axlim_clip=axlim_clip) col.set_sort_zpos(zsortval) - if autolim: - if isinstance(col, art3d.Line3DCollection): - self.auto_scale_xyz(*np.array(col._segments3d).transpose(), - had_data=had_data) - elif isinstance(col, art3d.Poly3DCollection): - self.auto_scale_xyz(col._faces[..., 0], - col._faces[..., 1], - col._faces[..., 2], had_data=had_data) - elif isinstance(col, art3d.Patch3DCollection): - pass - # FIXME: Implement auto-scaling function for Patch3DCollection - # Currently unable to do so due to issues with Patch3DCollection - # See https://github.com/matplotlib/matplotlib/issues/14298 for details + if autolim and hasattr(col, "_get_datalim3d"): + bbox3d = col._get_datalim3d() + self.auto_scale_lim(bbox3d, had_data=had_data) collection = super().add_collection(col) return collection diff --git a/lib/mpl_toolkits/mplot3d/bbox3d.py b/lib/mpl_toolkits/mplot3d/bbox3d.py new file mode 100644 index 000000000000..ba9953c83aa0 --- /dev/null +++ b/lib/mpl_toolkits/mplot3d/bbox3d.py @@ -0,0 +1,39 @@ +from matplotlib.transforms import Bbox + + +class _Bbox3d: + """ + A helper class to represent a 3D bounding box. + + This class stores the minimum and maximum extents of data in 3D space + (xmin, xmax, ymin, ymax, zmin, zmax). It provides methods to convert + these extents into 2D bounding boxes (`Bbox`) for compatibility with + existing matplotlib functionality. + + Attributes + ---------- + xmin, xmax : float + The minimum and maximum extents along the x-axis. + ymin, ymax : float + The minimum and maximum extents along the y-axis. + zmin, zmax : float + The minimum and maximum extents along the z-axis. + + Methods + ------- + to_bbox_xy(): + Converts the x and y extents into a 2D `Bbox`. + to_bbox_zz(): + Converts the z extents into a 2D `Bbox`, with the y-component unused. + """ + def __init__(self, points): + ((self.xmin, self.xmax), + (self.ymin, self.ymax), + (self.zmin, self.zmax)) = points + + def to_bbox_xy(self): + return Bbox(((self.xmin, self.ymin), (self.xmax, self.ymax))) + + def to_bbox_zz(self): + # first component contains z, second is unused + return Bbox(((self.zmin, 0), (self.zmax, 0))) diff --git a/lib/mpl_toolkits/mplot3d/meson.build b/lib/mpl_toolkits/mplot3d/meson.build index 2d9cade6c93c..b77ce337c7c3 100644 --- a/lib/mpl_toolkits/mplot3d/meson.build +++ b/lib/mpl_toolkits/mplot3d/meson.build @@ -4,6 +4,7 @@ python_sources = [ 'axes3d.py', 'axis3d.py', 'proj3d.py', + 'bbox3d.py' ] py3.install_sources(python_sources, subdir: 'mpl_toolkits/mplot3d')