From 56ec318efa8080601e4fbf2bb8f4c58a0e99794b Mon Sep 17 00:00:00 2001 From: Vagner Messias Date: Mon, 26 May 2025 00:41:40 -0300 Subject: [PATCH 1/8] Bbox refactor --- lib/mpl_toolkits/mplot3d/art3d.py | 37 ++++++++++++++++++++++++++++- lib/mpl_toolkits/mplot3d/axes3d.py | 23 ++++++++---------- lib/mpl_toolkits/mplot3d/bbox3d.py | 38 ++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 14 deletions(-) create mode 100644 lib/mpl_toolkits/mplot3d/bbox3d.py diff --git a/lib/mpl_toolkits/mplot3d/art3d.py b/lib/mpl_toolkits/mplot3d/art3d.py index 483fd09be163..c16567991042 100644 --- a/lib/mpl_toolkits/mplot3d/art3d.py +++ b/lib/mpl_toolkits/mplot3d/art3d.py @@ -20,7 +20,7 @@ Collection, LineCollection, PolyCollection, PatchCollection, PathCollection) from matplotlib.patches import Patch from . import proj3d - +from .bbox3d import _Bbox3d def _norm_angle(a): """Return the given angle normalized to -180 < *a* <= 180 degrees.""" @@ -97,6 +97,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. @@ -330,6 +340,10 @@ def draw(self, renderer): self.set_data(xs, ys) 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 +527,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 +609,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 +674,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 +856,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 +1115,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 +1496,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..a5891c6679b0 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -607,6 +607,13 @@ def auto_scale_xyz(self, X, Y, Z=None, had_data=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 auto_scale_lim(self, bbox3d, had_data=False): + self.xy_dataLim.update_from_bbox(bbox3d.to_bbox_xy()) + self.zz_dataLim.update_from_bbox(bbox3d.to_bbox_zz()) + if not had_data: + self._xy_dataLim_set = True + self._zz_dataLim_set = True def autoscale_view(self, tight=None, scalex=True, scaley=True, scalez=True): @@ -2887,19 +2894,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..c5b05944517d --- /dev/null +++ b/lib/mpl_toolkits/mplot3d/bbox3d.py @@ -0,0 +1,38 @@ +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.xmax), (self.ymin, self.ymax))) + + def to_bbox_zz(self): + # first component contains z, second is unused + return Bbox(((self.zmin, self.zmax), (0, 0))) \ No newline at end of file From b6e8b433d05b7b88677a7f861444f95d3a98636a Mon Sep 17 00:00:00 2001 From: Vagner Messias Date: Tue, 27 May 2025 14:16:51 -0300 Subject: [PATCH 2/8] ruff checks --- lib/mpl_toolkits/mplot3d/art3d.py | 3 ++- lib/mpl_toolkits/mplot3d/axes3d.py | 2 +- lib/mpl_toolkits/mplot3d/bbox3d.py | 3 ++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/mpl_toolkits/mplot3d/art3d.py b/lib/mpl_toolkits/mplot3d/art3d.py index c16567991042..4010910fa9a1 100644 --- a/lib/mpl_toolkits/mplot3d/art3d.py +++ b/lib/mpl_toolkits/mplot3d/art3d.py @@ -22,6 +22,7 @@ from . import proj3d from .bbox3d import _Bbox3d + def _norm_angle(a): """Return the given angle normalized to -180 < *a* <= 180 degrees.""" a = (a + 360) % 360 @@ -340,7 +341,7 @@ def draw(self, renderer): self.set_data(xs, ys) 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))) diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index a5891c6679b0..f45ec3cea0b6 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -607,7 +607,7 @@ def auto_scale_xyz(self, X, Y, Z=None, had_data=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 auto_scale_lim(self, bbox3d, had_data=False): self.xy_dataLim.update_from_bbox(bbox3d.to_bbox_xy()) self.zz_dataLim.update_from_bbox(bbox3d.to_bbox_zz()) diff --git a/lib/mpl_toolkits/mplot3d/bbox3d.py b/lib/mpl_toolkits/mplot3d/bbox3d.py index c5b05944517d..083bb76d0cb7 100644 --- a/lib/mpl_toolkits/mplot3d/bbox3d.py +++ b/lib/mpl_toolkits/mplot3d/bbox3d.py @@ -1,5 +1,6 @@ from matplotlib.transforms import Bbox + class _Bbox3d: """ A helper class to represent a 3D bounding box. @@ -35,4 +36,4 @@ def to_bbox_xy(self): def to_bbox_zz(self): # first component contains z, second is unused - return Bbox(((self.zmin, self.zmax), (0, 0))) \ No newline at end of file + return Bbox(((self.zmin, self.zmax), (0, 0))) From 8d39945119e52cf155144b3ffb7acb3861e69a38 Mon Sep 17 00:00:00 2001 From: Vagner Messias Date: Tue, 27 May 2025 14:20:03 -0300 Subject: [PATCH 3/8] Update meson.build --- lib/mpl_toolkits/mplot3d/meson.build | 1 + 1 file changed, 1 insertion(+) 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') From 109aadcb8ad5e18495174c52e8fc8aa9530583aa Mon Sep 17 00:00:00 2001 From: Vagner Messias Date: Tue, 27 May 2025 14:45:17 -0300 Subject: [PATCH 4/8] Fixing auto_scale_lim --- lib/mpl_toolkits/mplot3d/axes3d.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index f45ec3cea0b6..3c2592db546f 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -609,12 +609,23 @@ def auto_scale_xyz(self, X, Y, Z=None, had_data=None): self.autoscale_view() def auto_scale_lim(self, bbox3d, had_data=False): - self.xy_dataLim.update_from_bbox(bbox3d.to_bbox_xy()) - self.zz_dataLim.update_from_bbox(bbox3d.to_bbox_zz()) + """ + 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 = Bbox.union([self.xy_dataLim, bbox3d.to_bbox_xy()]) + self.zz_dataLim = Bbox.union([self.zz_dataLim, bbox3d.to_bbox_zz()]) if not had_data: self._xy_dataLim_set = True self._zz_dataLim_set = True + def autoscale_view(self, tight=None, scalex=True, scaley=True, scalez=True): """ From 7b8c5e1f2d3a3a0c45deabf7210b79b7df9fd02f Mon Sep 17 00:00:00 2001 From: Vagner Messias Date: Thu, 29 May 2025 12:55:11 -0300 Subject: [PATCH 5/8] Updating tests --- lib/mpl_toolkits/mplot3d/axes3d.py | 1 + lib/mpl_toolkits/mplot3d/tests/test_axes3d.py | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index 3c2592db546f..f57c834f459e 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -624,6 +624,7 @@ def auto_scale_lim(self, bbox3d, had_data=False): if not had_data: self._xy_dataLim_set = True self._zz_dataLim_set = True + self.autoscale_view() def autoscale_view(self, tight=None, diff --git a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py index 79c7baba9bd1..21e80c0a215f 100644 --- a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py +++ b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py @@ -1111,9 +1111,9 @@ def test_line3dCollection_autoscaling(): lc = art3d.Line3DCollection(lines) ax.add_collection3d(lc) - assert np.allclose(ax.get_xlim3d(), (-0.041666666666666664, 2.0416666666666665)) + assert np.allclose(ax.get_xlim3d(), (-0.020833333333333332, 1.0208333333333333)) assert np.allclose(ax.get_ylim3d(), (-0.08333333333333333, 4.083333333333333)) - assert np.allclose(ax.get_zlim3d(), (-0.10416666666666666, 5.104166666666667)) + assert np.allclose(ax.get_zlim3d(), (-0.020833333333333332, 1.0208333333333333)) def test_poly3dCollection_autoscaling(): @@ -1124,7 +1124,7 @@ def test_poly3dCollection_autoscaling(): ax.add_collection3d(col) assert np.allclose(ax.get_xlim3d(), (-0.020833333333333332, 1.0208333333333333)) assert np.allclose(ax.get_ylim3d(), (-0.020833333333333332, 1.0208333333333333)) - assert np.allclose(ax.get_zlim3d(), (-0.0833333333333333, 4.083333333333333)) + assert np.allclose(ax.get_zlim3d(), (-0.020833333333333332, 1.0208333333333333)) @mpl3d_image_comparison(['axes3d_labelpad.png'], From 224f9c040407dd21946b54ca14bc9b28a2ca32da Mon Sep 17 00:00:00 2001 From: Vagner Messias Date: Thu, 29 May 2025 13:04:40 -0300 Subject: [PATCH 6/8] Revert "Updating tests" This reverts commit 7b8c5e1f2d3a3a0c45deabf7210b79b7df9fd02f. --- lib/mpl_toolkits/mplot3d/axes3d.py | 1 - lib/mpl_toolkits/mplot3d/tests/test_axes3d.py | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index f57c834f459e..3c2592db546f 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -624,7 +624,6 @@ def auto_scale_lim(self, bbox3d, had_data=False): if not had_data: self._xy_dataLim_set = True self._zz_dataLim_set = True - self.autoscale_view() def autoscale_view(self, tight=None, diff --git a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py index 21e80c0a215f..79c7baba9bd1 100644 --- a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py +++ b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py @@ -1111,9 +1111,9 @@ def test_line3dCollection_autoscaling(): lc = art3d.Line3DCollection(lines) ax.add_collection3d(lc) - assert np.allclose(ax.get_xlim3d(), (-0.020833333333333332, 1.0208333333333333)) + assert np.allclose(ax.get_xlim3d(), (-0.041666666666666664, 2.0416666666666665)) assert np.allclose(ax.get_ylim3d(), (-0.08333333333333333, 4.083333333333333)) - assert np.allclose(ax.get_zlim3d(), (-0.020833333333333332, 1.0208333333333333)) + assert np.allclose(ax.get_zlim3d(), (-0.10416666666666666, 5.104166666666667)) def test_poly3dCollection_autoscaling(): @@ -1124,7 +1124,7 @@ def test_poly3dCollection_autoscaling(): ax.add_collection3d(col) assert np.allclose(ax.get_xlim3d(), (-0.020833333333333332, 1.0208333333333333)) assert np.allclose(ax.get_ylim3d(), (-0.020833333333333332, 1.0208333333333333)) - assert np.allclose(ax.get_zlim3d(), (-0.020833333333333332, 1.0208333333333333)) + assert np.allclose(ax.get_zlim3d(), (-0.0833333333333333, 4.083333333333333)) @mpl3d_image_comparison(['axes3d_labelpad.png'], From 8f9e5d647c353f07a259da472376850ef5ccdbf5 Mon Sep 17 00:00:00 2001 From: Vagner Messias Date: Sat, 31 May 2025 15:43:46 -0300 Subject: [PATCH 7/8] Implementing update_from_bbox --- lib/matplotlib/transforms.py | 22 ++++++++++++++++++++++ lib/matplotlib/transforms.pyi | 1 + lib/mpl_toolkits/mplot3d/axes3d.py | 5 +++-- lib/mpl_toolkits/mplot3d/bbox3d.py | 4 ++-- 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/lib/matplotlib/transforms.py b/lib/matplotlib/transforms.py index 2cca56f04457..5563715772e4 100644 --- a/lib/matplotlib/transforms.py +++ b/lib/matplotlib/transforms.py @@ -965,6 +965,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 551487a11c60..30bfbeb39a3b 100644 --- a/lib/matplotlib/transforms.pyi +++ b/lib/matplotlib/transforms.pyi @@ -131,6 +131,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/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index 3c2592db546f..c1d3e6dc1d8a 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -619,11 +619,12 @@ def auto_scale_lim(self, bbox3d, had_data=False): had_data : bool, default: False Whether the axes already had data limits set before. """ - self.xy_dataLim = Bbox.union([self.xy_dataLim, bbox3d.to_bbox_xy()]) - self.zz_dataLim = Bbox.union([self.zz_dataLim, bbox3d.to_bbox_zz()]) + 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, diff --git a/lib/mpl_toolkits/mplot3d/bbox3d.py b/lib/mpl_toolkits/mplot3d/bbox3d.py index 083bb76d0cb7..ba9953c83aa0 100644 --- a/lib/mpl_toolkits/mplot3d/bbox3d.py +++ b/lib/mpl_toolkits/mplot3d/bbox3d.py @@ -32,8 +32,8 @@ def __init__(self, points): (self.zmin, self.zmax)) = points def to_bbox_xy(self): - return Bbox(((self.xmin, self.xmax), (self.ymin, self.ymax))) + 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, self.zmax), (0, 0))) + return Bbox(((self.zmin, 0), (self.zmax, 0))) From 8dc8584b2b152e28755d5858090ee09c494eb5d3 Mon Sep 17 00:00:00 2001 From: Vagner Messias Date: Sat, 31 May 2025 16:30:16 -0300 Subject: [PATCH 8/8] Fix in Docstring --- lib/matplotlib/transforms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/matplotlib/transforms.py b/lib/matplotlib/transforms.py index 5563715772e4..f2aed919b3e7 100644 --- a/lib/matplotlib/transforms.py +++ b/lib/matplotlib/transforms.py @@ -969,7 +969,7 @@ 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`. + This is equivalent to performing an in-place union of this Bbox with *bbox*. Parameters ----------