diff --git a/doc/users/next_whats_new/separated_hatchcolor.rst b/doc/users/next_whats_new/separated_hatchcolor.rst index f3932cf876f8..a794fba2e515 100644 --- a/doc/users/next_whats_new/separated_hatchcolor.rst +++ b/doc/users/next_whats_new/separated_hatchcolor.rst @@ -57,3 +57,39 @@ Previously, hatch colors were the same as edge colors, with a fallback to xy=(.5, 1.03), xycoords=patch4, ha='center', va='bottom') plt.show() + +For collections, a sequence of colors can be passed to the *hatchcolor* parameter +which will be cycled through for each hatch, similar to *facecolor* and *edgecolor*. + +Previously, if *edgecolor* was not specified, the hatch color would fall back to +:rc:`patch.edgecolor`, but the alpha value would default to **1.0**, regardless of the +alpha value of the collection. This behavior has been changed such that, if both +*hatchcolor* and *edgecolor* are not specified, the hatch color will fall back +to 'patch.edgecolor' with the alpha value of the collection. + +.. plot:: + :include-source: true + :alt: A random scatter plot with hatches on the markers. The hatches are colored in blue, orange, and green, respectively. After the first three markers, the colors are cycled through again. + + import matplotlib.pyplot as plt + import numpy as np + + np.random.seed(19680801) + + fig, ax = plt.subplots() + + x = [29, 36, 41, 25, 32, 70, 62, 58, 66, 80, 58, 68, 62, 37, 48] + y = [82, 76, 48, 53, 62, 70, 84, 68, 55, 75, 29, 25, 12, 17, 20] + colors = ['tab:blue'] * 5 + ['tab:orange'] * 5 + ['tab:green'] * 5 + + ax.scatter( + x, + y, + s=800, + hatch="xxxx", + hatchcolor=colors, + facecolor="none", + edgecolor="black", + ) + + plt.show() diff --git a/galleries/examples/shapes_and_collections/hatchcolor_demo.py b/galleries/examples/shapes_and_collections/hatchcolor_demo.py index 7125ddb57fe7..7a51248ffc14 100644 --- a/galleries/examples/shapes_and_collections/hatchcolor_demo.py +++ b/galleries/examples/shapes_and_collections/hatchcolor_demo.py @@ -1,16 +1,27 @@ """ -================ -Patch hatchcolor -================ +=============== +Hatchcolor Demo +=============== + +The color of the hatch can be set using the *hatchcolor* parameter. The following +examples show how to use the *hatchcolor* parameter to set the color of the hatch +in `~.patches.Patch` and `~.collections.Collection`. + +See also :doc:`/gallery/shapes_and_collections/hatch_demo` for more usage examples +of hatching. + +Patch Hatchcolor +---------------- This example shows how to use the *hatchcolor* parameter to set the color of -the hatch. The *hatchcolor* parameter is available for `~.patches.Patch`, -child classes of Patch, and methods that pass through to Patch. +the hatch in a rectangle and a bar plot. The *hatchcolor* parameter is available for +`~.patches.Patch`, child classes of Patch, and methods that pass through to Patch. """ import matplotlib.pyplot as plt import numpy as np +import matplotlib.cm as cm from matplotlib.patches import Rectangle fig, (ax1, ax2) = plt.subplots(1, 2) @@ -28,6 +39,43 @@ ax2.set_xlim(0, 5) ax2.set_ylim(0, 5) +# %% +# Collection Hatchcolor +# --------------------- +# +# The following example shows how to use the *hatchcolor* parameter to set the color of +# the hatch in a scatter plot. The *hatchcolor* parameter can also be passed to +# `~.collections.Collection`, child classes of Collection, and methods that pass +# through to Collection. + +fig, ax = plt.subplots() + +num_points_x = 10 +num_points_y = 9 +x = np.linspace(0, 1, num_points_x) +y = np.linspace(0, 1, num_points_y) + +X, Y = np.meshgrid(x, y) +X[1::2, :] += (x[1] - x[0]) / 2 # stagger every alternate row + +# As ax.scatter (PathCollection) is drawn row by row, setting hatchcolors to the +# first row is enough, as the colors will be cycled through for the next rows. +colors = [cm.rainbow(val) for val in x] + +ax.scatter( + X.ravel(), + Y.ravel(), + s=1700, + facecolor="none", + edgecolor="gray", + linewidth=2, + marker="h", # Use hexagon as marker + hatch="xxx", + hatchcolor=colors, +) +ax.set_xlim(0, 1) +ax.set_ylim(0, 1) + plt.show() # %% @@ -41,3 +89,5 @@ # - `matplotlib.patches.Polygon` # - `matplotlib.axes.Axes.add_patch` # - `matplotlib.axes.Axes.bar` / `matplotlib.pyplot.bar` +# - `matplotlib.collections` +# - `matplotlib.axes.Axes.scatter` / `matplotlib.pyplot.scatter` diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 62c26a90a91c..73b96b102e4f 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -208,7 +208,7 @@ def draw_markers(self, gc, marker_path, marker_trans, path, def draw_path_collection(self, gc, master_transform, paths, all_transforms, offsets, offset_trans, facecolors, edgecolors, linewidths, linestyles, antialiaseds, urls, - offset_position): + offset_position, *, hatchcolors=None): """ Draw a collection of *paths*. @@ -217,8 +217,11 @@ def draw_path_collection(self, gc, master_transform, paths, all_transforms, *master_transform*. They are then translated by the corresponding entry in *offsets*, which has been first transformed by *offset_trans*. - *facecolors*, *edgecolors*, *linewidths*, *linestyles*, and - *antialiased* are lists that set the corresponding properties. + *facecolors*, *edgecolors*, *linewidths*, *linestyles*, *antialiased* + and *hatchcolors* are lists that set the corresponding properties. + + .. versionadded:: 3.11 + Allow *hatchcolors* to be specified. *offset_position* is unused now, but the argument is kept for backwards compatibility. @@ -235,10 +238,13 @@ def draw_path_collection(self, gc, master_transform, paths, all_transforms, path_ids = self._iter_collection_raw_paths(master_transform, paths, all_transforms) + if hatchcolors is None: + hatchcolors = [] + for xo, yo, path_id, gc0, rgbFace in self._iter_collection( gc, list(path_ids), offsets, offset_trans, facecolors, edgecolors, linewidths, linestyles, - antialiaseds, urls, offset_position): + antialiaseds, urls, offset_position, hatchcolors=hatchcolors): path, transform = path_id # Only apply another translation if we have an offset, else we # reuse the initial transform. @@ -252,7 +258,7 @@ def draw_path_collection(self, gc, master_transform, paths, all_transforms, def draw_quad_mesh(self, gc, master_transform, meshWidth, meshHeight, coordinates, offsets, offsetTrans, facecolors, - antialiased, edgecolors): + antialiased, edgecolors, *, hatchcolors=None): """ Draw a quadmesh. @@ -265,11 +271,14 @@ def draw_quad_mesh(self, gc, master_transform, meshWidth, meshHeight, if edgecolors is None: edgecolors = facecolors + if hatchcolors is None: + hatchcolors = [] linewidths = np.array([gc.get_linewidth()], float) return self.draw_path_collection( gc, master_transform, paths, [], offsets, offsetTrans, facecolors, - edgecolors, linewidths, [], [antialiased], [None], 'screen') + edgecolors, linewidths, [], [antialiased], [None], 'screen', + hatchcolors=hatchcolors) def draw_gouraud_triangles(self, gc, triangles_array, colors_array, transform): @@ -337,7 +346,7 @@ def _iter_collection_uses_per_path(self, paths, all_transforms, def _iter_collection(self, gc, path_ids, offsets, offset_trans, facecolors, edgecolors, linewidths, linestyles, - antialiaseds, urls, offset_position): + antialiaseds, urls, offset_position, *, hatchcolors): """ Helper method (along with `_iter_collection_raw_paths`) to implement `draw_path_collection` in a memory-efficient manner. @@ -365,11 +374,12 @@ def _iter_collection(self, gc, path_ids, offsets, offset_trans, facecolors, N = max(Npaths, Noffsets) Nfacecolors = len(facecolors) Nedgecolors = len(edgecolors) + Nhatchcolors = len(hatchcolors) Nlinewidths = len(linewidths) Nlinestyles = len(linestyles) Nurls = len(urls) - if (Nfacecolors == 0 and Nedgecolors == 0) or Npaths == 0: + if (Nfacecolors == 0 and Nedgecolors == 0 and Nhatchcolors == 0) or Npaths == 0: return gc0 = self.new_gc() @@ -384,6 +394,7 @@ def cycle_or_default(seq, default=None): toffsets = cycle_or_default(offset_trans.transform(offsets), (0, 0)) fcs = cycle_or_default(facecolors) ecs = cycle_or_default(edgecolors) + hcs = cycle_or_default(hatchcolors) lws = cycle_or_default(linewidths) lss = cycle_or_default(linestyles) aas = cycle_or_default(antialiaseds) @@ -392,8 +403,8 @@ def cycle_or_default(seq, default=None): if Nedgecolors == 0: gc0.set_linewidth(0.0) - for pathid, (xo, yo), fc, ec, lw, ls, aa, url in itertools.islice( - zip(pathids, toffsets, fcs, ecs, lws, lss, aas, urls), N): + for pathid, (xo, yo), fc, ec, hc, lw, ls, aa, url in itertools.islice( + zip(pathids, toffsets, fcs, ecs, hcs, lws, lss, aas, urls), N): if not (np.isfinite(xo) and np.isfinite(yo)): continue if Nedgecolors: @@ -405,6 +416,8 @@ def cycle_or_default(seq, default=None): gc0.set_linewidth(0) else: gc0.set_foreground(ec) + if Nhatchcolors: + gc0.set_hatch_color(hc) if fc is not None and len(fc) == 4 and fc[3] == 0: fc = None gc0.set_antialiased(aa) diff --git a/lib/matplotlib/backend_bases.pyi b/lib/matplotlib/backend_bases.pyi index 898d6fb0e7f5..fc25ab416a74 100644 --- a/lib/matplotlib/backend_bases.pyi +++ b/lib/matplotlib/backend_bases.pyi @@ -63,6 +63,8 @@ class RendererBase: antialiaseds: bool | Sequence[bool], urls: str | Sequence[str], offset_position: Any, + *, + hatchcolors: ColorType | Sequence[ColorType] | None = None, ) -> None: ... def draw_quad_mesh( self, @@ -76,6 +78,8 @@ class RendererBase: facecolors: Sequence[ColorType], antialiased: bool, edgecolors: Sequence[ColorType] | ColorType | None, + *, + hatchcolors: Sequence[ColorType] | ColorType | None = None, ) -> None: ... def draw_gouraud_triangles( self, diff --git a/lib/matplotlib/backends/backend_pdf.py b/lib/matplotlib/backends/backend_pdf.py index c1c5eb8819be..860f0ad04c39 100644 --- a/lib/matplotlib/backends/backend_pdf.py +++ b/lib/matplotlib/backends/backend_pdf.py @@ -2030,7 +2030,7 @@ def draw_path(self, gc, path, transform, rgbFace=None): def draw_path_collection(self, gc, master_transform, paths, all_transforms, offsets, offset_trans, facecolors, edgecolors, linewidths, linestyles, antialiaseds, urls, - offset_position): + offset_position, *, hatchcolors=None): # We can only reuse the objects if the presence of fill and # stroke (and the amount of alpha for each) is the same for # all of them @@ -2038,6 +2038,9 @@ def draw_path_collection(self, gc, master_transform, paths, all_transforms, facecolors = np.asarray(facecolors) edgecolors = np.asarray(edgecolors) + if hatchcolors is None: + hatchcolors = [] + if not len(facecolors): filled = False can_do_optimization = not gc.get_hatch() @@ -2072,7 +2075,7 @@ def draw_path_collection(self, gc, master_transform, paths, all_transforms, self, gc, master_transform, paths, all_transforms, offsets, offset_trans, facecolors, edgecolors, linewidths, linestyles, antialiaseds, urls, - offset_position) + offset_position, hatchcolors=hatchcolors) padding = np.max(linewidths) path_codes = [] @@ -2088,7 +2091,7 @@ def draw_path_collection(self, gc, master_transform, paths, all_transforms, for xo, yo, path_id, gc0, rgbFace in self._iter_collection( gc, path_codes, offsets, offset_trans, facecolors, edgecolors, linewidths, linestyles, - antialiaseds, urls, offset_position): + antialiaseds, urls, offset_position, hatchcolors=hatchcolors): self.check_gc(gc0, rgbFace) dx, dy = xo - lastx, yo - lasty @@ -2603,7 +2606,10 @@ def delta(self, other): different = ours is not theirs else: different = bool(ours != theirs) - except ValueError: + except (ValueError, DeprecationWarning): + # numpy version < 1.25 raises DeprecationWarning when array shapes + # mismatch, unlike numpy >= 1.25 which raises ValueError. + # This should be removed when numpy < 1.25 is no longer supported. ours = np.asarray(ours) theirs = np.asarray(theirs) different = (ours.shape != theirs.shape or diff --git a/lib/matplotlib/backends/backend_ps.py b/lib/matplotlib/backends/backend_ps.py index f1f914ae5420..62952caa32e1 100644 --- a/lib/matplotlib/backends/backend_ps.py +++ b/lib/matplotlib/backends/backend_ps.py @@ -674,7 +674,9 @@ def draw_markers( def draw_path_collection(self, gc, master_transform, paths, all_transforms, offsets, offset_trans, facecolors, edgecolors, linewidths, linestyles, antialiaseds, urls, - offset_position): + offset_position, *, hatchcolors=None): + if hatchcolors is None: + hatchcolors = [] # Is the optimization worth it? Rough calculation: # cost of emitting a path in-line is # (len_path + 2) * uses_per_path @@ -690,7 +692,7 @@ def draw_path_collection(self, gc, master_transform, paths, all_transforms, self, gc, master_transform, paths, all_transforms, offsets, offset_trans, facecolors, edgecolors, linewidths, linestyles, antialiaseds, urls, - offset_position) + offset_position, hatchcolors=hatchcolors) path_codes = [] for i, (path, transform) in enumerate(self._iter_collection_raw_paths( @@ -709,7 +711,7 @@ def draw_path_collection(self, gc, master_transform, paths, all_transforms, for xo, yo, path_id, gc0, rgbFace in self._iter_collection( gc, path_codes, offsets, offset_trans, facecolors, edgecolors, linewidths, linestyles, - antialiaseds, urls, offset_position): + antialiaseds, urls, offset_position, hatchcolors=hatchcolors): ps = f"{xo:g} {yo:g} {path_id}" self._draw_ps(ps, gc0, rgbFace) diff --git a/lib/matplotlib/backends/backend_svg.py b/lib/matplotlib/backends/backend_svg.py index 2193dc6b6cdc..0cb6430ec823 100644 --- a/lib/matplotlib/backends/backend_svg.py +++ b/lib/matplotlib/backends/backend_svg.py @@ -736,7 +736,9 @@ def draw_markers( def draw_path_collection(self, gc, master_transform, paths, all_transforms, offsets, offset_trans, facecolors, edgecolors, linewidths, linestyles, antialiaseds, urls, - offset_position): + offset_position, *, hatchcolors=None): + if hatchcolors is None: + hatchcolors = [] # Is the optimization worth it? Rough calculation: # cost of emitting a path in-line is # (len_path + 5) * uses_per_path @@ -752,7 +754,7 @@ def draw_path_collection(self, gc, master_transform, paths, all_transforms, gc, master_transform, paths, all_transforms, offsets, offset_trans, facecolors, edgecolors, linewidths, linestyles, antialiaseds, urls, - offset_position) + offset_position, hatchcolors=hatchcolors) writer = self.writer path_codes = [] @@ -770,7 +772,7 @@ def draw_path_collection(self, gc, master_transform, paths, all_transforms, for xo, yo, path_id, gc0, rgbFace in self._iter_collection( gc, path_codes, offsets, offset_trans, facecolors, edgecolors, linewidths, linestyles, - antialiaseds, urls, offset_position): + antialiaseds, urls, offset_position, hatchcolors=hatchcolors): url = gc0.get_url() if url is not None: writer.start('a', attrib={'xlink:href': url}) diff --git a/lib/matplotlib/collections.py b/lib/matplotlib/collections.py index a3f245bbc2c8..92ac5726f26c 100644 --- a/lib/matplotlib/collections.py +++ b/lib/matplotlib/collections.py @@ -79,6 +79,7 @@ class Collection(mcolorizer.ColorizingArtist): def __init__(self, *, edgecolors=None, facecolors=None, + hatchcolors=None, linewidths=None, linestyles='solid', capstyle=None, @@ -104,6 +105,10 @@ def __init__(self, *, facecolor. facecolors : :mpltype:`color` or list of colors, default: :rc:`patch.facecolor` Face color for each patch making up the collection. + hatchcolors : :mpltype:`color` or list of colors, default: :rc:`hatch.color` + Hatch color for each patch making up the collection. The color + can be set to the special value 'edge' to make the hatchcolor match the + edgecolor. linewidths : float or list of floats, default: :rc:`patch.linewidth` Line width for each patch making up the collection. linestyles : str or tuple or list thereof, default: 'solid' @@ -174,13 +179,6 @@ def __init__(self, *, self._face_is_mapped = None self._edge_is_mapped = None self._mapped_colors = None # calculated in update_scalarmappable - - # Temporary logic to set hatchcolor. This eager resolution is temporary - # and will be replaced by a proper mechanism in a follow-up PR. - hatch_color = mpl.rcParams['hatch.color'] - if hatch_color == 'edge': - hatch_color = mpl.rcParams['patch.edgecolor'] - self._hatch_color = mcolors.to_rgba(hatch_color) self._hatch_linewidth = mpl.rcParams['hatch.linewidth'] self.set_facecolor(facecolors) self.set_edgecolor(edgecolors) @@ -190,6 +188,7 @@ def __init__(self, *, self.set_pickradius(pickradius) self.set_urls(urls) self.set_hatch(hatch) + self.set_hatchcolor(hatchcolors) self.set_zorder(zorder) if capstyle: @@ -371,7 +370,6 @@ def draw(self, renderer): if self._hatch: gc.set_hatch(self._hatch) - gc.set_hatch_color(self._hatch_color) gc.set_hatch_linewidth(self._hatch_linewidth) if self.get_sketch_params() is not None: @@ -422,24 +420,86 @@ def draw(self, renderer): gc, paths[0], combined_transform.frozen(), mpath.Path(offsets), offset_trf, tuple(facecolors[0])) else: - if self._gapcolor is not None: - # First draw paths within the gaps. - ipaths, ilinestyles = self._get_inverse_paths_linestyles() + # The current new API of draw_path_collection() is provisional + # and will be changed in a future PR. + + # Find whether renderer.draw_path_collection() takes hatchcolor parameter. + # Since third-party implementations of draw_path_collection() may not be + # introspectable, e.g. with inspect.signature, the only way is to try and + # call this with the hatchcolors parameter. + hatchcolors_arg_supported = True + try: renderer.draw_path_collection( - gc, transform.frozen(), ipaths, + gc, transform.frozen(), [], self.get_transforms(), offsets, offset_trf, - [mcolors.to_rgba("none")], self._gapcolor, - self._linewidths, ilinestyles, + self.get_facecolor(), self.get_edgecolor(), + self._linewidths, self._linestyles, self._antialiaseds, self._urls, - "screen") + "screen", hatchcolors=self.get_hatchcolor() + ) + except TypeError: + # If the renderer does not support the hatchcolors argument, + # it will raise a TypeError. In this case, we will + # iterate over all paths and draw them one by one. + hatchcolors_arg_supported = False + + # If the hatchcolors argument is not needed or not passed + # then we can skip the iteration over paths in case the + # argument is not supported by the renderer. + hatchcolors_not_needed = (self.get_hatch() is None or + self._original_hatchcolor is None) - renderer.draw_path_collection( - gc, transform.frozen(), paths, - self.get_transforms(), offsets, offset_trf, - self.get_facecolor(), self.get_edgecolor(), - self._linewidths, self._linestyles, - self._antialiaseds, self._urls, - "screen") # offset_position, kept for backcompat. + if self._gapcolor is not None: + # First draw paths within the gaps. + ipaths, ilinestyles = self._get_inverse_paths_linestyles() + args = [offsets, offset_trf, [mcolors.to_rgba("none")], self._gapcolor, + self._linewidths, ilinestyles, self._antialiaseds, self._urls, + "screen"] + + if hatchcolors_arg_supported: + renderer.draw_path_collection(gc, transform.frozen(), ipaths, + self.get_transforms(), *args, + hatchcolors=self.get_hatchcolor()) + else: + if hatchcolors_not_needed: + renderer.draw_path_collection(gc, transform.frozen(), ipaths, + self.get_transforms(), *args) + else: + path_ids = renderer._iter_collection_raw_paths( + transform.frozen(), ipaths, self.get_transforms()) + for xo, yo, path_id, gc0, rgbFace in renderer._iter_collection( + gc, list(path_ids), *args, + hatchcolors=self.get_hatchcolor(), + ): + path, transform = path_id + if xo != 0 or yo != 0: + transform = transform.frozen() + transform.translate(xo, yo) + renderer.draw_path(gc0, path, transform, rgbFace) + + args = [offsets, offset_trf, self.get_facecolor(), self.get_edgecolor(), + self._linewidths, self._linestyles, self._antialiaseds, self._urls, + "screen"] + + if hatchcolors_arg_supported: + renderer.draw_path_collection(gc, transform.frozen(), paths, + self.get_transforms(), *args, + hatchcolors=self.get_hatchcolor()) + else: + if hatchcolors_not_needed: + renderer.draw_path_collection(gc, transform.frozen(), paths, + self.get_transforms(), *args) + else: + path_ids = renderer._iter_collection_raw_paths( + transform.frozen(), paths, self.get_transforms()) + for xo, yo, path_id, gc0, rgbFace in renderer._iter_collection( + gc, list(path_ids), *args, hatchcolors=self.get_hatchcolor(), + ): + path, transform = path_id + if xo != 0 or yo != 0: + transform = transform.frozen() + transform.translate(xo, yo) + renderer.draw_path(gc0, path, transform, rgbFace) gc.restore() renderer.close_group(self.__class__.__name__) @@ -759,7 +819,10 @@ def _get_default_antialiased(self): def set_color(self, c): """ - Set both the edgecolor and the facecolor. + Set the edgecolor, facecolor and hatchcolor. + + .. versionchanged:: 3.11 + Now sets the hatchcolor as well. Parameters ---------- @@ -767,11 +830,12 @@ def set_color(self, c): See Also -------- - Collection.set_facecolor, Collection.set_edgecolor - For setting the edge or face color individually. + Collection.set_facecolor, Collection.set_edgecolor, Collection.set_hatchcolor + For setting the facecolor, edgecolor, and hatchcolor individually. """ self.set_facecolor(c) self.set_edgecolor(c) + self.set_hatchcolor(c) def _get_default_facecolor(self): # This may be overridden in a subclass. @@ -814,8 +878,15 @@ def _get_default_edgecolor(self): # This may be overridden in a subclass. return mpl.rcParams['patch.edgecolor'] + def get_hatchcolor(self): + if cbook._str_equal(self._hatchcolors, 'edge'): + if len(self.get_edgecolor()) == 0: + return mpl.colors.to_rgba_array(self._get_default_edgecolor(), + self._alpha) + return self.get_edgecolor() + return self._hatchcolors + def _set_edgecolor(self, c): - set_hatch_color = True if c is None: if (mpl.rcParams['patch.force_edgecolor'] or self._edge_default @@ -823,14 +894,11 @@ def _set_edgecolor(self, c): c = self._get_default_edgecolor() else: c = 'none' - set_hatch_color = False if cbook._str_lower_equal(c, 'face'): self._edgecolors = 'face' self.stale = True return self._edgecolors = mcolors.to_rgba_array(c, self._alpha) - if set_hatch_color and len(self._edgecolors): - self._hatch_color = tuple(self._edgecolors[0]) self.stale = True def set_edgecolor(self, c): @@ -851,6 +919,27 @@ def set_edgecolor(self, c): self._original_edgecolor = c self._set_edgecolor(c) + def _set_hatchcolor(self, c): + c = mpl._val_or_rc(c, 'hatch.color') + if cbook._str_equal(c, 'edge'): + self._hatchcolors = 'edge' + else: + self._hatchcolors = mcolors.to_rgba_array(c, self._alpha) + self.stale = True + + def set_hatchcolor(self, c): + """ + Set the hatchcolor(s) of the collection. + + Parameters + ---------- + c : :mpltype:`color` or list of :mpltype:`color` or 'edge' + The collection hatchcolor(s). If a sequence, the patches cycle + through it. + """ + self._original_hatchcolor = c + self._set_hatchcolor(c) + def set_alpha(self, alpha): """ Set the transparency of the collection. @@ -866,6 +955,7 @@ def set_alpha(self, alpha): artist.Artist._set_alpha_for_array(self, alpha) self._set_facecolor(self._original_facecolor) self._set_edgecolor(self._original_edgecolor) + self._set_hatchcolor(self._original_hatchcolor) set_alpha.__doc__ = artist.Artist._set_alpha_for_array.__doc__ @@ -968,6 +1058,7 @@ def update_from(self, other): self._us_linestyles = other._us_linestyles self._pickradius = other._pickradius self._hatch = other._hatch + self._hatchcolors = other._hatchcolors # update_from for scalarmappable self._A = other._A @@ -2465,7 +2556,8 @@ def draw(self, renderer): coordinates, offsets, offset_trf, # Backends expect flattened rgba arrays (n*m, 4) for fc and ec self.get_facecolor().reshape((-1, 4)), - self._antialiased, self.get_edgecolors().reshape((-1, 4))) + self._antialiased, self.get_edgecolors().reshape((-1, 4)), + hatchcolors=self.get_hatchcolor().reshape((-1, 4))) gc.restore() renderer.close_group(self.__class__.__name__) self.stale = False diff --git a/lib/matplotlib/collections.pyi b/lib/matplotlib/collections.pyi index 0805adef4293..ecd969cfacc6 100644 --- a/lib/matplotlib/collections.pyi +++ b/lib/matplotlib/collections.pyi @@ -21,6 +21,7 @@ class Collection(colorizer.ColorizingArtist): *, edgecolors: ColorType | Sequence[ColorType] | None = ..., facecolors: ColorType | Sequence[ColorType] | None = ..., + hatchcolors: ColorType | Sequence[ColorType] | Literal["edge"] | None = ..., linewidths: float | Sequence[float] | None = ..., linestyles: LineStyleType | Sequence[LineStyleType] = ..., capstyle: CapStyleType | None = ..., @@ -66,6 +67,10 @@ class Collection(colorizer.ColorizingArtist): def get_facecolor(self) -> ColorType | Sequence[ColorType]: ... def get_edgecolor(self) -> ColorType | Sequence[ColorType]: ... def set_edgecolor(self, c: ColorType | Sequence[ColorType]) -> None: ... + def get_hatchcolor(self) -> ColorType | Sequence[ColorType]: ... + def set_hatchcolor( + self, c: ColorType | Sequence[ColorType] | Literal["edge"] + ) -> None: ... def set_alpha(self, alpha: float | Sequence[float] | None) -> None: ... def get_linewidth(self) -> float | Sequence[float]: ... def get_linestyle(self) -> LineStyleType | Sequence[LineStyleType]: ... diff --git a/lib/matplotlib/legend_handler.py b/lib/matplotlib/legend_handler.py index 97076ad09cb8..263945b050d0 100644 --- a/lib/matplotlib/legend_handler.py +++ b/lib/matplotlib/legend_handler.py @@ -790,12 +790,11 @@ def get_first(prop_array): # Directly set Patch color attributes (must be RGBA tuples). legend_handle._facecolor = first_color(orig_handle.get_facecolor()) legend_handle._edgecolor = first_color(orig_handle.get_edgecolor()) + legend_handle._hatch_color = first_color(orig_handle.get_hatchcolor()) legend_handle._original_facecolor = orig_handle._original_facecolor legend_handle._original_edgecolor = orig_handle._original_edgecolor legend_handle._fill = orig_handle.get_fill() legend_handle._hatch = orig_handle.get_hatch() - # Hatch color is anomalous in having no getters and setters. - legend_handle._hatch_color = orig_handle._hatch_color # Setters are fine for the remaining attributes. legend_handle.set_linewidth(get_first(orig_handle.get_linewidths())) legend_handle.set_linestyle(get_first(orig_handle.get_linestyles())) diff --git a/lib/matplotlib/patches.pyi b/lib/matplotlib/patches.pyi index 3d119cfa076d..c95f20e35812 100644 --- a/lib/matplotlib/patches.pyi +++ b/lib/matplotlib/patches.pyi @@ -25,7 +25,7 @@ class Patch(artist.Artist): fill: bool = ..., capstyle: CapStyleType | None = ..., joinstyle: JoinStyleType | None = ..., - hatchcolor: ColorType | None = ..., + hatchcolor: ColorType | Literal["edge"] | None = ..., **kwargs, ) -> None: ... def get_verts(self) -> ArrayLike: ... diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index 10b6af8d5883..9dd50e436ea3 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -2649,8 +2649,8 @@ def test_contour_hatching(): x, y, z = contour_dat() fig, ax = plt.subplots() ax.contourf(x, y, z, 7, hatches=['/', '\\', '//', '-'], - cmap=mpl.colormaps['gray'], - extend='both', alpha=0.5) + cmap=mpl.colormaps['gray'].with_alpha(0.5), + extend='both') @image_comparison(['contour_colorbar'], style='mpl20', diff --git a/lib/matplotlib/tests/test_backend_bases.py b/lib/matplotlib/tests/test_backend_bases.py index ef5a52d988bb..d96ce1ad9433 100644 --- a/lib/matplotlib/tests/test_backend_bases.py +++ b/lib/matplotlib/tests/test_backend_bases.py @@ -38,7 +38,7 @@ def check(master_transform, paths, all_transforms, gc, range(len(raw_paths)), offsets, transforms.AffineDeltaTransform(master_transform), facecolors, edgecolors, [], [], [False], - [], 'screen')] + [], 'screen', hatchcolors=[])] uses = rb._iter_collection_uses_per_path( paths, all_transforms, offsets, facecolors, edgecolors) if raw_paths: diff --git a/lib/matplotlib/tests/test_collections.py b/lib/matplotlib/tests/test_collections.py index db9c4be81a44..bbed939c0c07 100644 --- a/lib/matplotlib/tests/test_collections.py +++ b/lib/matplotlib/tests/test_collections.py @@ -1384,3 +1384,130 @@ def test_hatch_linewidth(fig_test, fig_ref): ax_test.add_collection(test) assert test.get_hatch_linewidth() == ref.get_hatch_linewidth() == lw + + +def test_collection_hatchcolor_inherit_logic(): + from matplotlib.collections import PathCollection + path = mpath.Path.unit_rectangle() + + edgecolors = ['purple', 'red', 'green', 'yellow'] + hatchcolors = ['orange', 'cyan', 'blue', 'magenta'] + with mpl.rc_context({'hatch.color': 'edge'}): + # edgecolor and hatchcolor is set + col = PathCollection([path], hatch='//', + edgecolor=edgecolors, hatchcolor=hatchcolors) + assert_array_equal(col.get_hatchcolor(), mpl.colors.to_rgba_array(hatchcolors)) + + # explicitly setting edgecolor and then hatchcolor + col = PathCollection([path], hatch='//') + col.set_edgecolor(edgecolors) + assert_array_equal(col.get_hatchcolor(), mpl.colors.to_rgba_array(edgecolors)) + col.set_hatchcolor(hatchcolors) + assert_array_equal(col.get_hatchcolor(), mpl.colors.to_rgba_array(hatchcolors)) + + # explicitly setting hatchcolor and then edgecolor + col = PathCollection([path], hatch='//') + col.set_hatchcolor(hatchcolors) + assert_array_equal(col.get_hatchcolor(), mpl.colors.to_rgba_array(hatchcolors)) + col.set_edgecolor(edgecolors) + assert_array_equal(col.get_hatchcolor(), mpl.colors.to_rgba_array(hatchcolors)) + + +def test_collection_hatchcolor_fallback_logic(): + from matplotlib.collections import PathCollection + path = mpath.Path.unit_rectangle() + + edgecolors = ['purple', 'red', 'green', 'yellow'] + hatchcolors = ['orange', 'cyan', 'blue', 'magenta'] + + # hatchcolor parameter should take precedence over rcParam + # When edgecolor is not set + with mpl.rc_context({'hatch.color': 'green'}): + col = PathCollection([path], hatch='//', hatchcolor=hatchcolors) + assert_array_equal(col.get_hatchcolor(), mpl.colors.to_rgba_array(hatchcolors)) + # When edgecolor is set + with mpl.rc_context({'hatch.color': 'green'}): + col = PathCollection([path], hatch='//', + edgecolor=edgecolors, hatchcolor=hatchcolors) + assert_array_equal(col.get_hatchcolor(), mpl.colors.to_rgba_array(hatchcolors)) + + # hatchcolor should not be overridden by edgecolor when + # hatchcolor parameter is not passed and hatch.color rcParam is set to a color + with mpl.rc_context({'hatch.color': 'green'}): + col = PathCollection([path], hatch='//') + assert_array_equal(col.get_hatchcolor(), mpl.colors.to_rgba_array('green')) + col.set_edgecolor(edgecolors) + assert_array_equal(col.get_hatchcolor(), mpl.colors.to_rgba_array('green')) + + # hatchcolor should match edgecolor when + # hatchcolor parameter is not passed and hatch.color rcParam is set to 'edge' + with mpl.rc_context({'hatch.color': 'edge'}): + col = PathCollection([path], hatch='//', edgecolor=edgecolors) + assert_array_equal(col.get_hatchcolor(), mpl.colors.to_rgba_array(edgecolors)) + # hatchcolor parameter is set to 'edge' + col = PathCollection([path], hatch='//', edgecolor=edgecolors, hatchcolor='edge') + assert_array_equal(col.get_hatchcolor(), mpl.colors.to_rgba_array(edgecolors)) + + # default hatchcolor should be used when hatchcolor parameter is not passed and + # hatch.color rcParam is set to 'edge' and edgecolor is not set + col = PathCollection([path], hatch='//') + assert_array_equal(col.get_hatchcolor(), + mpl.colors.to_rgba_array(mpl.rcParams['patch.edgecolor'])) + + +@pytest.mark.parametrize('backend', ['agg', 'pdf', 'svg', 'ps']) +def test_draw_path_collection_no_hatchcolor(backend): + from matplotlib.collections import PathCollection + path = mpath.Path.unit_rectangle() + + plt.switch_backend(backend) + fig, ax = plt.subplots() + renderer = fig._get_renderer() + + col = PathCollection([path], hatch='//') + ax.add_collection(col) + + gc = renderer.new_gc() + transform = mtransforms.IdentityTransform() + paths = col.get_paths() + transforms = col.get_transforms() + offsets = col.get_offsets() + offset_trf = col.get_offset_transform() + facecolors = col.get_facecolor() + edgecolors = col.get_edgecolor() + linewidths = col.get_linewidth() + linestyles = col.get_linestyle() + antialiaseds = col.get_antialiased() + urls = col.get_urls() + offset_position = "screen" + + renderer.draw_path_collection( + gc, transform, paths, transforms, offsets, offset_trf, + facecolors, edgecolors, linewidths, linestyles, + antialiaseds, urls, offset_position + ) + + +def test_third_party_backend_hatchcolors_arg_fallback(monkeypatch): + fig, ax = plt.subplots() + canvas = fig.canvas + renderer = canvas.get_renderer() + + # monkeypatch the `draw_path_collection` method to simulate a third-party backend + # that does not support the `hatchcolors` argument. + def mock_draw_path_collection(self, gc, master_transform, paths, all_transforms, + offsets, offset_trans, facecolors, edgecolors, + linewidths, linestyles, antialiaseds, urls, + offset_position): + pass + + monkeypatch.setattr(renderer, 'draw_path_collection', mock_draw_path_collection) + + # Create a PathCollection with hatch colors + from matplotlib.collections import PathCollection + path = mpath.Path.unit_rectangle() + coll = PathCollection([path], hatch='//', hatchcolor='red') + + ax.add_collection(coll) + + plt.draw() diff --git a/src/_backend_agg.h b/src/_backend_agg.h index f1fbf11ea4e5..0e33d38dfd93 100644 --- a/src/_backend_agg.h +++ b/src/_backend_agg.h @@ -177,7 +177,8 @@ class RendererAgg ColorArray &edgecolors, LineWidthArray &linewidths, DashesVector &linestyles, - AntialiasedArray &antialiaseds); + AntialiasedArray &antialiaseds, + ColorArray &hatchcolors); template void draw_quad_mesh(GCAgg &gc, @@ -189,7 +190,8 @@ class RendererAgg agg::trans_affine &offset_trans, ColorArray &facecolors, bool antialiased, - ColorArray &edgecolors); + ColorArray &edgecolors, + ColorArray &hatchcolors); template void draw_gouraud_triangles(GCAgg &gc, @@ -272,7 +274,8 @@ class RendererAgg DashesVector &linestyles, AntialiasedArray &antialiaseds, bool check_snap, - bool has_codes); + bool has_codes, + ColorArray &hatchcolors); template void _draw_gouraud_triangle(PointArray &points, @@ -917,7 +920,8 @@ inline void RendererAgg::_draw_path_collection_generic(GCAgg &gc, DashesVector &linestyles, AntialiasedArray &antialiaseds, bool check_snap, - bool has_codes) + bool has_codes, + ColorArray &hatchcolors) { typedef agg::conv_transform transformed_path_t; typedef PathNanRemover nan_removed_t; @@ -937,11 +941,12 @@ inline void RendererAgg::_draw_path_collection_generic(GCAgg &gc, size_t Ntransforms = safe_first_shape(transforms); size_t Nfacecolors = safe_first_shape(facecolors); size_t Nedgecolors = safe_first_shape(edgecolors); + size_t Nhatchcolors = safe_first_shape(hatchcolors); size_t Nlinewidths = safe_first_shape(linewidths); size_t Nlinestyles = std::min(linestyles.size(), N); size_t Naa = safe_first_shape(antialiaseds); - if ((Nfacecolors == 0 && Nedgecolors == 0) || Npaths == 0) { + if ((Nfacecolors == 0 && Nedgecolors == 0 && Nhatchcolors == 0) || Npaths == 0) { return; } @@ -1004,6 +1009,11 @@ inline void RendererAgg::_draw_path_collection_generic(GCAgg &gc, } } + if(Nhatchcolors) { + int ic = i % Nhatchcolors; + gc.hatch_color = agg::rgba(hatchcolors(ic, 0), hatchcolors(ic, 1), hatchcolors(ic, 2), hatchcolors(ic, 3)); + } + gc.isaa = antialiaseds(i % Naa); transformed_path_t tpath(path, trans); nan_removed_t nan_removed(tpath, true, has_codes); @@ -1048,7 +1058,8 @@ inline void RendererAgg::draw_path_collection(GCAgg &gc, ColorArray &edgecolors, LineWidthArray &linewidths, DashesVector &linestyles, - AntialiasedArray &antialiaseds) + AntialiasedArray &antialiaseds, + ColorArray &hatchcolors) { _draw_path_collection_generic(gc, master_transform, @@ -1065,7 +1076,8 @@ inline void RendererAgg::draw_path_collection(GCAgg &gc, linestyles, antialiaseds, true, - true); + true, + hatchcolors); } template @@ -1151,7 +1163,8 @@ inline void RendererAgg::draw_quad_mesh(GCAgg &gc, agg::trans_affine &offset_trans, ColorArray &facecolors, bool antialiased, - ColorArray &edgecolors) + ColorArray &edgecolors, + ColorArray &hatchcolors) { QuadMeshGenerator path_generator(mesh_width, mesh_height, coordinates); @@ -1175,7 +1188,8 @@ inline void RendererAgg::draw_quad_mesh(GCAgg &gc, linestyles, antialiaseds, true, // check_snap - false); + false, + hatchcolors); } template diff --git a/src/_backend_agg_wrapper.cpp b/src/_backend_agg_wrapper.cpp index 269e2aaa9ee5..053606697388 100644 --- a/src/_backend_agg_wrapper.cpp +++ b/src/_backend_agg_wrapper.cpp @@ -146,12 +146,14 @@ PyRendererAgg_draw_path_collection(RendererAgg *self, py::array_t antialiaseds_obj, py::object Py_UNUSED(ignored_obj), // offset position is no longer used - py::object Py_UNUSED(offset_position_obj)) + py::object Py_UNUSED(offset_position_obj), + py::array_t hatchcolors_obj) { auto transforms = convert_transforms(transforms_obj); auto offsets = convert_points(offsets_obj); auto facecolors = convert_colors(facecolors_obj); auto edgecolors = convert_colors(edgecolors_obj); + auto hatchcolors = convert_colors(hatchcolors_obj); auto linewidths = linewidths_obj.unchecked<1>(); auto antialiaseds = antialiaseds_obj.unchecked<1>(); @@ -165,7 +167,8 @@ PyRendererAgg_draw_path_collection(RendererAgg *self, edgecolors, linewidths, dashes, - antialiaseds); + antialiaseds, + hatchcolors); } static void @@ -179,12 +182,14 @@ PyRendererAgg_draw_quad_mesh(RendererAgg *self, agg::trans_affine offset_trans, py::array_t facecolors_obj, bool antialiased, - py::array_t edgecolors_obj) + py::array_t edgecolors_obj, + py::array_t hatchcolors_obj) { auto coordinates = coordinates_obj.mutable_unchecked<3>(); auto offsets = convert_points(offsets_obj); auto facecolors = convert_colors(facecolors_obj); auto edgecolors = convert_colors(edgecolors_obj); + auto hatchcolors = convert_colors(hatchcolors_obj); self->draw_quad_mesh(gc, master_transform, @@ -195,7 +200,8 @@ PyRendererAgg_draw_quad_mesh(RendererAgg *self, offset_trans, facecolors, antialiased, - edgecolors); + edgecolors, + hatchcolors); } static void @@ -229,11 +235,13 @@ PYBIND11_MODULE(_backend_agg, m, py::mod_gil_not_used()) .def("draw_path_collection", &PyRendererAgg_draw_path_collection, "gc"_a, "master_transform"_a, "paths"_a, "transforms"_a, "offsets"_a, "offset_trans"_a, "facecolors"_a, "edgecolors"_a, "linewidths"_a, - "dashes"_a, "antialiaseds"_a, "ignored"_a, "offset_position"_a) + "dashes"_a, "antialiaseds"_a, "ignored"_a, "offset_position"_a, + py::kw_only(), "hatchcolors"_a = py::array_t().reshape({0, 4})) .def("draw_quad_mesh", &PyRendererAgg_draw_quad_mesh, "gc"_a, "master_transform"_a, "mesh_width"_a, "mesh_height"_a, "coordinates"_a, "offsets"_a, "offset_trans"_a, "facecolors"_a, - "antialiased"_a, "edgecolors"_a) + "antialiased"_a, "edgecolors"_a, py::kw_only(), + "hatchcolors"_a = py::array_t().reshape({0, 4})) .def("draw_gouraud_triangles", &PyRendererAgg_draw_gouraud_triangles, "gc"_a, "points"_a, "colors"_a, "trans"_a = nullptr)