Repository: matplotlib/matplotlib

Issue:
# [ENH]: Implement gapcolor for patch edges

### Problem

The gapcolor property allows drawing "stripey" Line2Ds and LineCollections with two different colors.  Another set of artists that could benefit from this feature is patches -- currently, their edge can be drawn with dashes, but not with two different colors.

An example use case is to draw (unfilled) patches on a background whose color is not controlled, and can in particular be very light or very dark.  In that case, using stripey patches alternating black and white ensures that the patch is visible no matter the underlying background.

### Proposed solution

Add support for gapcolor in Patches.  (Maybe the property should be named "edgegapcolor" for consistency.)

Implementation patch:
diff --git a/lib/matplotlib/patches.py b/lib/matplotlib/patches.py
index 0c8d6b5fee15..4a4bd698db04 100644
--- a/lib/matplotlib/patches.py
+++ b/lib/matplotlib/patches.py
@@ -57,6 +57,7 @@ def __init__(self, *,
                  capstyle=None,
                  joinstyle=None,
                  hatchcolor=None,
+                 edgegapcolor=None,
                  **kwargs):
         """
         The following kwarg properties are supported
@@ -88,6 +89,7 @@ def __init__(self, *,
         self._linewidth = 0
         self._unscaled_dash_pattern = (0, None)  # offset, dash
         self._dash_pattern = (0, None)  # offset, dash (scaled by linewidth)
+        self._gapcolor = None
 
         self.set_linestyle(linestyle)
         self.set_linewidth(linewidth)
@@ -95,6 +97,7 @@ def __init__(self, *,
         self.set_hatch(hatch)
         self.set_capstyle(capstyle)
         self.set_joinstyle(joinstyle)
+        self.set_edgegapcolor(edgegapcolor)
 
         if len(kwargs):
             self._internal_update(kwargs)
@@ -294,6 +297,7 @@ def update_from(self, other):
         self._hatch_color = other._hatch_color
         self._original_hatchcolor = other._original_hatchcolor
         self._unscaled_dash_pattern = other._unscaled_dash_pattern
+        self._gapcolor = other._gapcolor
         self.set_linewidth(other._linewidth)  # also sets scaled dashes
         self.set_transform(other.get_data_transform())
         # If the transform of other needs further initialization, then it will
@@ -442,6 +446,42 @@ def set_hatchcolor(self, color):
         self._original_hatchcolor = color
         self._set_hatchcolor(color)
 
+    def get_edgegapcolor(self):
+        """
+        Return the edge gap color.
+
+        .. versionadded:: 3.11
+
+        See also `~.Patch.set_edgegapcolor`.
+        """
+        return self._gapcolor
+
+    def set_edgegapcolor(self, edgegapcolor):
+        """
+        Set a color to fill the gaps in the dashed edge style.
+
+        .. versionadded:: 3.11
+
+        .. note::
+
+            Striped edges are created by drawing two interleaved dashed lines.
+            There can be overlaps between those two, which may result in
+            artifacts when using transparency.
+
+            This functionality is experimental and may change.
+
+        Parameters
+        ----------
+        edgegapcolor : :mpltype:`color` or None
+            The color with which to fill the gaps. If None, the gaps are
+            unfilled.
+        """
+        if edgegapcolor is not None:
+            self._gapcolor = colors.to_rgba(edgegapcolor, self._alpha)
+        else:
+            self._gapcolor = None
+        self.stale = True
+
     def set_alpha(self, alpha):
         # docstring inherited
         super().set_alpha(alpha)
@@ -618,6 +658,17 @@ def get_hatch_linewidth(self):
         """Return the hatch linewidth."""
         return self._hatch_linewidth
 
+    def _has_dashed_edge(self):
+        """
+        Return whether the patch edge has a dashed linestyle.
+
+        A custom linestyle is assumed to be dashed, we do not inspect the
+        ``onoffseq`` directly.
+
+        See also `~.Patch.set_linestyle`.
+        """
+        return self._linestyle not in ('solid', '-')
+
     def _draw_paths_with_artist_properties(
             self, renderer, draw_path_args_list):
         """
