diff --git a/doc/missing-references.json b/doc/missing-references.json index 1b0a6f9ef226..9b65f4bc4ab9 100644 --- a/doc/missing-references.json +++ b/doc/missing-references.json @@ -158,6 +158,12 @@ "lib/matplotlib/collections.py:docstring of matplotlib.collections.PolyCollection:1", "lib/matplotlib/collections.py:docstring of matplotlib.collections.RegularPolyCollection:1" ], + "matplotlib.collections._CollectionWithWidthHeightAngle": [ + "doc/api/artist_api.rst:202", + "doc/api/collections_api.rst:13", + "lib/matplotlib/collections.py:docstring of matplotlib.collections.EllipseCollection:1", + "lib/matplotlib/collections.py:docstring of matplotlib.collections.RectangleCollection:1" + ], "matplotlib.collections._MeshData": [ "doc/api/artist_api.rst:202", "doc/api/collections_api.rst:13", @@ -326,6 +332,18 @@ "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" ], + "matplotlib.collections._CollectionWithWidthHeightAngle.set_angles": [ + "lib/matplotlib/collections.py:docstring of matplotlib.artist.EllipseCollection.set:15", + "lib/matplotlib/collections.py:docstring of matplotlib.artist.RectangleCollection.set:15" + ], + "matplotlib.collections._CollectionWithWidthHeightAngle.set_heights": [ + "lib/matplotlib/collections.py:docstring of matplotlib.artist.EllipseCollection.set:31", + "lib/matplotlib/collections.py:docstring of matplotlib.artist.RectangleCollection.set:31" + ], + "matplotlib.collections._CollectionWithWidthHeightAngle.set_widths": [ + "lib/matplotlib/collections.py:docstring of matplotlib.artist.EllipseCollection.set:52", + "lib/matplotlib/collections.py:docstring of matplotlib.artist.RectangleCollection.set:52" + ], "matplotlib.collections._MeshData.set_array": [ "lib/matplotlib/axes/_axes.py:docstring of matplotlib.axes._axes.Axes.pcolormesh:160", "lib/matplotlib/collections.py:docstring of matplotlib.artist.PolyQuadMesh.set:17", diff --git a/doc/users/next_whats_new/add_RectangleCollection.rst b/doc/users/next_whats_new/add_RectangleCollection.rst new file mode 100644 index 000000000000..ef0f0775ecfc --- /dev/null +++ b/doc/users/next_whats_new/add_RectangleCollection.rst @@ -0,0 +1,33 @@ +Add ``RectangleCollection`` +--------------------------- + +The `~matplotlib.collections.RectangleCollection` is added to create collection of `~matplotlib.patches.Rectangle` + +.. plot:: + :include-source: true + + import matplotlib.pyplot as plt + from matplotlib.collections import RectangleCollection + import numpy as np + + rng = np.random.default_rng(0) + + widths = (2, ) + heights = (3, ) + angles = (45, ) + offsets = rng.random((10, 2)) * 10 + + fig, ax = plt.subplots() + + ec = RectangleCollection( + widths=widths, + heights=heights, + angles=angles, + offsets=offsets, + units='x', + offset_transform=ax.transData, + ) + + ax.add_collection(ec) + ax.set_xlim(-2, 12) + ax.set_ylim(-2, 12) diff --git a/lib/matplotlib/collections.py b/lib/matplotlib/collections.py index fd6cc4339d64..4afcc5da2065 100644 --- a/lib/matplotlib/collections.py +++ b/lib/matplotlib/collections.py @@ -1720,8 +1720,10 @@ def __init__(self, sizes, **kwargs): self._paths = [mpath.Path.unit_circle()] -class EllipseCollection(Collection): - """A collection of ellipses, drawn using splines.""" +class _CollectionWithWidthHeightAngle(Collection): + """ + Base class for collections that have an array of widths, heights and angles + """ def __init__(self, widths, heights, angles, *, units='points', **kwargs): """ @@ -1751,7 +1753,7 @@ def __init__(self, widths, heights, angles, *, units='points', **kwargs): self._units = units self.set_transform(transforms.IdentityTransform()) self._transforms = np.empty((0, 3, 3)) - self._paths = [mpath.Path.unit_circle()] + self._paths = [self._path_generator()] def _set_transforms(self): """Calculate transforms immediately before drawing.""" @@ -1797,12 +1799,12 @@ def _set_transforms(self): def set_widths(self, widths): """Set the lengths of the first axes (e.g., major axis).""" - self._widths = 0.5 * np.asarray(widths).ravel() + self._widths = np.asarray(widths).ravel() self.stale = True def set_heights(self, heights): """Set the lengths of second axes (e.g., minor axes).""" - self._heights = 0.5 * np.asarray(heights).ravel() + self._heights = np.asarray(heights).ravel() self.stale = True def set_angles(self, angles): @@ -1812,11 +1814,11 @@ def set_angles(self, angles): def get_widths(self): """Get the lengths of the first axes (e.g., major axis).""" - return self._widths * 2 + return self._widths def get_heights(self): """Set the lengths of second axes (e.g., minor axes).""" - return self._heights * 2 + return self._heights def get_angles(self): """Get the angles of the first axes, degrees CCW from the x-axis.""" @@ -1828,6 +1830,67 @@ def draw(self, renderer): super().draw(renderer) +class EllipseCollection(_CollectionWithWidthHeightAngle): + """ + A collection of ellipses, drawn using splines. + + Parameters + ---------- + widths : array-like + The lengths of the first axes (e.g., major axis lengths). + heights : array-like + The lengths of second axes. + angles : array-like + The angles of the first axes, degrees CCW from the x-axis. + units : {'points', 'inches', 'dots', 'width', 'height', 'x', 'y', 'xy'} + The units in which majors and minors are given; 'width' and + 'height' refer to the dimensions of the axes, while 'x' and 'y' + refer to the *offsets* data units. 'xy' differs from all others in + that the angle as plotted varies with the aspect ratio, and equals + the specified angle only when the aspect ratio is unity. Hence + it behaves the same as the `~.patches.Ellipse` with + ``axes.transData`` as its transform. + **kwargs + Forwarded to `Collection`. + """ + _path_generator = mpath.Path.half_unit_circle + + +class RectangleCollection(_CollectionWithWidthHeightAngle): + """ + A collection of rectangles, drawn using splines. + + Parameters + ---------- + widths : array-like + The lengths of the first axes (e.g., major axis lengths). + heights : array-like + The lengths of second axes. + angles : array-like + The angles of the first axes, degrees CCW from the x-axis. + units : {'points', 'inches', 'dots', 'width', 'height', 'x', 'y', 'xy'} + The units in which majors and minors are given; 'width' and + 'height' refer to the dimensions of the axes, while 'x' and 'y' + refer to the *offsets* data units. 'xy' differs from all others in + that the angle as plotted varies with the aspect ratio, and equals + the specified angle only when the aspect ratio is unity. Hence + it behaves the same as the `~.patches.Rectangle` with + ``axes.transData`` as its transform. + centered : bool + Whether to use the center or the corner of the rectangle to + define the position of the rectangles. Default is False. + **kwargs + Forwarded to `Collection`. + + """ + _path_generator = mpath.Path.unit_rectangle + + def __init__(self, *args, **kwargs): + if kwargs.pop("centered", False): + self._path_generator = mpath.Path.unit_rectangle_centered + super().__init__(*args, **kwargs) + + class PatchCollection(Collection): """ A generic collection of patches. diff --git a/lib/matplotlib/collections.pyi b/lib/matplotlib/collections.pyi index e4c46229517f..d4c0164a3b3a 100644 --- a/lib/matplotlib/collections.pyi +++ b/lib/matplotlib/collections.pyi @@ -168,7 +168,7 @@ class EventCollection(LineCollection): class CircleCollection(_CollectionWithSizes): def __init__(self, sizes: float | ArrayLike, **kwargs) -> None: ... -class EllipseCollection(Collection): +class _CollectionWithWidthHeightAngle(Collection): def __init__( self, widths: ArrayLike, @@ -187,6 +187,9 @@ class EllipseCollection(Collection): def get_heights(self) -> ArrayLike: ... def get_angles(self) -> ArrayLike: ... +class EllipseCollection(_CollectionWithWidthHeightAngle): ... +class RectangleCollection(_CollectionWithWidthHeightAngle): ... + class PatchCollection(Collection): def __init__( self, patches: Iterable[Patch], *, match_original: bool = ..., **kwargs diff --git a/lib/matplotlib/path.py b/lib/matplotlib/path.py index e72eb1a9ca73..371e4f3e8e07 100644 --- a/lib/matplotlib/path.py +++ b/lib/matplotlib/path.py @@ -741,6 +741,20 @@ def unit_rectangle(cls): closed=True, readonly=True) return cls._unit_rectangle + _unit_rectangle_centered = None + + @classmethod + def unit_rectangle_centered(cls): + """ + Return a `Path` instance of the unit rectangle from (-0.5, -0.5) to (0.5, 0.5). + """ + if cls._unit_rectangle_centered is None: + cls._unit_rectangle_centered = cls( + [[-0.5, -0.5], [0.5, -0.5], [0.5, 0.5], [-0.5, 0.5], [-0.5, -0.5]], + closed=True, readonly=True + ) + return cls._unit_rectangle_centered + _unit_regular_polygons = WeakValueDictionary() @classmethod @@ -799,6 +813,21 @@ def unit_regular_asterisk(cls, numVertices): """ return cls.unit_regular_star(numVertices, 0.0) + _half_unit_circle = None + + @classmethod + def half_unit_circle(cls): + """ + Return the readonly :class:`Path` of the half unit circle. + + For most cases, :func:`Path.circle` will be what you want. + """ + if cls._half_unit_circle is None: + cls._half_unit_circle = cls.circle( + center=(0, 0), radius=0.5, readonly=True + ) + return cls._half_unit_circle + _unit_circle = None @classmethod diff --git a/lib/matplotlib/path.pyi b/lib/matplotlib/path.pyi index 464fc6d9a912..35977b4eab5f 100644 --- a/lib/matplotlib/path.pyi +++ b/lib/matplotlib/path.pyi @@ -101,12 +101,16 @@ class Path: @classmethod def unit_rectangle(cls) -> Path: ... @classmethod + def unit_rectangle_centered(cls) -> Path: ... + @classmethod def unit_regular_polygon(cls, numVertices: int) -> Path: ... @classmethod def unit_regular_star(cls, numVertices: int, innerCircle: float = ...) -> Path: ... @classmethod def unit_regular_asterisk(cls, numVertices: int) -> Path: ... @classmethod + def half_unit_circle(cls) -> Path: ... + @classmethod def unit_circle(cls) -> Path: ... @classmethod def circle( diff --git a/lib/matplotlib/tests/baseline_images/test_collections/RectangleCollection_test_image.png b/lib/matplotlib/tests/baseline_images/test_collections/RectangleCollection_test_image.png new file mode 100644 index 000000000000..23c67ea29f9a Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_collections/RectangleCollection_test_image.png differ diff --git a/lib/matplotlib/tests/test_collections.py b/lib/matplotlib/tests/test_collections.py index 23e951b17a2f..0439a63030d7 100644 --- a/lib/matplotlib/tests/test_collections.py +++ b/lib/matplotlib/tests/test_collections.py @@ -410,7 +410,33 @@ def test_EllipseCollection(): ax.autoscale_view() -def test_EllipseCollection_setter_getter(): +@image_comparison(['RectangleCollection_test_image.png'], remove_text=True) +def test_RectangleCollection(): + # Test basic functionality + fig, ax = plt.subplots() + x = np.arange(4) + y = np.arange(3) + X, Y = np.meshgrid(x, y) + XY = np.vstack((X.ravel(), Y.ravel())).T + + ww = X / x[-1] + hh = Y / y[-1] + aa = np.ones_like(ww) * 20 # first axis is 20 degrees CCW from x axis + + ec = mcollections.RectangleCollection( + ww, hh, aa, units='x', offsets=XY, offset_transform=ax.transData, + facecolors='none') + ax.add_collection(ec) + ax.autoscale_view() + + +@pytest.mark.parametrize( + 'Class, centered', + [(mcollections.EllipseCollection, None), + (mcollections.RectangleCollection, False), + (mcollections.RectangleCollection, True)] + ) +def test_WidthHeightAngleCollection_setter_getter(Class, centered): # Test widths, heights and angle setter rng = np.random.default_rng(0) @@ -421,17 +447,21 @@ def test_EllipseCollection_setter_getter(): fig, ax = plt.subplots() - ec = mcollections.EllipseCollection( + kwargs = {} + if centered is not None: + kwargs["centered"] = centered + ec = Class( widths=widths, heights=heights, angles=angles, offsets=offsets, units='x', offset_transform=ax.transData, + **kwargs, ) - assert_array_almost_equal(ec._widths, np.array(widths).ravel() * 0.5) - assert_array_almost_equal(ec._heights, np.array(heights).ravel() * 0.5) + assert_array_almost_equal(ec._widths, np.array(widths).ravel()) + assert_array_almost_equal(ec._heights, np.array(heights).ravel()) assert_array_almost_equal(ec._angles, np.deg2rad(angles).ravel()) assert_array_almost_equal(ec.get_widths(), widths)