Repository: SciTools/cartopy

Issue:
Objects created with 'add_geometries' are missing the 'color', 'edgecolor', ... properties
### Description
<!-- Please provide a general introduction to the issue/proposal. -->
In matplotlib it is possible to change the color (face,edge,...) of an object that has already been created. This is not possible with an object created with `add_geometries`.

In matplotlib, we can create a plot and change its color with:
```
import matplolib.pyplot as plt

line, = plt.plot([1,2,3],[4,5,6],'red') #Plot in red
line.set_color('green') #Change the color to green
```
But in cartopy, the 'geo' object created with the following code will not have any 'color' (or similar) property.:
```
geo = ax.add_geometries(geometry,facecolor='ghostwhite', edgecolor='black',crs=data_transf)
geo.set_color('green') #Does not exist
```

This issue is in response to a stackoverflow [question](https://stackoverflow.com/questions/53356382/is-it-possible-to-change-the-color-of-an-object-in-cartopy-after-it-has-been-add).


<!--
If you are reporting a bug, attach the *entire* traceback from Python.

If you are proposing an enhancement/new feature, provide links to related articles, reference examples, etc.

If you are asking a question, please ask on StackOverflow and use the cartopy tag. All cartopy
questions on StackOverflow can be found at https://stackoverflow.com/questions/tagged/cartopy
-->


#### Code to reproduce

A call to `matplotlib.artist.getp` will show that no `color`, `facecolor`,... exist for a `FeatureArtist` from cartopy added with `add_geometries`.

For a plot, the call returns:

```
>>import matplotlib.artist as mart
>>mart.getp(line)
agg_filter = None
alpha = None
animated = False
antialiased = True
children = []
clip_box = TransformedBbox(     Bbox(x0=0.0, y0=0.0, x1=1.0, ...
clip_on = True
clip_path = None
color = (1.0, 0.0, 0.0, 1.0)
contains = None
dash_capstyle = butt
dash_joinstyle = round
data = (array([1, 2, 3]), array([4, 5, 6]))
drawstyle = default
figure = Figure(640x476)
fillstyle = full
gid = None
in_layout = True
label = _line0
linestyle = -
linewidth = 1.5
marker = None
markeredgecolor = (1.0, 0.0, 0.0, 1.0)
markeredgewidth = 1.0
markerfacecolor = (1.0, 0.0, 0.0, 1.0)
markerfacecoloralt = none
markersize = 6.0
markevery = None
path = Path(array([[ 1.,  4.],        [ 2.,  5.],        ...
path_effects = []
picker = None
pickradius = 5
rasterized = None
```


And for a geometry, it returns:

```
>>mart.getp(geo)
agg_filter = None
alpha = None
animated = False
children = []
clip_box = TransformedBbox(     Bbox(x0=0.0, y0=0.0, x1=1.0, ...
clip_on = True
clip_path = None
contains = None
figure = Figure(1200x990)
gid = None
in_layout = True
label = 
path_effects = []
picker = None
rasterized = None
sketch_params = None
snap = None
transform = CompositeGenericTransform(     TransformWrapper(  ...
transformed_clip_path_and_affine = (None, None)
url = None
visible = True
zorder = 1
```

We can see that the properties related to colors are missing.




Discussion:
I'd be happy enough to enable this behaviour, but it looks like to support ``getp`` and ``setp``, we'd need to implement all of the ``set_*`` and ``get_*`` methods. I'm quite reluctant to do this, as it would inevitably get out of date, add things we shouldn't, and miss things we don't want to miss.

The feature class has been created to be somewhat immutable, but the feature artist not so much (as they are cheap to construct, and you need one per mpl axes anyway). As a result, updating the kwargs of a FeatureArtist isn't too much of a concern IMO, so long as we don't do too much work in the ``__init__``. Unfortunately, we have **just** added some work into the ``__init__`` (in #1029) that should be removed by #1190.

The magic line in matplotlib that does the work of finding the properties of the feature is in https://github.com/matplotlib/matplotlib/blob/master/lib/matplotlib/artist.py#L1284 (``dir(self.o)`` followed by a ``continue`` if the method doesn't start with ``get_`` or ``set_``). There is a really great opportunity for somebody to make a significant improvement to the mpl code here - matplotlib could happly have a "what style kwargs do you support" type API rather than a method introspection one.

Alas, without that, we're forced to expose all of the methods that ``PathCollection`` exposes. What better way of doing that than using the matplotlib ArtistInspector class...

```
>>> import matplotlib.artist
>>> import matplotlib.collections
>>> pc = matplotlib.collections.PathCollection
>>> matplotlib.artist.ArtistInspector(pc)
<matplotlib.artist.ArtistInspector object at 0x112ee0048>
>>> i = matplotlib.artist.ArtistInspector(pc)
>>> i._
i._get_setters_and_targets(  i._get_valid_values_regex    i._replace_path(             
>>> i._get_setters_and_targets()
[('agg_filter', 'matplotlib.artist.Artist.set_agg_filter'), ('alpha', 'matplotlib.collections.Collection.set_alpha'), ('animated', 'matplotlib.artist.Artist.set_animated'), ('antialiased', 'matplotlib.collections.Collection.set_antialiased'), ('array', 'matplotlib.cm.ScalarMappable.set_array'), ('capstyle', 'matplotlib.collections.Collection.set_capstyle'), ('clim', 'matplotlib.cm.ScalarMappable.set_clim'), ('clip_box', 'matplotlib.artist.Artist.set_clip_box'), ('clip_on', 'matplotlib.artist.Artist.set_clip_on'), ('clip_path', 'matplotlib.artist.Artist.set_clip_path'), ('cmap', 'matplotlib.cm.ScalarMappable.set_cmap'), ('color', 'matplotlib.collections.Collection.set_color'), ('contains', 'matplotlib.artist.Artist.set_contains'), ('edgecolor', 'matplotlib.collections.Collection.set_edgecolor'), ('facecolor', 'matplotlib.collections.Collection.set_facecolor'), ('figure', 'matplotlib.artist.Artist.set_figure'), ('gid', 'matplotlib.artist.Artist.set_gid'), ('hatch', 'matplotlib.collections.Collection.set_hatch'), ('in_layout', 'matplotlib.artist.Artist.set_in_layout'), ('joinstyle', 'matplotlib.collections.Collection.set_joinstyle'), ('label', 'matplotlib.artist.Artist.set_label'), ('linestyle', 'matplotlib.collections.Collection.set_linestyle'), ('linewidth', 'matplotlib.collections.Collection.set_linewidth'), ('norm', 'matplotlib.cm.ScalarMappable.set_norm'), ('offset_position', 'matplotlib.collections.Collection.set_offset_position'), ('offsets', 'matplotlib.collections.Collection.set_offsets'), ('path_effects', 'matplotlib.artist.Artist.set_path_effects'), ('paths', 'matplotlib.collections.PathCollection.set_paths'), ('picker', 'matplotlib.artist.Artist.set_picker'), ('pickradius', 'matplotlib.collections.Collection.set_pickradius'), ('rasterized', 'matplotlib.artist.Artist.set_rasterized'), ('sizes', 'matplotlib.collections._CollectionWithSizes.set_sizes'), ('sketch_params', 'matplotlib.artist.Artist.set_sketch_params'), ('snap', 'matplotlib.artist.Artist.set_snap'), ('transform', 'matplotlib.artist.Artist.set_transform'), ('url', 'matplotlib.artist.Artist.set_url'), ('urls', 'matplotlib.collections.Collection.set_urls'), ('visible', 'matplotlib.artist.Artist.set_visible'), ('zorder', 'matplotlib.artist.Artist.set_zorder')]
>>> i.get_setters()
['agg_filter', 'alpha', 'animated', 'antialiased', 'array', 'capstyle', 'clim', 'clip_box', 'clip_on', 'clip_path', 'cmap', 'color', 'contains', 'edgecolor', 'facecolor', 'figure', 'gid', 'hatch', 'in_layout', 'joinstyle', 'label', 'linestyle', 'linewidth', 'norm', 'offset_position', 'offsets', 'path_effects', 'paths', 'picker', 'pickradius', 'rasterized', 'sizes', 'sketch_params', 'snap', 'transform', 'url', 'urls', 'visible', 'zorder']
```


I've taken a punt at an implementation in #1231.

*poke* ... also poked the accompanying PR from way back - I think lots of folks would enjoy this functionality.

Resolving patch:
diff --git a/lib/cartopy/mpl/feature_artist.py b/lib/cartopy/mpl/feature_artist.py
--- a/lib/cartopy/mpl/feature_artist.py
+++ b/lib/cartopy/mpl/feature_artist.py
@@ -18,8 +18,9 @@
 import matplotlib.path as mpath
 import numpy as np
 
+import cartopy.feature as cfeature
+from cartopy.mpl import _MPL_38
 import cartopy.mpl.patch as cpatch
-from .style import finalize as style_finalize
 from .style import merge as style_merge
 
 
@@ -59,9 +60,9 @@ def _freeze(obj):
     return obj
 
 
-class FeatureArtist(matplotlib.artist.Artist):
+class FeatureArtist(matplotlib.collections.Collection):
     """
-    A subclass of :class:`~matplotlib.artist.Artist` capable of
+    A subclass of :class:`~matplotlib.collections.Collection` capable of
     drawing a :class:`cartopy.feature.Feature`.
 
     """
@@ -103,8 +104,6 @@ def __init__(self, feature, **kwargs):
         """
         super().__init__()
 
-        if kwargs is None:
-            kwargs = {}
         self._styler = kwargs.pop('styler', None)
         self._kwargs = dict(kwargs)
 
@@ -114,21 +113,46 @@ def __init__(self, feature, **kwargs):
             color = self._kwargs.pop('color')
             self._kwargs['facecolor'] = self._kwargs['edgecolor'] = color
 
+        # Paths are worked out at draw, but add_collection fails if paths is
+        # left to the default of None.
+        self.set_paths([])
+
         # Set default zorder so that features are drawn under
         # lines e.g. contours but over images and filled patches.
         # Note that the zorder of Patch, PatchCollection and PathCollection
         # are all 1 by default. Assuming default zorder, drawing takes place in
         # the following order: collections, patches, FeatureArtist, lines,
         # text.
-        if self._kwargs.get('zorder') is not None:
-            self.set_zorder(self._kwargs['zorder'])
-        elif feature.kwargs.get('zorder') is not None:
-            self.set_zorder(feature.kwargs['zorder'])
-        else:
-            self.set_zorder(1.5)
+        self.set_zorder(1.5)
+
+        # Update drawing styles from the feature and **kwargs.
+        self.set(**feature.kwargs)
+        self.set(**self._kwargs)
 
         self._feature = feature
 
+    def set_facecolor(self, c):
+        """
+        Set the facecolor(s) of the `.FeatureArtist`.  If set to 'never' then
+        subsequent calls will have no effect.  Otherwise works the same as
+        `matplotlib.collections.Collection.set_facecolor`.
+        """
+        if isinstance(c, str) and c == 'never':
+            self._never_fc = True
+            super().set_facecolor('none')
+
+        elif (getattr(self, '_never_fc', False) and
+                (not isinstance(c, str) or c != 'none')):
+            warnings.warn('facecolor will have no effect as it has been '
+                          'defined as "never".')
+        else:
+            super().set_facecolor(c)
+
+    if not _MPL_38:
+        # set_paths does not yet exist on Collection.
+        def set_paths(self, paths):
+            self._paths = paths
+
     @matplotlib.artist.allow_rasterization
     def draw(self, renderer, *args, **kwargs):
         """
@@ -149,16 +173,22 @@ def draw(self, renderer, *args, **kwargs):
             extent = ax.get_extent(feature_crs)
         except ValueError:
             warnings.warn('Unable to determine extent. Defaulting to global.')
-        geoms = self._feature.intersecting_geometries(extent)
 
-        # Combine all the keyword args in priority order.
-        prepared_kwargs = style_merge(self._feature.kwargs,
-                                      self._kwargs,
-                                      kwargs)
+        if isinstance(self._feature, cfeature.ShapelyFeature):
+            # User passed a specific list of geometries.  If they also passed
+            # `array` or a list of facecolors then we should keep the colours
+            # consistent after pan/zoom.  Do this by creating a Path for every
+            # geometry regardless of whether they are currently in view.
+            geoms = self._feature.geometries()
+        else:
+            # For efficiency on local maps with high resolution features (e.g
+            # from Natural Earth), only create paths for geometries that are
+            # in view.
+            geoms = self._feature.intersecting_geometries(extent)
 
         # Freeze the kwargs so that we can use them as a dict key. We will
         # need to unfreeze this with dict(frozen) before passing to mpl.
-        prepared_kwargs = _freeze(prepared_kwargs)
+        prepared_kwargs = _freeze(kwargs)
 
         # Project (if necessary) and convert geometries to matplotlib paths.
         stylised_paths = OrderedDict()
@@ -189,8 +219,7 @@ def draw(self, renderer, *args, **kwargs):
                     projected_geom = geom
 
                 geom_paths = cpatch.geos_to_path(projected_geom)
-                if not geom_paths:
-                    continue
+
                 # The transform may have split the geometry into two paths, we only want
                 # one compound path.
                 geom_path = mpath.Path.make_compound_path(*geom_paths)
@@ -205,20 +234,17 @@ def draw(self, renderer, *args, **kwargs):
 
             stylised_paths.setdefault(style, []).append(geom_path)
 
-        transform = ax.projection._as_mpl_transform(ax)
+        self.set_clip_path(ax.patch)
 
-        # Draw one PathCollection per style. We could instead pass an array
-        # of style items through to a single PathCollection, but that
-        # complexity does not yet justify the effort.
+        # Draw each style individually.  Note that there will only be multiple
+        # styles if styler was used.
         for style, paths in stylised_paths.items():
-            style = style_finalize(dict(style))
-            # Build path collection and draw it.
-            c = matplotlib.collections.PathCollection(paths,
-                                                      transform=transform,
-                                                      **style)
-            c.set_clip_path(ax.patch)
-            c.set_figure(ax.figure)
-            c.draw(renderer)
-
-        # n.b. matplotlib.collection.Collection.draw returns None
-        return None
+            style = dict(style)
+
+            # Temporarily replace properties.
+            orig_style = {k: getattr(self, f"get_{k}")() for k in style}
+            self.set(paths=paths, **style)
+
+            super().draw(renderer)
+
+            self.set(paths=[], **orig_style)
diff --git a/lib/cartopy/mpl/geoaxes.py b/lib/cartopy/mpl/geoaxes.py
--- a/lib/cartopy/mpl/geoaxes.py
+++ b/lib/cartopy/mpl/geoaxes.py
@@ -713,7 +713,7 @@ def add_feature(self, feature, **kwargs):
         """
         # Instantiate an artist to draw the feature and add it to the axes.
         artist = feature_artist.FeatureArtist(feature, **kwargs)
-        return self.add_artist(artist)
+        return self.add_collection(artist)
 
     def add_geometries(self, geoms, crs, **kwargs):
         """


Test patch:
diff --git a/lib/cartopy/tests/mpl/baseline_images/mpl/test_feature_artist/feature_artist.png b/lib/cartopy/tests/mpl/baseline_images/mpl/test_feature_artist/feature_artist.png
new file mode 100644
Binary files /dev/null and b/lib/cartopy/tests/mpl/baseline_images/mpl/test_feature_artist/feature_artist.png differ
diff --git a/lib/cartopy/tests/mpl/test_feature_artist.py b/lib/cartopy/tests/mpl/test_feature_artist.py
--- a/lib/cartopy/tests/mpl/test_feature_artist.py
+++ b/lib/cartopy/tests/mpl/test_feature_artist.py
@@ -3,20 +3,17 @@
 # This file is part of Cartopy and is released under the BSD 3-clause license.
 # See LICENSE in the root of the repository for full licensing details.
 
-from unittest import mock
 
+import matplotlib.colors as mcolors
 import matplotlib.path as mpath
 import matplotlib.pyplot as plt
-from matplotlib.transforms import IdentityTransform
 import numpy as np
 import pytest
 import shapely.geometry as sgeom
 
 import cartopy.crs as ccrs
 from cartopy.feature import ShapelyFeature
-from cartopy.mpl import style
 from cartopy.mpl.feature_artist import FeatureArtist, _freeze, _GeomKey
-import cartopy.mpl.geoaxes as geoaxes
 
 
 @pytest.mark.parametrize("source, expected", [
@@ -35,33 +32,26 @@ def test_freeze(source, expected):
 
 @pytest.fixture
 def feature():
-    unit_circle = sgeom.Point(0, 0).buffer(0.5)
-    unit_square = unit_circle.envelope
-    geoms = [unit_circle, unit_square]
+    circle1 = sgeom.Point(0, 0).buffer(1)
+    circle2 = sgeom.Point(0, 0).buffer(10)
+    square = sgeom.Polygon([(30, 0), (50, 0), (50, 20), (30, 20), (30, 0)])
+    geoms = [circle1, circle2, square]
     feature = ShapelyFeature(geoms, ccrs.PlateCarree())
     return feature
 
 
-def mocked_axes(extent, projection=ccrs.PlateCarree()):
-    return mock.MagicMock(
-        get_extent=mock.Mock(return_value=extent),
-        projection=projection,
-        spec=geoaxes.GeoAxes,
-        transData=IdentityTransform(),
-        patch=mock.sentinel.patch,
-        figure=mock.sentinel.figure)
-
-
-# Need to initialize private renderer properties on the sentinel
-mock.sentinel.renderer._raster_depth = 0
-mock.sentinel.renderer._rasterizing = False
-
+def robinson_map():
+    """
+    Set up a common map for the image tests.  The extent is chosen to include
+    only the square geometry from `feature`.  This means that we can check that
+    `array` or a list of facecolors remains 1-to-1 with the list of geometries.
+    """
+    prj_crs = ccrs.Robinson()
+    fig, ax = plt.subplots(subplot_kw={'projection':prj_crs})
+    ax.set_extent([20, 180, -90, 90])
+    ax.coastlines()
 
-def style_from_call(call):
-    args, kwargs = call
-    # Drop the transform keyword.
-    kwargs.pop('transform')
-    return kwargs
+    return fig, ax
 
 
 def cached_paths(geom, target_projection):
@@ -71,73 +61,67 @@ def cached_paths(geom, target_projection):
     return geom_cache.get(target_projection, None)
 
 
-@mock.patch('matplotlib.collections.PathCollection')
-def test_feature_artist_draw(path_collection_cls, feature):
-    geoms = list(feature.geometries())
+@pytest.mark.mpl_image_compare(filename='feature_artist.png')
+def test_feature_artist_draw(feature):
+    fig, ax = robinson_map()
+    ax.add_feature(feature, facecolor='blue')
 
-    fa = FeatureArtist(feature, facecolor='red')
-    prj_crs = ccrs.Robinson()
-    fa.axes = mocked_axes(extent=[-10, 10, -10, 10], projection=prj_crs)
-    fa.draw(mock.sentinel.renderer)
+    return fig
+
+
+@pytest.mark.mpl_image_compare(filename='feature_artist.png')
+def test_feature_artist_draw_facecolor_list(feature):
+    fig, ax = robinson_map()
+    ax.add_feature(feature, facecolor=['red', 'green', 'blue'])
+
+    return fig
 
-    transform = prj_crs._as_mpl_transform(fa.axes)
-    expected_paths = ([cached_paths(geoms[0], prj_crs),
-                       cached_paths(geoms[1], prj_crs)], )
-    expected_style = {'facecolor': 'red'}
 
-    args, kwargs = path_collection_cls.call_args_list[0]
-    assert transform == kwargs.pop('transform', None)
-    assert kwargs == expected_style
-    assert args == expected_paths
+@pytest.mark.mpl_image_compare(filename='feature_artist.png')
+def test_feature_artist_draw_cmap(feature):
+    fig, ax = robinson_map()
 
-    path_collection_cls().set_clip_path.assert_called_once_with(fa.axes.patch)
-    path_collection_cls().set_figure.assert_called_once_with(fa.axes.figure)
-    path_collection_cls().draw(mock.sentinel.renderer)
+    cmap = mcolors.ListedColormap(['red', 'gray', 'blue'])
+    ax.add_feature(feature, cmap=cmap, array=[0, 0, 1])
 
+    return fig
 
-@mock.patch('matplotlib.collections.PathCollection')
-def test_feature_artist_draw_styler(path_collection_cls, feature):
+
+@pytest.mark.mpl_image_compare(filename='feature_artist.png')
+def test_feature_artist_draw_styled_feature(feature):
     geoms = list(feature.geometries())
-    style1 = {'facecolor': 'blue', 'edgecolor': 'white'}
-    style2 = {'color': 'black', 'linewidth': 1}
-    style2_finalized = style.finalize(style.merge(style2))
+    styled_feature = ShapelyFeature(geoms, crs=ccrs.PlateCarree(), facecolor='blue')
 
-    def styler(geom):
-        if geom == geoms[0]:
-            return style1
-        else:
-            return style2
+    fig, ax = robinson_map()
+    ax.add_feature(styled_feature)
+
+    return fig
 
-    fa = FeatureArtist(feature, styler=styler, linewidth=2)
-    extent = [-10, 10, -10, 10]
-    prj_crs = ccrs.Robinson()
-    fa.axes = mocked_axes(extent, projection=prj_crs)
-    fa.draw(mock.sentinel.renderer)
 
-    transform = prj_crs._as_mpl_transform(fa.axes)
+@pytest.mark.mpl_image_compare(filename='feature_artist.png')
+def test_feature_artist_draw_styler(feature):
+    geoms = list(feature.geometries())
+
+    def styler(geom):
+        if geom == geoms[1]:
+            return {'facecolor': 'red'}
+        else:
+            return {'facecolor':'blue'}
 
-    calls = [{'paths': ([cached_paths(geoms[0], prj_crs)], ),
-              'style': dict(linewidth=2, **style1)},
-             {'paths': ([cached_paths(geoms[1], prj_crs)], ),
-              'style': style2_finalized}]
+    fig, ax = robinson_map()
+    ax.add_feature(feature, facecolor='red', styler=styler)
 
-    assert path_collection_cls.call_count == 2
-    for expected_call, (actual_args, actual_kwargs) in \
-            zip(calls, path_collection_cls.call_args_list):
-        assert expected_call['paths'] == actual_args
-        assert transform == actual_kwargs.pop('transform')
-        assert expected_call['style'] == actual_kwargs
+    return fig
 
 
 def test_feature_artist_geom_single_path(feature):
     plot_crs = ccrs.PlateCarree(central_longitude=180)
-    fig, ax = plt.subplots(
-        subplot_kw={'projection': plot_crs})
+    fig, ax = plt.subplots(subplot_kw={'projection': plot_crs})
     ax.add_feature(feature)
 
     fig.draw_without_rendering()
 
-    # Square gets split into two geometries across the dateline, but should still be
+    # Circles get split into two geometries across the dateline, but should still be
     # plotted as one compound path to ensure style consistency.
     for geom in feature.geometries():
         assert isinstance(cached_paths(geom, plot_crs), mpath.Path)
diff --git a/lib/cartopy/tests/mpl/test_gridliner.py b/lib/cartopy/tests/mpl/test_gridliner.py
--- a/lib/cartopy/tests/mpl/test_gridliner.py
+++ b/lib/cartopy/tests/mpl/test_gridliner.py
@@ -510,7 +510,6 @@ def test_gridliner_remove():
     gl.remove()
 
     assert gl not in ax.artists
-    assert not ax.collections
 
     return fig