Thanks to visit codestin.com
Credit goes to github.com

Skip to content

FIX: boundary attachment arc direction - #2658

Draft
greglucas wants to merge 1 commit into
SciTools:mainfrom
greglucas:fix/boundary-arc-direction
Draft

FIX: boundary attachment arc direction#2658
greglucas wants to merge 1 commit into
SciTools:mainfrom
greglucas:fix/boundary-arc-direction

Conversation

@greglucas

Copy link
Copy Markdown
Contributor

Previously _attach_lines_to_boundary always connected projected LineString endpoints by walking forward around the boundary, even when the shorter path went backward. For cut lines that split large polygons near the middle of a boundary edge (e.g. ObliqueMercator over Alaska/Russia), this caused the ring to trace ~98% of the perimeter instead of ~2%, producing a polygon that is the complement of the intended shape — an inside-out land or ocean feature.

Additionally, it had the possibility of adding a midpoint on the far edge boundary while doing that tracing, even though the two endpoints were not actually on the boundary. So the linestring should really be closed with itself and not attached to the boundary. So I've added in an extra check to make sure that we are only adding boundary points/attaching when we are within the threshold of the boundary, not in the middle of the domain.

This is a pretty major overhaul of the _attach_line_to_boundary function to try and make the intent a little more clear and improve the performance some as well. It is largely the same logic, just refactored with one fix to choose the shortest walk direction. This may fix more cases because it is a pretty low-level thing that is done a lot, but this specifically addresses #2650.

Fixes #2650

@rcomer

rcomer commented Apr 5, 2026

Copy link
Copy Markdown
Member

Cycling to pick up #2659

@rcomer rcomer closed this Apr 5, 2026
@rcomer rcomer reopened this Apr 5, 2026

@rcomer rcomer left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This certainly makes the code easier to read! I also learned about a new module as I wasn't familiar with bisect.

I think I'm confused about the need to take the shortest route around the edge.

For the basic case with one linestring: if you go forward, you always get an exterior polygon, which may fill up most of the map. If you go backwards, you get the inverse polygon, but it's identified as an interior. So it will get inverted later on, and you end up with the same polygon as if you went forward.

So was the problem case one that should have been an interior contained within a polygon formed from a different linestring? If so, shouldn't the "find the next edge thing" logic have found the start of that outer linestring before going all the way round?

Comment thread lib/cartopy/crs.py
"""
boundary = self.ccw_boundary if is_ccw else self.cw_boundary
perimeter = boundary.length
threshold = self.threshold

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have it documented anywhere what self.threshold is? I have not previously known about it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👀 apparently not! I thought we did, but I don't see a docstring on it immediately. It is a useful tool though when trying to make lines look smoother. I did add an example using it in my other adaptive-resampling PR because I think that illustrates the point quite well when it is symmetric and shows you what different values will do.

https://github.com/SciTools/cartopy/pull/2647/changes#diff-44b7ad8f2f0073da2576021a84484f3e4dcf524edf5ddaad9d12d0225451577b

Comment thread lib/cartopy/crs.py Outdated
Comment on lines +1110 to +1122
if not corners:
# No boundary corners on the closing arc. Insert a
# midpoint on the shorter arc so the ring has a point
# on the boundary (needed for correct winding). Skip
# when the arc has zero length (start == end).
d_fwd = (d_ring_start - d_end) % perimeter
if d_fwd > 0:
if d_fwd <= perimeter / 2:
d_mid = (d_end + d_fwd / 2) % perimeter
else:
d_mid = (d_end - (perimeter - d_fwd) / 2) % perimeter
mid_pt = boundary.interpolate(d_mid)
ring_coords.append((mid_pt.x, mid_pt.y))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this be done within arc_corners? Since it already has the check on which route is shortest there wouldn't be too much to add in there. Also I got myself a bit confused trying to map "start" <==> "to", "end" <==> "from" in my head, so having all that similar logic together might make it easier to follow.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, let me think about this some more and how to consolidate it.

Comment thread lib/cartopy/crs.py Outdated
Comment on lines +1147 to +1152
if d_fwd_close <= perimeter / 2:
mid_d = (d_end + d_fwd_close / 2) % perimeter
else:
mid_d = (d_end - (perimeter - d_fwd_close) / 2) % perimeter
mid_pt = boundary.interpolate(mid_d)
ring_coords.append((mid_pt.x, mid_pt.y))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be skipped if the start and end are in the same place, similar to the case where there are no remaining lines?

Comment thread lib/cartopy/crs.py
@rcomer

rcomer commented Apr 5, 2026

Copy link
Copy Markdown
Member

This also gives a big improvement on the left panel from #2584 (comment), though it's still not quite there

image

@greglucas

Copy link
Copy Markdown
Contributor Author

Thanks for adding that other example here too. I can try and investigate that specific case some more and see if there is another condition we can add to capture that as well. It again looks like something where we are closing/adding points in wrong locations.

Your comments actually got me to thinking, I wonder if we could pass some of this information into project_linear() so that we could tell project_linear itself that this was a ring coming in, so all outputs need to be closed by either being attached to a boundary or upon themselves. That would likely mean moving (at least some of) the attach_to_boundary logic into trace.pyx, but I'm wondering if it could simplify some of the dealing with linestrings/rings on the Python caller side because then we could say "project all polygon exteriors, then project all holes and do a difference on the results". We wouldn't have to keep track of rings/linestrings together and then determine if a string should have been a ring etc. That would be encoded in the calling logic.

I'll have a think about some of this and experiment around to see if there is a potential for any simplification, or if it is really just pushing the same complicated logic around to a different place.

@rcomer

rcomer commented Apr 15, 2026

Copy link
Copy Markdown
Member

I tried rebasing this against main, and found that it additionally fixes #2137, so I guess that needs a combination of this and #2665

image

@rcomer

rcomer commented Apr 15, 2026

Copy link
Copy Markdown
Member

I also found that it solves the error from the second case at #2176 (comment), but the plot is probably not what the user wants
image

@greglucas
greglucas force-pushed the fix/boundary-arc-direction branch from cbe072d to 8e66f93 Compare April 30, 2026 03:11
Background
----------
The old _project_linear_ring / _attach_lines_to_boundary pair treated
all projected line fragments identically regardless of whether the source
geometry was a ring. This caused two classes of correctness failures:

1. Boundary arc direction ambiguity: when a ring projected to two or
   more fragments, the boundary arc connecting consecutive fragments
   could be chosen in either direction around the projection boundary.

2. Inverted ring misclassification: a ring whose projection completely
   surrounds the destination domain (so the "interior" of the ring in the
   source CRS maps to the area outside the visible map) was silently
   discarded rather than inverted.
@greglucas
greglucas force-pushed the fix/boundary-arc-direction branch from 8e66f93 to ced0ea8 Compare May 26, 2026 03:21
@QuLogic QuLogic closed this Jul 14, 2026
@QuLogic QuLogic reopened this Jul 14, 2026
Comment thread lib/cartopy/crs.py
Comment on lines +778 to +779
def _prepared_domain(self):
"""Prepared (indexed) version of :attr:`domain`, cached per projection instance.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See #2698; you should just use shapely.prepare and not bother with caching it yourself.

