Skip to content

FIX: boundary attachment arc direction#2658

Open
greglucas wants to merge 1 commit intoSciTools:mainfrom
greglucas:fix/boundary-arc-direction
Open

FIX: boundary attachment arc direction#2658
greglucas wants to merge 1 commit intoSciTools: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

Make the boundary attachment method more robust by iterating through
and checking the forward/backward directions for the shortest path.
Making sure to check closing upon the ring itself and iterate through
other line attachment options.
@rcomer
Copy link
Copy Markdown
Member

rcomer commented Apr 5, 2026

Cycling to pick up #2659

@rcomer rcomer closed this Apr 5, 2026
@rcomer rcomer reopened this Apr 5, 2026
Copy link
Copy Markdown
Member

@rcomer rcomer left a comment

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?

"""
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 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 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?

@rcomer
Copy link
Copy Markdown
Member

rcomer commented Apr 5, 2026

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

_rings_to_multi_polygon produces inside-out polygons for features crossing the ObliqueMercator cut line

2 participants