Skip to content

Commit 960e3a2

Browse files
committed
FIX: handle exterior ring - boundary attachment inversion
Some rings can get inverted when being transformed and the exterior should actually be classified as an interior hole. Catch that case for when an interior area is larger than the exterior area and invert the polygons.
1 parent 0587a3f commit 960e3a2

2 files changed

Lines changed: 31 additions & 3 deletions

File tree

lib/cartopy/crs.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -917,7 +917,10 @@ def _project_linear_ring(self, linear_ring, src_crs):
917917
line.coords[-1],
918918
atol=threshold):
919919
result_geometry = sgeom.LinearRing(line.coords[:-1])
920-
rings.append(result_geometry)
920+
# Discard near-zero area rings; they are boundary artefacts
921+
# produced when a source vertex lies just outside the domain.
922+
if sgeom.Polygon(result_geometry).area >= threshold ** 2:
923+
rings.append(result_geometry)
921924
else:
922925
line_strings.append(line)
923926
# If we found any rings, then we should re-create the multi-line str.
@@ -1190,6 +1193,26 @@ def _rings_to_multi_polygon(self, rings, is_ccw):
11901193
else:
11911194
exterior_rings.append(ring)
11921195

1196+
# Detect "inverted" exterior rings: when a projected exterior ring is
1197+
# smaller than an interior ring, the source polygon spans a region
1198+
# that wraps around the projection (e.g. a global polygon projected
1199+
# to a bounded CRS). Such a ring represents the polar/antipodal
1200+
# boundary that should be a hole, not an exterior boundary. Treat
1201+
# it as an interior ring so the full projection domain is used as the
1202+
# implicit exterior via the box-difference path below.
1203+
# Only apply when all exterior rings are smaller than an interior ring,
1204+
# which signals the whole polygon is "inverted" rather than merely
1205+
# having one small piece from an antimeridian split.
1206+
if interior_rings and exterior_rings:
1207+
interior_areas = [sgeom.Polygon(r).area for r in interior_rings]
1208+
max_interior_area = max(interior_areas)
1209+
reclassified = [r for r in exterior_rings
1210+
if sgeom.Polygon(r).area < max_interior_area]
1211+
if len(reclassified) == len(exterior_rings):
1212+
for r in reclassified:
1213+
exterior_rings.remove(r)
1214+
interior_rings.append(r)
1215+
11931216
polygon_bits = []
11941217

11951218
# Turn all the exterior rings into polygon definitions,

lib/cartopy/tests/test_polygon.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -453,10 +453,15 @@ def test_inverted_poly_multi_hole(self):
453453
central_latitude=45, central_longitude=-100)
454454
poly = sgeom.Polygon([(-180, -80), (180, -80), (180, 90), (-180, 90)],
455455
[[(-50, -50), (-50, 0), (0, 0), (0, -50)]])
456-
multi_polygon = proj.project_geometry(poly)
457-
# Should project to single polygon with multiple holes
456+
pc = ccrs.PlateCarree()
457+
multi_polygon = proj.project_geometry(poly, pc)
458458
assert len(multi_polygon.geoms) == 1
459459
assert len(multi_polygon.geoms[0].interiors) >= 2
460+
# A point below the -80 line shouldn't be covered up by the
461+
# projected polygon
462+
pt = proj.project_geometry(sgeom.Point(0, -85), pc)
463+
assert not multi_polygon.contains(pt)
464+
460465

461466
def test_inverted_poly_merged_holes(self):
462467
proj = ccrs.LambertAzimuthalEqualArea(central_latitude=-90)

0 commit comments

Comments
 (0)