Comment thread lib/cartopy/crs.py
try:
return self.__ring_threshold_cache
except AttributeError:
val = max(abs(v) for v in self.x_limits + self.y_limits) * 1e-5

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we not want to be looking at the difference in limits? If the limits were (1e7, 1e7+100), then this threshold would be bigger than the whole map.

Comment thread lib/cartopy/trace.pyx
`shapely.geometry.MultiLineString`
The result of projecting the given geometry from the source projection
into the destination projection.
`shapely.geometry.MultiLineString` or list of `shapely.geometry.LineString`

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
`shapely.geometry.MultiLineString` or list of `shapely.geometry.LineString`
`shapely.MultiLineString` or list of `shapely.LineString`

Comment thread lib/cartopy/trace.pyx
Comment on lines +583 to +587
Set to ``True`` when *geometry* is a closed ring. Controls the
return type: ``True`` returns an ordered list of
`~shapely.geometry.LineString` fragments (ring-traversal order);
``False`` returns a `~shapely.geometry.MultiLineString`.
Defaults to ``False``.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Set to ``True`` when *geometry* is a closed ring. Controls the
return type: ``True`` returns an ordered list of
`~shapely.geometry.LineString` fragments (ring-traversal order);
``False`` returns a `~shapely.geometry.MultiLineString`.
Defaults to ``False``.
Set to ``True`` when *geometry* is a closed ring. Controls the return type:
- ``True`` returns an ordered list of `~shapely.LineString` fragments
(ring-traversal order);
- ``False`` returns a `~shapely.MultiLineString`.
Defaults to ``False``.

Comment thread lib/cartopy/trace.pyx
gp_domain = sprep.prep(g_domain)
# Use the cached prepared domain from the projection – avoids rebuilding the
# prepared geometry (sprep.prep()) on every ring projection call.
gp_domain = dest_projection._prepared_domain

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See above, re. prepared geometries.

