Repository: SciTools/cartopy
Pull request #2325 (2024-02-09 17:16:30)

FIX: use compound paths to draw transformed geometries
<!--

Thanks for contributing to cartopy!
Please use this template as a guide to streamline the pull request you are about to make.

Remember: it is significantly easier to merge small pull requests. Consider whether this pull
request could be broken into smaller parts before submitting.

-->



## Rationale

<!-- Please provide detail as to *why* you are making this change. -->
When a geometry gets transformed to a different crs, it can become multiple geometries.  Currently we plot these as separate paths which could be problematic if the user passes a list of e.g. facecolors.

```python
import matplotlib.pyplot as plt
import cartopy.crs as ccrs
import shapely.geometry as sgeom

square1 = sgeom.Polygon([(-10, -10), (10, -10), (10, 10), (-10, 10), (-10, 10) ])
square2 = sgeom.Polygon([(30, 0), (50, 0), (50, 20), (30, 20), (30, 0)])

fig = plt.figure(figsize=(4, 5))

ax1 = fig.add_subplot(211, projection=ccrs.PlateCarree())
ax2 = fig.add_subplot(212, projection=ccrs.PlateCarree(central_longitude=180))

for ax in ax1, ax2:
    ax.coastlines()
    ax.add_geometries([square1, square2], crs=ccrs.PlateCarree(),
                      facecolors=['r', 'b'])

plt.show()
```
Before:
![image](https://github.com/SciTools/cartopy/assets/10599679/dfdc629e-b556-4a99-8e21-0364c9c9a810)

After:
![image](https://github.com/SciTools/cartopy/assets/10599679/fa6ae622-0eb4-448e-9b90-ef3c5b88efc6)


## Implications

<!-- If applicable, to the best of your knowledge, what are the implications of this change? -->


<!--
## Checklist

 * If you have not already done so, ensure you've read and signed the Contributor Licence Agreement (CLA).
   (See the [governance page](http://scitools.org.uk/governance.html) for the CLA and what to do with it).

 * If this is a new feature, please provide an example of its use in the description. We may want to make a
   follow-on pull request to put the example in the gallery!

 * Ensure there is a suitable item in the cartopy test suite for the change you are proposing.

-->



Patch:
diff --git a/lib/cartopy/mpl/feature_artist.py b/lib/cartopy/mpl/feature_artist.py
index 74067939..5b6f31bb 100644
--- a/lib/cartopy/mpl/feature_artist.py
+++ b/lib/cartopy/mpl/feature_artist.py
@@ -15,6 +15,7 @@ import weakref
 
 import matplotlib.artist
 import matplotlib.collections
+import matplotlib.path as mpath
 import numpy as np
 
 import cartopy.mpl.patch as cpatch
@@ -179,15 +180,21 @@ class FeatureArtist(matplotlib.artist.Artist):
                 geom_key, geom)
             mapping = FeatureArtist._geom_key_to_path_cache.setdefault(
                 geom_key, {})
-            geom_paths = mapping.get(key)
-            if geom_paths is None:
+            geom_path = mapping.get(key)
+            if geom_path is None:
                 if ax.projection != feature_crs:
                     projected_geom = ax.projection.project_geometry(
                         geom, feature_crs)
                 else:
                     projected_geom = geom
+
                 geom_paths = cpatch.geos_to_path(projected_geom)
-                mapping[key] = geom_paths
+                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)
+                mapping[key] = geom_path
 
             if not self._styler:
                 style = prepared_kwargs
@@ -196,7 +203,7 @@ class FeatureArtist(matplotlib.artist.Artist):
                 style = style_merge(dict(prepared_kwargs), self._styler(geom))
                 style = _freeze(style)
 
-            stylised_paths.setdefault(style, []).extend(geom_paths)
+            stylised_paths.setdefault(style, []).append(geom_path)
 
         transform = ax.projection._as_mpl_transform(ax)
 


Test patch:
diff --git a/lib/cartopy/tests/mpl/test_feature_artist.py b/lib/cartopy/tests/mpl/test_feature_artist.py
index e54561bc..d6ccff31 100644
--- a/lib/cartopy/tests/mpl/test_feature_artist.py
+++ b/lib/cartopy/tests/mpl/test_feature_artist.py
@@ -5,6 +5,8 @@
 
 from unittest import mock
 
+import matplotlib.path as mpath
+import matplotlib.pyplot as plt
 from matplotlib.transforms import IdentityTransform
 import numpy as np
 import pytest
@@ -79,8 +81,8 @@ def test_feature_artist_draw(path_collection_cls, feature):
     fa.draw(mock.sentinel.renderer)
 
     transform = prj_crs._as_mpl_transform(fa.axes)
-    expected_paths = (cached_paths(geoms[0], prj_crs) +
-                      cached_paths(geoms[1], prj_crs), )
+    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]
@@ -114,9 +116,9 @@ def test_feature_artist_draw_styler(path_collection_cls, feature):
 
     transform = prj_crs._as_mpl_transform(fa.axes)
 
-    calls = [{'paths': (cached_paths(geoms[0], prj_crs), ),
+    calls = [{'paths': ([cached_paths(geoms[0], prj_crs)], ),
               'style': dict(linewidth=2, **style1)},
-             {'paths': (cached_paths(geoms[1], prj_crs), ),
+             {'paths': ([cached_paths(geoms[1], prj_crs)], ),
               'style': style2_finalized}]
 
     assert path_collection_cls.call_count == 2
@@ -125,3 +127,17 @@ def test_feature_artist_draw_styler(path_collection_cls, feature):
         assert expected_call['paths'] == actual_args
         assert transform == actual_kwargs.pop('transform')
         assert expected_call['style'] == actual_kwargs
+
+
+def test_feature_artist_geom_single_path(feature):
+    plot_crs = ccrs.PlateCarree(central_longitude=180)
+    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
+    # 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_mpl_integration.py b/lib/cartopy/tests/mpl/test_mpl_integration.py
index 37be6681..ebe09634 100644
--- a/lib/cartopy/tests/mpl/test_mpl_integration.py
+++ b/lib/cartopy/tests/mpl/test_mpl_integration.py
@@ -585,7 +585,7 @@ def test_pcolormesh_set_clim_with_mask():
 
 @pytest.mark.natural_earth
 @pytest.mark.mpl_image_compare(filename='pcolormesh_limited_area_wrap.png',
-                               tolerance=1.82)
+                               tolerance=1.83)
 def test_pcolormesh_limited_area_wrap():
     # make up some realistic data with bounds (such as data from the UM's North
     # Atlantic Europe model)