@@ -632,13 +683,10 @@ def _draw_paths_with_artist_properties(
         renderer.open_group('patch', self.get_gid())
         gc = renderer.new_gc()
 
-        gc.set_foreground(self._edgecolor, isRGBA=True)
-
         lw = self._linewidth
         if self._edgecolor[3] == 0 or self._linestyle == 'None':
             lw = 0
         gc.set_linewidth(lw)
-        gc.set_dashes(*self._dash_pattern)
         gc.set_capstyle(self._capstyle)
         gc.set_joinstyle(self._joinstyle)
 
@@ -661,6 +709,18 @@ def _draw_paths_with_artist_properties(
             from matplotlib.patheffects import PathEffectRenderer
             renderer = PathEffectRenderer(self.get_path_effects(), renderer)
 
+        # Draw the gaps first if gapcolor is set
+        if self._has_dashed_edge() and self._gapcolor is not None:
+            gc.set_foreground(self._gapcolor, isRGBA=True)
+            offset_gaps, gaps = mlines._get_inverse_dash_pattern(
+                *self._dash_pattern)
+            gc.set_dashes(offset_gaps, gaps)
+            for draw_path_args in draw_path_args_list:
+                renderer.draw_path(gc, *draw_path_args)
+
+        # Draw the main edge
+        gc.set_foreground(self._edgecolor, isRGBA=True)
+        gc.set_dashes(*self._dash_pattern)
         for draw_path_args in draw_path_args_list:
             renderer.draw_path(gc, *draw_path_args)
 


Test patch:
diff --git a/lib/matplotlib/tests/test_patches.py b/lib/matplotlib/tests/test_patches.py
index ed608eebb6a7..f3057d7106fd 100644
--- a/lib/matplotlib/tests/test_patches.py
+++ b/lib/matplotlib/tests/test_patches.py
@@ -1101,3 +1101,81 @@ def test_empty_fancyarrow():
     fig, ax = plt.subplots()
     arrow = ax.arrow([], [], [], [])
     assert arrow is not None
+
+
+def test_patch_edgegapcolor_getter_setter():
+    """Test that edgegapcolor can be set and retrieved."""
+    patch = Rectangle((0, 0), 1, 1)
+    # Default is None
+    assert patch.get_edgegapcolor() is None
+
+    # Set to a color
+    patch.set_edgegapcolor('red')
+    assert mcolors.same_color(patch.get_edgegapcolor(), 'red')
+
+    # Set back to None
+    patch.set_edgegapcolor(None)
+    assert patch.get_edgegapcolor() is None
+
+
+def test_patch_edgegapcolor_init():
+    """Test that edgegapcolor can be passed in __init__."""
+    patch = Rectangle((0, 0), 1, 1, edgegapcolor='blue')
+    assert mcolors.same_color(patch.get_edgegapcolor(), 'blue')
+
+
+def test_patch_has_dashed_edge():
+    """Test _has_dashed_edge method for patches."""
+    patch = Rectangle((0, 0), 1, 1)
+    patch.set_linestyle('solid')
+    assert not patch._has_dashed_edge()
+
+    patch.set_linestyle('--')
+    assert patch._has_dashed_edge()
+
+    patch.set_linestyle(':')
+    assert patch._has_dashed_edge()
+
+    patch.set_linestyle('-.')
+    assert patch._has_dashed_edge()
+
+    # Test custom linestyle
+    patch.set_linestyle((0, (2, 2, 10, 2)))
+    assert patch._has_dashed_edge()
+
+
+def test_patch_edgegapcolor_update_from():
+    """Test that edgegapcolor is copied in update_from."""
+    patch1 = Rectangle((0, 0), 1, 1, edgegapcolor='green')
+    patch2 = Rectangle((1, 1), 2, 2)
+
+    patch2.update_from(patch1)
+    assert mcolors.same_color(patch2.get_edgegapcolor(), 'green')
+
+
+@image_comparison(['patch_edgegapcolor.png'], remove_text=True, style='mpl20')
+def test_patch_edgegapcolor_visual():
+    """Visual test for patch edgegapcolor (striped edges)."""
+    fig, ax = plt.subplots()
+
+    # Rectangle with edgegapcolor
+    rect = Rectangle((0.1, 0.1), 0.3, 0.3, fill=False,
+                      edgecolor='blue', edgegapcolor='orange',
+                      linestyle='--', linewidth=3)
+    ax.add_patch(rect)
+
+    # Ellipse with edgegapcolor
+    ellipse = Ellipse((0.7, 0.3), 0.3, 0.2, fill=False,
+                       edgecolor='red', edgegapcolor='yellow',
+                       linestyle=':', linewidth=3)
+    ax.add_patch(ellipse)
+
+    # Polygon with edgegapcolor
+    polygon = Polygon([[0.1, 0.6], [0.3, 0.9], [0.4, 0.6]], fill=False,
+                       edgecolor='green', edgegapcolor='purple',
+                       linestyle='-.', linewidth=3)
+    ax.add_patch(polygon)
+
+    ax.set_xlim(0, 1)
+    ax.set_ylim(0, 1)
+    ax.set_aspect('equal')