Comment thread lib/cartopy/crs.py
Comment on lines +1122 to +1123
sgeom.LinearRing(
list(line_strings[k].coords) + arc_corners(d_e, d_s)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
sgeom.LinearRing(
list(line_strings[k].coords) + arc_corners(d_e, d_s)
shapely.LinearRing(
[*line_strings[k].coords, *arc_corners(d_e, d_s)]

Comment thread lib/cartopy/crs.py

def finish_ring(d_end_local, ring_coords_local, d_fwd_local,
d_ring_start_local):
"""Close ring_coords with the shorter arc back to d_ring_start_local."""

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"""Close ring_coords with the shorter arc back to d_ring_start_local."""
"""
Close ring_coords_local with the shorter arc back to d_ring_start_local.
"""

Comment thread lib/cartopy/crs.py
Comment on lines +1179 to +1203
else:
# Segment j comes before our ring start. If j is from a
# different source ring and the gap to it is large, only
# connect if the ring can't otherwise close without a very
# long boundary arc (> 1/5 perimeter). This prevents an
# artifact segment from a source ring outside the domain
# from stealing an adjacent exterior segment.
if j_mls != current_mls:
next_start_xy = line_strings[j].coords[0]
gap = math.hypot(
ring_coords[-1][0] - next_start_xy[0],
ring_coords[-1][1] - next_start_xy[1],
)
if gap > threshold and d_fwd_close < perimeter / 5:
break # close cannot be long; discard, leave j
# Connect to segment j via forward boundary arc, then
# continue building the ring. Using fwd_arc_corners
# (not arc_corners) guarantees that corners are inserted
# in the forward direction, preserving the ring's winding.
ring_coords += fwd_arc_corners(d_end, d_fwd_next)
ring_coords += list(line_strings[j].coords)
remaining_segs.pop(pos)
remaining_segs_d.pop(pos)
d_end = d_next_end
current_mls = j_mls # track mls of most recently added seg

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the previous clause broke out of the loop, can dedent this half to reduce the wrapping.

Suggested change
else:
# Segment j comes before our ring start. If j is from a
# different source ring and the gap to it is large, only
# connect if the ring can't otherwise close without a very
# long boundary arc (> 1/5 perimeter). This prevents an
# artifact segment from a source ring outside the domain
# from stealing an adjacent exterior segment.
if j_mls != current_mls:
next_start_xy = line_strings[j].coords[0]
gap = math.hypot(
ring_coords[-1][0] - next_start_xy[0],
ring_coords[-1][1] - next_start_xy[1],
)
if gap > threshold and d_fwd_close < perimeter / 5:
break # close cannot be long; discard, leave j
# Connect to segment j via forward boundary arc, then
# continue building the ring. Using fwd_arc_corners
# (not arc_corners) guarantees that corners are inserted
# in the forward direction, preserving the ring's winding.
ring_coords += fwd_arc_corners(d_end, d_fwd_next)
ring_coords += list(line_strings[j].coords)
remaining_segs.pop(pos)
remaining_segs_d.pop(pos)
d_end = d_next_end
current_mls = j_mls # track mls of most recently added seg
# Segment j comes before our ring start. If j is from a different source
# ring and the gap to it is large, only connect if the ring can't
# otherwise close without a very long boundary arc (> 1/5 perimeter).
# This prevents an artifact segment from a source ring outside the
# domain from stealing an adjacent exterior segment.
if j_mls != current_mls:
next_start_xy = line_strings[j].coords[0]
gap = math.hypot(
ring_coords[-1][0] - next_start_xy[0],
ring_coords[-1][1] - next_start_xy[1],
)
if gap > threshold and d_fwd_close < perimeter / 5:
break # close cannot be long; discard, leave j
# Connect to segment j via forward boundary arc, then continue building
# the ring. Using fwd_arc_corners (not arc_corners) guarantees that
# corners are inserted in the forward direction, preserving the ring's
# winding.
ring_coords += [
*fwd_arc_corners(d_end, d_fwd_next),
*line_strings[j].coords,
]
remaining_segs.pop(pos)
remaining_segs_d.pop(pos)
d_end = d_next_end
current_mls = j_mls # track mls of most recently added seg

Comment thread lib/cartopy/crs.py
Comment on lines +1234 to +1236
for r in reclassified:
exterior_rings.remove(r)
interior_rings.append(r)

@QuLogic QuLogic Jul 15, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did I misread or isn't reclassified == exterior_rings? So that this is just:

Suggested change
for r in reclassified:
exterior_rings.remove(r)
interior_rings.append(r)
interior_rings += exterior_rings
exterior_rings = []

Comment thread lib/cartopy/crs.py
Comment on lines +1182 to +1183
# connect if the ring can't otherwise close without a very
# long boundary arc (> 1/5 perimeter). This prevents an

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I understand the 1/5 choice here.

@QuLogic

QuLogic commented Jul 15, 2026

Copy link
Copy Markdown
Member

Since #2698 is in now, you should also change sgeom to shapely.

@greglucas

Copy link
Copy Markdown
Contributor Author

Thanks for the review! I will be reworking this a little bit to try and take more of the d3-style approach in a similar vein to the adaptive-resampling work. I'm going to try and keep things similar here and only fix the boundary arc, but fix it with a more consistent way rather than the 1/5 heuristics you called out and add a few more individual tests for those periodic boundaries. It might take me a little bit more time but just wanted to leave an update.

@QuLogic
QuLogic marked this pull request as draft August 27, 2026 06:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

3 participants